A few weeks ago I shipped NetDiag+, an iOS network toolkit — ping, traceroute,
DNS, whois, port/LAN scan — built entirely on BSD sockets through C-interop, with
no private entitlements. I wrote up the ping/traceroute internals here.
Then I spent a month adding 9 more tools. I expected "more of the same" — wrap a
socket, draw a SwiftUI list. Instead almost every tool turned into its own little
research project. Here are the parts that surprised me.
1. MTR — a continuous traceroute that can't be slow
mtr keeps probing every hop and accumulates per-hop loss + RTT stats. My first
version probed hops sequentially: TTL=1, wait for reply or timeout, TTL=2, wait…
On a 12-hop path where a couple of routers rate-limit ICMP, one cycle took 5-7
seconds. Set the interval to "1 second" and the cycle counter ticks every five.
Fix: fire all probes in a cycle at once and collect replies in a single window.
The trick is matching an incoming ICMP reply to the TTL that triggered it. I give
each probe a unique destination UDP port (base + ttl + cycle_offset). When a router
returns ICMP Time Exceeded it includes the first bytes of the original packet — UDP
header included — so I demultiplex on that port:
/// Pull the dest port out of the UDP header embedded in an ICMP Time-Exceeded
/// error, so we know which outstanding probe this reply belongs to.
static func extractInnerDestPort(from data: Data) -> UInt16? {
let bytes = Array(data)
let icmpHeader = 8
guard bytes.count > icmpHeader else { return nil }
let ihlBytes = Int(bytes[icmpHeader] & 0x0F) * 4 // inner IP header length
guard ihlBytes >= 20 else { return nil }
let innerUDP = icmpHeader + ihlBytes
guard bytes.count >= innerUDP + 4 else { return nil }
return (UInt16(bytes[innerUDP + 2]) << 8) | UInt16(bytes[innerUDP + 3])
}
Now cycle time ≈ probeTimeout, independent of how many hops are timing out.
For the stats, storing every RTT and recomputing stddev leaks memory in a tool that
runs forever. Welford's online algorithm updates mean + variance in O(1):
recv += 1
let delta = rttMs - mean
mean += delta / Double(recv)
m2 += delta * (rttMs - mean)
// ...
var stdDevMs: Double? {
guard recv >= 2 else { return nil }
return (m2 / Double(recv - 1)).squareRoot()
}
2. Path MTU — the obvious approach silently lied
Path MTU = the largest packet that reaches the destination unfragmented. Classic VPN
debugging: if your tunnel caps MTU at 1420 and the app sends 1500 with DF set, packets
vanish and "the internet works but half the sites don't load."
The algorithm is a binary search: send a Don't-Fragment packet of growing size, watch
for ICMP "Fragmentation Needed." My first version sent UDP to a random high port
and treated ICMP Port Unreachable as the "it arrived" signal.
Testing against 1.1.1.1 it reported Path MTU = 688 bytes — nonsense. Cloudflare
(like most well-run hosts) silently drops UDP to random ports, so Port Unreachable
never comes back. Every timeout looked like "too big" and the search converged on junk.
Rewrote it on ICMP Echo with DF. Public hosts answer pings reliably, so "it fit"
became a real signal. And when a router returns Frag-Needed it includes its next-hop
MTU (RFC 1191), so you can jump straight to the answer:
static func extractNextHopMTU(from data: Data) -> Int? {
let bytes = Array(data)
guard bytes.count >= 8 else { return nil }
let mtu = (UInt16(bytes[6]) << 8) | UInt16(bytes[7])
return mtu > 0 ? Int(mtu) : nil
}
Bonus: the discovered value hints at the link type — 1492 → PPPoE, 1480 → GRE,
1420-1440 → WireGuard, 1400 → OpenVPN/IPSec.
3. Site Reach — TCP success doesn't mean "reachable"
A tool that checks whether popular sites are reachable. Naive version: TCP-connect to
443, success = "reachable." That misses the dominant modern block.
National/corporate DPI often lets the TCP handshake complete and only injects RST after
seeing the forbidden hostname in the SNI field of the TLS Client Hello. From the
device's view, TCP connected — so a TCP-only probe reports "reachable" while HTTPS
is actually dead.
To catch it, go all the way to the TLS handshake. Two probes, compared:
| TCP | TLS | Verdict |
|---|---|---|
| fail | fail | IP block (or DNS pointing at a dead IP) |
| ok | fail | SNI / TLS block — DPI killed the Client Hello by hostname |
| ok | ok | actually reachable |
The TLS probe uses NWConnection with NWProtocolTLS, which sets SNI to the
connection's hostname automatically — exactly what trips SNI-aware DPI. Flagged sites
get an "SNI" badge so they're distinct from plain unreachable ones.
The other six, briefly
-
TLS Inspector — probes TLS 1.0–1.3 in parallel (one
NWConnectionper version, min/max pinned), shows the negotiated cipher, flags deprecated 1.0/1.1 and weak ciphers. - Encrypted DNS — resolves a domain over DoH and DoT at Cloudflare/Google/Quad9/ NextDNS, compares answers + latency. Hand-rolled minimal DNS wire codec.
- STUN / NAT — real RFC 5389 Binding Request to several servers, parses XOR-Mapped-Address, classifies NAT (Open / Cone / Symmetric) by whether the external port differs per server. The signal for whether VoIP/WebRTC/P2P will work.
-
Bonjour browser —
NWBrowserover ~20 mDNS service types (AirPlay, Chromecast, HomeKit, printers) with TXT-record parsing. -
HTTP/3 (QUIC) — real QUIC handshake via
NWConnection+NWProtocolQUICwith ALPN "h3" on UDP/443. URLSession won't attempt QUIC without a cached Alt-Svc hint, so this is the only honest way to check whether your network passes QUIC. - IPv6 Check — local v6 enumeration with tunnel awareness (utun → iCloud Private Relay / NAT66), NAT64/DNS64 detection (RFC 7050), v4-vs-v6 latency.
Four takeaways
- The "obvious" probe usually lies. UDP-to-random-port (Path MTU), TCP-only (Site Reach) — both give false results on real networks. Only a real protocol exchange — ICMP Echo with DF, a full TLS handshake — tells the truth.
-
Raw ICMP/UDP on iOS is more accessible than you'd think — a
SOCK_DGRAMICMP socket needs no entitlements and covers ping, traceroute, MTR, Path MTU. Raw SYN, ARP, and packet capture are still off-limits. - Test on two networks. Half my bugs only showed up comparing a clean European Wi-Fi against a cellular CGNAT/symmetric-NAT link — the false "blocks" lived there.
-
Network.frameworkis the right tool for TLS/QUIC. Where ICMP means BSD sockets, TLS Inspector and HTTP/3 are cleaner withNWConnection: version pinning, ALPN, a real QUIC handshake — all built in.
Everything still runs without private entitlements and collects no data — results stay
on the device.
NetDiag+ on the App Store: https://apps.apple.com/app/id6761954529 (12 languages, 25 tools)
Happy to answer implementation questions in the comments.
Top comments (0)