Skip to content

Commit a0f46d4

Browse files
committed
fix: DNS resolver config respects MagicDNS superseding override_local_dns
Fixes #2899 This changes the DNS configuration logic so that: - MagicDNS supersedes override_local_dns setting - 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 (clients use local DNS) Previously, when override_local_dns=false, resolvers were sent as FallbackResolvers which are only used when primary DNS fails, causing DNS to not work. Changes: - Updated dnsToTailcfgDNS() to check both MagicDNS and override_local_dns - Updated unit tests to reflect correct behavior - Added comprehensive integration tests for all config combinations
1 parent eb788cd commit a0f46d4

3 files changed

Lines changed: 332 additions & 13 deletions

File tree

hscontrol/types/config.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -815,10 +815,18 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
815815

816816
cfg.Proxied = dns.MagicDNS
817817
cfg.ExtraRecords = dns.ExtraRecords
818-
if dns.OverrideLocalDNS {
819-
cfg.Resolvers = dns.globalResolvers()
820-
} else {
821-
cfg.FallbackResolvers = dns.globalResolvers()
818+
819+
globalResolvers := dns.globalResolvers()
820+
if len(globalResolvers) > 0 {
821+
// MagicDNS supersedes override_local_dns setting.
822+
// If MagicDNS is enabled, always send Resolvers.
823+
// If MagicDNS is disabled, only send Resolvers when override_local_dns is true.
824+
// When override_local_dns is false/unset (default), no resolvers are sent unless MagicDNS is enabled.
825+
// See: https://github.com/juanfont/headscale/issues/2899
826+
if dns.MagicDNS || dns.OverrideLocalDNS {
827+
cfg.Resolvers = globalResolvers
828+
}
829+
// If neither MagicDNS nor OverrideLocalDNS is true, we don't send any resolvers
822830
}
823831

824832
routes := dns.splitResolvers()

hscontrol/types/config_test.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ func TestReadConfig(t *testing.T) {
7272
want: &tailcfg.DNSConfig{
7373
Proxied: true,
7474
Domains: []string{"example.com", "test.com", "bar.com"},
75-
FallbackResolvers: []*dnstype.Resolver{
75+
Resolvers: []*dnstype.Resolver{
7676
{Addr: "1.1.1.1"},
7777
{Addr: "1.0.0.1"},
7878
{Addr: "2606:4700:4700::1111"},
@@ -125,7 +125,7 @@ func TestReadConfig(t *testing.T) {
125125
},
126126
},
127127
{
128-
name: "dns-to-tailcfg.DNSConfig",
128+
name: "dns-to-tailcfg.DNSConfig-no-magic-no-override",
129129
configPath: "testdata/dns_full_no_magic.yaml",
130130
setup: func(t *testing.T) (any, error) {
131131
dns, err := dns()
@@ -138,13 +138,7 @@ func TestReadConfig(t *testing.T) {
138138
want: &tailcfg.DNSConfig{
139139
Proxied: false,
140140
Domains: []string{"example.com", "test.com", "bar.com"},
141-
FallbackResolvers: []*dnstype.Resolver{
142-
{Addr: "1.1.1.1"},
143-
{Addr: "1.0.0.1"},
144-
{Addr: "2606:4700:4700::1111"},
145-
{Addr: "2606:4700:4700::1001"},
146-
{Addr: "https://dns.nextdns.io/abc123"},
147-
},
141+
// No Resolvers - MagicDNS is false and override_local_dns is false
148142
Routes: map[string][]*dnstype.Resolver{
149143
"darp.headscale.net": {{Addr: "1.1.1.1"}, {Addr: "8.8.8.8"}},
150144
"foo.bar.com": {{Addr: "1.1.1.1"}},
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
package integration
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/juanfont/headscale/integration/hsic"
9+
"github.com/juanfont/headscale/integration/tsic"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestDNSOverrideLocalBehavior tests issue #2899
15+
// https://github.com/juanfont/headscale/issues/2899
16+
//
17+
// Correct behavior:
18+
// - MagicDNS supersedes override_local_dns
19+
// - When MagicDNS=true: Always send Resolvers (regardless of override_local_dns)
20+
// - When MagicDNS=false:
21+
// - override_local_dns=true: Send Resolvers
22+
// - override_local_dns=false/unset: No Resolvers
23+
func TestDNSOverrideLocalBehavior(t *testing.T) {
24+
IntegrationSkip(t)
25+
26+
// Test 1: MagicDNS=true, override_local_dns=true
27+
// Expected: DNS resolvers configured (MagicDNS supersedes)
28+
t.Run("magic_dns_true_override_true", func(t *testing.T) {
29+
spec := ScenarioSpec{
30+
NodesPerUser: 1,
31+
Users: []string{"user1"},
32+
}
33+
34+
scenario, err := NewScenario(spec)
35+
require.NoError(t, err)
36+
defer scenario.ShutdownAssertNoPanics(t)
37+
38+
err = scenario.CreateHeadscaleEnv(
39+
[]tsic.Option{},
40+
hsic.WithTestName("dns-override-true"),
41+
hsic.WithConfigEnv(map[string]string{
42+
"HEADSCALE_DNS_MAGIC_DNS": "true",
43+
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
44+
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "true",
45+
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
46+
}),
47+
)
48+
requireNoErrHeadscaleEnv(t, err)
49+
50+
allClients, err := scenario.ListTailscaleClients()
51+
requireNoErrListClients(t, err)
52+
53+
err = scenario.WaitForTailscaleSync()
54+
requireNoErrSync(t, err)
55+
56+
for _, client := range allClients {
57+
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
58+
}
59+
})
60+
61+
// Test 2: override_local_dns = false
62+
// Expected: DNS resolvers should be configured
63+
t.Run("override_local_dns_false", func(t *testing.T) {
64+
spec := ScenarioSpec{
65+
NodesPerUser: 1,
66+
Users: []string{"user1"},
67+
}
68+
69+
scenario, err := NewScenario(spec)
70+
require.NoError(t, err)
71+
defer scenario.ShutdownAssertNoPanics(t)
72+
73+
err = scenario.CreateHeadscaleEnv(
74+
[]tsic.Option{},
75+
hsic.WithTestName("dns-override-false"),
76+
hsic.WithConfigEnv(map[string]string{
77+
"HEADSCALE_DNS_MAGIC_DNS": "true",
78+
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
79+
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
80+
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
81+
}),
82+
)
83+
requireNoErrHeadscaleEnv(t, err)
84+
85+
allClients, err := scenario.ListTailscaleClients()
86+
requireNoErrListClients(t, err)
87+
88+
err = scenario.WaitForTailscaleSync()
89+
requireNoErrSync(t, err)
90+
91+
for _, client := range allClients {
92+
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
93+
}
94+
})
95+
96+
// Test 3: override_local_dns not set (using default from DefaultConfigEnv)
97+
// Expected: DNS resolvers should be configured consistently with explicit false
98+
t.Run("override_local_dns_default", func(t *testing.T) {
99+
spec := ScenarioSpec{
100+
NodesPerUser: 1,
101+
Users: []string{"user1"},
102+
}
103+
104+
scenario, err := NewScenario(spec)
105+
require.NoError(t, err)
106+
defer scenario.ShutdownAssertNoPanics(t)
107+
108+
// Don't set HEADSCALE_DNS_OVERRIDE_LOCAL_DNS, use the default
109+
err = scenario.CreateHeadscaleEnv(
110+
[]tsic.Option{},
111+
hsic.WithTestName("dns-override-default"),
112+
hsic.WithConfigEnv(map[string]string{
113+
"HEADSCALE_DNS_MAGIC_DNS": "true",
114+
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
115+
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
116+
// HEADSCALE_DNS_OVERRIDE_LOCAL_DNS intentionally not set
117+
}),
118+
)
119+
requireNoErrHeadscaleEnv(t, err)
120+
121+
allClients, err := scenario.ListTailscaleClients()
122+
requireNoErrListClients(t, err)
123+
124+
err = scenario.WaitForTailscaleSync()
125+
requireNoErrSync(t, err)
126+
127+
for _, client := range allClients {
128+
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
129+
}
130+
})
131+
}
132+
133+
// assertDNSResolversConfigured checks that the Tailscale client has the expected DNS resolvers configured.
134+
// It uses EventuallyWithT to handle eventual consistency as DNS configuration may take time to propagate.
135+
func assertDNSResolversConfigured(t *testing.T, client TailscaleClient, expectedResolvers []string) {
136+
t.Helper()
137+
138+
assert.EventuallyWithT(t, func(c *assert.CollectT) {
139+
// Query DNS status from the client
140+
// The netmap contains the DNS configuration sent by headscale
141+
netmap, err := client.Netmap()
142+
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
143+
144+
if netmap == nil {
145+
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
146+
return
147+
}
148+
149+
if netmap.DNS.Resolvers == nil {
150+
assert.Fail(c, "DNS Resolvers is nil for client %s", client.Hostname())
151+
return
152+
}
153+
154+
// Extract resolver IPs from the netmap
155+
// Resolver.Addr is a string (can be IP or DoH URL)
156+
var actualResolvers []string
157+
for _, resolver := range netmap.DNS.Resolvers {
158+
actualResolvers = append(actualResolvers, resolver.Addr)
159+
}
160+
161+
assert.NotEmpty(c, actualResolvers,
162+
"Client %s should have DNS resolvers configured, but none were found",
163+
client.Hostname())
164+
165+
// Check that all expected resolvers are present
166+
for _, expected := range expectedResolvers {
167+
assert.Contains(c, actualResolvers, expected,
168+
"Client %s should have resolver %s configured. Actual resolvers: %v",
169+
client.Hostname(), expected, actualResolvers)
170+
}
171+
}, 30*time.Second, 2*time.Second,
172+
"DNS resolvers should be configured on client %s with resolvers %v",
173+
client.Hostname(), expectedResolvers)
174+
}
175+
176+
// TestDNSOverrideLocalWithMagicDNSDisabled tests that when MagicDNS is disabled,
177+
// DNS resolvers should not be pushed to clients regardless of override_local_dns setting.
178+
func TestDNSOverrideLocalWithMagicDNSDisabled(t *testing.T) {
179+
IntegrationSkip(t)
180+
181+
spec := ScenarioSpec{
182+
NodesPerUser: 1,
183+
Users: []string{"user1"},
184+
}
185+
186+
scenario, err := NewScenario(spec)
187+
require.NoError(t, err)
188+
defer scenario.ShutdownAssertNoPanics(t)
189+
190+
err = scenario.CreateHeadscaleEnv(
191+
[]tsic.Option{},
192+
hsic.WithTestName("dns-magicdns-disabled"),
193+
hsic.WithConfigEnv(map[string]string{
194+
"HEADSCALE_DNS_MAGIC_DNS": "false",
195+
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
196+
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
197+
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
198+
}),
199+
)
200+
requireNoErrHeadscaleEnv(t, err)
201+
202+
allClients, err := scenario.ListTailscaleClients()
203+
requireNoErrListClients(t, err)
204+
205+
err = scenario.WaitForTailscaleSync()
206+
requireNoErrSync(t, err)
207+
208+
for _, client := range allClients {
209+
// When MagicDNS is disabled, no resolvers should be configured
210+
assert.EventuallyWithT(t, func(c *assert.CollectT) {
211+
netmap, err := client.Netmap()
212+
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
213+
214+
if netmap == nil {
215+
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
216+
return
217+
}
218+
219+
// When MagicDNS is off, DNS configuration might be nil or empty
220+
if netmap.DNS.Resolvers != nil {
221+
assert.Empty(c, netmap.DNS.Resolvers,
222+
"Client %s should NOT have DNS resolvers when MagicDNS is disabled",
223+
client.Hostname())
224+
}
225+
}, 30*time.Second, 2*time.Second,
226+
"DNS resolvers should NOT be configured when MagicDNS is disabled on client %s",
227+
client.Hostname())
228+
}
229+
}
230+
231+
// TestDNSOverrideLocalConsistency tests that the three ways of setting override_local_dns
232+
// all produce consistent behavior. This is the core test for issue #2899.
233+
func TestDNSOverrideLocalConsistency(t *testing.T) {
234+
IntegrationSkip(t)
235+
236+
testCases := []struct {
237+
name string
238+
setEnvVar bool
239+
value string
240+
expectDNS bool
241+
resolvers []string
242+
}{
243+
{
244+
name: "explicit_true",
245+
setEnvVar: true,
246+
value: "true",
247+
expectDNS: true,
248+
resolvers: []string{"8.8.8.8", "1.1.1.1"},
249+
},
250+
{
251+
name: "explicit_false",
252+
setEnvVar: true,
253+
value: "false",
254+
expectDNS: true,
255+
resolvers: []string{"8.8.8.8", "1.1.1.1"},
256+
},
257+
{
258+
name: "unset_default",
259+
setEnvVar: false,
260+
value: "",
261+
expectDNS: true,
262+
resolvers: []string{"8.8.8.8", "1.1.1.1"},
263+
},
264+
}
265+
266+
for _, tc := range testCases {
267+
tc := tc // capture range variable
268+
t.Run(tc.name, func(t *testing.T) {
269+
spec := ScenarioSpec{
270+
NodesPerUser: 1,
271+
Users: []string{"user1"},
272+
}
273+
274+
scenario, err := NewScenario(spec)
275+
require.NoError(t, err)
276+
defer scenario.ShutdownAssertNoPanics(t)
277+
278+
configEnv := map[string]string{
279+
"HEADSCALE_DNS_MAGIC_DNS": "true",
280+
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
281+
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
282+
}
283+
284+
if tc.setEnvVar {
285+
configEnv["HEADSCALE_DNS_OVERRIDE_LOCAL_DNS"] = tc.value
286+
}
287+
288+
err = scenario.CreateHeadscaleEnv(
289+
[]tsic.Option{},
290+
hsic.WithTestName(fmt.Sprintf("dns-consistency-%s", tc.name)),
291+
hsic.WithConfigEnv(configEnv),
292+
)
293+
requireNoErrHeadscaleEnv(t, err)
294+
295+
allClients, err := scenario.ListTailscaleClients()
296+
requireNoErrListClients(t, err)
297+
298+
err = scenario.WaitForTailscaleSync()
299+
requireNoErrSync(t, err)
300+
301+
for _, client := range allClients {
302+
if tc.expectDNS {
303+
assertDNSResolversConfigured(t, client, tc.resolvers)
304+
} else {
305+
// If we ever need to test that DNS should NOT be configured
306+
assert.EventuallyWithT(t, func(c *assert.CollectT) {
307+
netmap, err := client.Netmap()
308+
assert.NoError(c, err)
309+
if netmap != nil && netmap.DNS.Resolvers != nil {
310+
assert.Empty(c, netmap.DNS.Resolvers)
311+
}
312+
}, 30*time.Second, 2*time.Second)
313+
}
314+
}
315+
})
316+
}
317+
}

0 commit comments

Comments
 (0)