mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-20 18:09:29 +00:00
Merge 96c49fe02a into 048308511c
This commit is contained in:
commit
5fa3e261a1
8 changed files with 324 additions and 11 deletions
1
.github/workflows/test-integration.yaml
vendored
1
.github/workflows/test-integration.yaml
vendored
|
|
@ -319,6 +319,7 @@ jobs:
|
|||
- TestExitRoutesWithAutogroupInternetACL
|
||||
- TestSubnetRouterMultiNetwork
|
||||
- TestSubnetRouterMultiNetworkExitNode
|
||||
- TestExitNodeUseWithExitNodeDNS
|
||||
- TestAutoApproveMultiNetwork/authkey-tag.*
|
||||
- TestAutoApproveMultiNetwork/authkey-user.*
|
||||
- TestAutoApproveMultiNetwork/authkey-group.*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
28
hscontrol/types/testdata/dns-use-with-exit-node.yaml
vendored
Normal file
28
hscontrol/types/testdata/dns-use-with-exit-node.yaml
vendored
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue