diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 0e2a03377..afab141b4 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -319,6 +319,7 @@ jobs: - TestExitRoutesWithAutogroupInternetACL - TestSubnetRouterMultiNetwork - TestSubnetRouterMultiNetworkExitNode + - TestExitNodeUseWithExitNodeDNS - TestAutoApproveMultiNetwork/authkey-tag.* - TestAutoApproveMultiNetwork/authkey-user.* - TestAutoApproveMultiNetwork/authkey-group.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 785d7d855..b3d8df962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ keys remain all-access. - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) +- Add `dns.nameservers.use_with_exit_node` to keep selected resolvers active when a client uses an exit node, so a self-hosted resolver can be reached without routing DNS through the exit node (requires Tailscale v1.88+) [#3376](https://github.com/juanfont/headscale/pull/3376) ## 0.29.2 (2026-07-01) diff --git a/config-example.yaml b/config-example.yaml index 09f0356b8..e9a1aaf82 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -337,6 +337,18 @@ dns: # - 1.1.1.1 # - 8.8.8.8 + # By default, when a client selects an exit node, Tailscale routes all of + # that client's DNS through the exit node and ignores the nameservers + # above. List the nameserver addresses here (from `global` or `split`) that + # should keep being used even while an exit node is selected. This is useful + # to reach a self-hosted resolver (e.g. Pi-hole on the tailnet) directly, + # without the round trip through the exit node. + # + # Requires a Tailscale client from v1.88 or newer (capability version 125); + # older clients silently ignore this setting. + use_with_exit_node: [] + # - 100.64.0.53 + # Set custom DNS search domains. With MagicDNS enabled, # your tailnet base_domain is always the first search domain. search_domains: [] diff --git a/docs/ref/dns.md b/docs/ref/dns.md index fe1862eff..655bb4f67 100644 --- a/docs/ref/dns.md +++ b/docs/ref/dns.md @@ -3,6 +3,34 @@ Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS related settings can be configured within the `dns` section of the [configuration file](configuration.md). +## Keeping nameservers active when using an exit node + +By default, when a client selects an exit node, Tailscale sends **all** of that client's DNS through the exit node and +ignores the nameservers configured in Headscale. This is usually desirable, but it prevents reaching a self-hosted +resolver (for example a Pi-hole running on the tailnet) directly: the query has to take the round trip through the exit +node first. + +The `dns.nameservers.use_with_exit_node` option lists the nameserver addresses that should keep being used even while an +exit node is selected. Listed addresses must also appear in `dns.nameservers.global` or `dns.nameservers.split`. + +```yaml title="config.yaml" +dns: + nameservers: + global: + - 100.64.0.53 + use_with_exit_node: + - 100.64.0.53 +``` + +When the listed resolver is reachable within the tailnet (such as `100.64.0.53`), the client talks to it directly +instead of routing the query through the exit node. + +!!! warning "Requires a recent Tailscale client" + + This maps to Tailscale's [`UseWithExitNode`](https://tailscale.com/kb/1054/dns#nameservers-and-exit-nodes) resolver + flag, added in **capability version 125 (Tailscale v1.88)**. Older clients silently ignore the setting and continue + to send DNS through the exit node. + ## Setting extra DNS records Headscale allows to set extra DNS records which are made available via diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 4f0b4ecfa..71670408c 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -167,6 +167,12 @@ type DNSConfig struct { type Nameservers struct { Global []string Split map[string][]string + // UseWithExitNode lists nameserver addresses (from Global or Split) + // that should keep being used even when a client selects an exit node. + // Without this, Tailscale clients delegate all DNS to the exit node, + // ignoring the configured resolvers. Requires client capability version + // 125 (Tailscale v1.88 or newer); older clients ignore the flag. + UseWithExitNode []string `mapstructure:"use_with_exit_node"` } type SqliteConfig struct { @@ -446,6 +452,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("dns.override_local_dns", true) viper.SetDefault("dns.nameservers.global", []string{}) viper.SetDefault("dns.nameservers.split", map[string]string{}) + viper.SetDefault("dns.nameservers.use_with_exit_node", []string{}) viper.SetDefault("dns.search_domains", []string{}) viper.SetDefault("derp.server.enabled", false) @@ -914,6 +921,9 @@ func dns() (DNSConfig, error) { dns.OverrideLocalDNS = viper.GetBool("dns.override_local_dns") dns.Nameservers.Global = viper.GetStringSlice("dns.nameservers.global") dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split") + dns.Nameservers.UseWithExitNode = viper.GetStringSlice( + "dns.nameservers.use_with_exit_node", + ) dns.SearchDomains = viper.GetStringSlice("dns.search_domains") dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path") @@ -936,13 +946,20 @@ func dns() (DNSConfig, error) { // If a nameserver is a valid URL, it will be used as a DoH resolver. // If a nameserver is neither a valid URL nor a valid IP, it will be ignored. // When domain is non-empty, it is included in the warning for invalid entries. -func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver { +// Resolvers whose address is present in useWithExitNode are marked so Tailscale +// clients keep using them even when an exit node is selected. +func parseResolvers( + nameservers []string, + domain string, + useWithExitNode map[string]bool, +) []*dnstype.Resolver { var resolvers []*dnstype.Resolver for _, nsStr := range nameservers { if _, err := netip.ParseAddr(nsStr); err == nil { //nolint:noinlineerr resolvers = append(resolvers, &dnstype.Resolver{ - Addr: nsStr, + Addr: nsStr, + UseWithExitNode: useWithExitNode[nsStr], }) continue @@ -950,7 +967,8 @@ func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver { if _, err := url.Parse(nsStr); err == nil { //nolint:noinlineerr resolvers = append(resolvers, &dnstype.Resolver{ - Addr: nsStr, + Addr: nsStr, + UseWithExitNode: useWithExitNode[nsStr], }) continue @@ -967,18 +985,34 @@ func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver { return resolvers } +// useWithExitNodeSet returns the set of nameserver addresses that should keep +// being used when an exit node is selected. +func (d *DNSConfig) useWithExitNodeSet() map[string]bool { + if len(d.Nameservers.UseWithExitNode) == 0 { + return nil + } + + set := make(map[string]bool, len(d.Nameservers.UseWithExitNode)) + for _, ns := range d.Nameservers.UseWithExitNode { + set[ns] = true + } + + return set +} + // globalResolvers returns the global DNS resolvers // defined in the config file. func (d *DNSConfig) globalResolvers() []*dnstype.Resolver { - return parseResolvers(d.Nameservers.Global, "") + return parseResolvers(d.Nameservers.Global, "", d.useWithExitNodeSet()) } // splitResolvers returns a map of domain to DNS resolvers. func (d *DNSConfig) splitResolvers() map[string][]*dnstype.Resolver { routes := make(map[string][]*dnstype.Resolver) + useWithExitNode := d.useWithExitNodeSet() for domain, nameservers := range d.Nameservers.Split { - routes[domain] = parseResolvers(nameservers, domain) + routes[domain] = parseResolvers(nameservers, domain, useWithExitNode) } return routes diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index d0859758c..a53f64296 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -52,10 +52,15 @@ func TestReadConfig(t *testing.T) { "darp.headscale.net": {"1.1.1.1", "8.8.8.8"}, "foo.bar.com": {"1.1.1.1"}, }, + UseWithExitNode: []string{}, }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, SearchDomains: []string{"test.com", "bar.com"}, }, @@ -87,7 +92,11 @@ func TestReadConfig(t *testing.T) { }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, }, }, @@ -118,10 +127,15 @@ func TestReadConfig(t *testing.T) { "darp.headscale.net": {"1.1.1.1", "8.8.8.8"}, "foo.bar.com": {"1.1.1.1"}, }, + UseWithExitNode: []string{}, }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, SearchDomains: []string{"test.com", "bar.com"}, }, @@ -153,7 +167,11 @@ func TestReadConfig(t *testing.T) { }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, }, }, @@ -220,6 +238,35 @@ func TestReadConfig(t *testing.T) { }, }, }, + { + name: "dns-use-with-exit-node", + configPath: "testdata/dns-use-with-exit-node.yaml", + setup: func(t *testing.T) (any, error) { //nolint:thelper + _, err := LoadServerConfig() + if err != nil { + return nil, err + } + + dns, err := dns() + if err != nil { + return nil, err + } + + return dnsToTailcfgDNS(dns), nil + }, + want: &tailcfg.DNSConfig{ + Proxied: true, + Domains: []string{"derp2.no"}, + Resolvers: []*dnstype.Resolver{ + {Addr: "1.1.1.1", UseWithExitNode: true}, + {Addr: "1.0.0.1"}, + }, + Routes: map[string][]*dnstype.Resolver{ + "foo.bar.com": {{Addr: "8.8.8.8", UseWithExitNode: true}}, + "baz.example.com": {{Addr: "9.9.9.9"}}, + }, + }, + }, { name: "policy-path-is-loaded", configPath: "testdata/policy-path-is-loaded.yaml", @@ -495,7 +542,10 @@ dns: override_local_dns: false oidc:` + tt.oidcBlock + "\n") - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600)) + require.NoError( + t, + os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600), + ) require.NoError(t, LoadConfig(tmpDir, false)) err := validateServerConfig() @@ -735,7 +785,11 @@ func TestTrustedProxies(t *testing.T) { require.NoError(t, err) - if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(netip.Prefix{})); diff != "" { + if diff := cmp.Diff( + tt.want, + got, + cmpopts.EquateComparable(netip.Prefix{}), + ); diff != "" { t.Errorf("trustedProxies() mismatch (-want +got):\n%s", diff) } }) diff --git a/hscontrol/types/testdata/dns-use-with-exit-node.yaml b/hscontrol/types/testdata/dns-use-with-exit-node.yaml new file mode 100644 index 000000000..1132748f4 --- /dev/null +++ b/hscontrol/types/testdata/dns-use-with-exit-node.yaml @@ -0,0 +1,28 @@ +noise: + private_key_path: "private_key.pem" + +prefixes: + v6: fd7a:115c:a1e0::/48 + v4: 100.64.0.0/10 + +database: + type: sqlite3 + +server_url: "https://server.derp.no" + +dns: + magic_dns: true + base_domain: derp2.no + override_local_dns: true + nameservers: + global: + - 1.1.1.1 + - 1.0.0.1 + split: + foo.bar.com: + - 8.8.8.8 + baz.example.com: + - 9.9.9.9 + use_with_exit_node: + - 1.1.1.1 + - 8.8.8.8 diff --git a/integration/route_test.go b/integration/route_test.go index 387a0ae1f..d6faa3fe7 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -27,6 +27,7 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" + "tailscale.com/types/dnstype" "tailscale.com/types/ipproto" "tailscale.com/types/views" "tailscale.com/util/must" @@ -2151,6 +2152,160 @@ func TestSubnetRouterMultiNetworkExitNode(t *testing.T) { }, 10*time.Second, 200*time.Millisecond, "user2 traceroute should go through user1 exit node") } +// TestExitNodeUseWithExitNodeDNS verifies that nameservers listed in +// dns.nameservers.use_with_exit_node keep their UseWithExitNode flag when sent +// to clients, so a client that selects an exit node still resolves via the +// configured resolver instead of having all DNS delegated to the exit node. +// Nameservers not in the list must not carry the flag. See issue #2816. +func TestExitNodeUseWithExitNodeDNS(t *testing.T) { + IntegrationSkip(t) + + // The UseWithExitNode resolver flag requires Tailscale capability version + // 125 (v1.88+); older clients ignore it. Pin to head so the netmap carries + // the flag. + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1", "user2"}, + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + require.NoErrorf(t, err, "failed to create scenario: %s", err) + + defer scenario.ShutdownAssertNoPanics(t) + + // keepResolver is reachable within the tailnet and should keep being used + // when an exit node is selected. dropResolver is configured but not listed, + // so it must not carry the flag. + const ( + keepResolver = "100.64.0.53" + dropResolver = "1.1.1.1" + ) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("rt-exitdns"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "true", + "HEADSCALE_DNS_NAMESERVERS_GLOBAL": keepResolver + " " + dropResolver, + "HEADSCALE_DNS_NAMESERVERS_USE_WITH_EXIT_NODE": keepResolver, + }), + ) + requireNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + assert.NotNil(t, headscale) + + var user1c, user2c TailscaleClient + + for _, c := range allClients { + s := c.MustStatus() + switch s.User[s.Self.UserID].LoginName { + case "user1@test.no": + user1c = c + case "user2@test.no": + user2c = c + } + } + + require.NotNil(t, user1c) + require.NotNil(t, user2c) + + // Advertise the exit node on user1c. + _, _, err = user1c.Execute([]string{ + "tailscale", "set", "--advertise-exit-node", + }) + require.NoErrorf(t, err, "failed to advertise exit node: %s", err) + + // headscale should see the two exit routes announced. + var nodes []*clientv1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err = headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, nodes, 2) + requireNodeRouteCountWithCollect(c, nodes[0], 2, 0, 0) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "exit routes should be announced") + + // Approve the exit routes. + _, err = headscale.ApproveRoutes( + mustParseID(nodes[0].Id), + []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}, + ) + require.NoError(t, err) + + // The exit node becomes an option for user2c. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := user2c.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + assert.True( + c, + status.Peer[peerKey].ExitNodeOption, + "peer should be an exit node option", + ) + } + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "exit node should be visible to client") + + // user2c selects user1c as its exit node. + _, _, err = user2c.Execute([]string{ + "tailscale", "set", "--exit-node", user1c.Hostname(), + }) + require.NoErrorf(t, err, "failed to set exit node: %s", err) + + // The exit node becomes active. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := user2c.Status() + assert.NoError(c, err) + assert.NotNil(c, status.ExitNodeStatus, "exit node should be active") + }, 30*time.Second, 500*time.Millisecond, "exit node activation") + + // The netmap DNS config carries the UseWithExitNode flag per resolver. The + // listed resolver must keep it; the unlisted one must not, regardless of + // the active exit node. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nm, err := user2c.Netmap() + assert.NoError(c, err) + + var keep, drop *dnstype.Resolver + + for _, r := range nm.DNS.Resolvers { + switch r.Addr { + case keepResolver: + keep = r + case dropResolver: + drop = r + } + } + + if assert.NotNil(c, keep, "resolver %s should be present", keepResolver) { + assert.True( + c, + keep.UseWithExitNode, + "resolver %s should keep UseWithExitNode under an exit node", + keepResolver, + ) + } + + if assert.NotNil(c, drop, "resolver %s should be present", dropResolver) { + assert.False( + c, + drop.UseWithExitNode, + "resolver %s should not have UseWithExitNode set", + dropResolver, + ) + } + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "configured resolver should stay active under exit node") +} + func MustFindNode(hostname string, nodes []*clientv1.Node) *clientv1.Node { for _, node := range nodes { if node.Name == hostname {