Skip to content

Commit 6ea0656

Browse files
committed
feat: Add magicdns_peer_aaaa config option
This change introduces a new boolean config option, `magicdns_peer_aaaa`. When enabled, it adds the `magicdns-aaaa` node attribute to devices with a valid overlay IPv6 address. This attribute allows MagicDNS to resolve AAAA (IPv6) DNS queries for other nodes in the tailnet. This is a new opt-in feature for Tailscale 1.84+ and is implemented as a global config option, similar to `RandomizeClientPort`, since support for configuring node attributes via policies is not yet available.
1 parent a058bf3 commit 6ea0656

File tree

6 files changed

+214
-1
lines changed

6 files changed

+214
-1
lines changed

.github/workflows/test-integration.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ jobs:
5050
- TestDERPVerifyEndpoint
5151
- TestResolveMagicDNS
5252
- TestResolveMagicDNSExtraRecordsPath
53+
- TestMagicDNSPeerAAAA
5354
- TestDERPServerScenario
5455
- TestDERPServerWebsocketScenario
5556
- TestPingAllByIP

config-example.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,11 @@ logtail:
404404
# default static port 41641. This option is intended as a workaround for some buggy
405405
# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information.
406406
randomize_client_port: false
407+
408+
409+
# Enables MagicDNS AAAA query resolution for tailnet nodes with IPv6.
410+
# When this option is enabled, devices that have a valid overlay IPv6 address will
411+
# automatically gain the `magicdns-aaaa` Node Attribute.
412+
# This attribute allows them to resolve AAAA (IPv6) DNS queries through MagicDNS for
413+
# other nodes within the tailnet.
414+
magicdns_peer_aaaa: false

hscontrol/mapper/tail.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,17 @@ func tailNode(
7878
}
7979

8080
var tags []string
81+
8182
for _, tag := range node.RequestTagsSlice().All() {
8283
if checker.NodeCanHaveTag(node, tag) {
8384
tags = append(tags, tag)
8485
}
8586
}
87+
8688
for _, tag := range node.ForcedTags().All() {
8789
tags = append(tags, tag)
8890
}
91+
8992
tags = lo.Uniq(tags)
9093

9194
routes := primaryRouteFunc(node.ID())
@@ -133,6 +136,12 @@ func tailNode(
133136
tNode.CapMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{}
134137
}
135138

