From b062abb1eac7a90c13814e44e4c2a12cd8f39e58 Mon Sep 17 00:00:00 2001 From: Michael Ben-Ami Date: Thu, 23 Jul 2026 10:18:42 -0400 Subject: [PATCH] appc,ipn/ipnlocal: install conn25 DNS routes when using exit node We were early-returning when the node was using an exit node, before Connectors 2025 split DNS routes were calculated and installed. Now we assemble the routes first, then install them in both exit node and non-exit-node contexts. The returned resolvers set UseWithExitNode to true even though as of today, we believe they should be installed in all cases without regard to that boolean value. With the boolean, we preserve the flexibility to toggle behavior without touching ipnlocal. We also add a TODO to turn the extra split DNS route gathering into a feature hook (tailscale/corp#37125). This does not affect appc connectors, which receive split DNS routes, and the UseWithExitNode value directly from control. Updates #16384 Signed-off-by: Michael Ben-Ami --- appc/conn25.go | 2 +- appc/conn25_test.go | 2 +- ipn/ipnlocal/dnsconfig_test.go | 52 +++++++++++++++++++++++++++++++++- ipn/ipnlocal/node_backend.go | 18 +++++++----- 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/appc/conn25.go b/appc/conn25.go index 13a41e93f..e4c999a7a 100644 --- a/appc/conn25.go +++ b/appc/conn25.go @@ -80,7 +80,7 @@ func AppDNSRoutes(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeV } m := make(map[string][]*dnstype.Resolver, len(appNamesByDomain)) for domain, appName := range appNamesByDomain { - m[domain] = []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName)}} + m[domain] = []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}} } return m } diff --git a/appc/conn25_test.go b/appc/conn25_test.go index d9b4201a9..7194a67fe 100644 --- a/appc/conn25_test.go +++ b/appc/conn25_test.go @@ -37,7 +37,7 @@ func TestAppDNSRoutes(t *testing.T) { appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"}) resolver := func(appName string) []*dnstype.Resolver { - return []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName)}} + return []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}} } for _, tt := range []struct { diff --git a/ipn/ipnlocal/dnsconfig_test.go b/ipn/ipnlocal/dnsconfig_test.go index f4f4f1b38..3198222f1 100644 --- a/ipn/ipnlocal/dnsconfig_test.go +++ b/ipn/ipnlocal/dnsconfig_test.go @@ -429,12 +429,62 @@ func TestDNSConfigForNetmap(t *testing.T) { Hosts: map[dnsname.FQDN][]netip.Addr{}, Routes: map[dnsname.FQDN][]*dnstype.Resolver{ dnsname.FQDN("example.com."): { - {Addr: "tailscale-app:app1"}, + {Addr: "tailscale-app:app1", UseWithExitNode: true}, }, }, MagicDNSHostsUnrouted: true, }, }, + { + name: "conn25-split-dns-with-exit-node", + nm: &netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Name: "a", + Addresses: ipps("100.101.101.101"), + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName): []tailcfg.RawMessage{ + tailcfg.RawMessage(`{"name":"app1","connectors":["tag:woo"],"domains":["example.com"]}`), + }, + }, + }).View(), + AllCaps: set.Of(tailcfg.NodeCapability(appc.AppConnectorsExperimentalAttrName)), + }, + peers: nodeViews([]*tailcfg.Node{ + { + ID: 1, + StableID: "exit", + Name: "p1", + Cap: 26, // can proxy DNS over DoH + Addresses: ipps("100.102.0.1"), + Tags: []string{"tag:woo"}, + Hostinfo: (&tailcfg.Hostinfo{ + Services: []tailcfg.Service{ + { + Proto: tailcfg.PeerAPI4, + Port: 1234, + }, + }, + }).View(), + }, + }), + prefs: &ipn.Prefs{ + CorpDNS: true, + ExitNodeID: "exit", + }, + want: &dns.Config{ + AcceptDNS: true, + Hosts: map[dnsname.FQDN][]netip.Addr{}, + Routes: map[dnsname.FQDN][]*dnstype.Resolver{ + dnsname.FQDN("example.com."): { + {Addr: "tailscale-app:app1", UseWithExitNode: true}, + }, + }, + DefaultResolvers: []*dnstype.Resolver{ + {Addr: "http://100.102.0.1:1234/dns-query"}, + }, + MagicDNSHostsUnrouted: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/ipn/ipnlocal/node_backend.go b/ipn/ipnlocal/node_backend.go index a0b539eb1..874ac04c6 100644 --- a/ipn/ipnlocal/node_backend.go +++ b/ipn/ipnlocal/node_backend.go @@ -1536,6 +1536,15 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. } } + // 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 + // to reduce dependency from ipnlocal to appc. + var conn25AppRoutes map[string][]*dnstype.Resolver + if (envknob.UseWIPCode() || testenv.InTest()) && buildfeatures.HasConn25 && !prefs.AppConnector().Advertise { + conn25AppRoutes = appc.AppDNSRoutes(nm.HasCap, nm.SelfNode) + } + // 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, // unless we find resolvers with UseWithExitNode set, in which case we use that. @@ -1551,6 +1560,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg. } addSplitDNSRoutes(useWithExitNodeRoutes(nm.DNS.Routes)) + addSplitDNSRoutes(useWithExitNodeRoutes(conn25AppRoutes)) return dcfg } } @@ -1568,13 +1578,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) - - if (envknob.UseWIPCode() || testenv.InTest()) && buildfeatures.HasConn25 && !prefs.AppConnector().Advertise { - // Add split DNS routes for conn25 - if appRoutes := appc.AppDNSRoutes(nm.HasCap, nm.SelfNode); appRoutes != nil { - addSplitDNSRoutes(appRoutes) - } - } + addSplitDNSRoutes(conn25AppRoutes) // Set FallbackResolvers as the default resolvers in the // scenarios that can't handle a purely split-DNS config. See