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
@@ -0,0 +1,225 @@
/* Real HTTP policy, store, parser and backend; deterministic owner boundaries.
* Real mutex-local Wi-Fi/mDNS behavior is covered in tests/web_network_settings. */
#include "../../src/web_network_settings.c"
#include "network_parse_production.h"
static bool dispatcher, queue_fail, snapshot_fail, timer_create_fail, timer_start_fail;
static unsigned mutations, projections, timer_creates, timer_starts;
static uint32_t queued;
static esp_err_t owner_error, mdns_queue_error;
static bool stored_mdns = true;
static void (*timer_callback)(void *);
static void (*owner_hook)(void);
static void (*queue_hook)(void);
static unsigned expected_action;
static void input_wiped(void) {
zero(&s_operation.patch,sizeof(s_operation.patch));
zero(&s_operation.mdns,sizeof(s_operation.mdns));
zero(&s_operation.principal,sizeof(s_operation.principal));
assert(!s_operation.generation);
}
int esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *out) {
assert(!host_lock_depth); ++timer_creates;
if (timer_create_fail) return ESP_FAIL;
timer_callback=args->callback; *out=(void *)1; return ESP_OK;
}
int esp_timer_start_periodic(esp_timer_handle_t handle,uint64_t period) {
assert(handle && period==1000000 && !host_lock_depth); ++timer_starts;
return timer_start_fail ? ESP_FAIL : ESP_OK;
}
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id) {
assert(!dispatcher && !host_lock_depth && id);
if(queue_hook) { void (*hook)(void)=queue_hook; queue_hook=NULL; hook(); }
if(queue_fail) return ESP_ERR_TIMEOUT;
queued=id; return ESP_OK;
}
static esp_err_t owner(unsigned action) {
assert(dispatcher && !host_lock_depth && action==expected_action && s_operation.executing);
input_wiped(); ++mutations;
if(owner_hook) { void (*hook)(void)=owner_hook; owner_hook=NULL; hook(); }
return owner_error;
}
esp_err_t wifi_manager_patch_current(uint32_t generation,const wifi_manager_patch_t *patch) {
assert(generation==7 && patch->fields);
if(expected_action==PROFILE_PATCH) assert(patch->profile==0); else assert(patch->profile==-1);
return owner(expected_action);
}
esp_err_t wifi_manager_save_current(uint32_t generation) { assert(generation==7); return owner(WIFI_SAVE); }
esp_err_t wifi_manager_load_current(uint32_t generation) { assert(generation==7); return owner(WIFI_LOAD); }
esp_err_t wifi_manager_start(void) { return owner(START); }
esp_err_t wifi_manager_stop(void) { return owner(STOP); }
esp_err_t wifi_manager_reconnect(void) { return owner(RECONNECT); }
esp_err_t wifi_manager_next_profile(void) { return owner(NEXT_PROFILE); }
esp_err_t wifi_manager_mdns_reannounce(void) { assert(dispatcher); return mdns_queue_error; }
esp_err_t mdns_service_update_current(uint32_t generation,mdns_settings_action_t action,const mdns_config_t *config,bool *stored) {
assert(generation==7 && action==(mdns_settings_action_t)(expected_action-MDNS_SET));
if(action==MDNS_SETTINGS_SET) assert(mdns_config_validate(config)==ESP_OK);
*stored=stored_mdns; return owner(expected_action);
}
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *out) {
assert(!dispatcher && !host_lock_depth); ++projections;
if(snapshot_fail) return ESP_ERR_TIMEOUT;
memset(out,0,sizeof(*out)); out->runtime.config_generation=UINT32_MAX;
out->ap_ssid_len=32; memset(out->ap_ssid,0xff,32); out->ap_policy=WIFI_CONFIG_AP_POLICY_FALLBACK;
out->ap_channel=11; out->ap_password_configured=true;
for(unsigned i=0;i<4;++i) {
out->profiles[i].ssid_len=32; memset(out->profiles[i].ssid,0xff,32);
out->profiles[i].priority=255; out->profiles[i].security=WIFI_CONFIG_SECURITY_MIXED;
}
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
memset(&out->runtime.ip,255,4); out->runtime.ap_client_count=255;
return ESP_OK;
}
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
assert(!dispatcher && !host_lock_depth); memset(out,0,sizeof(*out));
out->config_generation=UINT32_MAX; out->last_error=INT32_MIN;
memset(out->suffix,'s',55); strcpy(out->hostname,"sak-"); memset(out->hostname+4,'s',55);
return ESP_OK;
}
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { (void)state; return "waiting-ip"; }
static void network_begin(const issued_t *identity,const char *body) {
begin("/api/settings/network-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
if(body) add("Content-Type","application/json");
if(identity) {
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",identity->token); add("Cookie",cookie);
if(body) add("X-CSRF-Token",identity->view.csrf);
}
}
static void network_expect(const char *status,bool snapshot_route) {
unsigned before=mutations;
esp_err_t result=snapshot_route ? web_network_snapshot_handler(&req) : web_network_operation_handler(&req);
assert(result==((send_fail || aux.remaining_len || fail_header)?ESP_FAIL:ESP_OK));
if(strcmp(status,response_status)) fprintf(stderr,"expected %s got %s (%s)\n",status,response_status,output);
assert(!strcmp(status,response_status) && mutations==before);
assert(!strstr(output,"supersecret") && !strstr(output,"psk") && !strstr(output,"password_len"));
zero(scratch,sizeof(scratch));
bool no_store=false;
for(unsigned i=0;i<aux.resp_hdrs_count;++i)
if(!strcmp(response_headers[i].field,"Cache-Control") && !strcmp(response_headers[i].value,"no-store")) no_store=true;
if(!fail_header) assert(no_store);
}
static const char patch_body[]="{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"password\":\"supersecret\"}";
static void submit_network(const issued_t *identity,const char *body) {
network_begin(identity,body); network_expect("202 Accepted",false);
expected_action=s_operation.action;
}
static void execute_network(void) { dispatcher=true; web_network_settings_execute(queued); dispatcher=false; }
static void expire_queued(void) { now=s_operation.deadline; timer_callback(NULL); }
static void executing_tick(void) {
assert(s_operation.executing); input_wiped(); now=s_operation.deadline;
timer_callback(NULL); assert(s_operation.state==PENDING);
}
static bool parsed(const char *body) {
network_operation_t op={0}; bool valid=parse_request(body,strlen(body),&op); secure_wipe(&op,sizeof(op)); return valid;
}
static void network_settings_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob); receive_fragment=768;
network_begin(NULL,NULL); network_expect("401 Unauthorized",false);
network_begin(&user,NULL); network_expect("403 Forbidden",false);
network_begin(&user,patch_body); network_expect("403 Forbidden",false);
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strlen(output)<2048 && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
printf("PASS Network maximum escaped snapshot: %zu bytes, no secret fields\n",strlen(output));
snapshot_fail=true; network_begin(&admin,NULL); network_expect("503 Service Unavailable",true); snapshot_fail=false;
network_begin(&user,NULL); network_expect("403 Forbidden",true);
network_begin(&admin,NULL); req.uri="/api/settings/network?secret=x"; network_expect("400 Bad Request",true);
network_begin(&admin,patch_body); req.uri="/api/settings/network-operation?x=1"; network_expect("400 Bad Request",false);
network_begin(&admin,patch_body); add("X-CSRF-Token",admin.view.csrf); network_expect("400 Bad Request",false);
network_begin(&admin,patch_body); add("Origin",origin); network_expect("400 Bad Request",false);
begin("/api/settings/network-operation",HTTP_POST,patch_body); same_origin(); add("Content-Type","application/json");
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",admin.token); add("Cookie",cookie);
network_expect("403 Forbidden",false);
network_begin(&admin,NULL); req.content_len=1; aux.remaining_len=1; network_expect("400 Bad Request",false);
char oversized[770]; memset(oversized,' ',769); oversized[769]=0;
network_begin(&admin,oversized); network_expect("400 Bad Request",false);
puts("PASS Network current-admin, CSRF, duplicate headers, body/query bounds, no-store and zero snapshot writes");
const char *invalid[]={"{}", "{\"action\":\"defaults\"}", "{\"action\":\"reset\"}",
"{\"action\":\"start\",\"generation\":7}", "{\"action\":\"wifi-save\",\"generation\":0}",
"{\"action\":\"wifi-save\",\"generation\":4294967296}", "{\"action\":\"wifi-save\",\"generation\":07}",
"{\"action\":\"wifi-save\",\"generation\":7.0}", "{\"action\":\"wifi-patch\",\"generation\":7}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"enabled\":true}",
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":4,\"enabled\":true}",
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"priority\":256}",
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"enabled\":1}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"short\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"supersecret\",\"clear_password\":true}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"clear_password\":false}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"\\u0100\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"\\ud800\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"é\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"ap_policy\":\"off\\u0000x\"}",
"{\"action\":\"wifi-patch\",\"generation\":7,\"channel\":0}",
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"-bad\"}",
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"BAD\"}",
"{\"action\":\"start\",\"action\":\"stop\"}", "{\"action\":\"start\",}",
"{\"action\":\"start\",\"unknown\":{}}", "{\"action\":\"start\"}x"};
for(unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
assert(!parsed(invalid[i])); network_begin(&admin,invalid[i]); network_expect("400 Bad Request",false);
}
/* Every possible byte survives the codec; 32 decoded bytes, not JSON length. */
for(unsigned byte=0;byte<256;++byte) {
uint8_t raw=(uint8_t)byte; char encoded[16]; size_t used=0;
assert(append_ssid(encoded,sizeof(encoded),&used,&raw,1));
parser_t p={.body=encoded,.size=used}; uint8_t decoded[1]; size_t n;
assert(byte_string(&p,decoded,1,&n) && n==1 && decoded[0]==raw && p.pos==used);
}
char body[768], ssid[199];
for(unsigned n=31;n<=33;++n) {
for(unsigned i=0;i<n;++i) memcpy(ssid+i*6,"\\u0000",6);
ssid[n*6]=0;
snprintf(body,sizeof(body),"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"%s\"}",ssid);
assert(parsed(body)==(n<=32));
}
for(unsigned n=7;n<=64;++n) {
char pass[385]; for(unsigned i=0;i<n;++i) memcpy(pass+i*6,"\\u0022",6); pass[n*6]=0;
snprintf(body,sizeof(body),"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"%s\"}",pass);
assert(parsed(body)==(n>=8 && n<=63));
}
receive_fragment=1; network_begin(&admin,patch_body); network_expect("400 Bad Request",false); receive_fragment=768;
recv_fail=true; network_begin(&admin,patch_body); network_expect("400 Bad Request",false); recv_fail=false;
puts("PASS Network strict parser, 256-byte SSID roundtrip, 32-byte SSID and 8..63 password bounds, four receives");
timer_create_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); timer_create_fail=false;
timer_start_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); timer_start_fail=false;
submit_network(&admin,patch_body); assert(s_operation.patch.password_len==11 && timer_creates==2 && timer_starts==2);
network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false);
network_begin(&user,NULL); network_expect("403 Forbidden",false);
issued_t other=mint(&alice); network_begin(&other,NULL); network_expect("200 OK",false); assert(strstr(output,"\"state\":\"idle\""));
uint32_t old=queued; unsigned before=mutations;
now=s_operation.deadline-1; timer_callback(NULL); assert(s_operation.state==PENDING);
++now; timer_callback(NULL); assert(s_operation.state==CANCELLED); input_wiped();
submit_network(&admin,patch_body); dispatcher=true; web_network_settings_execute(old); dispatcher=false;
assert(s_operation.state==PENDING && mutations==before); execute_network(); assert(s_operation.state==ACCEPTED); input_wiped();
network_begin(&admin,NULL); network_expect("200 OK",false); assert(strstr(output,"\"state\":\"accepted\""));
queue_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); queue_fail=false;
zero(&s_operation,sizeof(s_operation));
queue_hook=expire_queued; submit_network(&admin,patch_body); assert(s_operation.state==CANCELLED); before=mutations; execute_network(); assert(mutations==before);
submit_network(&admin,patch_body); db_hook=executing_tick; execute_network(); assert(s_operation.state==CANCELLED && mutations==before);
submit_network(&admin,patch_body); owner_hook=executing_tick; execute_network(); assert(s_operation.state==ACCEPTED); input_wiped();
submit_network(&admin,patch_body); web_session_store_invalidate(admin.view.id); execute_network(); assert(s_operation.state==CANCELLED); input_wiped();
admin=mint(&alice); submit_network(&admin,patch_body); stale_user=alice.user_id; execute_network(); stale_user=0; assert(s_operation.state==CANCELLED);
admin=mint(&alice); submit_network(&admin,patch_body); db_fail=true; execute_network(); db_fail=false; assert(s_operation.state==CANCELLED);
puts("PASS Network timer failure/expiry, old IDs, session isolation, executing reservation, logout/revocation/deadline and wiped shared input");
auth_reset(); admin=mint(&alice);
const char *bodies[]={"{\"action\":\"wifi-patch\",\"generation\":7,\"ap_policy\":\"always\"}",patch_body,
"{\"action\":\"wifi-save\",\"generation\":7}","{\"action\":\"wifi-load\",\"generation\":7}",
"{\"action\":\"start\"}","{\"action\":\"stop\"}","{\"action\":\"reconnect\"}","{\"action\":\"next-profile\"}",
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"new-name\"}","{\"action\":\"mdns-save\",\"generation\":7}",
"{\"action\":\"mdns-load\",\"generation\":7}","{\"action\":\"mdns-defaults\",\"generation\":7}"};
for(unsigned i=0;i<ACTION_COUNT;++i) {
submit_network(&admin,bodies[i]); assert(expected_action==i); execute_network();
assert(s_operation.state==((i==WIFI_SAVE || i==MDNS_SAVE)?OK:ACCEPTED));
}
const esp_err_t errors[]={ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_ARG,ESP_ERR_TIMEOUT,ESP_FAIL};
const unsigned states[]={STALE,INVALID,FAILED,FAILED};
for(unsigned i=0;i<4;++i) {
owner_error=errors[i]; submit_network(&admin,patch_body); execute_network(); assert(s_operation.state==states[i]); input_wiped();
}
owner_error=ESP_OK; stored_mdns=false; submit_network(&admin,bodies[MDNS_LOAD]); execute_network(); assert(s_operation.state==LOADED_DEFAULTS);
mdns_queue_error=ESP_ERR_TIMEOUT; submit_network(&admin,bodies[MDNS_SET]); execute_network(); assert(s_operation.state==APPLIED_NOT_QUEUED); mdns_queue_error=ESP_OK;
send_fail=true; submit_network(&admin,patch_body); send_fail=false; execute_network(); assert(s_operation.state==ACCEPTED);
s_next_id=UINT32_MAX; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false);
puts("PASS Network all typed actions, owner queue/NVS error mapping, truthful async status, mDNS partial apply, lost ACK and nonwrapping IDs");
}
+17
View File
@@ -51,6 +51,17 @@ admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
network = "--network" in sys.argv
if network:
HEADERS["esp_wifi_types.h"] = "#pragma once\ntypedef int wifi_auth_mode_t;\n"
HEADERS["esp_err.h"] += "\n#define ESP_ERR_TIMEOUT 0x107\n"
HEADERS["esp_timer.h"] += """
#include <stdbool.h>
typedef void *esp_timer_handle_t;
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
"""
if accounts:
HEADERS["mbedtls/base64.h"] = """
#pragma once
@@ -167,6 +178,11 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
settings_source += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
(tmp / 'settings_production.h').write_text(settings_source)
if network:
wifi_source = (ROOT / 'src/wifi_config.c').read_text()
mdns_source = (ROOT / 'src/mdns_config.c').read_text()
names = ['wifi_config_parse_ap_policy', 'wifi_config_ap_policy_to_string', 'wifi_config_parse_security', 'wifi_config_security_to_string']
(tmp / 'network_parse_production.h').write_text('\n'.join(function(wifi_source, name) for name in names) + '\n' + '\n'.join(function(mdns_source, name) for name in ('suffix_character_is_valid', 'mdns_config_validate')))
if accounts:
db_source = (ROOT / 'src/user_database.c').read_text()
alphabet_start = db_source.index('static const uint8_t s_generated_alphabet')
@@ -196,6 +212,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
*(["-DHOST_SETTINGS"] if settings else []),
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
*(["-DHOST_ACCOUNTS"] if accounts else []),
*(["-DHOST_NETWORK"] if network 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)
+12 -1
View File
@@ -18,7 +18,12 @@
static struct httpd_data server = {.config.max_resp_headers = 8};
static struct sock_db socket_state;
static struct resp_hdr response_headers[8];
static char scratch[1024], output[1024], cookie_values[2][200];
static char scratch[1024], cookie_values[2][200];
#ifdef HOST_NETWORK
static char output[2048];
#else
static char output[1024];
#endif
static const char *request_body;
static size_t body_offset;
static unsigned password_calls, cookie_count, sends, upgrades;
@@ -135,6 +140,9 @@ static void auth_reset(void) {
#ifdef HOST_ACCOUNTS
#include "account_settings_test.c"
#endif
#ifdef HOST_NETWORK
#include "network_settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -291,6 +299,9 @@ int main(void) {
#endif
#ifdef HOST_ACCOUNTS
account_settings_tests();
#endif
#ifdef HOST_NETWORK
network_settings_tests();
#endif
return 0;
}