fix(dns): iOS never matched the domains control overrides
Reported from an iPad on 2026-08-18: officer.pertento.ai, gitea.pertento.ai and *.edge.pertento.ai all failed with "couldn't establish a secure connection", while desktop clients on the same tailnet reached them with a valid certificate. The provider DID apply dnsSettings — it just scoped them to the MagicDNS suffix alone. Everything Headscale overrides lives under an unrelated domain: dns.extra_records serves an A record for a name like officer.pertento.ai, and dns.nameservers.split gives one domain its own resolver. Neither is under the tailnet suffix, so iOS never sent those queries to us. The device asked public DNS instead and got the public answer — which for these names is a TAILNET address belonging to a DIFFERENT machine (100.64.0.4 rather than the bridge at 100.64.0.12). TLS then failed against a host holding no certificate for that name, which is exactly what Safari reported. Nothing was missing from the server: CorpDNS is true, so the netmap already carried the split routes and the extra records. The data simply had no way across to Swift. So Go gains DNSMatchDomains(), returning the split-DNS route keys, the tailnet search domains, and the PARENT zone of each extra record — the parent because a record is a host, and one entry then covers its siblings without the device being told about each one. Swift builds matchDomains from the suffix plus those, and remembers them exactly as it remembers the suffix, because the fast path applies settings before any netmap exists and would otherwise reintroduce this bug on every reconnect. Forgotten with the suffix when the profile changes: those domains belong to the tailnet that sent them. Deliberately NOT the catch-all [""], which the COMMS note suggested as an option. That is already in this file and reserved for exit-node mode. Off an exit node it would put every lookup on the phone through a resolver that cannot always forward — the failure Android hit, and worse than this one: a broken tailnet name is an app that will not load, a broken everything is a phone with no internet. Android needs no change: it has no matchDomains equivalent and already routes all DNS at the core. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<String>()
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user