Restore support for "Override local DNS" (#2438)

Tailscale allows to override the local DNS settings of a node via
"Override local DNS" [1]. Restore this flag with the same config setting
name `dns.override_local_dns` but disable it by default to align it with
Tailscale's default behaviour.

Tested with Tailscale 1.80.2 and systemd-resolved on Debian 12.

With `dns.override_local_dns: false`:

```
Link 12 (tailscale0)
Current Scopes: DNS
     Protocols: -DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
   DNS Servers: 100.100.100.100
    DNS Domain: tn.example.com ~0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa [snip]
```

With `dns.override_local_dns: true`:

```
Link 12 (tailscale0)
Current Scopes: DNS
     Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
   DNS Servers: 100.100.100.100
    DNS Domain: tn.example.com ~.
```

[1] https://tailscale.com/kb/1054/dns#override-local-dns

Fixes: #2256
This commit is contained in:
nblock 2025-04-17 17:16:59 +02:00 committed by GitHub
parent 0fbe392499
commit 1e0516b99d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 113 additions and 12 deletions

View File

@ -92,6 +92,8 @@ The new policy can be used by setting the environment variable
- node FQDNs in the netmap will now contain a dot (".") at the end. This aligns - node FQDNs in the netmap will now contain a dot (".") at the end. This aligns
with behaviour of tailscale.com with behaviour of tailscale.com
[#2503](https://github.com/juanfont/headscale/pull/2503) [#2503](https://github.com/juanfont/headscale/pull/2503)
- Restore support for "Override local DNS"
[#2438](https://github.com/juanfont/headscale/pull/2438)
## 0.25.1 (2025-02-25) ## 0.25.1 (2025-02-25)

View File

@ -270,6 +270,10 @@ dns:
# `hostname.base_domain` (e.g., _myhost.example.com_). # `hostname.base_domain` (e.g., _myhost.example.com_).
base_domain: example.com base_domain: example.com
# Whether to use the local DNS settings of a node (default) or override the
# local DNS settings and force the use of Headscale's DNS configuration.
override_local_dns: false
# List of DNS servers to expose to clients. # List of DNS servers to expose to clients.
nameservers: nameservers:
global: global:

View File

@ -102,6 +102,7 @@ type Config struct {
type DNSConfig struct { type DNSConfig struct {
MagicDNS bool `mapstructure:"magic_dns"` MagicDNS bool `mapstructure:"magic_dns"`
BaseDomain string `mapstructure:"base_domain"` BaseDomain string `mapstructure:"base_domain"`
OverrideLocalDNS bool `mapstructure:"override_local_dns"`
Nameservers Nameservers Nameservers Nameservers
SearchDomains []string `mapstructure:"search_domains"` SearchDomains []string `mapstructure:"search_domains"`
ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"` ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"`
@ -287,6 +288,7 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("dns.magic_dns", true) viper.SetDefault("dns.magic_dns", true)
viper.SetDefault("dns.base_domain", "") viper.SetDefault("dns.base_domain", "")
viper.SetDefault("dns.override_local_dns", true)
viper.SetDefault("dns.nameservers.global", []string{}) viper.SetDefault("dns.nameservers.global", []string{})
viper.SetDefault("dns.nameservers.split", map[string]string{}) viper.SetDefault("dns.nameservers.split", map[string]string{})
viper.SetDefault("dns.search_domains", []string{}) viper.SetDefault("dns.search_domains", []string{})
@ -351,9 +353,9 @@ func validateServerConfig() error {
depr.fatalIfNewKeyIsNotUsed("policy.path", "acl_policy_path") depr.fatalIfNewKeyIsNotUsed("policy.path", "acl_policy_path")
// Move dns_config -> dns // Move dns_config -> dns
depr.warn("dns_config.override_local_dns")
depr.fatalIfNewKeyIsNotUsed("dns.magic_dns", "dns_config.magic_dns") depr.fatalIfNewKeyIsNotUsed("dns.magic_dns", "dns_config.magic_dns")
depr.fatalIfNewKeyIsNotUsed("dns.base_domain", "dns_config.base_domain") depr.fatalIfNewKeyIsNotUsed("dns.base_domain", "dns_config.base_domain")
depr.fatalIfNewKeyIsNotUsed("dns.override_local_dns", "dns_config.override_local_dns")
depr.fatalIfNewKeyIsNotUsed("dns.nameservers.global", "dns_config.nameservers") depr.fatalIfNewKeyIsNotUsed("dns.nameservers.global", "dns_config.nameservers")
depr.fatalIfNewKeyIsNotUsed("dns.nameservers.split", "dns_config.restricted_nameservers") depr.fatalIfNewKeyIsNotUsed("dns.nameservers.split", "dns_config.restricted_nameservers")
depr.fatalIfNewKeyIsNotUsed("dns.search_domains", "dns_config.domains") depr.fatalIfNewKeyIsNotUsed("dns.search_domains", "dns_config.domains")
@ -417,6 +419,12 @@ func validateServerConfig() error {
) )
} }
if viper.GetBool("dns.override_local_dns") {
if global := viper.GetStringSlice("dns.nameservers.global"); len(global) == 0 {
errorText += "Fatal config error: dns.nameservers.global must be set when dns.override_local_dns is true\n"
}
}
if errorText != "" { if errorText != "" {
// nolint // nolint
return errors.New(strings.TrimSuffix(errorText, "\n")) return errors.New(strings.TrimSuffix(errorText, "\n"))
@ -616,6 +624,7 @@ func dns() (DNSConfig, error) {
dns.MagicDNS = viper.GetBool("dns.magic_dns") dns.MagicDNS = viper.GetBool("dns.magic_dns")
dns.BaseDomain = viper.GetString("dns.base_domain") 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.Global = viper.GetStringSlice("dns.nameservers.global")
dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split") dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")
dns.SearchDomains = viper.GetStringSlice("dns.search_domains") dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
@ -721,7 +730,11 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
cfg.Proxied = dns.MagicDNS cfg.Proxied = dns.MagicDNS
cfg.ExtraRecords = dns.ExtraRecords cfg.ExtraRecords = dns.ExtraRecords
if dns.OverrideLocalDNS {
cfg.Resolvers = dns.globalResolvers() cfg.Resolvers = dns.globalResolvers()
} else {
cfg.FallbackResolvers = dns.globalResolvers()
}
routes := dns.splitResolvers() routes := dns.splitResolvers()
cfg.Routes = routes cfg.Routes = routes

View File

@ -7,6 +7,7 @@ import (
"testing" "testing"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -36,6 +37,7 @@ func TestReadConfig(t *testing.T) {
want: DNSConfig{ want: DNSConfig{
MagicDNS: true, MagicDNS: true,
BaseDomain: "example.com", BaseDomain: "example.com",
OverrideLocalDNS: false,
Nameservers: Nameservers{ Nameservers: Nameservers{
Global: []string{ Global: []string{
"1.1.1.1", "1.1.1.1",
@ -70,7 +72,7 @@ func TestReadConfig(t *testing.T) {
want: &tailcfg.DNSConfig{ want: &tailcfg.DNSConfig{
Proxied: true, Proxied: true,
Domains: []string{"example.com", "test.com", "bar.com"}, Domains: []string{"example.com", "test.com", "bar.com"},
Resolvers: []*dnstype.Resolver{ FallbackResolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"}, {Addr: "1.1.1.1"},
{Addr: "1.0.0.1"}, {Addr: "1.0.0.1"},
{Addr: "2606:4700:4700::1111"}, {Addr: "2606:4700:4700::1111"},
@ -101,6 +103,7 @@ func TestReadConfig(t *testing.T) {
want: DNSConfig{ want: DNSConfig{
MagicDNS: false, MagicDNS: false,
BaseDomain: "example.com", BaseDomain: "example.com",
OverrideLocalDNS: false,
Nameservers: Nameservers{ Nameservers: Nameservers{
Global: []string{ Global: []string{
"1.1.1.1", "1.1.1.1",
@ -135,7 +138,7 @@ func TestReadConfig(t *testing.T) {
want: &tailcfg.DNSConfig{ want: &tailcfg.DNSConfig{
Proxied: false, Proxied: false,
Domains: []string{"example.com", "test.com", "bar.com"}, Domains: []string{"example.com", "test.com", "bar.com"},
Resolvers: []*dnstype.Resolver{ FallbackResolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"}, {Addr: "1.1.1.1"},
{Addr: "1.0.0.1"}, {Addr: "1.0.0.1"},
{Addr: "2606:4700:4700::1111"}, {Addr: "2606:4700:4700::1111"},
@ -181,6 +184,40 @@ func TestReadConfig(t *testing.T) {
}, },
wantErr: "", wantErr: "",
}, },
{
name: "dns-override-true-errors",
configPath: "testdata/dns-override-true-error.yaml",
setup: func(t *testing.T) (any, error) {
return LoadServerConfig()
},
wantErr: "Fatal config error: dns.nameservers.global must be set when dns.override_local_dns is true",
},
{
name: "dns-override-true",
configPath: "testdata/dns-override-true.yaml",
setup: func(t *testing.T) (any, error) {
_, 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"},
Routes: map[string][]*dnstype.Resolver{},
Resolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
},
},
{ {
name: "policy-path-is-loaded", name: "policy-path-is-loaded",
configPath: "testdata/policy-path-is-loaded.yaml", configPath: "testdata/policy-path-is-loaded.yaml",
@ -254,6 +291,7 @@ func TestReadConfigFromEnv(t *testing.T) {
configEnv: map[string]string{ configEnv: map[string]string{
"HEADSCALE_DNS_MAGIC_DNS": "true", "HEADSCALE_DNS_MAGIC_DNS": "true",
"HEADSCALE_DNS_BASE_DOMAIN": "example.com", "HEADSCALE_DNS_BASE_DOMAIN": "example.com",
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": `1.1.1.1 8.8.8.8`, "HEADSCALE_DNS_NAMESERVERS_GLOBAL": `1.1.1.1 8.8.8.8`,
"HEADSCALE_DNS_SEARCH_DOMAINS": "test.com bar.com", "HEADSCALE_DNS_SEARCH_DOMAINS": "test.com bar.com",
@ -274,6 +312,7 @@ func TestReadConfigFromEnv(t *testing.T) {
want: DNSConfig{ want: DNSConfig{
MagicDNS: true, MagicDNS: true,
BaseDomain: "example.com", BaseDomain: "example.com",
OverrideLocalDNS: false,
Nameservers: Nameservers{ Nameservers: Nameservers{
Global: []string{"1.1.1.1", "8.8.8.8"}, Global: []string{"1.1.1.1", "8.8.8.8"},
Split: map[string][]string{ Split: map[string][]string{
@ -301,7 +340,7 @@ func TestReadConfigFromEnv(t *testing.T) {
conf, err := tt.setup(t) conf, err := tt.setup(t)
require.NoError(t, err) require.NoError(t, err)
if diff := cmp.Diff(tt.want, conf); diff != "" { if diff := cmp.Diff(tt.want, conf, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("ReadConfig() mismatch (-want +got):\n%s", diff) t.Errorf("ReadConfig() mismatch (-want +got):\n%s", diff)
} }
}) })

View File

@ -13,3 +13,4 @@ server_url: "https://server.derp.no"
dns: dns:
magic_dns: true magic_dns: true
base_domain: derp.no base_domain: derp.no
override_local_dns: false

View File

@ -13,3 +13,4 @@ server_url: "https://derp.no"
dns: dns:
magic_dns: true magic_dns: true
base_domain: clients.derp.no base_domain: clients.derp.no
override_local_dns: false

View File

@ -0,0 +1,16 @@
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: derp.no
override_local_dns: true

View File

@ -0,0 +1,20 @@
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

View File

@ -7,6 +7,7 @@ dns:
magic_dns: true magic_dns: true
base_domain: example.com base_domain: example.com
override_local_dns: false
nameservers: nameservers:
global: global:
- 1.1.1.1 - 1.1.1.1

View File

@ -7,6 +7,7 @@ dns:
magic_dns: false magic_dns: false
base_domain: example.com base_domain: example.com
override_local_dns: false
nameservers: nameservers:
global: global:
- 1.1.1.1 - 1.1.1.1

View File

@ -15,4 +15,6 @@ policy:
type: file type: file
path: "/etc/policy.hujson" path: "/etc/policy.hujson"
dns.magic_dns: false dns:
magic_dns: false
override_local_dns: false

View File

@ -23,6 +23,7 @@ func DefaultConfigEnv() map[string]string {
"HEADSCALE_PREFIXES_V6": "fd7a:115c:a1e0::/48", "HEADSCALE_PREFIXES_V6": "fd7a:115c:a1e0::/48",
"HEADSCALE_DNS_BASE_DOMAIN": "headscale.net", "HEADSCALE_DNS_BASE_DOMAIN": "headscale.net",
"HEADSCALE_DNS_MAGIC_DNS": "true", "HEADSCALE_DNS_MAGIC_DNS": "true",
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "127.0.0.11 1.1.1.1", "HEADSCALE_DNS_NAMESERVERS_GLOBAL": "127.0.0.11 1.1.1.1",
"HEADSCALE_PRIVATE_KEY_PATH": "/tmp/private.key", "HEADSCALE_PRIVATE_KEY_PATH": "/tmp/private.key",
"HEADSCALE_NOISE_PRIVATE_KEY_PATH": "/tmp/noise_private.key", "HEADSCALE_NOISE_PRIVATE_KEY_PATH": "/tmp/noise_private.key",