Add Bounded Web Admission Diagnostics
This commit is contained in:
@@ -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”.
|
||||
@@ -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
|
||||
@@ -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)')
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user