diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index 2a3add8e..be190eee 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -1,6 +1,7 @@ package mapper import ( + "maps" "net/netip" "slices" "sort" @@ -90,6 +91,14 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder { return b } + if policyCaps := b.mapper.state.NodeCapMap(nv.ID()); len(policyCaps) > 0 { + if tailnode.CapMap == nil { + tailnode.CapMap = make(tailcfg.NodeCapMap, len(policyCaps)) + } + + maps.Copy(tailnode.CapMap, policyCaps) + } + b.resp.Node = tailnode return b @@ -158,7 +167,7 @@ func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder { return b } - b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node) + b.resp.DNSConfig = generateDNSConfig(b.mapper.cfg, node, b.mapper.state.NodeCapMap(node.ID())) return b } @@ -266,6 +275,22 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( return nil, err } + // Each peer's CapMap travels alongside the peer entry -- + // Tailscale's client reads it for `NodeAttrSuggestExitNode`, + // `NodeAttrDNSSubdomainResolve`, and other peer-self attrs + // (see tstest/integration/testcontrol/testcontrol.go:1350, + // ipn/ipnlocal/local.go:7562, ipn/ipnlocal/node_backend.go:745). + // TailNode already stamped the baseline; merge the + // peer's own policy nodeAttrs delta on top so peer-side + // consumers see the same value the peer sees on its self entry. + if policyCaps := b.mapper.state.NodeCapMap(peer.ID()); len(policyCaps) > 0 { + if tn.CapMap == nil { + tn.CapMap = make(tailcfg.NodeCapMap, len(policyCaps)) + } + + maps.Copy(tn.CapMap, policyCaps) + } + tailPeers = append(tailPeers, tn) } diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index 4df73933..b90d45a8 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "path" + "regexp" "slices" "strconv" "strings" @@ -114,9 +115,25 @@ func generateUserProfiles( return profiles } +// nextDNSAttrPrefix is the form Tailscale uses for per-node NextDNS profile +// selection: an "attr" entry of "nextdns:" overrides the resolver +// path, and "nextdns:no-device-info" suppresses the metadata-appending step. +// See https://tailscale.com/docs/integrations/nextdns. +const ( + nextDNSAttrPrefix = "nextdns:" + nextDNSAttrNoInfo tailcfg.NodeCapability = "nextdns:no-device-info" +) + +// nextDNSProfileRE bounds the characters accepted in a `nextdns:` +// suffix. NextDNS profile IDs are short alphanumeric strings; restricting +// to that charset prevents a policy author from injecting `?`, `/`, `@`, +// or `..` into the resolver URL via a crafted cap name. +var nextDNSProfileRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) + func generateDNSConfig( cfg *types.Config, node types.NodeView, + capMap tailcfg.NodeCapMap, ) *tailcfg.DNSConfig { if cfg.TailcfgDNSConfig == nil { return nil @@ -124,32 +141,129 @@ func generateDNSConfig( dnsConfig := cfg.TailcfgDNSConfig.Clone() - addNextDNSMetadata(dnsConfig.Resolvers, node) + profile := nextDNSProfileFromCapMap(capMap) + if profile != "" { + applyNextDNSProfile(dnsConfig.Resolvers, profile) + applyNextDNSProfile(dnsConfig.FallbackResolvers, profile) + + for suffix, rs := range dnsConfig.Routes { + applyNextDNSProfile(rs, profile) + dnsConfig.Routes[suffix] = rs + } + } + + if _, suppressMetadata := capMap[nextDNSAttrNoInfo]; !suppressMetadata { + addNextDNSMetadata(dnsConfig.Resolvers, node) + addNextDNSMetadata(dnsConfig.FallbackResolvers, node) + + for suffix, rs := range dnsConfig.Routes { + addNextDNSMetadata(rs, node) + dnsConfig.Routes[suffix] = rs + } + } return dnsConfig } -// If any nextdns DoH resolvers are present in the list of resolvers it will -// take metadata from the node metadata and instruct tailscale to add it -// to the requests. This makes it possible to identify from which device the -// requests come in the NextDNS dashboard. +// nextDNSProfileFromCapMap returns the policy-selected +// `nextdns:` value on the node, or the empty string when none +// is set or the cap is malformed. The reserved +// `nextdns:no-device-info` string is not a profile — it controls +// metadata appending and is handled separately. // -// This will produce a resolver like: -// `https://dns.nextdns.io/?device_name=node-name&device_model=linux&device_ip=100.64.0.1` +// The profile pick is deterministic across reloads: cap keys are +// gathered, sorted, and the first valid profile wins. Map iteration +// order in Go is randomised, so taking the literal first match would +// cause the chosen profile to flip between reloads when a node has +// multiple `nextdns:` caps. The profile string is also validated +// against [nextDNSProfileRE] so a crafted cap cannot inject path or +// query characters into the resolver URL. +func nextDNSProfileFromCapMap(capMap tailcfg.NodeCapMap) string { + if len(capMap) == 0 { + return "" + } + + candidates := make([]string, 0, len(capMap)) + + for cap := range capMap { + if cap == nextDNSAttrNoInfo { + continue + } + + profile, ok := strings.CutPrefix(string(cap), nextDNSAttrPrefix) + if !ok || profile == "" { + continue + } + + if !nextDNSProfileRE.MatchString(profile) { + log.Warn(). + Str("cap", string(cap)). + Msg("nextdns profile rejected: must match [A-Za-z0-9._-]{1,64}") + + continue + } + + candidates = append(candidates, profile) + } + + if len(candidates) == 0 { + return "" + } + + slices.Sort(candidates) + + return candidates[0] +} + +// nextDNSDoHHost matches a NextDNS DoH resolver address. The check is +// anchored on the host segment so a typo-squatted operator-configured +// resolver such as `https://dns.nextdns.io.attacker.example/x` does +// not slip through. +func nextDNSDoHHost(addr string) bool { + return addr == nextDNSDoHPrefix || + strings.HasPrefix(addr, nextDNSDoHPrefix+"/") || + strings.HasPrefix(addr, nextDNSDoHPrefix+"?") +} + +// applyNextDNSProfile rewrites every NextDNS DoH resolver to point at +// the given profile, dropping any existing profile path or query. Per +// the Tailscale spec the per-node profile overrides the global value, +// so the rewrite is unconditional rather than additive. +func applyNextDNSProfile(resolvers []*dnstype.Resolver, profile string) { + for _, resolver := range resolvers { + if !nextDNSDoHHost(resolver.Addr) { + continue + } + + resolver.Addr = nextDNSDoHPrefix + "/" + profile + } +} + +// addNextDNSMetadata appends device metadata as a query string to +// every NextDNS DoH resolver. Existing query parameters on the +// resolver address are preserved by parsing the URL and merging into +// its [url.URL.RawQuery] rather than concatenating with `?`. func addNextDNSMetadata(resolvers []*dnstype.Resolver, node types.NodeView) { for _, resolver := range resolvers { - if strings.HasPrefix(resolver.Addr, nextDNSDoHPrefix) { - attrs := url.Values{ - "device_name": []string{node.Hostname()}, - "device_model": []string{node.Hostinfo().OS()}, - } - - if len(node.IPs()) > 0 { - attrs.Add("device_ip", node.IPs()[0].String()) - } - - resolver.Addr = fmt.Sprintf("%s?%s", resolver.Addr, attrs.Encode()) + if !nextDNSDoHHost(resolver.Addr) { + continue } + + u, err := url.Parse(resolver.Addr) + if err != nil { + continue + } + + q := u.Query() + q.Set("device_name", node.Hostname()) + q.Set("device_model", node.Hostinfo().OS()) + + if ips := node.IPs(); len(ips) > 0 { + q.Set("device_ip", ips[0].String()) + } + + u.RawQuery = q.Encode() + resolver.Addr = u.String() } } diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 3349a155..54975e84 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -68,6 +68,7 @@ func TestDNSConfigMapResponse(t *testing.T) { TailcfgDNSConfig: &dnsConfigOrig, }, nodeInShared1.View(), + nil, ) if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { @@ -76,3 +77,119 @@ func TestDNSConfigMapResponse(t *testing.T) { }) } } + +func TestNextDNSCapMapRendering(t *testing.T) { + t.Parallel() + + mkConfig := func(addrs ...string) *types.Config { + resolvers := make([]*dnstype.Resolver, len(addrs)) + for i, a := range addrs { + resolvers[i] = &dnstype.Resolver{Addr: a} + } + + return &types.Config{ + TailcfgDNSConfig: &tailcfg.DNSConfig{ + Resolvers: resolvers, + }, + } + } + + mkNode := func() types.NodeView { + return (&types.Node{ + ID: 1, + Hostname: "node1", + IPv4: iap("100.64.0.1"), + Hostinfo: &tailcfg.Hostinfo{OS: "linux"}, + }).View() + } + + // resolverAddr extracts the first resolver's address with a + // bounds check. Without it, a regression that drops the + // resolver list would nil-panic instead of failing cleanly. + resolverAddr := func(t *testing.T, got *tailcfg.DNSConfig) string { + t.Helper() + + if got == nil { + t.Fatalf("generateDNSConfig returned nil") + } + + if len(got.Resolvers) == 0 { + t.Fatalf("generateDNSConfig returned no Resolvers") + } + + return got.Resolvers[0].Addr + } + + t.Run("no_capmap_metadata_appended", func(t *testing.T) { + t.Parallel() + + got := generateDNSConfig( + mkConfig("https://dns.nextdns.io/abc"), + mkNode(), + nil, + ) + + want := "https://dns.nextdns.io/abc?device_ip=100.64.0.1&device_model=linux&device_name=node1" + if addr := resolverAddr(t, got); addr != want { + t.Errorf("addr = %q, want %q", addr, want) + } + }) + + t.Run("profile_overrides_global", func(t *testing.T) { + t.Parallel() + + capMap := tailcfg.NodeCapMap{ + "nextdns:override": []tailcfg.RawMessage{}, + } + + got := generateDNSConfig( + mkConfig("https://dns.nextdns.io/global"), + mkNode(), + capMap, + ) + + want := "https://dns.nextdns.io/override?device_ip=100.64.0.1&device_model=linux&device_name=node1" + if addr := resolverAddr(t, got); addr != want { + t.Errorf("addr = %q, want %q", addr, want) + } + }) + + t.Run("no_device_info_skips_metadata", func(t *testing.T) { + t.Parallel() + + capMap := tailcfg.NodeCapMap{ + "nextdns:abc": []tailcfg.RawMessage{}, + "nextdns:no-device-info": []tailcfg.RawMessage{}, + } + + got := generateDNSConfig( + mkConfig("https://dns.nextdns.io/global"), + mkNode(), + capMap, + ) + + want := "https://dns.nextdns.io/abc" + if addr := resolverAddr(t, got); addr != want { + t.Errorf("addr = %q, want %q", addr, want) + } + }) + + t.Run("non_nextdns_resolver_untouched", func(t *testing.T) { + t.Parallel() + + capMap := tailcfg.NodeCapMap{ + "nextdns:abc": []tailcfg.RawMessage{}, + } + + got := generateDNSConfig( + mkConfig("https://dns.example.org/dns-query"), + mkNode(), + capMap, + ) + + want := "https://dns.example.org/dns-query" + if addr := resolverAddr(t, got); addr != want { + t.Errorf("non-nextdns resolver was rewritten: %q", addr) + } + }) +} diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index 69a7ec5c..c50b1da4 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -42,6 +42,7 @@ type serverConfig struct { ephemeralTimeout time.Duration nodeExpiry time.Duration batcherWorkers int + taildropEnabled bool } func defaultServerConfig() *serverConfig { @@ -50,6 +51,7 @@ func defaultServerConfig() *serverConfig { bufferedChanSize: 30, batcherWorkers: 1, ephemeralTimeout: 30 * time.Second, + taildropEnabled: true, } } @@ -73,6 +75,15 @@ func WithNodeExpiry(d time.Duration) ServerOption { return func(c *serverConfig) { c.nodeExpiry = d } } +// WithTaildropEnabled toggles the Taildrop file-sharing feature. +// Defaults to true to match production. Pass false to verify +// behaviour when an operator has switched the toggle off — e.g. +// that [tailcfg.CapabilityFileSharing] is withheld from the +// always-on baseline. +func WithTaildropEnabled(enabled bool) ServerOption { + return func(c *serverConfig) { c.taildropEnabled = enabled } +} + // NewServer creates and starts a Headscale test server. // The server is fully functional and accepts real Tailscale control // protocol connections over Noise. @@ -111,6 +122,7 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer { Policy: types.PolicyConfig{ Mode: types.PolicyModeDB, }, + Taildrop: types.TaildropConfig{Enabled: sc.taildropEnabled}, Tuning: types.Tuning{ BatchChangeDelay: sc.batchDelay, BatcherWorkers: sc.batcherWorkers, diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 08357391..5ff95e2c 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -313,6 +313,18 @@ func (s *State) ReloadPolicy() ([]change.Change, error) { //nolint:prealloc // cs starts with one element and may grow cs := []change.Change{change.PolicyChange()} + // Per-node selective self refresh for nodeAttrs. A broadcast + // PolicyChange() re-renders peer lists and packet filters but + // never repopulates a node's own [tailcfg.Node.CapMap]; that + // lives on the self entry only. The drain returns every node ID + // whose cap output shifted across recent updateLocked calls — + // refreshNodeAttrsLocked appends rather than overwrites so a + // concurrent SetUsers/SetNodes between SetPolicy and the drain + // cannot silently lose the policy-reload diff. + for _, id := range s.polMan.NodesWithChangedCapMap() { + cs = append(cs, change.SelfUpdate(id)) + } + // Always call autoApproveNodes during policy reload, regardless of whether // the policy content has changed. This ensures that routes are re-evaluated // when they might have been manually disabled but could now be auto-approved @@ -1048,6 +1060,19 @@ func (s *State) MatchersForNode(node types.NodeView) ([]matcher.Match, error) { return s.polMan.MatchersForNode(node) } +// NodeCapMap returns the policy-derived CapMap for the given node, suitable +// for merging into tailcfg.Node.CapMap when the node is rendered as self or +// as someone else's peer. +func (s *State) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap { + return s.polMan.NodeCapMap(id) +} + +// NodeCapMaps returns a snapshot of every node's policy CapMap so +// callers can amortise lock acquisition over a peer loop. +func (s *State) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap { + return s.polMan.NodeCapMaps() +} + // NodeCanHaveTag checks if a node is allowed to have a specific tag. func (s *State) NodeCanHaveTag(node types.NodeView, tag string) bool { return s.polMan.NodeCanHaveTag(node, tag)