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>
171 lines
6.3 KiB
Go
171 lines
6.3 KiB
Go
package tailcore
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
|
|
"tailscale.com/net/dns"
|
|
"tailscale.com/util/dnsname"
|
|
)
|
|
|
|
// MagicDNS — the piece that makes short names like `alpha` resolve.
|
|
//
|
|
// The resolver itself lives in the Tailscale core and answers on 100.100.100.100; netstack routes
|
|
// queries to it, but only if a dns.Manager exists. Ours never created one, so that address answered
|
|
// nothing and pointing the OS at it black-holed every lookup. See OFFTAIL_ANALYSIS_2026-08-02.md.
|
|
//
|
|
// The manager needs an OSConfigurator. On desktop that writes /etc/resolv.conf or talks to
|
|
// NetworkManager; on mobile the OS applies DNS itself (VpnService.Builder / NEPacketTunnelNetworkSettings),
|
|
// so the platform-specific ones are wrong here — manager_darwin.go would try to rewrite the phone's
|
|
// resolv.conf. Hence this no-op.
|
|
//
|
|
// One method is NOT a no-op and is the whole reason this file is subtle: GetBaseConfig supplies the
|
|
// UPSTREAM resolvers the tailnet resolver forwards non-tailnet queries to. Return nothing and only
|
|
// tailnet names resolve while the rest of the internet goes dark — the exact failure we just spent an
|
|
// evening removing. The platform hands them in via SetUpstreamDNS before Start.
|
|
|
|
var (
|
|
upstreamMu sync.Mutex
|
|
upstreamDNS []netip.Addr
|
|
)
|
|
|
|
// SetUpstreamDNS gives the core the resolvers of the underlying network, as a JSON array of IP
|
|
// strings. Call BEFORE Start. Mirrors SetAndroidInterfaces: gomobile-friendly, JSON in, nothing out.
|
|
//
|
|
// The platform already knows these — Android reads them off the non-VPN network's LinkProperties,
|
|
// iOS gets them from the system — and the core cannot discover them itself once the tunnel is up,
|
|
// because by then it would only see its own.
|
|
func SetUpstreamDNS(jsonArray string) {
|
|
var ips []string
|
|
if err := json.Unmarshal([]byte(jsonArray), &ips); err != nil {
|
|
log.Printf("[tailcore] SetUpstreamDNS: %v", err)
|
|
return
|
|
}
|
|
var addrs []netip.Addr
|
|
for _, s := range ips {
|
|
if a, err := netip.ParseAddr(s); err == nil {
|
|
addrs = append(addrs, a)
|
|
}
|
|
}
|
|
upstreamMu.Lock()
|
|
upstreamDNS = addrs
|
|
upstreamMu.Unlock()
|
|
log.Printf("[tailcore] upstream DNS = %v", addrs)
|
|
}
|
|
|
|
func baseResolvers() []netip.Addr {
|
|
upstreamMu.Lock()
|
|
defer upstreamMu.Unlock()
|
|
return append([]netip.Addr(nil), upstreamDNS...)
|
|
}
|
|
|
|
// mobileOSConfigurator applies nothing: the OS already has the DNS settings, handed to it by
|
|
// VpnService.Builder / NEPacketTunnelNetworkSettings alongside the routes.
|
|
type mobileOSConfigurator struct{}
|
|
|
|
func (mobileOSConfigurator) SetDNS(dns.OSConfig) error { return nil }
|
|
|
|
// False: we cannot split DNS at the OS level on mobile, so the core must handle every query itself —
|
|
// answering tailnet names and forwarding the rest to the base config below. Claiming true would have
|
|
// the core assume the OS routes only tailnet names to it, and everything else would be dropped.
|
|
func (mobileOSConfigurator) SupportsSplitDNS() bool { return false }
|
|
|
|
func (mobileOSConfigurator) GetBaseConfig() (dns.OSConfig, error) {
|
|
return dns.OSConfig{Nameservers: baseResolvers()}, nil
|
|
}
|
|
|
|
func (mobileOSConfigurator) Close() error { return nil }
|
|
|
|
// searchDomainsFor is a convenience for callers that want the tailnet suffix as a search domain.
|
|
func searchDomainsFor(suffix string) []dnsname.FQDN {
|
|
if suffix == "" {
|
|
return nil
|
|
}
|
|
f, err := dnsname.ToFQDN(suffix)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
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)
|
|
}
|