Complete Phase 12 dual-stack networking

Add IPv6-aware Wi-Fi state, HTTPS/SSH listeners, mDNS service
reconciliation, and browser Wi-Fi administration.

Include a guarded build-local fix for mDNS 1.12.0 membership handling,
focused regression suites, and Phase 12 acceptance documentation.
This commit is contained in:
2026-09-20 22:35:34 +02:00
parent ece4ba77e3
commit 8902b25d78
52 changed files with 3042 additions and 163 deletions
+102 -4
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Compile production server lifecycle and URI tables against fixed host fakes.
No HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler is
executed. Assertions cover server orchestration and values passed to registration
No complete HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler
are executed. The Wi-Fi status JSON projection is compiled separately from its
production format and arguments. Assertions cover server orchestration and values passed to registration
and SSL-start fakes, not actual requests/101, socket eviction or concurrent stop.
No firmware build, network access or device operation. CC selects the compiler.
"""
import json
import os
from pathlib import Path
import re
@@ -91,6 +93,18 @@ static int mutex_storage, server_storage, locked;
static void esp_restart(void);
void web_lifecycle_settings_stopped(httpd_handle_t server);
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live, mutex_busy;
static bool https_available;
static unsigned https_publications, https_withdrawals;
static void mdns_service_set_https_available(bool available) {
assert(locked);
if (available) {
assert(auth_live && ssl_live && !https_available);
++https_publications;
} else {
++https_withdrawals;
}
https_available = available;
}
static void (*unlock_hook)(void);
static esp_err_t serial_detach_error;
static unsigned ssl_stop_fail_at;
@@ -271,7 +285,7 @@ static bool generation_fail;
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
static void web_cookie_auth_stop(void) { event('A'); ++auth_stops; auth_live = false; }
static void web_cookie_auth_stop(void) { assert(!https_available); event('A'); ++auth_stops; auth_live = false; }
static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t *lc,
uint8_t *key, size_t nk, size_t *lk) {
assert(!locked && auth_live && nc && nk); cert[0] = 1; key[0] = 2; *lc = *lk = 1; return ESP_OK;
@@ -416,6 +430,7 @@ static void reset(void) {
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
s_generation = 1U; mutex_busy = false; unlock_hook = NULL; serial_detach_error = ESP_OK;
ssl_stop_fail_at = 0;
https_available = false; https_publications = https_withdrawals = 0;
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
s_serial_transport_attached = s_admin_transport_owned = false;
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
@@ -451,6 +466,7 @@ static void start(void) {
assert(web_server_start() == ESP_OK);
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning && idle_owned);
assert(https_available);
}
static const httpd_uri_t *route(const char *uri) {
const httpd_uri_t *found = NULL;
@@ -515,6 +531,32 @@ static void other_domains_complete(void) {
}
}
int main(void) {
reset();
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
assert(!https_available && !https_publications && !https_withdrawals);
start();
assert(https_publications == 1 && !https_withdrawals);
assert(web_server_start() == ESP_ERR_INVALID_STATE);
assert(stop_server(s_generation + 1, false, false) == ESP_ERR_INVALID_STATE);
s_transitioning = true;
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
s_transitioning = false;
assert(https_available && https_publications == 1 && !https_withdrawals);
for (unsigned failure = 0; failure < 4; ++failure) {
idle_detach_error = failure == 0 ? ESP_ERR_TIMEOUT : ESP_OK;
admin_detach_error = failure == 1 ? ESP_ERR_TIMEOUT : ESP_OK;
serial_detach_error = failure == 2 ? ESP_FAIL : ESP_OK;
ssl_stop_error = failure == 3 ? ESP_FAIL : ESP_OK;
assert(web_server_stop() != ESP_OK);
assert(!https_available && https_publications == 1 && https_withdrawals == failure + 1);
assert(web_server_start() == ESP_ERR_INVALID_STATE && !https_available);
}
ssl_stop_error = ESP_OK;
assert(web_server_stop() == ESP_OK && !https_available && https_withdrawals == 5);
fresh_registration(); start();
assert(https_publications == 2 && https_withdrawals == 5);
assert(web_server_stop() == ESP_OK && !https_available && https_withdrawals == 6);
puts("PASS mDNS HTTPS publication under mutex, rejected calls unchanged, withdrawal before cookie stop through all teardown failures/retry/restart");
reset(); mutex_fail = true;
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
mutex_fail = false; serial_init_error = ESP_FAIL;
@@ -578,6 +620,7 @@ int main(void) {
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
assert(!https_available && !https_publications && https_withdrawals == 1);
}
puts("PASS required registration positions 1..17 fail fatally before transport attachment");
@@ -621,6 +664,7 @@ int main(void) {
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && s_server == SERVER && ssl_live);
assert(!s_admin_transport_owned && !admin_attaches && !auth_live);
assert(!https_available && !https_publications && https_withdrawals == 1);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "AH") && !admin_stoppeds);
@@ -866,7 +910,7 @@ int main(void) {
puts("PASS every other settings route failure leaves the complete Network domain available");
management_tests();
pipeline_tests();
puts("44 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups)");
puts("45 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups, 1 mDNS availability group)");
return 0;
}
'''
@@ -1049,6 +1093,60 @@ with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as direct
subprocess.run([str(executable)], check=True, timeout=15)
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
# Keep the production Wi-Fi JSON format and all its arguments together, without
# doubling every unrelated /api/status subsystem or claiming full HTTP coverage.
status = function('status_handler')
wifi_format = status[status.index(' " \\"wifi\\":'):status.index(' " \\"serial\\":')]
wifi_arguments = status[status.index(' wifi_available ? "true"'):status.index(' serial_config_available ?')].rstrip().removesuffix(',')
wifi_unit = r'''
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
static const char *wifi_manager_state_to_string(int state) {
assert(state == 1); return "connected";
}
int main(void) {
struct { bool ipv6_linklocal, ipv6_routable, ap_running;
int state, sta_rssi; unsigned sta_channel, ap_client_count; } wifi = {
.state = 1, .sta_rssi = -42, .sta_channel = 6,
.ap_running = true, .ap_client_count = 2 };
const char *ipv4 = "192.0.2.1";
char response[512];
for (unsigned available = 0; available < 2; ++available)
for (unsigned flags = 0; flags < 4; ++flags) {
bool wifi_available = available;
wifi.ipv6_linklocal = flags & 1;
wifi.ipv6_routable = flags & 2;
int written = snprintf(response, sizeof(response),
''' + wifi_format + ',\n' + wifi_arguments + r''');
assert(written > 0 && (size_t)written < sizeof(response));
fputs(response, stdout);
}
return 0;
}
'''
with tempfile.TemporaryDirectory(prefix='web-status-wifi-') as directory:
temporary = Path(directory)
(temporary / 'status.c').write_text(wifi_unit)
executable = temporary / 'status'
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
str(temporary / 'status.c'), '-o', str(executable)], check=True, timeout=30)
result = subprocess.run([str(executable)], check=True, capture_output=True, text=True, timeout=15)
rows = result.stdout.splitlines()
assert len(rows) == 8
for index, row in enumerate(rows):
wifi = json.loads('{' + row.rstrip().removesuffix(',') + '}')['wifi']
available, flags = divmod(index, 4)
assert wifi == {
'available': bool(available), 'state': 'connected' if available else 'unavailable',
'sta_ipv4': '192.0.2.1', 'ipv6_linklocal': bool(available and flags & 1),
'ipv6_routable': bool(available and flags & 2), 'rssi': -42 if available else 0,
'channel': 6 if available else 0, 'ap_running': bool(available),
'ap_clients': 2 if available else 0,
}
assert type(wifi['ipv6_linklocal']) is bool and type(wifi['ipv6_routable']) is bool
print('PASS production /api/status Wi-Fi JSON projection: eight availability/IPv6 combinations, native booleans and unavailable masking')
# Second executable links the same production server functions to the COMPLETE
# security implementation and real mbedTLS. Only NVS/HTTPD/scheduler are doubles.
import ast