mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-13 06:19:11 -04:00
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:
1 parent
deded79f0e
commit
7e01825e51
6 files changed
+227
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user