Add Bounded Web Admission Diagnostics

This commit is contained in:
2026-09-08 18:04:46 +02:00
parent 42f6423d4e
commit f6263042ff
17 changed files with 682 additions and 13 deletions
@@ -43,6 +43,7 @@ static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms)
static void print_usage(void) { assert(false); }
static int web_diagnostics_command(const char *action) { assert(!strcmp(action, "show")); return 0; }
static int show_status(void) { assert(false); return 1; }
static int show_counters(void) { assert(false); return 1; }
static int show_credentials(void) { assert(false); return 1; }
@@ -56,6 +57,8 @@ static bool force_is_present(int argc, char **argv, int expected) {
'''
tests = r'''
int main(void) {
char *diagnostics[]={"web", "diagnostics", "show"};
assert(command_web(3, diagnostics)==0 && !stops && !scheduled);
char *stop[]={"web", "stop"};
remote=web=true;
assert(command_web(2,stop)==0 && scheduled==1 && !stops && last_action==ADMIN_CONSOLE_DEFER_WEB_STOP);
+1
View File
@@ -72,6 +72,7 @@ int main(void) {
};
const char *web_denied[] = {
"web", "web help", "web start", "web stop extra", "web counters", "web clear-counters",
"web diagnostics enable", "web diagnostics disable", "web diagnostics show", "web diagnostics clear",
"web credentials show", "web credentials rotate --force", "web certificate info",
"web certificate rotate", "web certificate rotate --force extra",
"web certificate rotate --force --force", "web certificate rotate --Force",
+12 -9
View File
@@ -79,6 +79,7 @@ typedef struct {
const uint8_t *servercert, *prvtkey_pem;
size_t servercert_len, prvtkey_len;
unsigned port_secure, tls_handshake_timeout_ms;
void (*user_cb)(void *);
} httpd_ssl_config_t;
/* Nonproduction defaults deliberately make explicit overrides observable. */
#define HTTPD_SSL_CONFIG_DEFAULT() ((httpd_ssl_config_t){.httpd = {.max_open_sockets = 1, .lru_purge_enable = true}})
@@ -104,9 +105,10 @@ static void xSemaphoreTake(SemaphoreHandle_t m, int wait) { (void)wait; assert(m
static void xSemaphoreGive(SemaphoreHandle_t m) { assert(m && locked); locked = 0; }
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(traced_admin_ticket_handler) HANDLER(traced_admin_upgrade_handler)
static void web_diagnostics_tls(void *arg) { (void)arg; assert(false); }
HANDLER(serial_settings_handler)
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
HANDLER(web_account_generate_password_handler) HANDLER(web_account_keys_handler)
@@ -129,6 +131,7 @@ static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_
assert(config->httpd.max_uri_handlers == 24 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->user_cb == web_diagnostics_tls);
assert(config->servercert_len == 1 && config->servercert[0] == 1);
assert(config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2);
if (ssl_start_error != ESP_OK) return ssl_start_error;
@@ -297,8 +300,8 @@ int main(void) {
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket);
assert(ticket->method == HTTP_POST && ticket->handler == traced_admin_ticket_handler && !ticket->is_websocket);
assert(ws->method == HTTP_GET && ws->handler == traced_admin_upgrade_handler && !ws->is_websocket);
assert(route("/ws/serial")->method == HTTP_GET && !route("/ws/serial")->is_websocket);
assert(route("/api/login")->method == HTTP_POST && route("/api/session")->method == HTTP_GET);
assert(web_server_start() == ESP_ERR_INVALID_STATE && auth_starts == 1 && ssl_starts == 1);
@@ -349,7 +352,7 @@ int main(void) {
assert(registered_count == 22 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler);
assert(route("/ws/serial")->handler == traced_websocket_handler);
assert(route("/api/session")->handler == web_cookie_auth_handler);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
@@ -366,7 +369,7 @@ int main(void) {
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
assert(ticket->method == HTTP_POST && !ticket->is_websocket &&
ticket->handler == web_admin_transport_ticket_handler);
ticket->handler == traced_admin_ticket_handler);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/ws/admin"));
/* Handler identity is checked, not its authentication implementation (doubled). */
assert(!auth_stops && !ssl_stops && !s_transitioning && s_last_error == ESP_OK);
@@ -451,8 +454,8 @@ int main(void) {
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/session")->handler == web_cookie_auth_handler);
assert(route("/ws/serial")->handler == websocket_handler);
assert(route("/ws/admin")->handler == web_admin_transport_upgrade_handler);
assert(route("/ws/serial")->handler == traced_websocket_handler);
assert(route("/ws/admin")->handler == traced_admin_upgrade_handler);
account_mutations = 0;
for (unsigned i = 0; i < registered_count; ++i) {
assert(strcmp(registered[i]->uri, "/api/settings/accounts/keys"));
+37
View File
@@ -0,0 +1,37 @@
# Web admission diagnostics host tests
Run from the repository root:
```sh
python3 tests/web_diagnostics/run.py
python3 tests/web_admin_transport/server_lifecycle.py
python3 tests/admin_console_boundary/lifecycle.py
python3 tests/admin_ssh_policy/run.py
```
`run.py` compiles the entire production `web_diagnostics.c` (only includes are
substituted) plus all four actual server tracing wrappers, with strict C11
warnings. `CC` selects the compiler. No dependencies beyond Python 3 and a C
compiler; no device/network/build actions or persistent generated files.
Twelve runtime groups cover disabled capture, existing live connections,
handler return/route preservation, WS classification, heap/stack field mapping,
six slots and duplicate/overflow callbacks, stale TLS identity/fd reuse,
capture-epoch interleavings, exact ring overwrite accounting, formatted-output
secrecy with poisoned URI/header/body fields, bounded concurrent show/clear,
stale upgrade completion, partial stop/restart, invalid input, and nonwrapping
IDs/saturating counters. One additional source guard group checks production
wiring and absence of request/logging/allocation/task/queue APIs. Fakes assert
that printing, heap sampling and public HTTPD/TLS calls never run under the
metadata lock.
These are deterministic injected interleavings, not a real FreeRTOS concurrency
or TLS/socket simulation. The restart test models synchronous callbacks; the
separate lifecycle harness executes real server orchestration with dependency
fakes and verifies callback configuration, six sockets/no LRU, unchanged
timeouts and all 16 existing failure/restart groups. Canonical CLI routing and
SSH/browser policy have separate tests. No target heap, stack margin, admission
reliability or secrecy of external SDK logging is proven by these tests.
Usage, exact SDK cleanup audit, resource accounting, and target checklist:
`docs/phase8d11_implementation.md`, “Authorized admission diagnostic slice”.
+69
View File
@@ -0,0 +1,69 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
enum { ESP_OK = 0, ESP_FAIL = -1 };
typedef struct { int fd; bool invalid; } esp_tls_t;
typedef struct { unsigned user_cb_state; esp_tls_t *tls; } esp_https_server_user_cb_arg_t;
enum { HTTPD_SSL_USER_CB_SESS_CREATE, HTTPD_SSL_USER_CB_SESS_CLOSE };
typedef struct { void *handle; int fd; const char *uri, *headers, *body; } httpd_req_t;
enum { HTTPD_WS_CLIENT_HTTP, HTTPD_WS_CLIENT_WEBSOCKET };
#define MALLOC_CAP_INTERNAL 1U
#define MALLOC_CAP_8BIT 2U
#define MALLOC_CAP_DMA 4U
#define MALLOC_CAP_SPIRAM 8U
#define portMUX_TYPE int
#define portMUX_INITIALIZER_UNLOCKED 0
static bool locked, upgraded;
static unsigned scans, calls, ws_queries;
static int64_t clock_us = 1000;
static void (*during_scan)(void), (*during_handler)(void), (*during_print)(void);
static void lock(int *m) { (void)m; assert(!locked); locked = true; }
static void unlock(int *m) { (void)m; assert(locked); locked = false; }
#define portENTER_CRITICAL(m) lock(m)
#define portEXIT_CRITICAL(m) unlock(m)
static int64_t esp_timer_get_time(void) { assert(!locked); return clock_us; }
static uint32_t heap_caps_get_free_size(uint32_t caps) {
assert(!locked); ++scans;
if (during_scan) { void (*hook)(void) = during_scan; during_scan = NULL; hook(); }
return 10000 + caps;
}
static uint32_t heap_caps_get_largest_free_block(uint32_t caps) { assert(!locked); return 5000 + caps; }
static uint32_t uxTaskGetStackHighWaterMark(void *task) { assert(!locked && !task); return 1234; }
static esp_err_t esp_tls_get_conn_sockfd(esp_tls_t *tls, int *fd) {
assert(!locked); *fd = tls->fd; return tls->invalid ? ESP_FAIL : ESP_OK;
}
static int httpd_req_to_sockfd(httpd_req_t *r) { assert(!locked); return r->fd; }
static int httpd_ws_get_fd_info(void *handle, int fd) {
assert(!locked && handle && fd >= 0); ++ws_queries;
return upgraded ? HTTPD_WS_CLIENT_WEBSOCKET : HTTPD_WS_CLIENT_HTTP;
}
static esp_err_t handler_result;
static esp_err_t handler(httpd_req_t *r) {
assert(!locked && r->handle); ++calls; clock_us += 75;
if (during_handler) { void (*hook)(void) = during_handler; during_handler = NULL; hook(); }
return handler_result;
}
static esp_err_t ticket_handler(httpd_req_t *r) { return handler(r); }
static esp_err_t websocket_handler(httpd_req_t *r) { return handler(r); }
static esp_err_t web_admin_transport_ticket_handler(httpd_req_t *r) { return handler(r); }
static esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *r) { return handler(r); }
static char output[65536];
static size_t output_size;
static int capture_printf(const char *fmt, ...) {
assert(!locked);
va_list ap; va_start(ap, fmt);
int count = vsnprintf(output + output_size, sizeof(output) - output_size, fmt, ap);
va_end(ap);
assert(count >= 0 && (size_t)count < sizeof(output) - output_size);
output_size += count;
if (during_print) { void (*hook)(void) = during_print; during_print = NULL; hook(); }
return count;
}
#define printf capture_printf
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Compile the entire production diagnostic module with deterministic public-API fakes.
No TLS, network, scheduler, hardware or real heap/stack measurement is simulated.
Lock assertions and injected interleavings test the bounded publication contract.
"""
import os
from pathlib import Path
import re
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
def without_includes(path):
return '\n'.join(line for line in path.read_text().splitlines()
if not line.startswith(('#include', '#pragma once')))
server = (ROOT / 'src/web_server.c').read_text()
wrappers = []
for name in ('traced_ticket_handler', 'traced_websocket_handler',
'traced_admin_ticket_handler', 'traced_admin_upgrade_handler'):
match = re.search(r'static esp_err_t ' + name + r'\(httpd_req_t \*request\)\n\{.*?\n\}', server, re.S)
assert match, name
wrappers.append(match.group())
# This slice must not take over cleanup, add async probes, or enable SDK logging.
assert 'config.user_cb = web_diagnostics_tls;' in server
assert 'config.httpd.close_fn' not in server and 'config.httpd.open_fn' not in server
source = (ROOT / 'src/web_diagnostics.c').read_text()
for forbidden in ('httpd_queue_work', 'httpd_get_client_list', 'esp_event_handler_register',
'httpd_req_get_', 'request->uri', 'request->user_ctx', 'request->sess_ctx',
'malloc(', 'calloc(', 'xTaskCreate', 'ESP_LOG', 'esp_log_level_set'):
assert forbidden not in source, forbidden
with tempfile.TemporaryDirectory(prefix='web-diagnostics-') as directory:
directory = Path(directory)
unit = directory / 'test.c'
unit.write_text((HERE / 'fakes.h').read_text() + '\n' +
without_includes(ROOT / 'src/web_diagnostics.h') + '\n' +
without_includes(ROOT / 'src/web_diagnostics.c') + '\n' +
'\n'.join(wrappers) + '\n' + (HERE / 'test.c').read_text())
executable = directory / 'test'
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
str(unit), '-o', str(executable)], check=True)
subprocess.run([str(executable)], check=True)
print('PASS: production integration/secrecy source guards (1 group)')
+157
View File
@@ -0,0 +1,157 @@
/* SPDX-License-Identifier: GPL-3.0-only */
static void notify(esp_tls_t *tls, unsigned state)
{
esp_https_server_user_cb_arg_t arg = {.tls = tls, .user_cb_state = state};
web_diagnostics_tls(&arg);
}
static trace_t last_row(void) { return s_events[(s_next + DIAG_EVENTS - 1) % DIAG_EVENTS]; }
static void clear_trace(void) { assert(web_diagnostics_command("clear") == 0); }
static void toggle(void) {
assert(web_diagnostics_command("disable") == 0);
assert(web_diagnostics_command("enable") == 0);
}
static esp_tls_t replacement = {.fd = 10};
static esp_tls_t *old_tls;
static void reuse_during_handler(void) {
notify(old_tls, HTTPD_SSL_USER_CB_SESS_CLOSE);
notify(&replacement, HTTPD_SSL_USER_CB_SESS_CREATE);
}
int main(void)
{
esp_tls_t tls[7];
for (unsigned i = 0; i < 7; ++i) tls[i] = (esp_tls_t){.fd = 10 + (int)i};
httpd_req_t request = {.handle = &tls, .fd = 10,
.uri = "/ws/admin?ticket=SECRET_TICKET", .headers = "Cookie: SECRET_COOKIE; Authorization: SECRET_PASSWORD",
.body = "SECRET_PRIVATE_KEY SECRET_WIFI SECRET_VERIFIER"};
/* 1: off by default, always-on identity only, no capability scans. */
assert(!s_enabled);
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CREATE);
uint64_t first = s_connections[0].seq;
assert(first && !scans && !s_count);
assert(traced_ticket_handler(&request) == ESP_OK && calls == 1 && !scans);
assert(web_diagnostics_command("enable") == 0);
assert(s_connections[0].seq == first && !s_count);
/* 2: all four actual server wrappers, rc unchanged, route tags and WS classification. */
assert(traced_ticket_handler(&request) == ESP_OK);
assert(last_row().route == WEB_DIAG_SERIAL_TICKET && last_row().elapsed_us == 75);
handler_result = ESP_FAIL;
assert(traced_admin_ticket_handler(&request) == ESP_FAIL);
assert(last_row().route == WEB_DIAG_ADMIN_TICKET && last_row().result == ESP_FAIL);
assert(!ws_queries);
handler_result = ESP_OK;
assert(traced_websocket_handler(&request) == ESP_OK);
assert(last_row().ordinary == 1 && last_row().serial == 0); /* ESP_OK is not 101. */
upgraded = true;
assert(traced_websocket_handler(&request) == ESP_OK);
assert(last_row().route == WEB_DIAG_SERIAL_UPGRADE && last_row().serial == 1);
notify(&tls[1], HTTPD_SSL_USER_CB_SESS_CREATE);
request.fd = 11;
assert(traced_admin_upgrade_handler(&request) == ESP_OK);
assert(last_row().route == WEB_DIAG_ADMIN_UPGRADE && last_row().admin == 1);
assert(last_row().stack_bytes == 1234 && last_row().free_bytes[0] == 10003 &&
last_row().largest[1] == 5005 && last_row().free_bytes[2] == 10010);
/* 3: six post-TLS slots; overflow cannot affect admission or evict observations. */
for (unsigned i = 2; i < 6; ++i) notify(&tls[i], HTTPD_SSL_USER_CB_SESS_CREATE);
trace_t row = last_row();
assert(row.ordinary == 4 && row.serial == 1 && row.admin == 1);
notify(&tls[6], HTTPD_SSL_USER_CB_SESS_CREATE);
assert(s_lost == 1 && s_connections[0].seq == first);
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CREATE);
assert(s_unmatched == 1 && s_connections[0].seq == first);
/* 4: close identifies TLS instance, not fd; later reuse gets a new sequence. */
clock_us += 100;
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CLOSE);
assert(last_row().event == TLS_CLOSE && last_row().seq == first && !last_row().serial);
notify(&replacement, HTTPD_SSL_USER_CB_SESS_CREATE);
uint64_t reused = s_connections[0].seq;
assert(reused > first && s_connections[0].fd == 10);
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CLOSE); /* stale instance with same fd */
assert(s_connections[0].seq == reused && s_unmatched == 2);
/* 5: clear and toggles never reset live IDs; no sample may cross an epoch. */
uint64_t event_before = s_event_seq;
clear_trace();
assert(s_connections[0].seq == reused && !s_count && !s_overwritten);
request.fd = 10;
during_scan = clear_trace;
assert(traced_ticket_handler(&request) == ESP_OK && !s_count);
during_handler = toggle;
assert(traced_ticket_handler(&request) == ESP_OK && s_count == 1); /* enter only */
assert(last_row().event == ENTER && last_row().id > event_before);
clear_trace();
during_scan = toggle;
assert(traced_ticket_handler(&request) == ESP_OK && !s_count);
unsigned prior_scans = scans;
assert(web_diagnostics_command("disable") == 0);
assert(traced_ticket_handler(&request) == ESP_OK && scans == prior_scans);
assert(web_diagnostics_command("enable") == 0);
/* 6: full ring bounded with exact overwrite count; sequence survives clear. */
clear_trace();
for (unsigned i = 0; i < 25; ++i) assert(traced_ticket_handler(&request) == ESP_OK);
assert(s_count == DIAG_EVENTS && s_overwritten == 18);
uint64_t cutoff = s_event_seq;
output_size = 0; output[0] = 0;
assert(web_diagnostics_command("show") == 0);
assert(s_event_seq == cutoff && s_count == DIAG_EVENTS);
assert(strstr(output, "post-TLS occupancy=6/6 ordinary=5 serial=0 admin=1"));
assert(strstr(output, "overwritten=18") && strstr(output, "NOT HTTP status"));
/* 7: actual formatted output contains no request secrets or pointer identity. */
for (const char *const *p = (const char *const[]){"SECRET_", "Cookie:", "Authorization:",
"ticket=", "/ws/admin?", "tls=", "0x", NULL}; *p; ++p) assert(!strstr(output, *p));
assert(strstr(output, "conn=") && strstr(output, "stack=1234"));
/* 8: show/clear interleaving is bounded and explicitly marks missing rows. */
output_size = 0; output[0] = 0;
during_print = clear_trace;
assert(web_diagnostics_command("show") == 0);
assert(!s_count && strstr(output, "no longer retained"));
/* 9: synthetic close/reuse inside handler cannot classify replacement using stale fd. */
notify(&replacement, HTTPD_SSL_USER_CB_SESS_CLOSE);
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CREATE);
old_tls = &tls[0];
during_handler = reuse_during_handler;
assert(traced_websocket_handler(&request) == ESP_OK);
assert(s_connections[0].tls == &replacement && s_connections[0].kind == 0);
/* 10: synchronous stop closes all; failed stop with live sockets keeps metadata;
* restart with reused TLS addresses and fds never resets connection sequence. */
uint64_t before_restart = s_connection_seq;
notify(&replacement, HTTPD_SSL_USER_CB_SESS_CLOSE);
assert(s_connections[1].seq); /* partial/failed stop still owned */
for (unsigned i = 1; i < 6; ++i) notify(&tls[i], HTTPD_SSL_USER_CB_SESS_CLOSE);
for (unsigned i = 0; i < 6; ++i) assert(!s_connections[i].seq);
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CREATE);
assert(s_connections[0].seq > before_restart);
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CLOSE);
/* 11: invalid callback/getter and unknown command have no side effects. */
uint64_t before = s_connection_seq;
web_diagnostics_tls(NULL);
tls[0].invalid = true;
notify(&tls[0], HTTPD_SSL_USER_CB_SESS_CREATE);
assert(s_connection_seq == before);
assert(web_diagnostics_command("show SECRET_PASSWORD") == 1);
/* 12: nonwrapping identities/epochs and saturating counters fail observation only. */
s_connection_seq = UINT64_MAX;
s_lost = UINT32_MAX;
notify(&tls[1], HTTPD_SSL_USER_CB_SESS_CREATE);
assert(s_connection_seq == UINT64_MAX && s_lost == UINT32_MAX && !s_connections[0].seq);
s_event_seq = UINT64_MAX;
unsigned retained = s_count;
record((connection_t){.fd = 10}, ENTER, 0, 0, 0, s_epoch);
assert(s_event_seq == UINT64_MAX && s_count == retained);
s_epoch = UINT64_MAX;
assert(web_diagnostics_command("enable") == 0 && !s_enabled);
assert(!locked);
puts("PASS: web diagnostics 12 lifecycle/ring/secrecy groups");
return 0;
}