Fast, parallel traceroute for Swift on macOS — no sudo required, full IPv4 and IPv6 support. SwiftFTR uses ICMP datagram sockets with async/await to probe every hop at once, then classifies the path into segments like LOCAL, ISP, TRANSIT, and DESTINATION.
API Documentation — generated via DocC and published by GitHub Pages.
- No sudo: Uses
SOCK_DGRAMwithIPPROTO_ICMP/IPPROTO_ICMPV6on macOS, so you can run traceroute‑style measurements from apps, tests, and CI without elevated privileges. - Parallel by design: Sends one ICMP Echo per TTL up to your max hop in a tight loop, then listens for all responses concurrently.
- Simple async API: A single
trace(...)call returns structured hops;traceClassified(...)adds ASN‑based labeling. The same entry point accepts IPv4 or IPv6 targets — family is detected automatically. - Dual-stack: ping, traceroute, TCP/UDP probes, and
getPublicIPs()support IPv4 and IPv6. v4 literals transparently work on v6-only NAT64 networks via system synthesis. - Production‑friendly: Monotonic RTT timing, buffer reuse, and an in‑memory ASN cache minimize noise and allocations.
- Resolve destination via a dual-stack
getaddrinfo(withAI_V4MAPPED | AI_ADDRCONFIG) so v4 literals on v6-only NAT64 networks transparently get a synthesized v6 mapping. - Open an ICMP/ICMPv6 datagram socket —
SOCK_DGRAMwithIPPROTO_ICMPorIPPROTO_ICMPV6depending on the resolved family. Set non‑blocking mode. - For TTL = 1…maxHops:
- Set
IP_TTL(v4) orIPV6_UNICAST_HOPS(v6) to the current TTL and send an Echo Request with a stable identifier and sequence (seq = TTL). - Record send time in a small map keyed by
sequence.
- Set
- Register a
DispatchSourceRead(kqueue-backed on macOS) and handle packets until a global deadline:- For v6,
recvmsgis used so the reply's hop limit arrives via cmsg ancillary data (the kernel strips the IPv6 header fromSOCK_DGRAMdeliveries). - Parse each incoming datagram as one of: Echo Reply, Time Exceeded, or Destination Unreachable.
- Match replies back to the original probe using the identifier/sequence embedded in the payload.
- Compute RTT with a monotonic clock and place the hop at
ttl - 1. - Stop early once the destination responded and all earlier hops are either filled or have timed out.
- For v6,
- Optional classification (when using
traceClassified):- Enrich classified traces with a configured value or cached IPv4 discovery (STUN, then DNS fallback). The separate
getPublicIPs()call returns uncached STUN results for v4 and v6 in parallel. - Batch‑resolve ASNs using Team Cymru DNS (
origin.asn.cymru.comfor v4,origin6.asn.cymru.comfor v6) or the embedded local database via swift-ip2asn. Apply heuristics for PRIVATE and CGNAT ranges. - Label each hop as LOCAL, ISP, TRANSIT, or DESTINATION and "hole‑fill" missing stretches between identical segments.
- Enrich classified traces with a configured value or cached IPv4 discovery (STUN, then DNS fallback). The separate
Classic traceroute often probes sequentially and waits per hop; SwiftFTR probes all hops in one burst and waits once.
- Latency model: Probe transmission and result processing scale with
maxHops, but all replies share one receive deadline instead of waiting once per hop. The ICMP receive phase is bounded bymaxWaitMs; hostname resolution and optional rDNS, STUN, and ASN classification perform separate I/O. - Efficient I/O: Single socket, kqueue-backed
DispatchSourceRead, reused receive buffer, and monotonic timing reduce overhead and jitter.
If you need tighter runs, lower maxWaitMs (for example, to 500) or cap maxHops (for example, at 20). You can also tune payloadSize in advanced scenarios.
- Swift 6.1+ (requires Xcode 16.4 or later)
- macOS 13+
- Core ping, traceroute, TCP, and UDP paths support IPv4 and IPv6 (including ICMPv6 Echo per RFC 4443). Multipath discovery remains IPv4-only. On Linux, typical ICMP requires raw sockets (root/CAP_NET_RAW); SwiftFTR targets macOS's ICMP datagram behavior.
-
Xcode: File → Add Package → enter this repository URL → select the
SwiftFTRproduct. -
Package.swift:dependencies: [ .package(url: "https://github.com/Network-Weather/SwiftFTR.git", from: "0.16.0") ], targets: [ .target(name: "YourTarget", dependencies: ["SwiftFTR"]) ]
Read the published Migrating to SwiftFTR 0.14
guide and the 0.14 changelog before updating. Existing direct call
sites remain source-compatible, but exhaustive DNSError switches, HTTP timing assumptions,
route-bound bufferbloat tests, and interface classification may require small changes.
SwiftFTR is fully compliant with Swift 6.1 concurrency requirements:
- ✅ All public value types are
Sendable - ✅ API works without
@MainActorrequirements - ✅ Thread-safe usage from any actor or task
- ✅ Builds under Swift 6 language mode with strict concurrency checks
Traceroute
- Parallel ICMP/ICMPv6 probing with a single receive deadline for all hops
- Streaming API with real-time hop updates via
AsyncThrowingStream - ASN-based hop classification: LOCAL, ISP, TRANSIT, VPN, DESTINATION
- v6 hops get full ASN annotations via Team Cymru
origin6.asn.cymru.comor the embedded swift-ip2asn database - VPN-aware classification of tunnel-local and exit-side path segments
- Automatic rDNS lookups with 24-hour caching
Dual-stack IPv4 / IPv6
- Ping, traceroute, TCP probes, and UDP probes accept v4 literals, v6 literals, and hostnames through the same entry points
PreferredFamily { .v4, .v6, .auto }lets callers force a family; default.autolets the OS decide- v4 literals on v6-only NAT64 networks transparently use the gateway's synthesized v6 mapping (RFC 6147)
- IPv6 formatting uses
inet_ntop; link-local paths retain a%zonesuffix when macOS supplies a scope ID - A caller-selected BSD interface name binds v6 sockets via
IPV6_BOUND_IF
Network Probing
- Ping: ICMP/ICMPv6 echo with statistics (min/avg/max RTT, jitter, packet loss)
- TCP Probe: Port state detection (open/closed/filtered) over v4 or v6
- UDP Probe: Connected-socket with ICMP/ICMPv6 unreachable detection
- DNS Probe: Direct server queries with 11 record types (A, AAAA, TXT, MX, etc.)
- HTTP/HTTPS Probe: Web server reachability testing
Multipath Discovery
- Dublin Traceroute-style ECMP path enumeration (v4)
- Smart deduplication and divergence point detection
- Flow identifier control for reproducible traces
Interface & Binding
- Traceroute and ping honor global interface/source-address settings; ping also supports a per-operation override
- TCP, UDP, and DNS probes expose operation-level binding; HTTP/HTTPS probes follow system routing
- Supported socket-backed diagnostics accept family-matched IPv4 or IPv6 source addresses (link-local
%zonehonored) - Bufferbloat accepts binding only for baseline-only latency (
loadDuration: 0); loaded tests reject any effective binding before network work
Public IP Discovery
getPublicIPs()is an uncached, STUN-only call that runs IPv4 and IPv6 in parallel and returns whichever families succeededdiscoverPublicIPWithHostname()performs fresh IPv4 discovery on each call: STUN first, then an unbound Akamai DNS-whoami fallback, plus optional system-routed rDNS- Classified trace honors
SwiftFTRConfig.publicIP; otherwise itsSwiftFTRactor caches discovered IPv4 until invalidation. Multipath also honors the override and reuses an actor-cached value when available
Architecture
- Actor-based design for thread safety
- Works from any actor or task (no
@MainActorrequired) - Network change API for cache invalidation and trace cancellation
import SwiftFTR
// Configure defaults for supported socket-backed diagnostics
let config = SwiftFTRConfig(
maxHops: 40, // Max TTL to probe
maxWaitMs: 1000, // Timeout in milliseconds
payloadSize: 56, // ICMP payload size
publicIP: nil, // Auto-detect for classified trace/multipath enrichment
enableLogging: false // Set true for debugging
)
let tracer = SwiftFTR(config: config)
// Basic trace - can be called from any actor context
let result = try await tracer.trace(to: "1.1.1.1")
for hop in result.hops {
let addr = hop.ipAddress ?? "*"
let rtt = hop.rtt.map { String(format: "%.3f ms", $0 * 1000) } ?? "timeout"
print("\(hop.ttl)\t\(addr)\t\(rtt)")
}
// IPv6 works through the same entry point — family auto-detected from the target.
let v6Result = try await tracer.trace(to: "2606:4700:4700::1111")
// Or force a family explicitly:
let v6Config = SwiftFTRConfig(preferredFamily: .v6)
let alwaysV6 = SwiftFTR(config: v6Config)
let googleTrace = try await alwaysV6.trace(to: "google.com") // prefers AAAA if available
// Dual-stack public-IP discovery:
let publicIPs = await getPublicIPs()
print("v4: \(publicIPs.v4 ?? "n/a"), v6: \(publicIPs.v6 ?? "n/a")")
// With ASN classification
let classified = try await tracer.traceClassified(to: "www.example.com")
for hop in classified.hops {
print(hop.ttl, hop.ip ?? "*", hop.category.rawValue, hop.asn ?? 0, hop.asName ?? "")
// Optional hostname from reverse DNS
if let hostname = hop.hostname {
print(" Hostname: \(hostname)")
}
}
// Handle network changes (e.g., WiFi to cellular, VPN connect/disconnect)
await tracer.networkChanged() // Cancels active traces and clears caches
// Ping API
let pingConfig = PingConfig(count: 5, interval: 1.0, timeout: 2.0)
let pingResult = try await tracer.ping(to: "1.1.1.1", config: pingConfig)
print("Packet loss: \(Int(pingResult.statistics.packetLoss * 100))%")
if let avg = pingResult.statistics.avgRTT {
print("Avg RTT: \(String(format: "%.2f ms", avg * 1000))")
}
// Concurrent pings: Multiple pings execute in parallel, not serially
async let cf = tracer.ping(to: "1.1.1.1", config: pingConfig)
async let goog = tracer.ping(to: "8.8.8.8", config: pingConfig)
let (cloudflare, google) = try await (cf, goog)
// 20 concurrent pings: ~1.1s (parallel) vs ~7.2s (if serialized) = 6.4x speedup
// Multipath Discovery (ECMP enumeration)
let multipathConfig = MultipathConfig(flowVariations: 8, maxPaths: 16)
let topology = try await tracer.discoverPaths(to: "8.8.8.8", config: multipathConfig)
print("Found \(topology.uniquePathCount) unique paths")
for hop in topology.uniqueHops() {
print("Discovered hop at TTL \(hop.ttl): \(hop.ip ?? "*")")
}
// Per-operation interface binding with names selected by your UI or caller.
func compareRoutes(primaryBSDName: String, alternateBSDName: String) async throws {
let snapshot = await NetworkInterfaceDiscovery().discover()
guard let primaryInterface = snapshot.interfaces.first(where: { $0.name == primaryBSDName }),
let alternateInterface = snapshot.interfaces.first(where: { $0.name == alternateBSDName }),
primaryInterface.isUp, alternateInterface.isUp
else {
return
}
let boundTracer = SwiftFTR(config: SwiftFTRConfig(interface: primaryInterface.name))
// The first call inherits the global selection; the second overrides it.
async let primary = boundTracer.ping(to: "1.1.1.1")
async let alternate = boundTracer.ping(
to: "1.1.1.1",
config: PingConfig(interface: alternateInterface.name)
)
let (primaryResult, alternateResult) = try await (primary, alternate)
print("Primary loss: \(Int(primaryResult.statistics.packetLoss * 100))%")
print("Alternate loss: \(Int(alternateResult.statistics.packetLoss * 100))%")
}
// DNS API with rich metadata
// IPv4 address lookup
let aResult = try await tracer.dns.a(hostname: "google.com")
print("Server: \(aResult.server), RTT: \(aResult.rttMs)ms")
for record in aResult.records {
if case .ipv4(let addr) = record.data {
print(" \(addr) (TTL: \(record.ttl)s)")
}
}
// IPv6 address lookup
let aaaaResult = try await tracer.dns.aaaa(hostname: "google.com")
for record in aaaaResult.records {
if case .ipv6(let addr) = record.data {
print(" \(addr)")
}
}
// Reverse DNS lookup
let ptrResult = try await tracer.dns.reverseIPv4(ip: "8.8.8.8")
for record in ptrResult.records {
if case .hostname(let name) = record.data {
print(" 8.8.8.8 → \(name)")
}
}
// MX records (mail exchange)
let mxResult = try await tracer.dns.query(name: "google.com", type: .mx)
for record in mxResult.records {
if case .mx(let priority, let exchange) = record.data {
print(" Priority \(priority): \(exchange)")
}
}
// TXT records (SPF, DKIM, etc.)
let txtResult = try await tracer.dns.txt(hostname: "google.com")
for record in txtResult.records {
if case .text(let strings) = record.data {
for str in strings {
print(" \(str)")
}
}
}
// CAA records (certificate authority authorization)
let caaResult = try await tracer.dns.query(name: "google.com", type: .caa)
for record in caaResult.records {
if case .caa(let flags, let tag, let value) = record.data {
print(" \(tag): \(value)")
}
}
// HTTPS records (HTTP/3 service binding)
let httpsResult = try await tracer.dns.query(name: "cloudflare.com", type: .https)
for record in httpsResult.records {
if case .https(let priority, let target, _) = record.data {
print(" Priority \(priority): \(target)")
}
}
// Supports 11 DNS record types:
// A, AAAA, PTR, TXT, MX, NS, CNAME, SOA, SRV, CAA, HTTPS
// Streaming Traceroute API
// Get hops as they arrive (not sorted by TTL)
for try await hop in tracer.traceStream(to: "1.1.1.1") {
if let ip = hop.ipAddress, let rtt = hop.rtt {
print("TTL \(hop.ttl): \(ip) - \(String(format: "%.1f", rtt * 1000))ms")
} else {
print("TTL \(hop.ttl): *")
}
if hop.reachedDestination {
print(" <-- destination")
}
}
// With custom config
let streamConfig = StreamingTraceConfig(
probeTimeout: 15.0, // Total timeout
retryAfter: 5.0, // Retry unresponsive TTLs after 5s
emitTimeouts: true, // Emit timeout placeholders at end
maxHops: 30
)
for try await hop in tracer.traceStream(to: "example.com", config: streamConfig) {
// Process each hop as it arrives
}- Thread Safety: The
SwiftFTRactor protects shared state, while operations designed for parallel use keep independent per-operation state. Its async API can be called from any actor or task. - Public IP enrichment:
SwiftFTRConfig(publicIP:)overrides discovery for classified trace and multipath enrichment; it does not changegetPublicIPs()ordiscoverPublicIPWithHostname(). - ASN Lookups:
traceClassifieduses DNS‑based Team Cymru with caching. Inject customASNResolverfor offline lookups. - Timeout Behavior:
maxWaitMsbounds the ICMP receive phase oftrace. Resolution and optional STUN, rDNS, or ASN enrichment have their own I/O and timeout behavior. - Error Handling: Detailed
TracerouteErrorwith context about failures (permissions, network, platform). - SwiftUI Ready: No MainActor requirements - integrate directly into SwiftUI views and view models.
Build the bundled executable and run it:
swift build -c release
.build/release/swift-ftr --help.build/release/swift-ftr trace example.com -m 40 -t 1.0Options:
-m, --max-hops N: Max TTL/hops to probe (default 40)-t, --timeout SEC: Overall wait after sending probes (default 1.0)-i, --interface IFACE: Use an exact BSD name reported byswift-ftr interfaces-s, --source IP: Bind to specific source IP address-p, --payload-size N: ICMP payload size in bytes (default 56)--json: Emit JSON with ASN categories and public IP--no-rdns: Disable reverse DNS lookups--public-ip IP: Override public IP (bypasses STUN)--verbose: Enable debug logging
.build/release/swift-ftr ping 1.1.1.1 -c 10 -i 1.0Options:
-c, --count N: Number of pings (default 5)-i, --interval SEC: Interval between pings (default 1.0)-t, --timeout SEC: Timeout per ping (default 2.0)--payload-size N: ICMP payload size (default 56)-I, --interface IFACE: Network interface to use--json: Output JSON format
.build/release/swift-ftr multipath 8.8.8.8 --flows 8 --max-paths 16Options:
--flows N: Number of flow variations (default 8)--max-paths N: Max unique paths to find (default 16)--early-stop N: Stop after N flows with no new paths (default 3)-m, --max-hops N: Max TTL to probe (default 40)-t, --timeout SEC: Timeout per flow in seconds (default 2.0)--json: Output JSON format
.build/release/swift-ftr stream 1.1.1.1 -m 30 --timeout 15Options:
-m, --max-hops N: Max TTL to probe (default 30)-t, --timeout SEC: Total timeout for trace (default 10.0)--retry-after SEC: Retry unresponsive TTLs after this time (default 4.0)--no-retry: Disable automatic retry of unresponsive TTLs
- Use
SwiftFTRConfig(publicIP: ...)to bypass discovery for classified trace and multipath enrichment only; the standalone public-IP APIs ignore this value. - Validate an exact caller-selected name and family-matched address against
NetworkInterfaceDiscovery; never infer a physical adapter's role from its BSD-name suffix. - Traceroute, streaming/classified trace, and IPv4-only multipath use the global
SwiftFTRConfigbinding. Ping supports IPv4 and IPv6; each non-niloperation-level binding independently overrides its global counterpart. - Actor DNS query helpers inherit globals unless the call overrides them. The numeric DNS server—not A versus AAAA record type—selects the IPv4 or IPv6 transport. Standalone DNS query functions and DNS/TCP/UDP probes use only their function arguments or operation config.
getPublicIPs()accepts operation-level bindings for parallel IPv4/IPv6 STUN.discoverPublicIPWithHostname()applies globals to its IPv4 STUN attempt, but its DNS-whoami fallback and rDNS lookup use system routing.- Bufferbloat honors route binding only for baseline latency (
loadDuration: 0); loaded tests reject effective binding. - HTTP/HTTPS probes use URLSession and do not support either binding option.
- Socket binding controls the documented probe sockets only. Hostname resolution, system rDNS, DNS-whoami fallback, and Team Cymru ASN lookups are not bound by these settings.
- CLI:
--public-ip x.y.z.w,--verbose,--payload-size,--max-hops,--timeout,-i/--interface,-s/--source.
- Socket: ICMP
SOCK_DGRAMon macOS (no privileges) withO_NONBLOCKandDispatchSourcereadiness handling. - Probing: One Echo Request per TTL; identifier is constant per run, sequence equals TTL for easy correlation.
- Matching: Echo Reply and Time Exceeded handlers pull out embedded id/seq from the packet to map to the original probe.
- Timing: RTT is measured with
CLOCK_MONOTONICto avoid wall‑clock jumps. - Classification: Team Cymru DNS WHOIS lookups with caching; PRIVATE and CGNAT ranges are recognized without lookups; missing stretches are “hole‑filled” between identical segment classes.
- Unit tests:
swift test - Lightweight runner:
.build/debug/ptrtests
- Random fuzzer (macOS):
- Build:
swift build -c release -Xswiftc -sanitize=address -Xswiftc -sanitize=undefined - Run:
.build/release/icmpfuzz(override iterations withITER=200000)
- Build:
- Corpus + libFuzzer (Linux):
- Generate corpus:
swift run genseeds FuzzCorpus/icmp - Build:
swift build -c release -Xswiftc -sanitize=fuzzer,address,undefined - Run:
.build/release/icmpfuzzer FuzzCorpus/icmp -max_total_time=30
- Generate corpus:
- DocC bundle at
Sources/SwiftFTR/SwiftFTR.docc. docs/CACHE-AND-TRANSITION-LIFECYCLE.md— what the tracer caches, what invalidates each class, and the controls a caller gets across a network transition (fresh as of 2026-09-03).BENCHMARKS.md— measured throughput, memory and ASN-database load cost per release, with theResourceBenchmarkandasnloadprobecommands that reproduce them (fresh as of 2026-09-01).docs/IPV6.md— sequenced plan and architectural contracts for IPv6 feature parity (ping, traceroute, probes, STUN, ASN).docs/BUG2-INVESTIGATION.md— audit and measurements behind the bounded-and-cancellable-enrichment work: continuation exit paths, the shared blocking-IO executor, and what did and did not reproduce.
Generate and view the docs:
- Xcode: Product → Build Documentation (or use the Documentation sidebar).
- SwiftPM plugin (Xcode 16.4+/Swift 6.1+):
swift package --allow-writing-to-directory docc \ generate-documentation --target SwiftFTR \ --output-path docc --transform-for-static-hosting \ --warnings-as-errors open docc/index.html
- Lint formatting locally before pushing:
swift format lint --strict -r Sources Tests
- Optional: install repo hooks so pushes fail on formatting issues:
git config core.hooksPath .githooks
SwiftFTR builds on decades of traceroute technique, and other tools may fit your needs better than an embeddable Swift library.
Lineage and techniques:
- Van Jacobson's original
traceroute(1987) established TTL-stepped probing. - Paris traceroute (Augustin et al., IMC 2006) showed that holding flow-identifying header fields constant keeps probes on a single path under ECMP load balancing, and that varying them enumerates paths.
- Dublin Traceroute (Andrea Barberio) extended
Paris-style probing with NAT detection. SwiftFTR's multipath discovery
(
discoverPaths) is modeled on its approach. - Unprivileged operation relies on Darwin's ICMP datagram sockets
(
SOCK_DGRAM+IPPROTO_ICMP/IPPROTO_ICMPV6), which permit echo probes without root. - Public-IP discovery speaks STUN (RFC 8489).
Data and services:
- Default ASN classification queries Team Cymru's IP-to-ASN mapping service over DNS. The embedded alternative is swift-ip2asn, whose database is built from iptoasn.com data.
Peer tools doing analogous work:
- mtr — combined traceroute and continuous ping monitoring (C, cross-platform).
- trippy — traceroute TUI and analysis (Rust).
- scamper — CAIDA's research-grade Internet measurement tool.
- dublin-traceroute — the C++/Python original.
- ftr — sibling project: parallel traceroute as a Rust CLI.
SwiftFTR's niche among these: an embeddable Swift 6 library — actor-based async API, unprivileged sockets, and classification/enrichment (ASN, reverse DNS, VPN/segment labeling) for applications rather than terminals.
MIT — see LICENSE.
- Semantic Versioning. See CHANGELOG.md for release notes.
- To consume via SwiftPM, use the
0.16.0tag or a later compatible release.
See CONTRIBUTING.md for development setup, formatting, testing, docs, and release guidance.
This project adheres to a Code of Conduct. By participating, you agree to abide by its terms. See CODE_OF_CONDUCT.md.