diff --git a/modules/officer-net/gocore/dns.go b/modules/officer-net/gocore/dns.go index 80eea8e..cfd015f 100644 --- a/modules/officer-net/gocore/dns.go +++ b/modules/officer-net/gocore/dns.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log" "net/netip" + "strings" "sync" "tailscale.com/net/dns" @@ -89,3 +90,81 @@ func searchDomainsFor(suffix string) []dnsname.FQDN { } return []dnsname.FQDN{f} } + +/* +DNSMatchDomains returns every domain the OS must route to the tunnel's resolver, as a JSON array. + +# WHY IT EXISTS + +iOS decides which names reach us with `NEDNSSettings.matchDomains`, and the provider was setting that +to the MagicDNS suffix alone. That is right for short tailnet names and wrong for everything the +control plane overrides: Headscale can point an arbitrary name at a tailnet address, either through +`dns.extra_records` (an A record it serves directly) or `dns.nameservers.split` (a resolver for one +domain). Those names are not under the tailnet suffix, so iOS never sent them here — the device asked +public DNS instead and got the public answer, which on a tailnet address means a DIFFERENT machine. + +Confirmed on 2026-08-18: an iPad resolved `officer.pertento.ai` to 100.64.0.4 (public) rather than the +bridge at 100.64.0.12, and the TLS handshake failed against a host holding no certificate for that +name. Desktop clients on the same tailnet were fine, because their resolvers apply the whole config. + +The data was already on the device — `CorpDNS: true` means the netmap carries it — it simply had no +way across to Swift. + +WHAT IS INCLUDED + + - `Routes` split-DNS: one resolver per domain. The keys ARE the domains. + - `Domains` the tailnet's own search domains, the suffix among them. + - ExtraRecords A/AAAA records served by control. A record is a HOST, so its parent is what + matters: `officer.pertento.ai` contributes `pertento.ai`, which also covers the + siblings in the same zone rather than needing one entry each. + +Deliberately NOT the catch-all `[""]`. That is reserved for exit-node mode, where all resolution has +to move to the core anyway. Off an exit node it would put every lookup on the device through a +resolver that cannot always forward — which is the failure Android hit, and a far worse one than the +bug this fixes: a broken tailnet name is an app that will not load, a broken everything is a phone +with no internet. +*/ +func DNSMatchDomains() string { + mu.Lock() + defer mu.Unlock() + if backend == nil { + return "[]" + } + nm := backend.NetMap() + if nm == nil { + return "[]" + } + + seen := map[string]bool{} + out := []string{} + add := func(d string) { + d = strings.Trim(strings.TrimSpace(strings.ToLower(d)), ".") + // An empty entry is the catch-all, which this must never emit by accident — see above. + if d == "" || seen[d] { + return + } + seen[d] = true + out = append(out, d) + } + + for domain := range nm.DNS.Routes { + add(domain) + } + for _, d := range nm.DNS.Domains { + add(d) + } + for _, rec := range nm.DNS.ExtraRecords { + // The parent zone, not the host: one entry covers every sibling record control adds later + // without the device having to be told about each one. + name := strings.Trim(strings.ToLower(rec.Name), ".") + if i := strings.Index(name, "."); i >= 0 && i+1 < len(name) { + add(name[i+1:]) + } + } + + b, err := json.Marshal(out) + if err != nil { + return "[]" + } + return string(b) +} diff --git a/modules/officer-net/ios/PacketTunnelProvider.swift b/modules/officer-net/ios/PacketTunnelProvider.swift index 0141a62..95c5cfc 100644 --- a/modules/officer-net/ios/PacketTunnelProvider.swift +++ b/modules/officer-net/ios/PacketTunnelProvider.swift @@ -372,7 +372,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let fm = FileManager.default try? fm.removeItem(at: savedIPsURL) try? fm.removeItem(at: savedSuffixURL) - d("cleared cached node IP + MagicDNS suffix (profile changed)") + // The override domains belong to the tailnet that sent them; a different profile is a different + // tailnet, and claiming the old one's names would capture DNS for a network we are no longer on. + try? fm.removeItem(at: savedMatchURL) + d("cleared cached node IP, MagicDNS suffix and DNS match domains (profile changed)") } private var savedIPsURL: URL { @@ -488,6 +491,63 @@ class PacketTunnelProvider: NEPacketTunnelProvider { try? s.write(to: savedSuffixURL, atomically: true, encoding: .utf8) } + private var savedMatchURL: URL { + URL(fileURLWithPath: stateDirectory()).appendingPathComponent("dns-match-domains.json") + } + + /* + The domains control overrides, remembered for exactly the reason the suffix is. + + The fast path applies settings before any netmap exists, so without this a reconnect resolves + tailnet names and silently misses every `extra_records` / split-DNS name until the netmap lands — + which is the bug this whole change is about, arriving a second time through a different door. + */ + private func savedMatchDomains() -> [String] { + guard let data = try? Data(contentsOf: savedMatchURL), + let list = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] } + return list + } + + private func saveMatchDomains(_ list: [String]) { + guard let data = try? JSONSerialization.data(withJSONObject: list) else { return } + try? data.write(to: savedMatchURL, options: .atomic) + } + + /* + Every domain the tunnel must answer for: the tailnet suffix, plus whatever control overrides. + + The core has always known the second half — `CorpDNS: true` means the netmap carries the split-DNS + routes and the extra records — but nothing carried it across to here, so matchDomains was the + suffix alone and an overridden name like `officer.pertento.ai` went to public DNS. Public DNS + answers with a TAILNET address, which on this tailnet is a different machine: the TLS handshake + then fails against a host with no certificate for that name. Confirmed on an iPad, 2026-08-18. + + Falls back to what was saved when the core has no netmap yet, so the fast path is not a downgrade. + */ + private func matchDomains(suffix: String?) -> [String] { + var out: [String] = [] + var seen = Set() + let add = { (d: String) in + let clean = d.trimmingCharacters(in: CharacterSet(charactersIn: " .")).lowercased() + // Never emit the empty entry: that is Apple's catch-all, and claiming every name off an exit + // node would route all resolution through a resolver that cannot always forward. + if !clean.isEmpty && !seen.contains(clean) { + seen.insert(clean) + out.append(clean) + } + } + if let suffix { add(suffix) } + + var overrides: [String] = [] + if let data = TailcoreDNSMatchDomains().data(using: .utf8), + let list = try? JSONSerialization.jsonObject(with: data) as? [String] { + overrides = list + } + if overrides.isEmpty { overrides = savedMatchDomains() } else { saveMatchDomains(overrides) } + overrides.forEach(add) + return out + } + private func savedIPs() -> [String] { guard let data = try? Data(contentsOf: savedIPsURL), let ips = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] } @@ -675,12 +735,23 @@ class PacketTunnelProvider: NEPacketTunnelProvider { settings.dnsSettings = dns if let suffix, !suffix.isEmpty { saveSuffix(suffix) } d("DNS: ALL names via core (exit node active)") - } else if let suffix, !suffix.isEmpty { - let dns = NEDNSSettings(servers: ["100.100.100.100"]) - dns.matchDomains = [suffix] - settings.dnsSettings = dns - saveSuffix(suffix) - d("MagicDNS on for .\(suffix)") + } else { + /* + The tailnet suffix AND every domain control overrides — see matchDomains(suffix:). + + Previously `[suffix]`, which silently excluded every `extra_records` and split-DNS name because + those live under an unrelated domain. Still narrow, still not the catch-all: this claims the + names the tailnet actually answers for and leaves the rest of the internet on the system + resolver, which is what keeps a core that cannot forward from taking the whole phone offline. + */ + let domains = matchDomains(suffix: suffix) + if !domains.isEmpty { + let dns = NEDNSSettings(servers: ["100.100.100.100"]) + dns.matchDomains = domains + settings.dnsSettings = dns + if let suffix, !suffix.isEmpty { saveSuffix(suffix) } + d("DNS via core for: \(domains.joined(separator: ", "))") + } } return settings }