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
18 changes: 16 additions & 2 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -294,14 +294,28 @@ dns:
# - https://dns.nextdns.io/abc123

# Split DNS (see https://tailscale.com/kb/1054/dns/),
# a map of domains and which DNS server to use for each.
split: {}
# It uses TS's "MagicDNS" Quad 100 functionality internally.
# Provides a map of domains and which DNS server to use for each.
# Queries for domains listed here will be forwarded to the
# specified DNS servers instead of the global nameservers.
split:
{}
# foo.bar.com:
# - 1.1.1.1
# darp.headscale.net:
# - 1.1.1.1
# - 8.8.8.8

# Split DNS - Fallback resolvers.
# These resolvers are used when split DNS is configured and a query doesn't
# match any of the split DNS domains. As a fall through.
# If not specified, the global nameservers will be used.
# Different clients may treat these subtly differently
# variably including them in their internal resolver list.
split_fallback: []
# - 1.1.1.1
# - 8.8.8.8

# Set custom DNS search domains. With MagicDNS enabled,
# your tailnet base_domain is always the first search domain.
search_domains: []
Expand Down
93 changes: 87 additions & 6 deletions hscontrol/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,9 @@ type DNSConfig struct {
}

type Nameservers struct {
Global []string
Split map[string][]string
Global []string
Split map[string][]string
SplitFallback []string
}

type SqliteConfig struct {
Expand Down Expand Up @@ -493,12 +494,37 @@ func validateServerConfig() error {
)
}

// Validate override_local_dns configuration
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"
}
}

// Validate split DNS configuration
splitDNS := viper.GetStringMapStringSlice("dns.nameservers.split")
if len(splitDNS) > 0 {
// Check if fallback resolvers are available
fallbackResolvers := viper.GetStringSlice("dns.split_dns_fallback_resolvers")
globalResolvers := viper.GetStringSlice("dns.nameservers.global")
if len(fallbackResolvers) == 0 && len(globalResolvers) == 0 {
errorText += "Fatal config error: when dns.nameservers.split is configured, either dns.split_dns_fallback_resolvers or dns.nameservers.global must be set\n"
}

// Log info if fallback resolvers will be derived from global resolvers
if len(fallbackResolvers) == 0 && len(globalResolvers) > 0 {
log.Info().
Msg("dns.split_dns_fallback_resolvers not configured - using dns.nameservers.global as fallback resolvers for split DNS")
}
}

// Validate MagicDNS configuration
if viper.GetBool("dns.magic_dns") {
if global := viper.GetStringSlice("dns.nameservers.global"); len(global) == 0 {
errorText += "Fatal config error: dns.nameservers.global must be set when dns.magic_dns is true\n"
}
}

// Validate tuning parameters
if size := viper.GetInt("tuning.node_store_batch_size"); size <= 0 {
errorText += fmt.Sprintf(
Expand Down Expand Up @@ -720,6 +746,7 @@ 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.SplitFallback = viper.GetStringSlice("dns.nameservers.split_fallback")
dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")

Expand Down Expand Up @@ -814,6 +841,37 @@ func (d *DNSConfig) splitResolvers() map[string][]*dnstype.Resolver {
return routes
}

// splitDNSFallbackResolvers returns the fallback DNS resolvers for split DNS
// defined in the config file.
// If a nameserver is a valid IP, it will be used as a Fallback Regular resolver.
// If a nameserver is a valid URL, it will be used as a Fallback DoH resolver.
// If a nameserver is neither a valid URL nor a valid IP, it will be ignored.
func (d *DNSConfig) splitDNSFallbackResolvers() []*dnstype.Resolver {
var resolvers []*dnstype.Resolver

for _, nsStr := range d.Nameservers.SplitFallback {
if _, err := netip.ParseAddr(nsStr); err == nil {
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})

continue
}

if _, err := url.Parse(nsStr); err == nil {
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})

continue
}

log.Warn().Msgf("Invalid split DNS fallback resolver %q - must be a valid IP address or URL, ignoring", nsStr)
}

return resolvers
}

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

