diff --git a/client/tailscale/apitype/controltype.go b/client/tailscale/apitype/controltype.go index 9a623be31..d9d79f0ad 100644 --- a/client/tailscale/apitype/controltype.go +++ b/client/tailscale/apitype/controltype.go @@ -3,17 +3,50 @@ package apitype +// DNSConfig is the DNS configuration for a tailnet +// used in /tailnet/{tailnet}/dns/config. type DNSConfig struct { - Resolvers []DNSResolver `json:"resolvers"` - FallbackResolvers []DNSResolver `json:"fallbackResolvers"` - Routes map[string][]DNSResolver `json:"routes"` - Domains []string `json:"domains"` - Nameservers []string `json:"nameservers"` - Proxied bool `json:"proxied"` - TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"` + // Resolvers are the global DNS resolvers to use + // overriding the local OS configuration. + Resolvers []DNSResolver `json:"resolvers"` + + // FallbackResolvers are used as global resolvers when + // the client is unable to determine the OS's preferred DNS servers. + FallbackResolvers []DNSResolver `json:"fallbackResolvers"` + + // Routes map DNS name suffixes to a set of DNS resolvers, + // used for Split DNS and other advanced routing overlays. + Routes map[string][]DNSResolver `json:"routes"` + + // Domains are the search domains to use. + Domains []string `json:"domains"` + + // Proxied means MagicDNS is enabled. + Proxied bool `json:"proxied"` + + // TempCorpIssue13969 is from an internal hack day prototype, + // See tailscale/corp#13969. + TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"` + + // Nameservers are the IP addresses of global nameservers to use. + // This is a deprecated format but may still be found in tailnets + // that were configured a long time ago. When making updates, + // set Resolvers and leave Nameservers empty. + Nameservers []string `json:"nameservers"` } +// DNSResolver is a DNS resolver in a DNS configuration. type DNSResolver struct { - Addr string `json:"addr"` + // Addr is the address of the DNS resolver. + // It is usually an IP address or a DoH URL. + // See dnstype.Resolver.Addr for full details. + Addr string `json:"addr"` + + // BootstrapResolution is an optional suggested resolution for + // the DoT/DoH resolver. BootstrapResolution []string `json:"bootstrapResolution,omitempty"` + + // UseWithExitNode signals this resolver should be used + // even when a tailscale exit node is configured on a device. + UseWithExitNode bool `json:"useWithExitNode,omitempty"` } diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 30833e748..49cfc3e07 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -2080,7 +2080,14 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { wantRoutes map[dnsname.FQDN][]*dnstype.Resolver } - defaultResolvers := []*dnstype.Resolver{{Addr: "default.example.com"}} + const tsUseWithExitNodeResolverAddr = "usewithexitnode.example.com" + defaultResolvers := []*dnstype.Resolver{ + {Addr: "default.example.com"}, + } + containsFlaggedResolvers := append([]*dnstype.Resolver{ + {Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}, + }, defaultResolvers...) + wgResolvers := []*dnstype.Resolver{{Addr: "wg.example.com"}} peers := []tailcfg.NodeView{ (&tailcfg.Node{ @@ -2099,9 +2106,33 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { }).View(), } exitDOH := peerAPIBase(&netmap.NetworkMap{Peers: peers}, peers[0]) + "/dns-query" - routes := map[dnsname.FQDN][]*dnstype.Resolver{ + baseRoutes := map[dnsname.FQDN][]*dnstype.Resolver{ "route.example.com.": {{Addr: "route.example.com"}}, } + containsEmptyRoutes := map[dnsname.FQDN][]*dnstype.Resolver{ + "route.example.com.": {{Addr: "route.example.com"}}, + "empty.example.com.": {}, + } + containsFlaggedRoutes := map[dnsname.FQDN][]*dnstype.Resolver{ + "route.example.com.": {{Addr: "route.example.com"}}, + "withexit.example.com.": {{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + } + containsFlaggedAndEmptyRoutes := map[dnsname.FQDN][]*dnstype.Resolver{ + "empty.example.com.": {}, + "route.example.com.": {{Addr: "route.example.com"}}, + "withexit.example.com.": {{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + } + flaggedRoutes := map[dnsname.FQDN][]*dnstype.Resolver{ + "withexit.example.com.": {{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + } + emptyRoutes := map[dnsname.FQDN][]*dnstype.Resolver{ + "empty.example.com.": {}, + } + flaggedAndEmptyRoutes := map[dnsname.FQDN][]*dnstype.Resolver{ + "empty.example.com.": {}, + "withexit.example.com.": {{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + } + stringifyRoutes := func(routes map[dnsname.FQDN][]*dnstype.Resolver) map[string][]*dnstype.Resolver { if routes == nil { return nil @@ -2138,19 +2169,23 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantRoutes: nil, }, + { + name: "tsExit/noRoutes/flaggedResolverOnly", + exitNode: "ts", + peers: peers, + dnsConfig: &tailcfg.DNSConfig{Resolvers: containsFlaggedResolvers}, + wantDefaultResolvers: []*dnstype.Resolver{{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + wantRoutes: nil, + }, - // The following two cases may need to be revisited. For a shared-in - // exit node split-DNS may effectively break, furthermore in the future - // if different nodes observe different DNS configurations, even a - // tailnet local exit node may present a different DNS configuration, - // which may not meet expectations in some use cases. - // In the case where a default resolver is set, the default resolver - // should also perhaps take precedence also. + // When at tailscale exit node is in use, + // only routes that reference resolvers with the UseWithExitNode should be installed, + // as well as routes with 0-length resolver lists, which should be installed in all cases. { name: "tsExit/routes/noResolver", exitNode: "ts", peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes)}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantRoutes: nil, }, @@ -2158,10 +2193,58 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { name: "tsExit/routes/defaultResolver", exitNode: "ts", peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes), Resolvers: defaultResolvers}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantRoutes: nil, }, + { + name: "tsExit/routes/flaggedResolverOnly", + exitNode: "ts", + peers: peers, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes), Resolvers: containsFlaggedResolvers}, + wantDefaultResolvers: []*dnstype.Resolver{{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + wantRoutes: nil, + }, + { + name: "tsExit/flaggedRoutesOnly/defaultResolver", + exitNode: "ts", + peers: peers, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(containsFlaggedRoutes), Resolvers: defaultResolvers}, + wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, + wantRoutes: flaggedRoutes, + }, + { + name: "tsExit/flaggedRoutesOnly/flaggedResolverOnly", + exitNode: "ts", + peers: peers, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(containsFlaggedRoutes), Resolvers: containsFlaggedResolvers}, + wantDefaultResolvers: []*dnstype.Resolver{{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + wantRoutes: flaggedRoutes, + }, + { + name: "tsExit/emptyRoutesOnly/defaultResolver", + exitNode: "ts", + peers: peers, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(containsEmptyRoutes), Resolvers: defaultResolvers}, + wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, + wantRoutes: emptyRoutes, + }, + { + name: "tsExit/flaggedAndEmptyRoutesOnly/defaultResolver", + exitNode: "ts", + peers: peers, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(containsFlaggedAndEmptyRoutes), Resolvers: defaultResolvers}, + wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, + wantRoutes: flaggedAndEmptyRoutes, + }, + { + name: "tsExit/flaggedAndEmptyRoutesOnly/flaggedResolverOnly", + exitNode: "ts", + peers: peers, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(containsFlaggedAndEmptyRoutes), Resolvers: containsFlaggedResolvers}, + wantDefaultResolvers: []*dnstype.Resolver{{Addr: tsUseWithExitNodeResolverAddr, UseWithExitNode: true}}, + wantRoutes: flaggedAndEmptyRoutes, + }, // WireGuard exit nodes with DNS capabilities provide a "fallback" type // behavior, they have a lower precedence than a default resolver, but @@ -2187,17 +2270,17 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { name: "wgExit/routes/defaultResolver", exitNode: "wg", peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes), Resolvers: defaultResolvers}, wantDefaultResolvers: defaultResolvers, - wantRoutes: routes, + wantRoutes: baseRoutes, }, { name: "wgExit/routes/noResolver", exitNode: "wg", peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, + dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes)}, wantDefaultResolvers: wgResolvers, - wantRoutes: routes, + wantRoutes: baseRoutes, }, } diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index ec503f130..a3889b643 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -578,6 +578,42 @@ func (nb *nodeBackend) doShutdown(cause error) { nb.eventClient.Close() } +// useWithExitNodeResolvers filters out resolvers so the ones that remain +// are all the ones marked for use with exit nodes. +func useWithExitNodeResolvers(resolvers []*dnstype.Resolver) []*dnstype.Resolver { + var filtered []*dnstype.Resolver + for _, res := range resolvers { + if res.UseWithExitNode { + filtered = append(filtered, res) + } + } + return filtered +} + +// useWithExitNodeRoutes filters out routes so the ones that remain +// are either zero-length resolver lists, or lists containing only +// resolvers marked for use with exit nodes. +func useWithExitNodeRoutes(routes map[string][]*dnstype.Resolver) map[string][]*dnstype.Resolver { + var filtered map[string][]*dnstype.Resolver + for suffix, resolvers := range routes { + // Suffixes with no resolvers represent a valid configuration, + // and should persist regardless of exit node considerations. + if len(resolvers) == 0 { + mak.Set(&filtered, suffix, make([]*dnstype.Resolver, 0)) + continue + } + + // In exit node contexts, we filter out resolvers not configured for use with + // exit nodes. If there are no such configured resolvers, there should not be an entry for that suffix. + filteredResolvers := useWithExitNodeResolvers(resolvers) + if len(filteredResolvers) > 0 { + mak.Set(&filtered, suffix, filteredResolvers) + } + } + + return filtered +} + // dnsConfigForNetmap returns a *dns.Config for the given netmap, // prefs, client OS version, and cloud hosting environment. // @@ -700,10 +736,36 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. dcfg.DefaultResolvers = append(dcfg.DefaultResolvers, resolvers...) } + addSplitDNSRoutes := func(routes map[string][]*dnstype.Resolver) { + for suffix, resolvers := range routes { + fqdn, err := dnsname.ToFQDN(suffix) + if err != nil { + logf("[unexpected] non-FQDN route suffix %q", suffix) + } + + // Create map entry even if len(resolvers) == 0; Issue 2706. + // This lets the control plane send ExtraRecords for which we + // can authoritatively answer "name not exists" for when the + // control plane also sends this explicit but empty route + // making it as something we handle. + dcfg.Routes[fqdn] = slices.Clone(resolvers) + } + } + // If we're using an exit node and that exit node is new enough (1.19.x+) - // to run a DoH DNS proxy, then send all our DNS traffic through it. + // to run a DoH DNS proxy, then send all our DNS traffic through it, + // unless we find resolvers with UseWithExitNode set, in which case we use that. if dohURL, ok := exitNodeCanProxyDNS(nm, peers, prefs.ExitNodeID()); ok { - addDefault([]*dnstype.Resolver{{Addr: dohURL}}) + filtered := useWithExitNodeResolvers(nm.DNS.Resolvers) + if len(filtered) > 0 { + addDefault(filtered) + } else { + // If no default global resolvers with the override + // are configured, configure the exit node's resolver. + addDefault([]*dnstype.Resolver{{Addr: dohURL}}) + } + + addSplitDNSRoutes(useWithExitNodeRoutes(nm.DNS.Routes)) return dcfg } @@ -718,25 +780,8 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. } } - for suffix, resolvers := range nm.DNS.Routes { - fqdn, err := dnsname.ToFQDN(suffix) - if err != nil { - logf("[unexpected] non-FQDN route suffix %q", suffix) - } - - // Create map entry even if len(resolvers) == 0; Issue 2706. - // This lets the control plane send ExtraRecords for which we - // can authoritatively answer "name not exists" for when the - // control plane also sends this explicit but empty route - // making it as something we handle. - // - // While we're already populating it, might as well size the - // slice appropriately. - // Per #9498 the exact requirements of nil vs empty slice remain - // unclear, this is a haunted graveyard to be resolved. - dcfg.Routes[fqdn] = make([]*dnstype.Resolver, 0, len(resolvers)) - dcfg.Routes[fqdn] = append(dcfg.Routes[fqdn], resolvers...) - } + // Add split DNS routes, with no regard to exit node configuration. + addSplitDNSRoutes(nm.DNS.Routes) // Set FallbackResolvers as the default resolvers in the // scenarios that can't handle a purely split-DNS config. See diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 9f4734f1f..d2125684d 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -169,7 +169,8 @@ type CapabilityVersion int // - 122: 2025-07-21: Client sends Hostinfo.ExitNodeID to report which exit node it has selected, if any. // - 123: 2025-07-28: fix deadlock regression from cryptokey routing change (issue #16651) // - 124: 2025-08-08: removed NodeAttrDisableMagicSockCryptoRouting support, crypto routing is now mandatory -const CurrentCapabilityVersion CapabilityVersion = 124 +// - 125: 2025-08-11: dnstype.Resolver adds UseWithExitNode field. +const CurrentCapabilityVersion CapabilityVersion = 125 // ID is an integer ID for a user, node, or login allocated by the // control plane. @@ -1730,10 +1731,9 @@ type DNSConfig struct { // proxying to be enabled. Proxied bool `json:",omitempty"` - // The following fields are only set and used by - // MapRequest.Version >=9 and <14. - - // Nameservers are the IP addresses of the nameservers to use. + // Nameservers are the IP addresses of the global nameservers to use. + // + // Deprecated: this is only set and used by MapRequest.Version >=9 and <14. Use Resolvers instead. Nameservers []netip.Addr `json:",omitempty"` // CertDomains are the set of DNS names for which the control diff --git a/types/dnstype/dnstype.go b/types/dnstype/dnstype.go index b7f5b9d02..a3ba1b0a9 100644 --- a/types/dnstype/dnstype.go +++ b/types/dnstype/dnstype.go @@ -35,6 +35,12 @@ type Resolver struct { // // As of 2022-09-08, BootstrapResolution is not yet used. BootstrapResolution []netip.Addr `json:",omitempty"` + + // UseWithExitNode designates that this resolver should continue to be used when an + // exit node is in use. Normally, DNS resolution is delegated to the exit node but + // there are situations where it is preferable to still use a Split DNS server and/or + // global DNS server instead of the exit node. + UseWithExitNode bool `json:",omitempty"` } // IPPort returns r.Addr as an IP address and port if either @@ -64,5 +70,7 @@ func (r *Resolver) Equal(other *Resolver) bool { return true } - return r.Addr == other.Addr && slices.Equal(r.BootstrapResolution, other.BootstrapResolution) + return r.Addr == other.Addr && + slices.Equal(r.BootstrapResolution, other.BootstrapResolution) && + r.UseWithExitNode == other.UseWithExitNode } diff --git a/types/dnstype/dnstype_clone.go b/types/dnstype/dnstype_clone.go index 86ca0535f..3985704aa 100644 --- a/types/dnstype/dnstype_clone.go +++ b/types/dnstype/dnstype_clone.go @@ -25,6 +25,7 @@ func (src *Resolver) Clone() *Resolver { var _ResolverCloneNeedsRegeneration = Resolver(struct { Addr string BootstrapResolution []netip.Addr + UseWithExitNode bool }{}) // Clone duplicates src into dst and reports whether it succeeded. diff --git a/types/dnstype/dnstype_test.go b/types/dnstype/dnstype_test.go index e3a941a20..ada5f687d 100644 --- a/types/dnstype/dnstype_test.go +++ b/types/dnstype/dnstype_test.go @@ -17,7 +17,7 @@ func TestResolverEqual(t *testing.T) { fieldNames = append(fieldNames, field.Name) } sort.Strings(fieldNames) - if !slices.Equal(fieldNames, []string{"Addr", "BootstrapResolution"}) { + if !slices.Equal(fieldNames, []string{"Addr", "BootstrapResolution", "UseWithExitNode"}) { t.Errorf("Resolver fields changed; update test") } @@ -68,6 +68,18 @@ func TestResolverEqual(t *testing.T) { }, want: false, }, + { + name: "equal UseWithExitNode", + a: &Resolver{Addr: "dns.example.com", UseWithExitNode: true}, + b: &Resolver{Addr: "dns.example.com", UseWithExitNode: true}, + want: true, + }, + { + name: "not equal UseWithExitNode", + a: &Resolver{Addr: "dns.example.com", UseWithExitNode: true}, + b: &Resolver{Addr: "dns.example.com", UseWithExitNode: false}, + want: false, + }, } for _, tt := range tests { diff --git a/types/dnstype/dnstype_view.go b/types/dnstype/dnstype_view.go index 3d374ab47..0704670a2 100644 --- a/types/dnstype/dnstype_view.go +++ b/types/dnstype/dnstype_view.go @@ -88,10 +88,12 @@ func (v ResolverView) Addr() string { return v.ж.Addr } func (v ResolverView) BootstrapResolution() views.Slice[netip.Addr] { return views.SliceOf(v.ж.BootstrapResolution) } +func (v ResolverView) UseWithExitNode() bool { return v.ж.UseWithExitNode } func (v ResolverView) Equal(v2 ResolverView) bool { return v.ж.Equal(v2.ж) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ResolverViewNeedsRegeneration = Resolver(struct { Addr string BootstrapResolution []netip.Addr + UseWithExitNode bool }{})