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
+2
View File
@@ -110,6 +110,8 @@ A WebSocket connection requires a one-time, principal-bound ticket with a maximu
`web_httpd_adapter` is the sole private ESP-IDF 5.5.0 boundary. Its compile-time version guard requires review on upgrades. It validates NUL-separated parsed headers because public getters return only the first field, and rejects duplicates/ambiguous framing. The serial URI is registered as ordinary HTTP GET so cookie/ticket/principal/broker admission precedes explicit 101 and frame-handler installation; automatic IDF WebSocket routing would send 101 too early. Cleanup wipes consumed scratch but preserves right-aligned unread pending data. CMake compiles HTTPD logs above ERROR out to prevent header/ticket logging. No SDK patch or component copy exists. See `docs/phase8d3_implementation.md` for source verification, tests and pending on-wire checks.
`web_diagnostics` observes public synchronous HTTPS create/close callbacks without replacing socket/transport cleanup, and wraps only the four serial/admin ticket/upgrade handlers. Six always-maintained post-TLS metadata records supply a console-safe occupancy snapshot without querying HTTPD off-owner; an opt-in 32-entry numeric ring adds timing/heap/HTTPD stack samples. Firmware-lifetime connection sequences and capture epochs survive clear/restart and fence fd reuse/toggle races. No tasks/probes/event subscriptions; no authentication/request data retained. UART0/admin SSH commands never wait for HTTPD; browser policy remains unchanged. This is successful-TLS occupancy, not preaccept or failed/in-progress handshake instrumentation; exact limits/overhead in `docs/phase8d11_implementation.md`.
Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes.
`web_ui.c` contains authored index/application strings and response policy; it validates `/api/session` before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. `web_login_ui` is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
+1
View File
@@ -63,6 +63,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients.
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
- Opt-in admission diagnostics: `src/web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`. Public synchronous HTTPS create/close callbacks publish six post-TLS connection records; four ticket/upgrade wrappers feed a 32-entry numeric ring. UART0/admin SSH `web diagnostics enable|disable|show|clear`; no queue/task/cleanup override or capacity change. Full bounds, SDK semantics and preaccept/TLS blind spots: `docs/phase8d11_implementation.md`.
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
+2
View File
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
## Development state
- **Authorized 8D.11 admission diagnostic slice complete (2026-09-08), host-tested/build-verified; target pending:** `web_diagnostics.{c,h}` uses public synchronous HTTPS create/close callbacks and four ticket/upgrade wrappers, six fixed metadata slots and a 32-record opt-in numeric ring. Console `web diagnostics enable|disable|show|clear` via UART0/admin SSH; browser policy unchanged. No close/open override, queue probe/task, capacity/timeout/log-level/assets/SDK change. Post-TLS owner-published occupancy only; TLS failures/in-progress handshakes and preaccept backlog remain invisible. Final `pio run` PASS **10.30 s, 99,156 B RAM / 1,707,597 B flash**, **+3,080/+3,912 B** versus recorded key-slice baseline. Ring/table target symbols 2,816/192 B; diagnostic handler/record/TLS callback/show local frames 144/144/160/528 B excluding callees, runtime margins unmeasured. PASS diagnostic **12+1**, server lifecycle **16**, canonical console lifecycle/policy/boundary, canonical account/key transactions, cookie/admin and cookie/accounts, admin transport **25**/tickets **12**, browser **68 plus renderer/CSP**, store/serial and diff check. Exact files/commands/contracts and reproduction checklist in `docs/phase8d11_implementation.md`. Existing key work preserved; no device/commit/upload/erase, timeout diagnosis, target acceptance or reserve approval claimed. Next is user target capture, not capacity changes or 8D.12.
- **8D.11 partial target evidence (2026-09-08):** User supplied 60-second fresh-boot/full-mix telemetry; no functional/sign-off claim. Full mix eventually established (two SSH public-key sessions, SSH writer + USB/two web observers, both admins), but last browser admission again required retries. Three TLS errors `-0x0050` verified against installed mbedTLS as NET_CONN_RESET, not allocation failure; cause unknown. Loaded internal/DMA free 32,596/24,840 B, lifetime minima 5,468/532 B, largest 22,528 B; per-region minima are conservative/non-simultaneous. Settled free is similar to prior 8D.10, minima much lower; no causal regression attribution. SSH stack minimum-free 18,468 boot/16,276 loaded B. Web send failure/close 1, expired tickets 3; SSH handshake/auth failures zero; no broker drop counters. Exact memory/counters/evidence limits in `docs/phase8d11_implementation.md`. Previously accepted admission issue recurred, not fixed; investigate timing/socket occupancy/allocation correlation before capacity changes. Key algorithms/new UI provenance, mutation checklist, HTTPD/dispatcher margins, cleanup/soak and target sign-off remain pending. Documentation-only update; no source/build/device action.
- **8D.11 implemented (2026-09-08), host-tested/build-verified; target sign-off pending:** Accounts now supports fingerprint listing and bounded Ed25519/P256 public-key import/delete/clear. Conditional database wrappers check target identity under the canonical lock; existing dispatcher/account slot and target-only revocation remain. Optional POST `/api/settings/accounts/keys` brings handler budget to 24, six sockets/tasks/stacks/queue depth unchanged. Sparse stable key slots are supported; review finding fixed and regressed. Parent canonical accounts, cookie accounts, UI, lifecycle (16), build and diff checks passed; final agent UI extension passes 68 groups plus CSP. Final production build 14.85 s, 96,076 B RAM / 1,703,685 B flash (+168/+9,448 vs 8D.10). Exact API, evidence attribution and pending target checklist: `docs/phase8d11_implementation.md`. No device/assets/commit action, reserve approval, full M3 claim or 8D.12 work. Supersedes the older wait-for-8D.11 instruction below, not accepted 8D.88D.10/M2.
+61
View File
@@ -26,6 +26,67 @@ Loaded counters: SSH TCP connections/handshake success/auth attempts 2/2/2; hand
Documentation-only evidence update: no new build, source/config edits, upload, erase, device commands or sign-off. Next diagnostic evidence should correlate connection attempts with heap/allocation failures and browser network timing/socket occupancy rather than assume heap exhaustion or increase socket capacity blindly. Key-workflow checklist and reserve approval remain open.
## Authorized admission diagnostic slice (2026-09-08)
Separately user-authorized instrumentation for the recurring third-webshell timeout, **not a timeout fix or a key-workflow sign-off**. Implemented in `src/web_diagnostics.{c,h}`, wired through `web_server.c`, `web_console.c`, `console_completion.c` and `src/CMakeLists.txt`. No generated assets, SDK patch, broad HTTPD debug logging, broker/serial change, new task/timer/queue, socket/URI capacity change, timeout change, LRU change or TLS cleanup override.
### Usage and interpretation
Use UART0 (preferred during network stalls) or authenticated admin SSH:
```text
web diagnostics clear
web diagnostics enable
web diagnostics show
```
Enable before reproducing the failed third connection. At failure, run `web diagnostics show` promptly, alongside existing `memory`/`web counters`/browser Network timing evidence; repeat the snapshot if admission remains stalled. Then `web diagnostics disable` freezes event retention (live occupancy continues updating); `web diagnostics show` prints retained history, and `web diagnostics clear` erases history/counters without changing live sockets or enable state. The setting is RAM-only and defaults off after boot. Existing browser-shell policy deliberately denies these commands, even though shared completion offers the fixed forms. There is no HTTP diagnostic endpoint.
- Six connection records are maintained from boot even with capture disabled, so enabling on an already loaded server does not mislabel existing sockets. Each successful TLS connection gets a monotonically increasing, non-wrapping, firmware-lifetime 64-bit `conn` sequence independent of fd, cookie, ticket, broker or user identity. Clear, disable and HTTPS restart do not reset it. Internal TLS object identity is used only during synchronous cleanup; no pointer is exported or dereferenced by the console.
- Snapshot output gives its boot-relative `snapshot_us`, post-TLS occupancy split ordinary/serial-WS/admin-WS and each live fd/connection sequence/open time/age. `kind=0/1/2` means ordinary/serial/admin. Ordinary includes every successful TLS socket not yet observed as upgraded, including idle keep-alives, assets/login/settings and sockets not yet used for HTTP. Classification is actual public `httpd_ws_get_fd_info()` state after upgrade-handler return, **not inferred from `ESP_OK`**. No URI string is inspected.
- The fixed 32-entry overwrite ring records successful TLS open, TLS transport-context close, and enter/result for serial/admin ticket and upgrade handlers. `t_us` is boot-relative observation time; result `dt_us` measures the underlying handler only (including its work/IO, excluding entry resource sampling); close `dt_us` is successful-TLS connection lifetime. Open/enter durations are zero. Open/result occupancy includes the connection; close occupancy excludes it. Event IDs survive clear; `overwritten` counts evicted retained records since clear. `unmatched` counts duplicate create/unmatched close; `lost` counts untrackable creates (getter failure, metadata/sequence exhaustion). Nonzero anomaly counters mean occupancy is not trustworthy as complete evidence. Counters saturate.
- `rc` is the exact handler return, **not HTTP status or ticket issuance outcome**: sending a 401/403/503 can return `ESP_OK`. Match browser HTTP status and existing rejection/issuance counters; there is no ticket-value correlation, request ID, authentication identity, header/body/query logging, or allocation-failure attribution. Upgrade success is visible in the occupancy classification. Ring overwrite or clear/toggle during an operation can leave unpaired enter/result records; do not invent a duration for a missing result.
- Each retained event samples free/largest bytes for internal 8-bit, internal DMA and PSRAM 8-bit, plus the current HTTPD task's minimum-free stack **in ESP-IDF bytes**. Capability scans run outside the diagnostic lock. Samples are sequential, not an atomic heap snapshot; overlapping internal/DMA pools must not be added. Stack watermark includes diagnostic call overhead and is not dispatcher margin. `show` does not query a live task handle: resources are historical event samples, not fresh heap values at show time. Use `memory` for current system heap.
- Console snapshots copy only local diagnostic metadata under a short portMUX, never inspect HTTPD session internals or wait for its owner. Printing occurs outside the lock. At most 32 ID-qualified rows and six live records are printed, even during churn; concurrent clear/overwrite is reported as “no longer retained”. Capture epochs reject samples crossing enable/disable/clear, and sequence checks prevent a stale upgrade result from reclassifying a reused fd. No queued diagnostic work exists, hence no outstanding probe or stale queue lifetime to retire on restart. Minimal connection bookkeeping and upgrade classification remain active while disabled; heap scans and event recording do not. Instrumentation still has CPU/static RAM cost, not zero perturbation.
### SDK audit and explicit blind spots
Audited installed PlatformIO ESP-IDF **5.5.0**, `components/esp_https_server/src/https_server.c` (`httpd_ssl_open`, `httpd_ssl_close`), `components/esp_http_server/src/httpd_sess.c` (`httpd_sess_delete`) and `httpd_main.c`. HTTPS performs synchronous TLS creation before the configured `open_fn` and `user_cb` create callback. It installs a transport-context destructor; that destructor invokes the public close callback before deleting TLS/freeing the context. HTTPD's default close closes the fd, then clears contexts, then frees its session slot. The diagnostic close therefore marks a cleanup observation, not a FIN timestamp or causal close reason. It uses the stored fd, not a getter on an already-closed socket. **Neither `open_fn` nor `close_fn` is replaced**, preserving all existing HTTPS cleanup ownership and failure behavior. Callbacks execute synchronously under the existing HTTPD lifecycle; successful stop finishes cleanup before restart, while failed/partial stop retains remaining live metadata. No asynchronous fd-only events are consumed, avoiding event-delay/fd-reuse ambiguity.
This deliberately bounded first slice does **not** measure TCP connect/accept/listen backlog, pending clients when IDF stops accepting at capacity, handshake begin/duration/failure, TLS allocation failure or aggregate lwIP socket pressure. The public configured open hook is post-TLS too; adding it would not fix these blind spots. No owner-queued client-list probe is added. Occupancy is an owner-published **successful-TLS lower bound**, not the complete HTTPD session table while a handshake is in progress. A clean **6/6** snapshot supports established-connection saturation at that instant (three WS + three ordinary is directly distinguishable); fewer than six does not exonerate admission/TLS/global socket pressure. Correlate time with browser evidence and existing secret-free TLS errors; do not call this pre-TLS tracing or claim the root cause is proven. Existing capacities, receive/send/handshake timeouts and accepted admission issue remain unchanged.
### Diagnostic validation and target follow-up
Focused host harness compiles the entire production module plus the four actual server wrappers with deterministic public-API fakes. Twelve groups cover disabled bookkeeping, routes/return preservation/actual WS state, six-slot saturation, duplicate/stale/reused fd, clear/toggle publication races, full ring/exact overwrite, formatted-output secrecy, bounded show/clear interleaving, stale upgrade completion, partial stop/restart, invalid callbacks/commands and sequence/counter exhaustion; one additional source-guard group checks forbidden APIs and callback wiring. Real TLS/network scheduling, heap/stack values and target stalls are not simulated. Server lifecycle harness additionally checks callback configuration while preserving all 16 existing groups; canonical console lifecycle and policy verify CLI dispatch and unchanged browser restrictions.
Final implementer-run validation (all PASS):
| Command | Evidence |
|---|---|
| `python3 tests/web_diagnostics/run.py` | 12 runtime groups + 1 production integration/secrecy guard group |
| `python3 tests/web_admin_transport/server_lifecycle.py` | 16 lifecycle groups; callback configured, budgets/timeouts preserved |
| `python3 tests/admin_console_boundary/lifecycle.py` | Actual canonical diagnostic dispatch plus existing stop/certificate/reboot cases |
| `python3 tests/admin_ssh_policy/run.py` | Actual IDF parser; four new diagnostic forms denied to WEB, accepted by SSH |
| `python3 tests/admin_console_boundary/run.py` | Shared dispatcher, owner/currentness, completion, deferred certificate and SSH adapter regressions |
| `python3 tests/admin_console_boundary/accounts.py` | Canonical account/key transactions, parser/curve validation, sparse keys and CLI parity |
| `python3 tests/web_cookie_auth/run.py --admin` | Shared cookie policy and combined admin ticket-to-101 integration |
| `python3 tests/web_cookie_auth/run.py --accounts` | Shared cookie policy and existing phase8D11 account/key route regressions |
| `python3 tests/web_admin_transport/run.py --tickets` | 25 transport groups + 12 ticket groups |
| `python3 tests/web_ui_session/run.py` | 68 browser behavior groups + production renderer/HTML/CSP checks |
| `python3 tests/web_session_store/run.py --serial` | Store lifecycle/races and serial/session binding/isolation |
| `pio run` | Final production source build **10.30 s**, **99,156 B RAM / 1,707,597 B flash** |
| `git --no-pager diff --check` | PASS |
Firmware delta versus recorded pre-instrumentation phase8D11 **96,076 B RAM / 1,703,685 B flash**: **+3,080 B RAM / +3,912 B flash**. `xtensa-esp32s3-elf-nm -S .pio/build/esp32-s3-devkitc-1-n16r8/src/web_diagnostics.c.o` verifies **2,816 B ring** (32 × 88), **192 B live table** (6 × 32) and **8 B portMUX**; remaining static metadata/alignment contributes to the build delta. `xtensa-esp32s3-elf-objdump -d` on that same target object gives local entry frames: handler **144 B**, record **144 B**, TLS callback **160 B**, show **528 B**, command **32 B**. These exclude callees and are **not** end-to-end stack margin proof. No task/stack-size increase or diagnostic dynamic allocation. Static RAM is paid even while disabled; this matters against the already low observed internal/DMA minima. Capability scans can perturb enabled timing and memory pressure correlation, so compare with disabled runs rather than treating this instrumentation as free. Initial full build also emitted installed SDK FATFS Kconfig boolean-default notes; all production builds succeeded.
Exact changed-file inventory for this diagnostic task (key implementation untouched):
- Production: `src/web_diagnostics.c`, `src/web_diagnostics.h`, `src/web_server.c`, `src/web_console.c`, `src/console_completion.c`, `src/CMakeLists.txt`.
- Tests: `tests/web_diagnostics/run.py`, `tests/web_diagnostics/fakes.h`, `tests/web_diagnostics/test.c`, `tests/web_diagnostics/README.md`, `tests/web_admin_transport/server_lifecycle.py`, `tests/admin_console_boundary/lifecycle.py`, `tests/admin_ssh_policy/run.py`.
- Docs: `docs/phase8d11_implementation.md`, `docs/agent/current-state.md`, `docs/agent/code-map.md`, `docs/agent/architecture.md`. No change to durable design decisions was needed beyond documenting the new observation module in the architecture/code map.
Target pending: enable before full-mix reproduction; capture both successful and failed third admission with browser status/timing, ordinary-vs-WS occupancy and memory evidence; check live counts on disconnect/stop/restart, disabled capture and clear; retain UART0/USB recovery and one broker writer throughout. Compare enabled/disabled timing and memory under the same load. No new hardware validation, reserve approval, timeout diagnosis or phase sign-off is claimed.
## Scope and contracts
- Accounts settings lists selected-account key types, stable slot indices and SHA256 fingerprints; imports OpenSSH Ed25519/P-256 public-key text and explicitly deletes one slot or clears all keys.
+1
View File
@@ -38,6 +38,7 @@ idf_component_register(
"web_assets_data.c"
"web_ui.c"
"web_server.c"
"web_diagnostics.c"
"web_session_store.c"
"web_auth_parse.c"
"web_httpd_adapter.c"
+4
View File
@@ -184,6 +184,10 @@ static const char *const s_completion_candidates[] = {
"web stop",
"web counters",
"web clear-counters",
"web diagnostics enable",
"web diagnostics disable",
"web diagnostics show",
"web diagnostics clear",
"web credentials",
"web credentials show",
"web credentials rotate",
+7
View File
@@ -18,12 +18,14 @@
#include "web_cookie_auth.h"
#include "web_admin_transport.h"
#include "web_admin_tickets.h"
#include "web_diagnostics.h"
static void print_usage(void)
{
printf("Usage:\n");
printf(" web status|start|stop\n");
printf(" web counters|clear-counters\n");
printf(" web diagnostics enable|disable|show|clear\n");
printf(" web credentials show\n");
printf(" web credentials rotate --force\n");
printf(" web certificate info\n");
@@ -382,6 +384,11 @@ static int command_web(int argc, char **argv)
print_usage();
return 0;
}
if (argc == 3 && strcmp(argv[1], "diagnostics") == 0) {
if (web_diagnostics_command(argv[2]) == 0) return 0;
print_usage();
return 1;
}
if (argc == 2 && strcmp(argv[1], "status") == 0) {
return show_status();
}
+233
View File
@@ -0,0 +1,233 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Post-TLS observation only. Never retain request data or replace TLS cleanup. */
#include "web_diagnostics.h"
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define DIAG_SOCKETS 6U
#define DIAG_EVENTS 32U
typedef struct {
const esp_tls_t *tls; /* Identity only, never dereferenced or printed. */
uint64_t seq;
int64_t opened_us;
int fd;
unsigned kind; /* 0 ordinary, 1 serial WS, 2 admin WS */
} connection_t;
typedef struct {
uint64_t id, seq;
int64_t at_us, elapsed_us;
uint32_t free_bytes[3], largest[3], stack_bytes;
int fd, result;
unsigned event, route, ordinary, serial, admin;
} trace_t;
enum { TLS_OPEN, TLS_CLOSE, ENTER, RESULT };
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static connection_t s_connections[DIAG_SOCKETS];
static trace_t s_events[DIAG_EVENTS];
static bool s_enabled;
static uint64_t s_connection_seq, s_event_seq, s_epoch;
static uint32_t s_overwritten, s_unmatched, s_lost;
static unsigned s_count, s_next;
static void increment(uint32_t *value)
{
if (*value != UINT32_MAX) ++*value;
}
static void occupancy(trace_t *event)
{
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
if (!s_connections[i].seq) continue;
if (s_connections[i].kind == 1) ++event->serial;
else if (s_connections[i].kind == 2) ++event->admin;
else ++event->ordinary;
}
}
/* Called by HTTPD only. Expensive capability scans stay outside the portMUX.
* Epoch rejects a sample crossing clear/disable/enable. Sequence fences fd reuse. */
static void record(connection_t connection, unsigned event, unsigned route,
int result, int64_t elapsed_us, uint64_t epoch)
{
portENTER_CRITICAL(&s_lock);
bool enabled = s_enabled && s_epoch == epoch;
portEXIT_CRITICAL(&s_lock);
if (!enabled) return;
trace_t row = {.seq = connection.seq, .fd = connection.fd,
.at_us = esp_timer_get_time(), .elapsed_us = elapsed_us,
.event = event, .route = route, .result = result};
const uint32_t caps[] = {MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT};
for (unsigned i = 0; i < 3; ++i) {
row.free_bytes[i] = heap_caps_get_free_size(caps[i]);
row.largest[i] = heap_caps_get_largest_free_block(caps[i]);
}
/* ESP-IDF FreeRTOS reports minimum-free stack in bytes, not vanilla words. */
row.stack_bytes = uxTaskGetStackHighWaterMark(NULL);
portENTER_CRITICAL(&s_lock);
if (s_enabled && s_epoch == epoch && s_event_seq != UINT64_MAX) {
row.id = ++s_event_seq;
occupancy(&row);
s_events[s_next] = row;
s_next = (s_next + 1U) % DIAG_EVENTS;
if (s_count < DIAG_EVENTS) ++s_count;
else increment(&s_overwritten);
}
portEXIT_CRITICAL(&s_lock);
}
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg)
{
if (!arg || !arg->tls) return;
connection_t connection = {0};
int fd = -1;
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE &&
(esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0)) {
portENTER_CRITICAL(&s_lock);
increment(&s_lost);
portEXIT_CRITICAL(&s_lock);
return;
}
int64_t now = esp_timer_get_time();
portENTER_CRITICAL(&s_lock);
uint64_t epoch = s_epoch;
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE) {
unsigned i;
for (i = 0; i < DIAG_SOCKETS; ++i)
if (s_connections[i].seq && (s_connections[i].tls == arg->tls ||
s_connections[i].fd == fd)) break;
/* Duplicate notifications are not new connections. */
if (i != DIAG_SOCKETS) {
increment(&s_unmatched);
} else {
for (i = 0; i < DIAG_SOCKETS; ++i) if (!s_connections[i].seq) break;
if (i < DIAG_SOCKETS && s_connection_seq != UINT64_MAX) {
connection = (connection_t){.tls = arg->tls, .fd = fd,
.seq = ++s_connection_seq, .opened_us = now};
s_connections[i] = connection;
} else increment(&s_lost);
}
} else if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CLOSE) {
unsigned i;
for (i = 0; i < DIAG_SOCKETS; ++i)
if (s_connections[i].seq && s_connections[i].tls == arg->tls) break;
if (i < DIAG_SOCKETS) {
connection = s_connections[i];
memset(&s_connections[i], 0, sizeof(s_connections[i]));
} else increment(&s_unmatched);
}
portEXIT_CRITICAL(&s_lock);
if (connection.seq)
record(connection, arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE ? TLS_OPEN : TLS_CLOSE,
0, 0, now - connection.opened_us, epoch);
}
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
esp_err_t (*handler)(httpd_req_t *))
{
int fd = httpd_req_to_sockfd(request);
connection_t connection = {.fd = fd};
portENTER_CRITICAL(&s_lock);
uint64_t epoch = s_epoch;
bool enabled = s_enabled;
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
if (s_connections[i].seq && s_connections[i].fd == fd) connection = s_connections[i];
portEXIT_CRITICAL(&s_lock);
record(connection, ENTER, route, 0, 0, epoch);
int64_t start = enabled ? esp_timer_get_time() : 0;
esp_err_t result = handler(request);
int64_t elapsed = enabled ? esp_timer_get_time() - start : 0;
if (route == WEB_DIAG_SERIAL_UPGRADE || route == WEB_DIAG_ADMIN_UPGRADE) {
bool upgraded = httpd_ws_get_fd_info(request->handle, fd) == HTTPD_WS_CLIENT_WEBSOCKET;
portENTER_CRITICAL(&s_lock);
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
if (connection.seq && s_connections[i].seq == connection.seq && upgraded)
s_connections[i].kind = route == WEB_DIAG_SERIAL_UPGRADE ? 1U : 2U;
portEXIT_CRITICAL(&s_lock);
}
record(connection, RESULT, route, result, elapsed, epoch);
return result;
}
static void show(void)
{
connection_t connections[DIAG_SOCKETS];
trace_t counts = {0};
portENTER_CRITICAL(&s_lock);
memcpy(connections, s_connections, sizeof(connections));
occupancy(&counts);
uint64_t last = s_event_seq;
unsigned count = s_count;
bool enabled = s_enabled;
uint32_t overwritten = s_overwritten, unmatched = s_unmatched, lost = s_lost;
portEXIT_CRITICAL(&s_lock);
int64_t now = esp_timer_get_time();
printf("Web diagnostics %s; post-TLS occupancy=%u/6 ordinary=%u serial=%u admin=%u\n",
enabled ? "enabled" : "disabled", counts.ordinary + counts.serial + counts.admin,
counts.ordinary, counts.serial, counts.admin);
printf("snapshot_us=%" PRId64 " retained=%u/32 overwritten=%" PRIu32 " unmatched=%" PRIu32 " lost=%" PRIu32 "\n",
now, count, overwritten, unmatched, lost);
printf("No preaccept/TLS-failure timing; occupancy excludes in-progress TLS. rc is handler return, NOT HTTP status.\n");
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
connection_t c = connections[i];
if (c.seq) printf("live fd=%d conn=%" PRIu64 " kind=%u opened_us=%" PRId64 " age_us=%" PRId64 "\n",
c.fd, c.seq, c.kind, c.opened_us, now - c.opened_us);
}
printf("events: open/close/enter/result; routes: serial-ticket/admin-ticket/serial-upgrade/admin-upgrade; heap pairs free/largest internal,DMA,PSRAM bytes; stack=HTTPD minimum-free bytes\n");
const char *const events[] = {"open", "close", "enter", "result"};
const char *const routes[] = {"serial-ticket", "admin-ticket", "serial-upgrade", "admin-upgrade"};
/* Copy one immutable-ID-qualified row at a time; never hold a lock while printing.
* Concurrent overwrite/clear can omit rows, but cannot turn show into an endless stream. */
for (unsigned n = 0; n < count; ++n) {
uint64_t id = last - count + 1U + n;
trace_t row = {0};
portENTER_CRITICAL(&s_lock);
for (unsigned i = 0; i < DIAG_EVENTS; ++i)
if (s_events[i].id == id) { row = s_events[i]; break; }
portEXIT_CRITICAL(&s_lock);
if (!row.id) { printf("event=%" PRIu64 " no longer retained\n", id); continue; }
printf("event=%" PRIu64 " t_us=%" PRId64 " fd=%d conn=%" PRIu64 " %s %s rc=%d dt_us=%" PRId64
" occ=%u/%u/%u heap=%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 " stack=%" PRIu32 "\n",
row.id, row.at_us, row.fd, row.seq, events[row.event],
row.event < ENTER ? "-" : routes[row.route], row.result, row.elapsed_us,
row.ordinary, row.serial, row.admin,
row.free_bytes[0], row.largest[0], row.free_bytes[1], row.largest[1],
row.free_bytes[2], row.largest[2], row.stack_bytes);
}
}
int web_diagnostics_command(const char *action)
{
if (strcmp(action, "show") == 0) { show(); return 0; }
bool enable = strcmp(action, "enable") == 0;
bool disable = strcmp(action, "disable") == 0;
bool clear = strcmp(action, "clear") == 0;
if (!enable && !disable && !clear) return 1;
portENTER_CRITICAL(&s_lock);
/* Never wrap identity or capture epochs; exhausting diagnostics cannot affect HTTPD. */
if (s_epoch != UINT64_MAX) {
++s_epoch;
if (enable || disable) s_enabled = enable;
} else s_enabled = false;
if (clear) {
memset(s_events, 0, sizeof(s_events));
s_count = s_next = 0;
s_overwritten = s_unmatched = s_lost = 0;
}
bool enabled = s_enabled;
portEXIT_CRITICAL(&s_lock);
printf("Web diagnostics %s%s; live identities retained.\n", enabled ? "enabled" : "disabled",
clear ? ", trace cleared" : "");
return 0;
}
+18
View File
@@ -0,0 +1,18 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_https_server.h"
typedef enum {
WEB_DIAG_SERIAL_TICKET,
WEB_DIAG_ADMIN_TICKET,
WEB_DIAG_SERIAL_UPGRADE,
WEB_DIAG_ADMIN_UPGRADE,
} web_diag_route_t;
/* Synchronous HTTPD-owner callbacks only; no socket/context ownership transfer. */
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg);
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
esp_err_t (*handler)(httpd_req_t *));
/* Canonical console dispatcher only. No HTTPD calls or network waits. */
int web_diagnostics_command(const char *action);
+27 -4
View File
@@ -29,6 +29,7 @@
#include "web_session_store.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "web_diagnostics.h"
#include "web_ui.h"
#include "wifi_manager.h"
@@ -421,10 +422,15 @@ static const httpd_uri_t s_status_uri = {
.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 = ticket_handler,
.handler = traced_ticket_handler,
.user_ctx = NULL,
};
@@ -439,10 +445,25 @@ static esp_err_t websocket_handler(httpd_req_t *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 = websocket_handler,
.handler = traced_websocket_handler,
.user_ctx = NULL,
/* Authorize and admit before the adapter sends 101, not IDF's pre-handler path. */
.is_websocket = false,
@@ -452,13 +473,13 @@ static const httpd_uri_t s_websocket_uri = {
static const httpd_uri_t s_admin_ticket_uri = {
.uri = WEB_ADMIN_TICKET_URI,
.method = HTTP_POST,
.handler = web_admin_transport_ticket_handler,
.handler = traced_admin_ticket_handler,
};
static const httpd_uri_t s_admin_websocket_uri = {
.uri = WEB_ADMIN_WS_URI,
.method = HTTP_GET,
.handler = web_admin_transport_upgrade_handler,
.handler = traced_admin_upgrade_handler,
.is_websocket = false, /* Cookie/Origin/ticket/console admission precedes 101. */
};
@@ -606,6 +627,8 @@ esp_err_t web_server_start(void)
config.prvtkey_len = private_key_length;
config.port_secure = WEB_SERVER_PORT;
config.tls_handshake_timeout_ms = 5000U;
/* Public synchronous post-TLS observation; HTTPS retains all cleanup. */
config.user_cb = web_diagnostics_tls;
error = httpd_ssl_start(&server, &config);
}
secure_wipe(certificate, sizeof(certificate));
@@ -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;
}