types/dnstype, ipn/ipnlocal: allow other DNS resolvers with exit nodes

dnstype.Resolver adds a boolean UseWithExitNode that controls
whether the resolver should be used in tailscale exit node contexts
(not wireguard exit nodes). If UseWithExitNode resolvers are found,
they are installed as the global resolvers. If no UseWithExitNode resolvers
are found, the exit node resolver continues to be installed as the global
resolver. Split DNS Routes referencing UseWithExitNode resolvers are also
installed.

Updates #8237

Fixes tailscale/corp#30906
Fixes tailscale/corp#30907

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
This commit is contained in:
Michael Ben-Ami 2025-08-11 12:10:33 -04:00 committed by mzbenami
parent b8c45a6a8f
commit 3f1851a6d9
8 changed files with 235 additions and 51 deletions

View File

@ -3,17 +3,50 @@
package apitype package apitype
// DNSConfig is the DNS configuration for a tailnet
// used in /tailnet/{tailnet}/dns/config.
type DNSConfig struct { type DNSConfig struct {
Resolvers []DNSResolver `json:"resolvers"` // Resolvers are the global DNS resolvers to use
FallbackResolvers []DNSResolver `json:"fallbackResolvers"` // overriding the local OS configuration.
Routes map[string][]DNSResolver `json:"routes"` Resolvers []DNSResolver `json:"resolvers"`
Domains []string `json:"domains"`
Nameservers []string `json:"nameservers"` // FallbackResolvers are used as global resolvers when
Proxied bool `json:"proxied"` // the client is unable to determine the OS's preferred DNS servers.
TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"` 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 { 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"` 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"`
} }

View File

@ -2080,7 +2080,14 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
wantRoutes map[dnsname.FQDN][]*dnstype.Resolver 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"}} wgResolvers := []*dnstype.Resolver{{Addr: "wg.example.com"}}
peers := []tailcfg.NodeView{ peers := []tailcfg.NodeView{
(&tailcfg.Node{ (&tailcfg.Node{
@ -2099,9 +2106,33 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
}).View(), }).View(),
} }
exitDOH := peerAPIBase(&netmap.NetworkMap{Peers: peers}, peers[0]) + "/dns-query" 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"}}, "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 { stringifyRoutes := func(routes map[dnsname.FQDN][]*dnstype.Resolver) map[string][]*dnstype.Resolver {
if routes == nil { if routes == nil {
return nil return nil
@ -2138,19 +2169,23 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}},
wantRoutes: nil, 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 // When at tailscale exit node is in use,
// exit node split-DNS may effectively break, furthermore in the future // only routes that reference resolvers with the UseWithExitNode should be installed,
// if different nodes observe different DNS configurations, even a // as well as routes with 0-length resolver lists, which should be installed in all cases.
// 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.
{ {
name: "tsExit/routes/noResolver", name: "tsExit/routes/noResolver",
exitNode: "ts", exitNode: "ts",
peers: peers, peers: peers,
dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes)},
wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}},
wantRoutes: nil, wantRoutes: nil,
}, },
@ -2158,10 +2193,58 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
name: "tsExit/routes/defaultResolver", name: "tsExit/routes/defaultResolver",
exitNode: "ts", exitNode: "ts",
peers: peers, peers: peers,
dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes), Resolvers: defaultResolvers},
wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}},
wantRoutes: nil, 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 // WireGuard exit nodes with DNS capabilities provide a "fallback" type
// behavior, they have a lower precedence than a default resolver, but // behavior, they have a lower precedence than a default resolver, but
@ -2187,17 +2270,17 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
name: "wgExit/routes/defaultResolver", name: "wgExit/routes/defaultResolver",
exitNode: "wg", exitNode: "wg",
peers: peers, peers: peers,
dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes), Resolvers: defaultResolvers},
wantDefaultResolvers: defaultResolvers, wantDefaultResolvers: defaultResolvers,
wantRoutes: routes, wantRoutes: baseRoutes,
}, },
{ {
name: "wgExit/routes/noResolver", name: "wgExit/routes/noResolver",
exitNode: "wg", exitNode: "wg",
peers: peers, peers: peers,
dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(baseRoutes)},
wantDefaultResolvers: wgResolvers, wantDefaultResolvers: wgResolvers,
wantRoutes: routes, wantRoutes: baseRoutes,
}, },
} }

