From 7e01825e51d60bb93da572ea6500fa87e9effea1 Mon Sep 17 00:00:00 2001 From: Will Hannah Date: Thu, 6 Aug 2026 16:01:16 -0400 Subject: [PATCH] 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 * 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 --------- Signed-off-by: Will Hannah Signed-off-by: Jonathan Nobels Co-authored-by: Jonathan Nobels --- control/controlknobs/controlknobs.go | 10 +++ ipn/ipnlocal/node_backend.go | 36 ++++++++ net/dns/config.go | 13 +++ net/dns/manager.go | 37 ++++++-- net/dns/manager_test.go | 128 ++++++++++++++++++++++++++- tailcfg/tailcfg.go | 12 ++- 6 files changed, 227 insertions(+), 9 deletions(-) diff --git a/control/controlknobs/controlknobs.go b/control/controlknobs/controlknobs.go index 280953cd9..45bfde0fa 100644 --- a/control/controlknobs/controlknobs.go +++ b/control/controlknobs/controlknobs.go @@ -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 diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index fbc6317c0..bf79600b0 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -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 diff --git a/net/dns/config.go b/net/dns/config.go index da19e7035..8e82a3247 100644 --- a/net/dns/config.go +++ b/net/dns/config.go @@ -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 { diff --git a/net/dns/manager.go b/net/dns/manager.go index b34c5b632..44a0782d7 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -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. diff --git a/net/dns/manager_test.go b/net/dns/manager_test.go index b10c89056..efd9d2c6f 100644 --- a/net/dns/manager_test.go +++ b/net/dns/manager_test.go @@ -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{ diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 5923e1a6d..99276d7ae 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -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