diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 956ecb92..641146d3 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -42,6 +42,28 @@ type PolicyManager interface { // both fields are empty and the caller falls back to existing behavior. ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult + // NodeCapMap returns the policy-derived CapMap for the given node, + // or nil when no nodeAttrs entry targets it. The returned map is + // owned by the manager; treat it as read-only and copy before + // merging into a [tailcfg.Node]. It describes the node's own + // capabilities, not a per-viewer view. + NodeCapMap(id types.NodeID) tailcfg.NodeCapMap + + // NodeCapMaps returns a snapshot of the per-node policy CapMap so + // callers can amortise lock acquisitions over a peer loop. The + // outer map is a fresh container; the inner [tailcfg.NodeCapMap] + // values are shared with the manager and read-only. + NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap + + // NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs + // CapMap shifted during recent updateLocked calls. The buffer + // drains on read; callers consume it once per update cycle to + // decide which nodes need a self-targeted MapResponse. + // refreshNodeAttrsLocked appends to the buffer rather than + // overwriting, so a SetUsers/SetNodes between SetPolicy and the + // drain cannot lose the policy-reload diff. + NodesWithChangedCapMap() []types.NodeID + Version() int DebugString() string } diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index ebc32c7b..04aca941 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -1,6 +1,7 @@ package v2 import ( + "fmt" "net/netip" "slices" @@ -94,6 +95,92 @@ func buildUserNodeIndex( return idx } +// compileNodeAttrs returns the per-node CapMap derived from policy +// nodeAttrs plus the tailnet-wide RandomizeClientPort flag. +// +// Returns an error when a target alias fails to resolve so the caller +// surfaces a corrupt policy instead of silently granting a partial set +// of attrs. +func (pol *Policy) compileNodeAttrs( + users types.Users, + nodes views.Slice[types.NodeView], +) (map[types.NodeID]tailcfg.NodeCapMap, error) { + empty := map[types.NodeID]tailcfg.NodeCapMap{} + + if pol == nil { + return empty, nil + } + + if len(pol.NodeAttrs) == 0 && !pol.RandomizeClientPort { + return empty, nil + } + + result := make(map[types.NodeID]tailcfg.NodeCapMap) + stamp := func(id types.NodeID, attr tailcfg.NodeCapability) { + capMap, ok := result[id] + if !ok { + capMap = tailcfg.NodeCapMap{} + result[id] = capMap + } + + // nil RawMessage matches the wire format from a Tailscale-hosted + // control plane: capabilities without companion data marshal as + // `null` rather than `[]`. Storing nil keeps the merge stable + // and lets the compat test diff cleanly against captured + // netmaps. + if _, exists := capMap[attr]; !exists { + capMap[attr] = nil + } + } + + // Cache each node's IPs once per call. Without the cache, the + // node-attr inner loop would call NodeView.IPs() once per attr + // per node — O(grants × nodes) allocations of a 2-element slice + // for what is invariant per node within a single policy compile. + type nodeIPs struct { + id types.NodeID + ips []netip.Addr + } + + nodeList := make([]nodeIPs, 0, nodes.Len()) + for _, n := range nodes.All() { + nodeList = append(nodeList, nodeIPs{id: n.ID(), ips: n.IPs()}) + } + + if pol.RandomizeClientPort { + for _, ni := range nodeList { + stamp(ni.id, tailcfg.NodeAttrRandomizeClientPort) + } + } + + for _, na := range pol.NodeAttrs { + if len(na.Attrs) == 0 { + continue + } + + resolved, err := na.Targets.Resolve(pol, users, nodes) + if err != nil { + return nil, fmt.Errorf("nodeAttrs target %s: %w", na.Targets, err) + } + + if resolved == nil { + continue + } + + for _, ni := range nodeList { + if !slices.ContainsFunc(ni.ips, resolved.Contains) { + continue + } + + for _, attr := range na.Attrs { + stamp(ni.id, attr) + } + } + } + + return result, nil +} + // compileGrants resolves all policy grants into compiledGrant structs. // Source resolution and non-self destination resolution happens once // here. This is the single resolution path that replaces the diff --git a/hscontrol/policy/v2/nodeattrs_test.go b/hscontrol/policy/v2/nodeattrs_test.go new file mode 100644 index 00000000..c494024e --- /dev/null +++ b/hscontrol/policy/v2/nodeattrs_test.go @@ -0,0 +1,357 @@ +package v2 + +import ( + "net/netip" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "tailscale.com/tailcfg" +) + +// nodeAttrsTestUsers returns a minimal user set: two passkey-style users on +// different domains, mirroring the production multi-domain shape so user-target +// resolution is exercised across both. +func nodeAttrsTestUsers() types.Users { + return types.Users{ + {Model: gorm.Model{ID: 1}, Name: "alice", Email: "alice@example.com"}, + {Model: gorm.Model{ID: 2}, Name: "bob", Email: "bob@example.org"}, + } +} + +// nodeAttrsTestNodes returns a fixed mix of user-owned and tagged nodes used +// by every nodeAttrs unit test. Two user-owned nodes (one per user) and three +// tagged nodes (server, client, prod) so target resolution can be exercised +// across user, group, tag, autogroup, and wildcard alias forms. +func nodeAttrsTestNodes(users types.Users) types.Nodes { + return types.Nodes{ + { + ID: 1, + GivenName: "alice-laptop", + User: &users[0], + UserID: &users[0].ID, + IPv4: ptrAddr("100.64.0.1"), + IPv6: ptrAddr("fd7a:115c:a1e0::1"), + Hostinfo: &tailcfg.Hostinfo{}, + }, + { + ID: 2, + GivenName: "bob-laptop", + User: &users[1], + UserID: &users[1].ID, + IPv4: ptrAddr("100.64.0.2"), + IPv6: ptrAddr("fd7a:115c:a1e0::2"), + Hostinfo: &tailcfg.Hostinfo{}, + }, + { + ID: 3, + GivenName: "server", + Tags: []string{"tag:server"}, + IPv4: ptrAddr("100.64.0.3"), + IPv6: ptrAddr("fd7a:115c:a1e0::3"), + Hostinfo: &tailcfg.Hostinfo{}, + }, + { + ID: 4, + GivenName: "client", + Tags: []string{"tag:client"}, + IPv4: ptrAddr("100.64.0.4"), + IPv6: ptrAddr("fd7a:115c:a1e0::4"), + Hostinfo: &tailcfg.Hostinfo{}, + }, + { + ID: 5, + GivenName: "prod", + Tags: []string{"tag:prod"}, + IPv4: ptrAddr("100.64.0.5"), + IPv6: ptrAddr("fd7a:115c:a1e0::5"), + Hostinfo: &tailcfg.Hostinfo{}, + }, + } +} + +const nodeAttrsTagOwners = `"tag:server": ["alice@example.com"], + "tag:client": ["alice@example.com"], + "tag:prod": ["alice@example.com"]` + +func TestNodeAttrsCompile(t *testing.T) { + t.Parallel() + + capMap := func(c tailcfg.NodeCapability) tailcfg.NodeCapMap { + return tailcfg.NodeCapMap{c: nil} + } + + tests := []struct { + name string + // extra is appended inside the policy block alongside tagOwners. + extra string + want map[types.NodeID]tailcfg.NodeCapMap + }{ + { + name: "wildcard target hits every node", + extra: `"nodeAttrs": [{"target": ["*"], "attr": ["randomize-client-port"]}]`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 1: capMap(tailcfg.NodeAttrRandomizeClientPort), + 2: capMap(tailcfg.NodeAttrRandomizeClientPort), + 3: capMap(tailcfg.NodeAttrRandomizeClientPort), + 4: capMap(tailcfg.NodeAttrRandomizeClientPort), + 5: capMap(tailcfg.NodeAttrRandomizeClientPort), + }, + }, + { + name: "user target hits only that user's untagged nodes", + extra: `"nodeAttrs": [{"target": ["alice@example.com"], "attr": ["randomize-client-port"]}]`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 1: capMap(tailcfg.NodeAttrRandomizeClientPort), + }, + }, + { + name: "tag target hits only matching tagged nodes", + extra: `"nodeAttrs": [{"target": ["tag:server"], "attr": ["drive:share", "drive:access"]}]`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 3: { + tailcfg.NodeAttrsTaildriveShare: nil, + tailcfg.NodeAttrsTaildriveAccess: nil, + }, + }, + }, + { + name: "autogroup:member hits untagged nodes only", + extra: `"nodeAttrs": [{"target": ["autogroup:member"], "attr": ["randomize-client-port"]}]`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 1: capMap(tailcfg.NodeAttrRandomizeClientPort), + 2: capMap(tailcfg.NodeAttrRandomizeClientPort), + }, + }, + { + name: "autogroup:tagged hits tagged nodes only", + extra: `"nodeAttrs": [{"target": ["autogroup:tagged"], "attr": ["disable-captive-portal-detection"]}]`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 3: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection), + 4: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection), + 5: capMap(tailcfg.NodeAttrDisableCaptivePortalDetection), + }, + }, + { + name: "merging two grants on overlapping targets unions attrs", + extra: `"nodeAttrs": [ + {"target": ["*"], "attr": ["drive:access"]}, + {"target": ["tag:server"], "attr": ["drive:share"]} + ]`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 1: capMap(tailcfg.NodeAttrsTaildriveAccess), + 2: capMap(tailcfg.NodeAttrsTaildriveAccess), + 3: { + tailcfg.NodeAttrsTaildriveAccess: nil, + tailcfg.NodeAttrsTaildriveShare: nil, + }, + 4: capMap(tailcfg.NodeAttrsTaildriveAccess), + 5: capMap(tailcfg.NodeAttrsTaildriveAccess), + }, + }, + { + name: "empty entry compiles to nothing", + extra: `"nodeAttrs": [{"target": ["*"]}]`, + want: nil, + }, + { + name: "top-level randomizeClientPort stamps every node", + extra: `"randomizeClientPort": true`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 1: capMap(tailcfg.NodeAttrRandomizeClientPort), + 2: capMap(tailcfg.NodeAttrRandomizeClientPort), + 3: capMap(tailcfg.NodeAttrRandomizeClientPort), + 4: capMap(tailcfg.NodeAttrRandomizeClientPort), + 5: capMap(tailcfg.NodeAttrRandomizeClientPort), + }, + }, + { + name: "global randomize plus per-tag entry merges", + extra: `"randomizeClientPort": true, + "nodeAttrs": [{"target": ["tag:server"], "attr": ["disable-captive-portal-detection"]}]`, + want: map[types.NodeID]tailcfg.NodeCapMap{ + 1: capMap(tailcfg.NodeAttrRandomizeClientPort), + 2: capMap(tailcfg.NodeAttrRandomizeClientPort), + 3: { + tailcfg.NodeAttrRandomizeClientPort: nil, + tailcfg.NodeAttrDisableCaptivePortalDetection: nil, + }, + 4: capMap(tailcfg.NodeAttrRandomizeClientPort), + 5: capMap(tailcfg.NodeAttrRandomizeClientPort), + }, + }, + } + + users := nodeAttrsTestUsers() + nodes := nodeAttrsTestNodes(users) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + policy := `{ + "tagOwners": {` + nodeAttrsTagOwners + `}, + ` + tt.extra + ` + }` + + pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice()) + require.NoErrorf(t, err, "policy must parse and validate:\n%s", policy) + + got, err := pm.pol.compileNodeAttrs(users, pm.nodes) + require.NoError(t, err) + + if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("compileNodeAttrs (-want +got):\n%s", diff) + } + }) + } +} + +func TestNodeAttrsValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + extra string + wantErr error + }{ + { + name: "autogroup:self target rejected", + extra: `"nodeAttrs": [{"target": ["autogroup:self"], "attr": ["randomize-client-port"]}]`, + wantErr: ErrNodeAttrsAutogroupNotAllowed, + }, + { + name: "autogroup:admin target rejected with user-role hint", + extra: `"nodeAttrs": [{"target": ["autogroup:admin"], "attr": ["randomize-client-port"]}]`, + wantErr: ErrNodeAttrsAutogroupNotAllowed, + }, + { + name: "autogroup:owner target rejected with user-role hint", + extra: `"nodeAttrs": [{"target": ["autogroup:owner"], "attr": ["randomize-client-port"]}]`, + wantErr: ErrNodeAttrsAutogroupNotAllowed, + }, + { + name: "funnel attr rejected as unsupported", + extra: `"nodeAttrs": [{"target": ["*"], "attr": ["funnel"]}]`, + wantErr: ErrNodeAttrUnsupported, + }, + { + name: "ipPool set rejected as unsupported", + extra: `"nodeAttrs": [{"target": ["autogroup:member"], "ipPool": ["100.81.0.0/16"]}]`, + wantErr: ErrNodeAttrIPPoolUnsupported, + }, + { + name: "ipPool overlapping reserved range rejected at validate", + extra: `"nodeAttrs": [{"target": ["autogroup:member"], "ipPool": ["100.100.100.0/24"]}]`, + wantErr: ErrNodeAttrsIPPoolReserved, + }, + } + + users := nodeAttrsTestUsers() + nodes := nodeAttrsTestNodes(users) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + policy := `{ + "tagOwners": {` + nodeAttrsTagOwners + `}, + ` + tt.extra + ` + }` + + _, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice()) + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestNodeAttrsIPPoolValidator(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + wantErr error + }{ + {name: "in cgnat", prefix: "100.81.0.0/16"}, + {name: "outside cgnat", prefix: "10.0.0.0/8", wantErr: ErrNodeAttrsIPPoolOutOfRange}, + {name: "less specific than cgnat", prefix: "100.0.0.0/8", wantErr: ErrNodeAttrsIPPoolOutOfRange}, + {name: "whole cgnat overlaps reserved", prefix: "100.64.0.0/10", wantErr: ErrNodeAttrsIPPoolReserved}, + {name: "overlaps quad100", prefix: "100.100.100.0/24", wantErr: ErrNodeAttrsIPPoolReserved}, + {name: "overlaps ipn", prefix: "100.115.92.0/24", wantErr: ErrNodeAttrsIPPoolReserved}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateNodeAttrIPPool(netip.MustParsePrefix(tt.prefix)) + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestNodesWithChangedCapMap(t *testing.T) { + t.Parallel() + + users := nodeAttrsTestUsers() + nodes := nodeAttrsTestNodes(users) + + policyA := `{ + "tagOwners": {` + nodeAttrsTagOwners + `}, + "nodeAttrs": [{ + "target": ["tag:server"], + "attr": ["randomize-client-port"] + }] + }` + + pm, err := NewPolicyManager([]byte(policyA), users, nodes.ViewSlice()) + require.NoError(t, err) + + initial := pm.NodesWithChangedCapMap() + slices.Sort(initial) + assert.Equal(t, []types.NodeID{3}, initial, + "first build reports every node with a non-empty CapMap") + + // Swap targets: server loses the attr, client and prod gain it. + policyB := `{ + "tagOwners": {` + nodeAttrsTagOwners + `}, + "nodeAttrs": [{ + "target": ["tag:client", "tag:prod"], + "attr": ["randomize-client-port"] + }] + }` + + changed, err := pm.SetPolicy([]byte(policyB)) + require.NoError(t, err) + require.True(t, changed) + + delta := pm.NodesWithChangedCapMap() + slices.Sort(delta) + assert.Equal(t, []types.NodeID{3, 4, 5}, delta, + "server lost the cap, client and prod gained it -- diff is the symmetric difference") + + assert.Empty(t, pm.NodesWithChangedCapMap(), + "NodesWithChangedCapMap drains its buffer on read") + + // Reload the same bytes. updateLocked still runs, but no node's + // CapMap hash should change. + _, err = pm.SetPolicy([]byte(policyB)) + require.NoError(t, err) + + assert.Empty(t, pm.NodesWithChangedCapMap(), + "reloading the same policy must not produce CapMap diffs") +} diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 1941ecf7..5494bd9c 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/netip" "slices" "strings" @@ -64,6 +65,16 @@ type PolicyManager struct { // needsPerNodeFilter is true when any compiled grant requires // per-node work (autogroup:self or via grants). needsPerNodeFilter bool + + // nodeAttrsMap is the per-node CapMap compiled from policy.NodeAttrs. + // nodeAttrsHashes shadow it for change detection between updateLocked + // runs. nodeAttrsChanged accumulates the union of all per-call diffs + // since the last drain — refresh APPENDS, never overwrites, so a + // concurrent SetUsers/SetNodes between SetPolicy and the drain + // cannot silently lose the policy-reload diff. + nodeAttrsMap map[types.NodeID]tailcfg.NodeCapMap + nodeAttrsHashes map[types.NodeID]deephash.Sum + nodeAttrsChanged []types.NodeID } // filterAndPolicy combines the compiled filter rules with policy content for hashing. @@ -298,6 +309,16 @@ func (pm *PolicyManager) updateLocked() (bool, error) { pm.exitSet = exitSet pm.exitSetHash = exitSetHash + // Recompile per-node nodeAttrs CapMap and append the diff to + // pm.nodeAttrsChanged. The drain (NodesWithChangedCapMap) returns + // the accumulated union of every change since the last drain; + // SetUsers/SetNodes appending between SetPolicy and the drain + // cannot lose the policy-reload diff. + err = pm.refreshNodeAttrsLocked() + if err != nil { + return false, err + } + // Determine if we need to send updates to nodes // filterChanged now includes policy content changes (via combined hash), // so it will detect changes even for autogroup:self where compiled filter is empty @@ -473,6 +494,7 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) { Int("groups.count", len(pol.Groups)). Int("hosts.count", len(pol.Hosts)). Int("tagOwners.count", len(pol.TagOwners)). + Int("nodeAttrs.count", len(pol.NodeAttrs)). Int("autoApprovers.routes.count", len(pol.AutoApprovers.Routes)). Int("tests.count", len(pol.Tests)). Msg("Policy parsed successfully") @@ -1571,3 +1593,125 @@ func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.Node return ret, nil } + +// refreshNodeAttrsLocked recompiles the per-node nodeAttrs CapMap and +// appends the IDs whose CapMap differs from the previous snapshot +// (including newly-targeted nodes and nodes that lost all attrs) to +// pm.nodeAttrsChanged. Append, not overwrite: a concurrent +// SetUsers/SetNodes between SetPolicy and a NodesWithChangedCapMap +// drain cannot clobber the policy-reload diff. +// +// Caller must hold pm.mu. +func (pm *PolicyManager) refreshNodeAttrsLocked() error { + // Fast path for the common steady-state shape: tailnet has no + // nodeAttrs entries and never had any. Skip the compile + per-node + // hash walk entirely. As soon as the operator adds a nodeAttrs + // entry pm.nodeAttrsHashes becomes non-empty and the gate opens. + if pm.pol != nil && + len(pm.pol.NodeAttrs) == 0 && + !pm.pol.RandomizeClientPort && + len(pm.nodeAttrsHashes) == 0 { + return nil + } + + newMap, err := pm.pol.compileNodeAttrs(pm.users, pm.nodes) + if err != nil { + return fmt.Errorf("compiling nodeAttrs: %w", err) + } + + newHashes := make(map[types.NodeID]deephash.Sum, len(newMap)) + for id, capMap := range newMap { + newHashes[id] = deephash.Hash(&capMap) + } + + // Walk the union of old and new node IDs and emit the delta. + seen := make(map[types.NodeID]struct{}, len(newHashes)+len(pm.nodeAttrsHashes)) + + var changed []types.NodeID + + for id, h := range newHashes { + seen[id] = struct{}{} + if pm.nodeAttrsHashes[id] != h { + changed = append(changed, id) + } + } + + for id := range pm.nodeAttrsHashes { + if _, ok := seen[id]; ok { + continue + } + // Node lost all nodeAttrs since the last update. + changed = append(changed, id) + } + + pm.nodeAttrsMap = newMap + pm.nodeAttrsHashes = newHashes + pm.nodeAttrsChanged = append(pm.nodeAttrsChanged, changed...) + + return nil +} + +// NodeCapMap returns the policy-derived CapMap for the given node, or +// nil when the node has no nodeAttrs entries that target it. The +// returned map is a defensive clone — caller mutations cannot reach +// the manager-owned cache. +func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap { + if pm == nil { + return nil + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + src := pm.nodeAttrsMap[id] + if len(src) == 0 { + return nil + } + + out := make(tailcfg.NodeCapMap, len(src)) + maps.Copy(out, src) + + return out +} + +// NodeCapMaps returns a snapshot of the per-node policy CapMap. The +// mapper calls this once per request to amortise lock acquisitions +// over a peer-loop instead of taking the lock per peer. The returned +// map is a fresh container; the inner [tailcfg.NodeCapMap] values are +// shared with the manager and must be treated as read-only. +func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap { + if pm == nil { + return nil + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap)) + maps.Copy(out, pm.nodeAttrsMap) + + return out +} + +// NodesWithChangedCapMap returns the IDs of nodes whose nodeAttrs +// CapMap shifted across one or more updateLocked calls since the +// last drain. The buffer drains on return. The mapper calls this +// once per ReloadPolicy to decide which nodes need a SelfUpdate. +// +// refreshNodeAttrsLocked APPENDS to the buffer; the drain returns +// the union of every change since the previous read. A concurrent +// SetUsers/SetNodes between SetPolicy and a drain cannot silently +// lose the policy-reload diff. +func (pm *PolicyManager) NodesWithChangedCapMap() []types.NodeID { + if pm == nil { + return nil + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + out := pm.nodeAttrsChanged + pm.nodeAttrsChanged = nil + + return out +} diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 5d305075..44531762 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -77,6 +77,26 @@ var ( ErrGrantDefaultRouteCIDR = errors.New("to allow all IP addresses, use \"*\" or \"autogroup:internet\"") ) +// NodeAttrs validation errors. +var ( + ErrNodeAttrsIPPoolReserved = errors.New("nodeAttrs ipPool must not overlap reserved Tailscale ranges") + ErrNodeAttrsIPPoolOutOfRange = errors.New("nodeAttrs ipPool must be within 100.64.0.0/10") + ErrNodeAttrsAutogroupNotAllowed = errors.New("nodeAttrs target does not support this autogroup") + ErrNodeAttrUnsupported = errors.New("nodeAttrs uses a feature headscale does not yet support") + ErrNodeAttrIPPoolUnsupported = errors.New("nodeAttrs ipPool requires the IP allocator (https://github.com/juanfont/headscale/issues/2912)") + ErrNodeAttrTargetUnsupported = errors.New("nodeAttrs target alias type is not supported") +) + +// nodeAttrUnsupportedCaps lists caps that headscale parses but cannot act on +// today. Each entry maps to the tracking issue an operator can follow. The +// caps are accepted by Tailscale SaaS, but delivering them via headscale +// without the matching server-side machinery would be misleading -- nodes +// would advertise a feature that does not work. Reject at policy load and +// point operators at the issue. +var nodeAttrUnsupportedCaps = map[tailcfg.NodeCapability]string{ + tailcfg.NodeAttrFunnel: "https://github.com/juanfont/headscale/issues/2527", +} + // Policy validation errors. var ( ErrUnknownAliasType = errors.New("unknown alias type") @@ -1917,6 +1937,19 @@ type Grant struct { Via []Tag `json:"via,omitzero"` } +// NodeAttrGrant attaches Tailscale node capabilities (and/or an IP-pool +// preference) to every node selected by Targets. The Targets aliases are +// resolved exactly like ACL/grant sources, so users, groups, tags, hosts, +// prefixes, autogroup:member, autogroup:tagged, and "*" are all valid. +// +// IPPool is parsed and validated for forward compatibility with the IP +// allocator; the policy compiler does not consume it yet. +type NodeAttrGrant struct { + Targets Aliases `json:"target"` + Attrs []tailcfg.NodeCapability `json:"attr,omitempty"` + IPPool []netip.Prefix `json:"ipPool,omitempty"` +} + // aclToGrants converts an ACL rule to one or more equivalent Grant rules. func aclToGrants(acl ACL) []Grant { ret := make([]Grant, 0, len(acl.Destinations)) @@ -1998,14 +2031,16 @@ type Policy struct { // callers using it should panic if not validated bool `json:"-"` - Groups Groups `json:"groups,omitempty"` - Hosts Hosts `json:"hosts,omitempty"` - TagOwners TagOwners `json:"tagOwners,omitempty"` - ACLs []ACL `json:"acls,omitempty"` - Grants []Grant `json:"grants,omitempty"` - AutoApprovers AutoApproverPolicy `json:"autoApprovers"` - SSHs []SSH `json:"ssh,omitempty"` - Tests []PolicyTest `json:"tests,omitempty"` + Groups Groups `json:"groups,omitempty"` + Hosts Hosts `json:"hosts,omitempty"` + TagOwners TagOwners `json:"tagOwners,omitempty"` + ACLs []ACL `json:"acls,omitempty"` + Grants []Grant `json:"grants,omitempty"` + NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"` + AutoApprovers AutoApproverPolicy `json:"autoApprovers"` + SSHs []SSH `json:"ssh,omitempty"` + Tests []PolicyTest `json:"tests,omitempty"` + RandomizeClientPort bool `json:"randomizeClientPort,omitempty"` } // MarshalJSON is deliberately not implemented for Policy. @@ -2018,11 +2053,24 @@ var ( autogroupForSSHSrc = []AutoGroup{AutoGroupMember, AutoGroupTagged} autogroupForSSHDst = []AutoGroup{AutoGroupMember, AutoGroupTagged, AutoGroupSelf} autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot} + autogroupForNodeAttrs = []AutoGroup{AutoGroupMember, AutoGroupTagged} autogroupNotSupported = []AutoGroup{} errUnknownProtocolWildcard = errors.New("proto name \"*\" not known; use protocol number 0-255 or protocol name (icmp, tcp, udp, etc.)") ) +// reservedTSRanges are CGNAT subranges that Tailscale uses internally and that +// nodeAttrs ipPool entries must not overlap. +// +// - 100.100.100.0/24 is MagicDNS / TSMP +// - 100.115.92.0/23 is the Quad100 / IPN service range +// +// (See https://tailscale.com/kb/1304/ip-pool for the operator-facing list.) +var reservedTSRanges = []netip.Prefix{ + netip.MustParsePrefix("100.100.100.0/24"), + netip.MustParsePrefix("100.115.92.0/23"), +} + func validateAutogroupSupported(ag *AutoGroup) error { if ag == nil { return nil @@ -2071,6 +2119,47 @@ func validateAutogroupForDst(dst *AutoGroup) error { return nil } +// validateAutogroupForNodeAttrs accepts only the autogroups that make sense +// as a nodeAttrs target: autogroup:member and autogroup:tagged. +// autogroup:self / autogroup:internet / autogroup:danger-all are rejected — +// none of them describes a stable identity set that a node-level attribute +// can attach to. autogroup:admin / autogroup:owner are rejected one layer +// up: AutoGroup.UnmarshalJSON returns ErrInvalidAutogroup at parse time +// because those values aren't in the allowed set, so the policy never +// reaches this validator. +func validateAutogroupForNodeAttrs(ag *AutoGroup) error { + if ag == nil { + return nil + } + + if !slices.Contains(autogroupForNodeAttrs, *ag) { + return fmt.Errorf("%w: %q, can be %v", ErrNodeAttrsAutogroupNotAllowed, *ag, autogroupForNodeAttrs) + } + + return nil +} + +// validateNodeAttrIPPool rejects ipPool entries outside the CGNAT range or +// overlapping the Tailscale-reserved subranges (MagicDNS, Quad100/IPN). A +// prefix is considered "within" CGNAT when it is at least as specific as +// 100.64.0.0/10 and its first address lies inside it. +func validateNodeAttrIPPool(prefix netip.Prefix) error { + cgnat := tsaddr.CGNATRange() + masked := prefix.Masked() + + if masked.Bits() < cgnat.Bits() || !cgnat.Contains(masked.Addr()) { + return fmt.Errorf("%w: %q", ErrNodeAttrsIPPoolOutOfRange, prefix) + } + + for _, reserved := range reservedTSRanges { + if masked.Overlaps(reserved) { + return fmt.Errorf("%w: %q overlaps %q", ErrNodeAttrsIPPoolReserved, prefix, reserved) + } + } + + return nil +} + func validateAutogroupForSSHSrc(src *AutoGroup) error { if src == nil { return nil @@ -2628,6 +2717,68 @@ func (p *Policy) validate() error { } } + for _, na := range p.NodeAttrs { + // SaaS accepts entries with neither attr nor ipPool (they + // compile to a no-op); headscale follows suit so policies + // captured against SaaS round-trip cleanly. + for _, target := range na.Targets { + switch t := target.(type) { + case *Host: + if !p.Hosts.exist(*t) { + errs = append(errs, fmt.Errorf("%w: %q", ErrHostNotDefined, *t)) + } + case *AutoGroup: + err := validateAutogroupSupported(t) + if err != nil { + errs = append(errs, err) + + continue + } + + err = validateAutogroupForNodeAttrs(t) + if err != nil { + errs = append(errs, err) + } + case *Group: + err := p.Groups.Contains(t) + if err != nil { + errs = append(errs, err) + } + case *Tag: + err := p.TagOwners.Contains(t) + if err != nil { + errs = append(errs, err) + } + case *Username, *Prefix, Asterix: + // User / prefix / wildcard targets are accepted at + // parse time and resolved at compile time (where a + // typo'd username surfaces as a propagated Resolve + // error from compileNodeAttrs). Mirrors the grant + // source-side validation shape. + default: + errs = append(errs, fmt.Errorf("%w: %q (%T)", ErrNodeAttrTargetUnsupported, target, target)) + } + } + + for _, attr := range na.Attrs { + issue, ok := nodeAttrUnsupportedCaps[attr] + if ok { + errs = append(errs, fmt.Errorf("%w: %q tracked in %s", ErrNodeAttrUnsupported, attr, issue)) + } + } + + if len(na.IPPool) > 0 { + errs = append(errs, ErrNodeAttrIPPoolUnsupported) + } + + for _, prefix := range na.IPPool { + err := validateNodeAttrIPPool(prefix) + if err != nil { + errs = append(errs, err) + } + } + } + for _, tagOwners := range p.TagOwners { for _, tagOwner := range tagOwners { switch tagOwner := tagOwner.(type) {