This commit is contained in:
Nelson Alex Jeppesen 2026-07-09 19:59:14 +00:00 committed by GitHub
commit 5fa3e261a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 324 additions and 11 deletions

View file

@ -319,6 +319,7 @@ jobs:
- TestExitRoutesWithAutogroupInternetACL
- TestSubnetRouterMultiNetwork
- TestSubnetRouterMultiNetworkExitNode
- TestExitNodeUseWithExitNodeDNS
- TestAutoApproveMultiNetwork/authkey-tag.*
- TestAutoApproveMultiNetwork/authkey-user.*
- TestAutoApproveMultiNetwork/authkey-group.*

View file

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

View file

@ -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: []

View file

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

View file

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

View file

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

View file

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

View file

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