net/dns: scope quad-100 on macOS so DoH profiles aren't shadowed (#20603)

* net/dns: scope quad-100 on macOS so DoH profiles aren't shadowed

On sandboxed macOS, an uncovered control ExtraRecord forced quad-100 to
be the primary resolver, proxying all public DNS and shadowing a user's
DoH system profile. Scope quad-100 to its match domains instead, adding
the uncovered host records to MatchDomains so they still resolve while
public names fall through to the OS resolver. quad-100 remains primary
only without a usable base resolver or with non-enumerable MagicDNS
host records.

fixes tailscale/corp#45534

Signed-off-by: Will Hannah <willh@tailscale.com>

* net/dns: move scoped DNS behind an envknob

updates tailscale/corp#45534

Given the sensitivity of this change, let's stuff it behind
a control knob for a release.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

Signed-off-by: Will Hannah <willh@tailscale.com>
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Jonathan Nobels <jonathan@tailscale.com>
This commit is contained in:
Will HannahandJonathan Nobels authored and GitHub committed 2026-08-06 13:01:16 -07:00
1 parent deded79f0e
commit 7e01825e51
6 files changed
+227 -9

No files matched your search

+10
View File
@@ -140,6 +140,14 @@ type Knobs struct {
// maps and use them to establish peer connectivity on start, if doing so
// is supported by the client and storage is available.
CacheNetworkMaps atomic.Bool
// ScopeQuad100OnMacOS is whether sandboxed macOS should scope quad-100 to
// its match domains rather than installing it as the OS's primary resolver,
// so a user's DoH system profile isn't shadowed. It has no effect on other
// platforms. Off by default; when off, sandboxed macOS keeps the older
// behavior of making quad-100 the default resolver, as iOS still does.
// See tailscale/corp#45534.
ScopeQuad100OnMacOS atomic.Bool
}
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
@@ -176,6 +184,7 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
disableTUNTCPGRO = has(tailcfg.NodeAttrDisableTUNTCPGRO)
neverGSOEqualTail = has(tailcfg.NodeAttrNeverGSOEqualTail)
cacheNetworkMaps = has(tailcfg.NodeAttrCacheNetworkMaps)
scopeQuad100OnMacOS = has(tailcfg.NodeAttrScopeQuad100OnMacOS)
)
if has(tailcfg.NodeAttrOneCGNATEnable) {
@@ -210,6 +219,7 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
k.DisableTUNTCPGRO.Store(disableTUNTCPGRO)
k.NeverGSOEqualTail.Store(neverGSOEqualTail)
k.CacheNetworkMaps.Store(cacheNetworkMaps)
k.ScopeQuad100OnMacOS.Store(scopeQuad100OnMacOS)
}
// AsDebugJSON returns k as something that can be marshalled with json.Marshal
+36
View File
@@ -1503,6 +1503,9 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
set(peer.Name(), peer.Addresses())
}
}
// extraRecordNames are the ExtraRecord FQDNs, tracked separately from
// dcfg.Hosts because on Windows that map also holds every node's records.
var extraRecordNames []dnsname.FQDN
for _, rec := range nm.DNS.ExtraRecords {
switch rec.Type {
case "", "A", "AAAA":
@@ -1520,6 +1523,9 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
if err != nil {
continue
}
if !slices.Contains(extraRecordNames, fqdn) {
extraRecordNames = append(extraRecordNames, fqdn)
}
dcfg.Hosts[fqdn] = append(dcfg.Hosts[fqdn], ip)
}
@@ -1568,6 +1574,34 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
}
}
// coverExtraRecords adds an authoritative (resolver-less) route for each
// ExtraRecord name not already covered by one, so dns.Manager can scope
// quad-100 to those names instead of having to install it as the OS's
// primary resolver just to answer them. This is the same convention
// addSplitDNSRoutes implements for control-sent empty routes (Issue 2706);
// here we apply it to records control sent without a matching route.
//
// Only ExtraRecords, not all of dcfg.Hosts: on Windows Hosts also carries
// every node's records, and routing each one individually would mean a
// per-node NRPT rule.
//
// Must run after all addSplitDNSRoutes calls so a control-sent route for
// the same suffix wins.
coverExtraRecords := func() {
for _, fqdn := range extraRecordNames {
covered := false
for route := range dcfg.Routes {
if route.Contains(fqdn) {
covered = true
break
}
}
if !covered {
dcfg.Routes[fqdn] = nil
}
}
}
// conn25 split DNS routes are calculated from the domains in the SelfNode.CapMap
// section of the netmap, so need to be assembled separately.
// TODO(tailscale/corp#37125): make this a hook the extension can add
@@ -1593,6 +1627,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
addSplitDNSRoutes(useWithExitNodeRoutes(nm.DNS.Routes))
addSplitDNSRoutes(useWithExitNodeRoutes(conn25AppRoutes))
coverExtraRecords()
return dcfg
}
}
@@ -1611,6 +1646,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
// Add split DNS routes, with no regard to exit node configuration.
addSplitDNSRoutes(nm.DNS.Routes)
addSplitDNSRoutes(conn25AppRoutes)
coverExtraRecords()
// Set FallbackResolvers as the default resolvers in the
// scenarios that can't handle a purely split-DNS config. See
+13
View File
@@ -150,6 +150,19 @@ func (c Config) hasHostsWithoutSplitDNSRoutes() bool {
return false
}
// requiresPrimaryResolver reports whether c can only be served correctly with
// quad-100 installed as the OS's primary (catch-all) resolver, rather than
// scoped to a set of match domains.
//
// That's the case when c has names quad-100 must answer that no route suffix
// covers, so there is no suffix to scope to. dnsConfigForNetmap pairs every
// ExtraRecord it emits with a route, so in practice this is the
// MagicDNS-names-present but MagicDNS-domain-routing-off case
// (MagicDNSHostsUnrouted).
func (c Config) requiresPrimaryResolver() bool {
return c.hasHostsWithoutSplitDNSRoutes()
}
// hasSplitDNSRouteForHost reports whether c contains a SplitDNS route
// that contains hosts.
func (c Config) hasSplitDNSRouteForHost(host dnsname.FQDN) bool {
+32 -5
View File
@@ -20,6 +20,7 @@
"time"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/net/dns/resolver"
@@ -320,6 +321,9 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
routes[suffix] = resolvers
}
}
// LocalDomains is an unordered suffix set, but it comes out of map
// iteration; sort it so equal configs compare and log equal.
slices.Sort(rcfg.LocalDomains)
// Similarly, the OS always gets search paths.
ocfg.SearchDomains = cfg.SearchDomains
@@ -378,11 +382,13 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
// workaround.
isWindows := m.goos == "windows"
isIOS := m.goos == "ios"
isSandboxedMac := m.goos == "darwin" && isSandboxedMacOS()
supportsSplitDNS := m.os.SupportsSplitDNS()
// Sandboxed macOS builds use NetworkExtension DNS settings, not
// tailscaled's /etc/resolver configurator, so keep the Apple workaround.
appleSplitDNSWorkaround := isIOS || (m.goos == "darwin" && isSandboxedMacOS())
if supportsSplitDNS && !isWindows && !appleSplitDNSWorkaround {
isSandboxedApple := isIOS || isSandboxedMac
// Apple platforms keep split-domain traffic pointed at quad-100 rather than
// handing the upstream resolvers to the OS directly, because those resolvers
// may only be reachable through the tunnel.
if supportsSplitDNS && !isWindows && !isSandboxedApple {
if srs := toIPsOnly(cfg.singleResolverSet()); len(srs) > 0 {
// Split DNS configuration requested, where all split domains
// go to the same resolvers. We can let the OS do it.
@@ -398,7 +404,15 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
rcfg.Routes = routes
ocfg.Nameservers = cfg.serviceIPs(m.knobs)
if supportsSplitDNS && !appleSplitDNSWorkaround {
// usePrimaryResolver forces quad-100 to be installed as the OS's primary
// (catch-all) resolver rather than scoped to the match domains. iOS always
// does this (it has no way to selectively answer ExtraRecords). Sandboxed
// macOS did too until control opts it into scoping via
// NodeAttrScopeQuad100OnMacOS, so that a user's DoH system profile isn't
// shadowed by quad-100. See tailscale/corp#45534.
usePrimaryResolver := isIOS || (isSandboxedMac && !m.scopeQuad100OnMacOS())
if supportsSplitDNS && !usePrimaryResolver && !cfg.requiresPrimaryResolver() {
ocfg.MatchDomains = cfg.matchDomains()
return rcfg, ocfg, nil
}
@@ -464,6 +478,19 @@ func (m *Manager) disableSplitDNSOptimization() bool {
return m.knobs != nil && m.knobs.DisableSplitDNSWhenNoCustomResolvers.Load()
}
var scopeQuad100OnMacOSEnv = envknob.RegisterOptBool("TS_DEBUG_SCOPE_QUAD100_MACOS")
// scopeQuad100OnMacOS reports whether sandboxed macOS should scope quad-100 to
// its match domains rather than installing it as the OS's primary resolver.
// Off (false) unless control sets NodeAttrScopeQuad100OnMacOS, or the
// TS_DEBUG_SCOPE_QUAD100_MACOS env override is set. See tailscale/corp#45534.
func (m *Manager) scopeQuad100OnMacOS() bool {
if v, ok := scopeQuad100OnMacOSEnv().Get(); ok {
return v
}
return m.knobs != nil && m.knobs.ScopeQuad100OnMacOS.Load()
}
var isSandboxedMacOS = version.IsSandboxedMacOS
// toIPsOnly returns only the IP portion of dnstype.Resolver.
+125 -3
View File
@@ -175,6 +175,15 @@ func TestCompileHostEntries(t *testing.T) {
var serviceAddr46 = []netip.Addr{tsaddr.TailscaleServiceIP(), tsaddr.TailscaleServiceIPv6()}
// scopeQuad100Knobs returns Knobs with ScopeQuad100OnMacOS set, i.e. the
// NodeAttrScopeQuad100OnMacOS opt-in that lets sandboxed macOS scope quad-100
// to its match domains instead of installing it as the primary resolver.
func scopeQuad100Knobs() *controlknobs.Knobs {
k := new(controlknobs.Knobs)
k.ScopeQuad100OnMacOS.Store(true)
return k
}
func TestManager(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skipf("test's assumptions break because of https://github.com/tailscale/corp/issues/1662")
@@ -452,15 +461,49 @@ func TestManager(t *testing.T) {
},
},
{
// Sandboxed macOS app builds use NetworkExtension DNS settings, not
// tailscaled's /etc/resolver configurator, so they keep the older
// Apple base-config behavior.
// Sandboxed macOS app builds use NetworkExtension DNS settings
// rather than tailscaled's /etc/resolver configurator, so split
// traffic stays pointed at quad-100 instead of handing 2.2.2.2 to
// the OS directly (it may only be reachable via the tunnel). With
// NodeAttrScopeQuad100OnMacOS set, quad-100 is scoped to the match
// domains, so public names fall through to the OS resolver -- e.g. a
// DoH profile -- rather than being shadowed by a "." route. See the
// -no-knob variant below for the default behavior. tailscale/corp#45534.
name: "routes-split-sandboxed-darwin",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
knobs: scopeQuad100Knobs(),
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("corp.com"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2"),
},
goos: "darwin",
sandboxedMacOS: true,
},
{
// Same as above but without NodeAttrScopeQuad100OnMacOS: the default
// on sandboxed macOS, matching iOS. quad-100 is installed as the OS
// primary resolver (a "." route to the base config's 8.8.8.8), so it
// proxies all public DNS -- shadowing any DoH profile -- and is not
// scoped to MatchDomains. tailscale/corp#45534.
name: "routes-split-sandboxed-darwin-no-knob",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
@@ -477,6 +520,85 @@ func TestManager(t *testing.T) {
goos: "darwin",
sandboxedMacOS: true,
},
{
// An ExtraRecord that dnsConfigForNetmap has paired with an
// authoritative (resolver-less) route is scoped like any other
// split domain, on every platform. Nothing here is Apple-specific:
// the darwin and linux variants below must agree.
name: "extra-record-routed-scopes-quad100",
in: Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
Routes: upstreams("corp.ts.net.", "", "extra.example.com.", ""),
SearchDomains: fqdns("corp.ts.net"),
},
split: true,
knobs: scopeQuad100Knobs(),
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
MatchDomains: fqdns("corp.ts.net", "extra.example.com"),
},
rs: resolver.Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
LocalDomains: fqdns("corp.ts.net.", "extra.example.com."),
},
goos: "darwin",
sandboxedMacOS: true,
},
{
name: "extra-record-routed-scopes-quad100-linux",
in: Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
Routes: upstreams("corp.ts.net.", "", "extra.example.com.", ""),
SearchDomains: fqdns("corp.ts.net"),
},
split: true,
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
MatchDomains: fqdns("corp.ts.net", "extra.example.com"),
},
rs: resolver.Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
LocalDomains: fqdns("corp.ts.net.", "extra.example.com."),
},
},
{
// MagicDNS names exist but MagicDNS domain routing is off, so no
// route suffix covers them and there is nothing to scope to.
// quad-100 must stay primary or those names stop resolving --
// requiresPrimaryResolver overrides NodeAttrScopeQuad100OnMacOS even
// when it's set. The DoH profile is still shadowed here; that is the
// cost of serving unrouted names.
name: "unrouted-magicdns-hosts-keep-quad100-primary",
in: Config{
Routes: upstreams("corp.ts.net.", "1.2.3.4"),
SearchDomains: fqdns("corp.ts.net"),
MagicDNSHostsUnrouted: true,
},
split: true,
knobs: scopeQuad100Knobs(),
bs: OSConfig{
Nameservers: mustIPs("192.168.1.1"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
},
rs: resolver.Config{
Routes: upstreams(
".", "192.168.1.1",
"corp.ts.net.", "1.2.3.4"),
},
goos: "darwin",
sandboxedMacOS: true,
},
{
name: "routes-multi",
in: Config{
+11 -1
View File
@@ -191,7 +191,8 @@
// - 142: 2026-07-06: Client understands c2n /remoteapi/localapi/* proxy
// - 143: 2026-07-22: Client correctly ignores conn25 node attributes when not enabled by environment variable
// - 144: 2026-07-31: Client sends [packet.TSMPDiscoKeyAdvertisement] around WireGuard handshakes
const CurrentCapabilityVersion CapabilityVersion = 144
// - 145: 2026-08-04: Client understands [NodeAttrScopeQuad100OnMacOS]
const CurrentCapabilityVersion CapabilityVersion = 145
// ID is an integer ID for a user, node, or login allocated by the
// control plane.
@@ -2697,6 +2698,15 @@ func (p NodeCapabilityPrefix) ToAttribute(value string) NodeCapability {
// if needed.
NodeAttrDisableSplitDNSWhenNoCustomResolvers NodeCapability = "disable-split-dns-when-no-custom-resolvers"
// NodeAttrScopeQuad100OnMacOS makes sandboxed macOS clients scope quad-100
// to its match domains instead of installing it as the OS's primary
// (catch-all) resolver, so that public names fall through to the OS
// resolver -- e.g. a user's DoH system profile -- rather than being
// shadowed. It has no effect on any other platform. Without this attribute,
// sandboxed macOS keeps the older behavior of making quad-100 the default
// resolver, as iOS still does. See tailscale/corp#45534.
NodeAttrScopeQuad100OnMacOS NodeCapability = "scope-quad100-macos"
// NodeAttrDisableLocalDNSOverrideViaNRPT indicates that the node's DNS manager should not
// create a default (catch-all) Windows NRPT rule when "Override local DNS" is enabled.
// Without this rule, Windows 8.1 and newer devices issue parallel DNS requests to DNS servers