fix(dns): re-apply on the fast path, where overrides were never picked up
The first fix widened matchDomains but only where the netmap refine runs — first connect. Every connect after that takes the fast path, which applies settings before any netmap exists and then deliberately does not touch the live tunnel again. So its match domains could only come from the file the previous refine wrote. Fine for the suffix, which never changes; wrong for extra_records and split-DNS domains, because on the first connect there is no file yet — the tunnel comes up resolving tailnet names only and stays that way, since nothing re-applies on any later connect. That is the shape of what Andre still saw after reinstalling and reconnecting: officer.pertento.ai going to public DNS and failing TLS against 100.64.0.4, on a device that had connected more than once. The fast path now watches for the overrides and re-applies once if they differ from what it used. Bounded like the existing refine, silent when they match, and re-applying on a live tunnel is not novel — the exit-node and accept-routes paths have always done it. Still unconfirmed on hardware: this is the most likely cause found by reading, not a diagnosis from device logs. The tunnel now says which domains it claimed, so the next attempt can be settled rather than reasoned about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,19 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
completionHandler(nil)
|
completionHandler(nil)
|
||||||
// Still watch the netmap, purely to keep the saved IP current if Headscale reassigns it.
|
// Still watch the netmap, purely to keep the saved IP current if Headscale reassigns it.
|
||||||
self.refreshSavedIPs()
|
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 {
|
} else {
|
||||||
// First connect only. Complete on the placeholder rather than blocking for up to 15s —
|
// 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
|
// 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
|
/// 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
|
/// 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.
|
/// 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() {
|
private func refineSettingsFromNetmap() {
|
||||||
d("refine: start polling")
|
d("refine: start polling")
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
Reference in New Issue
Block a user