Files
ESP32_Serial_Swiss_Army_Knife/tests/network_diagnostics/README.md
T

10 KiB

Shared IPv4/IPv6 diagnostics fixtures and integration handoff

Commands

ping [-4|-6] <host> [count]              count 1..20, default 4
traceroute [-4|-6] <host> [max-hops]     hops 1..30, default 16
nslookup [-4|-6] <host>

One family flag may occur anywhere after the command, including after the host or numeric argument. Repeated/conflicting flags, unknown options, empty arguments, extra arguments, and non-decimal/out-of-range counts are rejected. Existing bare commands and aliases calling network_console_execute() share these semantics. For example: ping fe80::1%sta -6 3, traceroute example.net 20 -6.

Default hostname probes resolve IPv4 first, then IPv6 only when resolution returns no address (EAI_FAIL/EAI_NONAME). They never retry another family after a send failure, timeout, or unreachable reply. This is not Happy Eyeballs. Explicit -6 neither requests nor accepts IPv4/mapped IPv4. Default hostname nslookup makes separate A and AAAA queries, prints partial success, and explains resolver limits. Numeric literals do not query DNS or perform reverse lookup.

Zones refer to interfaces on the ESP32, not on the browser/SSH client: %sta, %ap, a currently valid numeric lwIP index (1..255), or an existing lwIP interface name. Ping/traceroute reject unscoped link-local addresses (including DNS AAAA answers with zone zero), showing the address and scoped-literal examples; no default interface is guessed. nslookup displays those records and unscoped link-local literals with a note that probes require an explicit device scope. Mapped IPv4 literals remain rejected. Scoped ping preserves sin6_scope_id as the ip6_addr_t zone and sets esp_ping_config_t.interface; scoped traceroute retains the socket zone and binds the raw socket to that interface. Numeric output includes the zone.

Run

python3 tests/network_diagnostics/run.py
python3 tests/network_diagnostics/run.py --sanitize  # optional installed ASan/UBSan
python3 tests/network_diagnostics/sdk_contract.py /path/to/framework-espidf

run.py includes the actual production src/network_console.c, not a duplicate parser/implementation. SDK calls and socket operations are deterministic fakes; there is no networking or hardware access. It checks argument permutations and bounds, per-family resolution/fallback/freeing, A+AAAA queries, scope parsing and ping zone/interface conversion, valid echo/time-exceeded/unreachable packets, all truncation lengths, bad checksums/IDs/sequences/targets/codes/protocols, unsupported IPv6 extension headers, recv source correlation, absolute deadlines, 1/500/999/1000-us remaining-budget boundaries and unrelated traffic leaving 500 us, zero-zone link-local AAAA display versus rejection before any probe socket/session, EINTR, all configured socket-option failure points, send/receive/create errors, fd-zero cleanup, PSRAM allocation failure, and ping create/start/delayed-completion lifetime handling. Also runs 10,000 deterministic malformed packet inputs.

Audited IDF 5.5.0 / lwIP contract

sdk_contract.py pins seven installed source files, including the FreeRTOS mailbox port. It also extracts and compiles the actual SDK timeval conversion macro and sys_arch_mbox_fetch() with a fake queue boundary, checking the infinite-wait sentinel for sub-millisecond values and finite waits for >=1 ms at 1/10-ms ticks. The production module also has an IDF version guard; upgrades require re-audit.

  • api/netdb.c: AF_INET and AF_INET6 choose explicit DNS address types. Current generated CONFIG_LWIP_DNS_MAX_HOST_IP=1 means one returned address per query; this command prints one selected address per family, not a complete RRset. DNS errors collapse into EAI_FAIL; NXDOMAIN, timeout, and server failure cannot reliably be distinguished. Resolution itself uses SDK DNS timeouts, not a command-owned deadline. .local depends on lwIP mDNS-query support, not client NSS.
  • core/ipv6/ip6.c restores the complete IPv6 header before raw_input(); api/api_msg.c:recv_raw() copies it. Raw receives are not Linux's ICMPv6-only framing. Raw delivery precedes ICMP checksum validation, so traceroute checks the complete outer ICMP checksum (including IPv6 pseudoheader) itself.
  • core/raw.c demultiplexes the IPv6 base next-header. Therefore traceroute explicitly rejects all outer/quoted IPv6 extension headers, including fragment, routing, hop-by-hop, destination options, AH and ESP. It does not walk or guess unsupported forms. Quoted packets may be incomplete after the required echo header, but the complete outer packet must fit/be received and pass its checksum.
  • api/api_msg.c enables checksum generation at offset 2 for raw ICMPv6 sockets. api/sockets.c refuses changing IPV6_CHECKSUM for ICMPv6; do not set it. IPPROTO_IP/IP_TTL sets the common PCB TTL used for both IPv4 TTL and IPv6 hop limit. IPV6_UNICAST_HOPS is not implemented. Fixtures require the actual supported option for both families. IPv6 traceroute also sets IPV6_V6ONLY. SDK ping's IPPROTO_IP/IP_TTL use is likewise correct for IPv6; it is unchanged.
  • api/sockets.c floors SO_RCVTIMEO timeval values to milliseconds; port/freertos/sys_arch.c:sys_arch_mbox_fetch() interprets zero milliseconds as portMAX_DELAY, not a poll. Traceroute stops when <1000 us remain, never supplying a zero-millisecond receive timeout.
  • apps/ping/ping_sock.c does not copy the target zone into sin6_scope_id; config.interface uses SO_BINDTODEVICE and is required for scoped ping. SDK ping is not the strict traceroute parser: its receive validation and IPv6 profile formatting remain upstream behavior (reply profiles do not retain the interface zone). Do not infer traceroute's packet correlation guarantees for SDK ping.

