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

@@ -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