Expand All @@ -823,14 +881,37 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {

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

globalResolvers := dns.globalResolvers()

// Only populate main Resolvers field if:
// 1. MagicDNS is enabled (MagicDNS supersedes override_local_dns), OR
// 2. override_local_dns is explicitly enabled
//
// This prevents leaking DNS configuration to clients when neither
// MagicDNS nor override_local_dns are enabled.
// See: https://github.com/juanfont/headscale/issues/2899
if dns.MagicDNS || dns.OverrideLocalDNS {
cfg.Resolvers = globalResolvers
}

routes := dns.splitResolvers()
cfg.Routes = routes

// Populate FallbackResolvers when split DNS is configured.
// FallbackResolvers are used when a split DNS query doesn't match any route.
// They are needed even when override_local_dns=false because the Magic DNS Forwarder
// requires fallback resolvers to function properly.
if len(routes) > 0 {
fallbackResolvers := dns.splitDNSFallbackResolvers()
if len(fallbackResolvers) > 0 {
cfg.FallbackResolvers = fallbackResolvers
} else if len(globalResolvers) > 0 {
// Backwards compatibility measure
cfg.FallbackResolvers = globalResolvers
}
}

if dns.BaseDomain != "" {
cfg.Domains = []string{dns.BaseDomain}
}
Expand Down
129 changes: 128 additions & 1 deletion hscontrol/types/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func TestReadConfig(t *testing.T) {
},
},
{
name: "dns-to-tailcfg.DNSConfig",
name: "dns-to-tailcfg.DNSConfig-no-magic-no-override",
configPath: "testdata/dns_full_no_magic.yaml",
setup: func(t *testing.T) (any, error) {
dns, err := dns()
Expand Down Expand Up @@ -237,6 +237,133 @@ func TestReadConfig(t *testing.T) {
"policy.path": "/etc/policy.hujson",
},
},
{
name: "dns-override-false-with-split-dns",
configPath: "testdata/dns_override_false_with_split.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: false,
Domains: []string{"example.com", "test.com"},
// No Resolvers - override_local_dns is false
Routes: map[string][]*dnstype.Resolver{
"foo.bar.com": {{Addr: "1.1.1.1"}},
"darp.headscale.net": {{Addr: "8.8.8.8"}},
},
FallbackResolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
},
},
{
name: "dns-split-with-explicit-fallback-resolvers",
configPath: "testdata/dns_split_with_fallback.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: false,
Domains: []string{"example.com"},
Resolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
Routes: map[string][]*dnstype.Resolver{
"foo.bar.com": {{Addr: "1.1.1.1"}},
"darp.headscale.net": {{Addr: "8.8.8.8"}},
},
FallbackResolvers: []*dnstype.Resolver{
{Addr: "9.9.9.9"},
{Addr: "8.8.4.4"},
},
},
},
{
name: "dns-split-without-explicit-fallback-uses-global",
configPath: "testdata/dns_split_without_fallback.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: false,
Domains: []string{"example.com"},
Resolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
Routes: map[string][]*dnstype.Resolver{
"foo.bar.com": {{Addr: "1.1.1.1"}},
"darp.headscale.net": {{Addr: "8.8.8.8"}},
},
FallbackResolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
},
},
{
name: "dns-split-no-fallback-source-error",
configPath: "testdata/dns_split_no_fallback_error.yaml",
setup: func(t *testing.T) (any, error) {
return LoadServerConfig()
},
wantErr: "Fatal config error: when dns.nameservers.split is configured, either dns.nameservers.split_fallback or dns.nameservers.global must be set",
},
{
name: "dns-global-without-override-warning",
configPath: "testdata/dns_global_without_override.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: false,
Domains: []string{"example.com"},
// No Resolvers - override_local_dns is false
Routes: map[string][]*dnstype.Resolver{},
},
},
}

for _, tt := range tests {
Expand Down
20 changes: 20 additions & 0 deletions hscontrol/types/testdata/dns_global_without_override.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"

prefixes:
v4: "100.64.0.0/10"

database:
type: sqlite3

dns:
magic_dns: false
base_domain: example.com
override_local_dns: false

nameservers:
global:
- 1.1.1.1
- 1.0.0.1
29 changes: 29 additions & 0 deletions hscontrol/types/testdata/dns_override_false_with_split.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"

prefixes:
v4: "100.64.0.0/10"

database:
type: sqlite3

dns:
magic_dns: false
base_domain: example.com

override_local_dns: false
nameservers:
global:
- 1.1.1.1
- 1.0.0.1

split:
foo.bar.com:
- 1.1.1.1
darp.headscale.net:
- 8.8.8.8

search_domains:
- test.com
20 changes: 20 additions & 0 deletions hscontrol/types/testdata/dns_split_no_fallback_error.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"

prefixes:
v4: "100.64.0.0/10"

database:
type: sqlite3

dns:
magic_dns: false
base_domain: example.com
override_local_dns: false

nameservers:
split:
foo.bar.com:
- 1.1.1.1
Loading