Bounds and remaining SDK limitation

Traceroute owns one raw socket in the dispatcher, sends one eight-byte probe per hop, and has a one-second absolute receive deadline per hop (unrelated packets and EINTR cannot extend it). It may stop up to 999 us early to avoid lwIP's zero-millisecond infinite-wait sentinel. Every post-creation exit closes it. The 1280-byte receive buffer rejects larger/truncated outer replies rather than partially matching them. DNS, scheduler and synchronous send latency are not included in that deadline. No extra task, serial hot-path work, or persistent trace allocation.

Ping keeps the existing 21-event, 4200-byte PSRAM-only, lazily retained queue payload and SDK's transient task. Count <=20, timeout=1000ms, interval=1000ms. The console wait has an absolute count * 2000 + 2000 ms budget after start. Callback context is now permanent (not a caller-stack pointer); if that deadline expires, the callback queue remains reserved until its END event. Until then, later ping calls fail busy rather than overlapping callback-producing sessions or resetting their queue. END ends callback production, not SDK task/socket retirement: esp_ping_delete_session() only requests asynchronous deletion. A new command may start while the previous task/socket is briefly retiring; this is not a guarantee that two SDK tasks/sockets can never coexist.

Hard ping socket-lifetime bounds are not available from this SDK API: unrelated raw ICMP traffic can repeatedly refresh esp_ping_receive()'s receive timeout; stop/delete are asynchronous and cannot interrupt that loop. The timeout path must not touch a potentially concurrently deleted handle. The retained reservation is intentional failure isolation, not proof of synchronous cleanup. A strict hard socket deadline would require replacing SDK ping with a dispatcher-owned raw ping implementation or an SDK cancellation helper with an acknowledged lifetime contract. No dependency/SDK patch was made. This limitation is for the parent to assess; ordinary SDK completion still self-deletes and releases its transient task.

Validation and measured impact (2026-09-21)

  • Host production fixtures: 8027 checks + 10,000 fuzz inputs PASS.
  • Installed seven-file SDK hash contract and actual timeout conversion/mailbox fixtures: PASS.
  • Actual target compiler: syntax-only PASS after the review fixes. Earlier isolated baseline/current object compiles used the existing network_console.c compilation arguments. No pio build, project build artifact writes, upload, monitor, or hardware test.
  • Pre-review object measurement (not remeasured after the timeout/scope fixes): text: 7281 -> 9917 bytes (+2636); bss: 92 -> 97 (+5); data: unchanged 0. These are object measurements, not final linked firmware.
  • Pre-review target -fstack-usage frames: execute_ping 400 -> 464; execute_nslookup 128 -> 144; execute_traceroute 224 -> 256; wait_for_trace_reply 240 -> 1408. These individual frames are not a measured runtime stack high-water mark.
  • ASan/UBSan attempt could not link: host libasan.so.8.0.0 and libubsan.so.1.0.0 are missing. Ordinary -Wall -Wextra -Werror fixtures passed.

Integrated surface and build validation

Wi-Fi help and root/alias completion use the syntax above. The production console boundary is covered by python3 tests/network_diagnostics_surfaces/run.py: 432 SSH/browser root-and-alias cases, UART0 parity, role/revocation guards, and fixed-token completion. All surfaces forward to the same canonical handler.

No new ESP-NETIF ownership helper is needed: public esp_netif_get_handle_from_ifkey() / esp_netif_get_netif_impl_index() map aliases. IPv4+IPv6, raw sockets and IPv6 scopes must remain enabled. The installed configuration supplies these; no configuration/dependency changes were made.

Final pio run passed: 94,452 B linked RAM / 1,857,673 B flash (+8 B / +3,188 B versus the recorded pre-follow-up build). This is not runtime heap or stack headroom. Host fixtures, exact SDK contract, surface tests and existing admin policy/Wi-Fi prompt regressions passed. No upload, packet capture, live DNS, multi-hop trace or hardware validation was performed. The prior Phase12 acceptance must not be treated as acceptance of this later diagnostics change.