Add admin serial settings view

This commit is contained in:
2026-09-07 20:12:33 +02:00
parent c73674cda2
commit 5a2aa0d4d8
20 changed files with 612 additions and 25 deletions
+21
View File
@@ -15,3 +15,24 @@ Coverage includes challenge reuse/consumption/expiry, capacities without evictio
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency remain M1 target gates. No sanitizer or runtime memory-reserve result is implied.
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
## Read-only Serial Settings
```sh
python3 tests/web_cookie_auth/run.py --settings
```
Runs the existing auth/store suite plus five 8D.8 groups. Compiles exact extracted
production server handler/helpers, serial snapshot getter and enum formatters,
with the real cookie/store/parser/private adapter. Serial locking/state and HTTP
IO are doubled; authorization is not. Exercises normal-role/stale/expired/revoked
denial, DB failure, body/query/method/header/framing/Origin rejection before any
serial read, working values, zero-wait busy/uninitialized failure, no-store and
header/send errors. Adapter-only allocator substitution injects both staged
registration failures; the installed IDF unregister function frees successful
registration. No SDK files are modified. Lifecycle registration/optional failure
orchestration is separately tested by `tests/web_admin_transport/server_lifecycle.py`.
This does not run the full serial task/UART driver, TLS/network dispatcher or a real
browser. Target comparison with UART0 and runtime memory/stack validation remain
pending in `docs/phase8d8_implementation.md`; prior M2 signoff remains accepted.
+28 -1
View File
@@ -37,11 +37,13 @@ struct sock_db { bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *);
struct httpd_req_aux { struct sock_db *sd; char *scratch; size_t scratch_cur_size, remaining_len;
unsigned req_hdrs_count, resp_hdrs_count; bool ws_handshake_detect;
struct resp_hdr { const char *field, *value; } *resp_hdrs; };
struct httpd_data { struct { unsigned max_resp_headers; } config; };
struct httpd_data { struct { unsigned max_resp_headers, max_uri_handlers; void *uri_match_fn; } config;
httpd_uri_t **hd_calls; };
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
"""
admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
if admin:
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
HEADERS["esp_heap_caps.h"] = """#pragma once
@@ -73,8 +75,10 @@ extracted = """
#pragma GCC diagnostic ignored "-Wsign-compare"
#include <string.h>
#include <strings.h>
#include <stdlib.h>
#include "esp_httpd_priv.h"
#define ESP_LOGD(...) ((void)0)
#define ESP_LOGW(...) ((void)0)
#define MIN(a,b) ((a) < (b) ? (a) : (b))
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
static size_t strlcpy(char *d, const char *s, size_t n) {
@@ -88,6 +92,9 @@ for name in ["httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str"]:
extracted += function(txrx[txrx.index("esp_err_t httpd_resp_set_hdr"):], "httpd_resp_set_hdr")
extracted += "\n" + function(txrx[txrx.index("static size_t httpd_recv_pending"):], "httpd_recv_pending")
extracted += "\nsize_t host_read_pending(httpd_req_t *r, char *out, size_t n) { return httpd_recv_pending(r, out, n); }\n"
if settings:
uri_source = (idf / 'components/esp_http_server/src/httpd_uri.c').read_text()
extracted += '\n' + function(uri_source[uri_source.index('esp_err_t httpd_unregister_uri_handler'):], 'httpd_unregister_uri_handler')
with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
tmp = pathlib.Path(directory)
@@ -96,12 +103,32 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
(tmp / "installed_httpd.c").write_text(extracted)
if settings:
# Compile exact production handler/helpers and nonblocking snapshot body.
server_source = (ROOT / 'src/web_server.c').read_text()
service_source = (ROOT / 'src/serial_service.c').read_text()
config_source = (ROOT / 'src/serial_config.c').read_text()
settings_source = function(service_source, 'serial_service_get_snapshot') + '\n'
for name in ('data_bits', 'parity', 'stop_bits', 'flow_control', 'dtr_behavior'):
settings_source += function(config_source[config_source.index('const char *serial_config_' + name + '_to_string'):], 'serial_config_' + name + '_to_string') + '\n'
for name in ('set_common_headers', 'send_plain_error', 'authorize_or_respond', 'safe_string', 'serial_settings_handler'):
settings_source += function(server_source, name) + '\n'
(tmp / 'settings_production.h').write_text(settings_source)
sources = [HERE / "test.c", tmp / "installed_httpd.c"]
sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]]
if admin:
sources += [ROOT / "src" / name for name in ["web_admin_tickets.c", "web_admin_transport.c"]]
if settings:
adapter = ROOT / 'src/web_httpd_adapter.c'
sources.remove(adapter)
subprocess.run(['cc', '-std=c11', '-D_GNU_SOURCE', '-Wall', '-Wextra', '-Werror',
'-Dmalloc=settings_malloc', '-Dfree=settings_free',
'-I' + str(tmp), '-I' + str(ROOT / 'src'), '-c', str(adapter),
'-o', str(tmp / 'adapter.o')], check=True, timeout=30)
sources.append(tmp / 'adapter.o')
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
*(["-DHOST_ADMIN"] if admin else []),
*(["-DHOST_SETTINGS"] if settings else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
+154
View File
@@ -0,0 +1,154 @@
/* Exact production snapshot/HTTP bodies, real auth/store/adapter, fake UART mutex. */
#include <inttypes.h>
#include <stdatomic.h>
#include <sys/types.h>
#include <stdlib.h>
#include "serial_service.h"
#include "web_server.h"
#define pdTRUE 1
#define ESP_ERR_TIMEOUT 0x107
static bool s_initialized, serial_busy, serial_locked;
static atomic_bool s_running;
static serial_config_t s_config;
static int s_state_mutex;
static unsigned serial_takes;
static web_server_counters_t s_counters;
static unsigned allocations, allocation_fail_at, frees;
static httpd_uri_t *routes[2];
void *settings_malloc(size_t size) {
assert(routes[1] == NULL); /* Neither allocation may publish partial state. */
if (++allocations == allocation_fail_at) return NULL;
void *value = malloc(size); assert(value); memset(value, 0xa5, size); return value;
}
void settings_free(void *value) { ++frees; free(value); }
esp_err_t httpd_unregister_uri_handler(httpd_handle_t, const char *, httpd_method_t);
static int xSemaphoreTake(int mutex, unsigned wait) {
(void)mutex; assert(wait == 0 && !serial_locked && !host_lock_depth);
++serial_takes;
if (serial_busy) return 0;
serial_locked = true; return pdTRUE;
}
static void xSemaphoreGive(int mutex) { (void)mutex; assert(serial_locked); serial_locked = false; }
static void increment_counter(uint64_t *counter) { ++*counter; }
esp_err_t httpd_resp_send(httpd_req_t *r, const char *body, int length) {
assert(!serial_locked && length >= 0 && length < 256 && (size_t)length == strlen(body));
return httpd_resp_sendstr(r, body);
}
#include "settings_production.h"
static void settings_begin(const issued_t *identity) {
begin("/api/settings/serial", HTTP_GET, NULL);
add("Host", "device.example"); /* GET may omit Origin, but Host is validated. */
if (identity) {
char cookies[100];
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", identity->token);
add("Cookie", cookies);
}
}
static void settings_expect(const char *status) {
unsigned before = serial_takes;
(void)serial_settings_handler(&req);
if (strcmp(response_status, status) || serial_takes != before)
fprintf(stderr, "Settings expected %s, got %s; serial reads %u\n", status, response_status, serial_takes - before);
assert(!strcmp(response_status, status) && serial_takes == before);
assert(strlen(output) < 128 && !body_offset);
zero(scratch, sizeof(scratch));
}
static void settings_tests(void) {
httpd_uri_t existing = {.uri = "/", .method = HTTP_GET, .handler = serial_settings_handler};
httpd_uri_t route = {.uri = "/api/settings/serial", .method = HTTP_GET, .handler = serial_settings_handler};
routes[0] = &existing; server.hd_calls = routes; server.config.max_uri_handlers = 2;
for (unsigned failure = 1; failure <= 2; ++failure) {
allocations = frees = 0; allocation_fail_at = failure;
assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_NO_MEM);
assert(allocations == failure && frees == failure - 1 && routes[0] == &existing && !routes[1]);
}
allocation_fail_at = 0; allocations = 0;
assert(web_httpd_register_optional_get(&server, &route) == ESP_OK && allocations == 2);
assert(routes[1] && routes[1] != &route && routes[1]->uri != route.uri);
assert(!strcmp(routes[1]->uri, route.uri) && routes[1]->handler == route.handler);
assert(web_httpd_register_optional_get(&server, &route) == ESP_ERR_INVALID_STATE);
httpd_uri_t other = route; other.uri = "/other";
assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_NO_MEM);
assert(httpd_unregister_uri_handler(&server, route.uri, HTTP_GET) == ESP_OK && !routes[1]);
assert(routes[0] == &existing); /* Actual installed IDF frees staged allocations. */
for (unsigned mode = 0; mode < 5; ++mode) {
other = route; char oversized[129]; memset(oversized, 'x', 128); oversized[128] = 0;
if (mode == 0) other.method = HTTP_POST;
if (mode == 1) other.is_websocket = true;
if (mode == 2) other.supported_subprotocol = "test";
if (mode == 3) other.uri = oversized;
if (mode == 4) server.config.uri_match_fn = &existing;
assert(web_httpd_register_optional_get(&server, &other) == ESP_ERR_INVALID_ARG && !routes[1]);
server.config.uri_match_fn = NULL;
}
assert(web_httpd_register_optional_get(NULL, &route) == ESP_ERR_INVALID_ARG);
assert(web_httpd_register_optional_get(&server, NULL) == ESP_ERR_INVALID_ARG);
puts("PASS Settings registration: both allocation failures leave table intact, duplicate/full/shape bounds, installed IDF unregister frees successful ownership");
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob);
s_initialized = true; s_running = true;
s_config = (serial_config_t){.version = SERIAL_CONFIG_VERSION, .baud_rate = 230400,
.data_bits = SERIAL_CONFIG_DATA_BITS_8, .parity = SERIAL_CONFIG_PARITY_NONE,
.stop_bits = SERIAL_CONFIG_STOP_BITS_1, .flow_control = SERIAL_CONFIG_FLOW_CONTROL_RTS_CTS,
.dtr_behavior = SERIAL_CONFIG_DTR_ON_CONNECT, .rts_threshold = 96};
serial_service_snapshot_t value;
assert(serial_service_get_snapshot(NULL) == ESP_ERR_INVALID_ARG);
s_initialized = false; memset(&value, 0xa5, sizeof(value));
assert(serial_service_get_snapshot(&value) == ESP_ERR_INVALID_STATE); zero(&value, sizeof(value));
s_initialized = true; serial_busy = true;
assert(serial_service_get_snapshot(&value) == ESP_ERR_TIMEOUT); zero(&value, sizeof(value));
serial_busy = false;
assert(serial_service_get_snapshot(&value) == ESP_OK && value.running);
assert(!memcmp(&value.config, &s_config, sizeof(s_config)) && !serial_locked);
puts("PASS Settings snapshot: exact nonblocking production body, failure clearing and lock-consistent copy");
settings_begin(NULL); settings_expect("401 Unauthorized");
settings_begin(&user); settings_expect("403 Forbidden");
for (unsigned mode = 0; mode < 7; ++mode) {
settings_begin(&admin);
if (mode == 0) req.uri = "/api/settings/serial?unknown=1";
if (mode == 1) req.content_len = aux.remaining_len = 1000000;
if (mode == 2) req.method = HTTP_POST;
if (mode == 3) add("Host", "device.example");
if (mode == 4) add("Transfer-Encoding", "chunked");
if (mode == 5) add("Origin", "https://elsewhere.example");
if (mode == 6) add("Origin", "null");
settings_expect(mode < 5 ? "400 Bad Request" : "403 Forbidden");
}
stale_user = alice.user_id; settings_begin(&admin); settings_expect("401 Unauthorized"); stale_user = 0;
admin = mint(&alice);
db_fail = true; settings_begin(&admin); settings_expect("503 Service Unavailable"); db_fail = false;
admin = mint(&alice);
puts("PASS Settings HTTP: unauthenticated/user/stale/DB failure and query/body/method/duplicate/framing/Origin denied before serial read");
settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK);
assert(!strcmp(output, "{\"running\":true,\"baud\":230400,\"data_bits\":\"8\",\"parity\":\"none\",\"stop_bits\":\"1\",\"flow\":\"rts-cts\",\"dtr\":\"on-connect\",\"rts_threshold\":96}"));
assert(aux.resp_hdrs_count == 3 && !strcmp(response_headers[0].value, "no-store"));
zero(scratch, sizeof(scratch));
s_running = false; s_config.baud_rate = 1000000; s_config.data_bits = SERIAL_CONFIG_DATA_BITS_7;
s_config.parity = SERIAL_CONFIG_PARITY_ODD; s_config.stop_bits = SERIAL_CONFIG_STOP_BITS_2;
s_config.flow_control = SERIAL_CONFIG_FLOW_CONTROL_NONE;
s_config.dtr_behavior = SERIAL_CONFIG_DTR_ACTIVE; s_config.rts_threshold = 127;
settings_begin(&admin); assert(serial_settings_handler(&req) == ESP_OK);
assert(!strcmp(output, "{\"running\":false,\"baud\":1000000,\"data_bits\":\"7\",\"parity\":\"odd\",\"stop_bits\":\"2\",\"flow\":\"none\",\"dtr\":\"active\",\"rts_threshold\":127}"));
for (unsigned mode = 0; mode < 2; ++mode) {
settings_begin(&admin); serial_busy = mode == 0; s_initialized = mode == 0;
assert(serial_settings_handler(&req) == ESP_OK && !strcmp(response_status, "503 Service Unavailable"));
assert(!strcmp(response_headers[0].field, "Retry-After") && !strcmp(response_headers[0].value, "1"));
}
serial_busy = false; s_initialized = true;
settings_begin(&admin); send_fail = true; unsigned before = sends;
assert(serial_settings_handler(&req) == ESP_FAIL && sends == before + 1); send_fail = false;
for (unsigned limit = 0; limit < 3; ++limit) {
settings_begin(&admin); server.config.max_resp_headers = limit; before = sends;
assert(serial_settings_handler(&req) != ESP_OK && sends == before);
}
server.config.max_resp_headers = 8;
puts("PASS Settings HTTP: exact typed working values, no-store, bounded busy/unavailable, header/send failure without second response");
web_session_store_invalidate(admin.view.id);
settings_begin(&admin); settings_expect("401 Unauthorized");
settings_begin(&user); settings_expect("403 Forbidden");
admin = mint(&alice); now += WEB_SESSION_STORE_LIFETIME_US;
settings_begin(&admin); settings_expect("401 Unauthorized");
puts("PASS Settings HTTP: originating-session invalidation, unrelated user isolation and absolute expiry");
}
+6
View File
@@ -119,6 +119,9 @@ static void auth_reset(void) {
#ifdef HOST_ADMIN
#include "admin_test.c"
#endif
#ifdef HOST_SETTINGS
#include "settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -266,6 +269,9 @@ int main(void) {
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
#ifdef HOST_ADMIN
admin_tests();
#endif
#ifdef HOST_SETTINGS
settings_tests();
#endif
return 0;
}