View File

@ -578,6 +578,42 @@ func (nb *nodeBackend) doShutdown(cause error) {
nb.eventClient.Close() 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, // dnsConfigForNetmap returns a *dns.Config for the given netmap,
// prefs, client OS version, and cloud hosting environment. // 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...) 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+) // 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 { 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 return dcfg
} }
@ -718,25 +780,8 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
} }
} }
for suffix, resolvers := range nm.DNS.Routes { // Add split DNS routes, with no regard to exit node configuration.
fqdn, err := dnsname.ToFQDN(suffix) addSplitDNSRoutes(nm.DNS.Routes)
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...)
}
// Set FallbackResolvers as the default resolvers in the // Set FallbackResolvers as the default resolvers in the
// scenarios that can't handle a purely split-DNS config. See // scenarios that can't handle a purely split-DNS config. See

View File

@ -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. // - 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) // - 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 // - 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 // ID is an integer ID for a user, node, or login allocated by the
// control plane. // control plane.
@ -1730,10 +1731,9 @@ type DNSConfig struct {
// proxying to be enabled. // proxying to be enabled.
Proxied bool `json:",omitempty"` Proxied bool `json:",omitempty"`
// The following fields are only set and used by // Nameservers are the IP addresses of the global nameservers to use.
// MapRequest.Version >=9 and <14. //
// Deprecated: this is only set and used by MapRequest.Version >=9 and <14. Use Resolvers instead.
// Nameservers are the IP addresses of the nameservers to use.
Nameservers []netip.Addr `json:",omitempty"` Nameservers []netip.Addr `json:",omitempty"`
// CertDomains are the set of DNS names for which the control // CertDomains are the set of DNS names for which the control

View File

@ -35,6 +35,12 @@ type Resolver struct {
// //
// As of 2022-09-08, BootstrapResolution is not yet used. // As of 2022-09-08, BootstrapResolution is not yet used.
BootstrapResolution []netip.Addr `json:",omitempty"` 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 // 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 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
} }

View File

@ -25,6 +25,7 @@ func (src *Resolver) Clone() *Resolver {
var _ResolverCloneNeedsRegeneration = Resolver(struct { var _ResolverCloneNeedsRegeneration = Resolver(struct {
Addr string Addr string
BootstrapResolution []netip.Addr BootstrapResolution []netip.Addr
UseWithExitNode bool
}{}) }{})
// Clone duplicates src into dst and reports whether it succeeded. // Clone duplicates src into dst and reports whether it succeeded.

View File

@ -17,7 +17,7 @@ func TestResolverEqual(t *testing.T) {
fieldNames = append(fieldNames, field.Name) fieldNames = append(fieldNames, field.Name)
} }
sort.Strings(fieldNames) 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") t.Errorf("Resolver fields changed; update test")
} }
@ -68,6 +68,18 @@ func TestResolverEqual(t *testing.T) {
}, },
want: false, 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 { for _, tt := range tests {

View File

@ -88,10 +88,12 @@ func (v ResolverView) Addr() string { return v.ж.Addr }
func (v ResolverView) BootstrapResolution() views.Slice[netip.Addr] { func (v ResolverView) BootstrapResolution() views.Slice[netip.Addr] {
return views.SliceOf(v.ж.BootstrapResolution) return views.SliceOf(v.ж.BootstrapResolution)
} }
func (v ResolverView) UseWithExitNode() bool { return v.ж.UseWithExitNode }
func (v ResolverView) Equal(v2 ResolverView) bool { return v.ж.Equal(v2.ж) } 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. // A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ResolverViewNeedsRegeneration = Resolver(struct { var _ResolverViewNeedsRegeneration = Resolver(struct {
Addr string Addr string
BootstrapResolution []netip.Addr BootstrapResolution []netip.Addr
UseWithExitNode bool
}{}) }{})