mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-23 02:24:10 +00:00
Squashed Commit for Fix of issue 2899
This commit is contained in:
parent
eb788cd007
commit
62a306ed50
9 changed files with 765 additions and 8 deletions
|
|
@ -294,7 +294,10 @@ 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.
|
||||
# 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:
|
||||
|
|
@ -303,6 +306,16 @@ dns:
|
|||
# - 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: []
|
||||
|
|
|
|||
|
|
@ -113,8 +113,9 @@ type DNSConfig struct {
|
|||
}
|
||||
|
||||
type Nameservers struct {
|
||||
Global []string
|
||||
Split map[string][]string
|
||||
Global []string
|
||||
Split map[string][]string
|
||||
SplitFallback []string
|
||||
}
|
||||
|
||||
type SqliteConfig struct {
|
||||
|
|
@ -485,12 +486,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(
|
||||
|
|
@ -712,6 +738,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")
|
||||
|
||||
|
|
@ -806,6 +833,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{}
|
||||
|
||||
|
|
@ -815,14 +873,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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
20
hscontrol/types/testdata/dns_global_without_override.yaml
vendored
Normal file
20
hscontrol/types/testdata/dns_global_without_override.yaml
vendored
Normal file
|
|
@ -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
hscontrol/types/testdata/dns_override_false_with_split.yaml
vendored
Normal file
29
hscontrol/types/testdata/dns_override_false_with_split.yaml
vendored
Normal file
|
|
@ -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
hscontrol/types/testdata/dns_split_no_fallback_error.yaml
vendored
Normal file
20
hscontrol/types/testdata/dns_split_no_fallback_error.yaml
vendored
Normal file
|
|
@ -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
|
||||
30
hscontrol/types/testdata/dns_split_with_fallback.yaml
vendored
Normal file
30
hscontrol/types/testdata/dns_split_with_fallback.yaml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# 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: true
|
||||
|
||||
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
|
||||
|
||||
split_dns_fallback_resolvers:
|
||||
- 9.9.9.9
|
||||
- 8.8.4.4
|
||||
26
hscontrol/types/testdata/dns_split_without_fallback.yaml
vendored
Normal file
26
hscontrol/types/testdata/dns_split_without_fallback.yaml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# 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: true
|
||||
|
||||
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
|
||||
|
|
@ -226,3 +226,414 @@ func TestResolveMagicDNSExtraRecordsPath(t *testing.T) {
|
|||
assertCommandOutputContains(t, client, []string{"dig", "copy.myvpn.example.com"}, "8.8.8.8")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDNSOverrideLocalBehavior tests issue #2899
|
||||
// https://github.com/juanfont/headscale/issues/2899
|
||||
//
|
||||
// Correct behavior:
|
||||
// - MagicDNS supersedes override_local_dns
|
||||
// - When MagicDNS=true: Always send Resolvers (regardless of override_local_dns)
|
||||
// - When MagicDNS=false:
|
||||
// - override_local_dns=true: Send Resolvers
|
||||
// - override_local_dns=false/unset: No Resolvers
|
||||
func TestDNSOverrideLocalBehavior(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
// Test 1: MagicDNS=true, override_local_dns=true
|
||||
// Expected: DNS resolvers configured (MagicDNS supersedes)
|
||||
t.Run("magic_dns_true_override_true", func(t *testing.T) {
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{},
|
||||
hsic.WithTestName("dns-override-true"),
|
||||
hsic.WithConfigEnv(map[string]string{
|
||||
"HEADSCALE_DNS_MAGIC_DNS": "true",
|
||||
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
|
||||
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "true",
|
||||
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
|
||||
}),
|
||||
)
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
for _, client := range allClients {
|
||||
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: override_local_dns = false
|
||||
// Expected: DNS resolvers should be configured
|
||||
t.Run("override_local_dns_false", func(t *testing.T) {
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{},
|
||||
hsic.WithTestName("dns-override-false"),
|
||||
hsic.WithConfigEnv(map[string]string{
|
||||
"HEADSCALE_DNS_MAGIC_DNS": "true",
|
||||
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
|
||||
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
|
||||
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
|
||||
}),
|
||||
)
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
for _, client := range allClients {
|
||||
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: override_local_dns not set (using default from DefaultConfigEnv)
|
||||
// Expected: DNS resolvers should be configured consistently with explicit false
|
||||
t.Run("override_local_dns_default", func(t *testing.T) {
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
// Don't set HEADSCALE_DNS_OVERRIDE_LOCAL_DNS, use the default
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{},
|
||||
hsic.WithTestName("dns-override-default"),
|
||||
hsic.WithConfigEnv(map[string]string{
|
||||
"HEADSCALE_DNS_MAGIC_DNS": "true",
|
||||
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
|
||||
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
|
||||
// HEADSCALE_DNS_OVERRIDE_LOCAL_DNS intentionally not set
|
||||
}),
|
||||
)
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
for _, client := range allClients {
|
||||
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// assertDNSResolversConfigured checks that the Tailscale client has the expected DNS resolvers configured.
|
||||
// It uses EventuallyWithT to handle eventual consistency as DNS configuration may take time to propagate.
|
||||
func assertDNSResolversConfigured(t *testing.T, client TailscaleClient, expectedResolvers []string) {
|
||||
t.Helper()
|
||||
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
// Query DNS status from the client
|
||||
// The netmap contains the DNS configuration sent by headscale
|
||||
netmap, err := client.Netmap()
|
||||
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
|
||||
|
||||
if netmap == nil {
|
||||
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
|
||||
return
|
||||
}
|
||||
|
||||
if netmap.DNS.Resolvers == nil {
|
||||
assert.Fail(c, "DNS Resolvers is nil for client %s", client.Hostname())
|
||||
return
|
||||
}
|
||||
|
||||
// Extract resolver IPs from the netmap
|
||||
// Resolver.Addr is a string (can be IP or DoH URL)
|
||||
var actualResolvers []string
|
||||
for _, resolver := range netmap.DNS.Resolvers {
|
||||
actualResolvers = append(actualResolvers, resolver.Addr)
|
||||
}
|
||||
|
||||
assert.NotEmpty(c, actualResolvers,
|
||||
"Client %s should have DNS resolvers configured, but none were found",
|
||||
client.Hostname())
|
||||
|
||||
// Check that all expected resolvers are present
|
||||
for _, expected := range expectedResolvers {
|
||||
assert.Contains(c, actualResolvers, expected,
|
||||
"Client %s should have resolver %s configured. Actual resolvers: %v",
|
||||
client.Hostname(), expected, actualResolvers)
|
||||
}
|
||||
}, 30*time.Second, 2*time.Second,
|
||||
"DNS resolvers should be configured on client %s with resolvers %v",
|
||||
client.Hostname(), expectedResolvers)
|
||||
}
|
||||
|
||||
// TestDNSOverrideLocalWithMagicDNSDisabled tests that when MagicDNS is disabled,
|
||||
// DNS resolvers should not be pushed to clients regardless of override_local_dns setting.
|
||||
func TestDNSOverrideLocalWithMagicDNSDisabled(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{},
|
||||
hsic.WithTestName("dns-magicdns-disabled"),
|
||||
hsic.WithConfigEnv(map[string]string{
|
||||
"HEADSCALE_DNS_MAGIC_DNS": "false",
|
||||
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
|
||||
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
|
||||
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
|
||||
}),
|
||||
)
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
for _, client := range allClients {
|
||||
// When MagicDNS is disabled, no resolvers should be configured
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
netmap, err := client.Netmap()
|
||||
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
|
||||
|
||||
if netmap == nil {
|
||||
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
|
||||
return
|
||||
}
|
||||
|
||||
// When MagicDNS is off, DNS configuration might be nil or empty
|
||||
if netmap.DNS.Resolvers != nil {
|
||||
assert.Empty(c, netmap.DNS.Resolvers,
|
||||
"Client %s should NOT have DNS resolvers when MagicDNS is disabled",
|
||||
client.Hostname())
|
||||
}
|
||||
}, 30*time.Second, 2*time.Second,
|
||||
"DNS resolvers should NOT be configured when MagicDNS is disabled on client %s",
|
||||
client.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
// This is a Canary test - to be sure that TS client doesn't leak DNS config when it shouldn't.
|
||||
// Even though both Global Resolver, split DNS & Fallback resolvers are configured, the client should not receive any DNS resolvers
|
||||
// when override_local_dns is false. (otherwise split DNS is overriding the override_local_dns setting at the client).
|
||||
// TestDNSSplitWithoutOverride tests that when split DNS is enabled but override_local_dns is false,
|
||||
// the client should NOT receive global DNS resolvers, but SHOULD receive split DNS routes and fallback resolvers,
|
||||
// but they should not make it to the hosts' actual DNS configuration.
|
||||
// This test uses distinct DNS server addresses to identify which configuration field any leaked values came from.
|
||||
func TestDNSSplitWithoutOverride(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
spec := ScenarioSpec{
|
||||
NodesPerUser: 1,
|
||||
Users: []string{"user1"},
|
||||
}
|
||||
|
||||
scenario, err := NewScenario(spec)
|
||||
require.NoError(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
// Use distinct DNS server addresses to identify source of any leaked configuration:
|
||||
// - Global nameservers: 1.1.1.1, 1.0.0.1 (should NOT appear in client Resolvers)
|
||||
// - Fallback resolvers: 9.9.9.9, 149.112.112.112 (SHOULD appear in FallbackResolvers)
|
||||
// - Split DNS resolvers: 8.8.8.8, 8.8.4.4 (SHOULD appear in Routes)
|
||||
|
||||
// Create a custom config file with split DNS configuration
|
||||
configYAML := []byte(`
|
||||
noise:
|
||||
private_key_path: /tmp/noise_private.key
|
||||
|
||||
server_url: http://headscale:8080
|
||||
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
allocation: sequential
|
||||
|
||||
database:
|
||||
type: sqlite3
|
||||
sqlite:
|
||||
path: /tmp/integration_test_db.sqlite3
|
||||
|
||||
dns:
|
||||
magic_dns: false
|
||||
base_domain: example.com
|
||||
override_local_dns: false
|
||||
nameservers:
|
||||
global:
|
||||
- 1.1.1.1
|
||||
- 1.0.0.1
|
||||
split:
|
||||
corp.example.com:
|
||||
- 8.8.8.8
|
||||
internal.example.com:
|
||||
- 8.8.4.4
|
||||
split_fallback:
|
||||
- 9.9.9.9
|
||||
- 149.112.112.112
|
||||
`)
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(
|
||||
[]tsic.Option{
|
||||
tsic.WithDockerEntrypoint([]string{
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"/bin/sleep 3 ; apk add python3 curl bind-tools ; update-ca-certificates ; tailscaled --tun=tsdev",
|
||||
}),
|
||||
},
|
||||
hsic.WithTestName("dns-split-no-override"),
|
||||
hsic.WithFileInContainer("/etc/headscale/config.yaml", configYAML),
|
||||
)
|
||||
requireNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
for _, client := range allClients {
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
netmap, err := client.Netmap()
|
||||
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
|
||||
|
||||
if netmap == nil {
|
||||
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
|
||||
return
|
||||
}
|
||||
|
||||
// Critical assertion: Resolvers should be nil or empty when override_local_dns is false
|
||||
if len(netmap.DNS.Resolvers) > 0 {
|
||||
var leakedResolvers []string
|
||||
for _, resolver := range netmap.DNS.Resolvers {
|
||||
leakedResolvers = append(leakedResolvers, resolver.Addr)
|
||||
|
||||
// Identify which configuration field this resolver came from
|
||||
switch resolver.Addr {
|
||||
case "1.1.1.1", "1.0.0.1":
|
||||
assert.Fail(c, "Client %s received global nameserver %s in Resolvers field - this should NOT happen when override_local_dns=false. "+
|
||||
"This value came from dns.nameservers.global configuration.",
|
||||
client.Hostname(), resolver.Addr)
|
||||
case "9.9.9.9", "149.112.112.112":
|
||||
assert.Fail(c, "Client %s received fallback resolver %s in Resolvers field - this should be in FallbackResolvers, not Resolvers. "+
|
||||
"This value came from dns.split_dns_fallback_resolvers configuration.",
|
||||
client.Hostname(), resolver.Addr)
|
||||
case "8.8.8.8", "8.8.4.4":
|
||||
assert.Fail(c, "Client %s received split DNS resolver %s in Resolvers field - this should be in Routes, not Resolvers. "+
|
||||
"This value came from dns.nameservers.split configuration.",
|
||||
client.Hostname(), resolver.Addr)
|
||||
default:
|
||||
assert.Fail(c, "Client %s received unexpected resolver %s in Resolvers field",
|
||||
client.Hostname(), resolver.Addr)
|
||||
}
|
||||
}
|
||||
assert.Fail(c, "Client %s should NOT have Resolvers configured when override_local_dns=false, but found: %v",
|
||||
client.Hostname(), leakedResolvers)
|
||||
return
|
||||
}
|
||||
|
||||
// FallbackResolvers should be configured with the explicit fallback resolvers
|
||||
assert.NotNil(c, netmap.DNS.FallbackResolvers,
|
||||
"Client %s should have FallbackResolvers configured for split DNS",
|
||||
client.Hostname())
|
||||
|
||||
var actualFallback []string
|
||||
for _, resolver := range netmap.DNS.FallbackResolvers {
|
||||
actualFallback = append(actualFallback, resolver.Addr)
|
||||
}
|
||||
|
||||
expectedFallback := []string{"9.9.9.9", "149.112.112.112"}
|
||||
assert.ElementsMatch(c, expectedFallback, actualFallback,
|
||||
"Client %s FallbackResolvers mismatch. Expected: %v (from dns.nameservers.split_fallback), Got: %v",
|
||||
client.Hostname(), expectedFallback, actualFallback)
|
||||
|
||||
// Routes should be configured with split DNS
|
||||
assert.NotNil(c, netmap.DNS.Routes,
|
||||
"Client %s should have DNS Routes configured for split DNS",
|
||||
client.Hostname())
|
||||
|
||||
// Verify specific split DNS routes
|
||||
corpResolvers, hasCorp := netmap.DNS.Routes["corp.example.com"]
|
||||
assert.True(c, hasCorp, "Client %s should have route for corp.example.com", client.Hostname())
|
||||
if hasCorp {
|
||||
assert.Len(c, corpResolvers, 1, "corp.example.com should have 1 resolver")
|
||||
assert.Equal(c, "8.8.8.8", corpResolvers[0].Addr,
|
||||
"corp.example.com resolver should be 8.8.8.8 (from dns.nameservers.split)")
|
||||
}
|
||||
|
||||
internalResolvers, hasInternal := netmap.DNS.Routes["internal.example.com"]
|
||||
assert.True(c, hasInternal, "Client %s should have route for internal.example.com", client.Hostname())
|
||||
if hasInternal {
|
||||
assert.Len(c, internalResolvers, 1, "internal.example.com should have 1 resolver")
|
||||
assert.Equal(c, "8.8.4.4", internalResolvers[0].Addr,
|
||||
"internal.example.com resolver should be 8.8.4.4 (from dns.nameservers.split)")
|
||||
}
|
||||
}, 30*time.Second, 2*time.Second,
|
||||
"DNS configuration validation failed for client %s",
|
||||
client.Hostname())
|
||||
|
||||
// Verify the actual DNS configuration on the client doesn't contain ANY of the configured nameservers
|
||||
// Use 'dig' without arguments to query the client's actual DNS server
|
||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
result, _, err := client.Execute([]string{"dig"})
|
||||
assert.NoError(c, err, "Failed to execute dig command on client %s", client.Hostname())
|
||||
|
||||
// NONE of the configured nameservers should appear next to ";; SERVER:" in the dig output
|
||||
// Global nameservers (should NOT leak)
|
||||
assert.NotContains(c, result, ";; SERVER: 1.1.1.1",
|
||||
"Client %s should NOT be using global nameserver 1.1.1.1 when override_local_dns=false",
|
||||
client.Hostname())
|
||||
assert.NotContains(c, result, ";; SERVER: 1.0.0.1",
|
||||
"Client %s should NOT be using global nameserver 1.0.0.1 when override_local_dns=false",
|
||||
client.Hostname())
|
||||
|
||||
// Fallback resolvers (should NOT leak to actual DNS configuration)
|
||||
assert.NotContains(c, result, ";; SERVER: 9.9.9.9",
|
||||
"Client %s should NOT be using fallback resolver 9.9.9.9 as actual DNS server when override_local_dns=false",
|
||||
client.Hostname())
|
||||
assert.NotContains(c, result, ";; SERVER: 149.112.112.112",
|
||||
"Client %s should NOT be using fallback resolver 149.112.112.112 as actual DNS server when override_local_dns=false",
|
||||
client.Hostname())
|
||||
|
||||
// Split DNS resolvers (should NOT leak to actual DNS configuration)
|
||||
assert.NotContains(c, result, ";; SERVER: 8.8.8.8",
|
||||
"Client %s should NOT be using split DNS resolver 8.8.8.8 as actual DNS server when override_local_dns=false",
|
||||
client.Hostname())
|
||||
assert.NotContains(c, result, ";; SERVER: 8.8.4.4",
|
||||
"Client %s should NOT be using split DNS resolver 8.8.4.4 as actual DNS server when override_local_dns=false",
|
||||
client.Hostname())
|
||||
}, 30*time.Second, 2*time.Second,
|
||||
"Client %s should not be using any of headscale's configured DNS servers",
|
||||
client.Hostname())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue