From 59030f380d5f881bcd6618475f61d8908f36de3b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 28 Sep 2026 13:18:01 +0000 Subject: [PATCH] servertest: cover a node's DNS config following its NextDNS inputs Profile via policy reload or tag change, device metadata via hostname; a hostname change today waits for the next policy response to reach DNS. --- hscontrol/servertest/nodeattrs_test.go | 93 ++++++++++++++++++++++++++ hscontrol/servertest/server.go | 22 ++++-- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/hscontrol/servertest/nodeattrs_test.go b/hscontrol/servertest/nodeattrs_test.go index 41555d554..c84eec1f5 100644 --- a/hscontrol/servertest/nodeattrs_test.go +++ b/hscontrol/servertest/nodeattrs_test.go @@ -3,6 +3,7 @@ package servertest_test import ( "context" "net/netip" + "strings" "testing" "time" @@ -463,3 +464,95 @@ func TestSuggestExitNodeDefaultOnPeerCapMap(t *testing.T) { viewer.WaitForCondition(t, "peer suggest-exit-node gone after unapprove", 10*time.Second, peerHasCap(false)) } + +// firstResolver returns the address of the first DNS resolver in nm. +func firstResolver(nm *netmap.NetworkMap) string { + if nm == nil || len(nm.DNS.Resolvers) == 0 { + return "" + } + + return nm.DNS.Resolvers[0].Addr +} + +// TestNodeAttrsNextDNS checks a node's DNS config follows each of its +// inputs: the NextDNS profile from nodeAttrs, whether reached through a +// policy reload or a tag change, and the device metadata from its Hostinfo. +func TestNodeAttrsNextDNS(t *testing.T) { + t.Parallel() + + setup := func(t *testing.T) (*servertest.TestServer, *servertest.TestClient) { + t.Helper() + + srv := servertest.NewServer(t, servertest.WithDNSResolvers("https://dns.nextdns.io/base")) + user := srv.CreateUser(t, "nd-user") + c := servertest.NewClient(t, srv, "nd-node", servertest.WithUser(user)) + + c.WaitForCondition(t, "base resolver", 10*time.Second, + func(nm *netmap.NetworkMap) bool { + return strings.HasPrefix(firstResolver(nm), "https://dns.nextdns.io/base?") + }) + + return srv, c + } + + waitResolver := func(t *testing.T, c *servertest.TestClient, prefix string) { + t.Helper() + + c.WaitForCondition(t, "resolver "+prefix, 10*time.Second, + func(nm *netmap.NetworkMap) bool { + return strings.HasPrefix(firstResolver(nm), prefix) + }) + } + + t.Run("policy_reload", func(t *testing.T) { + t.Parallel() + + srv, c := setup(t) + + reloadPolicy(t, srv, `{ + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}], + "nodeAttrs": [{"target": ["nd-user@"], "attr": ["nextdns:userprof"]}] + }`) + + waitResolver(t, c, "https://dns.nextdns.io/userprof?") + }) + + t.Run("tag_change", func(t *testing.T) { + t.Parallel() + + srv, c := setup(t) + + reloadPolicy(t, srv, `{ + "tagOwners": {"tag:dns": ["nd-user@"]}, + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}], + "nodeAttrs": [{"target": ["tag:dns"], "attr": ["nextdns:tagprof"]}] + }`) + + _, tagChange, err := srv.State().SetNodeTags(findNodeID(t, srv, "nd-node"), []string{"tag:dns"}) + require.NoError(t, err) + srv.App.Change(tagChange) + + waitResolver(t, c, "https://dns.nextdns.io/tagprof?") + }) + + t.Run("hostname_change", func(t *testing.T) { + t.Parallel() + + _, c := setup(t) + + c.Direct().SetHostinfo(&tailcfg.Hostinfo{ + BackendLogID: "servertest-nd-node", + Hostname: "nd-renamed", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _ = c.Direct().SendUpdate(ctx) + + c.WaitForCondition(t, "renamed device_name", 10*time.Second, + func(nm *netmap.NetworkMap) bool { + return strings.Contains(firstResolver(nm), "device_name=nd-renamed") + }) + }) +} diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index 65e351583..8b6cf85a1 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -17,6 +17,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "tailscale.com/net/memnet" "tailscale.com/tailcfg" + "tailscale.com/types/dnstype" ) // TestServer is an in-process Headscale control server suitable for @@ -46,6 +47,7 @@ type serverConfig struct { taildropEnabled bool realListener bool magicDNSDomain string + dnsResolvers []string } func defaultServerConfig() *serverConfig { @@ -108,6 +110,12 @@ func WithMagicDNS(domain string) ServerOption { return func(c *serverConfig) { c.magicDNSDomain = domain } } +// WithDNSResolvers sets the global DNS resolvers, so map responses carry a +// [tailcfg.DNSConfig]. +func WithDNSResolvers(addrs ...string) ServerOption { + return func(c *serverConfig) { c.dnsResolvers = addrs } +} + // NewServer creates and starts a Headscale test server. // The server is fully functional and accepts real Tailscale control // protocol connections over Noise. @@ -154,12 +162,18 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer { }, } + if sc.magicDNSDomain != "" || len(sc.dnsResolvers) > 0 { + cfg.TailcfgDNSConfig = &tailcfg.DNSConfig{} + } + if sc.magicDNSDomain != "" { cfg.BaseDomain = sc.magicDNSDomain - cfg.TailcfgDNSConfig = &tailcfg.DNSConfig{ - Proxied: true, - Domains: []string{sc.magicDNSDomain}, - } + cfg.TailcfgDNSConfig.Proxied = true + cfg.TailcfgDNSConfig.Domains = []string{sc.magicDNSDomain} + } + + for _, addr := range sc.dnsResolvers { + cfg.TailcfgDNSConfig.Resolvers = append(cfg.TailcfgDNSConfig.Resolvers, &dnstype.Resolver{Addr: addr}) } app, err := hscontrol.NewHeadscale(&cfg)