Add Authenticated HTTPS Admin Foundation

This commit is contained in:
2026-08-23 17:37:49 +02:00
parent 0dcc0d20d4
commit 8b5417881c
15 changed files with 2233 additions and 89 deletions
+482
View File
@@ -0,0 +1,482 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* TLS-only HTTP server with bounded Basic authentication and status output. */
#include "web_server.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include "esp_http_server.h"
#include "esp_https_server.h"
#include "esp_netif_ip_addr.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "mbedtls/base64.h"
#include "secure_random.h"
#include "serial_config.h"
#include "serial_service.h"
#include "session_broker.h"
#include "usb_cdc_transport.h"
#include "web_security.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>";
static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server;
static bool s_initialized;
static bool s_transitioning;
static esp_err_t s_last_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 send_authentication_required(httpd_req_t *request)
{
esp_err_t error = httpd_resp_set_hdr(
request, "WWW-Authenticate",
"Basic realm=\"ESP32-SAK\", charset=\"UTF-8\"");
if (error != ESP_OK) {
increment_counter(&s_counters.response_errors);
return error;
}
return send_plain_error(request, "401 Unauthorized", "Authentication required.\n");
}
static esp_err_t authenticate_request(httpd_req_t *request, bool *authenticated)
{
char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0};
uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0};
size_t decoded_length = 0U;
esp_err_t result = ESP_OK;
*authenticated = false;
increment_counter(&s_counters.requests);
size_t header_length = httpd_req_get_hdr_value_len(request, "Authorization");
if (header_length == 0U || header_length >= sizeof(authorization)) {
goto cleanup;
}
if (httpd_req_get_hdr_value_str(request, "Authorization",
authorization, sizeof(authorization)) != ESP_OK ||
header_length < 7U || strncasecmp(authorization, "Basic ", 6U) != 0) {
goto cleanup;
}
int decode_result = mbedtls_base64_decode(
decoded, sizeof(decoded), &decoded_length,
(const unsigned char *)authorization + 6U, header_length - 6U);
if (decode_result != 0 || decoded_length == 0U) {
goto cleanup;
}
uint8_t *separator = memchr(decoded, ':', decoded_length);
if (separator == NULL) {
goto cleanup;
}
size_t username_length = (size_t)(separator - decoded);
size_t password_length = decoded_length - username_length - 1U;
result = web_security_authenticate_basic(decoded, username_length,
separator + 1U, password_length,
authenticated);
cleanup:
secure_wipe(authorization, sizeof(authorization));
secure_wipe(decoded, sizeof(decoded));
if (result != ESP_OK) {
return result;
}
if (*authenticated) {
increment_counter(&s_counters.authenticated_requests);
} else {
increment_counter(&s_counters.authentication_failures);
}
return ESP_OK;
}
static esp_err_t authorize_or_respond(httpd_req_t *request, bool *authorized)
{
*authorized = false;
esp_err_t error = authenticate_request(request, authorized);
if (error != ESP_OK) {
*authorized = false;
return send_plain_error(request, "503 Service Unavailable",
"Authentication service unavailable.\n");
}
return *authorized ? ESP_OK : send_authentication_required(request);
}
static esp_err_t root_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.root_requests);
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);
}
if (error != ESP_OK) {
increment_counter(&s_counters.response_errors);
}
return error;
}
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)
{
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized);
if (error != ESP_OK || !authorized) {
return error;
}
increment_counter(&s_counters.status_requests);
wifi_manager_snapshot_t wifi = {0};
serial_config_t serial_config = {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_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_config(&serial_config) == ESP_OK;
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 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"
"}\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_service_is_running() ? "true" : "false",
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);
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 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,
};
esp_err_t web_server_init(void)
{
esp_err_t error = ensure_mutex();
if (error != ESP_OK) {
return error;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_initialized = true;
if (s_last_error == ESP_ERR_INVALID_STATE) {
s_last_error = ESP_OK;
}
xSemaphoreGive(s_server_mutex);
return ESP_OK;
}
esp_err_t web_server_start(void)
{
esp_err_t error = web_server_init();
if (error != ESP_OK) {
return error;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server != NULL || s_transitioning) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
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;
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();
config.httpd.max_open_sockets = 2;
config.httpd.max_uri_handlers = 2;
config.httpd.lru_purge_enable = true;
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;
error = httpd_ssl_start(&server, &config);
}
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);
}
if (error == ESP_OK) {
error = httpd_register_uri_handler(server, &s_status_uri);
}
if (error != ESP_OK && server != NULL) {
(void)httpd_ssl_stop(server);
server = NULL;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = error;
if (error == ESP_OK) {
s_server = server;
++s_counters.starts;
} else {
++s_counters.start_failures;
}
xSemaphoreGive(s_server_mutex);
return error;
}
esp_err_t web_server_stop(void)
{
if (s_server_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server == NULL || s_transitioning) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
httpd_handle_t server = s_server;
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
esp_err_t error = httpd_ssl_stop(server);
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = error;
if (error == ESP_OK) {
s_server = NULL;
++s_counters.stops;
}
xSemaphoreGive(s_server_mutex);
return error;
}
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->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);
return ESP_OK;
}