Add Typed Admin Network Settings

This commit is contained in:
2026-09-08 20:57:27 +02:00
parent d9ac1319aa
commit 989821b7c4
31 changed files with 2568 additions and 62 deletions
+120 -20
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 21:
raise RuntimeError('Review URI extraction: expected 19 descriptors and two tables')
if len(uri_tables) != 24:
raise RuntimeError('Review URI extraction: expected 22 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
@@ -133,6 +133,26 @@ static void web_httpd_idle_stopped(httpd_handle_t s) {
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)
HANDLER(web_network_snapshot_handler) HANDLER(web_network_operation_handler)
static unsigned network_calls, network_allocations, network_fail_at;
static esp_err_t network_register(httpd_handle_t s, const httpd_uri_t *uri) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
++network_calls;
if (network_calls == 1) {
assert(!strcmp(uri->uri, "/api/settings/network") && uri->method == HTTP_GET);
assert(uri->handler == web_network_snapshot_handler);
} else {
assert(!strcmp(uri->uri, "/api/settings/network-operation"));
assert(uri->method == (network_calls == 2 ? HTTP_GET : HTTP_POST));
assert(uri->handler == web_network_operation_handler && network_calls <= 3);
}
/* Model the adapter's staged descriptor/name allocations, before publication. */
for (unsigned allocation = 0; allocation < 2; ++allocation)
if (++network_allocations == network_fail_at) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri;
return ESP_OK;
}
static unsigned keys_calls;
static bool keys_fail;
static unsigned account_calls, account_fail_at;
@@ -149,7 +169,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 24 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 27 && 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 == tls_session_callback);
@@ -187,11 +207,14 @@ static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
}
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
assert(uri->method == HTTP_GET);
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
return network_register(s, uri);
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
assert(!strcmp(uri->uri, "/api/settings/serial"));
return httpd_register_uri_handler(s, uri);
}
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
if (uri->handler == web_account_keys_handler) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/accounts/keys") && uri->method == HTTP_POST);
@@ -217,7 +240,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation")) && method == HTTP_GET));
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation")) && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
@@ -285,10 +308,14 @@ static void reset(void) {
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0;
network_calls = network_allocations = network_fail_at = 0;
account_calls = account_fail_at = generation_calls = keys_calls = 0;
generation_fail = keys_fail = false;
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void fresh_registration(void) {
registration_calls = registered_count = 0;
network_calls = network_allocations = 0;
}
static void start(void) {
assert(web_server_start() == ESP_OK);
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
@@ -301,6 +328,37 @@ static const httpd_uri_t *route(const char *uri) {
}
assert(found); return found;
}
static const httpd_uri_t *method_route(const char *uri, int method) {
const httpd_uri_t *found = NULL;
for (unsigned i = 0; i < registered_count; ++i)
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
assert(!found); found = registered[i];
}
return found;
}
static void network_complete(void) {
assert(network_calls == 3 && network_allocations == 6);
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
const httpd_uri_t *r = method_route("/api/settings/network-operation", method);
assert(r && r->handler == web_network_operation_handler);
}
}
static void other_domains_complete(void) {
assert(auth_live && ssl_live && serial_live && admin_owned && idle_owned);
assert(!auth_stops && !ssl_stops && !s_counters.start_failures);
assert(route("/api/session")->handler == web_cookie_auth_handler);
assert(route("/ws/serial")->handler == traced_websocket_handler);
assert(route("/ws/admin")->handler == traced_admin_upgrade_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
assert(method_route("/api/settings/serial-operation", method)->handler == web_serial_settings_handler);
assert(method_route("/api/settings/account-operation", method)->handler == web_account_settings_handler);
}
}
int main(void) {
reset(); mutex_fail = true;
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
@@ -320,7 +378,7 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 24 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 27 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
@@ -374,7 +432,7 @@ int main(void) {
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 22 && unregister_calls == failure - 17);
assert(registered_count == 25 && 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 == traced_websocket_handler);
@@ -383,13 +441,13 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 24 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 27 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 23);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 26);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
@@ -401,7 +459,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 24 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 27 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
@@ -423,7 +481,7 @@ int main(void) {
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 23);
assert(settings_calls == 1 && registered_count == 26);
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler);
@@ -431,7 +489,7 @@ int main(void) {
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 22 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 25 && operation_calls == failure && unregister_calls == failure - 1);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK);
@@ -439,7 +497,7 @@ int main(void) {
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
for (unsigned failure = 1; failure <= 3; ++failure) {
reset(); account_calls = 0; account_fail_at = failure; start();
assert(account_calls == failure && registered_count == (failure == 1 ? 21 : 22));
assert(account_calls == failure && registered_count == (failure == 1 ? 24 : 25));
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(auth_live && serial_live && admin_owned);
@@ -447,17 +505,17 @@ int main(void) {
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
assert(web_server_stop() == ESP_OK);
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
assert(registered_count == 24 && account_calls == 3);
assert(registered_count == 27 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 23 && auth_live && serial_live && admin_owned);
assert(registered_count == 26 && auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
reset(); generation_fail = true; start();
assert(generation_calls == 1 && registered_count == 23 && account_calls == 3);
assert(generation_calls == 1 && registered_count == 26 && account_calls == 3);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
@@ -469,12 +527,12 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
generation_fail = false; fresh_registration(); start();
assert(generation_calls == 2 && registered_count == 24);
assert(generation_calls == 2 && registered_count == 27);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
reset(); keys_fail = true; start();
assert(keys_calls == 1 && registered_count == 23 && account_calls == 3 && generation_calls == 1);
assert(keys_calls == 1 && registered_count == 26 && account_calls == 3 && generation_calls == 1);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
@@ -489,7 +547,7 @@ int main(void) {
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
keys_fail = false; fresh_registration(); start();
assert(keys_calls == 2 && registered_count == 24);
assert(keys_calls == 2 && registered_count == 27);
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
@@ -510,7 +568,49 @@ int main(void) {
assert(web_server_stop() == ESP_OK && !idle_owned && idle_stoppeds == 1);
fresh_registration(); start(); assert(web_server_stop() == ESP_OK);
puts("PASS idle submit fence failure forbids SSL destruction; failed stop retains ownership until retry/restart");
puts("18 lifecycle groups passed (16 required fatal positions, 10 optional routes, plus failed unregister)");
for (unsigned failure = 1; failure <= 6; ++failure) {
reset(); network_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(network_calls == failed_route && network_allocations == failure);
assert(registered_count == (failed_route == 1 ? 24 : 25));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/network-operation", HTTP_GET));
assert(!method_route("/api/settings/network-operation", HTTP_POST));
assert(!!method_route("/api/settings/network", HTTP_GET) == (failed_route != 1));
other_domains_complete();
assert(web_server_stop() == ESP_OK);
network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); network_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 26 && unregister_calls == 1);
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
assert(!method_route("/api/settings/network-operation", HTTP_POST));
other_domains_complete();
ssl_stop_error = ESP_FAIL;
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 27); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
for (unsigned failure = 0; failure < 8; ++failure) {
reset();
if (failure == 0) settings_fail = true;
else if (failure <= 2) operation_fail_at = failure;
else if (failure <= 5) account_fail_at = failure - 2;
else if (failure == 6) generation_fail = true;
else keys_fail = true;
start(); network_complete(); assert(web_server_stop() == ESP_OK);
}
puts("PASS every other settings route failure leaves the complete Network domain available");
puts("21 lifecycle groups passed (16 required fatal positions, 13 optional routes, all six Network allocation positions, plus failed unregister)");
return 0;
}
'''