diff --git a/modules/officer-net/ios/PacketTunnelProvider.swift b/modules/officer-net/ios/PacketTunnelProvider.swift index 95c5cfc..208be52 100644 --- a/modules/officer-net/ios/PacketTunnelProvider.swift +++ b/modules/officer-net/ios/PacketTunnelProvider.swift @@ -123,6 +123,19 @@ class PacketTunnelProvider: NEPacketTunnelProvider { completionHandler(nil) // Still watch the netmap, purely to keep the saved IP current if Headscale reassigns it. self.refreshSavedIPs() + /* + And re-apply if the tailnet's DNS overrides turn out to differ from what we just used. + + The fast path applies settings BEFORE any netmap exists, so its match domains can only come + from the file the last refine wrote. That is fine for the suffix, which never changes, and + wrong for `extra_records` / split-DNS domains: on the very first connect there is no file + yet, so the tunnel comes up resolving tailnet names only and STAYS that way — every later + connect takes this path and nothing ever re-applies. + + Which is exactly the shape of the bug reported on 2026-08-18: an override that was supposed + to be captured kept going to public DNS, on a device that had connected more than once. + */ + self.reapplyIfDNSChanged(selfIPs: known) } else { // First connect only. Complete on the placeholder rather than blocking for up to 15s — // the previous version waited before completing, which is its own teardown risk — and @@ -572,6 +585,34 @@ class PacketTunnelProvider: NEPacketTunnelProvider { /// Poll the core until it has a netmap (self Tailscale IPs), then re-apply the tunnel settings with the /// real per-node address + tailnet routes + MagicDNS. The initial settings in startTunnel are just /// placeholders to bring the utun up; this makes routing actually correct. + /* + Watch for the netmap's DNS overrides and re-apply settings once, if they are not what we used. + + Cheap and bounded: it polls the same way the netmap refine does, stops the moment the domains match + what is already applied, and gives up after the same window. Re-applying on a live tunnel is not + novel — the exit-node and accept-routes paths have always done it. + */ + private func reapplyIfDNSChanged(selfIPs: [String]) { + let applied = Set(savedMatchDomains()) + Task { + for _ in 0..<120 { + try? await Task.sleep(nanoseconds: 500_000_000) + guard let data = TailcoreDNSMatchDomains().data(using: .utf8), + let list = try? JSONSerialization.jsonObject(with: data) as? [String], + !list.isEmpty else { continue } + if Set(list) == applied { + self.d("DNS overrides unchanged (\(list.count) domains)") + return + } + self.d("DNS overrides changed — re-applying: \(list.joined(separator: ", "))") + self.saveMatchDomains(list) + self.applySettings(selfIPs: selfIPs, magicDNSSuffix: self.savedSuffix()) + return + } + self.d("no DNS overrides seen from the netmap within 60s") + } + } + private func refineSettingsFromNetmap() { d("refine: start polling") Task {