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
+80
View File
@@ -0,0 +1,80 @@
static admin_ssh_console_token_t token = {
.slot_index=0, .session_id=7, .slot_generation=1,
.transport=ADMIN_CONSOLE_TRANSPORT_WEB,
};
static user_principal_t admin = {.role=USER_ROLE_ADMIN};
static bool live=true;
static unsigned scenario;
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
{ assert(!lock_depth && t->transport==ADMIN_CONSOLE_TRANSPORT_WEB && p->role==USER_ROLE_ADMIN); return live; }
static bool drained(const admin_ssh_console_token_t *t) { (void)t; return true; }
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg)
{ (void)t; (void)action; (void)arg; assert(false); return ESP_FAIL; }
static const admin_console_owner_t owner = {
.is_current=is_current, .drained=drained, .perform=perform,
};
static void reply(void)
{
const char *text=scenario==1 ? "wifi-secret\x03" : scenario==5 ? "short\r" : "wifi-secret\t\r";
size_t used=0;
assert(s_sessions[0].prompt_hidden);
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)text,strlen(text),&used));
assert(used==strlen(text));
if (scenario==2) live=false;
if (scenario==3) principal_current=false;
if (scenario==4) admin_ssh_console_close(&token);
}
static void run_secret(void)
{
assert(admin_ssh_console_dispatch_is_web());
uint8_t greeting[4096]; size_t greeting_length;
assert(admin_ssh_console_read_output(&token,greeting,sizeof(greeting),&greeting_length)==ESP_OK);
unsigned before=applies, wiped=wipes;
uint8_t history[sizeof(s_sessions[0].history)];
memcpy(history,s_sessions[0].history,sizeof(history));
int result=(scenario%2 || scenario==6) ? set_ap_secret() : set_profile_secret("0");
bool success=scenario==0 || scenario==6;
assert(result==(success ? 0 : 1));
assert(applies==before+success);
assert(wipes>=wiped+2); /* Hidden local input and PSK-bearing candidate. */
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i)
assert(!s_sessions[0].prompt_input[i]);
if (scenario<2 || scenario>=5)
assert(!memcmp(history,s_sessions[0].history,sizeof(history)));
uint8_t output[4097]={0}; size_t n=0;
if (s_sessions[0].active) {
assert(admin_ssh_console_read_output(&token,output,sizeof(output)-1,&n)==ESP_OK);
assert(!strstr((char *)output,"wifi-secret"));
assert(!strstr((char *)output,"short"));
assert(!strstr((char *)output,"help")); /* Tab never invokes completion in a prompt. */
}
}
int main(void)
{
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
user_principal_t ordinary={.role=USER_ROLE_USER};
assert(admin_ssh_console_open_owned(&token,&ordinary,&owner)!=ESP_OK);
working.profiles[0].ssid_len=1;
working.profiles[0].ssid[0]='x';
for (scenario=0;scenario<7;++scenario) {
live=true; principal_current=true; ++token.slot_generation;
assert(admin_ssh_console_open_owned(&token,&admin,&owner)==ESP_OK);
const char *line=(scenario%2 || scenario==6) ? "wifi ap secret\r" : "wifi profile secret 0\r";
size_t used=0;
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)line,strlen(line),&used));
unsigned before=runs;
prompt_hook=reply; command_hook=run_secret;
if (!setjmp(loop_done)) worker_task(NULL);
assert(runs==before+1);
admin_ssh_console_close(&token);
admin_session_t empty={0};
assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
}
assert(applies==2 && working.profiles[0].psk_len==11);
assert(working.ap_psk_len==11 && !memcmp(working.ap_psk,"wifi-secret",11));
assert(!memcmp(working.profiles[0].psk,"wifi-secret",11));
puts("PASS: browser Wi-Fi policy-to-dispatch, actual profile/AP secret handlers and shared IO; hidden input/history/Tab, cancel, owner/account revocation, close, short input and wiping; ordinary role denied");
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Actual Wi-Fi secret handlers through shared browser prompt IO; host fakes only."""
from pathlib import Path
import os
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
def stripped(path):
return "\n".join(line for line in (ROOT / path).read_text().splitlines()
if not line.startswith(("#include", "#pragma once")))
def function(source, signature):
start = source.index(signature)
return source[start:source.index("\n}", start) + 2] + "\n"
wifi = (ROOT / "src/wifi_console.c").read_text()
unit = (ROOT / "tests/admin_console_boundary/fakes.h").read_text() + "\n"
unit += stripped("src/admin_ssh_console.h") + "\n" + stripped("src/admin_ssh_console.c") + "\n"
unit += stripped("src/wifi_config.h") + r'''
#include <stdlib.h>
#define ESP_ERR_INVALID_SIZE 100
#define UART_NUM_0 0
#define WIFI_CONSOLE_SECRET_CAPACITY WIFI_CONFIG_PSK_MAX_LEN
static esp_err_t uart_flush_input(int port) { (void)port; assert(false); return ESP_FAIL; }
static int uart_read_bytes(int port, void *out, size_t n, unsigned wait)
{ (void)port; (void)out; (void)n; (void)wait; assert(false); return -1; }
static wifi_app_config_t working;
static unsigned applies, wipes;
void wifi_config_secure_wipe(void *p, size_t n) {
secure_wipe(p,n); ++wipes;
for (size_t i=0;i<n;++i) assert(!((uint8_t *)p)[i]);
}
static esp_err_t wifi_manager_get_working_config(wifi_app_config_t *out)
{ *out=working; return ESP_OK; }
static esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *in)
{ working=*in; ++applies; return ESP_OK; }
'''
unit += stripped("src/console_input.c") + "\n"
for signature in ("static bool parse_u32(", "static bool parse_slot(",
"static esp_err_t apply_candidate(", "static esp_err_t read_secret_no_echo(",
"static int set_profile_secret(", "static int set_ap_secret("):
unit += function(wifi, signature)
unit += (ROOT / "tests/admin_console_boundary/wifi.c").read_text()
with tempfile.TemporaryDirectory(prefix="browser-wifi-prompts-") as directory:
path = Path(directory)
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable", str(path / "test.c"),
str(IDF / "components/console/split_argv.c"), "-o", str(path / "test")],
check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
+15 -4
View File
@@ -66,7 +66,19 @@ int main(void) {
"", " ", "help", "memory", "exit", "user", "user status", "user list",
"user show admin", "\"user\" \"show\" \"bootstrap\"",
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
"wifi status", "mdns status", "\"web\" \"status\"",
"mdns status", "\"web\" \"status\"",
"wifi", "wifi status", "wifi profiles", "wifi counters", "wifi clear-counters",
"wifi start", "wifi stop", "wifi reconnect", "wifi next-profile",
"wifi save", "wifi load", "wifi defaults", "wifi reset",
"wifi profile set 0 10 mixed \"Office Wi-Fi\"", "wifi profile secret 0",
"wifi profile enable 0", "wifi profile disable 0", "wifi profile delete 0",
"wifi ap policy fallback", "wifi ap ssid \"Recovery AP\"", "wifi ap channel 6",
"wifi ap secret", "wifi ap show-secret",
"wifi ping example.org", "wifi nslookup example.org", "wifi traceroute example.org",
" \"wifi\" ", " \"wifi\" \"stop\" ",
"\"wifi\" \"profile\" \"secret\" \"0\"", "\"wifi\" \"ap\" \"show-secret\"",
/* Admission is not syntax validation: canonical Wi-Fi rejects these. */
"wifi scan", "wifi status extra", "wifi profile secret 0 inline-secret", "wifix stop",
"user add other user", "user add other admin", "user password other",
"user delete other --force", "user role other user --force",
"user role other admin --force", "\"user\" \"password\" \"other\"",
@@ -84,8 +96,7 @@ int main(void) {
"web certificate rotate --forcex", "web certificate --force rotate",
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
"web reset --force", "web status extra",
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
"reboot --force", "user bootstrap", "user recover --force",
"user add other admin --generate", "user delete other",
@@ -100,7 +111,7 @@ int main(void) {
"user key delete admin 0 --force", "user list extra", "user show admin extra",
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
" \"user\" \"password\" \"admin\" \"--generate\"",
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
"\"web\" \"credentials\" \"show\"", "mdns status extra",
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
};
+145
View File
@@ -0,0 +1,145 @@
# mDNS 1.12.0 multicast membership regression
This is a narrow dependency correctness fix for the lwIP backend, not a general
patch framework or a revival of the abandoned Phase 9 patches. Dependency
versions, the component manifest/lock, managed sources, socket backend, and
application mDNS lifecycle policy are unchanged.
## Defects and fix
In the inspected `mdns_networking_lwip.c`, upstream `pcb_if_deinit()` (original
lines 267277) only leaves a group when the interface's **last** protocol bit is
cleared. Removing one family while the other remains active therefore leaks a
membership reference. Restoration joins again; repeated partial transitions
accumulate references in lwIP's bounded group-use counter. Deinitializing an
already-disabled family can also issue an unmatched leave.
The generated copy checks the requested family's active bit, returns immediately
if absent, and leaves that family's group before clearing its bit. It clears
`ready` only after the last family on that interface is removed, and frees the
shared PCB only if no other interface remains ready.
In `pcb_if_init()` (original lines 282301), a successful group join followed by
`pcb_init()` failure was not unwound. The copy attempts a leave before returning
the **original** PCB error. No readiness or protocol bit is published on failure.
These operations still run through upstream's existing lwIP-thread wrappers.
No new locks, allocations, task ownership, retry state, or public API are added.
Upstream `join_group()` is unchanged, including its refusal to act when the netif
is absent/down. Leaves remain best effort: the patch guarantees balanced leave
**attempts**, not successful lwIP cleanup after an interface disappears. It does
not retry a failed leave or retain a PCB solely because leave failed; physical
netif teardown remains responsible for its own membership cleanup.
## Build integration
1. Root `CMakeLists.txt` includes `cmake/mdns_membership.cmake` **after** IDF's
`project()`, when component targets and resolved versions are available.
2. The include obtains `espressif__mdns`'s `COMPONENT_DIR`, `COMPONENT_LIB`, and
`COMPONENT_VERSION` via IDF component properties. Socket-backend builds skip
the overlay entirely; the fix is only relevant to the lwIP source.
3. The resolved component version and manifest version must both be exactly
`1.12.0`. Using IDF's configured Python interpreter, the helper verifies the
complete source SHA-256, then performs two exact, unique replacements.
4. It writes only `${CMAKE_BINARY_DIR}/mdns_membership/mdns_networking_lwip.c`.
Unchanged output is not rewritten, avoiding gratuitous rebuilds. Everything
outside the two replacement regions, including provenance/license headers,
remains byte-for-byte intact. The actual upstream file identifies itself as
Apache-2.0, copyright 20222025 Espressif; it is not relabeled as GPL. Existing
project GPL/license material is untouched.
5. The include replaces exactly one matching entry in the existing component
target's `SOURCES` property, accepting absolute or component-relative paths.
All other source entries and all component compile definitions, include paths,
dependencies, and target linkage are retained. The original file is not also
compiled. An unexpected source list fails configuration.
6. The helper, original source, manifest, and generated copy are registered as
configure dependencies. Reconfiguration regenerates/verifies the copy; a
clean build simply recreates it. The CMake include itself is automatically a
CMake input. Version/hash/replacement errors fail configure rather than
silently compiling an unpatched dependency.
The integration uses the component's existing target; no whole-component copy,
managed in-place edits, dependency overrides, manifest changes, or extra source
library are involved. The inspected upstream CMake file offers no per-source
substitution option; replacing the target source after `project()` is the local
integration point.
### Exact reviewed baseline
- Component: `espressif/mdns`, version `1.12.0`.
- Manifest repository: `espressif/esp-protocols`, `components/mdns`.
- Manifest commit: `db06b19b7be729c163d346f62ec0eba01047b7f1` (provenance;
guards are the resolved/manifest version and complete source hash).
- Source: `managed_components/espressif__mdns/mdns_networking_lwip.c`.
- SHA-256: `adc139fa504a925ab644f21f8dce3659927f534e390a176b72b0ae3206c6a3ea`.
## Run the host tests
From the repository root, on a POSIX host with Python 3, CMake >= 3.16, and a C11
compiler:
```sh
./tests/mdns_membership/run.py
# Optional compiler selection (CC is an executable, not a shell command):
CC=clang ./tests/mdns_membership/run.py
```
The managed component must already be installed. The runner uses temporary
directories, does not fetch dependencies, does not build firmware, and disables
core dumps for deliberate negative tests. It generates the overlay with the
**same helper used by configure**, extracts the actual patched state declarations
and six functions (`pcb_init`, `pcb_deinit`, `mdns_priv_if_ready`,
`is_any_pcb_in_use`, `pcb_if_init`, `pcb_if_deinit`), and compiles them with
`-std=c11 -Wall -Wextra -Werror -pedantic`. Only group operations and low-level
UDP APIs are mocked; the lifecycle logic under test is not reimplemented.
Coverage:
- 512 IPv4 loss/restoration cycles while IPv6 stays active, and 512 inverse
cycles; exact group references and readiness checked after each transition.
- Repeated disabled-family deinit, final-family teardown, and duplicate init.
- Join failure without an unmatched leave or PCB allocation.
- 512 allocation failures and 512 bind failures per family, with successful
joins unwound, no state publication, original error preserved, and recovery.
- Leave failure on teardown and unwind: one attempt, no repeated disabled leave,
original allocation error retained.
- 512 cycles per family with a second interface holding the shared PCB; exactly
one allocation, no premature free, and one final removal.
- Separate negative controls restoring each original buggy function must fail
the very same executable harness.
- Mock-IDF CMake configure fixtures for absolute/relative source replacement,
unchanged unrelated source, repeat configure without rewriting, socket bypass,
missing/duplicate networking source rejection, resolved-version rejection,
and full-source hash rejection. Manifest-version rejection is tested through
the helper CLI. Rejected fresh helper runs must not produce output.
- Original managed networking bytes are checked unchanged after the run.
These are host unit/configure tests, **not** a real ESP-IDF integration build or
hardware/network test. The real `join_group()`, lwIP IGMP/MLD counters, scheduling,
and network teardown are not exercised by mocks. Run the normal `pio run`
separately (never concurrently with another full build) to validate actual IDF
integration. In its generated `compile_commands.json`, the mDNS networking entry
must point to the build-local `mdns_membership/mdns_networking_lwip.c`, with no
original managed networking entry; other mDNS sources must remain managed paths.
On device, exercise repeated IPv4-only and IPv6-only loss/restoration while the
other family remains active, final network teardown/recovery, and continued
mDNS discovery. No hardware result is implied by the host PASS output.
## Maintenance / removal
Do **not** update the expected hash merely to make a new dependency build. On
any mismatch, review the entire changed networking source and component CMake,
especially protocol state, join/leave behavior, netif-down semantics, and shared
PCB lifetime. Establish whether upstream has fixed both defects first. A
version bump is a separate explicitly reviewed change; this overlay does not
select or upgrade the dependency.
If upstream fixes both paths, remove the root include and these narrowly scoped
helper/test files (or replace the tests with suitable upstream coverage), then
clean/reconfigure and check that only the upstream source compiles. If a local
fix is still necessary, re-audit the exact new source/version, update the guards
and exact replacements together, update this provenance record, rerun the host
suite with its negative controls, and run a serial full firmware build. Never
fall back to unguarded search/replace or silently skip a failed patch. A stale
build-local copy is not authority: it must always be reproducible from the
managed source plus the reviewed helper.
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Host-only regression and configure integration tests; no firmware build."""
import hashlib
import os
import resource
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
COMPONENT = ROOT / "managed_components/espressif__mdns"
HELPER = ROOT / "cmake/mdns_membership.py"
def run(command, *, succeeds=True):
result = subprocess.run([str(item) for item in command], capture_output=True, text=True)
if (result.returncode == 0) != succeeds:
raise AssertionError(f"Unexpected result: {command}\n{result.stdout}\n{result.stderr}")
return result.stdout + result.stderr
def function(source, signature):
start = source.index(signature)
brace = source.index("{", start)
depth = 1
end = brace + 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end] + "\n"
def extract(source):
# Keep the original provenance header and actual state declarations too.
header = source[:source.index("#include")]
state = source[source.index("enum interface_protocol"):source.index("static const char *TAG")]
signatures = ["static esp_err_t pcb_init(void)", "static void pcb_deinit(void)",
"bool mdns_priv_if_ready(", "static bool is_any_pcb_in_use(void)",
"static void pcb_if_deinit(", "static esp_err_t pcb_if_init("]
return header + state + "\n".join(function(source, name) for name in signatures)
def cmake_fixture(work, component, mode, succeeds=True):
fixture = work / f"cmake-{mode}"
fixture.mkdir()
(fixture / "dummy.c").write_text("int dummy;\n")
source = component / "mdns_networking_lwip.c"
sources = f'"{source}"'
if mode == "missing":
sources = ""
if mode == "duplicate":
sources += " " + sources
# Test relative as well as absolute target source properties.
if mode == "relative":
shutil.copyfile(source, fixture / source.name)
shutil.copyfile(component / "idf_component.yml", fixture / "idf_component.yml")
component = fixture
sources = source.name
version = "1.13.0" if mode == "version" else "1.12.0"
(fixture / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.16)
project(mdns_overlay_fixture C)
add_library(mdns STATIC dummy.c {sources})
function(idf_component_get_property output component property)
if(property STREQUAL "COMPONENT_DIR")
set(value "{component}")
elseif(property STREQUAL "COMPONENT_LIB")
set(value mdns)
elseif(property STREQUAL "COMPONENT_VERSION")
set(value "{version}")
else()
message(FATAL_ERROR "Unexpected component property")
endif()
set(${{output}} "${{value}}" PARENT_SCOPE)
endfunction()
function(idf_build_get_property output property)
if(NOT property STREQUAL "PYTHON")
message(FATAL_ERROR "Unexpected build property")
endif()
set(${{output}} "{sys.executable}" PARENT_SCOPE)
endfunction()
set(CONFIG_MDNS_NETWORKING_SOCKET {"ON" if mode == "socket" else "OFF"})
include("{ROOT / 'cmake/mdns_membership.cmake'}")
get_target_property(sources mdns SOURCES)
file(WRITE "${{CMAKE_BINARY_DIR}}/selected.txt" "${{sources}}")
''')
build = fixture / "build"
output = run(["cmake", "-S", fixture, "-B", build], succeeds=succeeds)
if not succeeds:
assert "mDNS" in output, output
return
selected = (build / "selected.txt").read_text().split(";")
if mode == "socket":
assert str(source) in selected
assert not (build / "mdns_membership").exists()
else:
overlay = build / "mdns_membership/mdns_networking_lwip.c"
assert selected == ["dummy.c", str(overlay)], selected
before = overlay.stat().st_mtime_ns
run(["cmake", "-S", fixture, "-B", build])
assert overlay.stat().st_mtime_ns == before
def main():
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
original = (COMPONENT / "mdns_networking_lwip.c").read_bytes()
with tempfile.TemporaryDirectory(prefix="mdns-membership-") as directory:
work = Path(directory)
overlay = work / "overlay/mdns_networking_lwip.c"
run([sys.executable, HELPER, COMPONENT, overlay])
patched = overlay.read_text()
assert patched[:patched.index("#include")] == original.decode()[:original.decode().index("#include")]
timestamp = overlay.stat().st_mtime_ns
run([sys.executable, HELPER, COMPONENT, overlay])
assert overlay.stat().st_mtime_ns == timestamp
(work / "actual_functions.inc").write_text(extract(patched))
executable = work / "test"
command = [os.environ.get("CC", "cc"), "-std=c11", "-Wall", "-Wextra", "-Werror",
"-pedantic", "-I", work, HERE / "test.c", "-o", executable]
run(command)
print(run([executable]), end="")
# Prove the harness detects each original bug independently.
for name in ("pcb_if_deinit", "pcb_if_init"):
signature = ("static void " if name.endswith("deinit") else "static esp_err_t ") + name + "("
mutated = patched.replace(function(patched, signature), function(original.decode(), signature))
(work / "actual_functions.inc").write_text(extract(mutated))
run(command)
run([executable], succeeds=False)
print("PASS: both original defects independently fail the same harness")
copied = work / "component"
copied.mkdir()
shutil.copyfile(COMPONENT / "idf_component.yml", copied / "idf_component.yml")
(copied / "mdns_networking_lwip.c").write_bytes(original + b"\n")
output = run([sys.executable, HELPER, copied, work / "rejected.c"], succeeds=False)
assert "SHA-256 mismatch" in output and not (work / "rejected.c").exists()
(copied / "mdns_networking_lwip.c").write_bytes(original)
manifest = (copied / "idf_component.yml").read_text()
(copied / "idf_component.yml").write_text(manifest.replace("version: 1.12.0", "version: 1.13.0"))
output = run([sys.executable, HELPER, copied, work / "rejected.c"], succeeds=False)
assert "exactly version 1.12.0" in output and not (work / "rejected.c").exists()
(copied / "idf_component.yml").write_text(manifest)
for mode in ("absolute", "relative", "socket", "missing", "duplicate", "version"):
cmake_fixture(work, copied, mode, succeeds=mode not in ("missing", "duplicate", "version"))
# Also exercise hash failure through configure, not only the helper CLI.
(copied / "mdns_networking_lwip.c").write_bytes(original + b"\n")
cmake_fixture(work, copied, "hash", succeeds=False)
print("PASS: source/version guards, CMake replacement, relative paths, socket bypass, repeat configure")
assert hashlib.sha256((COMPONENT / "mdns_networking_lwip.c").read_bytes()).digest() == hashlib.sha256(original).digest()
print("PASS: managed networking source unchanged")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
typedef int mdns_if_t;
typedef int mdns_ip_protocol_t;
enum { ESP_OK, ESP_ERR_NO_MEM, ESP_ERR_INVALID_STATE };
enum { MDNS_IP_PROTOCOL_V4, MDNS_IP_PROTOCOL_V6, MDNS_IP_PROTOCOL_MAX };
#define MDNS_MAX_INTERFACES 2
#define MDNS_SERVICE_PORT 5353
struct udp_pcb { int mcast_ttl, remote_port, remote_ip; };
static int any_address;
#define IP_ANY_TYPE (&any_address)
#define ip_addr_copy(to, from) ((to) = (from))
static void receive(void) {}
static struct udp_pcb storage;
static bool allocated, fail_alloc, fail_bind, fail_join, fail_leave;
static int allocations, removals, joins[2][2], leaves[2][2], references[2][2];
static struct udp_pcb *udp_new(void)
{
if (fail_alloc) return NULL;
assert(!allocated);
allocated = true;
allocations++;
return &storage;
}
static int udp_bind(struct udp_pcb *pcb, const int *address, int port)
{
assert(pcb == &storage && address == IP_ANY_TYPE && port == 5353);
return fail_bind;
}
static void udp_remove(struct udp_pcb *pcb)
{
assert(pcb == &storage && allocated);
allocated = false;
removals++;
}
static void udp_recv(struct udp_pcb *pcb, void (*callback)(void), void *arg)
{ assert(pcb == &storage); (void)callback; (void)arg; }
static void udp_disconnect(struct udp_pcb *pcb) { assert(pcb == &storage); }
static esp_err_t join_group(mdns_if_t interface, mdns_ip_protocol_t family, bool join)
{
if (join) {
joins[interface][family]++;
if (fail_join) return ESP_ERR_INVALID_STATE;
references[interface][family]++;
/* A real lwIP group has a bounded use count: never accumulate it. */
assert(references[interface][family] == 1);
} else {
leaves[interface][family]++;
if (fail_leave) return ESP_ERR_INVALID_STATE;
assert(references[interface][family] == 1);
references[interface][family]--;
}
return ESP_OK;
}
#include "actual_functions.inc"
static void reset(void)
{
assert(!allocated && s_pcb_main == NULL);
memset(s_interfaces, 0, sizeof(s_interfaces));
memset(joins, 0, sizeof(joins));
memset(leaves, 0, sizeof(leaves));
memset(references, 0, sizeof(references));
allocations = removals = 0;
fail_alloc = fail_bind = fail_join = fail_leave = false;
}
static void check(int interface, int family, bool active)
{
assert(!!mdns_priv_if_ready(interface, family) == active);
assert(references[interface][family] == (int)active);
assert(joins[interface][family] - leaves[interface][family] == (int)active);
}
static void transitions(int family)
{
reset();
int other = 1 - family;
assert(pcb_if_init(0, family) == ESP_OK);
assert(pcb_if_init(0, other) == ESP_OK);
for (int i = 0; i < 512; i++) {
pcb_if_deinit(0, family);
check(0, family, false);
check(0, other, true);
assert(s_interfaces[0].ready && allocated && removals == 0);
pcb_if_deinit(0, family); /* Already-disabled family must not leave again. */
check(0, family, false);
assert(pcb_if_init(0, family) == ESP_OK);
check(0, family, true);
int before = joins[0][family];
assert(pcb_if_init(0, family) == ESP_ERR_INVALID_STATE);
assert(joins[0][family] == before);
}
pcb_if_deinit(0, other);
assert(s_interfaces[0].ready && allocated);
pcb_if_deinit(0, family);
assert(!s_interfaces[0].ready && !allocated);
assert(allocations == 1 && removals == 1);
pcb_if_deinit(0, family);
pcb_if_deinit(0, other);
check(0, family, false);
check(0, other, false);
}
static void failures(int family)
{
reset();
pcb_if_deinit(0, family);
assert(leaves[0][family] == 0 && removals == 0);
fail_join = true;
assert(pcb_if_init(0, family) == ESP_ERR_INVALID_STATE);
assert(!s_interfaces[0].ready && s_interfaces[0].proto == 0);
assert(allocations == 0 && leaves[0][family] == 0);
fail_join = false;
for (int mode = 0; mode < 2; mode++) {
fail_alloc = mode == 0;
fail_bind = mode == 1;
for (int i = 0; i < 512; i++) {
int before = leaves[0][family];
assert(pcb_if_init(0, family) == (fail_alloc ? ESP_ERR_NO_MEM : ESP_ERR_INVALID_STATE));
assert(leaves[0][family] == before + 1);
assert(references[0][family] == 0 && !allocated && !s_pcb_main);
assert(!s_interfaces[0].ready && s_interfaces[0].proto == 0);
pcb_if_deinit(0, family);
assert(leaves[0][family] == before + 1);
}
}
fail_alloc = fail_bind = false;
assert(pcb_if_init(0, family) == ESP_OK);
pcb_if_deinit(0, family);
assert(!allocated && references[0][family] == 0);
/* Preserve upstream best-effort leave semantics when netif has gone down. */
reset();
assert(pcb_if_init(0, family) == ESP_OK);
fail_leave = true;
pcb_if_deinit(0, family);
pcb_if_deinit(0, family);
assert(leaves[0][family] == 1 && !s_interfaces[0].ready && !allocated);
references[0][family] = 0; /* Model netif teardown clearing its memberships. */
reset();
fail_alloc = fail_leave = true;
assert(pcb_if_init(0, family) == ESP_ERR_NO_MEM);
assert(leaves[0][family] == 1 && !s_interfaces[0].ready && !allocated);
references[0][family] = 0;
}
static void shared_pcb(int family)
{
reset();
assert(pcb_if_init(1, 1 - family) == ESP_OK);
for (int i = 0; i < 512; i++) {
assert(pcb_if_init(0, family) == ESP_OK);
pcb_if_deinit(0, family);
pcb_if_deinit(0, family);
check(0, family, false);
check(1, 1 - family, true);
assert(!s_interfaces[0].ready && allocated && removals == 0);
}
assert(allocations == 1);
pcb_if_deinit(1, 1 - family);
assert(!allocated && removals == 1);
}
int main(void)
{
for (int family = 0; family < 2; family++) {
transitions(family);
failures(family);
shared_pcb(family);
}
puts("PASS: actual mDNS functions, 512 cycles per family/scenario, failure unwind and shared PCB");
return 0;
}
+27
View File
@@ -0,0 +1,27 @@
# Phase 12 mDNS owner host regression
Run `python3 tests/mdns_phase12/run.py` from the repository root. Uses the host C11 compiler and temporary fake SDK headers; compiles the actual production module. No PlatformIO, device operations, or generated firmware assets.
Covers notifications before initialization and while the project mutex is held; default unavailable SSH; fixed HTTPS443/SSH22 and empty TXT; repeated starts without duplicate records or hostname churn; record add/remove failures and retry; offline withdrawal; offline rename and retry; latest-state coalescing; init/hostname/instance failure latches and cleanup. Family tests cover lost IPv4 with surviving IPv6, IPv6-only/link-local readiness, no valid IPv6, netif down, stop with stale addresses, missed GOT_IP6, explicitly failed and silently dropped action submission, delayed upstream disable, missing STA netif, and no enable churn during ordinary healthy polls. TCP/IP fakes assert address reads occur in TCP/IP context; netif actions assert neither project mutex nor publication critical section is held. Actual multicast, component tasks, DNS conflicts, and sockets are not simulated.
## Integration contract
- Listener owners call `void mdns_service_set_https_available(bool)` / `void mdns_service_set_ssh_available(bool)` after successful listener creation and on unavailability. Calls use tiny portMUX critical sections, safe before initialization, with no blocking semaphore, allocation, callbacks, or component calls. Xtensa does not promise lock-free C11 atomic bool, so no atomics are required.
- The sole Wi-Fi owner calls `esp_err_t mdns_service_reconcile(void)` periodically, even offline. No new arguments are needed: it looks up the permanent `WIFI_STA_DEF` netif and samples addresses via `esp_netif_tcpip_exec`. No project mutex or publication mux is held across SDK/component calls.
- Reconciliation never initializes the responder. Before module init it returns invalid-state; after module init but before responder startup it is a successful no-op. Once initialized it applies listener states and family readiness, retrying failed operations on subsequent passes. Notifications converge on a later pass, not synchronously; an in-flight pass can briefly reflect an older state.
- Existing start/reannounce also reconcile. Start remains gated by usable STA in either family. Stop now clears announcement expectation **and requests family disable**, even if netif still holds nonzero addresses; it does not destroy/reinitialize the responder. Errors are retained in the snapshot. Do not call lifecycle operations concurrently or while holding another service lock. The manager owns start/stop, not event callbacks.
- Required effective SDK options remain `CONFIG_MDNS_PREDEF_NETIF_STA=y`, AP/ETH predefined interfaces disabled, `CONFIG_LWIP_IPV4=y`, `CONFIG_LWIP_IPV6=y`, `CONFIG_MDNS_MAX_SERVICES >= 2`. No configuration change was made for the repair.
## Exact dependency contracts and limitations
Inspected installed Espressif mDNS **1.12.0** and ESP-IDF **5.5.0**:
- `mdns_netif.c` initializes IPv6 from `esp_netif_get_ip6_linklocal`, independently of IPv4. Its predefined handlers process disconnect and address acquisition, but not STA_LOST_IP. `mdns_networking_lwip.c` tracks family readiness separately from current addresses. `mdns_send.c` checks readiness before emitting A/AAAA, but can emit zero A if readiness remains true after IPv4 loss. Explicit family disable repairs that stale readiness, including when IPv6 survives.
- `mdns_send.c` uses `esp_netif_get_all_ip6`, whose IDF implementation includes **valid addresses, including deprecated addresses**, excludes invalid/tentative/zero addresses, and reads lwIP state directly. Our sampling therefore runs in TCP/IP context and intentionally follows valid-address semantics, not preferred-only semantics. No address cache is passed to mDNS; it still reads addresses when serializing records.
- `mdns_netif_action` is asynchronous, has no public readiness getter or acknowledgement, and `post_custom_action` returns `ESP_OK` even if action enqueue fails. A cached successful API return is not proof of applied state. Allocation errors retry on the next pass; silent losses require reassertion. Missing-family disables are idempotent and reasserted every poll. Present-family enables restart probes, so they are submitted on family-mask transitions and at a **30-second repair cadence**, not every healthy interval. This also repairs late upstream disconnect actions and failed internal PCB initialization. There is no responder restart or service-record churn.
- Public-only control cannot simultaneously guarantee acknowledged readiness and absolutely no periodic enable reprobes. Healthy families re-probe every 30 seconds as the explicit tradeoff. Recovery from silent dropped enables is on a subsequent repair cadence **once the component queue/network resources make progress**, not a hard deadline under sustained failure. Missing-family disable retries each poll. Polling, concurrent network changes, and component queuing leave a transient window in which stale/zero A responses can still escape; this is convergence, not an atomic packet-level filter. No upstream patch is included.
- `mdns_responder.c` adds/removes records synchronously under its own mutex. Hostname setting waits for the component worker; instance setting queues work. Work per pass is one bounded address scan, at most one family action, and two record decisions; no additional tasks, queues, dynamic application storage, or retry loops. This is not a hard wall-clock guarantee for upstream blocking calls.
## Target validation
In addition to the host suite, the updated `src/mdns_service.c` was compiled to a temporary object using its exact `.pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json` command and the installed Xtensa compiler / IDF 5.5 headers. Compilation passed. No full PlatformIO build, link, or hardware validation was performed.
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Compile the production mDNS owner against bounded host fakes; no IDF build."""
import pathlib
import subprocess
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="mdns-phase12-") as tmp:
p = pathlib.Path(tmp)
(p / "freertos").mkdir()
headers = {
"esp_err.h": """#pragma once
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_ERR_INVALID_ARG 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_NO_MEM 3
#define ESP_ERR_NOT_FOUND 4
#define ESP_ERR_TIMEOUT 5
""",
"sdkconfig.h": """#define CONFIG_MDNS_PREDEF_NETIF_STA 1
#define CONFIG_MDNS_PREDEF_NETIF_AP 0
#define CONFIG_MDNS_PREDEF_NETIF_ETH 0
#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3
""",
"freertos/FreeRTOS.h": """#pragma once
#define portMAX_DELAY 100
#define pdTRUE 1
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
void fake_enter(portMUX_TYPE *);
void fake_exit(portMUX_TYPE *);
#define portENTER_CRITICAL(m) fake_enter(m)
#define portEXIT_CRITICAL(m) fake_exit(m)
""",
"freertos/semphr.h": """#pragma once
typedef void *SemaphoreHandle_t;
SemaphoreHandle_t xSemaphoreCreateMutex(void);
int xSemaphoreTake(SemaphoreHandle_t, int);
int xSemaphoreGive(SemaphoreHandle_t);
""",
"esp_timer.h": """#pragma once
#include <stdint.h>
int64_t esp_timer_get_time(void);
""",
"esp_netif.h": """#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct { int unused; } esp_netif_t;
typedef struct { uint32_t addr; } esp_ip4_addr_t;
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
typedef struct { esp_ip4_addr_t ip; } esp_netif_ip_info_t;
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
bool esp_netif_is_netif_up(esp_netif_t *);
esp_err_t esp_netif_get_ip_info(esp_netif_t *, esp_netif_ip_info_t *);
int esp_netif_get_all_ip6(esp_netif_t *, esp_ip6_addr_t *);
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *), void *);
""",
"mdns.h": """#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "esp_netif.h"
typedef enum {
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
} mdns_event_actions_t;
esp_err_t mdns_netif_action(esp_netif_t *, mdns_event_actions_t);
esp_err_t mdns_init(void);
void mdns_free(void);
esp_err_t mdns_hostname_set(const char *);
esp_err_t mdns_instance_name_set(const char *);
esp_err_t mdns_service_add(const char *, const char *, const char *, uint16_t, void *, size_t);
esp_err_t mdns_service_remove(const char *, const char *);
""",
}
for name, text in headers.items():
(p / name).write_text(text)
exe = p / "test"
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", tmp,
"-I", str(ROOT / "src"), str(pathlib.Path(__file__).with_name("test.c")),
"-o", str(exe)], check=True)
for case in ("normal", "init-failure", "hostname-failure", "instance-failure"):
subprocess.run([str(exe), case], check=True)
+171
View File
@@ -0,0 +1,171 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "mdns_service.c"
static int held, inits, frees, names, adds, removes;
static int fail_init, fail_name, fail_instance, fail_record;
static bool https, ssh;
static char hostname[64];
static int critical, tcpip, actions, enables, fail_action, drop_action;
static bool up = true, valid6 = true, present = true, zero6;
static uint32_t ipv4 = 1;
static unsigned ready;
static int64_t clock_us;
static esp_netif_t sta;
void fake_enter(portMUX_TYPE *m) { (void)m; assert(!critical); critical = 1; }
void fake_exit(portMUX_TYPE *m) { (void)m; assert(critical); critical = 0; }
int64_t esp_timer_get_time(void) { assert(!held && !critical); return clock_us; }
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
assert(!held && !critical && !strcmp(key, "WIFI_STA_DEF")); return present ? &sta : NULL;
}
bool esp_netif_is_netif_up(esp_netif_t *n) { assert(tcpip && n == &sta); return up; }
esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *ip) {
assert(tcpip && n == &sta); ip->ip.addr = ipv4; return ESP_OK;
}
int esp_netif_get_all_ip6(esp_netif_t *n, esp_ip6_addr_t *ip) {
assert(tcpip && n == &sta); memset(ip, 0, sizeof(*ip)); ip->addr[0] = zero6 ? 0 : 0xfe80;
return valid6 ? 1 : 0;
}
esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *arg) {
assert(!held && !critical && !tcpip); tcpip = 1;
esp_err_t error = fn(arg); tcpip = 0; return error;
}
esp_err_t mdns_netif_action(esp_netif_t *n, mdns_event_actions_t action) {
assert(!held && !critical && !tcpip && n == &sta); ++actions;
if (fail_action) return ESP_ERR_NO_MEM;
if (drop_action) return ESP_OK; /* Actual upstream full-queue behavior. */
if (action & MDNS_EVENT_ENABLE_IP4) { ready |= 1; ++enables; }
if (action & MDNS_EVENT_ENABLE_IP6) { ready |= 2; ++enables; }
if (action & MDNS_EVENT_DISABLE_IP4) ready &= ~1U;
if (action & MDNS_EVENT_DISABLE_IP6) ready &= ~2U;
return ESP_OK;
}
static void test_families(void) {
assert(ready == 3);
int before = actions;
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
assert(actions == before); /* Healthy dual-stack poll has no action. */
ipv4 = 0; /* Lost IPv4, surviving IPv6: A readiness must be removed. */
drop_action = 1;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
drop_action = 0;
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
before = enables;
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
assert(enables == before); /* Disables do not restart the healthy family. */
zero6 = true; /* Defensive rejection even if an API supplied a zero entry. */
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
zero6 = false;
valid6 = false;
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
valid6 = true; /* Missed GOT_IP6 and silently lost repair submission. */
drop_action = 1;
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
drop_action = 0;
clock_us += MDNS_FAMILY_REPAIR_US;
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
ipv4 = 1; fail_action = 1;
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && ready == 2);
fail_action = 0;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
ready = 0; /* Late upstream disconnect action after the last sample. */
clock_us += MDNS_FAMILY_REPAIR_US;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
up = false;
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
up = true;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
mdns_service_stop();
assert(ready == 0); /* Even if stale nonzero addresses remain in netif. */
assert(mdns_service_start() == ESP_OK && ready == 3);
present = false;
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
present = true;
assert(mdns_service_reconcile() == ESP_OK);
}
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &held; }
int xSemaphoreTake(SemaphoreHandle_t m, int wait) {
(void)m;
assert(!critical);
if (held) { assert(!wait); return 0; }
held = 1; return pdTRUE;
}
int xSemaphoreGive(SemaphoreHandle_t m) { (void)m; assert(held); held = 0; return 1; }
esp_err_t mdns_config_validate(const mdns_config_t *c) { return c && c->suffix_len ? ESP_OK : ESP_ERR_INVALID_ARG; }
void mdns_config_defaults(mdns_config_t *c) { memset(c, 0, sizeof(*c)); strcpy(c->suffix, "default"); c->suffix_len = 7; }
esp_err_t mdns_config_load(mdns_config_t *c, bool *stored) { mdns_config_defaults(c); *stored = false; return ESP_OK; }
esp_err_t mdns_config_save(const mdns_config_t *c) { (void)c; return ESP_OK; }
esp_err_t mdns_init(void) { assert(!held && !critical); ++inits; return fail_init ? ESP_ERR_NO_MEM : ESP_OK; }
void mdns_free(void) { assert(!held && !critical); ++frees; }
esp_err_t mdns_hostname_set(const char *n) { assert(!held && !critical); ++names; if (fail_name) return ESP_ERR_NO_MEM; strcpy(hostname, n); return ESP_OK; }
esp_err_t mdns_instance_name_set(const char *n) { assert(!held && !critical && n); return fail_instance ? ESP_ERR_NO_MEM : ESP_OK; }
esp_err_t mdns_service_add(const char *instance, const char *type, const char *proto, uint16_t port, void *txt, size_t count) {
assert(!held && !critical && !instance && !txt && !count && !strcmp(proto, "_tcp"));
++adds;
if (fail_record) return ESP_ERR_NO_MEM;
bool *record = !strcmp(type, "_https") ? &https : &ssh;
assert(port == (record == &https ? 443 : 22));
assert(!*record); *record = true; return ESP_OK;
}
esp_err_t mdns_service_remove(const char *type, const char *proto) {
assert(!held && !critical && !strcmp(proto, "_tcp")); ++removes;
if (fail_record) return ESP_ERR_NO_MEM;
bool *record = !strcmp(type, "_https") ? &https : &ssh;
assert(*record); *record = false; return ESP_OK;
}
int main(int argc, char **argv) {
assert(argc == 2);
fail_init = !strcmp(argv[1], "init-failure");
fail_name = !strcmp(argv[1], "hostname-failure");
fail_instance = !strcmp(argv[1], "instance-failure");
mdns_service_set_https_available(true);
mdns_service_set_ssh_available(false);
assert(!inits && !adds);
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
mdns_config_t c; mdns_config_defaults(&c);
assert(mdns_service_init(&c) == ESP_OK);
assert(mdns_service_reconcile() == ESP_OK && !inits);
if (fail_init || fail_name || fail_instance) {
assert(mdns_service_start() == ESP_ERR_NO_MEM);
assert(frees == (fail_init ? 0 : 1));
fail_init = fail_name = fail_instance = 0;
for (int i = 0; i < 10; ++i) {
assert(mdns_service_start() == ESP_ERR_NO_MEM);
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM);
assert(mdns_service_reannounce() == ESP_ERR_NO_MEM);
mdns_service_stop();
}
assert(inits == 1 && !adds);
} else {
assert(mdns_service_start() == ESP_OK && https && !ssh);
for (int i = 0; i < 10; ++i) assert(mdns_service_start() == ESP_OK);
assert(inits == 1 && names == 1 && adds == 1);
test_families();
held = 1; /* Notifications must never acquire the service mutex. */
mdns_service_set_https_available(false);
mdns_service_set_ssh_available(true);
held = 0;
fail_record = 1;
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && https && !ssh);
fail_record = 0;
assert(mdns_service_reconcile() == ESP_OK && !https && ssh);
mdns_service_stop();
mdns_service_set_ssh_available(false);
assert(mdns_service_reconcile() == ESP_OK && !ssh);
strcpy(c.suffix, "offline"); c.suffix_len = 7;
assert(mdns_service_set_config(&c) == ESP_OK);
fail_name = 1;
assert(mdns_service_start() == ESP_ERR_NO_MEM);
fail_name = 0;
assert(mdns_service_start() == ESP_OK && !strcmp(hostname, "sak-offline"));
mdns_service_set_https_available(true);
mdns_service_set_https_available(false);
int before = adds;
assert(mdns_service_reconcile() == ESP_OK && adds == before);
assert(inits == 1 && !frees);
}
printf("PASS %s\n", argv[1]);
return 0;
}
+6 -5
View File
@@ -31,7 +31,8 @@ static ssh_slot_t s_slots[2];
static void *s_context;
static int s_listen_fd=-1, s_lock;
static unsigned depth, frees, creates;
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail;
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail, advertised;
static void mdns_service_set_ssh_available(bool available) { assert(!depth); assert(available == (s_listen_fd >= 0)); advertised=available; }
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while(0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while(0)
static void wolfSSH_CTX_free(void *p) { assert(!depth && p==s_context); for(unsigned i=0;i<2;++i) assert(!s_slots[i].state); ++frees; }
@@ -48,17 +49,17 @@ static esp_err_t create_listener(void) { if(listener_fail)return ESP_FAIL;s_list
'''
tests=r'''
int main(void) {
assert(start_runtime()==ESP_OK && creates==1);
assert(start_runtime()==ESP_OK && creates==1 && advertised);
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1 && !frees);
s_slots[0].state=2;cleanup_fail=true;
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1);
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1 && !advertised);
s_cleanup_pending=true;s_running=false;
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1);
process_slots();assert(s_context && s_cleanup_pending && !frees);
cleanup_fail=false;process_slots();assert(!s_context && !s_cleanup_pending && frees==1);
process_slots();assert(frees==1);
assert(start_runtime()==ESP_OK && creates==2);assert(stop_runtime()==ESP_OK && frees==2);
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1);
assert(start_runtime()==ESP_OK && creates==2 && advertised);assert(stop_runtime()==ESP_OK && frees==2 && !advertised);
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1 && !advertised);
s_slots[1].state=2;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);s_slots[1].state=0;
s_listen_fd=22;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);
puts("PASS SSH actual runtime stop failure retains context, rejects orphan overwrite, owner retires only after all slots free, failed listener frees context exactly once");
+57
View File
@@ -0,0 +1,57 @@
# Phase 12 SSH listener regression
Run `python3 tests/ssh_phase12/run.py` from the repository root. Requires Python 3
and a host C compiler (`cc`). It extracts the production functions without rewriting
them and compiles with `-std=c11 -Wall -Wextra -Werror`.
Coverage:
- One AF_INET6/TCP wildcard listener, explicitly checked IPV6_V6ONLY=0 before bind.
- Failures at socket, dual-stack option, bind, listen and both nonblocking steps:
no published descriptor, exactly one close after socket allocation.
- Permanent accept errors withdraw availability, invalidate the service generation
without wrap, and request owner-side session cleanup; temporary errors retain
the listener. Capacity rejection respects the existing four-accept budget.
- IPv4, global IPv6, scoped link-local IPv6, mapped IPv4, maximum numeric scope,
unknown families, and bounded/truncated peer formatting.
`tests/ssh_management/runtime.py` additionally tests availability across real
production start/stop functions with context/listener/cleanup doubles, including
failed session cleanup retaining the context, successful restart, and failed start.
Existing management/security regressions remain responsible for authentication,
identity ownership and generation fencing.
## SDK contract checked during implementation
Read-only inspection of installed ESP-IDF **5.5.0**
(`framework-espidf@3.50500.0`, not the separately installed 5.5.3):
- `components/lwip/lwip/src/include/lwip/sockets.h`: `sockaddr_in6.sin6_scope_id`
is `u32_t`; `IPV6_V6ONLY` is supported.
- `components/lwip/lwip/src/include/lwip/inet.h`: `IN6ADDR_ANY_INIT`.
- `components/lwip/lwip/src/api/sockets.c`: `lwip_setsockopt_impl` applies
`netconn_set_ipv6only`; `IP6ADDR_PORT_TO_SOCKADDR` copies the interface zone;
`lwip_accept` publishes the peer address and maps a closed listener to EINVAL,
non-TCP sockets to EOPNOTSUPP, and descriptor exhaustion to ENFILE.
- `components/lwip/lwip/src/api/api_msg.c`: wildcard IPv6 bind/listen with V6ONLY
disabled selects `IPADDR_TYPE_ANY`, accepting both families on one listener.
These host doubles do **not** execute lwIP, actual networking, wolfSSH handshakes,
RTOS concurrency, discovery traffic or hardware. No PlatformIO build is required
or claimed. Device follow-up must verify IPv4-only, IPv6-only and dual-stack
connections, scoped link-local access, address changes/reconnects, simultaneous
clients and binary UART traffic, advertisement convergence after start/stop/error,
and independent UART0/native USB recovery.
## Resource and handoff notes
No extra socket, task, session, queue, I/O buffer or heap allocation is introduced.
Peer arrays grow from 48 to 65 bytes: 68 additional raw bytes across the two owner
slots and two published snapshots, plus ABI padding. Public snapshot copies also
grow. Exact linked RAM/flash and stack headroom were not measured without a target
build; host tests do not establish ESP32 memory headroom.
Project agent memory is intentionally not edited under the exclusive-write scope.
The parent should record the one-socket dual-stack contract, scoped peer capacity,
and owner-driven mDNS availability (eventual Wi-Fi-manager reconciliation) in the
relevant durable memory when integrating Phase 12.
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Compile exact SSH listener/peer/failure functions with deterministic socket doubles.
No target, network, wolfSSH handshake, or SDK build is exercised.
"""
from pathlib import Path
import re
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / 'src/ssh_transport.c').read_text()
header = (ROOT / 'src/ssh_transport.h').read_text()
def function(name):
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
assert match, name
return match.group() + '\n'
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', text, re.M).group()
for text, name in ((header, 'SSH_TRANSPORT_PORT'),
(header, 'SSH_TRANSPORT_PEER_CAPACITY'),
(source, 'SSH_TRANSPORT_LISTEN_BACKLOG')))
fakes = r'''
#include <assert.h>
#include <errno.h>
#include <netinet/tcp.h>
#include <stdbool.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define ESP_OK 0
#define ESP_FAIL -1
#define SSH_TRANSPORT_MAX_SESSIONS 2U
typedef int esp_err_t;
static int s_listen_fd = -1;
static unsigned stage, fail_at, closes, depth;
static bool s_running, s_cleanup_pending;
static unsigned withdrawals;
static void mdns_service_set_ssh_available(bool available) { assert(!depth && !available && s_listen_fd == -1); ++withdrawals; }
static int s_last_error, s_lock;
static uint32_t s_management_generation;
typedef struct {
bool close_requested;
unsigned generation, state, session_id;
int socket_fd;
int64_t handshake_deadline_us;
char peer[SSH_TRANSPORT_PEER_CAPACITY];
void *ssh;
} ssh_slot_t;
static ssh_slot_t s_slots[2];
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while (0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while (0)
#define request_slot_close(slot, revoked) do { assert(!(revoked)); (slot)->close_requested=true; } while (0)
static bool step(void) { return ++stage == fail_at; }
static int fake_socket(int family, int type, int protocol) {
assert(family == AF_INET6 && type == SOCK_STREAM && protocol == IPPROTO_TCP);
return step() ? -1 : 42;
}
static int fake_setsockopt(int fd, int level, int option, const void *value, socklen_t size) {
assert(fd == 42 && size == sizeof(int));
if (level == SOL_SOCKET) { assert(option == SO_REUSEADDR && *(const int *)value == 1); return 0; }
assert(stage == 1 && level == IPPROTO_IPV6 && option == IPV6_V6ONLY && *(const int *)value == 0);
return step() ? -1 : 0;
}
static int fake_bind(int fd, const struct sockaddr *addr, socklen_t size) {
const struct sockaddr_in6 *v6 = (const struct sockaddr_in6 *)addr;
assert(fd == 42 && stage == 2 && size == sizeof(*v6));
assert(v6->sin6_family == AF_INET6 && ntohs(v6->sin6_port) == SSH_TRANSPORT_PORT);
assert(IN6_IS_ADDR_UNSPECIFIED(&v6->sin6_addr) && !v6->sin6_scope_id);
return step() ? -1 : 0;
}
static int fake_listen(int fd, int backlog) {
assert(fd == 42 && stage == 3 && backlog == SSH_TRANSPORT_LISTEN_BACKLOG);
return step() ? -1 : 0;
}
static int fake_fcntl(int fd, int op, int arg) {
assert(fd == 42);
assert((stage == 4 && op == F_GETFL && arg == 0) ||
(stage == 5 && op == F_SETFL && arg == (O_NONBLOCK | O_APPEND)));
return step() ? -1 : (op == F_GETFL ? O_APPEND : 0);
}
static int fake_close(int fd) { assert(fd == 42 && !depth); ++closes; return 0; }
static int fake_shutdown(int fd, int how) { assert(fd == 42 && how == SHUT_RDWR && !depth); return 0; }
#define socket fake_socket
#define setsockopt fake_setsockopt
#define bind fake_bind
#define listen fake_listen
#define fcntl fake_fcntl
#define close fake_close
#define shutdown fake_shutdown
'''
accept_fakes = r'''
#define SSH_TRANSPORT_ACCEPT_BUDGET 4U
#define SSH_TRANSPORT_SESSION_HANDSHAKE 1
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
#define WS_SUCCESS 0
static void *s_context = (void *)1;
static struct { uint64_t io_failures, tcp_connections, capacity_rejections, handshake_failures; } s_counters;
static unsigned accepts;
static int accept_error;
static void add_counter(uint64_t *counter, uint64_t value) { *counter += value; }
static int fake_accept(int fd, struct sockaddr *addr, socklen_t *length) {
assert(fd == 42 && addr && *length == sizeof(struct sockaddr_storage));
++accepts; errno = accept_error;
return accept_error ? -1 : 42;
}
#define accept fake_accept
static ssh_slot_t *find_free_slot(size_t *index) { (void)index; return NULL; }
static uint32_t make_session_id(size_t index, uint32_t generation) { (void)index; return generation; }
static int64_t esp_timer_get_time(void) { return 0; }
static void *wolfSSH_new(void *ctx) { (void)ctx; assert(0); return NULL; }
static int wolfSSH_set_fd(void *ssh, int fd) { (void)ssh; (void)fd; assert(0); return 0; }
static void set_ctx(void *ssh, void *slot) { (void)ssh; (void)slot; assert(0); }
#define wolfSSH_SetIOReadCtx set_ctx
#define wolfSSH_SetUserAuthCtx set_ctx
#define wolfSSH_SetUserAuthResultCtx set_ctx
#define wolfSSH_SetChannelReqCtx set_ctx
static bool cleanup_slot(ssh_slot_t *slot) { (void)slot; assert(0); return false; }
static void publish_slot(ssh_slot_t *slot, size_t index) { (void)slot; (void)index; assert(0); }
'''
tests = r'''
int main(void) {
for (fail_at = 1; fail_at <= 6; ++fail_at) {
stage = closes = 0;
assert(create_listener() == ESP_FAIL && s_listen_fd == -1);
assert(stage == fail_at && closes == (fail_at != 1));
}
fail_at = stage = closes = 0;
assert(create_listener() == ESP_OK && s_listen_fd == 42 && stage == 6 && !closes);
s_running = true; s_management_generation = 7;
listener_failed();
assert(s_listen_fd == -1 && closes == 1 && !s_running && s_cleanup_pending && withdrawals == 1);
assert(s_last_error == ESP_FAIL && s_management_generation == 8);
assert(s_slots[0].close_requested && s_slots[1].close_requested);
s_management_generation = UINT32_MAX;
listener_failed(); assert(s_management_generation == UINT32_MAX && closes == 1);
const int retry_errors[] = { EAGAIN, EWOULDBLOCK, EINTR, ENOMEM, ENOBUFS, ENFILE, ECONNABORTED };
for (unsigned i = 0; i < sizeof(retry_errors)/sizeof(retry_errors[0]); ++i) {
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = retry_errors[i];
unsigned before = withdrawals;
accept_connections();
assert(accepts == 1 && s_listen_fd == 42 && s_running && withdrawals == before);
}
const int fatal_errors[] = { EBADF, EINVAL, ENOTSOCK, EOPNOTSUPP };
for (unsigned i = 0; i < sizeof(fatal_errors)/sizeof(fatal_errors[0]); ++i) {
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = fatal_errors[i];
unsigned before = withdrawals;
accept_connections();
assert(accepts == 1 && s_listen_fd == -1 && !s_running && withdrawals == before + 1);
}
accepts = 0; accept_connections(); assert(!accepts);
s_listen_fd = 42; accepts = closes = 0; accept_error = 0;
accept_connections();
assert(accepts == SSH_TRANSPORT_ACCEPT_BUDGET && closes == accepts);
assert(s_counters.capacity_rejections == accepts && s_counters.tcp_connections == accepts);
struct sockaddr_storage storage = {0};
char output[SSH_TRANSPORT_PEER_CAPACITY];
struct sockaddr_in *v4 = (struct sockaddr_in *)&storage;
v4->sin_family = AF_INET; v4->sin_port = htons(65535);
assert(inet_pton(AF_INET, "192.0.2.1", &v4->sin_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "192.0.2.1:65535"));
memset(&storage, 0, sizeof(storage));
struct sockaddr_in6 *v6 = (struct sockaddr_in6 *)&storage;
v6->sin6_family = AF_INET6; v6->sin6_port = htons(65535);
assert(inet_pton(AF_INET6, "fe80::1", &v6->sin6_addr) == 1);
v6->sin6_scope_id = 3;
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[fe80::1%3]:65535"));
v6->sin6_scope_id = UINT32_MAX;
assert(inet_pton(AF_INET6, "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output));
assert(!strcmp(output, "[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff%4294967295]:65535"));
v6->sin6_scope_id = 0;
assert(inet_pton(AF_INET6, "2001:db8::1", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[2001:db8::1]:65535"));
assert(inet_pton(AF_INET6, "::ffff:192.0.2.1", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[::ffff:192.0.2.1]:65535"));
format_peer(&storage, output, 8); assert(!strcmp(output, "unknown"));
output[0] = 'x'; format_peer(&storage, output, 0); assert(output[0] == 'x');
format_peer(&storage, output, 1); assert(output[0] == 0);
storage.ss_family = AF_UNSPEC;
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "unknown"));
puts("PASS SSH Phase12 dual-stack socket setup, all setup failures, owner listener failure, scoped/mapped peers and bounded formatting");
}
'''
with tempfile.TemporaryDirectory(prefix='ssh-phase12-') as directory:
out = Path(directory)
(out / 'test.c').write_text(constants + '\n' + fakes + ''.join(function(name) for name in (
'close_socket', 'set_nonblocking', 'create_listener', 'format_peer', 'listener_failed')) + accept_fakes + function('accept_connections') + tests)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(out / 'test.c'), '-o', str(out / 'test')], check=True, timeout=30)
subprocess.run([str(out / 'test')], check=True, timeout=10)
+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
+17 -2
View File
@@ -14,7 +14,7 @@ parser substitutes, network access, or persistent build artifacts are used.
Compilation errors and test failures produce nonzero exit status.
Tables cover DNS/IPv4 origin canonicalization, case folding and optional `:443`,
malformed authorities/origins and unsupported IPv6; selected cookie presence,
bracketed IPv6 canonicalization and malformed authorities/origins; selected cookie presence,
uniqueness, exact lowercase 64-digit hex and surrounding cookie syntax; strict
login JSON, both field orders, escapes, Unicode/surrogate pairs, invalid UTF-8,
NUL, duplicate/unknown fields, truncation and byte limits (512-byte body,
@@ -31,7 +31,22 @@ bytes. Successful results check canonical/decoded bytes and termination.
- This is a focused parser contract suite, not HTTP integration, authorization,
CSRF/session, duplicate HTTP header-line, TLS, credential-policy, or hardware
testing. Empty credentials are syntactically valid; database policy is separate.
- IPv6 is intentionally rejected, not normalized or supported.
- IPv6 tests check expanded/compressed/case/default-port equivalence, longest
zero runs and ties, dotted tails, mapped-address separation from IPv4,
DNS separation, mismatched addresses, zones, malformed groups/brackets/ports,
userinfo, suffixes, controls and exact-span output wiping.
- Canonical IPv6 uses lowercase hex tails even for mapped addresses. No DNS
resolution, scope inference or network reachability is involved.
- To additionally audit the installed ESP-IDF conversion implementation, run:
```sh
WEB_AUTH_LWIP_SOURCE=/home/mscholz/.platformio/packages/framework-espidf/components/lwip/lwip/src/core/ipv6/ip6_addr.c python3 tests/web_auth_parse/run.py
```
Adjust the path for your installation. This compiles the actual extracted
`ip6addr_aton` body with host type/byte-order adapters and the same temporary
address-copy boundary as `lwip_inet_pton`. IPv4-tail and scope branches are
disabled: production validates/replaces dotted tails before conversion and
rejects zones. The production formatter is exercised, not substituted.
This is not a target build or full lwIP networking test.
- Python mirrors the public C struct and capacities; interface changes must
update these tests. Shared-library loading assumes a Unix-like host/compiler.
- Tables are not exhaustive fuzzing, memory-safety instrumentation, or proof of
+71 -3
View File
@@ -2,6 +2,8 @@
"""Dependency-free contract tests against the production parser, not a model."""
import ctypes as C
import json
import ipaddress
import os
from pathlib import Path
import subprocess
import tempfile
@@ -33,6 +35,40 @@ def main():
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-shared", "-fPIC", "-I", str(ROOT / "src"),
str(ROOT / "src/web_auth_parse.c"), "-o", str(library)], check=True)
# Optional audit uses installed target conversion code, not a mocked
# inet_pton that merely returns success or delegates IPv6 to host libc.
sdk = os.environ.get("WEB_AUTH_LWIP_SOURCE")
if sdk:
source = Path(sdk).read_text()
start = source.index("int\nip6addr_aton(")
end = source.index("\n/**", start)
shim = Path(temporary) / "lwip.c"
shim.write_text('''#include <arpa/inet.h>
#include <stdint.h>
#include <ctype.h>
#include <string.h>
typedef uint32_t u32_t;
typedef struct { uint32_t addr[4]; } ip6_addr_t;
#define LWIP_IPV4 0
#define LWIP_IPV6_SCOPES 0
#define lwip_htonl htonl
#define lwip_isxdigit isxdigit
#define lwip_isdigit isdigit
#define lwip_islower islower
#define ip6_addr_clear_zone(a) ((void)(a))
''' + source[start:end] + '''
int audit_inet_pton(int af, const char *src, void *dst) {
ip6_addr_t addr;
if (af != AF_INET6) return -1;
int result = ip6addr_aton(src, &addr);
if (result) memcpy(dst, addr.addr, sizeof(addr.addr));
return result;
}
''')
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-shared", "-fPIC", "-Dinet_pton=audit_inet_pton",
"-I", str(ROOT / "src"), str(ROOT / "src/web_auth_parse.c"),
str(shim), "-o", str(library)], check=True)
api = C.CDLL(str(library))
api.web_auth_parse_origin.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_void_p]
api.web_auth_parse_cookie.argtypes = [C.c_void_p, C.c_size_t, C.c_char_p, C.c_void_p]
@@ -52,7 +88,7 @@ def main():
if not result and raw != bytes(len(raw)):
failures.append(f"{kind}: {label}: failure did not wipe every output byte")
if result and expected is not None and extract(output) != expected:
failures.append(f"{kind}: {label}: incorrect decoded/canonical output")
failures.append(f"{kind}: {label}: decoded/canonical {extract(output)!r}, expected {expected!r}")
origins = [(b"EXAMPLE.Com", b"https://example.com", b"https://example.com"),
(b"a-b.local", b"https://A-B.LOCAL", b"https://a-b.local"),
@@ -65,8 +101,7 @@ def main():
b"https://" + host.lower()))
bad_hosts = [b"", b" ", b"example.com ", b" example.com", b"a..b", b".a", b"a.",
b"-a", b"a-", b"a_b", b"a/b", b"a?b", b"a#b", b"u@a", b"a,b",
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"[::1]", b"::1",
b"[::1]:443", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"::1", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
b"a:443:443", b"a" * 64 + b".com", b"a" * 130]
origins += [(h, b"https://" + h, None) for h in bad_hosts]
origins += [(b"example.com", o, None) for o in
@@ -77,6 +112,39 @@ def main():
b"https://example.com\0", b"https://example.com\r\n", b"https://[::1]",
b"https://example.com https://example.com", b"https:///example.com")]
origins += [(None, b"https://example.com", None)]
literals = ["::", "::1", "2001:DB8::ABCD", "2001:0:0:1:0:0:0:1",
"1:0:0:2:0:0:3:4", "1:2:3:4:5:6:0:8", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
"::ffff:192.0.2.1", "::192.0.2.1", "2001:db8:1:2:3:4:192.0.2.1"]
for literal in literals:
address = ipaddress.IPv6Address(literal)
normalized = address.compressed
if address.ipv4_mapped:
value = int(address.ipv4_mapped)
normalized = f"::ffff:{value >> 16:x}:{value & 65535:x}"
canonical = b"[" + normalized.encode() + b"]"
for h in (literal.encode(), address.exploded.upper().encode()):
for hp in (b"", b":443"):
for op in (b"", b":443"):
origins.append((b"[" + h + b"]" + hp, b"https://" + canonical + op,
b"https://" + canonical))
invalid = [b"[]", b"[1]", b"[1:2:3:4:5:6:7]", b"[1:2:3:4:5:6:7:8:9]",
b"[1:2:3:4:5:6:7:8::]", b"[:::]", b"[1::2::3]", b"[:1]", b"[1:]",
b"[12345::]", b"[gg::]", b"[::1", b"::1]", b"[[::1]]",
b"[::ffff:192.00.2.1]", b"[::ffff:256.0.2.1]", b"[::ffff:192.2.1]",
b"[::ffff:0xc0.0.2.1]", b"[::ffff:192.0.2.1:1]",
b"[fe80::1%eth0]", b"[fe80::1%25eth0]", b"user@[::1]",
b"[::1]:80", b"[::1]:0443", b"[::1]:+443", b"[::1]:443:443",
b"[::1]:", b"[::1]/", b"[::1]?x", b"[::1]#x", b"[::1]@host"]
for c in list(range(33)) + [127, 128, 255]:
invalid.extend((b"[::1" + bytes([c]) + b"]", b"[::1]" + bytes([c])))
for h in invalid:
origins.extend(((h, b"https://" + h, None),
(h, b"https://[::1]", None),
(b"[::1]", b"https://" + h, None)))
for h, o in ((b"[::1]", b"[::2]"), (b"[::1]", b"localhost"),
(b"[::ffff:192.0.2.1]", b"192.0.2.1"),
(b"[::192.0.2.1]", b"[::ffff:192.0.2.1]")):
origins.extend(((h, b"https://" + o, None), (o, b"https://" + h, None)))
# 128 canonical bytes fit with the terminator; 129 do not.
for n in (56, 57):
host = b"a" * 63 + b"." + b"b" * n
+17
View File
@@ -107,6 +107,23 @@ static void admin_tests(void) {
user_principal_t administrator = alice; administrator.role = USER_ROLE_ADMIN;
issued_t user = mint(&bob), admin = mint(&administrator), other = mint(&administrator);
unsigned before = upgrades;
issued_t ipv6_admin;
const char *ipv6_origin = "https://[2001:db8::1]";
assert(web_session_store_issue(&administrator, ipv6_origin, strlen(ipv6_origin),
ipv6_admin.token, &ipv6_admin.view) == ESP_OK);
for (unsigned mode = 0; mode < 3; ++mode) {
begin(WEB_ADMIN_TICKET_URI, HTTP_POST, NULL);
add("Host", mode == 2 ? "[2001:db8::2]" : "[2001:0db8:0:0:0:0:0:1]:443");
add("Origin", mode == 1 ? "https://[2001:db8::1]" : "https://[2001:db8::2]");
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", ipv6_admin.token);
add("Cookie", cookies); add("X-CSRF-Token", ipv6_admin.view.csrf);
assert(web_admin_transport_ticket_handler(&req) == ESP_OK);
assert(!strcmp(response_status, mode == 1 ? "200 OK" : mode == 2 ? "401 Unauthorized" : "403 Forbidden"));
assert(upgrades == before);
}
web_session_store_invalidate(ipv6_admin.view.id);
web_admin_tickets_revoke(0, NULL, 0);
puts("PASS: admin ticket IPv6 canonical Origin accepted, cross-address Origin rejected without upgrade");
for (unsigned mode = 0; mode < 5; ++mode) {
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin,
WEB_ADMIN_TICKET_URI, true, mode != 2, mode != 3);
+49 -4
View File
@@ -7,6 +7,13 @@ 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 bool snapshot_ipv6_case, snapshot_ipv4, snapshot_linklocal, snapshot_routable;
static uint8_t snapshot_ipv6_count;
static const uint8_t snapshot_ipv6_bytes[3][16] = {
{0xfe,0x80,0,0,0,0,0,0,0xea,0x3d,0xc1,0xff,0xfe,0xfa,0x70,0x58},
{0xfd,0x39,0xb3,0x78,0x78,0xf2,0xc7,0x15,0xea,0x3d,0xc1,0xff,0xfe,0xfa,0x70,0x58},
{0x20,0x01,0x0d,0xb8,0,1,0,2,0,3,0,4,0,5,0,6},
};
static void (*timer_callback)(void *);
static void (*owner_hook)(void);
static void (*queue_hook)(void);
@@ -65,8 +72,19 @@ esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *out) {
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;
out->runtime.ipv6_count=snapshot_ipv6_count;
for(unsigned i=0;i<snapshot_ipv6_count && i<3;++i)
memcpy(out->runtime.ipv6_addresses[i].addr,snapshot_ipv6_bytes[i],16);
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
memset(&out->runtime.ip,255,4); out->runtime.ap_client_count=255;
if(snapshot_ipv6_case) {
const uint8_t ip[4]={192,0,2,8};
out->runtime.started=true; out->runtime.state=WIFI_MANAGER_STATE_ONLINE;
out->runtime.active_profile=0; out->runtime.ip=0;
if(snapshot_ipv4) memcpy(&out->runtime.ip,ip,sizeof(ip));
out->runtime.ipv6_linklocal=snapshot_linklocal;
out->runtime.ipv6_routable=snapshot_routable;
}
return ESP_OK;
}
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
@@ -75,7 +93,7 @@ esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
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"; }
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { return state==WIFI_MANAGER_STATE_ONLINE ? "online" : "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");
@@ -117,8 +135,35 @@ static void network_settings_tests(void) {
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));
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
assert(strstr(output,"\"ipv6_addresses\":[]"));
assert(strstr(output,"\"ipv6_linklocal\":false,\"ipv6_routable\":false"));
snapshot_ipv6_case=true;
for(unsigned flags=0;flags<8;++flags) {
snapshot_ipv4=(flags&4)!=0; snapshot_linklocal=(flags&1)!=0; snapshot_routable=(flags&2)!=0;
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strstr(output,"\"state\":\"online\""));
assert(strstr(output,snapshot_ipv4 ? "\"ip\":\"192.0.2.8\"" : "\"ip\":\"0.0.0.0\""));
assert(strstr(output,snapshot_linklocal ? "\"ipv6_linklocal\":true" : "\"ipv6_linklocal\":false"));
assert(strstr(output,snapshot_routable ? "\"ipv6_routable\":true" : "\"ipv6_routable\":false"));
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX);
}
snapshot_ipv6_case=false;
for(snapshot_ipv6_count=1;snapshot_ipv6_count<=3;++snapshot_ipv6_count) {
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strstr(output,"\"ipv6_addresses\":[\"fe80:0000:0000:0000:ea3d:c1ff:fefa:7058\""));
assert((strstr(output,"fd39:b378:78f2:c715:ea3d:c1ff:fefa:7058")!=NULL)==(snapshot_ipv6_count>=2));
assert((strstr(output,"2001:0db8:0001:0002:0003:0004:0005:0006")!=NULL)==(snapshot_ipv6_count==3));
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX);
}
printf("PASS Network maximum escaped SSIDs plus three IPv6 addresses: %zu bytes, no secret fields\n",strlen(output));
assert(strlen(output)>2048);
/* Corrupt owner counts fail closed instead of reading beyond the bounded copy. */
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("503 Service Unavailable",true);
snapshot_ipv6_count=0;
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strstr(output,"\"ipv6_addresses\":[]") && !strstr(output,"fd39:"));
puts("PASS Network IPv6-only/dual-stack: availability flags, bounded network-order address list and stale clearing, no writes");
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);
+19 -1
View File
@@ -19,7 +19,10 @@ 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], cookie_values[2][200];
#if defined(HOST_NETWORK) || defined(HOST_BROKER)
#if defined(HOST_NETWORK)
#include "web_network_settings.h"
static char output[WEB_NETWORK_SNAPSHOT_MAX];
#elif defined(HOST_BROKER)
static char output[2048];
#else
static char output[1024];
@@ -161,6 +164,21 @@ static void auth_reset(void) {
int main(void) {
assert(store_tests() == 0); auth_reset();
char token[65], csrf[65], session[65], cookies[200];
begin("/api/login-challenge", HTTP_GET, NULL);
add("Host", "[2001:0DB8:0:0:0:0:0:1]:443"); add("X-Login-Bootstrap", "1");
expect("200 OK"); token_from(cookie_values[0], token); csrf_from(csrf);
for (unsigned mode = 0; mode < 3; ++mode) {
begin("/api/login", HTTP_POST, good_body);
add("Host", mode == 0 ? "[2001:db8::2]" : "[2001:db8::1]");
add("Origin", mode == 1 ? "https://[2001:db8::2]" :
mode == 0 ? "https://[2001:db8::2]" : "https://[2001:DB8::1]:443");
add("Content-Type", "application/json"); add("X-CSRF-Token", csrf);
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
expect(mode == 2 ? "200 OK" : "403 Forbidden");
assert(password_calls == (mode == 2 ? 1U : 0U));
}
puts("PASS: IPv6 challenge canonical binding, cross-address challenge replay and mismatched Origin rejected before password verification");
auth_reset();
challenge(token, csrf);
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
+10 -2
View File
@@ -40,7 +40,7 @@ A complete example (values are illustrative, never defaults to install):
{"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
]
},
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ap_running": false, "ap_clients": 0, "last_error": 0},
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ipv6_linklocal": true, "ipv6_routable": true, "ap_running": false, "ap_clients": 0, "last_error": 0},
"mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
}
```
@@ -51,6 +51,14 @@ Runtime states are canonical `stopped`, `starting`, `connecting`, `waiting-ip`,
`.local`. The existing responder is STA-only. `announced` is the service's
expected-announcement status, not a client-observed DNS verification.
`ip` is IPv4 only; `0.0.0.0` means absent, including when `state` is `online`.
The required boolean `ipv6_linklocal` and `ipv6_routable` fields report preferred
IPv6 address availability (link-local and ULA/GUA respectively). They do not
assert a default route or Internet reachability. No actual IPv6 literal addresses
are available in this snapshot; reporting is flags only. Settings, quick Network,
and the main status summary label IPv4 absence separately from IPv6 availability.
The OLED network header uses `LL` and `ULA/GUA` with `Y`/`N` (`?` if unavailable).
Wi-Fi working configuration and runtime are copied together under its mutex;
mDNS is a separate consistent projection, not an atomic cross-domain snapshot.
Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503
@@ -202,7 +210,7 @@ remain independent. No terminal lease/transport changes are made by this module.
- 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte
parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte
replacement plus the typed fields. No heap JSON tree/cJSON.
- 2,048-byte snapshot buffer. Maximum escaped fixture: 1,877 payload bytes
- 2,048-byte snapshot buffer. Escaped fixture remains below the fixed limit with both IPv6 booleans
(five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers,
55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
- 128-byte operation response buffer; one static operation and one small timer.
+33 -1
View File
@@ -15,6 +15,7 @@ sys.dont_write_bytecode = True
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
HEADERS = {
'sdkconfig.h': '#pragma once\n#define CONFIG_MDNS_PREDEF_NETIF_STA 1\n#define CONFIG_MDNS_PREDEF_NETIF_AP 0\n#define CONFIG_MDNS_PREDEF_NETIF_ETH 0\n#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3\n',
'esp_err.h': '''#pragma once
typedef int esp_err_t;
#define ESP_OK 0
@@ -34,6 +35,12 @@ typedef int esp_err_t;
#include <stdint.h>
#define pdTRUE 1
#define portMAX_DELAY UINT32_MAX
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
void fake_enter(portMUX_TYPE *);
void fake_exit(portMUX_TYPE *);
#define portENTER_CRITICAL(mux) fake_enter(mux)
#define portEXIT_CRITICAL(mux) fake_exit(mux)
''',
'freertos/semphr.h': '''#pragma once
#include <stdint.h>
@@ -62,8 +69,33 @@ void nvs_close(nvs_handle_t);
#define ESP_MAC_WIFI_SOFTAP 1
esp_err_t esp_read_mac(uint8_t *,int);
''',
'mdns.h': '''#pragma once
'esp_timer.h': '#pragma once\n#include <stdint.h>\nint64_t esp_timer_get_time(void);\n',
'esp_netif.h': '''#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct { int unused; } esp_netif_t;
typedef struct { uint32_t addr; } esp_ip4_addr_t;
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
typedef struct { esp_ip4_addr_t ip, netmask, gw; } esp_netif_ip_info_t;
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
bool esp_netif_is_netif_up(esp_netif_t *);
esp_err_t esp_netif_get_ip_info(esp_netif_t *,esp_netif_ip_info_t *);
int esp_netif_get_all_ip6(esp_netif_t *,esp_ip6_addr_t *);
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *),void *);
''',
'mdns.h': '''#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "esp_netif.h"
typedef enum {
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
} mdns_event_actions_t;
esp_err_t mdns_netif_action(esp_netif_t *,mdns_event_actions_t);
esp_err_t mdns_service_add(const char *,const char *,const char *,uint16_t,const void *,size_t);
esp_err_t mdns_service_remove(const char *,const char *);
esp_err_t mdns_init(void);
esp_err_t mdns_hostname_set(const char *);
esp_err_t mdns_instance_name_set(const char *);
+73 -2
View File
@@ -8,6 +8,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "nvs.h"
#include "mdns.h"
#include "esp_timer.h"
static int wifi_mutex, mdns_mutex;
static SemaphoreHandle_t s_mutex=&wifi_mutex;
@@ -23,8 +25,15 @@ static void lock_shared(void) { assert(!wifi_mutex); wifi_mutex=1; }
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
static int s_drop_mux, s_queue;
static uint64_t s_queue_drops;
#define portENTER_CRITICAL(mux) do { assert((mux)==&s_drop_mux && !s_drop_mux); s_drop_mux=1; } while (0)
#define portEXIT_CRITICAL(mux) do { assert((mux)==&s_drop_mux && s_drop_mux); s_drop_mux=0; } while (0)
static portMUX_TYPE *critical;
void fake_enter(portMUX_TYPE *mux) {
assert(!critical && !*mux);
if(mux!=&s_drop_mux) assert(!wifi_mutex && !mdns_mutex);
critical=mux; *mux=1;
}
void fake_exit(portMUX_TYPE *mux) {
assert(critical==mux && *mux); *mux=0; critical=NULL;
}
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
if(queue_fail) return 0;
@@ -32,6 +41,7 @@ static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait)
}
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
assert(!critical);
if(wait==0 && (snapshot_contention || *mutex)) return 0;
assert(!*mutex); *mutex=1; return pdTRUE;
}
@@ -63,6 +73,39 @@ esp_err_t nvs_set_blob(nvs_handle_t handle,const char *key,const void *data,size
}
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
void nvs_close(nvs_handle_t handle) { (void)handle; }
/* Minimal synchronous netif boundary; queue-loss/repair faults live in mdns_phase12. */
static esp_netif_t sta;
static bool tcpip;
static unsigned netif_families=3, requested_families;
static int64_t clock_us;
int64_t esp_timer_get_time(void) { assert(!mdns_mutex && !critical); return clock_us; }
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
assert(!mdns_mutex && !critical && !strcmp(key,"WIFI_STA_DEF")); return &sta;
}
bool esp_netif_is_netif_up(esp_netif_t *netif) { assert(tcpip && netif==&sta); return true; }
esp_err_t esp_netif_get_ip_info(esp_netif_t *netif,esp_netif_ip_info_t *ip) {
assert(tcpip && netif==&sta); memset(ip,0,sizeof(*ip));
ip->ip.addr=(netif_families&1) ? 1 : 0; return ESP_OK;
}
int esp_netif_get_all_ip6(esp_netif_t *netif,esp_ip6_addr_t *ip) {
assert(tcpip && netif==&sta);
if(!(netif_families&2)) return 0;
memset(ip,0,sizeof(*ip)); ip->addr[0]=0xfe80; return 1;
}
esp_err_t esp_netif_tcpip_exec(esp_err_t (*callback)(void *),void *context) {
assert(!mdns_mutex && !critical && !tcpip); tcpip=true;
esp_err_t error=callback(context); tcpip=false; return error;
}
esp_err_t mdns_netif_action(esp_netif_t *netif,mdns_event_actions_t action) {
assert(netif==&sta && !mdns_mutex && !critical && !tcpip);
assert(action && !(action & ~(MDNS_EVENT_ENABLE_IP4 | MDNS_EVENT_ENABLE_IP6 |
MDNS_EVENT_DISABLE_IP4 | MDNS_EVENT_DISABLE_IP6)));
if(action&MDNS_EVENT_ENABLE_IP4) requested_families|=1;
if(action&MDNS_EVENT_ENABLE_IP6) requested_families|=2;
if(action&MDNS_EVENT_DISABLE_IP4) requested_families&=~1U;
if(action&MDNS_EVENT_DISABLE_IP6) requested_families&=~2U;
return ESP_OK;
}
static char announced_hostname[60];
esp_err_t mdns_init(void) { return ESP_OK; }
esp_err_t mdns_hostname_set(const char *hostname) {
@@ -70,6 +113,13 @@ esp_err_t mdns_hostname_set(const char *hostname) {
}
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
void mdns_free(void) {}
esp_err_t mdns_service_add(const char *name,const char *type,const char *proto,uint16_t port,const void *txt,size_t count) {
assert(!mdns_mutex && !name && type && !strcmp(proto,"_tcp") && (port==443 || port==22) && !txt && !count);
return ESP_OK;
}
esp_err_t mdns_service_remove(const char *type,const char *proto) {
assert(!mdns_mutex && type && !strcmp(proto,"_tcp")); return ESP_OK;
}
#include "manager_production.h"
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
@@ -85,6 +135,20 @@ int main(void) {
snapshot_contention=false;
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
for(unsigned flags=0;flags<8;++flags) {
s_shared.snapshot.state=WIFI_MANAGER_STATE_ONLINE;
s_shared.snapshot.ip=(flags&4) ? UINT32_C(0x080200c0) : 0;
s_shared.snapshot.ipv6_linklocal=(flags&1)!=0;
s_shared.snapshot.ipv6_routable=(flags&2)!=0;
assert(wifi_manager_get_settings(&projection)==ESP_OK);
assert(projection.runtime.state==WIFI_MANAGER_STATE_ONLINE);
assert(projection.runtime.ip==s_shared.snapshot.ip);
assert(projection.runtime.ipv6_linklocal==s_shared.snapshot.ipv6_linklocal);
assert(projection.runtime.ipv6_routable==s_shared.snapshot.ipv6_routable);
}
s_shared.snapshot.state=WIFI_MANAGER_STATE_STOPPED; s_shared.snapshot.ip=0;
s_shared.snapshot.ipv6_linklocal=false; s_shared.snapshot.ipv6_routable=false;
puts("PASS real manager projection: IPv6-only/dual-stack preserve independent availability flags and zero IPv4 online");
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
@@ -188,12 +252,19 @@ int main(void) {
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
assert(requested_families==3);
netif_families=2;
assert(mdns_service_reconcile()==ESP_OK && requested_families==2);
netif_families=3; clock_us+=30000000;
assert(mdns_service_reconcile()==ESP_OK && requested_families==3);
mdns_service_stop();
assert(requested_families==0);
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
unsigned calls=hostname_calls;
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
assert(requested_families==3 && !critical && !tcpip);
assert(mdns_service_get_settings(&m)==ESP_OK);
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);
+94 -6
View File
@@ -6,7 +6,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
ap: {policy: 'fallback', channel: 6, ssid: 'access', password_configured: true},
profiles: Array.from({length: 4}, (_, index) => ({index, enabled: index === 0, priority: index * 10,
security: 'mixed', ssid: index === 0 ? 'office' : '', password_configured: index === 0}))},
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ap_running: true, ap_clients: 1, last_error: 0},
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ipv6_linklocal: false, ipv6_routable: false, ipv6_addresses: [], ap_running: true, ap_clients: 1, last_error: 0},
mdns: {generation: 3, suffix: 'example', hostname: 'sak-example', announced: false, last_error: 0}});
const reply = (id = 42, state = 'pending', action = 'wifi-patch', status = 200, error = 0) => new Response(JSON.stringify({id, action, state, error}), {status});
const ack = action => reply(42, 'pending', action, 202);
@@ -28,6 +28,90 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
b.fire(1000); await tick();
}
await test('IPv6-only and dual-stack availability renders in Settings and quick Network without a route claim', async () => {
for (const quick of [false, true]) for (const ip of ['0.0.0.0', '192.0.2.8']) {
for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false]]) {
const v = fixture(); Object.assign(v.runtime, {state: 'online', ip, ipv6_linklocal: linklocal, ipv6_routable: routable});
const b = quick ? await adminBrowser() : await open(v);
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
assert.equal(n(b, 'edit').hidden, false);
const rows = n(b, 'summary').children;
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
assert.equal(values.IPv4, ip === '0.0.0.0' ? 'none' : ip);
assert.equal(values['IPv6 link-local'], linklocal ? 'available' : 'none');
assert.equal(values['IPv6 ULA/GUA'], routable ? 'available' : 'none');
assert.match(values['IPv6 reporting'], /Preferred addresses; no route or Internet reachability guarantee/);
assert.ok(!n(b, 'summary').textContent.includes('0.0.0.0'));
if (quick) {
assert.ok(n(b, 'detail').textContent.includes('IPv4: ' + values.IPv4));
assert.ok(n(b, 'detail').textContent.includes('IPv6 link-local: ' + values['IPv6 link-local']));
assert.ok(n(b, 'detail').textContent.includes('ULA/GUA: ' + values['IPv6 ULA/GUA']));
}
assert.equal(posts(b).length, 0);
}
}
});
const address = (prefix, tail = '0001') => prefix + ':0000:0000:0000:0000:0000:0000:' + tail;
const groups = {'IPv6 link-local addresses': ['fe80', 'febf'], 'IPv6 ULA addresses': ['fc00', 'fd12'], 'IPv6 GUA addresses': ['2001', '3fff']};
await test('Network preferred IPv6 addresses group every entry as safe text, including empty and same-kind slots', async () => {
const samples = [[], ['fe80', 'fd12', '2001'], ...Object.values(groups).map(([prefix, other]) => [prefix, other, prefix])];
for (const quick of [false, true]) for (const prefixes of samples) {
const v = fixture(); v.runtime.ipv6_addresses = prefixes.map((p, i) => address(p, '000' + (i + 1)));
const b = quick ? await adminBrowser() : await open(v);
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
const rows = n(b, 'summary').children;
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
for (const [label, kinds] of Object.entries(groups)) {
assert.equal(values[label], v.runtime.ipv6_addresses.filter(a => kinds.some(p => a.startsWith(p))).join(', ') || 'none');
}
rows.forEach(node => assert.equal(node.children.length, 0));
assert.match(values['IPv6 reporting'], /Link-local access requires the client interface scope/);
assert.equal(posts(b).length, 0);
}
});
await test('Network rejects missing, malformed, oversized and injected IPv6 address arrays in both controllers', async () => {
const a = address('fe80');
const bad = [undefined, null, {}, a, 3, [a, a, a, a], [null], [1], [true], [{}], [[a]], [''],
['fe80::1'], [a.toUpperCase()], [a + '\n'], [a + '0'], [a.slice(1)], [a.replace('0001', '000g')],
[a + '%eth0'], ['[' + a + ']'], ['<img onerror="SECRET">'], [a, '<script>SECRET</script>']];
for (const quick of [false, true]) {
const b = quick ? await adminBrowser() : await open();
if (quick) { b.queues[path].push(json(fixture())); b.click('quick-network'); await tick(); }
const before = n(b, 'summary').textContent;
for (const value of bad) {
const v = fixture(); v.runtime.ipv6_addresses = value;
b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
assert.match(n(b, 'detail').textContent, /stale.*invalid/);
assert.equal(n(b, 'summary').textContent, before); assert.ok(n(b, 'apply').disabled); safe(b);
}
}
});
await test('Both Network controllers accept exactly 2304 bytes and reject 2305 bytes', async () => {
for (const quick of [false, true]) {
const v = fixture(); v.runtime.ipv6_addresses = ['fe80', 'fd12', '2001'].map(p => address(p));
const encoded = JSON.stringify(v);
const b = quick ? await adminBrowser() : await open();
b.queues[path].push(new Response(encoded.padEnd(2304, ' ')));
b.click(quick ? 'quick-network' : 'network-refresh'); await tick();
assert.equal(n(b, 'apply').disabled, false);
assert.ok(n(b, 'summary').textContent.includes(address('2001')));
b.queues[path].push(new Response(encoded.padEnd(2305, ' '))); b.click('network-refresh'); await tick();
assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled);
}
});
await test('Main status reports IPv6-only/dual-stack flags and treats malformed or missing flags as unknown', async () => {
for (const ip of ['0.0.0.0', '192.0.2.8']) for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false], [1, true], [true, 'false'], [null, false], [false, undefined]]) {
const b = browser();
b.queues['/api/status'].push(json({wifi: {available: true, state: 'online', sta_ipv4: ip, ipv6_linklocal: linklocal, ipv6_routable: routable}}));
b.start(); await tick(); const text = b.nodes['wifi-summary'].textContent;
assert.ok(text.includes('IPv4: ' + (ip === '0.0.0.0' ? 'none' : ip)));
assert.ok(!text.includes('0.0.0.0'));
if (typeof linklocal === 'boolean' && typeof routable === 'boolean') {
assert.ok(text.includes('IPv6 link-local: ' + (linklocal ? 'available' : 'none')));
assert.ok(text.includes('ULA/GUA: ' + (routable ? 'available' : 'none')));
} else assert.ok(text.includes('IPv6 availability: unknown'));
}
});
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
b.click('quick-network'); await tick();
@@ -148,7 +232,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
const b = await open(v), summary = n(b, 'summary');
const rows = summary.children;
assert.equal(rows.length, 58);
assert.equal(rows.length, 70);
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
@@ -162,7 +246,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
assert.ok(rows.every(node => node.parentNode === null));
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
assert.equal(summary.children.length, 58); assert.ok(!summary.textContent.includes('<img'));
assert.equal(summary.children.length, 70); assert.ok(!summary.textContent.includes('<img'));
});
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
@@ -176,6 +260,10 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
v => v.runtime.state = '<img>', v => v.runtime.started = 1, v => v.runtime.active_profile = 4, v => v.runtime.active_profile = -2,
v => v.runtime.ip = '256.1.1.1', v => v.runtime.ip = '<img>', v => v.runtime.ap_running = 1, v => v.runtime.ap_clients = 256, v => v.runtime.last_error = 2147483648,
v => v.mdns.suffix = 'A', v => v.mdns.suffix = '-x', v => v.mdns.suffix = 'x-', v => v.mdns.hostname = 'not-matching', v => v.mdns.announced = 0, v => v.mdns.last_error = null];
for (const key of ['ipv6_linklocal', 'ipv6_routable']) {
edits.push(v => delete v.runtime[key]);
for (const value of [null, 0, 1, 'true', 'false', [], {}]) edits.push(v => v.runtime[key] = value);
}
const b = await open(), before = n(b, 'summary').textContent;
for (const change of edits) {
const v = fixture(); change(v); b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
@@ -185,16 +273,16 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); assert.ok(n(b, 'apply').disabled);
}
});
await test('Network snapshot 2048-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
await test('Network snapshot 2304-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
const b = await open();
for (const response of [new Response(' '.repeat(2049)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
for (const response of [new Response(' '.repeat(2305)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
b.queues[path].push(response); b.click('network-refresh'); await tick(); assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled); safe(b);
}
const v = fixture(); v.wifi.ap.ssid = '\xff'.repeat(32); v.wifi.generation = v.mdns.generation = 4294967295;
for (const p of v.wifi.profiles) p.ssid = '\xff'.repeat(32);
v.mdns.suffix = 'a'.repeat(55); v.mdns.hostname = 'sak-' + v.mdns.suffix;
const encoded = JSON.stringify(v).replace(/[\x7f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
assert.ok(Buffer.byteLength(encoded) < 2048); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
assert.ok(Buffer.byteLength(encoded) < 2304); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
assert.equal(n(b, 'edit').hidden, false); assert.equal(n(b, 'apply').disabled, false); assert.equal(n(b, 'ssid-mode').value, 'hex');
v.wifi.ap.ssid = '<img onerror="x">'; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
assert.equal(n(b, 'ssid').value, '<img onerror="x">'); assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify('<img onerror="x">')));
+47
View File
@@ -0,0 +1,47 @@
# Wi-Fi Phase 12 focused host regression
Run `python3 tests/wifi_phase12/run.py`. The renderer tests use the installed public `esp_netif_ip_addr.h` at `~/.platformio/packages/framework-espidf/components/esp_netif/include/` with host endian/config shims (no SDK modification). Optional `--sanitize` requires host ASan/UBSan runtime libraries. No PlatformIO build or device access is performed. Compilation/executables use a temporary directory.
The harness extracts production hostname, address reconciliation, IP callback, and deadline functions verbatim. Real manager/config headers define the data contracts. Host fakes model driver association, netif-up state, IPv6 slot states, TCP/IP execution, and clock; surrounding failover/driver operations are call-count fakes, not a full scheduler/network simulation. Wiring assertions cover pre-connect hostname installation and message dispatch.
Coverage includes:
- Configured hostname and rename without disconnect; full 59-byte hostname; config-read failure.
- STA-only IPv6 event filtering, null events, and IPv4 netif filtering.
- Explicit STA SLAAC enablement, link-local creation, no repeated tentative/duplicate DAD.
- Link-local-only, ULA/GUA, IPv4-only, and dual-stack readiness.
- IPv4 loss while IPv6 remains, IPv6 deprecation/removal and restoration.
- No IPv4 deadline failover on usable IPv6; no perpetual fallback stability reset.
- Dropped address/association events, dropped online disconnect, recreated LL after missed disconnect/reconnect, and stale events during stop/profile advance.
- Permanent owner wakeup, including boot-disabled and stopped Wi-Fi; offline service withdrawal, failed record updates and pending reannouncement retries at one-second cadence.
- Busy-loop calls do not repeat periodic work; persistent mDNS failure does not generate per-pass warnings.
- Permanent hostname pointer lifetime, failed offline rename retry, and mutex/TCP-IP-context assertions for address and hostname access.
- Address-read failure/uncertain callback completion withdraws stale readiness without retiring the recovery AP; later successful reads restore readiness.
- Verbatim production console rendering of simultaneous preferred link-local, ULA and GUA addresses, with non-symmetric network-order words and IDF public formatting macros; client-interface zone guidance never renders the ESP32 zone.
- Full numeric list and count clearing on failed callback admission, skipped callback, IP-info error, netif-down and the production disconnect-clear helper; wiring assertions require intentional disconnect, stop and disconnect-event paths to call that helper. Tentative, duplicate and deprecated slots are excluded by the preferred-getter fake matching SDK semantics.
- Increasing configured SDK slots to four fails the production compile-time capacity assertion.
## Contract and SDK evidence
`ONLINE` means a current association/netif with IPv4 or a **preferred** IPv6 link-local/ULA/GUA address. It is not an Internet/default-route check. Deprecated-only addresses do not qualify for new service readiness. In particular, link-local-only operation suppresses DHCPv4-driven profile failover and can retire the fallback AP after the existing stability interval. IPv6 link-local clients need an interface scope. Snapshot IPv4 fields remain zero on IPv6-only networks; `ipv6_linklocal` / `ipv6_routable` booleans report preferred-address availability, not routing success. The console also lists up to three numeric preferred IPv6 addresses from the exact same TCP/IP observation as those flags. Count and all address storage are published/cleared together under the manager mutex. The configured lwIP slot count must not exceed three (compile-time contract, no silent truncation). Web/OLED continue consuming the existing flags without adding list output.
Inspected installed ESP-IDF 5.5.0 sources:
- `esp_netif/lwip/esp_netif_lwip.c`: netif-up and address getters access lwIP directly; all manager netif-up/address checks now execute inside the TCP/IP callback. Preferred getter excludes tentative/duplicate/deprecated/invalid addresses. Disconnect invalidates/clears IPv6 slots. `esp_netif_tcpip_exec` supplies synchronous TCP/IP context, with no retained stack request on return. Its IDF 5.5 wrapper ignores the underlying `tcpip_send_msg_wait_sem` result: explicit completion markers therefore detect a callback that was never executed despite an apparent `ESP_OK`. No hard wall-clock guarantee is made if upstream stalls.
- `lwip/port/include/lwipopts.h`, `lwip/src/include/lwip/opt.h`, and `esp_netif_start_api`: saved `CONFIG_LWIP_IPV6_AUTOCONFIG` is disabled, but lwIP SLAAC remains compiled via `LWIP_IPV6_AUTOCONFIG`. Explicit per-STA `netif_set_ip6_autoconfig_enabled` is needed; AP policy is untouched.
- `esp_netif_set_hostname_api` has a 32-byte limit, below the existing configured hostname maximum of 59. The manager therefore installs permanent bounded storage through `netif_set_hostname` in TCP/IP context, without transferring ownership to esp-netif. `netif_add` does not clear the hostname; `esp_netif/lwip/netif/wlanif.c` preserves it via `esp_netif_get_hostname` during initialization.
- `lwip/src/core/ipv4/dhcp.c`: option 12 is read from the current netif hostname in subsequent outgoing DHCP exchanges (request/renew/rebind included). Rename does not force release/reacquisition or promise immediate router/DNS cache replacement.
Reverify these boundaries for an SDK upgrade. Tests do not emulate DHCP wire packets, router advertisements, actual DAD timers, mDNS component behavior, or socket listeners. A target syntax check is not a linked firmware build. Device and integration validation remain separate.
## Coordination
The owner calls the new `mdns_service_reconcile` every second even offline/stopped. A pending online reannouncement uses `mdns_service_reannounce`, which also reconciles records. Listener owners' lock-free availability stores need no queue wakeup; changes and failures converge on subsequent passes. Reconciliation never initializes the responder; startup still requires either-family STA readiness. Failures remain nonfatal, visible through service error status, and do not log each retry. Upstream mDNS calls may block: the cadence bounds attempt frequency, not upstream execution time.
`wifi_manager_mdns_reannounce` also refreshes DHCP option 12 while offline. Periodic hostname refresh retries failed/missed DHCP-name changes without reconnecting. The permanent hostname buffer is only read/written in TCP/IP context; no application status caller borrows that pointer (use the copied mDNS snapshot). Pending mDNS rename errors retry online. Other owners should consume the IPv6 snapshot flags rather than infer readiness from `snapshot.ip != 0`.
## Diagnostic addition resource budget and handoff
The numeric payload is three arrays of four network-order `uint32_t` words (48 bytes), plus a one-byte count that fits existing snapshot padding. No strings, device zones, task, socket, heap allocation or queue-message growth are introduced. Host layout measurements with production headers: snapshot 232 → 280 bytes; settings projection 424 → 472 bytes; manager shared storage 760 → 808 bytes; temporary address observation 16 → 64 bytes. Thus the manager's static storage grows by 48 bytes, and each existing snapshot/settings copy (console, web, local UI) grows by 48 bytes. The owner observation adds 48 bytes of transient stack payload; the existing three-entry SDK enumeration scratch is unchanged. Actual compiler stack-frame/high-water effects and linked target RAM remain for the parent's build/device checks; host sizes are not runtime headroom evidence.
Scoped validation: normal host suite and slot-cap negative compile PASS. `--sanitize` could not link on this host because `libasan.so.8.0.0` / `libubsan.so.1.0.0` are missing. No dependency changes, PlatformIO build or hardware operation performed. Parent owns durable documentation updates (the flags-only statements in agent memory/command docs are now stale for console status) and firmware build. Production edits are limited to `src/wifi_manager.c`, `src/wifi_manager.h`, and `src/wifi_console.c`; tests/docs for this addition stay here.
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Compile verbatim manager address/deadline paths with bounded host fakes."""
from pathlib import Path
import subprocess
import tempfile
import sys
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
source = (ROOT / 'src/wifi_manager.c').read_text()
def function(name):
# Definitions end at a column-zero brace; skip forward declarations.
import re
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\)\n\{', source, re.M)
assert match, name
start = match.start()
return source[start:source.index('\n}', match.end()) + 2]
types = source[source.index('typedef enum {'):source.index('static SemaphoreHandle_t')]
addresses = source[source.index('typedef struct {\n esp_netif_ip_info_t ip4;'):source.index('/* IDF\'s IPv6 getters')]
hostname_request = source[source.index('typedef struct {\n const char *hostname;'):source.index('static esp_err_t set_station_hostname')]
names = ['set_station_hostname', 'refresh_station_hostname', 'start_mdns_announcement',
'read_station_addresses', 'handle_got_ip', 'clear_station_network_snapshot',
'handle_expired_deadlines', 'next_runtime_deadline', 'runtime_wait_ticks',
'ip_event_callback']
# Check wiring not present in the extracted paths.
connect = function('start_next_profile')
assert connect.index('refresh_station_hostname()') < connect.index('esp_wifi_connect()')
message = function('handle_message')
assert 'case MESSAGE_STA_GOT_IP6:' in message
assert 'refresh_station_hostname()' in message
assert 'handle_got_ip(runtime, message);' in message
assert '#define WIFI_MANAGER_QUEUE_LENGTH 16U' in source
assert '#define WIFI_MANAGER_TASK_STACK_SIZE 6144U' in source
assert 'esp_netif_tcpip_exec(read_station_addresses, &addresses)' in source
assert 'esp_netif_is_netif_up' not in function('handle_got_ip')
assert 'mdns_service_reconcile()' in function('handle_expired_deadlines')
assert 'runtime->radio_started ? runtime->reconcile_deadline' not in source
for name in ['handle_sta_disconnected', 'mark_intentional_disconnect', 'stop_radio']:
assert 'clear_station_network_snapshot();' in function(name)
with tempfile.TemporaryDirectory(prefix='wifi-phase12-') as directory:
tmp = Path(directory)
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n#define ESP_OK 0\n#define ESP_FAIL -1\n#define ESP_ERR_INVALID_STATE 1\n#define ESP_ERR_INVALID_ARG 2\n')
(tmp / 'esp_wifi_types.h').write_text('#pragma once\ntypedef int wifi_auth_mode_t;\n')
# Use the installed IDF public address types/macros, not a reimplementation.
sdk_header = Path.home() / '.platformio/packages/framework-espidf/components/esp_netif/include/esp_netif_ip_addr.h'
(tmp / 'esp_netif_ip_addr.h').write_text(sdk_header.read_text())
(tmp / 'machine').mkdir()
(tmp / 'machine/endian.h').write_text('#include <endian.h>\n')
(tmp / 'sdkconfig.h').write_text('#define CONFIG_LWIP_IPV6 1\n')
(tmp / 'types.inc').write_text(types + addresses + hostname_request)
console = (ROOT / 'src/wifi_console.c').read_text()
start = console.index('static void print_ipv6_addresses(')
(tmp / 'console.inc').write_text(console[start:console.index('\n}', start) + 2])
assert 'print_ipv6_addresses(&snapshot);' in console
(tmp / 'production.inc').write_text('\n\n'.join(function(name) for name in names))
sanitizer = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else []
subprocess.run(['cc', '-std=gnu17', '-Wall', '-Wextra', '-Werror', *sanitizer,
'-I' + str(tmp), '-I' + str(ROOT / 'src'), str(HERE / 'test.c'),
'-o', str(tmp / 'test')], check=True, timeout=30)
subprocess.run([str(tmp / 'test')], check=True, timeout=20)
# Increasing SDK slots must fail compilation rather than truncate/overrun.
oversized = (HERE / 'test.c').read_text().replace('#define LWIP_IPV6_NUM_ADDRESSES 3', '#define LWIP_IPV6_NUM_ADDRESSES 4')
(tmp / 'oversized.c').write_text(oversized)
result = subprocess.run(['cc', '-std=gnu17', '-I' + str(tmp), '-I' + str(ROOT / 'src'),
'-fsyntax-only', str(tmp / 'oversized.c')], capture_output=True, text=True)
assert result.returncode != 0 and 'IPv6 snapshot capacity' in result.stderr
print('IPv6 slot-capacity compile contract PASS')
header = (ROOT / 'src/wifi_manager.h').read_text()
baseline = header.replace(' uint8_t ipv6_count;\n', '').replace(
' wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];\n', '')
(tmp / 'baseline.h').write_text(baseline)
(tmp / 'sizes.c').write_text('#include <stdio.h>\n#include "baseline.h"\n'
'int main(void) { printf("baseline snapshot=%zu settings=%zu bytes\\n", '
'sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t)); }\n')
subprocess.run(['cc', '-I' + str(tmp), '-I' + str(ROOT / 'src'), str(tmp / 'sizes.c'),
'-o', str(tmp / 'sizes')], check=True, timeout=30)
subprocess.run([str(tmp / 'sizes')], check=True, timeout=20)
+332
View File
@@ -0,0 +1,332 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include "esp_netif_ip_addr.h"
#define WIFI_AUTH_OPEN 0
#include "wifi_manager.h"
#include "mdns_service.h"
#define LWIP_NETIF_HOSTNAME 1
#define CONFIG_LWIP_IPV6 1
static char s_station_hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
#define LWIP_IPV6_AUTOCONFIG 1
#define LWIP_IPV6_NUM_ADDRESSES 3
#define IP6_ADDR_INVALID 0
#define TENTATIVE 1
#define PREFERRED 2
#define DEPRECATED 3
#define DUPLICATE 4
#define WIFI_MANAGER_ATTEMPT_US 12000000LL
#define WIFI_MANAGER_STABLE_US 30000000LL
#define WIFI_MANAGER_RECONCILE_US 1000000LL
#define WIFI_MANAGER_DISCONNECT_SETTLE_US 1000000LL
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2
#define ESP_LOGW(...) (++warnings)
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(x) (x)
typedef uint32_t TickType_t;
typedef struct { uint32_t addr; } ip4_t;
typedef struct { ip4_t ip, netmask, gw; } esp_netif_ip_info_t;
typedef struct { uint8_t ssid[33], primary; int8_t rssi; int authmode; } wifi_ap_record_t;
typedef struct { int unused; } esp_netif_t;
static esp_netif_t sta, other;
static esp_netif_t *s_sta_netif = &sta;
typedef const char *esp_event_base_t;
static const char *IP_EVENT = "ip";
enum { IP_EVENT_STA_GOT_IP, IP_EVENT_STA_LOST_IP, IP_EVENT_GOT_IP6 };
typedef struct { esp_netif_t *esp_netif; esp_netif_ip_info_t ip_info; } ip_event_got_ip_t;
typedef struct { esp_netif_t *esp_netif; } ip_event_got_ip6_t;
#include "types.inc"
static manager_shared_t s_shared;
struct netif { int state[3]; bool autoconfig; esp_ip6_addr_t addresses[3]; const char *hostname; };
static struct netif netif;
static bool up, associated, matching, in_tcpip, byte_ssid;
static int64_t now;
static esp_netif_ip_info_t ip4;
static int mdns_starts, mdns_stops, disconnects, next_profiles, cycles, ap_disables;
static int creations, messages, mdns_reconciles, mdns_reannounces, tcpip_calls;
static int shared_locks, warnings;
static esp_err_t tcpip_error, mdns_error, ip_info_error;
static bool https_available, https_registered, tcpip_skip;
static manager_message_t last_message;
static char hostname[64], configured[64];
static esp_err_t hostname_error;
static void lock_shared(void) { assert(shared_locks == 0); ++shared_locks; }
static void unlock_shared(void) { assert(shared_locks == 1); --shared_locks; }
static bool manager_is_started(void) { return s_shared.snapshot.started; }
static void set_state(wifi_manager_state_t state) { s_shared.snapshot.state = state; }
static void set_last_error(esp_err_t error) { s_shared.snapshot.last_error = error; }
static int64_t esp_timer_get_time(void) { return now; }
static bool esp_netif_is_netif_up(esp_netif_t *n) { assert(n == &sta && in_tcpip); return up; }
static esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *out)
{ assert(n == &sta && in_tcpip); *out = ip4; return ip_info_error; }
static void *esp_netif_get_netif_impl(esp_netif_t *n)
{ assert(n == &sta && in_tcpip); return &netif; }
static void netif_set_ip6_autoconfig_enabled(struct netif *n, int enabled)
{ assert(in_tcpip); n->autoconfig = enabled; }
static int netif_ip6_addr_state(struct netif *n, int slot) { return n->state[slot]; }
static void netif_create_ip6_linklocal_address(struct netif *n, int mac)
{ assert(in_tcpip && mac == 1); ++creations; n->state[0] = TENTATIVE; }
static int esp_netif_get_all_preferred_ip6(esp_netif_t *n, esp_ip6_addr_t *out)
{
assert(n == &sta && in_tcpip);
int count = 0;
for (int i = 0; i < 3; ++i) if (netif.state[i] == PREFERRED) out[count++] = netif.addresses[i];
return count;
}
static esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *context)
{ assert(!in_tcpip && shared_locks == 0); ++tcpip_calls;
if (tcpip_error != ESP_OK) return tcpip_error;
if (tcpip_skip) return ESP_OK; /* IDF wrapper ignores failed enqueue. */
in_tcpip = true; esp_err_t e = fn(context); in_tcpip = false; return e; }
static esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *out)
{ memset(out, 0, sizeof(*out)); memcpy(out->ssid, "test", 4);
if (byte_ssid) out->ssid[1] = 0;
return associated ? ESP_OK : ESP_FAIL; }
static bool connected_event_matches_active_profile(const manager_message_t *m)
{ assert(m->data.connected.ssid_len == 4); return matching; }
static void copy_working_config(wifi_app_config_t *out) { *out = s_shared.config; }
void wifi_config_secure_wipe(void *p, size_t n) { memset(p, 0, n); }
esp_err_t mdns_service_start(void) { ++mdns_starts; return mdns_error; }
esp_err_t mdns_service_reconcile(void)
{
assert(!in_tcpip && shared_locks == 0);
++mdns_reconciles;
if (mdns_error == ESP_OK) https_registered = https_available;
return mdns_error;
}
esp_err_t mdns_service_reannounce(void)
{ ++mdns_reannounces; return mdns_service_reconcile(); }
void mdns_service_stop(void) { ++mdns_stops; }
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *out)
{ memset(out, 0, sizeof(*out)); strcpy(out->hostname, configured); return hostname_error; }
static void netif_set_hostname(struct netif *n, const char *name)
{ assert(n == &netif && in_tcpip); n->hostname = name; strcpy(hostname, name); }
static void handle_sta_disconnected(manager_runtime_t *r, const manager_message_t *m)
{ (void)m; r->online = r->associated = false; ++disconnects; }
static void mark_intentional_disconnect(manager_runtime_t *r)
{ ++disconnects; ++r->intentional_disconnects; r->online = r->associated = false; }
static void start_next_profile(manager_runtime_t *r) { (void)r; ++next_profiles; }
static void start_profile_cycle(manager_runtime_t *r) { (void)r; ++cycles; }
static void start_radio_and_policy(manager_runtime_t *r) { (void)r; }
static esp_err_t esp_wifi_disconnect(void) { ++disconnects; return ESP_OK; }
static esp_err_t set_runtime_ap_enabled(manager_runtime_t *r, bool enable)
{ r->ap_enabled = enable; if (!enable) ++ap_disables; return ESP_OK; }
static bool enqueue_message(const manager_message_t *m) { last_message = *m; ++messages; return true; }
#include "production.inc"
static char rendered[2048];
static int capture_printf(const char *format, ...)
{
size_t used = strlen(rendered);
va_list args;
va_start(args, format);
int n = vsnprintf(rendered + used, sizeof(rendered) - used, format, args);
va_end(args);
assert(n >= 0 && (size_t)n < sizeof(rendered) - used);
return n;
}
#define printf capture_printf
#include "console.inc"
#undef printf
static void assert_empty_addresses(void)
{
const wifi_manager_ipv6_address_t zero[WIFI_MANAGER_IPV6_MAX_ADDRESSES] = {0};
assert(s_shared.snapshot.ipv6_count == 0);
assert(!s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
assert(memcmp(zero, s_shared.snapshot.ipv6_addresses, sizeof(zero)) == 0);
}
static manager_runtime_t reset(void)
{
memset(&s_shared, 0, sizeof(s_shared)); memset(&netif, 0, sizeof(netif));
memset(&ip4, 0, sizeof(ip4));
up = associated = matching = true; now = 1000000; byte_ssid = false;
mdns_starts = mdns_stops = disconnects = next_profiles = cycles = ap_disables = 0;
creations = messages = mdns_reconciles = mdns_reannounces = tcpip_calls = 0;
hostname_error = tcpip_error = mdns_error = ip_info_error = ESP_OK;
warnings = 0;
https_available = https_registered = tcpip_skip = false;
strcpy(configured, "sak-test");
s_shared.snapshot.started = true;
s_shared.snapshot.sta_ssid_len = 4;
s_shared.config.ap_policy = WIFI_CONFIG_AP_POLICY_FALLBACK;
manager_runtime_t r = { .radio_started = true, .attempt_deadline = now + 12000000 };
return r;
}
static void address(int slot, uint8_t first, uint8_t second, int state)
{
memset(&netif.addresses[slot], 0, sizeof(netif.addresses[slot]));
uint8_t *bytes = (uint8_t *)netif.addresses[slot].addr;
bytes[0] = first; bytes[1] = second; bytes[15] = 1;
netif.state[slot] = state;
}
int main(void)
{
manager_runtime_t r = reset();
strcpy(configured, "sak-first"); assert(refresh_station_hostname() == ESP_OK);
assert(!strcmp(hostname, "sak-first"));
strcpy(configured, "sak-renamed"); assert(refresh_station_hostname() == ESP_OK);
assert(!strcmp(hostname, "sak-renamed") && disconnects == 0);
memset(configured, 'a', 59); memcpy(configured, "sak-", 4); configured[59] = 0;
assert(refresh_station_hostname() == ESP_OK && strlen(hostname) == 59);
assert(netif.hostname == s_station_hostname); /* No captured stack pointer. */
assert(!strcmp(netif.hostname, configured));
hostname_error = ESP_FAIL; assert(refresh_station_hostname() == ESP_FAIL);
r = reset(); byte_ssid = true; ip4.ip.addr = 7;
handle_got_ip(&r, NULL); assert(r.online); /* Embedded NUL keeps profile length. */
r = reset(); handle_got_ip(&r, NULL);
assert(netif.autoconfig && creations == 1 && !r.online);
handle_got_ip(&r, NULL); assert(creations == 1); /* Do not restart DAD. */
address(0, 0xfe, 0x80, PREFERRED); now = r.attempt_deadline;
handle_expired_deadlines(&r); /* dropped CONNECTED and GOT_IP6 */
assert(r.online && r.attempt_deadline == 0 && disconnects == 0);
assert(s_shared.snapshot.ipv6_linklocal && s_shared.snapshot.ip == 0);
assert(mdns_starts == 1);
int64_t stable = r.stable_deadline;
now += 1000000; handle_expired_deadlines(&r);
assert(r.stable_deadline == stable && mdns_starts == 1);
now = stable; handle_expired_deadlines(&r); assert(ap_disables == 1);
assert(runtime_wait_ticks(&r) <= 1000); /* Never sleep indefinitely online. */
address(1, 0xfd, 0x12, PREFERRED); handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ipv6_routable);
ip4.ip.addr = 123; handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ip == 123 && s_shared.snapshot.counters.got_ip == 1);
ip4.ip.addr = 0; handle_got_ip(&r, NULL);
assert(r.online && !s_shared.snapshot.ip && mdns_stops == 0);
address(0, 0xfe, 0x80, DEPRECATED); address(1, 0xfd, 0x12, DEPRECATED);
now += 1000000; handle_expired_deadlines(&r);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
assert(mdns_stops == 1 && r.attempt_deadline > now);
address(1, 0x20, 0x01, PREFERRED); handle_got_ip(&r, NULL);
assert(r.online && s_shared.snapshot.ipv6_routable && mdns_starts == 2);
r = reset(); address(0, 0xfe, 0x80, DUPLICATE);
now = r.attempt_deadline; handle_expired_deadlines(&r);
assert(!r.online && creations == 0 && disconnects == 1);
assert(r.advance_after_disconnect && r.disconnect_deadline > now);
r = reset(); address(0, 0xfe, 0x80, TENTATIVE); ip4.ip.addr = 7;
handle_got_ip(&r, NULL); assert(r.online && !s_shared.snapshot.ipv6_linklocal);
ip4.ip.addr = 0; associated = false;
now += 1000000; handle_expired_deadlines(&r); assert(disconnects == 1 && !r.online);
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
r.stop_pending = true; handle_got_ip(&r, NULL); assert(!r.online);
r.stop_pending = false; r.advance_after_disconnect = true;
handle_got_ip(&r, NULL); assert(!r.online);
r.advance_after_disconnect = false; matching = false;
handle_got_ip(&r, NULL); assert(!r.online);
matching = true; up = false; handle_got_ip(&r, NULL); assert(!r.online);
up = true; handle_got_ip(&r, NULL); assert(r.online);
netif.state[0] = IP6_ADDR_INVALID; /* missed disconnect+connect: re-create LL */
handle_got_ip(&r, NULL); assert(creations == 1 && !r.online);
ip_event_got_ip6_t event6 = { .esp_netif = &other };
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6); assert(messages == 0);
event6.esp_netif = &sta;
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6);
assert(messages == 1 && last_message.type == MESSAGE_STA_GOT_IP6);
ip_event_got_ip_t event4 = { .esp_netif = &other };
ip_event_callback(NULL, IP_EVENT, IP_EVENT_STA_GOT_IP, &event4); assert(messages == 1);
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, NULL); assert(messages == 1);
r = reset(); mdns_error = ESP_FAIL;
start_mdns_announcement(&r); start_mdns_announcement(&r);
assert(warnings == 1 && r.mdns_reannounce_pending);
for (int i = 0; i < 5; ++i) {
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
}
assert(warnings == 1); /* No per-pass warning on persistent failure. */
r = reset(); r.radio_started = false; r.attempt_deadline = 0;
s_shared.snapshot.started = false; associated = up = false;
assert(runtime_wait_ticks(&r) == 0); /* Boot with Wi-Fi disabled. */
https_registered = true;
handle_expired_deadlines(&r);
assert(mdns_reconciles == 1 && !https_registered && creations == 0);
assert(runtime_wait_ticks(&r) == 1000);
for (int i = 0; i < 100; ++i) handle_expired_deadlines(&r);
assert(mdns_reconciles == 1); /* Busy owner queue cannot cause retry spam. */
mdns_error = ESP_FAIL; https_available = true;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(mdns_reconciles == 2 && !https_registered);
mdns_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(mdns_reconciles == 3 && https_registered && !r.radio_started);
/* Failed offline rename is retried without a lifecycle command. */
tcpip_error = ESP_FAIL; strcpy(configured, "sak-retry");
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(strcmp(hostname, configured) != 0);
tcpip_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(!strcmp(hostname, configured) && netif.hostname == s_station_hostname);
assert(disconnects == 0 && !r.radio_started);
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
handle_got_ip(&r, NULL); assert(r.online);
r.mdns_reannounce_pending = true; mdns_error = ESP_FAIL;
handle_expired_deadlines(&r);
assert(r.mdns_reannounce_pending && mdns_reannounces == 1);
mdns_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(!r.mdns_reannounce_pending && mdns_reannounces == 2);
tcpip_error = ESP_FAIL; now = r.stable_deadline;
handle_expired_deadlines(&r);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && ap_disables == 0);
assert(mdns_stops == 1 && r.attempt_deadline > now);
tcpip_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(r.online && s_shared.snapshot.ipv6_linklocal && disconnects == 0);
tcpip_skip = true;
assert(refresh_station_hostname() == ESP_FAIL);
handle_got_ip(&r, NULL);
assert(!r.online && s_shared.snapshot.last_error == ESP_FAIL);
tcpip_skip = false; handle_got_ip(&r, NULL); assert(r.online);
/* Netif-down with a stale driver association must withdraw readiness. */
up = false; handle_got_ip(&r, NULL);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal);
assert_empty_addresses();
r = reset();
address(0, 0xfe, 0x80, PREFERRED);
address(1, 0xfd, 0x12, PREFERRED);
address(2, 0x20, 0x01, PREFERRED);
netif.addresses[2].addr[1] = esp_netif_htonl(0x12345678);
netif.addresses[2].addr[2] = esp_netif_htonl(0x9abcdef0);
netif.addresses[2].zone = 42;
handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ipv6_count == 3);
for (int i = 0; i < 3; ++i)
assert(memcmp(s_shared.snapshot.ipv6_addresses[i].addr, netif.addresses[i].addr, 16) == 0);
print_ipv6_addresses(&s_shared.snapshot);
assert(strstr(rendered, "IPv6 preferred link-local: fe80:0000:0000:0000:0000:0000:0000:0001\n"));
assert(strstr(rendered, "IPv6 preferred ULA: fd12:0000:0000:0000:0000:0000:0000:0001\n"));
assert(strstr(rendered, "IPv6 preferred GUA: 2001:0000:1234:5678:9abc:def0:0000:0001\n"));
assert(strstr(rendered, "client's interface") && !strstr(rendered, "%42"));
netif.state[0] = TENTATIVE; netif.state[1] = DUPLICATE; netif.state[2] = DEPRECATED;
handle_got_ip(&r, NULL); assert_empty_addresses();
rendered[0] = 0; print_ipv6_addresses(&s_shared.snapshot);
assert(strstr(rendered, "IPv6 preferred addresses: none") && !strstr(rendered, "IPv6 preferred GUA:"));
for (int failure = 0; failure < 5; ++failure) {
address(0, 0xfe, 0x80, PREFERRED); address(1, 0xfd, 0x12, PREFERRED);
handle_got_ip(&r, NULL); assert(s_shared.snapshot.ipv6_count == 2);
if (failure == 0) tcpip_error = ESP_FAIL;
if (failure == 1) tcpip_skip = true;
if (failure == 2) up = false;
if (failure == 4) ip_info_error = ESP_FAIL;
if (failure == 3) clear_station_network_snapshot();
else handle_got_ip(&r, NULL);
assert_empty_addresses();
tcpip_error = ip_info_error = ESP_OK; tcpip_skip = false; up = true;
}
printf("snapshot=%zu settings=%zu shared=%zu observation=%zu bytes\n",
sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t),
sizeof(manager_shared_t), sizeof(station_addresses_t));
puts("wifi_phase12: hostname, IPv4/IPv6 lifecycle, deadlines, offline mDNS retries, TCP/IP ownership and event filtering PASS");
}