mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-23 10:42:30 +09:00
mapper, state: deliver nodeAttrs through MapResponse and harden nextdns DoH rewrite
WithSelfNode and buildTailPeers merge each node's policy CapMap
into the tailcfg.Node.CapMap they emit. State.NodeCapMap and
State.NodeCapMaps wrap the policy manager: NodeCapMap returns a
defensive clone per call; NodeCapMaps snapshots the full per-node
map once for batched callers, amortising pm.mu acquisition across
a peer build.
generateDNSConfig grew a per-node CapMap argument so it can apply
nodeAttr-driven DNS overlays. The nextdns DoH rewrite hardens against
policy-controlled inputs:
- nextDNSDoHHost anchors the prefix match instead of substring,
so a hostile resolver URL cannot smuggle a nextdns hostname in
a path or query.
- nextDNSProfileFromCapMap accepts only profile names matching
[A-Za-z0-9._-]{1,64} and picks the lexicographically first when
multiple are granted -- deterministic, no shell metacharacters
or URL fragments through.
- addNextDNSMetadata composes the rewritten URL via url.Parse +
url.Values rather than fmt.Sprintf, so existing query strings
on the resolver URL survive and metadata cannot inject a new
component.
WithTaildropEnabled in servertest controls cfg.Taildrop.Enabled per
test so cap/file-sharing emission can be toggled in tests that need
to verify the off path.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:<profile-id>" 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:<profile>`
|
||||
// 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:<profile>` 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/<nextdns-id>?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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user