Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Unreleased

- `cancelActiveTraces()` cancels currently active traces without invalidating cached rDNS,
public IP, or ASN resolution.
- `invalidateNetworkScopedRDNS()` evicts network-scoped (private, CGNAT, link-local, loopback,
ULA) rDNS entries and resets the stall breaker while preserving globally routable internet
hostnames and allowing in-flight global lookups to finish normally.
- `TraceOptions` and per-operation options on `trace(to:options:)` and `traceClassified(to:vpnContext:resolver:options:)`
permit overriding `maxHops` on individual traces without reconfiguring the actor.

Expand Down
48 changes: 35 additions & 13 deletions Sources/SwiftFTR/RDNSCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ actor RDNSCache {
private let clock = ContinuousClock()
private let resolver: Resolver
private let lookupDeadline: TimeInterval
private var generation: UInt64 = 0
private var scopedGeneration: UInt64 = 0
private var globalGeneration: UInt64 = 0
private var breakerGeneration: UInt64 = 0
private var consecutiveStalls = 0

/// Initialize a new rDNS cache.
Expand Down Expand Up @@ -86,23 +88,28 @@ actor RDNSCache {
// Lookups keep stalling, so skip the wait and report numerically until `clear()` reopens.
guard !isSuppressingLookups else { return nil }

let lookupGeneration = generation
let isScoped = ipAddressScope(of: ip) != .global
let lookupGeneration = isScoped ? scopedGeneration : globalGeneration
let lookupBreakerGeneration = breakerGeneration
let startedAt = clock.now
let hostname = await resolver(ip)
let elapsed = startedAt.duration(to: clock.now)

// A lookup that consumed its whole budget did not answer; it timed out or was starved. Track
// that separately from a resolver that answered "no such name", which is a normal fast result.
if elapsed >= .seconds(lookupDeadline * 0.9) {
consecutiveStalls += 1
} else {
consecutiveStalls = 0
// Only lookups initiated under the current breaker generation may update breaker state.
// Stale lookups from a prior network generation must not re-trip or reset the breaker on the new network.
if lookupBreakerGeneration == breakerGeneration {
if elapsed >= .seconds(lookupDeadline * 0.9) {
consecutiveStalls += 1
} else {
consecutiveStalls = 0
}
}

// `clear()` may run while the resolver is suspended. A result from the old
// network generation must neither escape to the caller nor repopulate the
// newly invalidated cache.
guard lookupGeneration == generation else { return nil }
// A result from an invalidated network generation must neither escape to the caller nor
// repopulate the newly invalidated cache. Network-scoped invalidation rejects in-flight
// scoped lookups while allowing global lookups to finish; clear() rejects both.
let currentGeneration = isScoped ? scopedGeneration : globalGeneration
guard lookupGeneration == currentGeneration else { return nil }

// Cache the result
cache[ip] = CacheEntry(hostname: hostname, timestamp: clock.now)
Expand Down Expand Up @@ -138,11 +145,26 @@ actor RDNSCache {

/// Clear all cached entries.
func clear() {
generation &+= 1
scopedGeneration &+= 1
globalGeneration &+= 1
breakerGeneration &+= 1
cache.removeAll()
consecutiveStalls = 0
}

/// Invalidate network-scoped (non-global) rDNS cache entries and reset the stall breaker.
///
/// Evicts cached rDNS entries (positive and negative) whose address is not globally routable
/// (RFC 1918 private, CGNAT, link-local, loopback, ULA). Preserves globally routable entries
/// and their TTLs. In-flight lookups for scoped addresses are discarded, while in-flight
/// global lookups continue to complete and cache normally.
func invalidateNetworkScoped() {
scopedGeneration &+= 1
breakerGeneration &+= 1
cache = cache.filter { ip, _ in ipAddressScope(of: ip) == .global }
consecutiveStalls = 0
}

/// Get the current number of cached entries.
var count: Int {
cache.count
Expand Down
10 changes: 10 additions & 0 deletions Sources/SwiftFTR/Traceroute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,16 @@ public actor SwiftFTR {
cacheGeneration &+= 1
cachedPublicIP = nil
}

/// Invalidate network-scoped reverse DNS cache entries.
///
/// Evicts cached reverse DNS entries whose address is not globally routable
/// (e.g. RFC 1918 private addresses, CGNAT, link-local, loopback, IPv6 ULA).
/// Globally routable Internet hostnames and their TTLs are preserved.
/// Also resets the reverse-DNS stall breaker so subsequent lookups are attempted.
public func invalidateNetworkScopedRDNS() async {
await rdnsCache.invalidateNetworkScoped()
}
}

// MARK: - Dual-stack trace helpers (Stage 2 IPv6)
Expand Down
162 changes: 162 additions & 0 deletions Tests/SwiftFTRTests/CacheGenerationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,168 @@ struct CacheGenerationTests {
#expect(current == "198.51.100.8")
#expect(await tracer.publicIP == "198.51.100.8")
}

@Test("invalidateNetworkScoped evicts only non-global entries and preserves global ones")
func scopedEvictionPreservesGlobal() async {
let resolver = CountingResolver(result: "resolved.example")
let cache = RDNSCache(resolver: { ip in await resolver.resolve(ip) })

let scopedIPs = [
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"100.64.0.1",
"169.254.1.1",
"127.0.0.1",
"::1",
"fe80::1",
"fc00::1",
]

let globalIPs = [
"1.1.1.1",
"8.8.8.8",
"2606:4700:4700::1111",
]

for ip in scopedIPs + globalIPs {
_ = await cache.lookup(ip)
}
#expect(await cache.count == scopedIPs.count + globalIPs.count)

let callsBefore = await resolver.callCount

// Perform network-scoped eviction
await cache.invalidateNetworkScoped()

// Global IPs should remain cached (no additional resolver call)
for ip in globalIPs {
#expect(await cache.lookup(ip) == "resolved.example")
}
#expect(await resolver.callCount == callsBefore)

// Scoped IPs were evicted, so looking them up hits the resolver again
for ip in scopedIPs {
_ = await cache.lookup(ip)
}
#expect(await resolver.callCount == callsBefore + scopedIPs.count)
}

@Test("invalidateNetworkScoped resets the stall breaker")
func scopedEvictionResetsStallBreaker() async {
let cache = RDNSCache(
lookupDeadline: 0.05,
resolver: { _ in
try? await Task.sleep(for: .milliseconds(60))
return nil
}
)

_ = await cache.lookup("192.0.2.1")
_ = await cache.lookup("192.0.2.2")
#expect(await cache.isSuppressingLookups)

await cache.invalidateNetworkScoped()
#expect(await cache.isSuppressingLookups == false)
}

@Test("Stale lookups finishing after invalidateNetworkScoped do not re-trip the breaker")
func staleScopedLookupsDoNotRetripBreaker() async {
let lookup = MultiSuspendedLookup()
let cache = RDNSCache(
lookupDeadline: 0.05,
resolver: { ip in
_ = await lookup.resolve(ip)
try? await Task.sleep(for: .milliseconds(60))
return nil
}
)

// Start 2 scoped lookups on the old network
let task1 = Task { await cache.lookup("192.168.1.1") }
let task2 = Task { await cache.lookup("192.168.1.2") }

await lookup.waitUntilStarted("192.168.1.1")
await lookup.waitUntilStarted("192.168.1.2")

// Invalidate network-scoped rDNS and reset breaker
await cache.invalidateNetworkScoped()
#expect(await cache.isSuppressingLookups == false)

// Resume the stale lookups so they complete with stall durations
await lookup.resume("192.168.1.1", returning: nil)
await lookup.resume("192.168.1.2", returning: nil)

_ = await task1.value
_ = await task2.value

// The breaker must NOT be re-tripped by stale lookups
#expect(await cache.isSuppressingLookups == false)
}

@Test("In-flight scoped lookup is rejected while concurrent global lookup succeeds")
func inFlightScopedVsGlobal() async {
let lookup = MultiSuspendedLookup()
let cache = RDNSCache(resolver: { ip in await lookup.resolve(ip) })

let scopedTask = Task { await cache.lookup("192.168.1.1") }
let globalTask = Task { await cache.lookup("1.1.1.1") }

await lookup.waitUntilStarted("192.168.1.1")
await lookup.waitUntilStarted("1.1.1.1")

// Invalidate network-scoped rDNS while both are in flight
await cache.invalidateNetworkScoped()

await lookup.resume("192.168.1.1", returning: "router.local")
await lookup.resume("1.1.1.1", returning: "one.one.one.one")

let scopedResult = await scopedTask.value
let globalResult = await globalTask.value

#expect(scopedResult == nil)
#expect(globalResult == "one.one.one.one")

// Global result is cached; scoped result is NOT cached
#expect(await cache.count == 1)
}

@Test("SwiftFTR.invalidateNetworkScopedRDNS clears scoped entries via actor")
func tracerScopedEviction() async {
let tracer = SwiftFTR(config: SwiftFTRConfig(noReverseDNS: false))
_ = await tracer.rdnsCache.lookup("192.168.1.1")
_ = await tracer.rdnsCache.lookup("1.1.1.1")

await tracer.invalidateNetworkScopedRDNS()
#expect(await tracer.rdnsCache.count == 1)
}
}

private actor MultiSuspendedLookup {
private var startedKeys: Set<String> = []
private var startWaiters: [String: [CheckedContinuation<Void, Never>]] = [:]
private var continuations: [String: CheckedContinuation<String?, Never>] = [:]

func resolve(_ key: String) async -> String? {
startedKeys.insert(key)
if let waiters = startWaiters.removeValue(forKey: key) {
for waiter in waiters { waiter.resume() }
}
return await withCheckedContinuation { cont in
continuations[key] = cont
}
}

func waitUntilStarted(_ key: String) async {
if startedKeys.contains(key) { return }
await withCheckedContinuation { cont in
startWaiters[key, default: []].append(cont)
}
}

func resume(_ key: String, returning value: String?) {
continuations.removeValue(forKey: key)?.resume(returning: value)
}
}

private actor SuspendedLookup {
Expand Down