Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ jobs:
- TestExitRoutesWithAutogroupInternetACL
- TestSubnetRouterMultiNetwork
- TestSubnetRouterMultiNetworkExitNode
- TestExitNodeUseWithExitNodeDNS
- TestAutoApproveMultiNetwork/authkey-tag.*
- TestAutoApproveMultiNetwork/authkey-user.*
- TestAutoApproveMultiNetwork/authkey-group.*
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 scoped `dns.nameservers.use_with_exit_node` selectors so configured resolvers remain active when a client uses an exit node (requires Tailscale v1.88.1+) [#3376](https://github.com/juanfont/headscale/pull/3376)

## 0.29.2 (2026-07-01)

Expand Down
10 changes: 10 additions & 0 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,16 @@ dns:
# - 1.1.1.1
# - 8.8.8.8

# Nameservers to keep using while an exit node is selected. Global entries
# must also be in `global` and require `override_local_dns: true`. Split
# entries must also be under the same domain in `split`.
use_with_exit_node:
global: []
# - 1.1.1.1
split: {}
# foo.bar.com:
# - 1.1.1.1

# Set custom DNS search domains. With MagicDNS enabled,
# your tailnet base_domain is always the first search domain.
search_domains: []
Expand Down
42 changes: 42 additions & 0 deletions docs/ref/dns.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,48 @@
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.

List a global or split nameserver under `dns.nameservers.use_with_exit_node` to keep using that resolver while an exit
node is selected. Each selected address must also appear in the corresponding `global` list or under the same domain in
`split`.

```yaml title="config.yaml"
dns:
override_local_dns: true
nameservers:
global:
- 100.64.0.53
split:
homelab.example.com:
- 100.64.0.54
use_with_exit_node:
global:
- 100.64.0.53
split:
homelab.example.com:
- 100.64.0.54
```

Global nameservers require `dns.override_local_dns: true`. Split nameservers can use this option regardless of that
setting. Global and split selections are independent, so the same address can be enabled for one split domain without
enabling its global entry.

This option controls which resolver receives a query, not how packets are routed. A resolver at a tailnet address or
behind an advertised subnet route is normally reached directly because that route is more specific than the exit-node
default route. Traffic to a public resolver normally still travels 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.1)**. 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
Expand Down
142 changes: 112 additions & 30 deletions hscontrol/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/netip"
"net/url"
"os"
"slices"
"strings"
"sync"
"time"
Expand All @@ -32,16 +33,18 @@ const (
)

var (
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errOIDCIssuerInvalid = errors.New("oidc.issuer must be a valid http(s) URL")
errOIDCClientIDRequired = errors.New("oidc.client_id is required when oidc.issuer is set")
errOIDCClientSecretRequired = errors.New("oidc.client_secret or oidc.client_secret_path is required when oidc.issuer is set")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
errTrustedProxyZeroRange = errors.New("0.0.0.0/0 and ::/0 are not allowed")
ErrNoPrefixConfigured = errors.New("no IPv4 or IPv6 prefix configured, minimum one prefix is required")
ErrInvalidAllocationStrategy = errors.New("invalid prefix allocation strategy")
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errOIDCIssuerInvalid = errors.New("oidc.issuer must be a valid http(s) URL")
errOIDCClientIDRequired = errors.New("oidc.client_id is required when oidc.issuer is set")
errOIDCClientSecretRequired = errors.New("oidc.client_secret or oidc.client_secret_path is required when oidc.issuer is set")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
errTrustedProxyZeroRange = errors.New("0.0.0.0/0 and ::/0 are not allowed")
errNameserverNotConfigured = errors.New("use_with_exit_node nameserver is not configured")
errNameserverGlobalNeedsOverride = errors.New("dns.nameservers.use_with_exit_node.global requires dns.override_local_dns to be true")
ErrNoPrefixConfigured = errors.New("no IPv4 or IPv6 prefix configured, minimum one prefix is required")
ErrInvalidAllocationStrategy = errors.New("invalid prefix allocation strategy")
)

type IPAllocationStrategy string
Expand Down Expand Up @@ -169,6 +172,12 @@ type Nameservers struct {
Split map[string][]string
}

type parsedDNSConfig struct {
dns DNSConfig
globalUseWithExitNode map[string]bool
splitUseWithExitNode map[string]map[string]bool
}

type SqliteConfig struct {
Path string
WriteAheadLog bool
Expand Down Expand Up @@ -446,6 +455,8 @@ 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.global", []string{})
viper.SetDefault("dns.nameservers.use_with_exit_node.split", map[string]string{})
viper.SetDefault("dns.search_domains", []string{})

viper.SetDefault("derp.server.enabled", false)
Expand Down Expand Up @@ -899,8 +910,10 @@ func databaseConfig() DatabaseConfig {
}
}

