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:
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 267–277) 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 282–301), 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 2022–2025 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.
|
||||
Executable
+157
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 *);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">')));
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user