ftr - Fast TraceRoute

IPv6 Support: Design and Validated Findings

Design basis for adding IPv6 traceroute support to ftr (issue #22). Every claim in the “Validated findings” section was observed by running the spike programs in examples/spike_*.rs on real hardware — output below is pasted verbatim, not paraphrased. The spikes stay in the repo as permanent diagnostics: re-run them whenever kernel, library, or network behavior is in question.

Validation environments: macOS 26.5.1 (build 25F80), Apple Silicon (arm64), rustc 1.97.0, socket2 0.6, dual-stack network with live public IPv6 (Sonic fiber); Linux — Ubuntu 24.04.4 LTS, kernel 6.8.0-124-generic (x86_64), glibc 2.39, rustc 1.97.1, same socket2, native IPv6 on the same Sonic fiber (see Validated findings (Linux)); Windows — Windows 11 ARM64 (build 26100), rustc 1.88.0, bridged network with native IPv6 on the same Sonic fiber (see Validated findings (Windows)). FreeBSD 14.3 and OpenBSD 7.7 were partially validated live on 2026-07-18 (external-v6 reachability, the v0.9.0 release build — including OpenBSD’s first-ever compile — and the non-root permission path; the root multi-hop trace is still pending). See Validated findings (FreeBSD / OpenBSD) and Open questions.

Run all spikes with:

cargo run --example spike_icmpv6_socket
cargo run --example spike_traceroute6
cargo run --example spike_linux_v6     # Linux-only sections; stub main elsewhere
cargo run --example spike_stun6
cargo run --example spike_asn6
cargo run --example spike_windows_v6   # Windows-only; stub main elsewhere

Validated findings (macOS)

spike_icmpv6_socket — socket basics

Question Verdict
Domain::IPV6/Type::DGRAM/Protocol::ICMPV6 without root Works (euid 501)
Type::RAW/Protocol::ICMPV6 without root EPERM (os error 1) — needs root
Kernel computes ICMPv6 checksum on DGRAM send Yes — echo sent with zeroed checksum got a reply
Received buffer starts at ICMPv6 header (no IPv6 header prepended) Yes — first byte is 129, not a 0x6X version nibble
Kernel demuxes DGRAM replies by ICMP identifier NO — every ICMPv6 DGRAM socket receives ALL ICMPv6 traffic

Observed output (2026-07-09):

[1] Socket::new(IPV6, RAW, ICMPV6):
    FAILED: Operation not permitted (os error 1) (os error Some(1)) — raw needs root, as expected

[2] Socket::new(IPV6, DGRAM, ICMPV6):
    OK — unprivileged DGRAM ICMPv6 socket opened

[3] Echo Request to 2001:4860:4860::8888 with ZEROED checksum (id=0x1234 seq=1):
    sent 22 bytes
    received 22 bytes from Some(2001:4860:4860::8888)
    first byte = 129 (0x81) — ICMPv6 Echo Reply: buffer starts at ICMPv6 header, NO IPv6 header prepended
    checksum=0xddc0 identifier=0x1234 sequence=1
    => reply came back despite zero checksum on send: kernel computed the ICMPv6 checksum

[4] Demux test: two DGRAM sockets, ids 0x1111 and 0x2222:
    socket A (sent id=0x1111), draining:
      got type=129 id=0x1111 seq=7 (own)
      got type=129 id=0x2222 seq=8 (FOREIGN)
      => own reply: true, foreign reply: true — kernel does NOT demux by identifier; userspace must filter
    socket B (sent id=0x2222), draining:
      got type=129 id=0x1111 seq=7 (FOREIGN)
      got type=129 id=0x2222 seq=8 (own)
      => own reply: true, foreign reply: true — kernel does NOT demux by identifier; userspace must filter

This is the key surprise. SwiftFTR observed that Darwin demuxes IPv4 DGRAM ICMP by echo identifier; for ICMPv6 it does not. A DGRAM ICMPv6 socket on Darwin behaves like an unprivileged raw socket: it sees every inbound ICMPv6 packet, including other sockets’ echo replies and link NDP chatter. During a busier run, unsolicited NDP/RA noise arrived interleaved with the replies:

      got type=134 id=0x40c8 seq=1800 (FOREIGN)   <- Router Advertisement
      got type=135 id=0x0000 seq=0 (FOREIGN)      <- Neighbor Solicitation
      got type=136 id=0xc000 seq=0 (FOREIGN)      <- Neighbor Advertisement

(The “id/seq” on types 134-136 is just the echo-header interpretation of unrelated bytes.)

spike_traceroute6 — hop-limited probes and Time Exceeded

The make-or-break question: an unprivileged DGRAM ICMPv6 socket on macOS DOES receive ICMPv6 Time Exceeded on its normal receive path. No errqueue, no raw socket, no root required. Full validated list:

Question Verdict
IPV6_UNICAST_HOPS settable and read back on DGRAM Works (set 3, read 3)
Time Exceeded (type 3) delivered to DGRAM socket Works — from the hop-3 router
TE payload = invoking IPv6 header (40 B) + invoking ICMPv6 echo header Confirmed; embedded id/seq match the probe
IPV6_RECVHOPLIMIT + recvmsg cmsg (IPV6_HOPLIMIT) Works — reply hop limit 62 read from ancillary data
ICMP6_FILTER via raw setsockopt (level IPPROTO_ICMPV6, optname 18) Works; BSD semantics, bit set = PASS
RAW ICMPv6 comparison Needs root — open item, see below

Observed output (2026-07-09):

[1] hop-limited probe on SOCK_DGRAM ICMPv6 socket:
    IPV6_UNICAST_HOPS readback: 3
    probe sent (hop_limit=3, id=0x3333, seq=1)
    got TIME EXCEEDED from Some(2001:5a8:5:403f::f0:2) (reply hop_limit cmsg: Some(62))
      embedded IPv6: version=6 next_header=58 hop_limit=1 src=2001:5a8:4684:c00:41b1:1c86:aee8:e97 dst=2001:4860:4860::8888
      embedded ICMPv6: type=128 code=0 identifier=0x3333 sequence=1
      => embedded id/seq MATCH our probe

    VERDICT: DGRAM ICMPv6 socket CAN receive Time Exceeded on macOS

[3] ICMP6_FILTER via raw setsockopt (socket2 has no API):
    phase A: filter passes ONLY Echo Reply (129); expect NO Time Exceeded:
    setsockopt(ICMP6_FILTER) OK
    probe sent (hop_limit=3, id=0x4444, seq=1)
    => Time Exceeded delivered with it filtered out: false (false means the filter blocks as intended)
    phase B: filter passes 1/3/129; expect Time Exceeded again:
    setsockopt(ICMP6_FILTER) OK
    probe sent (hop_limit=3, id=0x4444, seq=2)
    got TIME EXCEEDED from Some(2001:5a8:5:403f::f1:2) (reply hop_limit cmsg: Some(62))
      embedded ICMPv6: type=128 code=0 identifier=0x4444 sequence=2
      => embedded id/seq MATCH our probe
    => Time Exceeded delivered with filter passing type 3: true

Notes:

spike_stun6 — public IPv6 via STUN

Works. Both Google and Cloudflare STUN answered over UDPv6 and the XOR-MAPPED-ADDRESS (family 0x02) un-XORed to the same address, which exactly matches an independent HTTPS check (curl -6 -s https://api64.ipify.org → 2001:5a8:4684:c00:41b1:1c86:aee8:e97, same day, same machine).

[stun.l.google.com:19302]
    resolved to [2001:4860:4864:5:8000::1]:19302
    received 44 bytes from [2001:4860:4864:5:8000::1]:19302
    public IPv6: 2001:5a8:4684:c00:41b1:1c86:aee8:e97 (mapped port 52410)

[stun.cloudflare.com:3478]
    resolved to [2606:4700:49::]:3478
    received 44 bytes from [2606:4700:49::]:3478
    public IPv6: 2001:5a8:4684:c00:41b1:1c86:aee8:e97 (mapped port 50733)

VERDICT: public IPv6 = 2001:5a8:4684:c00:41b1:1c86:aee8:e97 (servers consistent: true)

Implementation notes carried into stage 6: v6 XOR recovery per RFC 5389 §15.2 — port XORed with the top 16 bits of the magic cookie; the 16 address bytes XORed with (magic cookie || transaction ID). Note there is no v6 NAT here: STUN returns the host’s own global address, so “public IP” for v6 usually equals the source address (still worth confirming via STUN to detect NAT66/NPTv6 edge cases).

spike_asn6 — Team Cymru origin6 lookup

Works. Nibble-reversed name + .origin6.asn.cymru.com TXT over plain UDP DNS (same wire format as src/dns/resolver.rs) returns the expected origin ASN:

query target: 2001:4860:4860::8888
origin6 name: 8.8.8.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.6.8.4.0.6.8.4.1.0.0.2.origin6.asn.cymru.com
received 163-byte DNS response
TXT: "15169 | 2001:4860::/32 | US | arin | 2005-03-14"
  parsed: ASN=15169 prefix=2001:4860::/32 country=US registry=arin allocated=2005-03-14
  => AS15169 (Google) as expected — origin6 lookup works

TXT payload format is identical to the v4 origin.asn.cymru.com zone (AS | prefix | CC | registry | allocated), so the existing parser generalizes; only the query-name construction differs (32 reversed nibbles vs 4 reversed octets).

Validated findings (Linux)

All from examples/spike_linux_v6.rs on trogdor: Ubuntu 24.04.4 LTS, kernel 6.8.0-124-generic (x86_64), glibc 2.39, rustc 1.97.1, run 2026-07-16. The host’s net.ipv4.ping_group_range is the kernel default 1 0 — an empty range, so ping sockets are disabled for every gid (the system ping works via file capabilities instead). Because widening it needs root, the ping-socket sections were validated in a Docker container on the same kernel with the netns-scoped sysctl widened (docker run --sysctl net.ipv4.ping_group_range="0 2147483647" ... on a docker network create --ipv6 network, NAT66 to real Internet routers). ping_group_range is per-network-namespace, so this exercises exactly the kernel code paths in question. The UDP sections ran directly on the host, fully unprivileged.

Ping sockets (SOCK_DGRAM, IPPROTO_ICMPV6)

Question Verdict
Available unprivileged Only if ping_group_range covers your gid — default 1 0 yields EACCES (os error 13); note the ipv4-named sysctl gates ICMPv6 ping sockets too
Kernel rewrites the echo identifier YES — sent id 0x1234, wire/reply id is the kernel-assigned per-socket ident (= getsockname port)
Kernel demuxes replies per-socket by ident YES — two concurrent sockets each saw only their own reply (opposite of Darwin, which floods every DGRAM ICMPv6 socket)
Received buffer starts at ICMPv6 header Yes — first byte 129, same as Darwin
Kernel computes checksum on send Yes — zero-checksum sends got replies
IPV6_RECVHOPLIMIT cmsg on normal path Works (reply hop limit 108 from Google DNS)

Observed output (host, unprivileged, default sysctl):

[1] unprivileged socket availability:
    RAW ICMPv6: Operation not permitted (os error 1) (os error Some(1))
    DGRAM ICMPv6 (ping socket): Permission denied (os error 13) (os error Some(13))

Observed output (container, same kernel, sysctl widened, euid=1000):

[2] echo identifier rewrite on ping socket:
    sent echo claiming id=0x1234 seq=1
    getsockname port (= kernel-assigned icmp ident): Some("0x0001")
    received 26 bytes from Some(2001:4860:4860::8888): type=129 id=0x0001 seq=1
    first byte = 129 — buffer starts at ICMPv6 header (no IPv6 header prepended)
    => kernel REWROTE the identifier (we sent 0x1234, reply carries 0x0001)

[3] demux test: two ping sockets, distinct seq markers:
    socket A ident=Some("0x0002") sent seq=41; socket B ident=Some("0x0003") sent seq=42
    socket A: got reply id=0x0002 seq=41
    socket A: own reply: true, foreign reply: false
    socket B: got reply id=0x0003 seq=42
    socket B: own reply: true, foreign reply: false

Design consequence: on Linux ping sockets the session cannot choose its own identifier — read it back via getsockname if it is ever reported, and match probes by sequence number only (safe because the kernel already isolates sockets by ident). The shared demux layer must support both models: Darwin = choose id, filter everything in userspace; Linux ping = kernel assigns id, per-socket isolation.

Time Exceeded delivery to ping sockets — errqueue ONLY

Question Verdict
TE on the normal receive path NO — nothing delivered within 3 s
TE via IPV6_RECVERR + MSG_ERRQUEUE YES — sock_extended_err with ee_origin=3 (SO_EE_ORIGIN_ICMP6), ee_type=3, ee_code=0, ee_errno=113 (EHOSTUNREACH)
Offender (router) address Works — SO_EE_OFFENDER sockaddr follows the sock_extended_err, exactly like ftr’s v4 src/socket/linux.rs
TE with IPV6_RECVERR off Dropped entirely — normal path silent and errqueue empty
ICMP error packet’s hop limit Works — IPV6_HOPLIMIT cmsg is attached to the errqueue message when IPV6_RECVHOPLIMIT is on
[4] Time Exceeded delivery to ping socket (hop_limit=3):
    phase A: plain ping socket, NO IPV6_RECVERR:
    normal path: NOTHING within 3s
    errqueue (RECVERR off): empty, as expected
    phase B: ping socket WITH IPV6_RECVERR + IPV6_RECVHOPLIMIT:
    errqueue: ee_errno=113 (No route to host (os error 113)) ee_origin=3 ee_type=3 ee_code=0 offender=Some(2001:5a8:657:21::f0:4) hoplimit_cmsg=Some(62)
    => Time Exceeded ARRIVES on the error queue (origin_icmp6=true)
    normal path: silent (error went only to the errqueue)

So the Linux v6 receive path is errqueue-based regardless of probe type (ICMP ping socket or UDP) — a clean mirror of ftr’s existing v4 Linux UDP mode, and structurally different from Darwin (normal receive path).

ICMP6_FILTER — raw sockets only; semantics INVERTED vs BSD

Question Verdict
Optname value 1 (/usr/include/linux/icmpv6.h:150 ICMPV6_FILTER, glibc netinet/icmp6.h:26 ICMP6_FILTER) — Darwin uses 18
Works on ping sockets NO — setsockopt fails with ENOPROTOOPT (“Protocol not available”, os error 92)
Bit semantics INVERTED vs BSD, empirically confirmed on a raw socket: bit SET = BLOCK (glibc ICMP6_FILTER_SETPASSALL = memset 0, SETBLOCKALL = memset 0xFF, netinet/icmp6.h:89-105)
[5] ICMP6_FILTER (optname 1, Linux bit=BLOCK semantics):
    phase A: filter BLOCKS Echo Reply (bit 129 set); expect NO reply:
    setsockopt(ICMPV6_FILTER) FAILED: Protocol not available (os error 92) — filter unusable here

[7] RAW ICMPv6 socket tests:  (container root = CAP_NET_RAW)
    c: ICMP6_FILTER positive control on the raw socket:
    phase A: pass-all + BLOCK Time Exceeded (bit 3 set); expect NO TE:
    setsockopt(ICMPV6_FILTER) OK
    => TE delivered while bit 3 SET: false (false = bit means BLOCK)
    phase B: pass-all (all bits clear); expect the TE again:
    got TIME EXCEEDED from Some(2001:5a8:657:21::f0:4) embedded id=0x7777 seq=4 (reply hoplimit cmsg: Some(62))
    => TE delivered with all bits CLEAR: true (true = clear means PASS)
    => positive control PASSED: Linux ICMP6_FILTER semantics are INVERTED vs BSD (bit set = BLOCK)

Two-phase positive control as on macOS: the same probe yields no Time Exceeded with bit 3 set and yields it with the filter cleared, so the filter (not luck) caused the difference. Design consequence: the planned ICMP6_FILTER noise-shedding optimization applies to Darwin (DGRAM) and raw sockets, but NOT to Linux ping sockets — which don’t need it anyway, since the kernel already delivers only the socket’s own echo replies.

Raw ICMPv6 (root / CAP_NET_RAW)

Validated as root inside the container (same kernel). Kernel computes the checksum on raw ICMPv6 sends too (zero-checksum echo got a reply), the raw receive buffer also starts at the ICMPv6 header, and — unlike ping sockets — Time Exceeded arrives on the raw socket’s normal receive path, no errqueue needed:

[7] RAW ICMPv6 socket tests:
    a: zero-checksum echo (does the kernel checksum raw v6 sends?):
    got ECHO REPLY id=0x7777 seq=1 from Some(2001:4860:4860::8888) — raw buffer also starts at the ICMPv6 header
    => reply to zero-checksum raw send: true (true = kernel computes ICMPv6 checksums on raw too)
    b: hop-limited probe — Time Exceeded on NORMAL receive path?
    got TIME EXCEEDED from Some(2001:5a8:657:21::f0:4) embedded id=0x7777 seq=2 (reply hoplimit cmsg: Some(62))
    => RAW socket receives Time Exceeded on the normal path (no errqueue needed)

UDP6 + IPV6_RECVERR — unprivileged Linux v6 traceroute WORKS

The make-or-break result. Plain unprivileged UDP sockets with IPV6_UNICAST_HOPS per hop and IPV6_RECVERR, read via MSG_ERRQUEUE — identical structure to ftr’s v4 Linux UDP mode — produce a complete real traceroute, run directly on the host with the default (ping-socket-disabled) sysctl, euid 1000:

[6] UDP6 + IPV6_RECVERR traceroute to 2001:4860:4860::8888 (ports 33434+hop, unprivileged):
    hop  1: 2001:5a8:4681:2c00::1 ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(64) rtt=10.2ms
    hop  2: 2001:5a8:657:21::f0:4 ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(63) rtt=10.2ms
    hop  3: 2001:5a8:5:403f::f1:2 ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(62) rtt=10.1ms
    hop  4: 2001:5a8:5:403f::e3:b ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(61) rtt=20.2ms
    hop  5: 2001:5a8:5:403f::97:a ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(60) rtt=10.1ms
    hop  6: 2001:5a8:5:40b0::b ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(59) rtt=10.1ms
    hop  7: 2001:5a8:5:40b0::b ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(59) rtt=10.1ms
    hop  8: 2001:5a8:5:403f::96:a ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(58) rtt=10.1ms
    hop  9: * (no ICMPv6 error within 2s)
    hop 10: * (no ICMPv6 error within 2s)
    hop 11: * (no ICMPv6 error within 2s)
    hop 12: 2001:5a8:5:403f::8a:a ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(53) rtt=10.2ms
    hop 13: 2001:4860:1:1::3ab6 ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(243) rtt=10.3ms
    hop 14: 2607:f8b0:85c1:c0::1 ee_type=3 ee_code=0 ee_errno=113 [time exceeded] hoplimit_cmsg=Some(47) rtt=10.3ms
    hop 15: 2001:4860:4860::8888 ee_type=1 ee_code=4 ee_errno=111 [port unreachable (DESTINATION)] hoplimit_cmsg=Some(109) rtt=10.2ms
    => DESTINATION REACHED at hop 15 — UDP6 mode WORKS

Notes:

What needs root on Linux (recorded, not run)

Validated findings (Windows)

Validated 2026-07-17 by examples/spike_windows_v6.rs in a Windows 11 ARM64 VM (build 26100, Parallels bridged network, native IPv6 on Sonic fiber), then confirmed end to end with live ftr -6 traces from the same VM. The implementation is src/socket/windows_v6.rs.

spike_windows_v6 — Icmp6SendEcho2 mechanics

Everything the design hypothesized about the IP Helper v6 API holds, and the open questions are answered:

  1. Source address: Icmp6SendEcho2 requires a source SOCKADDR_IN6 (unlike v4). Passing the unspecified address :: with sin6_family = AF_INET6 works — the stack performs normal source selection; no interface enumeration or GetBestInterfaceEx needed.
  2. Hop limit: the IP_OPTION_INFORMATION.Ttl field caps the outgoing hop limit exactly as in v4. Expired probes come back as replies with Status = IP_HOP_LIMIT_EXCEEDED (11013 — numerically identical to v4’s IP_TTL_EXPIRED_TRANSIT) carrying the router’s address.
  3. Reply layout (spike printed sizes and hex dumps): ICMPV6_ECHO_REPLY_LH is 36 bytes — packed 26-byte IPV6_ADDRESS_EX at offset 0 (address [u16; 8] words at byte 6, network byte order; sin6_scope_id at 22), 2 bytes padding, Status at 28, RoundTripTime (ms) at 32. For Echo Replies the echoed request payload sits directly after the struct at offset 36; Time Exceeded replies do not echo the payload — the same asymmetry as v4, so identifier/sequence verification is only possible on Echo Replies.
  4. Async completion: with an event handle, the event signals on reply AND on timeout. Icmp6ParseReplies returns the reply count; on timeout it returns 0 with GetLastError() = IP_REQ_TIMED_OUT (11010), and the buffer’s Status field also reads 11010. (The status field is observably valid even before the parse call, as the v4 code assumes for IcmpSendEcho2, but the v6 implementation calls Icmp6ParseReplies per the API contract.)
  5. No elevation: all of the above from a normal user context (the spike ran as SYSTEM via prlctl exec, but the shipped path is the same unprivileged Win32 API as v4 — no raw sockets anywhere).

Spike output (abridged; router addresses from the live Sonic path):

[1] Icmp6CreateFile: OK
[2] loopback ::1, full hop limit (sanity: Echo Reply path)
  hop_limit=128: replies=1 raw_status_pre_parse=0
    status=0 rtt_ms=0 from=::1 scope_id=0
    struct sizes: ICMPV6_ECHO_REPLY_LH=36 IPV6_ADDRESS_EX=26
[3] 2001:4860:4860::8888, hop limits 1..=4 (Time Exceeded path)
  hop_limit=1: status=11013 rtt_ms=0 from=2001:5a8:4681:2c00::1
  hop_limit=2: status=11013 rtt_ms=0 from=2001:5a8:657:21::f1:4
  hop_limit=3: status=11013 rtt_ms=1 from=2001:5a8:5:403f::f0:2
  hop_limit=4: status=11013 rtt_ms=7 from=2001:5a8:5:403f::e3:b
[4] 2001:4860:4860::8888, hop limit 128 (destination Echo Reply)
  hop_limit=128: status=0 rtt_ms=3 from=2001:4860:4860::8888
    (echoed id/seq/payload verified at offset 36 in the hex dump)
[5] unroutable 2001:db8::1, 50ms timeout (timeout path)
  hop_limit=128: replies=0 (GetLastError after parse=11010)

A follow-up live ftr -6 -v google.com from the VM traced 17 hops through Sonic to sfo07s17-in-x0e.1e100.net with full ASN/rDNS/segment enrichment and v6 STUN public-IP detection, and ftr 8.8.8.8 confirmed no v4 regression.

Validated findings (FreeBSD 14.3 / OpenBSD 7.7 — live VMs)

Validated 2026-07-18 on two local Parallels VMs bridged onto the same Sonic fiber LAN as the macOS/Linux/Windows environments above. Both run arm64. This pass covered what is reachable without root: external-v6 reachability, the v0.9.0 release build (the OpenBSD build is a first — see below), and the non-root permission path. The live multi-hop root trace was not run this session (see “what remains” at the end of this section).

FreeBSD 14.3-RELEASE (arm64)

uname -a:

FreeBSD freebsd143 14.3-RELEASE FreeBSD 14.3-RELEASE releng/14.3-n271432-8c9ce319fef7 GENERIC arm64

External IPv6: yes. Global SLAAC address and default v6 route present, ping6 to the public internet succeeds (verbatim):

# ifconfig (inet6, non-link-local)
	inet6 2001:5a8:4681:2c00:21c:42ff:fed9:7283 prefixlen 64 autoconf pltime 19510 vltime 19510
# netstat -rn -f inet6
default                           fe80::e263:daff:fe80:2451%vtnet0 UG        vtnet0
# ping6 -c2 google.com
16 bytes from 2607:f8b0:4005:808::200e, icmp_seq=0 hlim=109 time=3.251 ms
16 bytes from 2607:f8b0:4005:808::200e, icmp_seq=1 hlim=109 time=3.328 ms
2 packets transmitted, 2 packets received, 0.0% packet loss

Build: cargo/rustc were already installed under /usr/local/bin, cc present, but git was not installed — the v0.9.0 source was transferred as a tarball instead of cloned. cargo build --release succeeded (exit 0), producing a 1,163,584-byte target/release/ftr.

Non-root permission path (validated): run as the unprivileged ftr user, both the v6 and v4 CLI surface the typed permission error rather than attempting a raw socket (verbatim):

$ ./target/release/ftr --version
ftr 0.9.0
$ ./target/release/ftr -6 ::1        # exit 1
Error: ftr requires root privileges on freebsd
This platform does not support unprivileged traceroute.
$ ./target/release/ftr 8.8.8.8       # exit 1
Error: ftr requires root privileges on freebsd
This platform does not support unprivileged traceroute.

This confirms the InsufficientPermissions gate for BsdAsyncIcmpV6Socket / BsdAsyncIcmpSocket on real FreeBSD, matching the CI VM’s non-root test path.

OpenBSD 7.7 (arm64) — first-ever compile of the cfg(target_os = "openbsd") arm

uname -a:

OpenBSD openbsd7.localdomain 7.7 GENERIC.MP#2 arm64

External IPv6: no. The interface received no global v6 address and there is no v6 default route, so ping6 to the internet fails (verbatim) — OpenBSD’s v6 traces on this host are limited to ::1:

# ifconfig (inet6): only  inet6 ::1 prefixlen 128
# netstat -rn -f inet6: NO_V6_DEFAULT
# ping6 -c2 google.com
PING google.com (2607:f8b0:4005:808::200e): 56 data bytes
ping: wrote google.com 64 chars, ret=-1
ping: wrote google.com 64 chars, ret=-1
2 packets transmitted, 0 packets received, 100.0% packet loss

Build (headline): the OpenBSD bsd_v6.rs cfg arm compiled for the first time ever, and it compiled clean. cargo/rustc/git were all present under /usr/local/bin. git clone --branch v0.9.0 + cargo build --release succeeded (exit 0) in 2m20s, producing a 1,251,000-byte target/release/ftr. The build emitted exactly one warning — dead code, and in the shared IPv4 path, not the v6 arm:

warning: associated function `new` is never used
  --> src/socket/bsd.rs:35:12
warning: `ftr` (lib) generated 1 warning

BsdAsyncIcmpSocket::new() had only one caller, its own #[cfg(test)] unit test, so a non-test release build on any BSD flags it. It went unnoticed because CI’s FreeBSD job builds in test profile (where the test uses it) and clippy -D warnings runs on Linux/macOS, where bsd.rs is not compiled at all — the same class of platform-gated dead code cleaned up for the macOS and Windows arms earlier. The fix removes the redundant new() and points its test at new_with_config(TimingConfig::default()) (the constructor the factory actually uses); after the patch, cargo build --release finishes warning-free on both OpenBSD (33.85s incremental) and FreeBSD (7.50s incremental).

FreeBSD live root trace — router-originated Time Exceeded observed

Run 2026-07-19 on FreeBSD 14.3-RELEASE arm64 (freebsd143), a bridged Parallels VM with real Sonic global v6. Access recipe that finally worked non-interactively: sshpass with PreferredAuthentications=keyboard-interactive (FreeBSD sshd offers no password method) as user ftr, then sudo — not su. su fails because the ftr user is not in wheel; sudo is configured NOPASSWD, so raw ICMPv6 (root-only on BSD) runs cleanly. Binary was v0.9.0, but src/socket/bsd_v6.rs is unchanged through v0.10.0 (0.10.0 only added the Windows arm and removed an unrelated dead constructor), so this exercises the shipped FreeBSD raw ICMPv6 path.

sudo ftr -6 2001:4860:4860::8888 — 17 hops, router-originated ICMPv6 Time Exceeded received first-hand from every intermediate Sonic router, destination reached, full ASN/rDNS/segment enrichment; the gateway 2001:5a8:4681:2c00::1 correctly classified [LAN] via the shared-/64 heuristic:

 1 [LAN   ] unifi.localdomain (2001:5a8:4681:2c00::1) 2.429 ms [AS46375 - AS-SONICTELECOM - Sonic Telecom LLC, US]
 2 [ISP   ] 2001:5a8:657:21::f0:4 2.413 ms [AS46375 - AS-SONICTELECOM - Sonic Telecom LLC, US]
 3 [ISP   ] 2001:5a8:5:403f::f0:2 2.406 ms [AS46375 - AS-SONICTELECOM - Sonic Telecom LLC, US]
 4 [ISP   ] ae5.cr3.colaca01.sonic.net (2001:5a8:5:403f::e3:b) 7.603 ms [AS46375 ...]
 ... (hops 5-13 intermediate Sonic routers, all via ICMPv6 Time Exceeded) ...
14 [DESTINATION] 2001:4860:1:1::3ab6 5.005 ms [AS15169 - GOOGLE - Google LLC, US]
17 [DESTINATION] dns.google (2001:4860:4860::8888) 5.007 ms [AS15169 - GOOGLE - Google LLC, US]

sudo ftr -6 google.com (19 hops) and sudo ftr 8.8.8.8 (v4 regression) were also clean. This closes the last IPv6 design-doc open item: every supported platform’s IPv6 path is now implemented and observed on real hardware/kernels.

Still deferred (low value, environment-limited)

Design decisions for stage-6 integration

Socket mode per platform

Platform v6 probe socket Root needed Status
macOS IPV6/DGRAM/ICMPV6 No Validated here
Linux UDP + IPV6_RECVERR errqueue (unprivileged, works with default sysctls); IPV6/DGRAM/ICMPV6 ping socket + IPV6_RECVERR where ping_group_range allows (disabled by default: 1 0); raw ICMPv6 as root No (UDP mode) Validated here
Windows Icmp6SendEcho2 (IP Helper) No Validated & implemented (src/socket/windows_v6.rs; see Validated findings (Windows))
FreeBSD/OpenBSD IPV6/RAW/ICMPV6 Yes Implemented (src/socket/bsd_v6.rs); CI’s FreeBSD VM is the test gate. Builds clean on real FreeBSD 14.3 and OpenBSD 7.7 (arm64); FreeBSD root multi-hop v6 trace observed live (router-originated Time Exceeded); non-root permission path validated live (findings). OpenBSD external trace pending a v6-connected host; NetBSD/DragonFly best-effort, untested

macOS gets a first-class unprivileged v6 mode — this is strictly better than v4 on macOS, where ftr requires root for raw ICMP.

Identifier/sequence demux strategy

Because Darwin delivers all inbound ICMPv6 to every DGRAM ICMPv6 socket:

On Linux the kernel inverts both assumptions (validated above): ping sockets get a kernel-assigned identifier (chosen ids are rewritten on the wire) and replies are already demuxed per-socket, so per-socket sequence matching suffices there; Time Exceeded arrives via the errqueue, carrying ee_type/ee_code/offender instead of an embedded invoking packet. The demux layer therefore takes per-platform strategies rather than assuming either model.

Parsing differences v4 vs v6

Aspect IPv4 (raw) IPv6 DGRAM/raw
Received buffer Starts at IP header (variable IHL), ICMP at IHL*4 Starts at ICMPv6 header; kernel never prepends the IPv6 header (RFC 3542 behavior, validated)
Checksum on send Computed in userspace Leave zeroed; kernel computes (pseudo-header requires it; validated)
TTL/hop limit of reply Read from IP header byte 8 Not in buffer — request IPV6_RECVHOPLIMIT, read IPV6_HOPLIMIT cmsg via recvmsg (validated; needs libc for cmsg parsing — socket2 0.6 has recvmsg but no cmsg parser)
Time Exceeded payload 8 B ICMP + IP header (variable IHL) + 8 B ICMP 8 B ICMPv6 + fixed 40 B IPv6 header + 8 B ICMPv6 (validated); check embedded next_header == 58 before trusting the echo header
Echo types 8 / 0 128 / 129
Time Exceeded type 11 3

Address formatting contracts (carried from SwiftFTR)

What needs root

Open questions (unvalidated platforms)

Nothing below has been tested — these are hypotheses to validate by running the spikes on the target OS before implementing stage 6 there:

Dependency note

The spikes require libc on macOS (cmsg parsing for IPV6_HOPLIMIT, ICMP6_FILTER setsockopt) — added as a dev-dependency under [target.'cfg(target_os = "macos")'.dev-dependencies] so the shipped library and binary gain no new dependencies. Stage 6 will need to promote it to a regular target dependency (or upstream cmsg/ICMP6_FILTER support into socket2) when the production receive path adopts recvmsg.