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.
This commit is contained in:
Kristoffer Dalby
2026-09-28 13:18:01 +00:00
committed by Kristoffer Dalby
parent 60d42808b8
commit 59030f380d
2 changed files with 111 additions and 4 deletions
+93
View File
@@ -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")
})
})
}
+18 -4
View File
@@ -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)