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
+3
View File
@@ -13,3 +13,6 @@ add_compile_definitions(
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(PROJECT_VER "0.1.0")
project(esp32_serial_swiss_army_knife)
# Narrow mDNS 1.12.0 correctness fix; see tests/mdns_membership/README.md.
include(${CMAKE_CURRENT_LIST_DIR}/cmake/mdns_membership.cmake)
+1
View File
@@ -37,6 +37,7 @@ Keep UART0 ready for administrative recovery and native USB for network-independ
- [Firmware update](docs/roadmap.md#phase-10--simple-admin-web-firmware-upload): admin-only application upload, NVS preservation, uncertain outcomes, wired recovery, acceptance evidence and reusable regression checks.
- [Security operations](docs/security_operations.md): trusted-network use, credentials, identity verification, shutdown and recovery; physical-extraction limits and lightweight upstream maintenance.
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
- [Dual-stack networking and discovery](docs/roadmap.md#phase-12--advanced-network-integration): DHCPv4 hostname publication, STA SLAAC/link-local IPv6, HTTPS/SSH DNS-SD and dual-stack access; complete by explicit user validation, including fresh boot and the full client mix at 230400 baud. Includes recorded heap/stack measurements, evidence limits, saved-config requirements and the narrowly guarded mDNS membership fix.
## Flash partition layout
+44
View File
@@ -0,0 +1,44 @@
# Only the lwIP backend needs this fix. Keep the managed component immutable.
function(project_mdns_membership_overlay)
if(CONFIG_MDNS_NETWORKING_SOCKET)
return()
endif()
idf_component_get_property(mdns_dir espressif__mdns COMPONENT_DIR)
idf_component_get_property(mdns_lib espressif__mdns COMPONENT_LIB)
idf_component_get_property(mdns_version espressif__mdns COMPONENT_VERSION)
if(NOT mdns_version STREQUAL "1.12.0")
message(FATAL_ERROR "mDNS membership overlay requires component version 1.12.0; review upstream")
endif()
idf_build_get_property(python PYTHON)
set(helper "${CMAKE_CURRENT_LIST_DIR}/mdns_membership.py")
set(original "${mdns_dir}/mdns_networking_lwip.c")
set(overlay "${CMAKE_BINARY_DIR}/mdns_membership/mdns_networking_lwip.c")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${helper}" "${original}" "${mdns_dir}/idf_component.yml" "${overlay}")
execute_process(COMMAND "${python}" "${helper}" "${mdns_dir}" "${overlay}"
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error)
if(NOT result EQUAL 0)
message(FATAL_ERROR "mDNS membership overlay failed: ${output}${error}")
endif()
get_target_property(sources ${mdns_lib} SOURCES)
get_target_property(source_dir ${mdns_lib} SOURCE_DIR)
set(replaced 0)
set(updated_sources)
foreach(source IN LISTS sources)
get_filename_component(absolute "${source}" ABSOLUTE BASE_DIR "${source_dir}")
if(absolute STREQUAL original)
list(APPEND updated_sources "${overlay}")
math(EXPR replaced "${replaced} + 1")
else()
list(APPEND updated_sources "${source}")
endif()
endforeach()
if(NOT replaced EQUAL 1)
message(FATAL_ERROR "Expected exactly one mDNS lwIP target source, found ${replaced}; review upstream CMake")
endif()
set_property(TARGET ${mdns_lib} PROPERTY SOURCES "${updated_sources}")
message(STATUS "mDNS 1.12.0: using guarded build-local multicast membership fix")
endfunction()
project_mdns_membership_overlay()
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Generate the narrowly guarded mDNS 1.12.0 membership overlay; never edit upstream."""
import argparse
import hashlib
from pathlib import Path
import re
SOURCE_SHA256 = "adc139fa504a925ab644f21f8dce3659927f534e390a176b72b0ae3206c6a3ea"
OLD_DEINIT = """ s_interfaces[tcpip_if].proto &= ~(ip_protocol == MDNS_IP_PROTOCOL_V4 ? PROTO_IPV4 : PROTO_IPV6);
if (s_interfaces[tcpip_if].proto == 0) {
s_interfaces[tcpip_if].ready = false;
join_group(tcpip_if, ip_protocol, false);
"""
NEW_DEINIT = """ int proto = (ip_protocol == MDNS_IP_PROTOCOL_V4 ? PROTO_IPV4 : PROTO_IPV6);
if (!(s_interfaces[tcpip_if].proto & proto)) {
return;
}
join_group(tcpip_if, ip_protocol, false);
s_interfaces[tcpip_if].proto &= ~proto;
if (s_interfaces[tcpip_if].proto == 0) {
s_interfaces[tcpip_if].ready = false;
"""
OLD_INIT = """ err = pcb_init();
if (err) {
return err;
}
"""
NEW_INIT = """ err = pcb_init();
if (err) {
join_group(tcpip_if, ip_protocol, false);
return err;
}
"""
def generate(component: Path, output: Path) -> None:
manifest = (component / "idf_component.yml").read_text()
if re.findall(r"^version:\s*(\S+)\s*$", manifest, re.MULTILINE) != ["1.12.0"]:
raise ValueError("mDNS membership overlay requires exactly version 1.12.0; review upstream")
original = (component / "mdns_networking_lwip.c").read_bytes()
if hashlib.sha256(original).hexdigest() != SOURCE_SHA256:
raise ValueError("mDNS networking source SHA-256 mismatch; review upstream, do not bypass guard")
patched = original.decode("utf-8")
for old, new in ((OLD_DEINIT, NEW_DEINIT), (OLD_INIT, NEW_INIT)):
if patched.count(old) != 1:
raise ValueError("mDNS membership replacement must match exactly once")
patched = patched.replace(old, new, 1)
result = patched.encode("utf-8")
output.parent.mkdir(parents=True, exist_ok=True)
if not output.exists() or output.read_bytes() != result:
output.write_bytes(result)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("component", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
try:
generate(args.component, args.output)
except (OSError, ValueError) as error:
parser.exit(1, f"mDNS membership overlay: {error}\n")
+6 -4
View File
@@ -104,7 +104,7 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
### HTTPS, WebSocket, and web serial
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 40. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity and IDF's dual-stack listener. Shared Host/Origin parsing accepts canonical bracketed IPv6 literals (no interface zones) while preserving same-origin/session binding; DNS discovery does not confer certificate trust. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 40. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
@@ -132,7 +132,7 @@ The server transition and identity reservations exclude competing lifecycle work
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
`ssh_transport` uses wolfSSH on port 22 with one explicitly dual-stack IPv6 wildcard listener and two fixed session/handshake slots. Peer formatting preserves IPv6 interface scope; IPv4 shares the listener, not a second socket. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
@@ -185,13 +185,15 @@ For SSH, standard output/error is redirected to the invoking session's bounded o
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed, but browser self/generated/key/recovery and restricted network/SSH shell actions are not; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. [Browser policy and recovery](../web_administration.md#browser-shell-policy).
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed. Canonical Wi-Fi commands are also allowed, including hidden secret prompts and disruptive changes; these use the ordinary owner queue and may cut off the browser before output arrives, without a deferred acknowledgement guarantee. Browser self/generated/key/recovery, mDNS mutations and restricted SSH shell actions remain denied; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. [Browser policy and recovery](../web_administration.md#browser-shell-policy).
## Wi-Fi and persistence
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP/address deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. One-second reconciliation reads authoritative driver/netif state in TCP/IP context, enables STA SLAAC and creates link-local addresses without restarting DAD. IPv4 or preferred IPv6 (including link-local-only) establishes ONLINE and may retire the fallback AP; ONLINE does not imply a default route or Internet access. DHCPv4 option 12 carries the full `sak-<suffix>` from permanent bounded hostname storage installed before connection; rename updates subsequent exchanges without restarting DHCP. The bounded snapshot retains up to three preferred numeric IPv6 addresses from the same observation as its availability flags; compile-time capacity checks reject larger lwIP address-slot settings. `wifi status` formats these addresses and their scope. Web Network settings encode up to three fixed-width strings from the same snapshot and display link-local/ULA/GUA groups within a 2304-byte JSON bound; the overview/OLED retains availability-only reporting.
`mdns_service` initializes the responder at most once after either-family STA readiness. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. HTTPS/SSH owners publish availability under a short portMUX, and only the Wi-Fi owner reconciles the two STA-only DNS-SD records and address families, including while offline. Component calls run outside project service mutexes. Missing-family disable is reasserted every pass and present-family enable/reprobe every 30 seconds because upstream actions can silently drop and expose no readiness acknowledgement. This is eventual convergence, not instantaneous withdrawal: a transient stale/zero-A window can occur after DHCP loss, and AAAA follows upstream valid-address semantics including deprecated addresses. A version/hash-guarded build-local mDNS 1.12.0 source overlay balances per-family multicast membership and join cleanup; managed source stays immutable. Tests and upgrade/removal contract: `tests/mdns_membership/README.md`. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
Persistent namespaces/blobs include:
+3 -1
View File
@@ -158,7 +158,9 @@ Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtur
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only.
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only. One-second owner reconciliation handles DHCP hostname updates, preferred IPv4/IPv6 readiness, missed events and mDNS records/families. `ONLINE` includes preferred link-local-only IPv6 and may retire fallback AP without IPv4; snapshots retain IPv6 availability flags plus up to three preferred numeric addresses from the same observation. `wifi status` and web Network settings render those addresses; overview/OLED remain availability-only. The typed runtime adds `ipv6_addresses` (up to three fixed-width strings); backend and UI share a 2304-byte response bound. Regression coverage includes byte order, address clearing and the compile-time slot-capacity bound.
- Phase 12: `mdns_service_set_https_available()` / `mdns_service_set_ssh_available()` publish under a short portMUX; only the Wi-Fi owner calls component APIs. STA DNS-SD has two records; family repair re-probes available families every 30 seconds because upstream action admission has no reliable acknowledgement. AAAA uses valid (including deprecated) upstream addresses. DHCPv4 uses full `sak-<suffix>` before connection and in later exchanges after rename; permanent raw-netif hostname storage is updated only in TCP/IP context.
- Focused tests: `tests/wifi_phase12`, `tests/mdns_phase12`, `tests/ssh_phase12`, `tests/mdns_membership`; HTTPS IPv6 authority cases live in `tests/web_auth_parse` and cookie suites. Build-local `cmake/mdns_membership.*` replaces only reviewed mDNS 1.12.0 networking source to balance multicast membership; version/hash drift fails configuration. No managed source is edited.
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots.
## Local display and controls
+33 -2
View File
@@ -2,6 +2,37 @@
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), [architecture](architecture.md) and [decisions](design-decisions.md).
## Phase 12 COMPLETE — explicit user validation
- User states: “I just validated Phase 12 successfully.” Record acceptance, not a pending phase. Canonical device evidence and reusable regression guidance: [Phase 12](../roadmap.md#phase-12--advanced-network-integration). No unreported DHCP capture, DNS-zone update, exhaustive address/service transition, fault/soak or byte-integrity pass is implied.
- User validated IPv6 ping/HTTPS and Avahi AAAA; Fedora authselect dual-family mDNS resolved the normal hostname lookup issue. Fresh-boot and full-mix captures show healthy reported service lifecycle. At 230400 baud 8N1 RTS/CTS: two SSH sessions with IPv6 ULA peers (user observer/admin console), two serial WebSockets (writer/observer), active browser admin, USB observer; four broker clients, one writer, zero pending/event counts at capture.
- Memory table preserved in roadmap. Fresh internal8/DMA/PSRAM free: 65,880 / 58,124 / 8,196,732 B. Full-mix free: 35,408 / 27,652 / 8,111,952 B; lifetime minima: 8,528 / 772 / 8,072,612 B; largest blocks: 22,528 / 22,528 / 7,995,392 B. Capability pools overlap; minima are conservative per-region lifetime sums, not simultaneous reserves. DMA772 remains a watch item, not proof of OOM or an acceptance blocker. SSH stack20,480B minimum-free18,476→16,284B.
- SSH two successful handshakes, no auth/handshake/timeouts/I/O failures; RX68 accepted65 rejected3, TX161,209B, broker revocations2. Web serial RX27B accepted, TX318,230B across782 frames; send/queue/protocol failures0. Browser admin RX106/TX3,721B with no reported send/queue failures. Do not reinterpret nonzero rejected bytes/writer denials as proven transport loss or claim zero UART/observer drops without their counters.
- Boot TLS -0x004C receive errors and auth-failure counts have no demonstrated cause. Snapshots are non-atomic and counts are not an aligned interval. This handoff changes documentation only; no new build/test/upload/erase/device operation/commit; hardware directory untouched. Prior latest build94,444B RAM/1,854,485B flash and UI174+CSP remain historical validation.
## Follow-up — IPv6 addresses in web Network settings
- User confirms direct IPv6 ping/HTTPS and Avahi AAAA lookup for `sak-1024.local` succeed. Client NSS uses `mdns4_minimal [NOTFOUND=return]`; systemd-resolved explicit mDNS reports no eligible networks. This supports a client resolver integration issue, not failed firmware AAAA publication on the tested Avahi path. The user subsequently enabled dual-family mDNS through Fedora authselect and confirmed success; see acceptance above.
- Added bounded runtime `ipv6_addresses` (<=3 fixed-width lowercase eight-hextet strings) to existing admin Network snapshot, grouped as link-local/ULA/GUA in Settings. Overview/OLED remain flags-only. No new netif call, allocation, task or snapshot storage. Backend/client JSON bound 2048→2304 (+256B response stack); maximum-escaped fixture with all three addresses is 2067B. Strict UI validation/text-only output and existing session fences preserved; generated assets untouched.
- `pio run` PASS **94,444 B linked RAM / 1,854,485 B flash**, +0/+800 versus prior CLI-address build. Network cookie/owner regressions and UI174+CSP PASS. Initial cookie fixture had an obsolete 2048B output buffer; fixed to use the production bound and rerun PASS. Empty/full address lists, byte order, bad counts, malformed/injected UI entries and clearing covered. No upload or hardware operations; hardware directory untouched.
## Follow-up — browser Wi-Fi controls and IPv6 diagnosis
- User requests browser Admin shell Wi-Fi parity with SSH/typed settings. Removed only the Wi-Fi status-only policy gate; canonical settings/lifecycle/persistence/diagnostics and hidden secret prompts now work. mDNS remains status-only and unrelated restrictions stay intact. Disruptive commands may cut off the response; owner admission is not peer acknowledgement or cancellation on disconnect.
- User now confirms the CLI displays an IPv6 address, direct IPv6 ping works, and the web interface opens using IPv6. This validates those reported unicast operations, not all Phase 12 checks. `ping sak-1024.local -6` fails on the client with “Die Adressfamilie für Hostnamen wird nicht unterstützt.” That earlier client failure was subsequently resolved through Fedora authselect dual-family mDNS; Avahi AAAA lookup and normal IPv6 hostname access succeeded. Do not reopen this as an established firmware discovery defect.
- Added three bounded preferred numeric addresses to manager snapshot, copied/cleared with the same TCP/IP observation, and labelled output in `wifi status`. Web/OLED schema unchanged. +48 bytes per snapshot/settings copy and shared static storage; compile rejects >3 lwIP slots. No new allocation/task/socket. Latest `pio run` PASS **94,444 B RAM / 1,853,685 B flash** (+48/+660 vs initial Phase12). No upload/device operations.
- PASS: browser policy, new actual Wi-Fi secret-handler/browser-prompt fixture (cancellation/revocation/wiping/history/role guards), account/lifecycle boundary, preferred-address lifecycle/byte-order/rendering/capacity, Network settings, cookie Network and UI171+CSP, diff check. Broader `admin_console_boundary/run.py` passes console/certificate then fails existing SSH-adapter compilation due missing `web_firmware_update_reserve_reboot` fake; left unrelated fixture unchanged. ASan/UBSan unavailable at host link. Hardware directory untouched.
## Phase 12 implementation history — superseded by acceptance above
- User authorized the agreed DHCPv4 hostname / dual-stack SLAAC / STA DNS-SD baseline. Another agent owns `hardware/`; this work did not read or edit it. Do not commit or revert that agent's work. No upload, erase or device operation performed.
- Wi-Fi owner applies full `sak-<suffix>` before DHCP and on rename for future exchanges, using permanent TCP/IP-owned storage to preserve the existing 59-byte hostname maximum beyond IDF's setter limit. Preferred IPv6 link-local/ULA/GUA can establish ONLINE without IPv4; link-local-only can retire fallback AP. One-second reconciliation handles stale/missed events. Web/OLED expose availability; the follow-up above adds actual preferred addresses to CLI output.
- HTTPS default listener verified dual-stack; shared authority parser now strictly canonicalizes bracketed IPv6 without zone IDs. SSH uses one explicitly dual-stack listener with scope-safe peers, preserving two slots. Service owners publish availability through short portMUX sections; Wi-Fi owner reconciles two DNS-SD records and address families. No new task/socket/broker slot/serial payload buffer, dependency version, partition or generated asset change.
- mDNS action API can silently drop queued work; missing families disable each pass, available families repair/re-probe every 30 seconds. Transient stale/zero A window remains until processed; AAAA follows valid-address semantics including deprecated addresses. Upstream calls can block, so polling is not a hard deadline. The later user acceptance establishes the reported device/AAAA behavior, not exhaustive multicast fault/transition testing.
- New **narrow** `cmake/mdns_membership.*` overlay fixes verified mDNS 1.12.0 per-family multicast reference imbalance and failed-PCB join cleanup. Only a build-local source copy is changed; version/source hash guarded, managed source immutable. This is not the abandoned Phase 9 patch set. Membership/CMake tests include negative controls and repeated transitions.
- Final `pio run` PASS: **94,396 B RAM / 1,853,025 B flash** (+176 / +5,380 versus recorded Phase 10); not runtime headroom. Local saved sdkconfig mDNS capacity changed to 2; durable defaults pin IPv4/IPv6 and two services. Initial bool-atomic target failure fixed with portMUX; final target build includes overlay. Existing SDK Kconfig notes remain.
- Host suites PASS: Wi-Fi, mDNS, membership, SSH dual-stack and existing management/runtime/security, auth parser689 (host + actual lwIP), cookie variants, Network settings (after updated netif fakes), UI171+CSP, HTTPS lifecycle45+status8+identity, firmware88+SDKcontract, broker diagnostics and session-store/serial. No network packet, hardware, fault-injection or new high-speed serial pass is implied. Canonical contracts, limits and device checklist: [Phase 12](../roadmap.md#phase-12--advanced-network-integration).
## Phase 10 COMPLETE — explicit user acceptance, 2026-09-18
- User confirmed after firmware upload implementation and the concise-UI fix: “That works perfectly. And the usual operation is also verified.” Acceptance establishes that upload works and normal operation is verified. Do not infer specific fault-injection, NVS before/after comparisons, power-loss or recovery passes. The roadmap's compact regression guidance is reusable, not an acceptance blocker.
@@ -11,7 +42,7 @@ Working memory, not an implementation timeline. Source is authoritative; begin w
- 4KiB internal buffer + transient2048B-stack reboot owner allocated before erase; 10s stall/120s receive-loop budget, not totalflashdeadline. HTTPD synchronously blocks other web work during upload; networkserial maystall/drop, reboot disruptsall. No task/request/socket lifetime capture after handler. Service/identity reservation and atomic ordinary-reboot gate cover UI/UART0/SSH/browser/localbutton paths. Failed response after bootselect schedules noautomaticreset; selected latch rejects further uploads409, manual reboot available. Successful response schedules500ms reboot retaining reservations.
- Review fixed two actualSDK5.5.0 edge cases: failed esp_ota_begin maypublishlivehandle beforeeraseerror (abortthat handle); rawContentLength64 canwrapHTTPDsize_t32 (overflow-safe actualslotbound/strictdecimal/equality check beforebody/erase). End consumes handle evenerror. SDKvalidation followed by exactparsedimage length includingSHA; basic header requiresS3appdescriptor/hash. Unrelated old/privateSDK code unpatched.
- Parent final pio PASS **94,220 B RAM / 1,847,645 B flash**, +24RAM/+19,080flash vsPhase9, not runtimeheadroom. Parent newbackend88cases+actualSDKbeginfailurecontract, UI169groups+CSP, serverlifecycle44, admin25, consolelifecycle, SSHruntime, cookielifecyclePASS. Additionalbase/admin/display/lifecyclecookie, idle18, SSHmanagement/runtime/security agentPASS after adding missing rebootfake to adminfixture (no productionchange). Independent review final noactionablefindings; realbuilt firmware parsed with SDKmetadata bothOTAoffsets (notdeviceflashproof).
- **Scope decision (2026-09-18):** User removed the BLE transport/provisioning proposal entirely because it no longer fits the project concept. BLE is not planned; retain the existing USB, HTTPS/WebSocket and SSH transport scope. Remaining roadmap candidates are under evaluation, not authorized implementation work. Do not resurrect Phase 9 patches. This update changed documentation only; no build, test, upload, erase, device operation or commit was performed.
- **Scope decision (2026-09-18):** User removed the BLE transport/provisioning proposal entirely because it no longer fits the project concept. BLE is not planned; retain the existing USB, HTTPS/WebSocket and SSH transport scope. This earlier scope decision did not authorize implementation; Phase 12 was subsequently authorized and implemented as recorded above. Do not resurrect Phase 9 patches. This update changed documentation only; no build, test, upload, erase, device operation or commit was performed.
## Session logging scope decision
@@ -27,7 +58,7 @@ Working memory, not an implementation timeline. Source is authoritative; begin w
## Evidence limits and follow-ups
- Previously accepted combined binary WS send: CPU160MHz / 230400 baud full mix including browser admin. Latest recorded telemetry has very low internal/DMA lifetime minima (2,052/460 B); these are nonblocking headroom follow-ups, not approved reserves or proof of simultaneous allocation failure. Full table, capture workload and counter limits are preserved in the roadmap.
- Previously accepted combined binary WS send: CPU160MHz / 230400 baud full mix including browser admin. The earlier Phase 8 telemetry had very low internal/DMA lifetime minima (2,052/460 B); these are nonblocking headroom follow-ups, not approved reserves or proof of simultaneous allocation failure. Full table, capture workload and counter limits are preserved in the roadmap. The latest Phase 12 full-mix minima are 8,528/772 B, with the same evidence limits.
- TLS `-0x004C` means generic NET_RECV_FAILED, not OOM. Historical authentication/admission symptoms do not establish a cause. Do not invent fault, soak, timing or power-loss passes.
- Credentials remain unencrypted; old flash contents are not erased. Intermittent trusted-network operation reduces exposure, not physical-extraction risk. Upstream upgrades are separate deliberate tasks, not an endless local backport programme.
- Phase 10 is complete by the explicit acceptance above; detailed unreported regression scenarios remain unevidenced, not completion blockers. Device operations, branch/reset, commits and dependency upgrades remain outside this documentation task.
+2 -2
View File
@@ -144,13 +144,13 @@ Only constraints supported by implementation or current project documentation be
## Wi-Fi callbacks enqueue; the manager owns policy
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, reconciliation, and station mDNS announcement transitions. mDNS initializes at most once, remains allocated across transient disconnects while its component handlers withdraw/re-enable the STA interface, and treats failure as nonfatal.
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, reconciliation, and station mDNS announcement transitions. mDNS initializes at most once, remains allocated across transient disconnects, and treats failure as nonfatal. IPv4 or preferred IPv6 including link-local-only establishes ONLINE; route/Internet reachability is not implied. Full DHCP hostname storage and IPv6 state access belong to TCP/IP context. HTTPS/SSH owners only publish availability with short critical sections; Wi-Fi owns DNS-SD record and family reconciliation. Public mDNS actions are not acknowledged, so absent-family disables repeat every pass and healthy enables re-probe on a slower 30-second repair cadence. Record/address withdrawal is eventual, and upstream AAAA includes deprecated-but-valid addresses.
**Rationale/evidence:** Callback paths avoid blocking, NVS, and policy work. Manager deadlines consult authoritative driver/netif state so dropped events are recoverable.
**Consequence for future changes:** Keep callbacks short and nonblocking. Add state transitions to the manager rather than directly invoking Wi-Fi policy from consoles, UI, or callbacks. Preserve queue-drop observability.
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_config.{h,c}`
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_config.{h,c}`. `cmake/mdns_membership.*` is a narrowly reviewed exception for mDNS 1.12.0 multicast join/leave imbalance: patch only a build-local networking source with strict version/hash guards, never the managed source. Dependency upgrades must review/remove the overlay; do not bypass its guards or restore the abandoned Phase 9 patches.
## Optional local UI cannot become a core dependency
+4 -2
View File
@@ -4,7 +4,7 @@ UART0 and authenticated `admin` SSH sessions use the same registered command imp
Browser admin uses the same dispatcher with a [narrower parsed frontend policy](web_administration.md#browser-shell-policy), independent of typed Settings permissions. It supports bounded deferred `reboot`, `web stop`, exact `web certificate rotate --force` and owner-relative `exit`. Drain (up to ten seconds plus 200 ms) is best-effort application-buffer acknowledgement, not peer receipt or an execution deadline; pending input is discarded. Certificate work runs on the existing dispatcher through the shared service-before-identity reservation, commits before stop/restart and never rolls back a committed identity after lifecycle failure. Failed stop retains ownership and skips start. Verify changed trust through UART0 `web certificate info`, recover with UART0/admin SSH `web stop` / `web start`, then sign in freshly. HTTPS-only actions leave SSH/native USB/UART0 independent; reboot affects every transport and loses unsaved RAM.
Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/`mdns` allow status only. Browser `user` allows status/list/show and interactive add/password plus forced role/delete for **other accounts**, not self/generated/key/recovery commands. Restricted SSH stop/disconnect/reset/host-key mutation remains unavailable in the browser shell. Typed Accounts/Network/SSH settings separately provide their documented bounded workflows; this is not shell parity. See [web administration](web_administration.md) for lifecycle/API ownership and uncertainty.
Browser `web` allows only status/stop/exact forced certificate rotation; `mdns` allows status only. Browser `wifi` supports the canonical commands, including bare `wifi` status, settings, hidden secret prompts and explicit `wifi ap show-secret`. Secret input is not echoed, retained in history or completed. Wi-Fi changes can disconnect the browser before a result arrives, without deferred drain or cancellation; reconnect and inspect before retrying, or recover through UART0. Browser `user` allows status/list/show and interactive add/password plus forced role/delete for **other accounts**, not self/generated/key/recovery commands. Restricted SSH stop/disconnect/reset/host-key mutation remains unavailable in the browser shell. Typed Accounts/Network/SSH settings separately provide their documented bounded workflows; this is not shell parity. See [web administration](web_administration.md) for lifecycle/API ownership and uncertainty.
## System
@@ -123,7 +123,9 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
| `mdns save` / `mdns load` | Save the working suffix to its independent NVS record or load it. |
| `mdns defaults` / `mdns reset` | Restore the MAC-derived suffix in RAM, or restore and persist it. |
When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sak-<suffix>.local`. The default suffix is the lower-case hexadecimal STA MAC address. Suffixes may contain lowercase ASCII letters, digits, and internal hyphens only. Changing a suffix while online causes a best-effort reannouncement; mDNS failures do not stop Wi-Fi, UART0, UART1, or native USB access.
When the Wi-Fi station has IPv4 or a preferred IPv6 address, the Wi-Fi manager announces `sak-<suffix>.local` and advertises available HTTPS/SSH services through DNS-SD. This is STA-only, local-link discovery, not certificate or host-key trust. The default suffix is the lower-case hexadecimal STA MAC address. Suffixes may contain lowercase ASCII letters, digits, and internal hyphens only. Changing a suffix queues a best-effort reannouncement and updates the DHCPv4 hostname (`sak-<suffix>`, without `.local`) for subsequent DHCP exchanges; it does not force a lease restart. A configured DHCP/DNS server may publish that name in its own zone.
`wifi status` and browser Network status distinguish IPv4 absence and IPv6 link-local/ULA/GUA availability. `wifi` / `wifi status` and the web Network settings dialog additionally list up to three actual preferred IPv6 addresses, labelled link-local, ULA or GUA; these are copied with the flags, not inferred from enabled IPv6 support. A link-local destination needs the client's interface as its zone. To separate client address preference from IPv6 reachability, run `ping -6 -c 3 sak-1024.local` on an IPv6-capable client; choosing IPv4 with plain `ping` does not mean the device lacks IPv6. If lookup fails, inspect AAAA resolution (for example `avahi-resolve-host-name -6 sak-1024.local` where Avahi is installed), then test the numeric address from `wifi status` directly. Link-local-only connectivity counts as `ONLINE` and can retire the fallback AP after the existing stability interval; it does not establish Internet access. HTTPS/WebSocket and SSH support both families; browser IPv6 literals require brackets and cannot contain interface zones. Use the `.local` hostname for link-local browser access where supported by the client. Existing ping selects the first usable resolver result without family racing, and traceroute remains IPv4-only. mDNS failures do not stop Wi-Fi, UART0, UART1, or native USB access. See [Phase 12](roadmap.md#phase-12--advanced-network-integration) for discovery convergence limits and pending device validation.
## HTTPS web terminal
+61 -4
View File
@@ -41,8 +41,7 @@ These constraints apply across all phases:
| 8 | Role-based users and administrative access | **Complete** |
| 9 | Small intermittent-use security baseline | **Complete (user signoff 2026-09-18; new hardware check waived)** |
| 10 | Simple admin web firmware upload | **Complete (explicit user acceptance 2026-09-18; upload and normal operation verified)** |
| 12 | Advanced network integration | **Under evaluation** |
| 12 | Dual-stack networking and local service discovery | **Complete (explicit user validation; fresh boot and full client mix at 230400 baud)** |
## Completed phases
@@ -293,11 +292,11 @@ These are reusable checks, **not recorded passes or outstanding acceptance gates
## Current and planned phases
**Phases 8, 9 and 10 are complete** for their accepted scopes. Phase 9 includes the explicit new-hardware-check waiver above; Phase 10 includes explicit user acceptance of upload and normal operation. Remaining future work is under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below guide future work, not claims that every fault, soak, recovery or reserve measurement was performed for completed phases.
**Phases 8, 9, 10 and 12 are complete** for their accepted scopes. Phase 9 includes the explicit new-hardware-check waiver above; Phase 10 includes explicit user acceptance of upload and normal operation. Phase 12 includes explicit user validation and the fresh-boot/full-client evidence below. Optional features must not weaken completed serial and recovery paths. General release gates below guide future work, not claims that every fault, soak, recovery or reserve measurement was performed for completed phases.
### Phase 12 — Advanced network integration
Agreed planning baseline; implementation is not yet authorized:
**Complete by explicit user validation:** “I just validated Phase 12 successfully.” Accepted baseline:
- Dual-stack local access and DNS-SD discovery: advertise available HTTPS and SSH services using the shared `sak-<suffix>.local` hostname, with appropriate IPv4 `A` and IPv6 `AAAA` records. Preserve IPv4 access and verify IPv6 support throughout HTTPS, WebSocket and SSH. Clients choose address-family preference and fallback; DNS-SD cannot mandate IPv6 preference. Certificate-name integration and trust remain separate concerns.
- Send the configured device hostname (`sak-<suffix>`, without `.local`) through DHCPv4 Host Name option 12 so a suitably configured DHCP/DNS server can publish the lease address in its own DNS zone. Set the STA netif hostname before DHCP starts, define how hostname changes reach subsequent DHCP exchanges, and verify the transmitted option and server-side DNS registration. DNS publication and the DNS domain remain server policy; mDNS does not provide this integration.
@@ -306,6 +305,64 @@ Agreed planning baseline; implementation is not yet authorized:
The device is not intended to become a general-purpose router. Captive-portal interception, unauthenticated DNS redirection, NAPT, and a plaintext serial listener remain out of scope unless the project requirements are explicitly revised.
#### Implementation and operational boundaries
- The Wi-Fi owner installs the full configured hostname before STA connection and updates it after hostname edits. DHCPv4 option 12 uses `sak-<suffix>`, not `.local`. Rename does not force DHCP restart: the next DHCP exchange carries the new name, and DNS registration/cache cleanup remain server policy. Existing hostname limits and NVS formats are unchanged.
- STA enables SLAAC and IPv6 link-local creation; either IPv4 or a preferred IPv6 address establishes `ONLINE`. **Link-local-only counts as online**, prevents IPv4-only timeout/failover, and can retire the fallback AP after the existing stability interval. This indicates local address availability, not Internet reachability or a default route. Web, CLI and OLED distinguish absent IPv4 from IPv6 availability. A diagnostic follow-up adds up to three preferred numeric IPv6 addresses to the snapshot, copied and cleared with the same observation; `wifi status` and browser Network settings print them in link-local/ULA/GUA groups; overview/OLED status retains availability flags. The settings JSON is bounded to 2304 bytes, with no extra netif calls on HTTPD.
- HTTPS/WebSocket use IDF's existing dual-stack listener. SSH explicitly uses one dual-stack listener, retaining the existing two-session limit. Bracketed IPv6 HTTPS authorities are canonicalized and bound to the existing Host/Origin/session checks; scoped IPv6 literal URLs are rejected. Prefer the `.local` hostname for link-local browser access, subject to client resolver support. No certificate regeneration, new trust mechanism, or automatic IPv6 preference is introduced.
- STA-only mDNS advertises `_https._tcp:443` and `_ssh._tcp:22` according to listener availability, without TXT metadata. Record and address-family changes converge through the existing Wi-Fi owner, including offline reconciliation; service setters do no component work. The responder is not restarted for ordinary changes. Initialization failure remains latched/nonfatal.
- mDNS 1.12.0 exposes no readiness acknowledgement and can silently drop queued family actions. Absent families are disabled on each one-second owner pass; available families are re-enabled/reprobed on a 30-second repair cadence. Upstream synchronous calls can delay this cadence. There can be a transient stale/zero-A response window after IPv4 loss before disable is processed. AAAA records follow upstream **valid-address** semantics, including deprecated-but-still-valid addresses, not preferred-only filtering. Client caches expire independently.
- `cmake/mdns_membership.*` applies one version/hash-guarded, build-local source overlay to mDNS 1.12.0: balance per-family multicast leaves and unwind joins after PCB creation failure. Managed sources and dependency versions remain unchanged. An upstream mismatch fails configuration for deliberate review; see `tests/mdns_membership/README.md` for maintenance/removal and regression evidence. This is not the abandoned Phase 9 patch set.
- `sdkconfig.defaults` explicitly enables IPv4/IPv6 and increases mDNS service capacity from one to two. Existing saved configurations override defaults: verify `CONFIG_LWIP_IPV4=y`, `CONFIG_LWIP_IPV6=y`, `CONFIG_MDNS_MAX_SERVICES=2` and STA-only predefined mDNS interfaces before building. The local N16R8 saved configuration was updated accordingly. No new task, transport socket, broker slot, serial buffer, partition, filesystem or generated web asset was added.
- Existing diagnostic limits remain: hostname ping selects the resolver's first usable result (not Happy Eyeballs), scoped link-local ping is not newly supported, and traceroute remains IPv4-only. DHCPv6, infrastructure DNS registration of SLAAC addresses, and new IPv6 resolver provisioning are outside this baseline.
#### Validation evidence and acceptance
`pio run` passed on PlatformIO 6.12.0 / ESP-IDF 5.5.0 with the guarded mDNS source compiled: **94,396 B linked RAM / 1,853,025 B flash**, +176 B RAM / +5,380 B flash versus the recorded Phase 10 build. These are static link sizes, not runtime heap or stack headroom. Initial target compilation rejected lock-free bool atomics; the implementation now uses short portMUX sections. Existing SDK Kconfig notes remain.
Host checks passed: Wi-Fi/address/hostname reconciliation, mDNS lifecycle/family repair, actual patched multicast-membership functions and CMake guards, SSH dual-stack/management/runtime/security, HTTPS authority parser (689 cases on host and actual installed lwIP parser), cookie authorization variants, Network settings, browser UI/CSP (171 groups), HTTPS lifecycle (45 groups and eight status projections), firmware upload (88 cases plus SDK contract), broker diagnostics, and session-store/serial integration. Network-settings host fakes were updated after the new netif dependency exposed a compilation failure; the rerun passed. These are not packet-level or hardware evidence.
Follow-up after the user's live status report: both preferred-address flags were `yes`, while plain client `ping` selected IPv4. Source verification confirms the flags require actual preferred addresses; this is not evidence of failed SLAAC or verified IPv6 reachability. `wifi status` now prints the addresses for diagnosis. Latest follow-up `pio run` passed: **94,444 B linked RAM / 1,853,685 B flash** (+48 / +660 versus the initial Phase 12 build). Browser Admin console now permits canonical Wi-Fi commands, including disruptive edits and hidden prompts, as explicitly requested. Focused policy/browser Wi-Fi prompt, address snapshot/rendering, Network/cookie and UI regressions passed. The broader console-boundary suite passes its console/certificate stages but has a pre-existing SSH-adapter fixture compilation failure for missing `web_firmware_update_reserve_reboot`; no production change was made to hide it.
Latest address-display build: `pio run` **94,444 B linked RAM / 1,854,485 B flash**; Network API/owner checks and **174 browser groups plus CSP** passed. These remain build/host results, separate from the user's device evidence below.
##### User-reported device acceptance
User explicitly validated Phase 12 after confirming numeric IPv6 ping/HTTPS, Avahi AAAA resolution and normal IPv6 hostname access following the Fedora `authselect` mDNS correction. The earlier hostname failure was a client resolver configuration issue, not missing device IPv6 addresses. The acceptance capture adds:
- **Fresh boot:** HTTPS, SSH and mDNS running without reported startup failures; zero SSH sessions, serial stopped, no broker clients, USB attached but not host-open. The configured UART profile is **230400 baud, 8N1, RTS/CTS, DTR active, RTS threshold 96**.
- **Full client mix:** two authenticated public-key SSH sessions with IPv6 ULA peers (one serial observer, one admin console); two serial WebSockets (one writer, one observer); one active browser admin WebSocket; native USB host-open as an observer. Four broker clients, exactly one writer, all reported pending/event counts zero. Serial running with RX-available/TX-pending zero, CTS asserted and valid RS-232 voltage at observation time.
- SSH: two successful handshakes, zero handshake/authentication failures or timeouts, zero stream I/O failures and zero session revocations. Stream counters **RX 68 / accepted 65 / rejected 3 / TX 161,209 bytes**; two broker writer revocations were reported. The rejected bytes are retained as evidence, not silently described as zero loss or assigned an unverified cause.
- Web serial: two connections, **27 accepted RX frames/bytes**, zero rejected RX frames/bytes; **782 binary TX frames / 318,230 bytes**, 11 control frames / 948 bytes. Send/queue/protocol/close failure counters zero. Two writer requests were denied; the final broker snapshot still shows one writer. Browser admin: one connection, **106 RX / 3,721 TX bytes**, zero send/queue/protocol/authorization/backpressure failures. No response errors reported.
- HTTPS and SSH both report running, not transitioning, `ESP_OK`; mDNS reports expected announcement and `ESP_OK`. Expected announcement alone is not packet-level service-record proof; the earlier successful Avahi lookup separately establishes the reported AAAA lookup.
All heap figures are bytes, copied from the user's observations:
| Observation | Heap capability | Free | Lifetime minimum-free | Largest block |
|---|---|---:|---:|---:|
| Fresh boot | Internal 8-bit | 65,880 | 64,912 | 31,744 |
| Fresh boot | Internal DMA | 58,124 | 57,156 | 31,744 |
| Fresh boot | External PSRAM | 8,196,732 | 8,187,980 | 8,126,464 |
| Full client mix | Internal 8-bit | 35,408 | 8,528 | 22,528 |
| Full client mix | Internal DMA | 27,652 | 772 | 22,528 |
| Full client mix | External PSRAM | 8,111,952 | 8,072,612 | 7,995,392 |
SSH owner stack: **20,480 B** configured, **18,476 B minimum-free** at fresh-boot observation and **16,284 B minimum-free** under the full mix.
**Evidence limits:** minimum-free is the conservative sum of matching heap regions' lifetime minima, not a simultaneous free-space measurement or guaranteed allocation reserve. Internal 8-bit and DMA capabilities overlap and must not be added as independent pools. The **772 B DMA lifetime minimum remains a headroom watch item**, not an observed allocation failure or acceptance blocker. Boot output contains two TLS `-0x004C` receive errors and unauthenticated-request failures; the capture does not establish their cause or connect them to memory exhaustion. Command snapshots are non-atomic; differing cumulative request/authentication counts must not be treated as one aligned measurement interval. No UART overflow/per-observer drop counters, soak duration, byte-for-byte capture integrity, exhaustive fault injection, DHCP packet capture/server-zone update, or every address-family/service transition is established by this excerpt. User acceptance is recorded without inventing those passes.
##### Reusable regression guidance
The following procedures are retained for future regression testing, **not outstanding acceptance blockers or a claim that every item was executed**. Keep UART0 and USB recovery available:
1. On IPv4, IPv6-only and dual-stack STA networks, exercise HTTPS login, serial/admin WebSockets, SSH user/admin access and one-writer/observer isolation. Test hostname access plus IPv4 and unscoped ULA/GUA IPv6 literals; verify SSH host keys and HTTPS identity rather than trusting discovery.
2. Capture DHCP DISCOVER/REQUEST option 12 at boot, rename/renew and reconnect, including a maximum-length suffix. With a configured DHCP/DNS server, verify the resulting zone entry and its server-controlled update/removal behavior.
3. Browse `_https._tcp` and `_ssh._tcp` and inspect A/AAAA/SRV answers over both families. Stop/restart each service; rename; drop/reacquire DHCP while IPv6 survives; change/deprecate/expire RA prefixes; disconnect/reconnect. Confirm eventual withdrawal/restoration, valid-address AAAA semantics and no persistent zero-A response. Exercise repeated family transitions and, where practical, missed-event/action repair.
4. Check link-local-only readiness and the fallback-AP transition explicitly. Confirm the client preserves interface scope; do not infer browser link-local success from `ONLINE` alone. Test multicast filtering/AP isolation separately from firmware address readiness.
5. Repeat the previously accepted 230400-baud mixed USB/WebSocket/SSH workload with discovery and address changes. Capture current/minimum internal, DMA and PSRAM availability, task stack margins, UART overflow and per-client drop counters. No new runtime memory reserve or maximum-baud performance guarantee is claimed.
Phase 12 is complete by the explicit user validation above. This acceptance update changes documentation only; the agent did not run a new build, test, upload, erase or device operation.
## Cross-phase release gates
Every phase should satisfy the following before being marked complete, with any user-waived check explicitly recorded in that phase rather than reported as passed:
+6 -4
View File
@@ -28,12 +28,14 @@ HTTPD alone owns browser-admin socket IO and its 1,552-byte PSRAM-only payload.
Typed Settings permissions do not expand shell permissions. Parsed canonical arguments, not raw prefixes or completion suggestions, control admission:
- Browser `web` permits only `web status`, `web stop`, and exact `web certificate rotate --force`; certificate info/reset, diagnostics/performance and other web forms are denied.
- Browser `wifi`/`mdns` permit only status. Network mutation belongs to typed Settings or UART0/admin SSH.
- Browser `wifi` uses the canonical command handler, including bare `wifi` (status), profile/AP settings, persistence, lifecycle and network diagnostics. Profile/AP secret entry uses the shared hidden prompt: no input echo, history or completion of secret bytes; cancellation, disconnect and failed currentness discard the input. Explicit `wifi ap show-secret` reveals the AP password only in the invoking admin terminal; routine status and completion do not reveal credentials. Browser `mdns` still permits only exact `mdns status`.
- Browser `user` permits status/list/show and interactive add/password plus forced role/delete for **other accounts only**. Self changes, generated passwords, key commands and recovery are denied there; typed Accounts supports the separately bounded self/generated/key workflows.
- Browser SSH stop/disconnect/reset and host-key mutation are denied; typed SSH Settings has its own safe owner path. Do not claim full browser-shell parity.
- Browser `reboot` and owner-relative `exit` are supported. First-admin provisioning uses normal `user add` on UART0; unavailable-database recovery is UART0-only. The legacy `user bootstrap` and web credential commands no longer exist.
Self-affecting shell actions use the existing bounded drain/control path (up to ten seconds plus a short delay), not guaranteed peer delivery. Browser certificate rotation hands a typed action after drain/200 ms to the existing 12 KiB dispatcher, never crypto/NVS on the 4 KiB control stack. Pending input is discarded through execution, and an executing slot remains reserved across self-detach. UART0/admin SSH retain canonical recovery actions.
Wi-Fi changes use the existing canonical manager path, not deferred browser drain/acknowledgement. Stop, reconnect, profile/AP edits, load/defaults/reset can disconnect network clients before output arrives. A lost result does not cancel admitted work: reconnect and inspect before retrying; use UART0 if networking is unavailable. Changes are RAM-only until `wifi save`, except `wifi reset`, which also persists defaults. Native USB remains independent UART1 access, not an administration console.
Deferred self-affecting shell actions use the existing bounded drain/control path (up to ten seconds plus a short delay), not guaranteed peer delivery. Browser certificate rotation hands a typed action after drain/200 ms to the existing 12 KiB dispatcher, never crypto/NVS on the 4 KiB control stack. Pending input is discarded through execution, and an executing slot remains reserved across self-detach. UART0/admin SSH retain canonical recovery actions.
## Typed settings API and operation lifetime
@@ -43,7 +45,7 @@ All routes below are under `/api/settings/`. Each domain has bodyless GET snapsh
|---|---|---|
| `web_serial_settings`, serial service | `serial` / `serial-operation` | 256 / 256 / 96 |
| `web_account_settings`, user database | `accounts` / `account-operation` | 768 / 1024 (accounts), 512 (keys) / 96 |
| `web_network_settings`, Wi-Fi + mDNS | `network` / `network-operation` | 768 / 2048 / 128 |
| `web_network_settings`, Wi-Fi + mDNS | `network` / `network-operation` | 768 / 2304 / 128 |
| `web_display_settings`, local status UI | `display` / `display-operation` | 256 / 128 / 96 |
| `web_broker_settings`, session broker | `broker` / `broker-operation` | 256 / 2048 / 96 |
| `web_ssh_settings`, SSH owner/security | `ssh` / `ssh-operation` | 256 / 768 / 96 |
@@ -67,7 +69,7 @@ Separate bodyless POST `accounts/generate-password` returns one 24-character val
### Network
Wi-Fi config/runtime is one zero-wait consistent projection; mDNS is a separate projection, not cross-domain atomic authorization. Four stable profiles carry enabled/priority/security/SSID/password-configured metadata. `mixed` means WPA2-or-stronger, not open. `announced` is expected STA announcement, not verified DNS.
Wi-Fi config/runtime is one zero-wait consistent projection; mDNS is a separate projection, not cross-domain atomic authorization. Four stable profiles carry enabled/priority/security/SSID/password-configured metadata. `mixed` means WPA2-or-stronger, not open. `announced` is expected STA announcement, not verified DNS. Runtime `ipv6_addresses` contains up to three preferred addresses from the same snapshot as the availability flags, encoded as eight lowercase four-digit hextets without a zone. Network settings display separate link-local, ULA and GUA lists; absent groups show `none`. Link-local access needs the client's interface scope, and address presence does not assert a route or Internet reachability. Encoding uses the existing snapshot only, not netif/driver calls on HTTPD; the 2304-byte response buffer adds 256 bytes of bounded stack storage.
SSID wire values are reversible **bytes**, maximum 32: printable ASCII, standard single-character JSON escapes and `\u00HH`, with no raw non-ASCII, non-byte Unicode or surrogates. NUL/non-UTF-8 round-trip. UI text is UTF-8-encoded before byte serialization; exact reversible text or literal hex preserves existing bytes and BOM, with no silent replacement/truncation.
+4 -1
View File
@@ -39,6 +39,9 @@ CONFIG_ESP_HTTPS_SERVER_ENABLE=y
CONFIG_HTTPD_WS_SUPPORT=y
# Reserve capacity for HTTPS/WebSocket clients plus two bounded SSH sessions.
CONFIG_LWIP_MAX_SOCKETS=16
# One dual-stack listener per service; SLAAC is enabled on STA by its owner.
CONFIG_LWIP_IPV4=y
CONFIG_LWIP_IPV6=y
# Keep work submission bounded; one-second socket timeouts limit shared-task stalls.
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
@@ -54,7 +57,7 @@ CONFIG_ESP_WOLFSSL_NO_STACK_SIZE_BUILD_WARNING=y
# Keep configurable hostname discovery STA-only and bounded. The responder task
# remains internal; general mDNS metadata prefers PSRAM to protect internal heap.
CONFIG_MDNS_MAX_INTERFACES=1
CONFIG_MDNS_MAX_SERVICES=1
CONFIG_MDNS_MAX_SERVICES=2
CONFIG_MDNS_PREDEF_NETIF_STA=y
# CONFIG_MDNS_PREDEF_NETIF_AP is not set
# CONFIG_MDNS_PREDEF_NETIF_ETH is not set
+4 -4
View File
@@ -556,9 +556,9 @@ static bool remote_command_allowed(const admin_request_t *request)
strcmp(argv[1], "recover") == 0) {
allowed = false;
}
/* Temporary browser policy until lifecycle acknowledgements/revocation are
* coordinated (8D.7). Classify parsed canonical arguments, not raw prefixes.
* User mutations remain available through UART0/SSH, subject to their policy.
/* Classify parsed canonical arguments, not raw prefixes. Wi-Fi uses the
* canonical handler, including hidden prompts and disruptive changes;
* unrelated browser restrictions remain narrower than UART0/SSH.
*/
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
if (strcmp(argv[0], "web") == 0) {
@@ -566,7 +566,7 @@ static bool remote_command_allowed(const admin_request_t *request)
strcmp(argv[1], "stop") == 0)) ||
(argc == 4U && strcmp(argv[1], "certificate") == 0 &&
strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0);
} else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) {
} else if (strcmp(argv[0], "mdns") == 0) {
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
} else if (strcmp(argv[0], "user") == 0) {
allowed = admin_ssh_console_web_user_command_allowed(
+11 -7
View File
@@ -271,14 +271,14 @@ static char parity_letter(serial_config_parity_t parity)
static void format_ipv4(uint32_t address, char output[16])
{
if (address == 0U) {
memcpy(output, "0.0.0.0", sizeof("0.0.0.0"));
memcpy(output, "none", sizeof("none"));
return;
}
esp_ip4_addr_t ip = {.addr = address};
int written = snprintf(output, 16U, IPSTR, IP2STR(&ip));
if (written < 0 || written >= 16) {
memcpy(output, "0.0.0.0", sizeof("0.0.0.0"));
memcpy(output, "unknown", sizeof("unknown"));
}
}
@@ -565,8 +565,8 @@ static void render_overview_page(const local_status_snapshot_t *snapshot)
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line);
format_ipv4(snapshot->wifi.ip, address);
format_text(line, "IP:%s", address);
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 32U, line);
format_text(line, "IPv4:%s", snapshot->wifi_available ? address : "unknown");
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 32U, line);
format_text(line, "Web:%u SSH:%u USB:%u",
snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U,
@@ -664,7 +664,11 @@ static void render_network_page(const local_status_snapshot_t *snapshot)
const char *ssid;
uint8_t channel;
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "Network / services");
/* Address availability, not route/reachability; fit both flags in 21 columns. */
format_text(line, "IPv6 LL:%c ULA/GUA:%c",
!snapshot->wifi_available ? '?' : snapshot->wifi.ipv6_linklocal ? 'Y' : 'N',
!snapshot->wifi_available ? '?' : snapshot->wifi.ipv6_routable ? 'Y' : 'N');
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, line);
wifi_identity(snapshot, &ssid, &channel);
format_ssid(ssid, ssid_display);
format_text(line, "WiFi:%s Ch:%u", ssid_display, channel);
@@ -672,8 +676,8 @@ static void render_network_page(const local_status_snapshot_t *snapshot)
wifi_snapshot_bitmap(snapshot));
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 8U, line);
format_ipv4(snapshot->wifi.ip, address);
format_text(line, "IP:%s", address);
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 16U, line);
format_text(line, "IPv4:%s", snapshot->wifi_available ? address : "unknown");
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, line);
format_text(line, "AP:%s Clients:%u", on_off(snapshot->wifi.ap_running),
snapshot->wifi.ap_client_count);
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line);
+142 -4
View File
@@ -4,6 +4,9 @@
#include "mdns_service.h"
#include <stdio.h>
#include "esp_netif.h"
#include "esp_timer.h"
#include "sdkconfig.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
@@ -17,6 +20,125 @@ static bool s_component_initialized;
static bool s_initialization_failed;
static bool s_announced;
static esp_err_t s_last_error;
static void lock_service(void);
static void unlock_service(void);
static portMUX_TYPE s_availability_mux = portMUX_INITIALIZER_UNLOCKED;
static bool s_https_available;
static bool s_ssh_available;
static unsigned s_requested_families;
static bool s_family_request_valid;
static int64_t s_family_refresh_at;
#define MDNS_FAMILY_REPAIR_US INT64_C(30000000)
/* Component-facing state belongs exclusively to the Wi-Fi manager task. */
static bool s_https_registered;
static bool s_ssh_registered;
static uint32_t s_applied_generation;
void mdns_service_set_https_available(bool available)
{
portENTER_CRITICAL(&s_availability_mux);
s_https_available = available;
portEXIT_CRITICAL(&s_availability_mux);
}
void mdns_service_set_ssh_available(bool available)
{
portENTER_CRITICAL(&s_availability_mux);
s_ssh_available = available;
portEXIT_CRITICAL(&s_availability_mux);
}
static esp_err_t reconcile_record(const char *type, uint16_t port,
bool available, bool *registered)
{
if (available == *registered) return ESP_OK;
esp_err_t error = available
? mdns_service_add(NULL, type, "_tcp", port, NULL, 0)
: mdns_service_remove(type, "_tcp");
if (error == ESP_OK) *registered = available;
return error;
}
typedef struct {
esp_netif_t *sta;
bool online;
unsigned families;
} family_snapshot_t;
/* get_all_ip6 reads lwIP state directly in IDF 5.5; collect in TCP/IP context. */
static esp_err_t read_families(void *context)
{
family_snapshot_t *snapshot = context;
esp_netif_t *sta = snapshot->sta;
if (snapshot->online && esp_netif_is_netif_up(sta)) {
esp_netif_ip_info_t ip4 = {0};
if (esp_netif_get_ip_info(sta, &ip4) == ESP_OK && ip4.ip.addr != 0) snapshot->families |= 1U;
esp_ip6_addr_t ip6[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
int count = esp_netif_get_all_ip6(sta, ip6);
for (int i = 0; i < count; ++i) {
if (ip6[i].addr[0] || ip6[i].addr[1] || ip6[i].addr[2] || ip6[i].addr[3]) {
snapshot->families |= 2U;
break;
}
}
}
return ESP_OK;
}
/* Public mDNS has no readiness getter and its action API can silently drop a
* full-queue submission. Never treat our requested mask as acknowledged state.
* Disables are idempotent; enables restart probes, so repair those at a slower
* cadence rather than churning every manager poll. */
static esp_err_t reconcile_families(bool online)
{
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (!sta) return ESP_ERR_INVALID_STATE;
family_snapshot_t snapshot = { .sta = sta, .online = online };
esp_err_t error = esp_netif_tcpip_exec(read_families, &snapshot);
if (error != ESP_OK) return error;
unsigned families = snapshot.families;
int64_t now = esp_timer_get_time();
bool refresh = !s_family_request_valid || families != s_requested_families ||
now >= s_family_refresh_at;
unsigned actions = 0;
if (!(families & 1U)) actions |= MDNS_EVENT_DISABLE_IP4;
else if (refresh) actions |= MDNS_EVENT_ENABLE_IP4;
if (!(families & 2U)) actions |= MDNS_EVENT_DISABLE_IP6;
else if (refresh) actions |= MDNS_EVENT_ENABLE_IP6;
if (!actions) return ESP_OK;
error = mdns_netif_action(sta, (mdns_event_actions_t)actions);
if (error == ESP_OK && refresh) {
s_requested_families = families;
s_family_request_valid = true;
s_family_refresh_at = now + MDNS_FAMILY_REPAIR_US;
}
return error;
}
esp_err_t mdns_service_reconcile(void)
{
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_service();
bool initialized = s_component_initialized;
bool online = s_announced;
esp_err_t error = s_initialization_failed ? s_last_error : ESP_OK;
unlock_service();
if (!initialized) return error;
error = reconcile_families(online);
portENTER_CRITICAL(&s_availability_mux);
bool https_available = s_https_available;
bool ssh_available = s_ssh_available;
portEXIT_CRITICAL(&s_availability_mux);
esp_err_t https_error = reconcile_record("_https", 443, https_available, &s_https_registered);
if (error == ESP_OK) error = https_error;
esp_err_t ssh_error = reconcile_record("_ssh", 22, ssh_available, &s_ssh_registered);
if (error == ESP_OK) error = ssh_error;
lock_service();
s_last_error = error;
unlock_service();
return error;
}
static void lock_service(void)
{
@@ -149,9 +271,16 @@ esp_err_t mdns_service_start(void)
return error;
}
mdns_config_t config = s_config;
uint32_t generation = s_config_generation;
unlock_service();
/* Predefined AP/ETH handlers would independently re-enable those interfaces.
* Fail nonfatally rather than accidentally advertise outside STA. */
#if !CONFIG_MDNS_PREDEF_NETIF_STA || CONFIG_MDNS_PREDEF_NETIF_AP || CONFIG_MDNS_PREDEF_NETIF_ETH
esp_err_t error = ESP_ERR_INVALID_STATE;
#else
esp_err_t error = mdns_init();
#endif
if (error == ESP_OK) {
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
make_hostname(&config, hostname, sizeof(hostname));
@@ -165,12 +294,13 @@ esp_err_t mdns_service_start(void)
}
lock_service();
if (error == ESP_OK) s_applied_generation = generation;
s_component_initialized = error == ESP_OK;
s_initialization_failed = error != ESP_OK;
s_announced = error == ESP_OK;
s_last_error = error;
unlock_service();
return error;
return error == ESP_OK ? mdns_service_reconcile() : error;
}
void mdns_service_stop(void)
@@ -181,6 +311,7 @@ void mdns_service_stop(void)
lock_service();
s_announced = false;
unlock_service();
(void)mdns_service_reconcile();
}
esp_err_t mdns_service_reannounce(void)
@@ -195,11 +326,18 @@ esp_err_t mdns_service_reannounce(void)
return error;
}
mdns_config_t config = s_config;
uint32_t generation = s_config_generation;
unlock_service();
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
make_hostname(&config, hostname, sizeof(hostname));
esp_err_t error = mdns_hostname_set(hostname);
esp_err_t error = ESP_OK;
if (generation != s_applied_generation) {
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
make_hostname(&config, hostname, sizeof(hostname));
error = mdns_hostname_set(hostname);
if (error == ESP_OK) s_applied_generation = generation;
}
esp_err_t service_error = mdns_service_reconcile();
if (error == ESP_OK) error = service_error;
lock_service();
s_last_error = error;
+17 -1
View File
@@ -32,7 +32,23 @@ typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD,
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
const mdns_config_t *config, bool *stored);
/* Only wifi_manager may call these lifecycle operations. */
/* Service-owner notifications: short portMUX publication, allocation-free,
* safe before init. No blocking semaphore or component call.
* Publish true only after the listener starts, false when it becomes unavailable.
* No mDNS API or other service lock is taken here. */
void mdns_service_set_https_available(bool available);
void mdns_service_set_ssh_available(bool available);
/* Only wifi_manager may call these lifecycle operations (serialized).
* Reconcile periodically, including while offline, to retire unavailable services
* and reconcile STA address-family readiness from authoritative netif state.
* No additional Wi-Fi arguments are needed; WIFI_STA_DEF must remain alive.
* Stop requests family disable as well as clearing announcement expectation.
* Availability notifications converge on the next reconciliation, not immediately.
* Public upstream actions have no acknowledgement and may silently drop; absent
* families are disabled each pass, present families repaired every 30 seconds.
* Consequently healthy enables re-probe at that repair cadence, not every poll. */
esp_err_t mdns_service_reconcile(void);
esp_err_t mdns_service_start(void);
void mdns_service_stop(void);
esp_err_t mdns_service_reannounce(void);
+56 -14
View File
@@ -21,6 +21,7 @@
#include "lwip/inet.h"
#include "lwip/sockets.h"
#include "lwip/tcp.h"
#include "mdns_service.h"
#include "sdkconfig.h"
#include "secure_random.h"
#include "serial_service.h"
@@ -80,7 +81,7 @@ typedef struct {
size_t tx_length;
uint8_t rx_buffer[SSH_TRANSPORT_IO_BUFFER_SIZE];
uint8_t tx_buffer[SSH_TRANSPORT_IO_BUFFER_SIZE];
char peer[48];
char peer[SSH_TRANSPORT_PEER_CAPACITY];
} ssh_slot_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
@@ -675,7 +676,7 @@ static esp_err_t create_context(void)
static esp_err_t create_listener(void)
{
int socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
int socket_fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
if (socket_fd < 0) {
return ESP_FAIL;
}
@@ -683,12 +684,17 @@ static esp_err_t create_listener(void)
int enabled = 1;
(void)setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR,
&enabled, sizeof(enabled));
struct sockaddr_in address = {
.sin_family = AF_INET,
.sin_port = htons(SSH_TRANSPORT_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY),
/* IDF lwIP binds :: to IPADDR_TYPE_ANY when V6ONLY is disabled.
* One listener preserves the shared accept budget and socket footprint. */
int ipv6_only = 0;
struct sockaddr_in6 address = {
.sin6_family = AF_INET6,
.sin6_port = htons(SSH_TRANSPORT_PORT),
.sin6_addr = IN6ADDR_ANY_INIT,
};
if (bind(socket_fd, (struct sockaddr *)&address, sizeof(address)) < 0 ||
if (setsockopt(socket_fd, IPPROTO_IPV6, IPV6_V6ONLY,
&ipv6_only, sizeof(ipv6_only)) < 0 ||
bind(socket_fd, (struct sockaddr *)&address, sizeof(address)) < 0 ||
listen(socket_fd, SSH_TRANSPORT_LISTEN_BACKLOG) < 0 ||
set_nonblocking(socket_fd) != ESP_OK) {
close(socket_fd);
@@ -717,12 +723,14 @@ static esp_err_t start_runtime(void)
s_context = NULL;
}
}
mdns_service_set_ssh_available(error == ESP_OK);
return error;
}
static esp_err_t stop_runtime(void)
{
close_socket(&s_listen_fd);
mdns_service_set_ssh_available(false);
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
request_slot_close(&s_slots[index], false);
}
@@ -850,28 +858,56 @@ static ssh_slot_t *find_free_slot(size_t *slot_index)
static void format_peer(const struct sockaddr_storage *address,
char *output, size_t output_size)
{
if (output_size == 0U) return;
output[0] = '\0';
int written = -1;
if (address->ss_family == AF_INET) {
const struct sockaddr_in *ipv4 = (const struct sockaddr_in *)address;
char host[INET_ADDRSTRLEN] = {0};
if (inet_ntop(AF_INET, &ipv4->sin_addr, host, sizeof(host)) != NULL) {
(void)snprintf(output, output_size, "%s:%u", host,
(unsigned int)ntohs(ipv4->sin_port));
written = snprintf(output, output_size, "%s:%u", host,
(unsigned int)ntohs(ipv4->sin_port));
}
} else if (address->ss_family == AF_INET6) {
const struct sockaddr_in6 *ipv6 = (const struct sockaddr_in6 *)address;
char host[INET6_ADDRSTRLEN] = {0};
if (inet_ntop(AF_INET6, &ipv6->sin6_addr, host, sizeof(host)) != NULL) {
(void)snprintf(output, output_size, "[%s]:%u", host,
(unsigned int)ntohs(ipv6->sin6_port));
/* lwIP accept supplies the interface zone in sin6_scope_id.
* Retain it for link-local peers; never infer an interface by name. */
if (ipv6->sin6_scope_id != 0U) {
written = snprintf(output, output_size, "[%s%%%" PRIu32 "]:%u",
host, (uint32_t)ipv6->sin6_scope_id,
(unsigned int)ntohs(ipv6->sin6_port));
} else {
written = snprintf(output, output_size, "[%s]:%u", host,
(unsigned int)ntohs(ipv6->sin6_port));
}
}
}
if (output[0] == '\0') {
/* A truncated scoped endpoint must not look like a usable address. */
if (written < 0 || (size_t)written >= output_size) {
strncpy(output, "unknown", output_size - 1U);
output[output_size - 1U] = '\0';
}
}
static void listener_failed(void)
{
close_socket(&s_listen_fd);
mdns_service_set_ssh_available(false);
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
request_slot_close(&s_slots[index], false);
}
taskENTER_CRITICAL(&s_lock);
s_running = false;
s_cleanup_pending = true;
s_last_error = ESP_FAIL;
if (s_management_generation < UINT32_MAX) ++s_management_generation;
taskEXIT_CRITICAL(&s_lock);
/* process_slots retires the context only after every session is free.
* Do not alter an already admitted lifecycle command or auto-restart. */
}
static void accept_connections(void)
{
if (s_listen_fd < 0 || s_context == NULL) {
@@ -881,13 +917,19 @@ static void accept_connections(void)
for (unsigned int accepted_count = 0U;
accepted_count < SSH_TRANSPORT_ACCEPT_BUDGET;
++accepted_count) {
struct sockaddr_storage peer_address;
struct sockaddr_storage peer_address = {0};
socklen_t peer_length = sizeof(peer_address);
int socket_fd = accept(s_listen_fd, (struct sockaddr *)&peer_address,
&peer_length);
if (socket_fd < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK) {
int error = errno;
if (error != EAGAIN && error != EWOULDBLOCK && error != EINTR) {
add_counter(&s_counters.io_failures, 1U);
/* Resource pressure/aborted peers do not invalidate a listener. */
if (error == EBADF || error == EINVAL || error == ENOTSOCK ||
error == EOPNOTSUPP) {
listener_failed();
}
}
return;
}
+4 -1
View File
@@ -16,6 +16,8 @@ extern "C" {
#endif
#define SSH_TRANSPORT_PORT 22U
/* IPv6 text (45), %uint32 scope (11), brackets/colon/port (8), NUL. */
#define SSH_TRANSPORT_PEER_CAPACITY 65U
#define SSH_TRANSPORT_MAX_SESSIONS 2U
#define SSH_TRANSPORT_IO_BUFFER_SIZE 512U
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
@@ -82,7 +84,7 @@ typedef struct {
user_role_t user_role;
user_auth_method_t auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
char peer[48];
char peer[SSH_TRANSPORT_PEER_CAPACITY];
} ssh_transport_session_snapshot_t;
typedef struct {
@@ -121,6 +123,7 @@ esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
esp_err_t ssh_transport_init(void);
/* One dual-stack wildcard listener; failure never falls back to one family. */
esp_err_t ssh_transport_start(void);
esp_err_t ssh_transport_stop(void);
+120
View File
@@ -2,6 +2,125 @@
#include "web_auth_parse.h"
#include <string.h>
#ifdef ESP_PLATFORM
#include "lwip/sockets.h"
#else
#include <arpa/inet.h>
#endif
#include <stdio.h>
/* lwIP's inet_pton accepts some non-IPv6 suffixes and oversized hextets.
* Validate the entire grammar first, rather than trusting libc/lwIP parity. */
static bool ipv6_syntax(const char *s, size_t n)
{
size_t i = 0;
unsigned groups = 0;
bool compressed = false;
if (n && s[0] == ':') {
if (n < 2 || s[1] != ':') return false;
compressed = true;
i = 2;
}
while (i < n) {
size_t start = i;
while (i < n && s[i] != ':') ++i;
size_t end = i;
if (memchr(s + start, '.', end - start)) {
if (end != n) return false;
for (unsigned part = 0; part < 4; ++part) {
size_t first = start;
unsigned value = 0;
while (start < end && s[start] >= '0' && s[start] <= '9') {
value = value * 10U + (unsigned)(s[start++] - '0');
if (start - first > 3 || value > 255) return false;
}
if (start == first || (start - first > 1 && s[first] == '0')) return false;
if (part < 3 && (start == end || s[start++] != '.')) return false;
}
if (start != end) return false;
groups += 2;
} else {
if (end == start || end - start > 4) return false;
for (size_t j = start; j < end; ++j)
if (!((s[j] >= '0' && s[j] <= '9') ||
(s[j] >= 'a' && s[j] <= 'f') ||
(s[j] >= 'A' && s[j] <= 'F'))) return false;
++groups;
}
if (groups > 8) return false;
if (i < n) {
++i;
if (i < n && s[i] == ':') {
if (compressed) return false;
compressed = true;
++i;
} else if (i == n) return false;
}
}
return compressed ? groups < 8 : groups == 8;
}
static bool ipv6_authority(const char *text, size_t length, char *out)
{
const char *close = memchr(text, ']', length);
if (!close) return false;
size_t end = (size_t)(close - text), suffix = length - end - 1U;
if (suffix && (suffix != 4 || memcmp(close + 1, ":443", 4))) return false;
char literal[46];
if (end < 2 || end - 1 >= sizeof(literal) || !ipv6_syntax(text + 1, end - 1)) return false;
memcpy(literal, text + 1, end - 1);
literal[end - 1] = 0;
/* lwIP only recognizes some dotted-tail layouts. Convert the already
* validated decimal tail to two hextets before invoking its parser. */
if (strchr(literal, '.')) {
char *tail = strrchr(literal, ':');
if (!tail) return false;
++tail;
unsigned octets[4] = {0};
unsigned part = 0;
for (const char *p = tail; *p; ++p) {
if (*p == '.') ++part;
else octets[part] = octets[part] * 10U + (unsigned)(*p - '0');
}
snprintf(tail, sizeof(literal) - (size_t)(tail - literal), "%x:%x",
(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
}
struct in6_addr address;
if (inet_pton(AF_INET6, literal, &address) != 1) return false;
/* Format bytes ourselves: lwIP ntop differs from RFC 5952 and host libc.
* Mapped addresses stay bracketed IPv6, rendered as hex, never IPv4/DNS. */
const unsigned char *bytes = (const unsigned char *)&address;
unsigned words[8], best = 8, longest = 1;
for (unsigned i = 0; i < 8; ++i) words[i] = ((unsigned)bytes[2*i] << 8) | bytes[2*i+1];
for (unsigned i = 0; i < 8;) {
unsigned start = i;
while (i < 8 && !words[i]) ++i;
if (i - start > longest) { best = start; longest = i - start; }
if (i < 8) ++i;
}
static const char hex[] = "0123456789abcdef";
size_t pos = 0;
out[pos++] = '[';
for (unsigned i = 0; i < 8;) {
if (i == best) {
out[pos++] = ':'; out[pos++] = ':';
i += longest;
continue;
}
if (pos > 1 && out[pos - 1] != ':') out[pos++] = ':';
unsigned shift = 12;
while (shift && !(words[i] >> shift)) shift -= 4;
for (;;) {
out[pos++] = hex[(words[i] >> shift) & 15U];
if (!shift) break;
shift -= 4;
}
++i;
}
out[pos++] = ']'; out[pos] = 0;
return true;
}
static void wipe(void *buffer, size_t length)
{
@@ -18,6 +137,7 @@ static bool alnum_ascii(unsigned char c)
static bool authority(const char *text, size_t length, char *out)
{
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
if (text[0] == '[') return ipv6_authority(text, length, out);
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
size_t label = 0;
+5 -3
View File
@@ -21,9 +21,11 @@ typedef struct {
} web_auth_credentials_t;
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
* IPv6 literals are deliberately rejected until the device supports that route.
* Only optional :443 is accepted. Origin is mandatory and must match Host.
* alias. Failures clear output. Host supports ASCII DNS/IPv4 and bracketed IPv6.
* IPv6 uses lowercase hex, longest zero-run compression (first on ties), and
* hex tails even for mapped IPv4. No zones, DNS lookup or IPv4 equivalence.
* Only optional exact :443 is accepted. Origin is mandatory and must match
* canonical Host; accepting a literal does not establish network reachability.
* HTTP callers must separately reject duplicate header lines, enforce methods,
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
bool web_auth_parse_origin(const char *host, size_t host_length,
+16 -3
View File
@@ -334,7 +334,8 @@ static esp_err_t snapshot_response(httpd_req_t *request)
wifi_manager_settings_t wifi;
mdns_service_snapshot_t mdns;
/* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK)
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK ||
wifi.runtime.ipv6_count > WIFI_MANAGER_IPV6_MAX_ADDRESSES)
return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}");
char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0;
#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0)
@@ -355,9 +356,21 @@ static esp_err_t snapshot_response(httpd_req_t *request)
/* IPv4 bytes are already in network order, independent of host endianness. */
const uint8_t *ip = (const uint8_t *)&r->ip;
ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\","
"\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d},",
"\"ipv6_linklocal\":%s,\"ipv6_routable\":%s,\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d,\"ipv6_addresses\":[",
json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile,
ip[0], ip[1], ip[2], ip[3], json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
ip[0], ip[1], ip[2], ip[3], json_bool(r->ipv6_linklocal), json_bool(r->ipv6_routable),
json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
for (unsigned i = 0; i < r->ipv6_count; ++i) {
const uint8_t *bytes = (const uint8_t *)r->ipv6_addresses[i].addr;
ADD("%s\"", i ? "," : "");
/* Fixed-width network-order hextets keep the wire schema unambiguous. */
for (unsigned block = 0; block < 8; ++block) {
unsigned value = ((unsigned)bytes[2 * block] << 8) | bytes[2 * block + 1];
ADD("%s%04x", block ? ":" : "", value);
}
ADD("\"");
}
ADD("]},");
ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}",
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
#undef SSID
+3 -1
View File
@@ -4,7 +4,7 @@
#include "esp_http_server.h"
#define WEB_NETWORK_REQUEST_MAX 768U
#define WEB_NETWORK_SNAPSHOT_MAX 2048U
#define WEB_NETWORK_SNAPSHOT_MAX 2304U
/* Integration: optional exact GET /api/settings/network -> snapshot_handler;
* exact GET and POST /api/settings/network-operation -> operation_handler.
@@ -22,6 +22,8 @@
* non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI
* must encode UTF-8 text into bytes before encoding this field, and retain a
* reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes.
* Runtime ipv6_addresses contains at most three preferred addresses as fixed
* eight-hextet lowercase strings, without a zone or reachability assertion.
* No saved PSK/length is returned, only password_configured. Omitted password
* preserves current bytes; clear_password:true is distinct from replacement.
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
+9 -2
View File
@@ -40,6 +40,7 @@
#include "web_diagnostics.h"
#include "web_ui.h"
#include "wifi_manager.h"
#include "mdns_service.h"
#define WEB_SERVER_PORT 443U
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
@@ -271,7 +272,7 @@ static esp_err_t status_handler(httpd_req_t *request)
"{\n"
" \"uptime_ms\":%" PRIu64 ",\n"
" \"wifi\":{\"available\":%s,\"state\":\"%s\",\"sta_ipv4\":\"%s\","
"\"rssi\":%d,\"channel\":%u,\"ap_running\":%s,\"ap_clients\":%u},\n"
"\"ipv6_linklocal\":%s,\"ipv6_routable\":%s,\"rssi\":%d,\"channel\":%u,\"ap_running\":%s,\"ap_clients\":%u},\n"
" \"serial\":{\"running\":%s,\"config_available\":%s,\"baud\":%" PRIu32 ","
"\"data_bits\":\"%s\",\"parity\":\"%s\",\"stop_bits\":\"%s\","
"\"flow\":\"%s\",\"rx_bytes\":%" PRIu64 ",\"rx_dropped\":%" PRIu64 ","
@@ -289,7 +290,10 @@ static esp_err_t status_handler(httpd_req_t *request)
(uint64_t)(esp_timer_get_time() / 1000),
wifi_available ? "true" : "false",
wifi_available ? wifi_manager_state_to_string(wifi.state) : "unavailable",
ipv4, wifi_available ? (int)wifi.sta_rssi : 0,
ipv4,
wifi_available && wifi.ipv6_linklocal ? "true" : "false",
wifi_available && wifi.ipv6_routable ? "true" : "false",
wifi_available ? (int)wifi.sta_rssi : 0,
wifi_available ? (unsigned int)wifi.sta_channel : 0U,
wifi_available && wifi.ap_running ? "true" : "false",
wifi_available ? (unsigned int)wifi.ap_client_count : 0U,
@@ -786,6 +790,7 @@ static esp_err_t start_server(bool reserved)
s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached;
s_admin_transport_owned = admin_transport_owned;
mdns_service_set_https_available(error == ESP_OK);
if (error == ESP_OK) {
s_server = server;
++s_counters.starts;
@@ -824,6 +829,8 @@ static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool re
esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true;
if (s_generation != UINT32_MAX) ++s_generation;
/* Admission is about to close, even if later teardown must be retried. */
mdns_service_set_https_available(false);
xSemaphoreGive(s_server_mutex);
web_cookie_auth_stop();
+11 -5
View File
@@ -1308,9 +1308,12 @@ static const char s_app_js[] =
" return netShape(w, ['generation','enabled_at_boot','ap','profiles']) && netInteger(w.generation, 1, 4294967295) && typeof w.enabled_at_boot === 'boolean' &&\n"
" netShape(w.ap, ['policy','channel','ssid','password_configured']) && ['off','fallback','always'].includes(w.ap.policy) && netInteger(w.ap.channel, 1, 11) && netBytes(w.ap.ssid) && w.ap.ssid.length > 0 && w.ap.password_configured === true &&\n"
" Array.isArray(w.profiles) && w.profiles.length === 4 && w.profiles.every((p, i) => netShape(p, ['index','enabled','priority','security','ssid','password_configured']) && p.index === i && typeof p.enabled === 'boolean' && netInteger(p.priority, 0, 255) && ['mixed','wpa3'].includes(p.security) && netBytes(p.ssid) && typeof p.password_configured === 'boolean' && (!p.enabled || p.ssid.length > 0 && p.password_configured) && (p.ssid.length > 0 || !p.password_configured)) &&\n"
" netShape(r, ['started','state','active_profile','ip','ap_running','ap_clients','last_error']) && typeof r.started === 'boolean' && ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown'].includes(r.state) && netInteger(r.active_profile, -1, 3) && typeof r.ip === 'string' && r.ip.length <= 15 && (r.ip === '' || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(r.ip) && r.ip.split('.').every(n => Number(n) <= 255)) && typeof r.ap_running === 'boolean' && netInteger(r.ap_clients, 0, 255) && netInteger(r.last_error, -2147483648, 2147483647) &&\n"
" netShape(r, ['started','state','active_profile','ip','ipv6_linklocal','ipv6_routable','ipv6_addresses','ap_running','ap_clients','last_error']) && typeof r.started === 'boolean' && ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown'].includes(r.state) && netInteger(r.active_profile, -1, 3) && typeof r.ip === 'string' && r.ip.length <= 15 && (r.ip === '' || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(r.ip) && r.ip.split('.').every(n => Number(n) <= 255)) && typeof r.ipv6_linklocal === 'boolean' && typeof r.ipv6_routable === 'boolean' && Array.isArray(r.ipv6_addresses) && r.ipv6_addresses.length <= 3 && r.ipv6_addresses.every(a => typeof a === 'string' && a.length === 39 && /^(?:[0-9a-f]{4}:){7}[0-9a-f]{4}$/.test(a)) && typeof r.ap_running === 'boolean' && netInteger(r.ap_clients, 0, 255) && netInteger(r.last_error, -2147483648, 2147483647) &&\n"
" netShape(m, ['generation','suffix','hostname','announced','last_error']) && netInteger(m.generation, 1, 4294967295) && netSuffix(m.suffix) && m.hostname === 'sak-' + m.suffix && typeof m.announced === 'boolean' && netInteger(m.last_error, -2147483648, 2147483647);\n"
"}\n"
"const networkIPv4 = ip => ip && ip !== '0.0.0.0' ? ip : 'none';\n"
"const networkIPv6 = r => 'IPv6 link-local: ' + (r.ipv6_linklocal ? 'available' : 'none') + ' / ULA/GUA: ' + (r.ipv6_routable ? 'available' : 'none');\n"
"const networkIPv6Addresses = (r, prefix) => r.ipv6_addresses.filter(a => prefix.test(a)).join(', ') || 'none';\n"
"function networkContext() { return JSON.stringify([networkSnapshot?.wifi.generation, networkSnapshot?.mdns.generation, ...networkFields.map(id => [net(id).value, net(id).checked])]); }\n"
"function clearNetworkSecret() {\n"
" window.clearTimeout(networkSecretTimer); networkSecretTimer = null; networkSecretUntil = 0; networkSecretContext = '';\n"
@@ -1399,7 +1402,7 @@ static const char s_app_js[] =
" net('detail').textContent = 'Reading network. Previous snapshot is stale; refresh discards drafts.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" const {payload, status} = await api('/api/settings/network', generation, {signal: controller.signal, limit: 2048, current});\n"
" const {payload, status} = await api('/api/settings/network', generation, {signal: controller.signal, limit: 2304, current});\n"
" if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n"
" networkSnapshot = payload; networkFresh = true;\n"
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
@@ -1407,13 +1410,15 @@ static const char s_app_js[] =
" ['Wi-Fi generation', w.generation], ['Enabled at boot', w.enabled_at_boot],\n"
" ['AP policy / channel', w.ap.policy + ' / ' + w.ap.channel], ['AP SSID', networkSSIDSummary(w.ap.ssid)], ['AP password configured', w.ap.password_configured],\n"
" ...w.profiles.flatMap(p => [['STA ' + p.index, 'enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security], ['STA ' + p.index + ' SSID', networkSSIDSummary(p.ssid)], ['STA ' + p.index + ' password configured', p.password_configured]]),\n"
" ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IP', r.ip || 'none'],\n"
" ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IPv4', networkIPv4(r.ip)], ['IPv6 link-local', r.ipv6_linklocal ? 'available' : 'none'], ['IPv6 ULA/GUA', r.ipv6_routable ? 'available' : 'none'],\n"
" ['IPv6 link-local addresses', networkIPv6Addresses(r, /^fe[89ab]/)], ['IPv6 ULA addresses', networkIPv6Addresses(r, /^f[cd]/)], ['IPv6 GUA addresses', networkIPv6Addresses(r, /^[23]/)],\n"
" ['IPv6 reporting', 'Preferred addresses; no route or Internet reachability guarantee. Link-local access requires the client interface scope.'],\n"
" ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n"
" ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n"
" ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n"
" if (!['ap','0','1','2','3'].includes(net('target').value) || quick && net('target').value !== 'ap' && !w.profiles[Number(net('target').value)]?.ssid) net('target').value = 'ap';\n"
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : '') + (quick ? r.state + ' · IP: ' + (r.ip || 'none') + ' · AP: ' + (r.ap_running ? 'running' : 'off') + ' · Profile: ' + (r.active_profile < 0 ? 'none' : r.active_profile) : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). Browser drafts are not saved; Save persists device working state.');\n"
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : '') + (quick ? r.state + ' · IPv4: ' + networkIPv4(r.ip) + ' · ' + networkIPv6(r) + ' · AP: ' + (r.ap_running ? 'running' : 'off') + ' · Profile: ' + (r.active_profile < 0 ? 'none' : r.active_profile) : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). Browser drafts are not saved; Save persists device working state.');\n"
" } catch (error) { if (live(generation) && current()) net('detail').textContent = 'Network snapshot stale or unavailable/invalid. Refresh explicitly to retry. No values inferred.'; }\n"
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
"}\n"
@@ -2061,7 +2066,8 @@ static const char s_app_js[] =
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
" if (wifi && wifi.available) {\n"
" const parts = [textValue(wifi.state, 'unknown')];\n"
" if (typeof wifi.sta_ipv4 === 'string' && wifi.sta_ipv4 !== '0.0.0.0') parts.push(wifi.sta_ipv4);\n"
" parts.push('IPv4: ' + networkIPv4(typeof wifi.sta_ipv4 === 'string' ? wifi.sta_ipv4 : ''));\n"
" parts.push(typeof wifi.ipv6_linklocal === 'boolean' && typeof wifi.ipv6_routable === 'boolean' ? networkIPv6(wifi) : 'IPv6 availability: unknown');\n"
" if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n"
" if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n"
" if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n"
+25
View File
@@ -118,6 +118,28 @@ static void print_ipv4(uint32_t address)
printf(IPSTR, IP2STR(&ip));
}
static void print_ipv6_addresses(const wifi_manager_snapshot_t *snapshot)
{
printf("IPv6 preferred address availability: link-local=%s ULA/GUA=%s\n",
snapshot->ipv6_linklocal ? "yes" : "no",
snapshot->ipv6_routable ? "yes" : "no");
for (uint8_t i = 0; i < snapshot->ipv6_count && i < WIFI_MANAGER_IPV6_MAX_ADDRESSES; ++i) {
const wifi_manager_ipv6_address_t *address = &snapshot->ipv6_addresses[i];
uint16_t prefix = ESP_IP6_ADDR_BLOCK1(address);
const char *kind = (prefix & 0xffc0U) == 0xfe80U ? "link-local" :
(prefix & 0xfe00U) == 0xfc00U ? "ULA" :
(prefix & 0xe000U) == 0x2000U ? "GUA" : "other";
printf("IPv6 preferred %s: " IPV6STR "\n", kind, IPV62STR(*address));
}
if (snapshot->ipv6_count == 0U) {
printf("IPv6 preferred addresses: none\n");
}
if (snapshot->ipv6_linklocal) {
printf("Link-local destinations require a zone (%%interface) naming the client's interface, not the ESP32's.\n");
}
printf("IPv6 addresses do not establish a default route or Internet reachability.\n");
}
static int show_status(void)
{
wifi_manager_snapshot_t snapshot;
@@ -157,7 +179,10 @@ static int show_status(void)
printf(" gateway=");
print_ipv4(snapshot.gateway);
putchar('\n');
} else {
printf("IPv4: none\n");
}
print_ipv6_addresses(&snapshot);
printf("AP: policy=%s running=%s clients=%u channel=%u SSID=",
wifi_config_ap_policy_to_string(snapshot.ap_policy),
+260 -63
View File
@@ -19,11 +19,14 @@
#include "freertos/task.h"
#include "mdns_service.h"
#include "nvs.h"
#include "esp_netif_net_stack.h"
#include "lwip/netif.h"
#define WIFI_MANAGER_QUEUE_LENGTH 16U
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
#define WIFI_MANAGER_TASK_PRIORITY 5U
#define WIFI_MANAGER_ATTEMPT_US (12LL * 1000LL * 1000LL)
#define WIFI_MANAGER_RECONCILE_US (1LL * 1000LL * 1000LL)
#define WIFI_MANAGER_STABLE_US (30LL * 1000LL * 1000LL)
#define WIFI_MANAGER_DISCONNECT_SETTLE_US (1LL * 1000LL * 1000LL)
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2U
@@ -42,6 +45,7 @@ typedef enum {
MESSAGE_STA_DISCONNECTED,
MESSAGE_STA_GOT_IP,
MESSAGE_STA_LOST_IP,
MESSAGE_STA_GOT_IP6,
MESSAGE_STA_STOPPED,
MESSAGE_AP_STOPPED,
MESSAGE_AP_CLIENT_JOINED,
@@ -78,6 +82,9 @@ typedef struct {
bool ap_enabled;
bool associated;
bool online;
int64_t reconcile_deadline;
bool mdns_reannounce_pending;
bool mdns_failure_reported;
uint8_t profile_order[WIFI_CONFIG_STA_PROFILE_COUNT];
uint8_t profile_count;
uint8_t next_profile;
@@ -112,13 +119,72 @@ static void manager_task(void *context);
static void start_profile_cycle(manager_runtime_t *runtime);
static void start_next_profile(manager_runtime_t *runtime);
static void start_mdns_announcement(void)
/* IDF 5.5 esp_netif_set_hostname caps names at 32 bytes, below our existing
* 59-byte name contract. lwIP supports the full DHCP option 12 name. This
* permanent buffer and netif pointer are changed only in TCP/IP context;
* esp-netif never owns/frees it. wlanif_init preserves netif->hostname.
* Raw hostname readers must also run in TCP/IP context; application status
* uses the copied mdns_service snapshot, never this mutable pointer. */
static char s_station_hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
typedef struct {
const char *hostname;
bool applied;
} station_hostname_request_t;
static esp_err_t set_station_hostname(void *context)
{
#if LWIP_NETIF_HOSTNAME
station_hostname_request_t *request = context;
const char *hostname = request->hostname;
struct netif *netif = esp_netif_get_netif_impl(s_sta_netif);
if (netif == NULL) {
return ESP_ERR_INVALID_STATE;
}
size_t length = strnlen(hostname, sizeof(s_station_hostname));
if (length == 0 || length >= sizeof(s_station_hostname)) {
return ESP_ERR_INVALID_ARG;
}
if (strcmp(s_station_hostname, hostname) != 0) {
memcpy(s_station_hostname, hostname, length + 1U);
}
netif_set_hostname(netif, s_station_hostname);
request->applied = true;
return ESP_OK;
#else
(void)context;
return ESP_ERR_NOT_SUPPORTED;
#endif
}
/* Called before connect (and hence the default handler's DHCP start), and on
* rename. lwIP reads the name for subsequent DHCP option 12 exchanges,
* including renew/rebind. A rename never forces lease/radio churn. */
static esp_err_t refresh_station_hostname(void)
{
mdns_service_snapshot_t snapshot;
esp_err_t error = mdns_service_get_snapshot(&snapshot);
if (error == ESP_OK) {
station_hostname_request_t request = { .hostname = snapshot.hostname };
error = esp_netif_tcpip_exec(set_station_hostname, &request);
/* IDF 5.5 ignores tcpip_send_msg_wait_sem's enqueue error. Its wrapper
* can return ESP_OK without running the callback under memory pressure. */
if (error == ESP_OK && !request.applied) {
error = ESP_FAIL;
}
}
return error;
}
static void start_mdns_announcement(manager_runtime_t *runtime)
{
esp_err_t error = mdns_service_start();
if (error != ESP_OK) {
runtime->mdns_reannounce_pending = error != ESP_OK;
if (error != ESP_OK && !runtime->mdns_failure_reported) {
/* Name discovery is optional; never make network or serial recovery depend on it. */
ESP_LOGW(TAG, "mDNS announcement unavailable: %s", esp_err_to_name(error));
}
runtime->mdns_failure_reported = error != ESP_OK;
}
static void lock_shared(void)
@@ -177,6 +243,10 @@ static void clear_station_network_snapshot(void)
s_shared.snapshot.ip = 0U;
s_shared.snapshot.netmask = 0U;
s_shared.snapshot.gateway = 0U;
s_shared.snapshot.ipv6_linklocal = false;
s_shared.snapshot.ipv6_routable = false;
s_shared.snapshot.ipv6_count = 0U;
memset(s_shared.snapshot.ipv6_addresses, 0, sizeof(s_shared.snapshot.ipv6_addresses));
s_shared.snapshot.sta_channel = 0U;
s_shared.snapshot.sta_rssi = 0;
s_shared.snapshot.sta_auth = WIFI_AUTH_OPEN;
@@ -417,6 +487,7 @@ static void mark_intentional_disconnect(manager_runtime_t *runtime)
}
runtime->associated = false;
runtime->online = false;
mdns_service_stop();
clear_station_network_snapshot();
}
@@ -518,7 +589,10 @@ static void start_next_profile(manager_runtime_t *runtime)
set_active_profile((int8_t)slot, profile);
set_state(WIFI_MANAGER_STATE_CONNECTING);
esp_err_t error = configure_station(profile);
esp_err_t error = refresh_station_hostname();
if (error == ESP_OK) {
error = configure_station(profile);
}
if (error == ESP_OK) {
error = esp_wifi_connect();
}
@@ -662,6 +736,7 @@ static void start_radio_and_policy(manager_runtime_t *runtime)
}
runtime->radio_started = true;
runtime->ap_enabled = want_ap;
set_last_error(ESP_OK);
note_ap_running(want_ap, &config);
@@ -709,24 +784,127 @@ static void update_connected_snapshot(const manager_message_t *message,
unlock_shared();
}
typedef struct {
esp_netif_ip_info_t ip4;
bool read_completed;
bool linklocal;
bool routable;
uint8_t ipv6_count;
wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];
} station_addresses_t;
/* IDF's IPv6 getters access lwIP directly. Run the bounded address scan in
* TCP/IP context, not concurrently with DAD, RA lifetime changes or teardown. */
static esp_err_t read_station_addresses(void *context)
{
station_addresses_t *addresses = context;
if (!esp_netif_is_netif_up(s_sta_netif)) {
/* A successful empty read withdraws readiness during netif teardown. */
addresses->read_completed = true;
return ESP_OK;
}
esp_err_t error = esp_netif_get_ip_info(s_sta_netif, &addresses->ip4);
#if CONFIG_LWIP_IPV6
/* IDF 5.5's CONFIG_LWIP_IPV6_AUTOCONFIG only controls its default
* per-netif enablement. lwIP SLAAC is compiled with LWIP_IPV6_AUTOCONFIG.
* Keep this policy STA-only, including when that SDK default is disabled. */
struct netif *netif = esp_netif_get_netif_impl(s_sta_netif);
#if LWIP_IPV6_AUTOCONFIG
netif_set_ip6_autoconfig_enabled(netif, 1);
#endif
/* An IDF disconnect invalidates all slots. Inspect slot state rather than
* trusting queued association events: both disconnect/connect may drop.
* Do not restart DAD for tentative or duplicate addresses. */
if (netif_ip6_addr_state(netif, 0) == IP6_ADDR_INVALID) {
netif_create_ip6_linklocal_address(netif, 1);
}
_Static_assert(LWIP_IPV6_NUM_ADDRESSES <= WIFI_MANAGER_IPV6_MAX_ADDRESSES,
"IPv6 snapshot capacity must cover every configured lwIP slot");
esp_ip6_addr_t ip6[LWIP_IPV6_NUM_ADDRESSES];
int count = esp_netif_get_all_preferred_ip6(s_sta_netif, ip6);
for (int i = 0; i < count; ++i) {
memcpy(addresses->ipv6_addresses[i].addr, ip6[i].addr, sizeof(ip6[i].addr));
++addresses->ipv6_count;
uint16_t prefix = ESP_IP6_ADDR_BLOCK1(&ip6[i]);
if ((prefix & 0xffc0U) == 0xfe80U) {
addresses->linklocal = true;
} else if ((prefix & 0xe000U) == 0x2000U || (prefix & 0xfe00U) == 0xfc00U) {
addresses->routable = true;
}
}
#endif
addresses->read_completed = true;
return error;
}
static void handle_got_ip(manager_runtime_t *runtime,
const manager_message_t *message)
{
esp_netif_ip_info_t current_ip;
(void)message; /* Events are hints, never authoritative address storage. */
wifi_ap_record_t ap_record;
memset(&current_ip, 0, sizeof(current_ip));
memset(&ap_record, 0, sizeof(ap_record));
/* Driver/netif state is authoritative when old queued events arrive late. */
if (esp_netif_get_ip_info(s_sta_netif, &current_ip) != ESP_OK ||
current_ip.ip.addr == 0U ||
current_ip.ip.addr != message->data.got_ip.ip ||
if (!runtime->radio_started || runtime->stop_pending ||
runtime->advance_after_disconnect || runtime->backoff_deadline != 0 ||
esp_wifi_sta_get_ap_info(&ap_record) != ESP_OK) {
return;
}
manager_message_t connected = { .type = MESSAGE_STA_CONNECTED };
/* SSIDs are bytes, not strings. The driver record has no length field;
* retain the active profile's length, including any embedded NUL bytes. */
lock_shared();
connected.data.connected.ssid_len = s_shared.snapshot.sta_ssid_len;
unlock_shared();
if (connected.data.connected.ssid_len > WIFI_CONFIG_SSID_MAX_LEN ||
(connected.data.connected.ssid_len < WIFI_CONFIG_SSID_MAX_LEN &&
ap_record.ssid[connected.data.connected.ssid_len] != 0)) {
return;
}
memcpy(connected.data.connected.ssid, ap_record.ssid,
connected.data.connected.ssid_len);
if (!connected_event_matches_active_profile(&connected)) {
return;
}
runtime->associated = true;
runtime->online = true;
station_addresses_t addresses = {0};
esp_err_t address_error = esp_netif_tcpip_exec(read_station_addresses, &addresses);
if (address_error == ESP_OK && !addresses.read_completed) {
address_error = ESP_FAIL;
}
if (address_error != ESP_OK) {
/* Do not retire recovery AP or retain ONLINE on an unverified read.
* Retry on the owner cadence, without logging on every failure. */
memset(&addresses, 0, sizeof(addresses));
set_last_error(address_error);
}
bool online = addresses.ip4.ip.addr != 0U || addresses.linklocal || addresses.routable;
bool was_online = runtime->online;
lock_shared();
bool new_ip4 = addresses.ip4.ip.addr != 0U &&
addresses.ip4.ip.addr != s_shared.snapshot.ip;
s_shared.snapshot.ip = addresses.ip4.ip.addr;
s_shared.snapshot.netmask = addresses.ip4.netmask.addr;
s_shared.snapshot.gateway = addresses.ip4.gw.addr;
s_shared.snapshot.ipv6_linklocal = addresses.linklocal;
s_shared.snapshot.ipv6_routable = addresses.routable;
s_shared.snapshot.ipv6_count = addresses.ipv6_count;
memcpy(s_shared.snapshot.ipv6_addresses, addresses.ipv6_addresses,
sizeof(s_shared.snapshot.ipv6_addresses));
if (new_ip4) {
++s_shared.snapshot.counters.got_ip;
}
unlock_shared();
runtime->online = online;
if (!online) {
if (was_online) {
mdns_service_stop();
runtime->stable_deadline = 0;
}
if (runtime->attempt_deadline == 0) {
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
}
set_state(WIFI_MANAGER_STATE_WAITING_IP);
return;
}
runtime->intentional_disconnects = 0U;
runtime->advance_after_disconnect = false;
runtime->attempt_deadline = 0;
@@ -736,14 +914,13 @@ static void handle_got_ip(manager_runtime_t *runtime,
wifi_app_config_t config;
copy_working_config(&config);
runtime->stable_deadline = config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK
? esp_timer_get_time() + WIFI_MANAGER_STABLE_US
: 0;
if (!was_online) {
runtime->stable_deadline = config.ap_policy == WIFI_CONFIG_AP_POLICY_FALLBACK
? esp_timer_get_time() + WIFI_MANAGER_STABLE_US
: 0;
}
lock_shared();
s_shared.snapshot.ip = message->data.got_ip.ip;
s_shared.snapshot.netmask = message->data.got_ip.netmask;
s_shared.snapshot.gateway = message->data.got_ip.gateway;
s_shared.snapshot.sta_channel = ap_record.primary;
s_shared.snapshot.sta_rssi = ap_record.rssi;
s_shared.snapshot.sta_auth = ap_record.authmode;
@@ -753,11 +930,12 @@ static void handle_got_ip(manager_runtime_t *runtime,
s_shared.snapshot.retry_seconds = 0U;
s_shared.snapshot.last_error = ESP_OK;
s_shared.snapshot.state = WIFI_MANAGER_STATE_ONLINE;
++s_shared.snapshot.counters.got_ip;
unlock_shared();
wifi_config_secure_wipe(&config, sizeof(config));
start_mdns_announcement();
if (!was_online) {
start_mdns_announcement(runtime);
}
}
static void handle_sta_disconnected(manager_runtime_t *runtime,
@@ -884,13 +1062,16 @@ static void handle_message(manager_runtime_t *runtime,
break;
case MESSAGE_COMMAND_MDNS_REANNOUNCE:
if (runtime->online) {
esp_err_t error = mdns_service_reannounce();
{
esp_err_t error = refresh_station_hostname();
if (error != ESP_OK) {
ESP_LOGW(TAG, "mDNS reannouncement unavailable: %s",
esp_err_to_name(error));
set_last_error(error);
}
}
runtime->mdns_reannounce_pending = true;
if (runtime->online) {
runtime->mdns_reannounce_pending = mdns_service_reannounce() != ESP_OK;
}
break;
case MESSAGE_STA_CONNECTED:
@@ -898,18 +1079,17 @@ static void handle_message(manager_runtime_t *runtime,
!connected_event_matches_active_profile(message)) {
break;
}
runtime->associated = true;
runtime->online = false;
if (runtime->attempt_deadline == 0) {
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
if (!runtime->associated) {
update_connected_snapshot(message, runtime->ap_enabled);
}
update_connected_snapshot(message, runtime->ap_enabled);
handle_got_ip(runtime, message);
break;
case MESSAGE_STA_DISCONNECTED:
handle_sta_disconnected(runtime, message);
break;
case MESSAGE_STA_GOT_IP6:
case MESSAGE_STA_GOT_IP:
if (manager_is_started()) {
handle_got_ip(runtime, message);
@@ -917,20 +1097,8 @@ static void handle_message(manager_runtime_t *runtime,
break;
case MESSAGE_STA_LOST_IP:
if (manager_is_started() && runtime->online) {
esp_netif_ip_info_t current_ip;
memset(&current_ip, 0, sizeof(current_ip));
if (esp_netif_get_ip_info(s_sta_netif, &current_ip) == ESP_OK &&
current_ip.ip.addr != 0U) {
/* Ignore a delayed loss event after a newer DHCP lease. */
break;
}
mdns_service_stop();
runtime->online = false;
runtime->stable_deadline = 0;
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
clear_station_network_snapshot();
set_state(WIFI_MANAGER_STATE_WAITING_IP);
if (manager_is_started()) {
handle_got_ip(runtime, message);
}
break;
@@ -984,6 +1152,7 @@ static int64_t next_runtime_deadline(const manager_runtime_t *runtime)
runtime->restart_deadline,
runtime->backoff_deadline,
runtime->stable_deadline,
runtime->reconcile_deadline,
};
for (size_t i = 0U; i < sizeof(candidates) / sizeof(candidates[0]); ++i) {
@@ -998,7 +1167,8 @@ static TickType_t runtime_wait_ticks(const manager_runtime_t *runtime)
{
int64_t deadline = next_runtime_deadline(runtime);
if (deadline == 0) {
return portMAX_DELAY;
/* Bootstrap the permanent cadence even if Wi-Fi is never started. */
return 0;
}
int64_t remaining_us = deadline - esp_timer_get_time();
@@ -1015,24 +1185,45 @@ static void handle_expired_deadlines(manager_runtime_t *runtime)
{
int64_t now = esp_timer_get_time();
bool periodic = now >= runtime->reconcile_deadline;
if (periodic) {
runtime->reconcile_deadline = now + WIFI_MANAGER_RECONCILE_US;
/* Availability notifications have no queue/wakeup and may arrive while
* stopped. Reconcile on the sole owner, never gated by STA readiness.
* The service retains error status and retries record operations; no
* per-pass log spam or responder reinitialization on failure. */
if (runtime->online && runtime->mdns_reannounce_pending) {
runtime->mdns_reannounce_pending = mdns_service_reannounce() != ESP_OK;
} else {
(void)mdns_service_reconcile();
}
/* Retry failed/missed DHCP-name updates, including offline staging.
* Configuration is copied before entering TCP/IP context; neither
* project mutex nor a caller's stack pointer escapes that call. */
esp_err_t error = refresh_station_hostname();
if (error != ESP_OK) {
set_last_error(error);
}
}
if (runtime->radio_started &&
(periodic ||
(runtime->attempt_deadline != 0 && now >= runtime->attempt_deadline) ||
(runtime->stable_deadline != 0 && now >= runtime->stable_deadline))) {
wifi_ap_record_t current_ap;
if (runtime->associated && esp_wifi_sta_get_ap_info(&current_ap) != ESP_OK) {
manager_message_t lost = { .type = MESSAGE_STA_DISCONNECTED };
handle_sta_disconnected(runtime, &lost);
} else if (manager_is_started()) {
handle_got_ip(runtime, NULL);
}
}
if (runtime->attempt_deadline != 0 && now >= runtime->attempt_deadline) {
esp_netif_ip_info_t current_ip;
memset(&current_ip, 0, sizeof(current_ip));
if (esp_netif_get_ip_info(s_sta_netif, &current_ip) == ESP_OK &&
current_ip.ip.addr != 0U) {
/* Recover if the bounded manager queue dropped GOT_IP. */
manager_message_t synthetic = {
.type = MESSAGE_STA_GOT_IP,
.data.got_ip = {
.ip = current_ip.ip.addr,
.netmask = current_ip.netmask.addr,
.gateway = current_ip.gw.addr,
},
};
handle_got_ip(runtime, &synthetic);
if (runtime->online) {
return;
}
/* Both families must be reconciled before deciding to abandon a profile. */
handle_got_ip(runtime, NULL);
if (runtime->online) {
return;
}
runtime->attempt_deadline = 0;
@@ -1195,13 +1386,19 @@ static void ip_event_callback(void *argument, esp_event_base_t event_base,
if (event_id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *event = event_data;
if (event == NULL) {
if (event == NULL || event->esp_netif != s_sta_netif) {
return;
}
message.type = MESSAGE_STA_GOT_IP;
message.data.got_ip.ip = event->ip_info.ip.addr;
message.data.got_ip.netmask = event->ip_info.netmask.addr;
message.data.got_ip.gateway = event->ip_info.gw.addr;
} else if (event_id == IP_EVENT_GOT_IP6) {
const ip_event_got_ip6_t *event = event_data;
if (event == NULL || event->esp_netif != s_sta_netif) {
return;
}
message.type = MESSAGE_STA_GOT_IP6;
} else if (event_id == IP_EVENT_STA_LOST_IP) {
message.type = MESSAGE_STA_LOST_IP;
} else {
+19 -1
View File
@@ -42,6 +42,14 @@ typedef struct {
uint64_t queue_drops;
} wifi_manager_counters_t;
#define WIFI_MANAGER_IPV6_MAX_ADDRESSES 3U
/* Network-order words, compatible with IDF's IPV62STR/ESP_IP6_ADDR_BLOCK macros.
* No device-local zone: a remote client must select its own interface. */
typedef struct {
uint32_t addr[4];
} wifi_manager_ipv6_address_t;
typedef struct {
bool initialized;
bool started;
@@ -54,6 +62,13 @@ typedef struct {
uint32_t ip;
uint32_t netmask;
uint32_t gateway;
/* Preferred IPv6 addresses only (DAD complete, not deprecated/expired).
* ONLINE means IPv4 or either IPv6 flag, not Internet reachability.
* routable includes ULA/GUA; it does not assert a default route exists. */
bool ipv6_linklocal;
bool ipv6_routable;
uint8_t ipv6_count;
wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];
uint8_t sta_channel;
int8_t sta_rssi;
wifi_auth_mode_t sta_auth;
@@ -141,7 +156,10 @@ esp_err_t wifi_manager_stop(void);
esp_err_t wifi_manager_reconnect(void);
/* Advance to the next enabled station profile in priority order, wrapping safely. */
esp_err_t wifi_manager_next_profile(void);
/* Reannounce the configured hostname when the manager currently has a STA IP. */
/* Refresh DHCPv4 option 12 from the configured sak-<suffix> hostname, and
* reannounce mDNS when ONLINE (either IP family). No lease restart: the new
* name is used in subsequent DHCP exchanges, including renew/rebind; existing
* router/DNS records may remain until server policy expires or replaces them. */
esp_err_t wifi_manager_mdns_reannounce(void);
/* Snapshot data never contains station or AP passwords. */
+80
View File
@@ -0,0 +1,80 @@
static admin_ssh_console_token_t token = {
.slot_index=0, .session_id=7, .slot_generation=1,
.transport=ADMIN_CONSOLE_TRANSPORT_WEB,
};
static user_principal_t admin = {.role=USER_ROLE_ADMIN};
static bool live=true;
static unsigned scenario;
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
{ assert(!lock_depth && t->transport==ADMIN_CONSOLE_TRANSPORT_WEB && p->role==USER_ROLE_ADMIN); return live; }
static bool drained(const admin_ssh_console_token_t *t) { (void)t; return true; }
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg)
{ (void)t; (void)action; (void)arg; assert(false); return ESP_FAIL; }
static const admin_console_owner_t owner = {
.is_current=is_current, .drained=drained, .perform=perform,
};
static void reply(void)
{
const char *text=scenario==1 ? "wifi-secret\x03" : scenario==5 ? "short\r" : "wifi-secret\t\r";
size_t used=0;
assert(s_sessions[0].prompt_hidden);
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)text,strlen(text),&used));
assert(used==strlen(text));
if (scenario==2) live=false;
if (scenario==3) principal_current=false;
if (scenario==4) admin_ssh_console_close(&token);
}
static void run_secret(void)
{
assert(admin_ssh_console_dispatch_is_web());
uint8_t greeting[4096]; size_t greeting_length;
assert(admin_ssh_console_read_output(&token,greeting,sizeof(greeting),&greeting_length)==ESP_OK);
unsigned before=applies, wiped=wipes;
uint8_t history[sizeof(s_sessions[0].history)];
memcpy(history,s_sessions[0].history,sizeof(history));
int result=(scenario%2 || scenario==6) ? set_ap_secret() : set_profile_secret("0");
bool success=scenario==0 || scenario==6;
assert(result==(success ? 0 : 1));
assert(applies==before+success);
assert(wipes>=wiped+2); /* Hidden local input and PSK-bearing candidate. */
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i)
assert(!s_sessions[0].prompt_input[i]);
if (scenario<2 || scenario>=5)
assert(!memcmp(history,s_sessions[0].history,sizeof(history)));
uint8_t output[4097]={0}; size_t n=0;
if (s_sessions[0].active) {
assert(admin_ssh_console_read_output(&token,output,sizeof(output)-1,&n)==ESP_OK);
assert(!strstr((char *)output,"wifi-secret"));
assert(!strstr((char *)output,"short"));
assert(!strstr((char *)output,"help")); /* Tab never invokes completion in a prompt. */
}
}
int main(void)
{
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
user_principal_t ordinary={.role=USER_ROLE_USER};
assert(admin_ssh_console_open_owned(&token,&ordinary,&owner)!=ESP_OK);
working.profiles[0].ssid_len=1;
working.profiles[0].ssid[0]='x';
for (scenario=0;scenario<7;++scenario) {
live=true; principal_current=true; ++token.slot_generation;
assert(admin_ssh_console_open_owned(&token,&admin,&owner)==ESP_OK);
const char *line=(scenario%2 || scenario==6) ? "wifi ap secret\r" : "wifi profile secret 0\r";
size_t used=0;
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)line,strlen(line),&used));
unsigned before=runs;
prompt_hook=reply; command_hook=run_secret;
if (!setjmp(loop_done)) worker_task(NULL);
assert(runs==before+1);
admin_ssh_console_close(&token);
admin_session_t empty={0};
assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
}
assert(applies==2 && working.profiles[0].psk_len==11);
assert(working.ap_psk_len==11 && !memcmp(working.ap_psk,"wifi-secret",11));
assert(!memcmp(working.profiles[0].psk,"wifi-secret",11));
puts("PASS: browser Wi-Fi policy-to-dispatch, actual profile/AP secret handlers and shared IO; hidden input/history/Tab, cancel, owner/account revocation, close, short input and wiping; ordinary role denied");
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Actual Wi-Fi secret handlers through shared browser prompt IO; host fakes only."""
from pathlib import Path
import os
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
def stripped(path):
return "\n".join(line for line in (ROOT / path).read_text().splitlines()
if not line.startswith(("#include", "#pragma once")))
def function(source, signature):
start = source.index(signature)
return source[start:source.index("\n}", start) + 2] + "\n"
wifi = (ROOT / "src/wifi_console.c").read_text()
unit = (ROOT / "tests/admin_console_boundary/fakes.h").read_text() + "\n"
unit += stripped("src/admin_ssh_console.h") + "\n" + stripped("src/admin_ssh_console.c") + "\n"
unit += stripped("src/wifi_config.h") + r'''
#include <stdlib.h>
#define ESP_ERR_INVALID_SIZE 100
#define UART_NUM_0 0
#define WIFI_CONSOLE_SECRET_CAPACITY WIFI_CONFIG_PSK_MAX_LEN
static esp_err_t uart_flush_input(int port) { (void)port; assert(false); return ESP_FAIL; }
static int uart_read_bytes(int port, void *out, size_t n, unsigned wait)
{ (void)port; (void)out; (void)n; (void)wait; assert(false); return -1; }
static wifi_app_config_t working;
static unsigned applies, wipes;
void wifi_config_secure_wipe(void *p, size_t n) {
secure_wipe(p,n); ++wipes;
for (size_t i=0;i<n;++i) assert(!((uint8_t *)p)[i]);
}
static esp_err_t wifi_manager_get_working_config(wifi_app_config_t *out)
{ *out=working; return ESP_OK; }
static esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *in)
{ working=*in; ++applies; return ESP_OK; }
'''
unit += stripped("src/console_input.c") + "\n"
for signature in ("static bool parse_u32(", "static bool parse_slot(",
"static esp_err_t apply_candidate(", "static esp_err_t read_secret_no_echo(",
"static int set_profile_secret(", "static int set_ap_secret("):
unit += function(wifi, signature)
unit += (ROOT / "tests/admin_console_boundary/wifi.c").read_text()
with tempfile.TemporaryDirectory(prefix="browser-wifi-prompts-") as directory:
path = Path(directory)
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable", str(path / "test.c"),
str(IDF / "components/console/split_argv.c"), "-o", str(path / "test")],
check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
+15 -4
View File
@@ -66,7 +66,19 @@ int main(void) {
"", " ", "help", "memory", "exit", "user", "user status", "user list",
"user show admin", "\"user\" \"show\" \"bootstrap\"",
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
"wifi status", "mdns status", "\"web\" \"status\"",
"mdns status", "\"web\" \"status\"",
"wifi", "wifi status", "wifi profiles", "wifi counters", "wifi clear-counters",
"wifi start", "wifi stop", "wifi reconnect", "wifi next-profile",
"wifi save", "wifi load", "wifi defaults", "wifi reset",
"wifi profile set 0 10 mixed \"Office Wi-Fi\"", "wifi profile secret 0",
"wifi profile enable 0", "wifi profile disable 0", "wifi profile delete 0",
"wifi ap policy fallback", "wifi ap ssid \"Recovery AP\"", "wifi ap channel 6",
"wifi ap secret", "wifi ap show-secret",
"wifi ping example.org", "wifi nslookup example.org", "wifi traceroute example.org",
" \"wifi\" ", " \"wifi\" \"stop\" ",
"\"wifi\" \"profile\" \"secret\" \"0\"", "\"wifi\" \"ap\" \"show-secret\"",
/* Admission is not syntax validation: canonical Wi-Fi rejects these. */
"wifi scan", "wifi status extra", "wifi profile secret 0 inline-secret", "wifix stop",
"user add other user", "user add other admin", "user password other",
"user delete other --force", "user role other user --force",
"user role other admin --force", "\"user\" \"password\" \"other\"",
@@ -84,8 +96,7 @@ int main(void) {
"web certificate rotate --forcex", "web certificate --force rotate",
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
"web reset --force", "web status extra",
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
"reboot --force", "user bootstrap", "user recover --force",
"user add other admin --generate", "user delete other",
@@ -100,7 +111,7 @@ int main(void) {
"user key delete admin 0 --force", "user list extra", "user show admin extra",
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
" \"user\" \"password\" \"admin\" \"--generate\"",
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
"\"web\" \"credentials\" \"show\"", "mdns status extra",
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
};
+145
View File
@@ -0,0 +1,145 @@
# mDNS 1.12.0 multicast membership regression
This is a narrow dependency correctness fix for the lwIP backend, not a general
patch framework or a revival of the abandoned Phase 9 patches. Dependency
versions, the component manifest/lock, managed sources, socket backend, and
application mDNS lifecycle policy are unchanged.
## Defects and fix
In the inspected `mdns_networking_lwip.c`, upstream `pcb_if_deinit()` (original
lines 267277) only leaves a group when the interface's **last** protocol bit is
cleared. Removing one family while the other remains active therefore leaks a
membership reference. Restoration joins again; repeated partial transitions
accumulate references in lwIP's bounded group-use counter. Deinitializing an
already-disabled family can also issue an unmatched leave.
The generated copy checks the requested family's active bit, returns immediately
if absent, and leaves that family's group before clearing its bit. It clears
`ready` only after the last family on that interface is removed, and frees the
shared PCB only if no other interface remains ready.
In `pcb_if_init()` (original lines 282301), a successful group join followed by
`pcb_init()` failure was not unwound. The copy attempts a leave before returning
the **original** PCB error. No readiness or protocol bit is published on failure.
These operations still run through upstream's existing lwIP-thread wrappers.
No new locks, allocations, task ownership, retry state, or public API are added.
Upstream `join_group()` is unchanged, including its refusal to act when the netif
is absent/down. Leaves remain best effort: the patch guarantees balanced leave
**attempts**, not successful lwIP cleanup after an interface disappears. It does
not retry a failed leave or retain a PCB solely because leave failed; physical
netif teardown remains responsible for its own membership cleanup.
## Build integration
1. Root `CMakeLists.txt` includes `cmake/mdns_membership.cmake` **after** IDF's
`project()`, when component targets and resolved versions are available.
2. The include obtains `espressif__mdns`'s `COMPONENT_DIR`, `COMPONENT_LIB`, and
`COMPONENT_VERSION` via IDF component properties. Socket-backend builds skip
the overlay entirely; the fix is only relevant to the lwIP source.
3. The resolved component version and manifest version must both be exactly
`1.12.0`. Using IDF's configured Python interpreter, the helper verifies the
complete source SHA-256, then performs two exact, unique replacements.
4. It writes only `${CMAKE_BINARY_DIR}/mdns_membership/mdns_networking_lwip.c`.
Unchanged output is not rewritten, avoiding gratuitous rebuilds. Everything
outside the two replacement regions, including provenance/license headers,
remains byte-for-byte intact. The actual upstream file identifies itself as
Apache-2.0, copyright 20222025 Espressif; it is not relabeled as GPL. Existing
project GPL/license material is untouched.
5. The include replaces exactly one matching entry in the existing component
target's `SOURCES` property, accepting absolute or component-relative paths.
All other source entries and all component compile definitions, include paths,
dependencies, and target linkage are retained. The original file is not also
compiled. An unexpected source list fails configuration.
6. The helper, original source, manifest, and generated copy are registered as
configure dependencies. Reconfiguration regenerates/verifies the copy; a
clean build simply recreates it. The CMake include itself is automatically a
CMake input. Version/hash/replacement errors fail configure rather than
silently compiling an unpatched dependency.
The integration uses the component's existing target; no whole-component copy,
managed in-place edits, dependency overrides, manifest changes, or extra source
library are involved. The inspected upstream CMake file offers no per-source
substitution option; replacing the target source after `project()` is the local
integration point.
### Exact reviewed baseline
- Component: `espressif/mdns`, version `1.12.0`.
- Manifest repository: `espressif/esp-protocols`, `components/mdns`.
- Manifest commit: `db06b19b7be729c163d346f62ec0eba01047b7f1` (provenance;
guards are the resolved/manifest version and complete source hash).
- Source: `managed_components/espressif__mdns/mdns_networking_lwip.c`.
- SHA-256: `adc139fa504a925ab644f21f8dce3659927f534e390a176b72b0ae3206c6a3ea`.
## Run the host tests
From the repository root, on a POSIX host with Python 3, CMake >= 3.16, and a C11
compiler:
```sh
./tests/mdns_membership/run.py
# Optional compiler selection (CC is an executable, not a shell command):
CC=clang ./tests/mdns_membership/run.py
```
The managed component must already be installed. The runner uses temporary
directories, does not fetch dependencies, does not build firmware, and disables
core dumps for deliberate negative tests. It generates the overlay with the
**same helper used by configure**, extracts the actual patched state declarations
and six functions (`pcb_init`, `pcb_deinit`, `mdns_priv_if_ready`,
`is_any_pcb_in_use`, `pcb_if_init`, `pcb_if_deinit`), and compiles them with
`-std=c11 -Wall -Wextra -Werror -pedantic`. Only group operations and low-level
UDP APIs are mocked; the lifecycle logic under test is not reimplemented.
Coverage:
- 512 IPv4 loss/restoration cycles while IPv6 stays active, and 512 inverse
cycles; exact group references and readiness checked after each transition.
- Repeated disabled-family deinit, final-family teardown, and duplicate init.
- Join failure without an unmatched leave or PCB allocation.
- 512 allocation failures and 512 bind failures per family, with successful
joins unwound, no state publication, original error preserved, and recovery.
- Leave failure on teardown and unwind: one attempt, no repeated disabled leave,
original allocation error retained.
- 512 cycles per family with a second interface holding the shared PCB; exactly
one allocation, no premature free, and one final removal.
- Separate negative controls restoring each original buggy function must fail
the very same executable harness.
- Mock-IDF CMake configure fixtures for absolute/relative source replacement,
unchanged unrelated source, repeat configure without rewriting, socket bypass,
missing/duplicate networking source rejection, resolved-version rejection,
and full-source hash rejection. Manifest-version rejection is tested through
the helper CLI. Rejected fresh helper runs must not produce output.
- Original managed networking bytes are checked unchanged after the run.
These are host unit/configure tests, **not** a real ESP-IDF integration build or
hardware/network test. The real `join_group()`, lwIP IGMP/MLD counters, scheduling,
and network teardown are not exercised by mocks. Run the normal `pio run`
separately (never concurrently with another full build) to validate actual IDF
integration. In its generated `compile_commands.json`, the mDNS networking entry
must point to the build-local `mdns_membership/mdns_networking_lwip.c`, with no
original managed networking entry; other mDNS sources must remain managed paths.
On device, exercise repeated IPv4-only and IPv6-only loss/restoration while the
other family remains active, final network teardown/recovery, and continued
mDNS discovery. No hardware result is implied by the host PASS output.
## Maintenance / removal
Do **not** update the expected hash merely to make a new dependency build. On
any mismatch, review the entire changed networking source and component CMake,
especially protocol state, join/leave behavior, netif-down semantics, and shared
PCB lifetime. Establish whether upstream has fixed both defects first. A
version bump is a separate explicitly reviewed change; this overlay does not
select or upgrade the dependency.
If upstream fixes both paths, remove the root include and these narrowly scoped
helper/test files (or replace the tests with suitable upstream coverage), then
clean/reconfigure and check that only the upstream source compiles. If a local
fix is still necessary, re-audit the exact new source/version, update the guards
and exact replacements together, update this provenance record, rerun the host
suite with its negative controls, and run a serial full firmware build. Never
fall back to unguarded search/replace or silently skip a failed patch. A stale
build-local copy is not authority: it must always be reproducible from the
managed source plus the reviewed helper.
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Host-only regression and configure integration tests; no firmware build."""
import hashlib
import os
import resource
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
COMPONENT = ROOT / "managed_components/espressif__mdns"
HELPER = ROOT / "cmake/mdns_membership.py"
def run(command, *, succeeds=True):
result = subprocess.run([str(item) for item in command], capture_output=True, text=True)
if (result.returncode == 0) != succeeds:
raise AssertionError(f"Unexpected result: {command}\n{result.stdout}\n{result.stderr}")
return result.stdout + result.stderr
def function(source, signature):
start = source.index(signature)
brace = source.index("{", start)
depth = 1
end = brace + 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end] + "\n"
def extract(source):
# Keep the original provenance header and actual state declarations too.
header = source[:source.index("#include")]
state = source[source.index("enum interface_protocol"):source.index("static const char *TAG")]
signatures = ["static esp_err_t pcb_init(void)", "static void pcb_deinit(void)",
"bool mdns_priv_if_ready(", "static bool is_any_pcb_in_use(void)",
"static void pcb_if_deinit(", "static esp_err_t pcb_if_init("]
return header + state + "\n".join(function(source, name) for name in signatures)
def cmake_fixture(work, component, mode, succeeds=True):
fixture = work / f"cmake-{mode}"
fixture.mkdir()
(fixture / "dummy.c").write_text("int dummy;\n")
source = component / "mdns_networking_lwip.c"
sources = f'"{source}"'
if mode == "missing":
sources = ""
if mode == "duplicate":
sources += " " + sources
# Test relative as well as absolute target source properties.
if mode == "relative":
shutil.copyfile(source, fixture / source.name)
shutil.copyfile(component / "idf_component.yml", fixture / "idf_component.yml")
component = fixture
sources = source.name
version = "1.13.0" if mode == "version" else "1.12.0"
(fixture / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.16)
project(mdns_overlay_fixture C)
add_library(mdns STATIC dummy.c {sources})
function(idf_component_get_property output component property)
if(property STREQUAL "COMPONENT_DIR")
set(value "{component}")
elseif(property STREQUAL "COMPONENT_LIB")
set(value mdns)
elseif(property STREQUAL "COMPONENT_VERSION")
set(value "{version}")
else()
message(FATAL_ERROR "Unexpected component property")
endif()
set(${{output}} "${{value}}" PARENT_SCOPE)
endfunction()
function(idf_build_get_property output property)
if(NOT property STREQUAL "PYTHON")
message(FATAL_ERROR "Unexpected build property")
endif()
set(${{output}} "{sys.executable}" PARENT_SCOPE)
endfunction()
set(CONFIG_MDNS_NETWORKING_SOCKET {"ON" if mode == "socket" else "OFF"})
include("{ROOT / 'cmake/mdns_membership.cmake'}")
get_target_property(sources mdns SOURCES)
file(WRITE "${{CMAKE_BINARY_DIR}}/selected.txt" "${{sources}}")
''')
build = fixture / "build"
output = run(["cmake", "-S", fixture, "-B", build], succeeds=succeeds)
if not succeeds:
assert "mDNS" in output, output
return
selected = (build / "selected.txt").read_text().split(";")
if mode == "socket":
assert str(source) in selected
assert not (build / "mdns_membership").exists()
else:
overlay = build / "mdns_membership/mdns_networking_lwip.c"
assert selected == ["dummy.c", str(overlay)], selected
before = overlay.stat().st_mtime_ns
run(["cmake", "-S", fixture, "-B", build])
assert overlay.stat().st_mtime_ns == before
def main():
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
original = (COMPONENT / "mdns_networking_lwip.c").read_bytes()
with tempfile.TemporaryDirectory(prefix="mdns-membership-") as directory:
work = Path(directory)
overlay = work / "overlay/mdns_networking_lwip.c"
run([sys.executable, HELPER, COMPONENT, overlay])
patched = overlay.read_text()
assert patched[:patched.index("#include")] == original.decode()[:original.decode().index("#include")]
timestamp = overlay.stat().st_mtime_ns
run([sys.executable, HELPER, COMPONENT, overlay])
assert overlay.stat().st_mtime_ns == timestamp
(work / "actual_functions.inc").write_text(extract(patched))
executable = work / "test"
command = [os.environ.get("CC", "cc"), "-std=c11", "-Wall", "-Wextra", "-Werror",
"-pedantic", "-I", work, HERE / "test.c", "-o", executable]
run(command)
print(run([executable]), end="")
# Prove the harness detects each original bug independently.
for name in ("pcb_if_deinit", "pcb_if_init"):
signature = ("static void " if name.endswith("deinit") else "static esp_err_t ") + name + "("
mutated = patched.replace(function(patched, signature), function(original.decode(), signature))
(work / "actual_functions.inc").write_text(extract(mutated))
run(command)
run([executable], succeeds=False)
print("PASS: both original defects independently fail the same harness")
copied = work / "component"
copied.mkdir()
shutil.copyfile(COMPONENT / "idf_component.yml", copied / "idf_component.yml")
(copied / "mdns_networking_lwip.c").write_bytes(original + b"\n")
output = run([sys.executable, HELPER, copied, work / "rejected.c"], succeeds=False)
assert "SHA-256 mismatch" in output and not (work / "rejected.c").exists()
(copied / "mdns_networking_lwip.c").write_bytes(original)
manifest = (copied / "idf_component.yml").read_text()
(copied / "idf_component.yml").write_text(manifest.replace("version: 1.12.0", "version: 1.13.0"))
output = run([sys.executable, HELPER, copied, work / "rejected.c"], succeeds=False)
assert "exactly version 1.12.0" in output and not (work / "rejected.c").exists()
(copied / "idf_component.yml").write_text(manifest)
for mode in ("absolute", "relative", "socket", "missing", "duplicate", "version"):
cmake_fixture(work, copied, mode, succeeds=mode not in ("missing", "duplicate", "version"))
# Also exercise hash failure through configure, not only the helper CLI.
(copied / "mdns_networking_lwip.c").write_bytes(original + b"\n")
cmake_fixture(work, copied, "hash", succeeds=False)
print("PASS: source/version guards, CMake replacement, relative paths, socket bypass, repeat configure")
assert hashlib.sha256((COMPONENT / "mdns_networking_lwip.c").read_bytes()).digest() == hashlib.sha256(original).digest()
print("PASS: managed networking source unchanged")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
typedef int mdns_if_t;
typedef int mdns_ip_protocol_t;
enum { ESP_OK, ESP_ERR_NO_MEM, ESP_ERR_INVALID_STATE };
enum { MDNS_IP_PROTOCOL_V4, MDNS_IP_PROTOCOL_V6, MDNS_IP_PROTOCOL_MAX };
#define MDNS_MAX_INTERFACES 2
#define MDNS_SERVICE_PORT 5353
struct udp_pcb { int mcast_ttl, remote_port, remote_ip; };
static int any_address;
#define IP_ANY_TYPE (&any_address)
#define ip_addr_copy(to, from) ((to) = (from))
static void receive(void) {}
static struct udp_pcb storage;
static bool allocated, fail_alloc, fail_bind, fail_join, fail_leave;
static int allocations, removals, joins[2][2], leaves[2][2], references[2][2];
static struct udp_pcb *udp_new(void)
{
if (fail_alloc) return NULL;
assert(!allocated);
allocated = true;
allocations++;
return &storage;
}
static int udp_bind(struct udp_pcb *pcb, const int *address, int port)
{
assert(pcb == &storage && address == IP_ANY_TYPE && port == 5353);
return fail_bind;
}
static void udp_remove(struct udp_pcb *pcb)
{
assert(pcb == &storage && allocated);
allocated = false;
removals++;
}
static void udp_recv(struct udp_pcb *pcb, void (*callback)(void), void *arg)
{ assert(pcb == &storage); (void)callback; (void)arg; }
static void udp_disconnect(struct udp_pcb *pcb) { assert(pcb == &storage); }
static esp_err_t join_group(mdns_if_t interface, mdns_ip_protocol_t family, bool join)
{
if (join) {
joins[interface][family]++;
if (fail_join) return ESP_ERR_INVALID_STATE;
references[interface][family]++;
/* A real lwIP group has a bounded use count: never accumulate it. */
assert(references[interface][family] == 1);
} else {
leaves[interface][family]++;
if (fail_leave) return ESP_ERR_INVALID_STATE;
assert(references[interface][family] == 1);
references[interface][family]--;
}
return ESP_OK;
}
#include "actual_functions.inc"
static void reset(void)
{
assert(!allocated && s_pcb_main == NULL);
memset(s_interfaces, 0, sizeof(s_interfaces));
memset(joins, 0, sizeof(joins));
memset(leaves, 0, sizeof(leaves));
memset(references, 0, sizeof(references));
allocations = removals = 0;
fail_alloc = fail_bind = fail_join = fail_leave = false;
}
static void check(int interface, int family, bool active)
{
assert(!!mdns_priv_if_ready(interface, family) == active);
assert(references[interface][family] == (int)active);
assert(joins[interface][family] - leaves[interface][family] == (int)active);
}
static void transitions(int family)
{
reset();
int other = 1 - family;
assert(pcb_if_init(0, family) == ESP_OK);
assert(pcb_if_init(0, other) == ESP_OK);
for (int i = 0; i < 512; i++) {
pcb_if_deinit(0, family);
check(0, family, false);
check(0, other, true);
assert(s_interfaces[0].ready && allocated && removals == 0);
pcb_if_deinit(0, family); /* Already-disabled family must not leave again. */
check(0, family, false);
assert(pcb_if_init(0, family) == ESP_OK);
check(0, family, true);
int before = joins[0][family];
assert(pcb_if_init(0, family) == ESP_ERR_INVALID_STATE);
assert(joins[0][family] == before);
}
pcb_if_deinit(0, other);
assert(s_interfaces[0].ready && allocated);
pcb_if_deinit(0, family);
assert(!s_interfaces[0].ready && !allocated);
assert(allocations == 1 && removals == 1);
pcb_if_deinit(0, family);
pcb_if_deinit(0, other);
check(0, family, false);
check(0, other, false);
}
static void failures(int family)
{
reset();
pcb_if_deinit(0, family);
assert(leaves[0][family] == 0 && removals == 0);
fail_join = true;
assert(pcb_if_init(0, family) == ESP_ERR_INVALID_STATE);
assert(!s_interfaces[0].ready && s_interfaces[0].proto == 0);
assert(allocations == 0 && leaves[0][family] == 0);
fail_join = false;
for (int mode = 0; mode < 2; mode++) {
fail_alloc = mode == 0;
fail_bind = mode == 1;
for (int i = 0; i < 512; i++) {
int before = leaves[0][family];
assert(pcb_if_init(0, family) == (fail_alloc ? ESP_ERR_NO_MEM : ESP_ERR_INVALID_STATE));
assert(leaves[0][family] == before + 1);
assert(references[0][family] == 0 && !allocated && !s_pcb_main);
assert(!s_interfaces[0].ready && s_interfaces[0].proto == 0);
pcb_if_deinit(0, family);
assert(leaves[0][family] == before + 1);
}
}
fail_alloc = fail_bind = false;
assert(pcb_if_init(0, family) == ESP_OK);
pcb_if_deinit(0, family);
assert(!allocated && references[0][family] == 0);
/* Preserve upstream best-effort leave semantics when netif has gone down. */
reset();
assert(pcb_if_init(0, family) == ESP_OK);
fail_leave = true;
pcb_if_deinit(0, family);
pcb_if_deinit(0, family);
assert(leaves[0][family] == 1 && !s_interfaces[0].ready && !allocated);
references[0][family] = 0; /* Model netif teardown clearing its memberships. */
reset();
fail_alloc = fail_leave = true;
assert(pcb_if_init(0, family) == ESP_ERR_NO_MEM);
assert(leaves[0][family] == 1 && !s_interfaces[0].ready && !allocated);
references[0][family] = 0;
}
static void shared_pcb(int family)
{
reset();
assert(pcb_if_init(1, 1 - family) == ESP_OK);
for (int i = 0; i < 512; i++) {
assert(pcb_if_init(0, family) == ESP_OK);
pcb_if_deinit(0, family);
pcb_if_deinit(0, family);
check(0, family, false);
check(1, 1 - family, true);
assert(!s_interfaces[0].ready && allocated && removals == 0);
}
assert(allocations == 1);
pcb_if_deinit(1, 1 - family);
assert(!allocated && removals == 1);
}
int main(void)
{
for (int family = 0; family < 2; family++) {
transitions(family);
failures(family);
shared_pcb(family);
}
puts("PASS: actual mDNS functions, 512 cycles per family/scenario, failure unwind and shared PCB");
return 0;
}
+27
View File
@@ -0,0 +1,27 @@
# Phase 12 mDNS owner host regression
Run `python3 tests/mdns_phase12/run.py` from the repository root. Uses the host C11 compiler and temporary fake SDK headers; compiles the actual production module. No PlatformIO, device operations, or generated firmware assets.
Covers notifications before initialization and while the project mutex is held; default unavailable SSH; fixed HTTPS443/SSH22 and empty TXT; repeated starts without duplicate records or hostname churn; record add/remove failures and retry; offline withdrawal; offline rename and retry; latest-state coalescing; init/hostname/instance failure latches and cleanup. Family tests cover lost IPv4 with surviving IPv6, IPv6-only/link-local readiness, no valid IPv6, netif down, stop with stale addresses, missed GOT_IP6, explicitly failed and silently dropped action submission, delayed upstream disable, missing STA netif, and no enable churn during ordinary healthy polls. TCP/IP fakes assert address reads occur in TCP/IP context; netif actions assert neither project mutex nor publication critical section is held. Actual multicast, component tasks, DNS conflicts, and sockets are not simulated.
## Integration contract
- Listener owners call `void mdns_service_set_https_available(bool)` / `void mdns_service_set_ssh_available(bool)` after successful listener creation and on unavailability. Calls use tiny portMUX critical sections, safe before initialization, with no blocking semaphore, allocation, callbacks, or component calls. Xtensa does not promise lock-free C11 atomic bool, so no atomics are required.
- The sole Wi-Fi owner calls `esp_err_t mdns_service_reconcile(void)` periodically, even offline. No new arguments are needed: it looks up the permanent `WIFI_STA_DEF` netif and samples addresses via `esp_netif_tcpip_exec`. No project mutex or publication mux is held across SDK/component calls.
- Reconciliation never initializes the responder. Before module init it returns invalid-state; after module init but before responder startup it is a successful no-op. Once initialized it applies listener states and family readiness, retrying failed operations on subsequent passes. Notifications converge on a later pass, not synchronously; an in-flight pass can briefly reflect an older state.
- Existing start/reannounce also reconcile. Start remains gated by usable STA in either family. Stop now clears announcement expectation **and requests family disable**, even if netif still holds nonzero addresses; it does not destroy/reinitialize the responder. Errors are retained in the snapshot. Do not call lifecycle operations concurrently or while holding another service lock. The manager owns start/stop, not event callbacks.
- Required effective SDK options remain `CONFIG_MDNS_PREDEF_NETIF_STA=y`, AP/ETH predefined interfaces disabled, `CONFIG_LWIP_IPV4=y`, `CONFIG_LWIP_IPV6=y`, `CONFIG_MDNS_MAX_SERVICES >= 2`. No configuration change was made for the repair.
## Exact dependency contracts and limitations
Inspected installed Espressif mDNS **1.12.0** and ESP-IDF **5.5.0**:
- `mdns_netif.c` initializes IPv6 from `esp_netif_get_ip6_linklocal`, independently of IPv4. Its predefined handlers process disconnect and address acquisition, but not STA_LOST_IP. `mdns_networking_lwip.c` tracks family readiness separately from current addresses. `mdns_send.c` checks readiness before emitting A/AAAA, but can emit zero A if readiness remains true after IPv4 loss. Explicit family disable repairs that stale readiness, including when IPv6 survives.
- `mdns_send.c` uses `esp_netif_get_all_ip6`, whose IDF implementation includes **valid addresses, including deprecated addresses**, excludes invalid/tentative/zero addresses, and reads lwIP state directly. Our sampling therefore runs in TCP/IP context and intentionally follows valid-address semantics, not preferred-only semantics. No address cache is passed to mDNS; it still reads addresses when serializing records.
- `mdns_netif_action` is asynchronous, has no public readiness getter or acknowledgement, and `post_custom_action` returns `ESP_OK` even if action enqueue fails. A cached successful API return is not proof of applied state. Allocation errors retry on the next pass; silent losses require reassertion. Missing-family disables are idempotent and reasserted every poll. Present-family enables restart probes, so they are submitted on family-mask transitions and at a **30-second repair cadence**, not every healthy interval. This also repairs late upstream disconnect actions and failed internal PCB initialization. There is no responder restart or service-record churn.
- Public-only control cannot simultaneously guarantee acknowledged readiness and absolutely no periodic enable reprobes. Healthy families re-probe every 30 seconds as the explicit tradeoff. Recovery from silent dropped enables is on a subsequent repair cadence **once the component queue/network resources make progress**, not a hard deadline under sustained failure. Missing-family disable retries each poll. Polling, concurrent network changes, and component queuing leave a transient window in which stale/zero A responses can still escape; this is convergence, not an atomic packet-level filter. No upstream patch is included.
- `mdns_responder.c` adds/removes records synchronously under its own mutex. Hostname setting waits for the component worker; instance setting queues work. Work per pass is one bounded address scan, at most one family action, and two record decisions; no additional tasks, queues, dynamic application storage, or retry loops. This is not a hard wall-clock guarantee for upstream blocking calls.
## Target validation
In addition to the host suite, the updated `src/mdns_service.c` was compiled to a temporary object using its exact `.pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json` command and the installed Xtensa compiler / IDF 5.5 headers. Compilation passed. No full PlatformIO build, link, or hardware validation was performed.
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Compile the production mDNS owner against bounded host fakes; no IDF build."""
import pathlib
import subprocess
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="mdns-phase12-") as tmp:
p = pathlib.Path(tmp)
(p / "freertos").mkdir()
headers = {
"esp_err.h": """#pragma once
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_ERR_INVALID_ARG 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_NO_MEM 3
#define ESP_ERR_NOT_FOUND 4
#define ESP_ERR_TIMEOUT 5
""",
"sdkconfig.h": """#define CONFIG_MDNS_PREDEF_NETIF_STA 1
#define CONFIG_MDNS_PREDEF_NETIF_AP 0
#define CONFIG_MDNS_PREDEF_NETIF_ETH 0
#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3
""",
"freertos/FreeRTOS.h": """#pragma once
#define portMAX_DELAY 100
#define pdTRUE 1
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
void fake_enter(portMUX_TYPE *);
void fake_exit(portMUX_TYPE *);
#define portENTER_CRITICAL(m) fake_enter(m)
#define portEXIT_CRITICAL(m) fake_exit(m)
""",
"freertos/semphr.h": """#pragma once
typedef void *SemaphoreHandle_t;
SemaphoreHandle_t xSemaphoreCreateMutex(void);
int xSemaphoreTake(SemaphoreHandle_t, int);
int xSemaphoreGive(SemaphoreHandle_t);
""",
"esp_timer.h": """#pragma once
#include <stdint.h>
int64_t esp_timer_get_time(void);
""",
"esp_netif.h": """#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct { int unused; } esp_netif_t;
typedef struct { uint32_t addr; } esp_ip4_addr_t;
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
typedef struct { esp_ip4_addr_t ip; } esp_netif_ip_info_t;
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
bool esp_netif_is_netif_up(esp_netif_t *);
esp_err_t esp_netif_get_ip_info(esp_netif_t *, esp_netif_ip_info_t *);
int esp_netif_get_all_ip6(esp_netif_t *, esp_ip6_addr_t *);
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *), void *);
""",
"mdns.h": """#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "esp_netif.h"
typedef enum {
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
} mdns_event_actions_t;
esp_err_t mdns_netif_action(esp_netif_t *, mdns_event_actions_t);
esp_err_t mdns_init(void);
void mdns_free(void);
esp_err_t mdns_hostname_set(const char *);
esp_err_t mdns_instance_name_set(const char *);
esp_err_t mdns_service_add(const char *, const char *, const char *, uint16_t, void *, size_t);
esp_err_t mdns_service_remove(const char *, const char *);
""",
}
for name, text in headers.items():
(p / name).write_text(text)
exe = p / "test"
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", tmp,
"-I", str(ROOT / "src"), str(pathlib.Path(__file__).with_name("test.c")),
"-o", str(exe)], check=True)
for case in ("normal", "init-failure", "hostname-failure", "instance-failure"):
subprocess.run([str(exe), case], check=True)
+171
View File
@@ -0,0 +1,171 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "mdns_service.c"
static int held, inits, frees, names, adds, removes;
static int fail_init, fail_name, fail_instance, fail_record;
static bool https, ssh;
static char hostname[64];
static int critical, tcpip, actions, enables, fail_action, drop_action;
static bool up = true, valid6 = true, present = true, zero6;
static uint32_t ipv4 = 1;
static unsigned ready;
static int64_t clock_us;
static esp_netif_t sta;
void fake_enter(portMUX_TYPE *m) { (void)m; assert(!critical); critical = 1; }
void fake_exit(portMUX_TYPE *m) { (void)m; assert(critical); critical = 0; }
int64_t esp_timer_get_time(void) { assert(!held && !critical); return clock_us; }
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
assert(!held && !critical && !strcmp(key, "WIFI_STA_DEF")); return present ? &sta : NULL;
}
bool esp_netif_is_netif_up(esp_netif_t *n) { assert(tcpip && n == &sta); return up; }
esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *ip) {
assert(tcpip && n == &sta); ip->ip.addr = ipv4; return ESP_OK;
}
int esp_netif_get_all_ip6(esp_netif_t *n, esp_ip6_addr_t *ip) {
assert(tcpip && n == &sta); memset(ip, 0, sizeof(*ip)); ip->addr[0] = zero6 ? 0 : 0xfe80;
return valid6 ? 1 : 0;
}
esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *arg) {
assert(!held && !critical && !tcpip); tcpip = 1;
esp_err_t error = fn(arg); tcpip = 0; return error;
}
esp_err_t mdns_netif_action(esp_netif_t *n, mdns_event_actions_t action) {
assert(!held && !critical && !tcpip && n == &sta); ++actions;
if (fail_action) return ESP_ERR_NO_MEM;
if (drop_action) return ESP_OK; /* Actual upstream full-queue behavior. */
if (action & MDNS_EVENT_ENABLE_IP4) { ready |= 1; ++enables; }
if (action & MDNS_EVENT_ENABLE_IP6) { ready |= 2; ++enables; }
if (action & MDNS_EVENT_DISABLE_IP4) ready &= ~1U;
if (action & MDNS_EVENT_DISABLE_IP6) ready &= ~2U;
return ESP_OK;
}
static void test_families(void) {
assert(ready == 3);
int before = actions;
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
assert(actions == before); /* Healthy dual-stack poll has no action. */
ipv4 = 0; /* Lost IPv4, surviving IPv6: A readiness must be removed. */
drop_action = 1;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
drop_action = 0;
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
before = enables;
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
assert(enables == before); /* Disables do not restart the healthy family. */
zero6 = true; /* Defensive rejection even if an API supplied a zero entry. */
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
zero6 = false;
valid6 = false;
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
valid6 = true; /* Missed GOT_IP6 and silently lost repair submission. */
drop_action = 1;
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
drop_action = 0;
clock_us += MDNS_FAMILY_REPAIR_US;
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
ipv4 = 1; fail_action = 1;
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && ready == 2);
fail_action = 0;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
ready = 0; /* Late upstream disconnect action after the last sample. */
clock_us += MDNS_FAMILY_REPAIR_US;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
up = false;
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
up = true;
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
mdns_service_stop();
assert(ready == 0); /* Even if stale nonzero addresses remain in netif. */
assert(mdns_service_start() == ESP_OK && ready == 3);
present = false;
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
present = true;
assert(mdns_service_reconcile() == ESP_OK);
}
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &held; }
int xSemaphoreTake(SemaphoreHandle_t m, int wait) {
(void)m;
assert(!critical);
if (held) { assert(!wait); return 0; }
held = 1; return pdTRUE;
}
int xSemaphoreGive(SemaphoreHandle_t m) { (void)m; assert(held); held = 0; return 1; }
esp_err_t mdns_config_validate(const mdns_config_t *c) { return c && c->suffix_len ? ESP_OK : ESP_ERR_INVALID_ARG; }
void mdns_config_defaults(mdns_config_t *c) { memset(c, 0, sizeof(*c)); strcpy(c->suffix, "default"); c->suffix_len = 7; }
esp_err_t mdns_config_load(mdns_config_t *c, bool *stored) { mdns_config_defaults(c); *stored = false; return ESP_OK; }
esp_err_t mdns_config_save(const mdns_config_t *c) { (void)c; return ESP_OK; }
esp_err_t mdns_init(void) { assert(!held && !critical); ++inits; return fail_init ? ESP_ERR_NO_MEM : ESP_OK; }
void mdns_free(void) { assert(!held && !critical); ++frees; }
esp_err_t mdns_hostname_set(const char *n) { assert(!held && !critical); ++names; if (fail_name) return ESP_ERR_NO_MEM; strcpy(hostname, n); return ESP_OK; }
esp_err_t mdns_instance_name_set(const char *n) { assert(!held && !critical && n); return fail_instance ? ESP_ERR_NO_MEM : ESP_OK; }
esp_err_t mdns_service_add(const char *instance, const char *type, const char *proto, uint16_t port, void *txt, size_t count) {
assert(!held && !critical && !instance && !txt && !count && !strcmp(proto, "_tcp"));
++adds;
if (fail_record) return ESP_ERR_NO_MEM;
bool *record = !strcmp(type, "_https") ? &https : &ssh;
assert(port == (record == &https ? 443 : 22));
assert(!*record); *record = true; return ESP_OK;
}
esp_err_t mdns_service_remove(const char *type, const char *proto) {
assert(!held && !critical && !strcmp(proto, "_tcp")); ++removes;
if (fail_record) return ESP_ERR_NO_MEM;
bool *record = !strcmp(type, "_https") ? &https : &ssh;
assert(*record); *record = false; return ESP_OK;
}
int main(int argc, char **argv) {
assert(argc == 2);
fail_init = !strcmp(argv[1], "init-failure");
fail_name = !strcmp(argv[1], "hostname-failure");
fail_instance = !strcmp(argv[1], "instance-failure");
mdns_service_set_https_available(true);
mdns_service_set_ssh_available(false);
assert(!inits && !adds);
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
mdns_config_t c; mdns_config_defaults(&c);
assert(mdns_service_init(&c) == ESP_OK);
assert(mdns_service_reconcile() == ESP_OK && !inits);
if (fail_init || fail_name || fail_instance) {
assert(mdns_service_start() == ESP_ERR_NO_MEM);
assert(frees == (fail_init ? 0 : 1));
fail_init = fail_name = fail_instance = 0;
for (int i = 0; i < 10; ++i) {
assert(mdns_service_start() == ESP_ERR_NO_MEM);
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM);
assert(mdns_service_reannounce() == ESP_ERR_NO_MEM);
mdns_service_stop();
}
assert(inits == 1 && !adds);
} else {
assert(mdns_service_start() == ESP_OK && https && !ssh);
for (int i = 0; i < 10; ++i) assert(mdns_service_start() == ESP_OK);
assert(inits == 1 && names == 1 && adds == 1);
test_families();
held = 1; /* Notifications must never acquire the service mutex. */
mdns_service_set_https_available(false);
mdns_service_set_ssh_available(true);
held = 0;
fail_record = 1;
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && https && !ssh);
fail_record = 0;
assert(mdns_service_reconcile() == ESP_OK && !https && ssh);
mdns_service_stop();
mdns_service_set_ssh_available(false);
assert(mdns_service_reconcile() == ESP_OK && !ssh);
strcpy(c.suffix, "offline"); c.suffix_len = 7;
assert(mdns_service_set_config(&c) == ESP_OK);
fail_name = 1;
assert(mdns_service_start() == ESP_ERR_NO_MEM);
fail_name = 0;
assert(mdns_service_start() == ESP_OK && !strcmp(hostname, "sak-offline"));
mdns_service_set_https_available(true);
mdns_service_set_https_available(false);
int before = adds;
assert(mdns_service_reconcile() == ESP_OK && adds == before);
assert(inits == 1 && !frees);
}
printf("PASS %s\n", argv[1]);
return 0;
}
+6 -5
View File
@@ -31,7 +31,8 @@ static ssh_slot_t s_slots[2];
static void *s_context;
static int s_listen_fd=-1, s_lock;
static unsigned depth, frees, creates;
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail;
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail, advertised;
static void mdns_service_set_ssh_available(bool available) { assert(!depth); assert(available == (s_listen_fd >= 0)); advertised=available; }
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while(0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while(0)
static void wolfSSH_CTX_free(void *p) { assert(!depth && p==s_context); for(unsigned i=0;i<2;++i) assert(!s_slots[i].state); ++frees; }
@@ -48,17 +49,17 @@ static esp_err_t create_listener(void) { if(listener_fail)return ESP_FAIL;s_list
'''
tests=r'''
int main(void) {
assert(start_runtime()==ESP_OK && creates==1);
assert(start_runtime()==ESP_OK && creates==1 && advertised);
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1 && !frees);
s_slots[0].state=2;cleanup_fail=true;
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1);
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1 && !advertised);
s_cleanup_pending=true;s_running=false;
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1);
process_slots();assert(s_context && s_cleanup_pending && !frees);
cleanup_fail=false;process_slots();assert(!s_context && !s_cleanup_pending && frees==1);
process_slots();assert(frees==1);
assert(start_runtime()==ESP_OK && creates==2);assert(stop_runtime()==ESP_OK && frees==2);
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1);
assert(start_runtime()==ESP_OK && creates==2 && advertised);assert(stop_runtime()==ESP_OK && frees==2 && !advertised);
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1 && !advertised);
s_slots[1].state=2;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);s_slots[1].state=0;
s_listen_fd=22;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);
puts("PASS SSH actual runtime stop failure retains context, rejects orphan overwrite, owner retires only after all slots free, failed listener frees context exactly once");
+57
View File
@@ -0,0 +1,57 @@
# Phase 12 SSH listener regression
Run `python3 tests/ssh_phase12/run.py` from the repository root. Requires Python 3
and a host C compiler (`cc`). It extracts the production functions without rewriting
them and compiles with `-std=c11 -Wall -Wextra -Werror`.
Coverage:
- One AF_INET6/TCP wildcard listener, explicitly checked IPV6_V6ONLY=0 before bind.
- Failures at socket, dual-stack option, bind, listen and both nonblocking steps:
no published descriptor, exactly one close after socket allocation.
- Permanent accept errors withdraw availability, invalidate the service generation
without wrap, and request owner-side session cleanup; temporary errors retain
the listener. Capacity rejection respects the existing four-accept budget.
- IPv4, global IPv6, scoped link-local IPv6, mapped IPv4, maximum numeric scope,
unknown families, and bounded/truncated peer formatting.
`tests/ssh_management/runtime.py` additionally tests availability across real
production start/stop functions with context/listener/cleanup doubles, including
failed session cleanup retaining the context, successful restart, and failed start.
Existing management/security regressions remain responsible for authentication,
identity ownership and generation fencing.
## SDK contract checked during implementation
Read-only inspection of installed ESP-IDF **5.5.0**
(`framework-espidf@3.50500.0`, not the separately installed 5.5.3):
- `components/lwip/lwip/src/include/lwip/sockets.h`: `sockaddr_in6.sin6_scope_id`
is `u32_t`; `IPV6_V6ONLY` is supported.
- `components/lwip/lwip/src/include/lwip/inet.h`: `IN6ADDR_ANY_INIT`.
- `components/lwip/lwip/src/api/sockets.c`: `lwip_setsockopt_impl` applies
`netconn_set_ipv6only`; `IP6ADDR_PORT_TO_SOCKADDR` copies the interface zone;
`lwip_accept` publishes the peer address and maps a closed listener to EINVAL,
non-TCP sockets to EOPNOTSUPP, and descriptor exhaustion to ENFILE.
- `components/lwip/lwip/src/api/api_msg.c`: wildcard IPv6 bind/listen with V6ONLY
disabled selects `IPADDR_TYPE_ANY`, accepting both families on one listener.
These host doubles do **not** execute lwIP, actual networking, wolfSSH handshakes,
RTOS concurrency, discovery traffic or hardware. No PlatformIO build is required
or claimed. Device follow-up must verify IPv4-only, IPv6-only and dual-stack
connections, scoped link-local access, address changes/reconnects, simultaneous
clients and binary UART traffic, advertisement convergence after start/stop/error,
and independent UART0/native USB recovery.
## Resource and handoff notes
No extra socket, task, session, queue, I/O buffer or heap allocation is introduced.
Peer arrays grow from 48 to 65 bytes: 68 additional raw bytes across the two owner
slots and two published snapshots, plus ABI padding. Public snapshot copies also
grow. Exact linked RAM/flash and stack headroom were not measured without a target
build; host tests do not establish ESP32 memory headroom.
Project agent memory is intentionally not edited under the exclusive-write scope.
The parent should record the one-socket dual-stack contract, scoped peer capacity,
and owner-driven mDNS availability (eventual Wi-Fi-manager reconciliation) in the
relevant durable memory when integrating Phase 12.
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Compile exact SSH listener/peer/failure functions with deterministic socket doubles.
No target, network, wolfSSH handshake, or SDK build is exercised.
"""
from pathlib import Path
import re
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / 'src/ssh_transport.c').read_text()
header = (ROOT / 'src/ssh_transport.h').read_text()
def function(name):
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
assert match, name
return match.group() + '\n'
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', text, re.M).group()
for text, name in ((header, 'SSH_TRANSPORT_PORT'),
(header, 'SSH_TRANSPORT_PEER_CAPACITY'),
(source, 'SSH_TRANSPORT_LISTEN_BACKLOG')))
fakes = r'''
#include <assert.h>
#include <errno.h>
#include <netinet/tcp.h>
#include <stdbool.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define ESP_OK 0
#define ESP_FAIL -1
#define SSH_TRANSPORT_MAX_SESSIONS 2U
typedef int esp_err_t;
static int s_listen_fd = -1;
static unsigned stage, fail_at, closes, depth;
static bool s_running, s_cleanup_pending;
static unsigned withdrawals;
static void mdns_service_set_ssh_available(bool available) { assert(!depth && !available && s_listen_fd == -1); ++withdrawals; }
static int s_last_error, s_lock;
static uint32_t s_management_generation;
typedef struct {
bool close_requested;
unsigned generation, state, session_id;
int socket_fd;
int64_t handshake_deadline_us;
char peer[SSH_TRANSPORT_PEER_CAPACITY];
void *ssh;
} ssh_slot_t;
static ssh_slot_t s_slots[2];
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while (0)
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while (0)
#define request_slot_close(slot, revoked) do { assert(!(revoked)); (slot)->close_requested=true; } while (0)
static bool step(void) { return ++stage == fail_at; }
static int fake_socket(int family, int type, int protocol) {
assert(family == AF_INET6 && type == SOCK_STREAM && protocol == IPPROTO_TCP);
return step() ? -1 : 42;
}
static int fake_setsockopt(int fd, int level, int option, const void *value, socklen_t size) {
assert(fd == 42 && size == sizeof(int));
if (level == SOL_SOCKET) { assert(option == SO_REUSEADDR && *(const int *)value == 1); return 0; }
assert(stage == 1 && level == IPPROTO_IPV6 && option == IPV6_V6ONLY && *(const int *)value == 0);
return step() ? -1 : 0;
}
static int fake_bind(int fd, const struct sockaddr *addr, socklen_t size) {
const struct sockaddr_in6 *v6 = (const struct sockaddr_in6 *)addr;
assert(fd == 42 && stage == 2 && size == sizeof(*v6));
assert(v6->sin6_family == AF_INET6 && ntohs(v6->sin6_port) == SSH_TRANSPORT_PORT);
assert(IN6_IS_ADDR_UNSPECIFIED(&v6->sin6_addr) && !v6->sin6_scope_id);
return step() ? -1 : 0;
}
static int fake_listen(int fd, int backlog) {
assert(fd == 42 && stage == 3 && backlog == SSH_TRANSPORT_LISTEN_BACKLOG);
return step() ? -1 : 0;
}
static int fake_fcntl(int fd, int op, int arg) {
assert(fd == 42);
assert((stage == 4 && op == F_GETFL && arg == 0) ||
(stage == 5 && op == F_SETFL && arg == (O_NONBLOCK | O_APPEND)));
return step() ? -1 : (op == F_GETFL ? O_APPEND : 0);
}
static int fake_close(int fd) { assert(fd == 42 && !depth); ++closes; return 0; }
static int fake_shutdown(int fd, int how) { assert(fd == 42 && how == SHUT_RDWR && !depth); return 0; }
#define socket fake_socket
#define setsockopt fake_setsockopt
#define bind fake_bind
#define listen fake_listen
#define fcntl fake_fcntl
#define close fake_close
#define shutdown fake_shutdown
'''
accept_fakes = r'''
#define SSH_TRANSPORT_ACCEPT_BUDGET 4U
#define SSH_TRANSPORT_SESSION_HANDSHAKE 1
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
#define WS_SUCCESS 0
static void *s_context = (void *)1;
static struct { uint64_t io_failures, tcp_connections, capacity_rejections, handshake_failures; } s_counters;
static unsigned accepts;
static int accept_error;
static void add_counter(uint64_t *counter, uint64_t value) { *counter += value; }
static int fake_accept(int fd, struct sockaddr *addr, socklen_t *length) {
assert(fd == 42 && addr && *length == sizeof(struct sockaddr_storage));
++accepts; errno = accept_error;
return accept_error ? -1 : 42;
}
#define accept fake_accept
static ssh_slot_t *find_free_slot(size_t *index) { (void)index; return NULL; }
static uint32_t make_session_id(size_t index, uint32_t generation) { (void)index; return generation; }
static int64_t esp_timer_get_time(void) { return 0; }
static void *wolfSSH_new(void *ctx) { (void)ctx; assert(0); return NULL; }
static int wolfSSH_set_fd(void *ssh, int fd) { (void)ssh; (void)fd; assert(0); return 0; }
static void set_ctx(void *ssh, void *slot) { (void)ssh; (void)slot; assert(0); }
#define wolfSSH_SetIOReadCtx set_ctx
#define wolfSSH_SetUserAuthCtx set_ctx
#define wolfSSH_SetUserAuthResultCtx set_ctx
#define wolfSSH_SetChannelReqCtx set_ctx
static bool cleanup_slot(ssh_slot_t *slot) { (void)slot; assert(0); return false; }
static void publish_slot(ssh_slot_t *slot, size_t index) { (void)slot; (void)index; assert(0); }
'''
tests = r'''
int main(void) {
for (fail_at = 1; fail_at <= 6; ++fail_at) {
stage = closes = 0;
assert(create_listener() == ESP_FAIL && s_listen_fd == -1);
assert(stage == fail_at && closes == (fail_at != 1));
}
fail_at = stage = closes = 0;
assert(create_listener() == ESP_OK && s_listen_fd == 42 && stage == 6 && !closes);
s_running = true; s_management_generation = 7;
listener_failed();
assert(s_listen_fd == -1 && closes == 1 && !s_running && s_cleanup_pending && withdrawals == 1);
assert(s_last_error == ESP_FAIL && s_management_generation == 8);
assert(s_slots[0].close_requested && s_slots[1].close_requested);
s_management_generation = UINT32_MAX;
listener_failed(); assert(s_management_generation == UINT32_MAX && closes == 1);
const int retry_errors[] = { EAGAIN, EWOULDBLOCK, EINTR, ENOMEM, ENOBUFS, ENFILE, ECONNABORTED };
for (unsigned i = 0; i < sizeof(retry_errors)/sizeof(retry_errors[0]); ++i) {
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = retry_errors[i];
unsigned before = withdrawals;
accept_connections();
assert(accepts == 1 && s_listen_fd == 42 && s_running && withdrawals == before);
}
const int fatal_errors[] = { EBADF, EINVAL, ENOTSOCK, EOPNOTSUPP };
for (unsigned i = 0; i < sizeof(fatal_errors)/sizeof(fatal_errors[0]); ++i) {
s_listen_fd = 42; s_running = true; accepts = 0; accept_error = fatal_errors[i];
unsigned before = withdrawals;
accept_connections();
assert(accepts == 1 && s_listen_fd == -1 && !s_running && withdrawals == before + 1);
}
accepts = 0; accept_connections(); assert(!accepts);
s_listen_fd = 42; accepts = closes = 0; accept_error = 0;
accept_connections();
assert(accepts == SSH_TRANSPORT_ACCEPT_BUDGET && closes == accepts);
assert(s_counters.capacity_rejections == accepts && s_counters.tcp_connections == accepts);
struct sockaddr_storage storage = {0};
char output[SSH_TRANSPORT_PEER_CAPACITY];
struct sockaddr_in *v4 = (struct sockaddr_in *)&storage;
v4->sin_family = AF_INET; v4->sin_port = htons(65535);
assert(inet_pton(AF_INET, "192.0.2.1", &v4->sin_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "192.0.2.1:65535"));
memset(&storage, 0, sizeof(storage));
struct sockaddr_in6 *v6 = (struct sockaddr_in6 *)&storage;
v6->sin6_family = AF_INET6; v6->sin6_port = htons(65535);
assert(inet_pton(AF_INET6, "fe80::1", &v6->sin6_addr) == 1);
v6->sin6_scope_id = 3;
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[fe80::1%3]:65535"));
v6->sin6_scope_id = UINT32_MAX;
assert(inet_pton(AF_INET6, "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output));
assert(!strcmp(output, "[ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff%4294967295]:65535"));
v6->sin6_scope_id = 0;
assert(inet_pton(AF_INET6, "2001:db8::1", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[2001:db8::1]:65535"));
assert(inet_pton(AF_INET6, "::ffff:192.0.2.1", &v6->sin6_addr) == 1);
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "[::ffff:192.0.2.1]:65535"));
format_peer(&storage, output, 8); assert(!strcmp(output, "unknown"));
output[0] = 'x'; format_peer(&storage, output, 0); assert(output[0] == 'x');
format_peer(&storage, output, 1); assert(output[0] == 0);
storage.ss_family = AF_UNSPEC;
format_peer(&storage, output, sizeof(output)); assert(!strcmp(output, "unknown"));
puts("PASS SSH Phase12 dual-stack socket setup, all setup failures, owner listener failure, scoped/mapped peers and bounded formatting");
}
'''
with tempfile.TemporaryDirectory(prefix='ssh-phase12-') as directory:
out = Path(directory)
(out / 'test.c').write_text(constants + '\n' + fakes + ''.join(function(name) for name in (
'close_socket', 'set_nonblocking', 'create_listener', 'format_peer', 'listener_failed')) + accept_fakes + function('accept_connections') + tests)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(out / 'test.c'), '-o', str(out / 'test')], check=True, timeout=30)
subprocess.run([str(out / 'test')], check=True, timeout=10)
+102 -4
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Compile production server lifecycle and URI tables against fixed host fakes.
No HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler is
executed. Assertions cover server orchestration and values passed to registration
No complete HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler
are executed. The Wi-Fi status JSON projection is compiled separately from its
production format and arguments. Assertions cover server orchestration and values passed to registration
and SSL-start fakes, not actual requests/101, socket eviction or concurrent stop.
No firmware build, network access or device operation. CC selects the compiler.
"""
import json
import os
from pathlib import Path
import re
@@ -91,6 +93,18 @@ static int mutex_storage, server_storage, locked;
static void esp_restart(void);
void web_lifecycle_settings_stopped(httpd_handle_t server);
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live, mutex_busy;
static bool https_available;
static unsigned https_publications, https_withdrawals;
static void mdns_service_set_https_available(bool available) {
assert(locked);
if (available) {
assert(auth_live && ssl_live && !https_available);
++https_publications;
} else {
++https_withdrawals;
}
https_available = available;
}
static void (*unlock_hook)(void);
static esp_err_t serial_detach_error;
static unsigned ssl_stop_fail_at;
@@ -271,7 +285,7 @@ static bool generation_fail;
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
static void web_cookie_auth_stop(void) { event('A'); ++auth_stops; auth_live = false; }
static void web_cookie_auth_stop(void) { assert(!https_available); event('A'); ++auth_stops; auth_live = false; }
static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t *lc,
uint8_t *key, size_t nk, size_t *lk) {
assert(!locked && auth_live && nc && nk); cert[0] = 1; key[0] = 2; *lc = *lk = 1; return ESP_OK;
@@ -416,6 +430,7 @@ static void reset(void) {
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
s_generation = 1U; mutex_busy = false; unlock_hook = NULL; serial_detach_error = ESP_OK;
ssl_stop_fail_at = 0;
https_available = false; https_publications = https_withdrawals = 0;
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
s_serial_transport_attached = s_admin_transport_owned = false;
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
@@ -451,6 +466,7 @@ static void start(void) {
assert(web_server_start() == ESP_OK);
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning && idle_owned);
assert(https_available);
}
static const httpd_uri_t *route(const char *uri) {
const httpd_uri_t *found = NULL;
@@ -515,6 +531,32 @@ static void other_domains_complete(void) {
}
}
int main(void) {
reset();
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
assert(!https_available && !https_publications && !https_withdrawals);
start();
assert(https_publications == 1 && !https_withdrawals);
assert(web_server_start() == ESP_ERR_INVALID_STATE);
assert(stop_server(s_generation + 1, false, false) == ESP_ERR_INVALID_STATE);
s_transitioning = true;
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
s_transitioning = false;
assert(https_available && https_publications == 1 && !https_withdrawals);
for (unsigned failure = 0; failure < 4; ++failure) {
idle_detach_error = failure == 0 ? ESP_ERR_TIMEOUT : ESP_OK;
admin_detach_error = failure == 1 ? ESP_ERR_TIMEOUT : ESP_OK;
serial_detach_error = failure == 2 ? ESP_FAIL : ESP_OK;
ssl_stop_error = failure == 3 ? ESP_FAIL : ESP_OK;
assert(web_server_stop() != ESP_OK);
assert(!https_available && https_publications == 1 && https_withdrawals == failure + 1);
assert(web_server_start() == ESP_ERR_INVALID_STATE && !https_available);
}
ssl_stop_error = ESP_OK;
assert(web_server_stop() == ESP_OK && !https_available && https_withdrawals == 5);
fresh_registration(); start();
assert(https_publications == 2 && https_withdrawals == 5);
assert(web_server_stop() == ESP_OK && !https_available && https_withdrawals == 6);
puts("PASS mDNS HTTPS publication under mutex, rejected calls unchanged, withdrawal before cookie stop through all teardown failures/retry/restart");
reset(); mutex_fail = true;
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
mutex_fail = false; serial_init_error = ESP_FAIL;
@@ -578,6 +620,7 @@ int main(void) {
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
assert(!https_available && !https_publications && https_withdrawals == 1);
}
puts("PASS required registration positions 1..17 fail fatally before transport attachment");
@@ -621,6 +664,7 @@ int main(void) {
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
assert(web_server_start() == ESP_FAIL && s_server == SERVER && ssl_live);
assert(!s_admin_transport_owned && !admin_attaches && !auth_live);
assert(!https_available && !https_publications && https_withdrawals == 1);
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; clear_events();
assert(web_server_stop() == ESP_OK && !strcmp(events, "AH") && !admin_stoppeds);
@@ -866,7 +910,7 @@ int main(void) {
puts("PASS every other settings route failure leaves the complete Network domain available");
management_tests();
pipeline_tests();
puts("44 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups)");
puts("45 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups, 1 mDNS availability group)");
return 0;
}
'''
@@ -1049,6 +1093,60 @@ with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as direct
subprocess.run([str(executable)], check=True, timeout=15)
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
# Keep the production Wi-Fi JSON format and all its arguments together, without
# doubling every unrelated /api/status subsystem or claiming full HTTP coverage.
status = function('status_handler')
wifi_format = status[status.index(' " \\"wifi\\":'):status.index(' " \\"serial\\":')]
wifi_arguments = status[status.index(' wifi_available ? "true"'):status.index(' serial_config_available ?')].rstrip().removesuffix(',')
wifi_unit = r'''
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
static const char *wifi_manager_state_to_string(int state) {
assert(state == 1); return "connected";
}
int main(void) {
struct { bool ipv6_linklocal, ipv6_routable, ap_running;
int state, sta_rssi; unsigned sta_channel, ap_client_count; } wifi = {
.state = 1, .sta_rssi = -42, .sta_channel = 6,
.ap_running = true, .ap_client_count = 2 };
const char *ipv4 = "192.0.2.1";
char response[512];
for (unsigned available = 0; available < 2; ++available)
for (unsigned flags = 0; flags < 4; ++flags) {
bool wifi_available = available;
wifi.ipv6_linklocal = flags & 1;
wifi.ipv6_routable = flags & 2;
int written = snprintf(response, sizeof(response),
''' + wifi_format + ',\n' + wifi_arguments + r''');
assert(written > 0 && (size_t)written < sizeof(response));
fputs(response, stdout);
}
return 0;
}
'''
with tempfile.TemporaryDirectory(prefix='web-status-wifi-') as directory:
temporary = Path(directory)
(temporary / 'status.c').write_text(wifi_unit)
executable = temporary / 'status'
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
str(temporary / 'status.c'), '-o', str(executable)], check=True, timeout=30)
result = subprocess.run([str(executable)], check=True, capture_output=True, text=True, timeout=15)
rows = result.stdout.splitlines()
assert len(rows) == 8
for index, row in enumerate(rows):
wifi = json.loads('{' + row.rstrip().removesuffix(',') + '}')['wifi']
available, flags = divmod(index, 4)
assert wifi == {
'available': bool(available), 'state': 'connected' if available else 'unavailable',
'sta_ipv4': '192.0.2.1', 'ipv6_linklocal': bool(available and flags & 1),
'ipv6_routable': bool(available and flags & 2), 'rssi': -42 if available else 0,
'channel': 6 if available else 0, 'ap_running': bool(available),
'ap_clients': 2 if available else 0,
}
assert type(wifi['ipv6_linklocal']) is bool and type(wifi['ipv6_routable']) is bool
print('PASS production /api/status Wi-Fi JSON projection: eight availability/IPv6 combinations, native booleans and unavailable masking')
# Second executable links the same production server functions to the COMPLETE
# security implementation and real mbedTLS. Only NVS/HTTPD/scheduler are doubles.
import ast
+17 -2
View File
@@ -14,7 +14,7 @@ parser substitutes, network access, or persistent build artifacts are used.
Compilation errors and test failures produce nonzero exit status.
Tables cover DNS/IPv4 origin canonicalization, case folding and optional `:443`,
malformed authorities/origins and unsupported IPv6; selected cookie presence,
bracketed IPv6 canonicalization and malformed authorities/origins; selected cookie presence,
uniqueness, exact lowercase 64-digit hex and surrounding cookie syntax; strict
login JSON, both field orders, escapes, Unicode/surrogate pairs, invalid UTF-8,
NUL, duplicate/unknown fields, truncation and byte limits (512-byte body,
@@ -31,7 +31,22 @@ bytes. Successful results check canonical/decoded bytes and termination.
- This is a focused parser contract suite, not HTTP integration, authorization,
CSRF/session, duplicate HTTP header-line, TLS, credential-policy, or hardware
testing. Empty credentials are syntactically valid; database policy is separate.
- IPv6 is intentionally rejected, not normalized or supported.
- IPv6 tests check expanded/compressed/case/default-port equivalence, longest
zero runs and ties, dotted tails, mapped-address separation from IPv4,
DNS separation, mismatched addresses, zones, malformed groups/brackets/ports,
userinfo, suffixes, controls and exact-span output wiping.
- Canonical IPv6 uses lowercase hex tails even for mapped addresses. No DNS
resolution, scope inference or network reachability is involved.
- To additionally audit the installed ESP-IDF conversion implementation, run:
```sh
WEB_AUTH_LWIP_SOURCE=/home/mscholz/.platformio/packages/framework-espidf/components/lwip/lwip/src/core/ipv6/ip6_addr.c python3 tests/web_auth_parse/run.py
```
Adjust the path for your installation. This compiles the actual extracted
`ip6addr_aton` body with host type/byte-order adapters and the same temporary
address-copy boundary as `lwip_inet_pton`. IPv4-tail and scope branches are
disabled: production validates/replaces dotted tails before conversion and
rejects zones. The production formatter is exercised, not substituted.
This is not a target build or full lwIP networking test.
- Python mirrors the public C struct and capacities; interface changes must
update these tests. Shared-library loading assumes a Unix-like host/compiler.
- Tables are not exhaustive fuzzing, memory-safety instrumentation, or proof of
+71 -3
View File
@@ -2,6 +2,8 @@
"""Dependency-free contract tests against the production parser, not a model."""
import ctypes as C
import json
import ipaddress
import os
from pathlib import Path
import subprocess
import tempfile
@@ -33,6 +35,40 @@ def main():
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-shared", "-fPIC", "-I", str(ROOT / "src"),
str(ROOT / "src/web_auth_parse.c"), "-o", str(library)], check=True)
# Optional audit uses installed target conversion code, not a mocked
# inet_pton that merely returns success or delegates IPv6 to host libc.
sdk = os.environ.get("WEB_AUTH_LWIP_SOURCE")
if sdk:
source = Path(sdk).read_text()
start = source.index("int\nip6addr_aton(")
end = source.index("\n/**", start)
shim = Path(temporary) / "lwip.c"
shim.write_text('''#include <arpa/inet.h>
#include <stdint.h>
#include <ctype.h>
#include <string.h>
typedef uint32_t u32_t;
typedef struct { uint32_t addr[4]; } ip6_addr_t;
#define LWIP_IPV4 0
#define LWIP_IPV6_SCOPES 0
#define lwip_htonl htonl
#define lwip_isxdigit isxdigit
#define lwip_isdigit isdigit
#define lwip_islower islower
#define ip6_addr_clear_zone(a) ((void)(a))
''' + source[start:end] + '''
int audit_inet_pton(int af, const char *src, void *dst) {
ip6_addr_t addr;
if (af != AF_INET6) return -1;
int result = ip6addr_aton(src, &addr);
if (result) memcpy(dst, addr.addr, sizeof(addr.addr));
return result;
}
''')
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-shared", "-fPIC", "-Dinet_pton=audit_inet_pton",
"-I", str(ROOT / "src"), str(ROOT / "src/web_auth_parse.c"),
str(shim), "-o", str(library)], check=True)
api = C.CDLL(str(library))
api.web_auth_parse_origin.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_void_p]
api.web_auth_parse_cookie.argtypes = [C.c_void_p, C.c_size_t, C.c_char_p, C.c_void_p]
@@ -52,7 +88,7 @@ def main():
if not result and raw != bytes(len(raw)):
failures.append(f"{kind}: {label}: failure did not wipe every output byte")
if result and expected is not None and extract(output) != expected:
failures.append(f"{kind}: {label}: incorrect decoded/canonical output")
failures.append(f"{kind}: {label}: decoded/canonical {extract(output)!r}, expected {expected!r}")
origins = [(b"EXAMPLE.Com", b"https://example.com", b"https://example.com"),
(b"a-b.local", b"https://A-B.LOCAL", b"https://a-b.local"),
@@ -65,8 +101,7 @@ def main():
b"https://" + host.lower()))
bad_hosts = [b"", b" ", b"example.com ", b" example.com", b"a..b", b".a", b"a.",
b"-a", b"a-", b"a_b", b"a/b", b"a?b", b"a#b", b"u@a", b"a,b",
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"[::1]", b"::1",
b"[::1]:443", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"::1", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
b"a:443:443", b"a" * 64 + b".com", b"a" * 130]
origins += [(h, b"https://" + h, None) for h in bad_hosts]
origins += [(b"example.com", o, None) for o in
@@ -77,6 +112,39 @@ def main():
b"https://example.com\0", b"https://example.com\r\n", b"https://[::1]",
b"https://example.com https://example.com", b"https:///example.com")]
origins += [(None, b"https://example.com", None)]
literals = ["::", "::1", "2001:DB8::ABCD", "2001:0:0:1:0:0:0:1",
"1:0:0:2:0:0:3:4", "1:2:3:4:5:6:0:8", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
"::ffff:192.0.2.1", "::192.0.2.1", "2001:db8:1:2:3:4:192.0.2.1"]
for literal in literals:
address = ipaddress.IPv6Address(literal)
normalized = address.compressed
if address.ipv4_mapped:
value = int(address.ipv4_mapped)
normalized = f"::ffff:{value >> 16:x}:{value & 65535:x}"
canonical = b"[" + normalized.encode() + b"]"
for h in (literal.encode(), address.exploded.upper().encode()):
for hp in (b"", b":443"):
for op in (b"", b":443"):
origins.append((b"[" + h + b"]" + hp, b"https://" + canonical + op,
b"https://" + canonical))
invalid = [b"[]", b"[1]", b"[1:2:3:4:5:6:7]", b"[1:2:3:4:5:6:7:8:9]",
b"[1:2:3:4:5:6:7:8::]", b"[:::]", b"[1::2::3]", b"[:1]", b"[1:]",
b"[12345::]", b"[gg::]", b"[::1", b"::1]", b"[[::1]]",
b"[::ffff:192.00.2.1]", b"[::ffff:256.0.2.1]", b"[::ffff:192.2.1]",
b"[::ffff:0xc0.0.2.1]", b"[::ffff:192.0.2.1:1]",
b"[fe80::1%eth0]", b"[fe80::1%25eth0]", b"user@[::1]",
b"[::1]:80", b"[::1]:0443", b"[::1]:+443", b"[::1]:443:443",
b"[::1]:", b"[::1]/", b"[::1]?x", b"[::1]#x", b"[::1]@host"]
for c in list(range(33)) + [127, 128, 255]:
invalid.extend((b"[::1" + bytes([c]) + b"]", b"[::1]" + bytes([c])))
for h in invalid:
origins.extend(((h, b"https://" + h, None),
(h, b"https://[::1]", None),
(b"[::1]", b"https://" + h, None)))
for h, o in ((b"[::1]", b"[::2]"), (b"[::1]", b"localhost"),
(b"[::ffff:192.0.2.1]", b"192.0.2.1"),
(b"[::192.0.2.1]", b"[::ffff:192.0.2.1]")):
origins.extend(((h, b"https://" + o, None), (o, b"https://" + h, None)))
# 128 canonical bytes fit with the terminator; 129 do not.
for n in (56, 57):
host = b"a" * 63 + b"." + b"b" * n
+17
View File
@@ -107,6 +107,23 @@ static void admin_tests(void) {
user_principal_t administrator = alice; administrator.role = USER_ROLE_ADMIN;
issued_t user = mint(&bob), admin = mint(&administrator), other = mint(&administrator);
unsigned before = upgrades;
issued_t ipv6_admin;
const char *ipv6_origin = "https://[2001:db8::1]";
assert(web_session_store_issue(&administrator, ipv6_origin, strlen(ipv6_origin),
ipv6_admin.token, &ipv6_admin.view) == ESP_OK);
for (unsigned mode = 0; mode < 3; ++mode) {
begin(WEB_ADMIN_TICKET_URI, HTTP_POST, NULL);
add("Host", mode == 2 ? "[2001:db8::2]" : "[2001:0db8:0:0:0:0:0:1]:443");
add("Origin", mode == 1 ? "https://[2001:db8::1]" : "https://[2001:db8::2]");
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", ipv6_admin.token);
add("Cookie", cookies); add("X-CSRF-Token", ipv6_admin.view.csrf);
assert(web_admin_transport_ticket_handler(&req) == ESP_OK);
assert(!strcmp(response_status, mode == 1 ? "200 OK" : mode == 2 ? "401 Unauthorized" : "403 Forbidden"));
assert(upgrades == before);
}
web_session_store_invalidate(ipv6_admin.view.id);
web_admin_tickets_revoke(0, NULL, 0);
puts("PASS: admin ticket IPv6 canonical Origin accepted, cross-address Origin rejected without upgrade");
for (unsigned mode = 0; mode < 5; ++mode) {
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin,
WEB_ADMIN_TICKET_URI, true, mode != 2, mode != 3);
+49 -4
View File
@@ -7,6 +7,13 @@ static unsigned mutations, projections, timer_creates, timer_starts;
static uint32_t queued;
static esp_err_t owner_error, mdns_queue_error;
static bool stored_mdns = true;
static bool snapshot_ipv6_case, snapshot_ipv4, snapshot_linklocal, snapshot_routable;
static uint8_t snapshot_ipv6_count;
static const uint8_t snapshot_ipv6_bytes[3][16] = {
{0xfe,0x80,0,0,0,0,0,0,0xea,0x3d,0xc1,0xff,0xfe,0xfa,0x70,0x58},
{0xfd,0x39,0xb3,0x78,0x78,0xf2,0xc7,0x15,0xea,0x3d,0xc1,0xff,0xfe,0xfa,0x70,0x58},
{0x20,0x01,0x0d,0xb8,0,1,0,2,0,3,0,4,0,5,0,6},
};
static void (*timer_callback)(void *);
static void (*owner_hook)(void);
static void (*queue_hook)(void);
@@ -65,8 +72,19 @@ esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *out) {
out->profiles[i].ssid_len=32; memset(out->profiles[i].ssid,0xff,32);
out->profiles[i].priority=255; out->profiles[i].security=WIFI_CONFIG_SECURITY_MIXED;
}
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
out->runtime.ipv6_count=snapshot_ipv6_count;
for(unsigned i=0;i<snapshot_ipv6_count && i<3;++i)
memcpy(out->runtime.ipv6_addresses[i].addr,snapshot_ipv6_bytes[i],16);
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
memset(&out->runtime.ip,255,4); out->runtime.ap_client_count=255;
if(snapshot_ipv6_case) {
const uint8_t ip[4]={192,0,2,8};
out->runtime.started=true; out->runtime.state=WIFI_MANAGER_STATE_ONLINE;
out->runtime.active_profile=0; out->runtime.ip=0;
if(snapshot_ipv4) memcpy(&out->runtime.ip,ip,sizeof(ip));
out->runtime.ipv6_linklocal=snapshot_linklocal;
out->runtime.ipv6_routable=snapshot_routable;
}
return ESP_OK;
}
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
@@ -75,7 +93,7 @@ esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
memset(out->suffix,'s',55); strcpy(out->hostname,"sak-"); memset(out->hostname+4,'s',55);
return ESP_OK;
}
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { (void)state; return "waiting-ip"; }
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { return state==WIFI_MANAGER_STATE_ONLINE ? "online" : "waiting-ip"; }
static void network_begin(const issued_t *identity,const char *body) {
begin("/api/settings/network-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
if(body) add("Content-Type","application/json");
@@ -117,8 +135,35 @@ static void network_settings_tests(void) {
network_begin(&user,NULL); network_expect("403 Forbidden",false);
network_begin(&user,patch_body); network_expect("403 Forbidden",false);
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strlen(output)<2048 && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
printf("PASS Network maximum escaped snapshot: %zu bytes, no secret fields\n",strlen(output));
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
assert(strstr(output,"\"ipv6_addresses\":[]"));
assert(strstr(output,"\"ipv6_linklocal\":false,\"ipv6_routable\":false"));
snapshot_ipv6_case=true;
for(unsigned flags=0;flags<8;++flags) {
snapshot_ipv4=(flags&4)!=0; snapshot_linklocal=(flags&1)!=0; snapshot_routable=(flags&2)!=0;
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strstr(output,"\"state\":\"online\""));
assert(strstr(output,snapshot_ipv4 ? "\"ip\":\"192.0.2.8\"" : "\"ip\":\"0.0.0.0\""));
assert(strstr(output,snapshot_linklocal ? "\"ipv6_linklocal\":true" : "\"ipv6_linklocal\":false"));
assert(strstr(output,snapshot_routable ? "\"ipv6_routable\":true" : "\"ipv6_routable\":false"));
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX);
}
snapshot_ipv6_case=false;
for(snapshot_ipv6_count=1;snapshot_ipv6_count<=3;++snapshot_ipv6_count) {
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strstr(output,"\"ipv6_addresses\":[\"fe80:0000:0000:0000:ea3d:c1ff:fefa:7058\""));
assert((strstr(output,"fd39:b378:78f2:c715:ea3d:c1ff:fefa:7058")!=NULL)==(snapshot_ipv6_count>=2));
assert((strstr(output,"2001:0db8:0001:0002:0003:0004:0005:0006")!=NULL)==(snapshot_ipv6_count==3));
assert(strlen(output)<WEB_NETWORK_SNAPSHOT_MAX);
}
printf("PASS Network maximum escaped SSIDs plus three IPv6 addresses: %zu bytes, no secret fields\n",strlen(output));
assert(strlen(output)>2048);
/* Corrupt owner counts fail closed instead of reading beyond the bounded copy. */
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("503 Service Unavailable",true);
snapshot_ipv6_count=0;
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
assert(strstr(output,"\"ipv6_addresses\":[]") && !strstr(output,"fd39:"));
puts("PASS Network IPv6-only/dual-stack: availability flags, bounded network-order address list and stale clearing, no writes");
snapshot_fail=true; network_begin(&admin,NULL); network_expect("503 Service Unavailable",true); snapshot_fail=false;
network_begin(&user,NULL); network_expect("403 Forbidden",true);
network_begin(&admin,NULL); req.uri="/api/settings/network?secret=x"; network_expect("400 Bad Request",true);
+19 -1
View File
@@ -19,7 +19,10 @@ static struct httpd_data server = {.config.max_resp_headers = 8};
static struct sock_db socket_state;
static struct resp_hdr response_headers[8];
static char scratch[1024], cookie_values[2][200];
#if defined(HOST_NETWORK) || defined(HOST_BROKER)
#if defined(HOST_NETWORK)
#include "web_network_settings.h"
static char output[WEB_NETWORK_SNAPSHOT_MAX];
#elif defined(HOST_BROKER)
static char output[2048];
#else
static char output[1024];
@@ -161,6 +164,21 @@ static void auth_reset(void) {
int main(void) {
assert(store_tests() == 0); auth_reset();
char token[65], csrf[65], session[65], cookies[200];
begin("/api/login-challenge", HTTP_GET, NULL);
add("Host", "[2001:0DB8:0:0:0:0:0:1]:443"); add("X-Login-Bootstrap", "1");
expect("200 OK"); token_from(cookie_values[0], token); csrf_from(csrf);
for (unsigned mode = 0; mode < 3; ++mode) {
begin("/api/login", HTTP_POST, good_body);
add("Host", mode == 0 ? "[2001:db8::2]" : "[2001:db8::1]");
add("Origin", mode == 1 ? "https://[2001:db8::2]" :
mode == 0 ? "https://[2001:db8::2]" : "https://[2001:DB8::1]:443");
add("Content-Type", "application/json"); add("X-CSRF-Token", csrf);
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
expect(mode == 2 ? "200 OK" : "403 Forbidden");
assert(password_calls == (mode == 2 ? 1U : 0U));
}
puts("PASS: IPv6 challenge canonical binding, cross-address challenge replay and mismatched Origin rejected before password verification");
auth_reset();
challenge(token, csrf);
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
+10 -2
View File
@@ -40,7 +40,7 @@ A complete example (values are illustrative, never defaults to install):
{"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
]
},
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ap_running": false, "ap_clients": 0, "last_error": 0},
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ipv6_linklocal": true, "ipv6_routable": true, "ap_running": false, "ap_clients": 0, "last_error": 0},
"mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
}
```
@@ -51,6 +51,14 @@ Runtime states are canonical `stopped`, `starting`, `connecting`, `waiting-ip`,
`.local`. The existing responder is STA-only. `announced` is the service's
expected-announcement status, not a client-observed DNS verification.
`ip` is IPv4 only; `0.0.0.0` means absent, including when `state` is `online`.
The required boolean `ipv6_linklocal` and `ipv6_routable` fields report preferred
IPv6 address availability (link-local and ULA/GUA respectively). They do not
assert a default route or Internet reachability. No actual IPv6 literal addresses
are available in this snapshot; reporting is flags only. Settings, quick Network,
and the main status summary label IPv4 absence separately from IPv6 availability.
The OLED network header uses `LL` and `ULA/GUA` with `Y`/`N` (`?` if unavailable).
Wi-Fi working configuration and runtime are copied together under its mutex;
mDNS is a separate consistent projection, not an atomic cross-domain snapshot.
Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503
@@ -202,7 +210,7 @@ remain independent. No terminal lease/transport changes are made by this module.
- 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte
parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte
replacement plus the typed fields. No heap JSON tree/cJSON.
- 2,048-byte snapshot buffer. Maximum escaped fixture: 1,877 payload bytes
- 2,048-byte snapshot buffer. Escaped fixture remains below the fixed limit with both IPv6 booleans
(five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers,
55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
- 128-byte operation response buffer; one static operation and one small timer.
+33 -1
View File
@@ -15,6 +15,7 @@ sys.dont_write_bytecode = True
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
HEADERS = {
'sdkconfig.h': '#pragma once\n#define CONFIG_MDNS_PREDEF_NETIF_STA 1\n#define CONFIG_MDNS_PREDEF_NETIF_AP 0\n#define CONFIG_MDNS_PREDEF_NETIF_ETH 0\n#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3\n',
'esp_err.h': '''#pragma once
typedef int esp_err_t;
#define ESP_OK 0
@@ -34,6 +35,12 @@ typedef int esp_err_t;
#include <stdint.h>
#define pdTRUE 1
#define portMAX_DELAY UINT32_MAX
typedef int portMUX_TYPE;
#define portMUX_INITIALIZER_UNLOCKED 0
void fake_enter(portMUX_TYPE *);
void fake_exit(portMUX_TYPE *);
#define portENTER_CRITICAL(mux) fake_enter(mux)
#define portEXIT_CRITICAL(mux) fake_exit(mux)
''',
'freertos/semphr.h': '''#pragma once
#include <stdint.h>
@@ -62,8 +69,33 @@ void nvs_close(nvs_handle_t);
#define ESP_MAC_WIFI_SOFTAP 1
esp_err_t esp_read_mac(uint8_t *,int);
''',
'mdns.h': '''#pragma once
'esp_timer.h': '#pragma once\n#include <stdint.h>\nint64_t esp_timer_get_time(void);\n',
'esp_netif.h': '''#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct { int unused; } esp_netif_t;
typedef struct { uint32_t addr; } esp_ip4_addr_t;
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
typedef struct { esp_ip4_addr_t ip, netmask, gw; } esp_netif_ip_info_t;
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
bool esp_netif_is_netif_up(esp_netif_t *);
esp_err_t esp_netif_get_ip_info(esp_netif_t *,esp_netif_ip_info_t *);
int esp_netif_get_all_ip6(esp_netif_t *,esp_ip6_addr_t *);
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *),void *);
''',
'mdns.h': '''#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "esp_netif.h"
typedef enum {
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
} mdns_event_actions_t;
esp_err_t mdns_netif_action(esp_netif_t *,mdns_event_actions_t);
esp_err_t mdns_service_add(const char *,const char *,const char *,uint16_t,const void *,size_t);
esp_err_t mdns_service_remove(const char *,const char *);
esp_err_t mdns_init(void);
esp_err_t mdns_hostname_set(const char *);
esp_err_t mdns_instance_name_set(const char *);
+73 -2
View File
@@ -8,6 +8,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "nvs.h"
#include "mdns.h"
#include "esp_timer.h"
static int wifi_mutex, mdns_mutex;
static SemaphoreHandle_t s_mutex=&wifi_mutex;
@@ -23,8 +25,15 @@ static void lock_shared(void) { assert(!wifi_mutex); wifi_mutex=1; }
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
static int s_drop_mux, s_queue;
static uint64_t s_queue_drops;
#define portENTER_CRITICAL(mux) do { assert((mux)==&s_drop_mux && !s_drop_mux); s_drop_mux=1; } while (0)
#define portEXIT_CRITICAL(mux) do { assert((mux)==&s_drop_mux && s_drop_mux); s_drop_mux=0; } while (0)
static portMUX_TYPE *critical;
void fake_enter(portMUX_TYPE *mux) {
assert(!critical && !*mux);
if(mux!=&s_drop_mux) assert(!wifi_mutex && !mdns_mutex);
critical=mux; *mux=1;
}
void fake_exit(portMUX_TYPE *mux) {
assert(critical==mux && *mux); *mux=0; critical=NULL;
}
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
if(queue_fail) return 0;
@@ -32,6 +41,7 @@ static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait)
}
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
assert(!critical);
if(wait==0 && (snapshot_contention || *mutex)) return 0;
assert(!*mutex); *mutex=1; return pdTRUE;
}
@@ -63,6 +73,39 @@ esp_err_t nvs_set_blob(nvs_handle_t handle,const char *key,const void *data,size
}
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
void nvs_close(nvs_handle_t handle) { (void)handle; }
/* Minimal synchronous netif boundary; queue-loss/repair faults live in mdns_phase12. */
static esp_netif_t sta;
static bool tcpip;
static unsigned netif_families=3, requested_families;
static int64_t clock_us;
int64_t esp_timer_get_time(void) { assert(!mdns_mutex && !critical); return clock_us; }
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
assert(!mdns_mutex && !critical && !strcmp(key,"WIFI_STA_DEF")); return &sta;
}
bool esp_netif_is_netif_up(esp_netif_t *netif) { assert(tcpip && netif==&sta); return true; }
esp_err_t esp_netif_get_ip_info(esp_netif_t *netif,esp_netif_ip_info_t *ip) {
assert(tcpip && netif==&sta); memset(ip,0,sizeof(*ip));
ip->ip.addr=(netif_families&1) ? 1 : 0; return ESP_OK;
}
int esp_netif_get_all_ip6(esp_netif_t *netif,esp_ip6_addr_t *ip) {
assert(tcpip && netif==&sta);
if(!(netif_families&2)) return 0;
memset(ip,0,sizeof(*ip)); ip->addr[0]=0xfe80; return 1;
}
esp_err_t esp_netif_tcpip_exec(esp_err_t (*callback)(void *),void *context) {
assert(!mdns_mutex && !critical && !tcpip); tcpip=true;
esp_err_t error=callback(context); tcpip=false; return error;
}
esp_err_t mdns_netif_action(esp_netif_t *netif,mdns_event_actions_t action) {
assert(netif==&sta && !mdns_mutex && !critical && !tcpip);
assert(action && !(action & ~(MDNS_EVENT_ENABLE_IP4 | MDNS_EVENT_ENABLE_IP6 |
MDNS_EVENT_DISABLE_IP4 | MDNS_EVENT_DISABLE_IP6)));
if(action&MDNS_EVENT_ENABLE_IP4) requested_families|=1;
if(action&MDNS_EVENT_ENABLE_IP6) requested_families|=2;
if(action&MDNS_EVENT_DISABLE_IP4) requested_families&=~1U;
if(action&MDNS_EVENT_DISABLE_IP6) requested_families&=~2U;
return ESP_OK;
}
static char announced_hostname[60];
esp_err_t mdns_init(void) { return ESP_OK; }
esp_err_t mdns_hostname_set(const char *hostname) {
@@ -70,6 +113,13 @@ esp_err_t mdns_hostname_set(const char *hostname) {
}
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
void mdns_free(void) {}
esp_err_t mdns_service_add(const char *name,const char *type,const char *proto,uint16_t port,const void *txt,size_t count) {
assert(!mdns_mutex && !name && type && !strcmp(proto,"_tcp") && (port==443 || port==22) && !txt && !count);
return ESP_OK;
}
esp_err_t mdns_service_remove(const char *type,const char *proto) {
assert(!mdns_mutex && type && !strcmp(proto,"_tcp")); return ESP_OK;
}
#include "manager_production.h"
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
@@ -85,6 +135,20 @@ int main(void) {
snapshot_contention=false;
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
for(unsigned flags=0;flags<8;++flags) {
s_shared.snapshot.state=WIFI_MANAGER_STATE_ONLINE;
s_shared.snapshot.ip=(flags&4) ? UINT32_C(0x080200c0) : 0;
s_shared.snapshot.ipv6_linklocal=(flags&1)!=0;
s_shared.snapshot.ipv6_routable=(flags&2)!=0;
assert(wifi_manager_get_settings(&projection)==ESP_OK);
assert(projection.runtime.state==WIFI_MANAGER_STATE_ONLINE);
assert(projection.runtime.ip==s_shared.snapshot.ip);
assert(projection.runtime.ipv6_linklocal==s_shared.snapshot.ipv6_linklocal);
assert(projection.runtime.ipv6_routable==s_shared.snapshot.ipv6_routable);
}
s_shared.snapshot.state=WIFI_MANAGER_STATE_STOPPED; s_shared.snapshot.ip=0;
s_shared.snapshot.ipv6_linklocal=false; s_shared.snapshot.ipv6_routable=false;
puts("PASS real manager projection: IPv6-only/dual-stack preserve independent availability flags and zero IPv4 online");
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
@@ -188,12 +252,19 @@ int main(void) {
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
assert(requested_families==3);
netif_families=2;
assert(mdns_service_reconcile()==ESP_OK && requested_families==2);
netif_families=3; clock_us+=30000000;
assert(mdns_service_reconcile()==ESP_OK && requested_families==3);
mdns_service_stop();
assert(requested_families==0);
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
unsigned calls=hostname_calls;
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
assert(requested_families==3 && !critical && !tcpip);
assert(mdns_service_get_settings(&m)==ESP_OK);
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);
+94 -6
View File
@@ -6,7 +6,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
ap: {policy: 'fallback', channel: 6, ssid: 'access', password_configured: true},
profiles: Array.from({length: 4}, (_, index) => ({index, enabled: index === 0, priority: index * 10,
security: 'mixed', ssid: index === 0 ? 'office' : '', password_configured: index === 0}))},
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ap_running: true, ap_clients: 1, last_error: 0},
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ipv6_linklocal: false, ipv6_routable: false, ipv6_addresses: [], ap_running: true, ap_clients: 1, last_error: 0},
mdns: {generation: 3, suffix: 'example', hostname: 'sak-example', announced: false, last_error: 0}});
const reply = (id = 42, state = 'pending', action = 'wifi-patch', status = 200, error = 0) => new Response(JSON.stringify({id, action, state, error}), {status});
const ack = action => reply(42, 'pending', action, 202);
@@ -28,6 +28,90 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
b.fire(1000); await tick();
}
await test('IPv6-only and dual-stack availability renders in Settings and quick Network without a route claim', async () => {
for (const quick of [false, true]) for (const ip of ['0.0.0.0', '192.0.2.8']) {
for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false]]) {
const v = fixture(); Object.assign(v.runtime, {state: 'online', ip, ipv6_linklocal: linklocal, ipv6_routable: routable});
const b = quick ? await adminBrowser() : await open(v);
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
assert.equal(n(b, 'edit').hidden, false);
const rows = n(b, 'summary').children;
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
assert.equal(values.IPv4, ip === '0.0.0.0' ? 'none' : ip);
assert.equal(values['IPv6 link-local'], linklocal ? 'available' : 'none');
assert.equal(values['IPv6 ULA/GUA'], routable ? 'available' : 'none');
assert.match(values['IPv6 reporting'], /Preferred addresses; no route or Internet reachability guarantee/);
assert.ok(!n(b, 'summary').textContent.includes('0.0.0.0'));
if (quick) {
assert.ok(n(b, 'detail').textContent.includes('IPv4: ' + values.IPv4));
assert.ok(n(b, 'detail').textContent.includes('IPv6 link-local: ' + values['IPv6 link-local']));
assert.ok(n(b, 'detail').textContent.includes('ULA/GUA: ' + values['IPv6 ULA/GUA']));
}
assert.equal(posts(b).length, 0);
}
}
});
const address = (prefix, tail = '0001') => prefix + ':0000:0000:0000:0000:0000:0000:' + tail;
const groups = {'IPv6 link-local addresses': ['fe80', 'febf'], 'IPv6 ULA addresses': ['fc00', 'fd12'], 'IPv6 GUA addresses': ['2001', '3fff']};
await test('Network preferred IPv6 addresses group every entry as safe text, including empty and same-kind slots', async () => {
const samples = [[], ['fe80', 'fd12', '2001'], ...Object.values(groups).map(([prefix, other]) => [prefix, other, prefix])];
for (const quick of [false, true]) for (const prefixes of samples) {
const v = fixture(); v.runtime.ipv6_addresses = prefixes.map((p, i) => address(p, '000' + (i + 1)));
const b = quick ? await adminBrowser() : await open(v);
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
const rows = n(b, 'summary').children;
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
for (const [label, kinds] of Object.entries(groups)) {
assert.equal(values[label], v.runtime.ipv6_addresses.filter(a => kinds.some(p => a.startsWith(p))).join(', ') || 'none');
}
rows.forEach(node => assert.equal(node.children.length, 0));
assert.match(values['IPv6 reporting'], /Link-local access requires the client interface scope/);
assert.equal(posts(b).length, 0);
}
});
await test('Network rejects missing, malformed, oversized and injected IPv6 address arrays in both controllers', async () => {
const a = address('fe80');
const bad = [undefined, null, {}, a, 3, [a, a, a, a], [null], [1], [true], [{}], [[a]], [''],
['fe80::1'], [a.toUpperCase()], [a + '\n'], [a + '0'], [a.slice(1)], [a.replace('0001', '000g')],
[a + '%eth0'], ['[' + a + ']'], ['<img onerror="SECRET">'], [a, '<script>SECRET</script>']];
for (const quick of [false, true]) {
const b = quick ? await adminBrowser() : await open();
if (quick) { b.queues[path].push(json(fixture())); b.click('quick-network'); await tick(); }
const before = n(b, 'summary').textContent;
for (const value of bad) {
const v = fixture(); v.runtime.ipv6_addresses = value;
b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
assert.match(n(b, 'detail').textContent, /stale.*invalid/);
assert.equal(n(b, 'summary').textContent, before); assert.ok(n(b, 'apply').disabled); safe(b);
}
}
});
await test('Both Network controllers accept exactly 2304 bytes and reject 2305 bytes', async () => {
for (const quick of [false, true]) {
const v = fixture(); v.runtime.ipv6_addresses = ['fe80', 'fd12', '2001'].map(p => address(p));
const encoded = JSON.stringify(v);
const b = quick ? await adminBrowser() : await open();
b.queues[path].push(new Response(encoded.padEnd(2304, ' ')));
b.click(quick ? 'quick-network' : 'network-refresh'); await tick();
assert.equal(n(b, 'apply').disabled, false);
assert.ok(n(b, 'summary').textContent.includes(address('2001')));
b.queues[path].push(new Response(encoded.padEnd(2305, ' '))); b.click('network-refresh'); await tick();
assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled);
}
});
await test('Main status reports IPv6-only/dual-stack flags and treats malformed or missing flags as unknown', async () => {
for (const ip of ['0.0.0.0', '192.0.2.8']) for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false], [1, true], [true, 'false'], [null, false], [false, undefined]]) {
const b = browser();
b.queues['/api/status'].push(json({wifi: {available: true, state: 'online', sta_ipv4: ip, ipv6_linklocal: linklocal, ipv6_routable: routable}}));
b.start(); await tick(); const text = b.nodes['wifi-summary'].textContent;
assert.ok(text.includes('IPv4: ' + (ip === '0.0.0.0' ? 'none' : ip)));
assert.ok(!text.includes('0.0.0.0'));
if (typeof linklocal === 'boolean' && typeof routable === 'boolean') {
assert.ok(text.includes('IPv6 link-local: ' + (linklocal ? 'available' : 'none')));
assert.ok(text.includes('ULA/GUA: ' + (routable ? 'available' : 'none')));
} else assert.ok(text.includes('IPv6 availability: unknown'));
}
});
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
b.click('quick-network'); await tick();
@@ -148,7 +232,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
const b = await open(v), summary = n(b, 'summary');
const rows = summary.children;
assert.equal(rows.length, 58);
assert.equal(rows.length, 70);
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
@@ -162,7 +246,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
assert.ok(rows.every(node => node.parentNode === null));
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
assert.equal(summary.children.length, 58); assert.ok(!summary.textContent.includes('<img'));
assert.equal(summary.children.length, 70); assert.ok(!summary.textContent.includes('<img'));
});
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
@@ -176,6 +260,10 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
v => v.runtime.state = '<img>', v => v.runtime.started = 1, v => v.runtime.active_profile = 4, v => v.runtime.active_profile = -2,
v => v.runtime.ip = '256.1.1.1', v => v.runtime.ip = '<img>', v => v.runtime.ap_running = 1, v => v.runtime.ap_clients = 256, v => v.runtime.last_error = 2147483648,
v => v.mdns.suffix = 'A', v => v.mdns.suffix = '-x', v => v.mdns.suffix = 'x-', v => v.mdns.hostname = 'not-matching', v => v.mdns.announced = 0, v => v.mdns.last_error = null];
for (const key of ['ipv6_linklocal', 'ipv6_routable']) {
edits.push(v => delete v.runtime[key]);
for (const value of [null, 0, 1, 'true', 'false', [], {}]) edits.push(v => v.runtime[key] = value);
}
const b = await open(), before = n(b, 'summary').textContent;
for (const change of edits) {
const v = fixture(); change(v); b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
@@ -185,16 +273,16 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); assert.ok(n(b, 'apply').disabled);
}
});
await test('Network snapshot 2048-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
await test('Network snapshot 2304-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
const b = await open();
for (const response of [new Response(' '.repeat(2049)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
for (const response of [new Response(' '.repeat(2305)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
b.queues[path].push(response); b.click('network-refresh'); await tick(); assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled); safe(b);
}
const v = fixture(); v.wifi.ap.ssid = '\xff'.repeat(32); v.wifi.generation = v.mdns.generation = 4294967295;
for (const p of v.wifi.profiles) p.ssid = '\xff'.repeat(32);
v.mdns.suffix = 'a'.repeat(55); v.mdns.hostname = 'sak-' + v.mdns.suffix;
const encoded = JSON.stringify(v).replace(/[\x7f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
assert.ok(Buffer.byteLength(encoded) < 2048); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
assert.ok(Buffer.byteLength(encoded) < 2304); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
assert.equal(n(b, 'edit').hidden, false); assert.equal(n(b, 'apply').disabled, false); assert.equal(n(b, 'ssid-mode').value, 'hex');
v.wifi.ap.ssid = '<img onerror="x">'; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
assert.equal(n(b, 'ssid').value, '<img onerror="x">'); assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify('<img onerror="x">')));
+47
View File
@@ -0,0 +1,47 @@
# Wi-Fi Phase 12 focused host regression
Run `python3 tests/wifi_phase12/run.py`. The renderer tests use the installed public `esp_netif_ip_addr.h` at `~/.platformio/packages/framework-espidf/components/esp_netif/include/` with host endian/config shims (no SDK modification). Optional `--sanitize` requires host ASan/UBSan runtime libraries. No PlatformIO build or device access is performed. Compilation/executables use a temporary directory.
The harness extracts production hostname, address reconciliation, IP callback, and deadline functions verbatim. Real manager/config headers define the data contracts. Host fakes model driver association, netif-up state, IPv6 slot states, TCP/IP execution, and clock; surrounding failover/driver operations are call-count fakes, not a full scheduler/network simulation. Wiring assertions cover pre-connect hostname installation and message dispatch.
Coverage includes:
- Configured hostname and rename without disconnect; full 59-byte hostname; config-read failure.
- STA-only IPv6 event filtering, null events, and IPv4 netif filtering.
- Explicit STA SLAAC enablement, link-local creation, no repeated tentative/duplicate DAD.
- Link-local-only, ULA/GUA, IPv4-only, and dual-stack readiness.
- IPv4 loss while IPv6 remains, IPv6 deprecation/removal and restoration.
- No IPv4 deadline failover on usable IPv6; no perpetual fallback stability reset.
- Dropped address/association events, dropped online disconnect, recreated LL after missed disconnect/reconnect, and stale events during stop/profile advance.
- Permanent owner wakeup, including boot-disabled and stopped Wi-Fi; offline service withdrawal, failed record updates and pending reannouncement retries at one-second cadence.
- Busy-loop calls do not repeat periodic work; persistent mDNS failure does not generate per-pass warnings.
- Permanent hostname pointer lifetime, failed offline rename retry, and mutex/TCP-IP-context assertions for address and hostname access.
- Address-read failure/uncertain callback completion withdraws stale readiness without retiring the recovery AP; later successful reads restore readiness.
- Verbatim production console rendering of simultaneous preferred link-local, ULA and GUA addresses, with non-symmetric network-order words and IDF public formatting macros; client-interface zone guidance never renders the ESP32 zone.
- Full numeric list and count clearing on failed callback admission, skipped callback, IP-info error, netif-down and the production disconnect-clear helper; wiring assertions require intentional disconnect, stop and disconnect-event paths to call that helper. Tentative, duplicate and deprecated slots are excluded by the preferred-getter fake matching SDK semantics.
- Increasing configured SDK slots to four fails the production compile-time capacity assertion.
## Contract and SDK evidence
`ONLINE` means a current association/netif with IPv4 or a **preferred** IPv6 link-local/ULA/GUA address. It is not an Internet/default-route check. Deprecated-only addresses do not qualify for new service readiness. In particular, link-local-only operation suppresses DHCPv4-driven profile failover and can retire the fallback AP after the existing stability interval. IPv6 link-local clients need an interface scope. Snapshot IPv4 fields remain zero on IPv6-only networks; `ipv6_linklocal` / `ipv6_routable` booleans report preferred-address availability, not routing success. The console also lists up to three numeric preferred IPv6 addresses from the exact same TCP/IP observation as those flags. Count and all address storage are published/cleared together under the manager mutex. The configured lwIP slot count must not exceed three (compile-time contract, no silent truncation). Web/OLED continue consuming the existing flags without adding list output.
Inspected installed ESP-IDF 5.5.0 sources:
- `esp_netif/lwip/esp_netif_lwip.c`: netif-up and address getters access lwIP directly; all manager netif-up/address checks now execute inside the TCP/IP callback. Preferred getter excludes tentative/duplicate/deprecated/invalid addresses. Disconnect invalidates/clears IPv6 slots. `esp_netif_tcpip_exec` supplies synchronous TCP/IP context, with no retained stack request on return. Its IDF 5.5 wrapper ignores the underlying `tcpip_send_msg_wait_sem` result: explicit completion markers therefore detect a callback that was never executed despite an apparent `ESP_OK`. No hard wall-clock guarantee is made if upstream stalls.
- `lwip/port/include/lwipopts.h`, `lwip/src/include/lwip/opt.h`, and `esp_netif_start_api`: saved `CONFIG_LWIP_IPV6_AUTOCONFIG` is disabled, but lwIP SLAAC remains compiled via `LWIP_IPV6_AUTOCONFIG`. Explicit per-STA `netif_set_ip6_autoconfig_enabled` is needed; AP policy is untouched.
- `esp_netif_set_hostname_api` has a 32-byte limit, below the existing configured hostname maximum of 59. The manager therefore installs permanent bounded storage through `netif_set_hostname` in TCP/IP context, without transferring ownership to esp-netif. `netif_add` does not clear the hostname; `esp_netif/lwip/netif/wlanif.c` preserves it via `esp_netif_get_hostname` during initialization.
- `lwip/src/core/ipv4/dhcp.c`: option 12 is read from the current netif hostname in subsequent outgoing DHCP exchanges (request/renew/rebind included). Rename does not force release/reacquisition or promise immediate router/DNS cache replacement.
Reverify these boundaries for an SDK upgrade. Tests do not emulate DHCP wire packets, router advertisements, actual DAD timers, mDNS component behavior, or socket listeners. A target syntax check is not a linked firmware build. Device and integration validation remain separate.
## Coordination
The owner calls the new `mdns_service_reconcile` every second even offline/stopped. A pending online reannouncement uses `mdns_service_reannounce`, which also reconciles records. Listener owners' lock-free availability stores need no queue wakeup; changes and failures converge on subsequent passes. Reconciliation never initializes the responder; startup still requires either-family STA readiness. Failures remain nonfatal, visible through service error status, and do not log each retry. Upstream mDNS calls may block: the cadence bounds attempt frequency, not upstream execution time.
`wifi_manager_mdns_reannounce` also refreshes DHCP option 12 while offline. Periodic hostname refresh retries failed/missed DHCP-name changes without reconnecting. The permanent hostname buffer is only read/written in TCP/IP context; no application status caller borrows that pointer (use the copied mDNS snapshot). Pending mDNS rename errors retry online. Other owners should consume the IPv6 snapshot flags rather than infer readiness from `snapshot.ip != 0`.
## Diagnostic addition resource budget and handoff
The numeric payload is three arrays of four network-order `uint32_t` words (48 bytes), plus a one-byte count that fits existing snapshot padding. No strings, device zones, task, socket, heap allocation or queue-message growth are introduced. Host layout measurements with production headers: snapshot 232 → 280 bytes; settings projection 424 → 472 bytes; manager shared storage 760 → 808 bytes; temporary address observation 16 → 64 bytes. Thus the manager's static storage grows by 48 bytes, and each existing snapshot/settings copy (console, web, local UI) grows by 48 bytes. The owner observation adds 48 bytes of transient stack payload; the existing three-entry SDK enumeration scratch is unchanged. Actual compiler stack-frame/high-water effects and linked target RAM remain for the parent's build/device checks; host sizes are not runtime headroom evidence.
Scoped validation: normal host suite and slot-cap negative compile PASS. `--sanitize` could not link on this host because `libasan.so.8.0.0` / `libubsan.so.1.0.0` are missing. No dependency changes, PlatformIO build or hardware operation performed. Parent owns durable documentation updates (the flags-only statements in agent memory/command docs are now stale for console status) and firmware build. Production edits are limited to `src/wifi_manager.c`, `src/wifi_manager.h`, and `src/wifi_console.c`; tests/docs for this addition stay here.
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Compile verbatim manager address/deadline paths with bounded host fakes."""
from pathlib import Path
import subprocess
import tempfile
import sys
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
source = (ROOT / 'src/wifi_manager.c').read_text()
def function(name):
# Definitions end at a column-zero brace; skip forward declarations.
import re
match = re.search(r'^static [^\n]+\b' + name + r'\([^;]*?\)\n\{', source, re.M)
assert match, name
start = match.start()
return source[start:source.index('\n}', match.end()) + 2]
types = source[source.index('typedef enum {'):source.index('static SemaphoreHandle_t')]
addresses = source[source.index('typedef struct {\n esp_netif_ip_info_t ip4;'):source.index('/* IDF\'s IPv6 getters')]
hostname_request = source[source.index('typedef struct {\n const char *hostname;'):source.index('static esp_err_t set_station_hostname')]
names = ['set_station_hostname', 'refresh_station_hostname', 'start_mdns_announcement',
'read_station_addresses', 'handle_got_ip', 'clear_station_network_snapshot',
'handle_expired_deadlines', 'next_runtime_deadline', 'runtime_wait_ticks',
'ip_event_callback']
# Check wiring not present in the extracted paths.
connect = function('start_next_profile')
assert connect.index('refresh_station_hostname()') < connect.index('esp_wifi_connect()')
message = function('handle_message')
assert 'case MESSAGE_STA_GOT_IP6:' in message
assert 'refresh_station_hostname()' in message
assert 'handle_got_ip(runtime, message);' in message
assert '#define WIFI_MANAGER_QUEUE_LENGTH 16U' in source
assert '#define WIFI_MANAGER_TASK_STACK_SIZE 6144U' in source
assert 'esp_netif_tcpip_exec(read_station_addresses, &addresses)' in source
assert 'esp_netif_is_netif_up' not in function('handle_got_ip')
assert 'mdns_service_reconcile()' in function('handle_expired_deadlines')
assert 'runtime->radio_started ? runtime->reconcile_deadline' not in source
for name in ['handle_sta_disconnected', 'mark_intentional_disconnect', 'stop_radio']:
assert 'clear_station_network_snapshot();' in function(name)
with tempfile.TemporaryDirectory(prefix='wifi-phase12-') as directory:
tmp = Path(directory)
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n#define ESP_OK 0\n#define ESP_FAIL -1\n#define ESP_ERR_INVALID_STATE 1\n#define ESP_ERR_INVALID_ARG 2\n')
(tmp / 'esp_wifi_types.h').write_text('#pragma once\ntypedef int wifi_auth_mode_t;\n')
# Use the installed IDF public address types/macros, not a reimplementation.
sdk_header = Path.home() / '.platformio/packages/framework-espidf/components/esp_netif/include/esp_netif_ip_addr.h'
(tmp / 'esp_netif_ip_addr.h').write_text(sdk_header.read_text())
(tmp / 'machine').mkdir()
(tmp / 'machine/endian.h').write_text('#include <endian.h>\n')
(tmp / 'sdkconfig.h').write_text('#define CONFIG_LWIP_IPV6 1\n')
(tmp / 'types.inc').write_text(types + addresses + hostname_request)
console = (ROOT / 'src/wifi_console.c').read_text()
start = console.index('static void print_ipv6_addresses(')
(tmp / 'console.inc').write_text(console[start:console.index('\n}', start) + 2])
assert 'print_ipv6_addresses(&snapshot);' in console
(tmp / 'production.inc').write_text('\n\n'.join(function(name) for name in names))
sanitizer = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else []
subprocess.run(['cc', '-std=gnu17', '-Wall', '-Wextra', '-Werror', *sanitizer,
'-I' + str(tmp), '-I' + str(ROOT / 'src'), str(HERE / 'test.c'),
'-o', str(tmp / 'test')], check=True, timeout=30)
subprocess.run([str(tmp / 'test')], check=True, timeout=20)
# Increasing SDK slots must fail compilation rather than truncate/overrun.
oversized = (HERE / 'test.c').read_text().replace('#define LWIP_IPV6_NUM_ADDRESSES 3', '#define LWIP_IPV6_NUM_ADDRESSES 4')
(tmp / 'oversized.c').write_text(oversized)
result = subprocess.run(['cc', '-std=gnu17', '-I' + str(tmp), '-I' + str(ROOT / 'src'),
'-fsyntax-only', str(tmp / 'oversized.c')], capture_output=True, text=True)
assert result.returncode != 0 and 'IPv6 snapshot capacity' in result.stderr
print('IPv6 slot-capacity compile contract PASS')
header = (ROOT / 'src/wifi_manager.h').read_text()
baseline = header.replace(' uint8_t ipv6_count;\n', '').replace(
' wifi_manager_ipv6_address_t ipv6_addresses[WIFI_MANAGER_IPV6_MAX_ADDRESSES];\n', '')
(tmp / 'baseline.h').write_text(baseline)
(tmp / 'sizes.c').write_text('#include <stdio.h>\n#include "baseline.h"\n'
'int main(void) { printf("baseline snapshot=%zu settings=%zu bytes\\n", '
'sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t)); }\n')
subprocess.run(['cc', '-I' + str(tmp), '-I' + str(ROOT / 'src'), str(tmp / 'sizes.c'),
'-o', str(tmp / 'sizes')], check=True, timeout=30)
subprocess.run([str(tmp / 'sizes')], check=True, timeout=20)
+332
View File
@@ -0,0 +1,332 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include "esp_netif_ip_addr.h"
#define WIFI_AUTH_OPEN 0
#include "wifi_manager.h"
#include "mdns_service.h"
#define LWIP_NETIF_HOSTNAME 1
#define CONFIG_LWIP_IPV6 1
static char s_station_hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
#define LWIP_IPV6_AUTOCONFIG 1
#define LWIP_IPV6_NUM_ADDRESSES 3
#define IP6_ADDR_INVALID 0
#define TENTATIVE 1
#define PREFERRED 2
#define DEPRECATED 3
#define DUPLICATE 4
#define WIFI_MANAGER_ATTEMPT_US 12000000LL
#define WIFI_MANAGER_STABLE_US 30000000LL
#define WIFI_MANAGER_RECONCILE_US 1000000LL
#define WIFI_MANAGER_DISCONNECT_SETTLE_US 1000000LL
#define WIFI_MANAGER_INITIAL_BACKOFF_SECONDS 2
#define ESP_LOGW(...) (++warnings)
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(x) (x)
typedef uint32_t TickType_t;
typedef struct { uint32_t addr; } ip4_t;
typedef struct { ip4_t ip, netmask, gw; } esp_netif_ip_info_t;
typedef struct { uint8_t ssid[33], primary; int8_t rssi; int authmode; } wifi_ap_record_t;
typedef struct { int unused; } esp_netif_t;
static esp_netif_t sta, other;
static esp_netif_t *s_sta_netif = &sta;
typedef const char *esp_event_base_t;
static const char *IP_EVENT = "ip";
enum { IP_EVENT_STA_GOT_IP, IP_EVENT_STA_LOST_IP, IP_EVENT_GOT_IP6 };
typedef struct { esp_netif_t *esp_netif; esp_netif_ip_info_t ip_info; } ip_event_got_ip_t;
typedef struct { esp_netif_t *esp_netif; } ip_event_got_ip6_t;
#include "types.inc"
static manager_shared_t s_shared;
struct netif { int state[3]; bool autoconfig; esp_ip6_addr_t addresses[3]; const char *hostname; };
static struct netif netif;
static bool up, associated, matching, in_tcpip, byte_ssid;
static int64_t now;
static esp_netif_ip_info_t ip4;
static int mdns_starts, mdns_stops, disconnects, next_profiles, cycles, ap_disables;
static int creations, messages, mdns_reconciles, mdns_reannounces, tcpip_calls;
static int shared_locks, warnings;
static esp_err_t tcpip_error, mdns_error, ip_info_error;
static bool https_available, https_registered, tcpip_skip;
static manager_message_t last_message;
static char hostname[64], configured[64];
static esp_err_t hostname_error;
static void lock_shared(void) { assert(shared_locks == 0); ++shared_locks; }
static void unlock_shared(void) { assert(shared_locks == 1); --shared_locks; }
static bool manager_is_started(void) { return s_shared.snapshot.started; }
static void set_state(wifi_manager_state_t state) { s_shared.snapshot.state = state; }
static void set_last_error(esp_err_t error) { s_shared.snapshot.last_error = error; }
static int64_t esp_timer_get_time(void) { return now; }
static bool esp_netif_is_netif_up(esp_netif_t *n) { assert(n == &sta && in_tcpip); return up; }
static esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *out)
{ assert(n == &sta && in_tcpip); *out = ip4; return ip_info_error; }
static void *esp_netif_get_netif_impl(esp_netif_t *n)
{ assert(n == &sta && in_tcpip); return &netif; }
static void netif_set_ip6_autoconfig_enabled(struct netif *n, int enabled)
{ assert(in_tcpip); n->autoconfig = enabled; }
static int netif_ip6_addr_state(struct netif *n, int slot) { return n->state[slot]; }
static void netif_create_ip6_linklocal_address(struct netif *n, int mac)
{ assert(in_tcpip && mac == 1); ++creations; n->state[0] = TENTATIVE; }
static int esp_netif_get_all_preferred_ip6(esp_netif_t *n, esp_ip6_addr_t *out)
{
assert(n == &sta && in_tcpip);
int count = 0;
for (int i = 0; i < 3; ++i) if (netif.state[i] == PREFERRED) out[count++] = netif.addresses[i];
return count;
}
static esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *context)
{ assert(!in_tcpip && shared_locks == 0); ++tcpip_calls;
if (tcpip_error != ESP_OK) return tcpip_error;
if (tcpip_skip) return ESP_OK; /* IDF wrapper ignores failed enqueue. */
in_tcpip = true; esp_err_t e = fn(context); in_tcpip = false; return e; }
static esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *out)
{ memset(out, 0, sizeof(*out)); memcpy(out->ssid, "test", 4);
if (byte_ssid) out->ssid[1] = 0;
return associated ? ESP_OK : ESP_FAIL; }
static bool connected_event_matches_active_profile(const manager_message_t *m)
{ assert(m->data.connected.ssid_len == 4); return matching; }
static void copy_working_config(wifi_app_config_t *out) { *out = s_shared.config; }
void wifi_config_secure_wipe(void *p, size_t n) { memset(p, 0, n); }
esp_err_t mdns_service_start(void) { ++mdns_starts; return mdns_error; }
esp_err_t mdns_service_reconcile(void)
{
assert(!in_tcpip && shared_locks == 0);
++mdns_reconciles;
if (mdns_error == ESP_OK) https_registered = https_available;
return mdns_error;
}
esp_err_t mdns_service_reannounce(void)
{ ++mdns_reannounces; return mdns_service_reconcile(); }
void mdns_service_stop(void) { ++mdns_stops; }
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *out)
{ memset(out, 0, sizeof(*out)); strcpy(out->hostname, configured); return hostname_error; }
static void netif_set_hostname(struct netif *n, const char *name)
{ assert(n == &netif && in_tcpip); n->hostname = name; strcpy(hostname, name); }
static void handle_sta_disconnected(manager_runtime_t *r, const manager_message_t *m)
{ (void)m; r->online = r->associated = false; ++disconnects; }
static void mark_intentional_disconnect(manager_runtime_t *r)
{ ++disconnects; ++r->intentional_disconnects; r->online = r->associated = false; }
static void start_next_profile(manager_runtime_t *r) { (void)r; ++next_profiles; }
static void start_profile_cycle(manager_runtime_t *r) { (void)r; ++cycles; }
static void start_radio_and_policy(manager_runtime_t *r) { (void)r; }
static esp_err_t esp_wifi_disconnect(void) { ++disconnects; return ESP_OK; }
static esp_err_t set_runtime_ap_enabled(manager_runtime_t *r, bool enable)
{ r->ap_enabled = enable; if (!enable) ++ap_disables; return ESP_OK; }
static bool enqueue_message(const manager_message_t *m) { last_message = *m; ++messages; return true; }
#include "production.inc"
static char rendered[2048];
static int capture_printf(const char *format, ...)
{
size_t used = strlen(rendered);
va_list args;
va_start(args, format);
int n = vsnprintf(rendered + used, sizeof(rendered) - used, format, args);
va_end(args);
assert(n >= 0 && (size_t)n < sizeof(rendered) - used);
return n;
}
#define printf capture_printf
#include "console.inc"
#undef printf
static void assert_empty_addresses(void)
{
const wifi_manager_ipv6_address_t zero[WIFI_MANAGER_IPV6_MAX_ADDRESSES] = {0};
assert(s_shared.snapshot.ipv6_count == 0);
assert(!s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
assert(memcmp(zero, s_shared.snapshot.ipv6_addresses, sizeof(zero)) == 0);
}
static manager_runtime_t reset(void)
{
memset(&s_shared, 0, sizeof(s_shared)); memset(&netif, 0, sizeof(netif));
memset(&ip4, 0, sizeof(ip4));
up = associated = matching = true; now = 1000000; byte_ssid = false;
mdns_starts = mdns_stops = disconnects = next_profiles = cycles = ap_disables = 0;
creations = messages = mdns_reconciles = mdns_reannounces = tcpip_calls = 0;
hostname_error = tcpip_error = mdns_error = ip_info_error = ESP_OK;
warnings = 0;
https_available = https_registered = tcpip_skip = false;
strcpy(configured, "sak-test");
s_shared.snapshot.started = true;
s_shared.snapshot.sta_ssid_len = 4;
s_shared.config.ap_policy = WIFI_CONFIG_AP_POLICY_FALLBACK;
manager_runtime_t r = { .radio_started = true, .attempt_deadline = now + 12000000 };
return r;
}
static void address(int slot, uint8_t first, uint8_t second, int state)
{
memset(&netif.addresses[slot], 0, sizeof(netif.addresses[slot]));
uint8_t *bytes = (uint8_t *)netif.addresses[slot].addr;
bytes[0] = first; bytes[1] = second; bytes[15] = 1;
netif.state[slot] = state;
}
int main(void)
{
manager_runtime_t r = reset();
strcpy(configured, "sak-first"); assert(refresh_station_hostname() == ESP_OK);
assert(!strcmp(hostname, "sak-first"));
strcpy(configured, "sak-renamed"); assert(refresh_station_hostname() == ESP_OK);
assert(!strcmp(hostname, "sak-renamed") && disconnects == 0);
memset(configured, 'a', 59); memcpy(configured, "sak-", 4); configured[59] = 0;
assert(refresh_station_hostname() == ESP_OK && strlen(hostname) == 59);
assert(netif.hostname == s_station_hostname); /* No captured stack pointer. */
assert(!strcmp(netif.hostname, configured));
hostname_error = ESP_FAIL; assert(refresh_station_hostname() == ESP_FAIL);
r = reset(); byte_ssid = true; ip4.ip.addr = 7;
handle_got_ip(&r, NULL); assert(r.online); /* Embedded NUL keeps profile length. */
r = reset(); handle_got_ip(&r, NULL);
assert(netif.autoconfig && creations == 1 && !r.online);
handle_got_ip(&r, NULL); assert(creations == 1); /* Do not restart DAD. */
address(0, 0xfe, 0x80, PREFERRED); now = r.attempt_deadline;
handle_expired_deadlines(&r); /* dropped CONNECTED and GOT_IP6 */
assert(r.online && r.attempt_deadline == 0 && disconnects == 0);
assert(s_shared.snapshot.ipv6_linklocal && s_shared.snapshot.ip == 0);
assert(mdns_starts == 1);
int64_t stable = r.stable_deadline;
now += 1000000; handle_expired_deadlines(&r);
assert(r.stable_deadline == stable && mdns_starts == 1);
now = stable; handle_expired_deadlines(&r); assert(ap_disables == 1);
assert(runtime_wait_ticks(&r) <= 1000); /* Never sleep indefinitely online. */
address(1, 0xfd, 0x12, PREFERRED); handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ipv6_routable);
ip4.ip.addr = 123; handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ip == 123 && s_shared.snapshot.counters.got_ip == 1);
ip4.ip.addr = 0; handle_got_ip(&r, NULL);
assert(r.online && !s_shared.snapshot.ip && mdns_stops == 0);
address(0, 0xfe, 0x80, DEPRECATED); address(1, 0xfd, 0x12, DEPRECATED);
now += 1000000; handle_expired_deadlines(&r);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && !s_shared.snapshot.ipv6_routable);
assert(mdns_stops == 1 && r.attempt_deadline > now);
address(1, 0x20, 0x01, PREFERRED); handle_got_ip(&r, NULL);
assert(r.online && s_shared.snapshot.ipv6_routable && mdns_starts == 2);
r = reset(); address(0, 0xfe, 0x80, DUPLICATE);
now = r.attempt_deadline; handle_expired_deadlines(&r);
assert(!r.online && creations == 0 && disconnects == 1);
assert(r.advance_after_disconnect && r.disconnect_deadline > now);
r = reset(); address(0, 0xfe, 0x80, TENTATIVE); ip4.ip.addr = 7;
handle_got_ip(&r, NULL); assert(r.online && !s_shared.snapshot.ipv6_linklocal);
ip4.ip.addr = 0; associated = false;
now += 1000000; handle_expired_deadlines(&r); assert(disconnects == 1 && !r.online);
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
r.stop_pending = true; handle_got_ip(&r, NULL); assert(!r.online);
r.stop_pending = false; r.advance_after_disconnect = true;
handle_got_ip(&r, NULL); assert(!r.online);
r.advance_after_disconnect = false; matching = false;
handle_got_ip(&r, NULL); assert(!r.online);
matching = true; up = false; handle_got_ip(&r, NULL); assert(!r.online);
up = true; handle_got_ip(&r, NULL); assert(r.online);
netif.state[0] = IP6_ADDR_INVALID; /* missed disconnect+connect: re-create LL */
handle_got_ip(&r, NULL); assert(creations == 1 && !r.online);
ip_event_got_ip6_t event6 = { .esp_netif = &other };
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6); assert(messages == 0);
event6.esp_netif = &sta;
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, &event6);
assert(messages == 1 && last_message.type == MESSAGE_STA_GOT_IP6);
ip_event_got_ip_t event4 = { .esp_netif = &other };
ip_event_callback(NULL, IP_EVENT, IP_EVENT_STA_GOT_IP, &event4); assert(messages == 1);
ip_event_callback(NULL, IP_EVENT, IP_EVENT_GOT_IP6, NULL); assert(messages == 1);
r = reset(); mdns_error = ESP_FAIL;
start_mdns_announcement(&r); start_mdns_announcement(&r);
assert(warnings == 1 && r.mdns_reannounce_pending);
for (int i = 0; i < 5; ++i) {
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
}
assert(warnings == 1); /* No per-pass warning on persistent failure. */
r = reset(); r.radio_started = false; r.attempt_deadline = 0;
s_shared.snapshot.started = false; associated = up = false;
assert(runtime_wait_ticks(&r) == 0); /* Boot with Wi-Fi disabled. */
https_registered = true;
handle_expired_deadlines(&r);
assert(mdns_reconciles == 1 && !https_registered && creations == 0);
assert(runtime_wait_ticks(&r) == 1000);
for (int i = 0; i < 100; ++i) handle_expired_deadlines(&r);
assert(mdns_reconciles == 1); /* Busy owner queue cannot cause retry spam. */
mdns_error = ESP_FAIL; https_available = true;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(mdns_reconciles == 2 && !https_registered);
mdns_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(mdns_reconciles == 3 && https_registered && !r.radio_started);
/* Failed offline rename is retried without a lifecycle command. */
tcpip_error = ESP_FAIL; strcpy(configured, "sak-retry");
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(strcmp(hostname, configured) != 0);
tcpip_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(!strcmp(hostname, configured) && netif.hostname == s_station_hostname);
assert(disconnects == 0 && !r.radio_started);
r = reset(); address(0, 0xfe, 0x80, PREFERRED);
handle_got_ip(&r, NULL); assert(r.online);
r.mdns_reannounce_pending = true; mdns_error = ESP_FAIL;
handle_expired_deadlines(&r);
assert(r.mdns_reannounce_pending && mdns_reannounces == 1);
mdns_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(!r.mdns_reannounce_pending && mdns_reannounces == 2);
tcpip_error = ESP_FAIL; now = r.stable_deadline;
handle_expired_deadlines(&r);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal && ap_disables == 0);
assert(mdns_stops == 1 && r.attempt_deadline > now);
tcpip_error = ESP_OK;
now += WIFI_MANAGER_RECONCILE_US; handle_expired_deadlines(&r);
assert(r.online && s_shared.snapshot.ipv6_linklocal && disconnects == 0);
tcpip_skip = true;
assert(refresh_station_hostname() == ESP_FAIL);
handle_got_ip(&r, NULL);
assert(!r.online && s_shared.snapshot.last_error == ESP_FAIL);
tcpip_skip = false; handle_got_ip(&r, NULL); assert(r.online);
/* Netif-down with a stale driver association must withdraw readiness. */
up = false; handle_got_ip(&r, NULL);
assert(!r.online && !s_shared.snapshot.ipv6_linklocal);
assert_empty_addresses();
r = reset();
address(0, 0xfe, 0x80, PREFERRED);
address(1, 0xfd, 0x12, PREFERRED);
address(2, 0x20, 0x01, PREFERRED);
netif.addresses[2].addr[1] = esp_netif_htonl(0x12345678);
netif.addresses[2].addr[2] = esp_netif_htonl(0x9abcdef0);
netif.addresses[2].zone = 42;
handle_got_ip(&r, NULL);
assert(s_shared.snapshot.ipv6_count == 3);
for (int i = 0; i < 3; ++i)
assert(memcmp(s_shared.snapshot.ipv6_addresses[i].addr, netif.addresses[i].addr, 16) == 0);
print_ipv6_addresses(&s_shared.snapshot);
assert(strstr(rendered, "IPv6 preferred link-local: fe80:0000:0000:0000:0000:0000:0000:0001\n"));
assert(strstr(rendered, "IPv6 preferred ULA: fd12:0000:0000:0000:0000:0000:0000:0001\n"));
assert(strstr(rendered, "IPv6 preferred GUA: 2001:0000:1234:5678:9abc:def0:0000:0001\n"));
assert(strstr(rendered, "client's interface") && !strstr(rendered, "%42"));
netif.state[0] = TENTATIVE; netif.state[1] = DUPLICATE; netif.state[2] = DEPRECATED;
handle_got_ip(&r, NULL); assert_empty_addresses();
rendered[0] = 0; print_ipv6_addresses(&s_shared.snapshot);
assert(strstr(rendered, "IPv6 preferred addresses: none") && !strstr(rendered, "IPv6 preferred GUA:"));
for (int failure = 0; failure < 5; ++failure) {
address(0, 0xfe, 0x80, PREFERRED); address(1, 0xfd, 0x12, PREFERRED);
handle_got_ip(&r, NULL); assert(s_shared.snapshot.ipv6_count == 2);
if (failure == 0) tcpip_error = ESP_FAIL;
if (failure == 1) tcpip_skip = true;
if (failure == 2) up = false;
if (failure == 4) ip_info_error = ESP_FAIL;
if (failure == 3) clear_station_network_snapshot();
else handle_got_ip(&r, NULL);
assert_empty_addresses();
tcpip_error = ip_info_error = ESP_OK; tcpip_skip = false; up = true;
}
printf("snapshot=%zu settings=%zu shared=%zu observation=%zu bytes\n",
sizeof(wifi_manager_snapshot_t), sizeof(wifi_manager_settings_t),
sizeof(manager_shared_t), sizeof(station_addresses_t));
puts("wifi_phase12: hostname, IPv4/IPv6 lifecycle, deadlines, offline mDNS retries, TCP/IP ownership and event filtering PASS");
}