Complete Phase 12 dual-stack networking

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

Include a guarded build-local fix for mDNS 1.12.0 membership handling,
focused regression suites, and Phase 12 acceptance documentation.
This commit is contained in:
2026-09-20 22:35:34 +02:00
parent ece4ba77e3
commit 8902b25d78
52 changed files with 3042 additions and 163 deletions
+17 -2
View File
@@ -14,7 +14,7 @@ parser substitutes, network access, or persistent build artifacts are used.
Compilation errors and test failures produce nonzero exit status.
Tables cover DNS/IPv4 origin canonicalization, case folding and optional `:443`,
malformed authorities/origins and unsupported IPv6; selected cookie presence,
bracketed IPv6 canonicalization and malformed authorities/origins; selected cookie presence,
uniqueness, exact lowercase 64-digit hex and surrounding cookie syntax; strict
login JSON, both field orders, escapes, Unicode/surrogate pairs, invalid UTF-8,
NUL, duplicate/unknown fields, truncation and byte limits (512-byte body,
@@ -31,7 +31,22 @@ bytes. Successful results check canonical/decoded bytes and termination.
- This is a focused parser contract suite, not HTTP integration, authorization,
CSRF/session, duplicate HTTP header-line, TLS, credential-policy, or hardware
testing. Empty credentials are syntactically valid; database policy is separate.
- IPv6 is intentionally rejected, not normalized or supported.
- IPv6 tests check expanded/compressed/case/default-port equivalence, longest
zero runs and ties, dotted tails, mapped-address separation from IPv4,
DNS separation, mismatched addresses, zones, malformed groups/brackets/ports,
userinfo, suffixes, controls and exact-span output wiping.
- Canonical IPv6 uses lowercase hex tails even for mapped addresses. No DNS
resolution, scope inference or network reachability is involved.
- To additionally audit the installed ESP-IDF conversion implementation, run:
```sh
WEB_AUTH_LWIP_SOURCE=/home/mscholz/.platformio/packages/framework-espidf/components/lwip/lwip/src/core/ipv6/ip6_addr.c python3 tests/web_auth_parse/run.py
```
Adjust the path for your installation. This compiles the actual extracted
`ip6addr_aton` body with host type/byte-order adapters and the same temporary
address-copy boundary as `lwip_inet_pton`. IPv4-tail and scope branches are
disabled: production validates/replaces dotted tails before conversion and
rejects zones. The production formatter is exercised, not substituted.
This is not a target build or full lwIP networking test.
- Python mirrors the public C struct and capacities; interface changes must
update these tests. Shared-library loading assumes a Unix-like host/compiler.
- Tables are not exhaustive fuzzing, memory-safety instrumentation, or proof of
+71 -3
View File
@@ -2,6 +2,8 @@
"""Dependency-free contract tests against the production parser, not a model."""
import ctypes as C
import json
import ipaddress
import os
from pathlib import Path
import subprocess
import tempfile
@@ -33,6 +35,40 @@ def main():
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-shared", "-fPIC", "-I", str(ROOT / "src"),
str(ROOT / "src/web_auth_parse.c"), "-o", str(library)], check=True)
# Optional audit uses installed target conversion code, not a mocked
# inet_pton that merely returns success or delegates IPv6 to host libc.
sdk = os.environ.get("WEB_AUTH_LWIP_SOURCE")
if sdk:
source = Path(sdk).read_text()
start = source.index("int\nip6addr_aton(")
end = source.index("\n/**", start)
shim = Path(temporary) / "lwip.c"
shim.write_text('''#include <arpa/inet.h>
#include <stdint.h>
#include <ctype.h>
#include <string.h>
typedef uint32_t u32_t;
typedef struct { uint32_t addr[4]; } ip6_addr_t;
#define LWIP_IPV4 0
#define LWIP_IPV6_SCOPES 0
#define lwip_htonl htonl
#define lwip_isxdigit isxdigit
#define lwip_isdigit isdigit
#define lwip_islower islower
#define ip6_addr_clear_zone(a) ((void)(a))
''' + source[start:end] + '''
int audit_inet_pton(int af, const char *src, void *dst) {
ip6_addr_t addr;
if (af != AF_INET6) return -1;
int result = ip6addr_aton(src, &addr);
if (result) memcpy(dst, addr.addr, sizeof(addr.addr));
return result;
}
''')
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-shared", "-fPIC", "-Dinet_pton=audit_inet_pton",
"-I", str(ROOT / "src"), str(ROOT / "src/web_auth_parse.c"),
str(shim), "-o", str(library)], check=True)
api = C.CDLL(str(library))
api.web_auth_parse_origin.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_void_p]
api.web_auth_parse_cookie.argtypes = [C.c_void_p, C.c_size_t, C.c_char_p, C.c_void_p]
@@ -52,7 +88,7 @@ def main():
if not result and raw != bytes(len(raw)):
failures.append(f"{kind}: {label}: failure did not wipe every output byte")
if result and expected is not None and extract(output) != expected:
failures.append(f"{kind}: {label}: incorrect decoded/canonical output")
failures.append(f"{kind}: {label}: decoded/canonical {extract(output)!r}, expected {expected!r}")
origins = [(b"EXAMPLE.Com", b"https://example.com", b"https://example.com"),
(b"a-b.local", b"https://A-B.LOCAL", b"https://a-b.local"),
@@ -65,8 +101,7 @@ def main():
b"https://" + host.lower()))
bad_hosts = [b"", b" ", b"example.com ", b" example.com", b"a..b", b".a", b"a.",
b"-a", b"a-", b"a_b", b"a/b", b"a?b", b"a#b", b"u@a", b"a,b",
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"[::1]", b"::1",
b"[::1]:443", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"::1", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
b"a:443:443", b"a" * 64 + b".com", b"a" * 130]
origins += [(h, b"https://" + h, None) for h in bad_hosts]
origins += [(b"example.com", o, None) for o in
@@ -77,6 +112,39 @@ def main():
b"https://example.com\0", b"https://example.com\r\n", b"https://[::1]",
b"https://example.com https://example.com", b"https:///example.com")]
origins += [(None, b"https://example.com", None)]
literals = ["::", "::1", "2001:DB8::ABCD", "2001:0:0:1:0:0:0:1",
"1:0:0:2:0:0:3:4", "1:2:3:4:5:6:0:8", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
"::ffff:192.0.2.1", "::192.0.2.1", "2001:db8:1:2:3:4:192.0.2.1"]
for literal in literals:
address = ipaddress.IPv6Address(literal)
normalized = address.compressed
if address.ipv4_mapped:
value = int(address.ipv4_mapped)
normalized = f"::ffff:{value >> 16:x}:{value & 65535:x}"
canonical = b"[" + normalized.encode() + b"]"
for h in (literal.encode(), address.exploded.upper().encode()):
for hp in (b"", b":443"):
for op in (b"", b":443"):
origins.append((b"[" + h + b"]" + hp, b"https://" + canonical + op,
b"https://" + canonical))
invalid = [b"[]", b"[1]", b"[1:2:3:4:5:6:7]", b"[1:2:3:4:5:6:7:8:9]",
b"[1:2:3:4:5:6:7:8::]", b"[:::]", b"[1::2::3]", b"[:1]", b"[1:]",
b"[12345::]", b"[gg::]", b"[::1", b"::1]", b"[[::1]]",
b"[::ffff:192.00.2.1]", b"[::ffff:256.0.2.1]", b"[::ffff:192.2.1]",
b"[::ffff:0xc0.0.2.1]", b"[::ffff:192.0.2.1:1]",
b"[fe80::1%eth0]", b"[fe80::1%25eth0]", b"user@[::1]",
b"[::1]:80", b"[::1]:0443", b"[::1]:+443", b"[::1]:443:443",
b"[::1]:", b"[::1]/", b"[::1]?x", b"[::1]#x", b"[::1]@host"]
for c in list(range(33)) + [127, 128, 255]:
invalid.extend((b"[::1" + bytes([c]) + b"]", b"[::1]" + bytes([c])))
for h in invalid:
origins.extend(((h, b"https://" + h, None),
(h, b"https://[::1]", None),
(b"[::1]", b"https://" + h, None)))
for h, o in ((b"[::1]", b"[::2]"), (b"[::1]", b"localhost"),
(b"[::ffff:192.0.2.1]", b"192.0.2.1"),
(b"[::192.0.2.1]", b"[::ffff:192.0.2.1]")):
origins.extend(((h, b"https://" + o, None), (o, b"https://" + h, None)))
# 128 canonical bytes fit with the terminator; 129 do not.
for n in (56, 57):
host = b"a" * 63 + b"." + b"b" * n