func dns() (DNSConfig, error) {
var dns DNSConfig
func dns() (parsedDNSConfig, error) {
var result parsedDNSConfig

dns := &result.dns

// TODO: Use this instead of manually getting settings when
// UnmarshalKey is compatible with Environment Variables.
Expand All @@ -912,8 +925,62 @@ func dns() (DNSConfig, error) {
dns.MagicDNS = viper.GetBool("dns.magic_dns")
dns.BaseDomain = viper.GetString("dns.base_domain")
dns.OverrideLocalDNS = viper.GetBool("dns.override_local_dns")

dns.Nameservers.Global = viper.GetStringSlice("dns.nameservers.global")
dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")

globalUseWithExitNode := viper.GetStringSlice(
"dns.nameservers.use_with_exit_node.global",
)
if len(globalUseWithExitNode) > 0 && !dns.OverrideLocalDNS {
return parsedDNSConfig{}, errNameserverGlobalNeedsOverride
}

result.globalUseWithExitNode = make(map[string]bool, len(globalUseWithExitNode))
for _, address := range globalUseWithExitNode {
if !slices.Contains(dns.Nameservers.Global, address) {
return parsedDNSConfig{}, fmt.Errorf(
"%w in dns.nameservers.global: %q",
errNameserverNotConfigured,
address,
)
}

result.globalUseWithExitNode[address] = true
}

splitUseWithExitNode := viper.GetStringMapStringSlice(
"dns.nameservers.use_with_exit_node.split",
)

result.splitUseWithExitNode = make(map[string]map[string]bool, len(splitUseWithExitNode))
for domain, addresses := range splitUseWithExitNode {
configured, ok := dns.Nameservers.Split[domain]
if !ok {
return parsedDNSConfig{}, fmt.Errorf(
"%w for split domain %q",
errNameserverNotConfigured,
domain,
)
}

selected := make(map[string]bool, len(addresses))
for _, address := range addresses {
if !slices.Contains(configured, address) {
return parsedDNSConfig{}, fmt.Errorf(
"%w for split domain %q: %q",
errNameserverNotConfigured,
domain,
address,
)
}

selected[address] = true
}

result.splitUseWithExitNode[domain] = selected
}

dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")

Expand All @@ -922,35 +989,41 @@ func dns() (DNSConfig, error) {

err := viper.UnmarshalKey("dns.extra_records", &extraRecords)
if err != nil {
return DNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
return parsedDNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
}

dns.ExtraRecords = extraRecords
}

return dns, nil
return result, nil
}

// parseResolvers converts nameserver strings into DNS resolvers.
// If a nameserver is a valid IP, it will be used as a regular resolver.
// 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 {
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
}

if _, err := url.Parse(nsStr); err == nil { //nolint:noinlineerr
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
Addr: nsStr,
UseWithExitNode: useWithExitNode[nsStr],
})

continue
Expand All @@ -969,23 +1042,32 @@ func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver {

// globalResolvers returns the global DNS resolvers
// defined in the config file.
func (d *DNSConfig) globalResolvers() []*dnstype.Resolver {
return parseResolvers(d.Nameservers.Global, "")
func (d *parsedDNSConfig) globalResolvers() []*dnstype.Resolver {
return parseResolvers(
d.dns.Nameservers.Global,
"",
d.globalUseWithExitNode,
)
}

// splitResolvers returns a map of domain to DNS resolvers.
func (d *DNSConfig) splitResolvers() map[string][]*dnstype.Resolver {
func (d *parsedDNSConfig) splitResolvers() map[string][]*dnstype.Resolver {
routes := make(map[string][]*dnstype.Resolver)

for domain, nameservers := range d.Nameservers.Split {
routes[domain] = parseResolvers(nameservers, domain)
for domain, nameservers := range d.dns.Nameservers.Split {
routes[domain] = parseResolvers(
nameservers,
domain,
d.splitUseWithExitNode[domain],
)
}

return routes
}

func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
func dnsToTailcfgDNS(parsed parsedDNSConfig) *tailcfg.DNSConfig {
cfg := tailcfg.DNSConfig{}
dns := parsed.dns

if dns.BaseDomain == "" && dns.MagicDNS {
log.Fatal().Msg("dns.base_domain must be set when using MagicDNS (dns.magic_dns)")
Expand All @@ -995,12 +1077,12 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {

cfg.ExtraRecords = dns.ExtraRecords
if dns.OverrideLocalDNS {
cfg.Resolvers = dns.globalResolvers()
cfg.Resolvers = parsed.globalResolvers()
} else {
cfg.FallbackResolvers = dns.globalResolvers()
cfg.FallbackResolvers = parsed.globalResolvers()
}

routes := dns.splitResolvers()
routes := parsed.splitResolvers()

cfg.Routes = routes
if dns.BaseDomain != "" {
Expand Down Expand Up @@ -1207,8 +1289,8 @@ func LoadServerConfig() (*Config, error) {
// - DERP run on their own domains
// - Control plane runs on login.tailscale.com/controlplane.tailscale.com
// - MagicDNS (BaseDomain) for users is on a *.ts.net domain per tailnet (e.g. tail-scale.ts.net)
if dnsConfig.BaseDomain != "" {
err := isSafeServerURL(serverURL, dnsConfig.BaseDomain)
if dnsConfig.dns.BaseDomain != "" {
err := isSafeServerURL(serverURL, dnsConfig.dns.BaseDomain)
if err != nil {
return nil, err
}
Expand All @@ -1228,7 +1310,7 @@ func LoadServerConfig() (*Config, error) {
NoisePrivateKeyPath: util.AbsolutePathFromConfigPath(
viper.GetString("noise.private_key_path"),
),
BaseDomain: dnsConfig.BaseDomain,
BaseDomain: dnsConfig.dns.BaseDomain,

DERP: derpConfig,

Expand All @@ -1253,7 +1335,7 @@ func LoadServerConfig() (*Config, error) {

TLS: tlsConfig(),

DNSConfig: dnsConfig,
DNSConfig: dnsConfig.dns,
TailcfgDNSConfig: dnsToTailcfgDNS(dnsConfig),

ACMEEmail: viper.GetString("acme_email"),
Expand Down
Loading