Add Dual-Stack Network Diagnostics

This commit is contained in:
2026-09-21 10:01:55 +02:00
parent 60c1e279d6
commit 06df47c934
18 changed files with 1488 additions and 270 deletions
+160
View File
@@ -0,0 +1,160 @@
# Shared IPv4/IPv6 diagnostics fixtures and integration handoff
## Commands
```text
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
```sh
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.