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:
2026-08-18 05:53:27 +01:00
co-authored by Claude Opus 5
parent 5b1c0e76e1
commit a8e9dd712c
2 changed files with 157 additions and 7 deletions
+79
View File
@@ -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
}