/* SPDX-License-Identifier: GPL-3.0-only */ /* TLS-only HTTP server with bounded cookie authentication and status output. */ #include "web_server.h" #include #include #include #include #include "esp_http_server.h" #include "esp_https_server.h" #include "esp_log.h" #include "esp_netif_ip_addr.h" #include "esp_timer.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "secure_random.h" #include "serial_config.h" #include "serial_service.h" #include "session_broker.h" #include "usb_cdc_transport.h" #include "user_database.h" #include "web_security.h" #include "web_serial_transport.h" #include "web_serial_settings.h" #include "web_account_settings.h" #include "web_network_settings.h" #include "web_display_settings.h" #include "web_broker_settings.h" #include "web_ssh_settings.h" #include "web_lifecycle_settings.h" #include "web_admin_transport.h" #include "web_session_store.h" #include "web_cookie_auth.h" #include "web_httpd_adapter.h" #include "web_httpd_idle.h" #include "web_diagnostics.h" #include "web_ui.h" #include "wifi_manager.h" #define WEB_SERVER_PORT 443U #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; /* Firmware-lifetime lifecycle fence, independent of counters and handle reuse. */ static uint32_t s_generation = 1U; 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; static esp_err_t ensure_mutex(void) { if (s_server_mutex != NULL) { return ESP_OK; } s_server_mutex = xSemaphoreCreateMutex(); return s_server_mutex != NULL ? ESP_OK : ESP_ERR_NO_MEM; } static void increment_counter(uint64_t *counter) { xSemaphoreTake(s_server_mutex, portMAX_DELAY); ++*counter; xSemaphoreGive(s_server_mutex); } static esp_err_t set_common_headers(httpd_req_t *request) { esp_err_t error = httpd_resp_set_hdr(request, "Cache-Control", "no-store"); if (error == ESP_OK) { error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); } if (error == ESP_OK) { error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); } return error; } static esp_err_t send_plain_error(httpd_req_t *request, const char *status, const char *message) { esp_err_t error = httpd_resp_set_status(request, status); if (error == ESP_OK) { error = httpd_resp_set_type(request, "text/plain; charset=utf-8"); } if (error == ESP_OK) { error = set_common_headers(request); } if (error == ESP_OK) { error = httpd_resp_sendstr(request, message); } if (error != ESP_OK) { increment_counter(&s_counters.response_errors); } return error; } static esp_err_t authorize_or_respond(httpd_req_t *request, user_principal_t *principal, bool *authorized) { web_session_view_t view = {0}; increment_counter(&s_counters.requests); esp_err_t error = web_cookie_auth_require(request, false, false, &view, authorized); web_httpd_wipe_request(request, web_httpd_unread_body(request)); *principal = view.principal; secure_wipe(&view, sizeof(view)); increment_counter(*authorized ? &s_counters.authenticated_requests : &s_counters.authentication_failures); return error; } static esp_err_t send_authenticated_ui(httpd_req_t *request, web_ui_resource_t resource, uint64_t *counter) { user_principal_t principal = {0}; bool authorized = false; esp_err_t error = authorize_or_respond(request, &principal, &authorized); if (error != ESP_OK || !authorized) { secure_wipe(&principal, sizeof(principal)); return error; } increment_counter(counter); error = web_ui_send_response(request, resource); secure_wipe(&principal, sizeof(principal)); 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) { web_session_view_t view = {0}; bool authorized = false; increment_counter(&s_counters.requests); esp_err_t error = web_cookie_auth_require(request, true, false, &view, &authorized); increment_counter(authorized ? &s_counters.authenticated_requests : &s_counters.authentication_failures); if (error != ESP_OK || !authorized) { secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; } increment_counter(&s_counters.ticket_requests); error = web_serial_transport_handle_authenticated_ticket_request( request, &view.principal, view.id); secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(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"); } if (error == ESP_ERR_NO_MEM && httpd_resp_set_hdr(request, "Retry-After", "5") == ESP_OK) { return send_plain_error(request, "503 Service Unavailable", "{\"error\":\"capacity\"}"); } increment_counter(&s_counters.response_errors); if (error == ESP_ERR_INVALID_STATE) return send_plain_error(request, "503 Service Unavailable", "Web terminal transport unavailable.\n"); return error; /* A failed/partial send must close, not send a second response. */ } static const char *safe_string(const char *value) { return value != NULL ? value : "unknown"; } static void format_ipv4(uint32_t address, char output[16]) { if (address == 0U) { memcpy(output, "0.0.0.0", sizeof("0.0.0.0")); return; } esp_ip4_addr_t ip = {.addr = address}; int written = snprintf(output, 16U, IPSTR, IP2STR(&ip)); if (written < 0 || written >= 16) { memcpy(output, "0.0.0.0", sizeof("0.0.0.0")); } } static void format_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH], char output[(WEB_SECURITY_SHA256_LENGTH * 3U)]) { static const char hex[] = "0123456789ABCDEF"; size_t offset = 0U; for (size_t index = 0U; index < WEB_SECURITY_SHA256_LENGTH; ++index) { if (index != 0U) { output[offset++] = ':'; } output[offset++] = hex[fingerprint[index] >> 4U]; output[offset++] = hex[fingerprint[index] & 0x0fU]; } output[offset] = '\0'; } static esp_err_t status_handler(httpd_req_t *request) { user_principal_t principal = {0}; bool authorized = false; esp_err_t error = authorize_or_respond(request, &principal, &authorized); if (error != ESP_OK || !authorized) { secure_wipe(&principal, sizeof(principal)); return error; } secure_wipe(&principal, sizeof(principal)); increment_counter(&s_counters.status_requests); wifi_manager_snapshot_t wifi = {0}; serial_service_snapshot_t serial_snapshot = {0}; serial_service_counters_t serial_counters = {0}; 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}; char response[WEB_SERVER_STATUS_JSON_CAPACITY]; bool wifi_available = wifi_manager_get_snapshot(&wifi) == ESP_OK; bool serial_config_available = serial_service_get_snapshot(&serial_snapshot) == ESP_OK; serial_config_t serial_config = serial_snapshot.config; serial_service_get_counters(&serial_counters); 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; format_ipv4(wifi_available ? wifi.ip : 0U, ipv4); if (certificate_available) { format_fingerprint(certificate.sha256_fingerprint, fingerprint); } else { memcpy(fingerprint, "unavailable", sizeof("unavailable")); } int written = snprintf( response, sizeof(response), "{\n" " \"uptime_ms\":%" PRIu64 ",\n" " \"wifi\":{\"available\":%s,\"state\":\"%s\",\"sta_ipv4\":\"%s\"," "\"rssi\":%d,\"channel\":%u,\"ap_running\":%s,\"ap_clients\":%u},\n" " \"serial\":{\"running\":%s,\"config_available\":%s,\"baud\":%" PRIu32 "," "\"data_bits\":\"%s\",\"parity\":\"%s\",\"stop_bits\":\"%s\"," "\"flow\":\"%s\",\"rx_bytes\":%" PRIu64 ",\"rx_dropped\":%" PRIu64 "," "\"tx_sent\":%" PRIu64 ",\"tx_dropped\":%" PRIu64 "},\n" " \"broker\":{\"available\":%s,\"clients\":%" PRIu32 ",\"writer\":%" PRIu32 "," "\"uart_rx_bytes\":%" PRIu64 ",\"observer_dropped\":%" PRIu64 "},\n" " \"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" " \"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", wifi_available ? wifi_manager_state_to_string(wifi.state) : "unavailable", ipv4, wifi_available ? (int)wifi.sta_rssi : 0, wifi_available ? (unsigned int)wifi.sta_channel : 0U, wifi_available && wifi.ap_running ? "true" : "false", wifi_available ? (unsigned int)wifi.ap_client_count : 0U, serial_config_available ? (serial_snapshot.running ? "true" : "false") : "null", serial_config_available ? "true" : "false", serial_config_available ? serial_config.baud_rate : 0U, serial_config_available ? safe_string(serial_config_data_bits_to_string(serial_config.data_bits)) : "unknown", serial_config_available ? safe_string(serial_config_parity_to_string(serial_config.parity)) : "unknown", serial_config_available ? safe_string(serial_config_stop_bits_to_string(serial_config.stop_bits)) : "unknown", serial_config_available ? safe_string(serial_config_flow_control_to_string(serial_config.flow_control)) : "unknown", serial_counters.rx_bytes, serial_counters.rx_dropped_bytes, serial_counters.tx_sent_to_uart_bytes, serial_counters.tx_dropped_bytes, broker_available ? "true" : "false", broker_available ? broker.connected_clients : 0U, broker_available ? broker.writer_id : SESSION_BROKER_NO_CLIENT, broker_available ? broker.counters.uart_rx_bytes : 0U, broker_available ? broker.counters.output_dropped_bytes : 0U, usb_available ? "true" : "false", usb_available && usb.attached ? "true" : "false", usb_available && usb.attached && usb.dtr ? "true" : "false", usb_available && usb.writer ? "true" : "false", web_available && web.running ? "true" : "false", web_available ? web.counters.requests : 0U, web_available ? web.counters.authenticated_requests : 0U, web_available ? web.counters.authentication_failures : 0U, 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", "Status response overflow.\n"); } error = httpd_resp_set_type(request, "application/json; charset=utf-8"); if (error == ESP_OK) { error = set_common_headers(request); } if (error == ESP_OK) { error = httpd_resp_send(request, response, (ssize_t)written); } if (error != ESP_OK) { increment_counter(&s_counters.response_errors); } return error; } static esp_err_t serial_settings_handler(httpd_req_t *request) { user_principal_t principal = {0}; bool authorized = false; esp_err_t error = authorize_or_respond(request, &principal, &authorized); bool admin = authorized && principal.role == USER_ROLE_ADMIN; secure_wipe(&principal, sizeof(principal)); if (error != ESP_OK || !authorized) return error; if (!admin) return send_plain_error(request, "403 Forbidden", "Administrator access required.\n"); serial_service_snapshot_t snapshot = {0}; if (serial_service_get_snapshot(&snapshot) != ESP_OK) { error = httpd_resp_set_hdr(request, "Retry-After", "1"); if (error != ESP_OK) return error; return send_plain_error(request, "503 Service Unavailable", "Serial snapshot unavailable.\n"); } const serial_config_t *config = &snapshot.config; char response[256]; /* Only firmware-owned enum names and numeric values, never CLI output. */ int written = snprintf(response, sizeof(response), "{\"running\":%s,\"baud\":%" PRIu32 ",\"data_bits\":\"%s\"," "\"parity\":\"%s\",\"stop_bits\":\"%s\",\"flow\":\"%s\"," "\"dtr\":\"%s\",\"rts_threshold\":%" PRIu32 "}", snapshot.running ? "true" : "false", config->baud_rate, safe_string(serial_config_data_bits_to_string(config->data_bits)), safe_string(serial_config_parity_to_string(config->parity)), safe_string(serial_config_stop_bits_to_string(config->stop_bits)), safe_string(serial_config_flow_control_to_string(config->flow_control)), safe_string(serial_config_dtr_behavior_to_string(config->dtr_behavior)), config->rts_threshold); if (written < 0 || (size_t)written >= sizeof(response)) return send_plain_error(request, "500 Internal Server Error", "Serial response overflow.\n"); error = httpd_resp_set_type(request, "application/json; charset=utf-8"); if (error == ESP_OK) error = set_common_headers(request); if (error == ESP_OK) error = httpd_resp_send(request, response, (ssize_t)written); if (error != ESP_OK) increment_counter(&s_counters.response_errors); return error; } static const httpd_uri_t s_serial_settings_uri = { .uri = "/api/settings/serial", .method = HTTP_GET, .handler = serial_settings_handler, }; static const httpd_uri_t s_serial_operation_get_uri = { .uri = "/api/settings/serial-operation", .method = HTTP_GET, .handler = web_serial_settings_handler, }; static const httpd_uri_t s_serial_operation_post_uri = { .uri = "/api/settings/serial-operation", .method = HTTP_POST, .handler = web_serial_settings_handler, }; static const httpd_uri_t s_accounts_uri = { .uri = "/api/settings/accounts", .method = HTTP_GET, .handler = web_account_settings_handler, }; static const httpd_uri_t s_account_operation_get_uri = { .uri = "/api/settings/account-operation", .method = HTTP_GET, .handler = web_account_settings_handler, }; static const httpd_uri_t s_account_operation_post_uri = { .uri = "/api/settings/account-operation", .method = HTTP_POST, .handler = web_account_settings_handler, }; static const httpd_uri_t s_account_keys_uri = { .uri = "/api/settings/accounts/keys", .method = HTTP_POST, .handler = web_account_keys_handler, }; static const httpd_uri_t s_account_generate_password_uri = { .uri = "/api/settings/accounts/generate-password", .method = HTTP_POST, .handler = web_account_generate_password_handler, }; static const httpd_uri_t s_network_uri = { .uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler, }; static const httpd_uri_t s_lifecycle_settings_uri = { .uri = "/api/settings/lifecycle", .method = HTTP_GET, .handler = web_lifecycle_settings_handler, }; static const httpd_uri_t s_lifecycle_operation_get_uri = { .uri = "/api/settings/lifecycle-operation", .method = HTTP_GET, .handler = web_lifecycle_operation_handler, }; static const httpd_uri_t s_lifecycle_operation_post_uri = { .uri = "/api/settings/lifecycle-operation", .method = HTTP_POST, .handler = web_lifecycle_operation_handler, }; static const httpd_uri_t s_ssh_settings_uri = { .uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler, }; static const httpd_uri_t s_ssh_operation_get_uri = { .uri = "/api/settings/ssh-operation", .method = HTTP_GET, .handler = web_ssh_operation_handler, }; static const httpd_uri_t s_ssh_operation_post_uri = { .uri = "/api/settings/ssh-operation", .method = HTTP_POST, .handler = web_ssh_operation_handler, }; static const httpd_uri_t s_broker_uri = { .uri = "/api/settings/broker", .method = HTTP_GET, .handler = web_broker_settings_handler, }; static const httpd_uri_t s_broker_operation_get_uri = { .uri = "/api/settings/broker-operation", .method = HTTP_GET, .handler = web_broker_operation_handler, }; static const httpd_uri_t s_broker_operation_post_uri = { .uri = "/api/settings/broker-operation", .method = HTTP_POST, .handler = web_broker_operation_handler, }; static const httpd_uri_t s_display_uri = { .uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler, }; static const httpd_uri_t s_display_operation_get_uri = { .uri = "/api/settings/display-operation", .method = HTTP_GET, .handler = web_display_operation_handler, }; static const httpd_uri_t s_display_operation_post_uri = { .uri = "/api/settings/display-operation", .method = HTTP_POST, .handler = web_display_operation_handler, }; static const httpd_uri_t s_network_operation_get_uri = { .uri = "/api/settings/network-operation", .method = HTTP_GET, .handler = web_network_operation_handler, }; static const httpd_uri_t s_network_operation_post_uri = { .uri = "/api/settings/network-operation", .method = HTTP_POST, .handler = web_network_operation_handler, }; static const httpd_uri_t s_root_uri = { .uri = "/", .method = HTTP_GET, .handler = root_handler, .user_ctx = NULL, }; static const httpd_uri_t s_status_uri = { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler, .user_ctx = NULL, }; static esp_err_t traced_ticket_handler(httpd_req_t *request) { return web_diagnostics_handler(request, WEB_DIAG_SERIAL_TICKET, ticket_handler); } static const httpd_uri_t s_ticket_uri = { .uri = WEB_SERIAL_TRANSPORT_TICKET_URI, .method = HTTP_POST, .handler = traced_ticket_handler, .user_ctx = NULL, }; static esp_err_t websocket_handler(httpd_req_t *request) { web_session_view_t view = {0}; bool allowed = false; esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed); if (allowed) error = web_serial_transport_session_ws_handler(request, view.id); secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; } static esp_err_t traced_websocket_handler(httpd_req_t *request) { return web_diagnostics_handler(request, WEB_DIAG_SERIAL_UPGRADE, websocket_handler); } static esp_err_t traced_admin_ticket_handler(httpd_req_t *request) { return web_diagnostics_handler(request, WEB_DIAG_ADMIN_TICKET, web_admin_transport_ticket_handler); } static esp_err_t traced_admin_upgrade_handler(httpd_req_t *request) { return web_diagnostics_handler(request, WEB_DIAG_ADMIN_UPGRADE, web_admin_transport_upgrade_handler); } static const httpd_uri_t s_websocket_uri = { .uri = WEB_SERIAL_TRANSPORT_WS_URI, .method = HTTP_GET, .handler = traced_websocket_handler, .user_ctx = NULL, /* Authorize and admit before the adapter sends 101, not IDF's pre-handler path. */ .is_websocket = false, .handle_ws_control_frames = false, }; static const httpd_uri_t s_admin_ticket_uri = { .uri = WEB_ADMIN_TICKET_URI, .method = HTTP_POST, .handler = traced_admin_ticket_handler, }; static const httpd_uri_t s_admin_websocket_uri = { .uri = WEB_ADMIN_WS_URI, .method = HTTP_GET, .handler = traced_admin_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, .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 s_logo_uri = { .uri = "/assets/logo.png", .method = HTTP_GET, .handler = asset_handler, .user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_LOGO_PNG, }; 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, &s_logo_uri, }; static const httpd_uri_t s_auth_uris[] = { {.uri = "/login", .method = HTTP_GET, .handler = web_cookie_auth_handler}, {.uri = "/api/login-challenge", .method = HTTP_GET, .handler = web_cookie_auth_handler}, {.uri = "/api/login", .method = HTTP_POST, .handler = web_cookie_auth_handler}, {.uri = "/api/session", .method = HTTP_GET, .handler = web_cookie_auth_handler}, {.uri = "/api/logout", .method = HTTP_POST, .handler = web_cookie_auth_handler}, }; static esp_err_t route_error_handler(httpd_req_t *request, httpd_err_code_t code) { (void)send_plain_error(request, code == HTTPD_405_METHOD_NOT_ALLOWED ? "405 Method Not Allowed" : "404 Not Found", "Unsupported route or method.\n"); return ESP_FAIL; /* Do not drain a rejected request body on keepalive. */ } static void tls_session_callback(esp_https_server_user_cb_arg_t *arg) { web_httpd_idle_tls(arg); web_diagnostics_tls(arg); } 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; } if (!s_initialized && s_last_error == ESP_ERR_INVALID_STATE) { s_last_error = ESP_OK; } s_initialized = true; xSemaphoreGive(s_server_mutex); /* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */ return ESP_OK; } static esp_err_t start_server(bool reserved) { esp_err_t error; bool serial_transport_ready; xSemaphoreTake(s_server_mutex, portMAX_DELAY); if (s_server != NULL || s_transitioning != reserved) { xSemaphoreGive(s_server_mutex); return ESP_ERR_INVALID_STATE; } s_transitioning = true; if (s_generation != UINT32_MAX) ++s_generation; serial_transport_ready = s_serial_transport_initialized; xSemaphoreGive(s_server_mutex); /* Initialize only after lifecycle admission; failure gates all HTTPS auth. */ error = web_cookie_auth_start(); if (error == ESP_OK) error = web_httpd_idle_prepare(); uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0}; uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0}; size_t certificate_length = 0U; size_t private_key_length = 0U; httpd_handle_t server = NULL; if (error == ESP_OK) error = web_security_copy_tls_material( certificate, sizeof(certificate), &certificate_length, private_key, sizeof(private_key), &private_key_length); if (error == ESP_OK) { httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); /* 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]) + 25U; /* 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; config.servercert_len = certificate_length; config.prvtkey_pem = private_key; config.prvtkey_len = private_key_length; config.port_secure = WEB_SERVER_PORT; config.tls_handshake_timeout_ms = 5000U; /* Public synchronous identity reset/observation; HTTPS retains cleanup. */ config.user_cb = tls_session_callback; error = httpd_ssl_start(&server, &config); } secure_wipe(certificate, sizeof(certificate)); secure_wipe(private_key, sizeof(private_key)); 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]); } bool serial_transport_attached = false; for (size_t i = 0; error == ESP_OK && i < sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); ++i) error = httpd_register_uri_handler(server, &s_auth_uris[i]); if (error == ESP_OK) error = httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, route_error_handler); if (error == ESP_OK) error = httpd_register_err_handler(server, HTTPD_405_METHOD_NOT_ALLOWED, route_error_handler); if (error == ESP_OK) error = web_httpd_idle_attach(server); 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; } 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; /* Optional settings allocation failure must not disable either terminal. */ (void)web_httpd_register_optional_get(server, &s_serial_settings_uri); if (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET); if (web_httpd_register_optional_get(server, &s_accounts_uri) == ESP_OK && web_httpd_register_optional_get(server, &s_account_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET); (void)web_httpd_register_optional(server, &s_account_generate_password_uri); (void)web_httpd_register_optional(server, &s_account_keys_uri); if (web_httpd_register_optional_get(server, &s_network_uri) == ESP_OK && web_httpd_register_optional_get(server, &s_network_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_network_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_network_operation_get_uri.uri, HTTP_GET); if (web_httpd_register_optional_get(server, &s_display_uri) == ESP_OK && web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_display_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET); if (web_httpd_register_optional_get(server, &s_broker_uri) == ESP_OK && web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET); if (web_httpd_register_optional_get(server, &s_ssh_settings_uri) == ESP_OK && web_httpd_register_optional_get(server, &s_ssh_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_ssh_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_ssh_operation_get_uri.uri, HTTP_GET); if (web_httpd_register_optional_get(server, &s_lifecycle_settings_uri) == ESP_OK && web_httpd_register_optional_get(server, &s_lifecycle_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_lifecycle_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_lifecycle_operation_get_uri.uri, HTTP_GET); } if (error != ESP_OK) { web_cookie_auth_stop(); } if (error != ESP_OK && server != NULL) { esp_err_t cleanup_error = web_httpd_idle_detach(server); if (cleanup_error == ESP_OK) cleanup_error = httpd_ssl_stop(server); if (cleanup_error == ESP_OK) { web_httpd_idle_stopped(server); web_lifecycle_settings_stopped(server); server = NULL; } else { /* Retain ownership so stop can retry and start cannot allocate a second server. */ error = cleanup_error; } } 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; s_admin_transport_owned = admin_transport_owned; if (error == ESP_OK) { s_server = server; ++s_counters.starts; } else { /* A non-NULL handle is a partially started server whose cleanup failed. */ s_server = server; ++s_counters.start_failures; } xSemaphoreGive(s_server_mutex); return error; } esp_err_t web_server_start(void) { esp_err_t error = web_server_init(); return error == ESP_OK ? start_server(false) : error; } static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool reserved) { if (s_server_mutex == NULL) { return ESP_ERR_INVALID_STATE; } if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE) return ESP_ERR_TIMEOUT; if (s_server == NULL || s_transitioning != reserved || (expected_generation && (expected_generation != s_generation || s_generation == UINT32_MAX || s_last_error != ESP_OK))) { xSemaphoreGive(s_server_mutex); return ESP_ERR_INVALID_STATE; } 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; if (s_generation != UINT32_MAX) ++s_generation; xSemaphoreGive(s_server_mutex); web_cookie_auth_stop(); esp_err_t idle_error = web_httpd_idle_detach(server); if (idle_error != ESP_OK) { /* Never destroy HTTPD while a timer submission still holds its handle. */ xSemaphoreTake(s_server_mutex, portMAX_DELAY); s_transitioning = false; s_last_error = idle_error; xSemaphoreGive(s_server_mutex); return idle_error; } 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) { 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) { web_httpd_idle_stopped(server); web_lifecycle_settings_stopped(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; } xSemaphoreTake(s_server_mutex, portMAX_DELAY); /* Do not expose a stopped/unreserved gap to another lifecycle caller. */ s_transitioning = error == ESP_OK && restart; s_last_error = error; s_serial_transport_error = serial_transport_error; s_serial_transport_attached = false; if (error == ESP_OK) { s_server = NULL; s_admin_transport_owned = false; ++s_counters.stops; } xSemaphoreGive(s_server_mutex); return error == ESP_OK && restart ? start_server(true) : error; } esp_err_t web_server_replace_identity(uint32_t expected_service_generation, uint32_t expected_identity_generation, bool reset, bool *committed) { if (!committed || (!!expected_service_generation != !!expected_identity_generation) || (reset && expected_service_generation)) return ESP_ERR_INVALID_ARG; *committed = false; /* Conditional dispatcher admission must not wait in the legacy initializer. */ esp_err_t error = expected_service_generation ? (s_server_mutex ? ESP_OK : ESP_ERR_INVALID_STATE) : web_server_init(); if (error != ESP_OK) return error; if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; if (s_transitioning || (expected_service_generation && (!s_server || s_last_error != ESP_OK || s_generation == UINT32_MAX || expected_service_generation != s_generation))) { xSemaphoreGive(s_server_mutex); return ESP_ERR_INVALID_STATE; } bool running = s_server != NULL; s_transitioning = true; xSemaphoreGive(s_server_mutex); uint32_t token = 0; error = web_security_reserve_identity(expected_identity_generation, reset, &token); if (error == ESP_OK) { xSemaphoreTake(s_server_mutex, portMAX_DELAY); if (s_generation != UINT32_MAX) ++s_generation; xSemaphoreGive(s_server_mutex); error = web_security_replace_reserved(token); } if (error == ESP_OK) { *committed = true; if (running) error = stop_server(0, true, true); else if (reset) error = start_server(true); else { xSemaphoreTake(s_server_mutex, portMAX_DELAY); s_transitioning = false; xSemaphoreGive(s_server_mutex); } } else { /* No identity publication: leave HTTPD and its logins untouched. */ xSemaphoreTake(s_server_mutex, portMAX_DELAY); s_transitioning = false; xSemaphoreGive(s_server_mutex); } web_security_release_identity(token); return error; } esp_err_t web_server_stop(void) { return stop_server(0U, false, false); } esp_err_t web_server_stop_current(uint32_t expected_generation) { if (!expected_generation) return ESP_ERR_INVALID_ARG; return stop_server(expected_generation, false, false); } esp_err_t web_server_restart_current(uint32_t expected_generation) { if (!expected_generation) return ESP_ERR_INVALID_ARG; return stop_server(expected_generation, true, false); } esp_err_t web_server_reboot_current(uint32_t expected_generation) { if (!expected_generation) return ESP_ERR_INVALID_ARG; if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE; if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; if (s_server == NULL || s_transitioning || s_last_error != ESP_OK || s_generation == UINT32_MAX || expected_generation != s_generation) { xSemaphoreGive(s_server_mutex); return ESP_ERR_INVALID_STATE; } s_transitioning = true; ++s_generation; xSemaphoreGive(s_server_mutex); esp_restart(); return ESP_FAIL; /* Defensive only: reset normally never returns. */ } esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot) { if (snapshot == NULL) return ESP_ERR_INVALID_ARG; memset(snapshot, 0, sizeof(*snapshot)); if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE; if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; snapshot->generation = s_generation; snapshot->running = s_server != NULL; snapshot->transitioning = s_transitioning; snapshot->controllable = s_initialized && s_server != NULL && !s_transitioning && s_last_error == ESP_OK && s_generation != UINT32_MAX; xSemaphoreGive(s_server_mutex); return ESP_OK; } esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot) { if (snapshot == NULL) { return ESP_ERR_INVALID_ARG; } if (s_server_mutex == NULL) { return ESP_ERR_INVALID_STATE; } xSemaphoreTake(s_server_mutex, portMAX_DELAY); memset(snapshot, 0, sizeof(*snapshot)); snapshot->initialized = s_initialized; snapshot->running = s_server != NULL; 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; } esp_err_t web_server_clear_counters(void) { if (s_server_mutex == NULL) { return ESP_ERR_INVALID_STATE; } xSemaphoreTake(s_server_mutex, portMAX_DELAY); memset(&s_counters, 0, sizeof(s_counters)); xSemaphoreGive(s_server_mutex); web_cookie_auth_clear_counters(); return ESP_OK; }