139+
// Enable NodeAttrMagicDNSPeerAAAA only if requested in
140+
// the config and for nodes with a valid v6 overlay address
141+
if cfg.MagicDNSPeerAAAA && node.IPv6().Valid() {
142+
tNode.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA] = []tailcfg.RawMessage{}
143+
}
144+
136145
if !node.IsOnline().Valid() || !node.IsOnline().Get() {
137146
// LastSeen is only set when node is
138147
// not connected to the control server.

hscontrol/mapper/tail_test.go

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,23 @@ import (
2020
func TestTailNode(t *testing.T) {
2121
mustNK := func(str string) key.NodePublic {
2222
var k key.NodePublic
23+
2324
_ = k.UnmarshalText([]byte(str))
2425

2526
return k
2627
}
2728

2829
mustDK := func(str string) key.DiscoPublic {
2930
var k key.DiscoPublic
31+
3032
_ = k.UnmarshalText([]byte(str))
3133

3234
return k
3335
}
3436

3537
mustMK := func(str string) key.MachinePublic {
3638
var k key.MachinePublic
39+
3740
_ = k.UnmarshalText([]byte(str))
3841

3942
return k
@@ -204,6 +207,7 @@ func TestTailNode(t *testing.T) {
204207
t.Run(tt.name, func(t *testing.T) {
205208
polMan, err := policy.NewPolicyManager(tt.pol, []types.User{}, types.Nodes{tt.node}.ViewSlice())
206209
require.NoError(t, err)
210+
207211
primary := routes.New()
208212
cfg := &types.Config{
209213
BaseDomain: tt.baseDomain,
@@ -215,6 +219,7 @@ func TestTailNode(t *testing.T) {
215219
// This is a hack to avoid having a second node to test the primary route.
216220
// This should be baked into the test case proper if it is extended in the future.
217221
_ = primary.SetRoutes(2, netip.MustParsePrefix("192.168.0.0/24"))
222+
218223
got, err := tailNode(
219224
tt.node.View(),
220225
0,
@@ -224,7 +229,6 @@ func TestTailNode(t *testing.T) {
224229
},
225230
cfg,
226231
)
227-
228232
if (err != nil) != tt.wantErr {
229233
t.Errorf("tailNode() error = %v, wantErr %v", err, tt.wantErr)
230234

@@ -293,7 +297,9 @@ func TestNodeExpiry(t *testing.T) {
293297
if err != nil {
294298
t.Fatalf("nodeExpiry() error = %v", err)
295299
}
300+
296301
var deseri tailcfg.Node
302+
297303
err = json.Unmarshal(seri, &deseri)
298304
if err != nil {
299305
t.Fatalf("nodeExpiry() error = %v", err)
@@ -309,3 +315,113 @@ func TestNodeExpiry(t *testing.T) {
309315
})
310316
}
311317
}
318+
319+
func TestMagicDNSPeerAAAAEnabled(t *testing.T) {
320+
// A node with both IPv4 and IPv6 addresses.
321+
node := &types.Node{
322+
GivenName: "ipv6node",
323+
IPv4: iap("100.64.0.1"),
324+
IPv6: iap("fd7a:115c:a1e0::1"),
325+
Hostinfo: &tailcfg.Hostinfo{
326+
RoutableIPs: []netip.Prefix{
327+
tsaddr.AllIPv4(),
328+
tsaddr.AllIPv6(),
329+
},
330+
},
331+
}
332+
333+
// The key configuration setting is enabled.
334+
cfg := &types.Config{
335+
MagicDNSPeerAAAA: true,
336+
}
337+
338+
polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{node}.ViewSlice())
339+
require.NoError(t, err)
340+
341+
got, err := tailNode(
342+
node.View(),
343+
0,
344+
polMan,
345+
func(id types.NodeID) []netip.Prefix { return []netip.Prefix{} },
346+
cfg,
347+
)
348+
require.NoError(t, err)
349+
350+
// Assert that the magicdns-aaaa capability is present.
351+
_, hasAAAA := got.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA]
352+
require.True(t, hasAAAA, "expected magicdns-aaaa capability to be present")
353+
}
354+
355+
func TestMagicDNSPeerAAAADisabled(t *testing.T) {
356+
// A node with both IPv4 and IPv6 addresses.
357+
node := &types.Node{
358+
GivenName: "ipv6node",
359+
IPv4: iap("100.64.0.1"),
360+
IPv6: iap("fd7a:115c:a1e0::1"),
361+
Hostinfo: &tailcfg.Hostinfo{
362+
RoutableIPs: []netip.Prefix{
363+
tsaddr.AllIPv4(),
364+
tsaddr.AllIPv6(),
365+
},
366+
},
367+
}
368+
369+
// The key configuration setting is disabled.
370+
cfg := &types.Config{
371+
MagicDNSPeerAAAA: false,
372+
}
373+
374+
polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{node}.ViewSlice())
375+
require.NoError(t, err)
376+
377+
got, err := tailNode(
378+
node.View(),
379+
0,
380+
polMan,
381+
func(id types.NodeID) []netip.Prefix { return []netip.Prefix{} },
382+
cfg,
383+
)
384+
require.NoError(t, err)
385+
386+
// Assert that the magicdns-aaaa capability is NOT present.
387+
_, hasAAAA := got.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA]
388+
require.False(t, hasAAAA, "expected magicdns-aaaa capability NOT to be present")
389+
}
390+
391+
func TestMagicDNSPeerAAAAEnabledWithIPv4OnlyNode(t *testing.T) {
392+
// A node with a valid IPv4 address and no IPv6 address
393+
node := &types.Node{
394+
ID: 0,
395+
IPv4: iap("100.64.0.1"),
396+
GivenName: "ipv4only",
397+
Hostinfo: &tailcfg.Hostinfo{
398+
RoutableIPs: []netip.Prefix{
399+
tsaddr.AllIPv4(),
400+
},
401+
},
402+
}
403+
404+
// This is the configuration with the flag enabled
405+
cfg := &types.Config{
406+
MagicDNSPeerAAAA: true,
407+
}
408+
409+
polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{node}.ViewSlice())
410+
require.NoError(t, err)
411+
412+
got, err := tailNode(
413+
node.View(),
414+
0,
415+
polMan,
416+
func(id types.NodeID) []netip.Prefix {
417+
return []netip.Prefix{}
418+
},
419+
cfg,
420+
)
421+
require.NoError(t, err)
422+
423+
// The feature is enabled but the node lacks a V6 address
424+
// the node attribute should not be present
425+
_, hasAAAA := got.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA]
426+
require.False(t, hasAAAA, "expected magicdns-aaaa capability NOT to be present on IPv4 only node")
427+
}

hscontrol/types/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ type Config struct {
9898
Policy PolicyConfig
9999

100100
Tuning Tuning
101+
102+
// Controls the automatic addition of the `magicdns-aaaa` node attribute
103+
// If set to true, the attributes will be set for all nodes that have
104+
// a valid Overlay IPv6 Address.
105+
// This is separate from the real DNS
106+
MagicDNSPeerAAAA bool
101107
}
102108

103109
type DNSConfig struct {
@@ -335,6 +341,8 @@ func LoadConfig(path string, isFile bool) error {
335341

336342
viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential))
337343

344+
viper.SetDefault("magicdns_peer_aaaa", false)
345+
338346
if err := viper.ReadInConfig(); err != nil {
339347
if errors.Is(err, fs.ErrNotExist) {
340348
log.Warn().Msg("No config file found, using defaults")
@@ -872,6 +880,7 @@ func LoadServerConfig() (*Config, error) {
872880
derpConfig := derpConfig()
873881
logTailConfig := logtailConfig()
874882
randomizeClientPort := viper.GetBool("randomize_client_port")
883+
magicDNSPeerAAAA := viper.GetBool("magicdns_peer_aaaa")
875884

876885
oidcClientSecret := viper.GetString("oidc.client_secret")
877886
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
@@ -999,6 +1008,8 @@ func LoadServerConfig() (*Config, error) {
9991008
return DefaultBatcherWorkers()
10001009
}(),
10011010
},
1011+
1012+
MagicDNSPeerAAAA: magicDNSPeerAAAA,
10021013
}, nil
10031014
}
10041015

integration/dns_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package integration
33
import (
44
"encoding/json"
55
"fmt"
6+
"net/netip"
67
"strings"
78
"testing"
89
"time"
910

11+
"github.com/juanfont/headscale/hscontrol/util"
1012
"github.com/juanfont/headscale/integration/hsic"
1113
"github.com/juanfont/headscale/integration/tsic"
14+
"github.com/samber/lo"
1215
"github.com/stretchr/testify/assert"
1316
"tailscale.com/tailcfg"
1417
)
@@ -225,3 +228,68 @@ func TestResolveMagicDNSExtraRecordsPath(t *testing.T) {
225228
assertCommandOutputContains(t, client, []string{"dig", "copy.myvpn.example.com"}, "8.8.8.8")
226229
}
227230
}
231+
232+
func TestMagicDNSPeerAAAA(t *testing.T) {
233+
IntegrationSkip(t)
234+
235+
spec := ScenarioSpec{
236+
NodesPerUser: len(MustTestVersions),
237+
Users: []string{"user1", "user2"},
238+
MaxWait: dockertestMaxWait(),
239+
}
240+
241+
scenario, err := NewScenario(spec)
242+
assertNoErr(t, err)
243+
defer scenario.ShutdownAssertNoPanics(t)
244+
245+
err = scenario.CreateHeadscaleEnv(
246+
[]tsic.Option{
247+
tsic.WithDockerEntrypoint([]string{
248+
"/bin/sh",
249+
"-c",
250+
"/bin/sleep 3 ; apk add python3 curl bind-tools ; update-ca-certificates ; tailscaled --tun=tsdev",
251+
}),
252+
},
253+
hsic.WithTestName("magicdnspeeraaaa"),
254+
hsic.WithEmbeddedDERPServerOnly(),
255+
hsic.WithTLS(),
256+
hsic.WithConfigEnv(map[string]string{
257+
// The feature under test
258+
"HEADSCALE_MAGICDNS_PEER_AAAA": "true",
259+
}),
260+
)
261+
assertNoErrHeadscaleEnv(t, err)
262+
263+
allClients, err := scenario.ListTailscaleClients()
264+
assertNoErrListClients(t, err)
265+
266+
err = scenario.WaitForTailscaleSync()
267+
assertNoErrSync(t, err)
268+
269+
// Loop through all clients to perform the dig command.
270+
for _, client := range allClients {
271+
// Only run the test for Tailscale clients that support this feature (1.84+).
272+
// The `tailscaled` container has no field or method GivenName().
273+
// We obtain the name by splitting the FQDN and using the first part.
274+
clientName := client.Hostname()
275+
if util.TailscaleVersionNewerOrEqual("1.84", client.Version()) {
276+
t.Logf("Running AAAA check for client: %s (version: %s)", clientName, client.Version())
277+
278+
ips, err := client.IPs()
279+
assertNoErr(t, err)
280+
281+
targetIPv6 := lo.Filter(ips, func(ip netip.Addr, _ int) bool {
282+
return ip.Is6()
283+
})
284+
assert.NotEmpty(t, targetIPv6, "expected client to have an IPv6 address")
285+
286+
// Construct the dig command as a string slice and assert the output.
287+
fqdn := clientName + ".headscale.net"
288+
digCmd := []string{"dig", "+short", "-t", "AAAA", fqdn}
289+
290+
assertCommandOutputContains(t, client, digCmd, targetIPv6[0].String())
291+
} else {
292+
t.Logf("Skipping AAAA check for client: %s (version: %s), requires at least 1.84", clientName, client.Version())
293+
}
294+
}
295+
}

0 commit comments

Comments
 (0)