policy/v2: parse, validate, and compile nodeAttrs

ACL policies now accept a top-level nodeAttrs block. Each entry hands
a list of tailcfg node capabilities to every node matching target.
Accepted target forms are the same as acls.src and grants.src: users,
groups, tags, hosts, prefixes, autogroup:member, autogroup:tagged,
and *. autogroup:self, autogroup:internet, and autogroup:danger-all
are rejected at validate time because none describes a stable
identity set a node-level attribute can attach to.

NodeAttrGrant carries Targets, Attrs, and IPPool. IPPool is parsed
but rejected at validate time -- the allocator that consumes it is
not yet implemented. nodeAttrUnsupportedCaps lists caps SaaS accepts
that headscale cannot act on (funnel today) and rejects them with a
tracking-issue link in the error.

compileNodeAttrs resolves each entry's targets, then maps every
targeted node to a tailcfg.NodeCapMap of the entry's attrs. Per-node
IPs are cached once per call so the inner attr loop is O(grants)
instead of O(grants * nodes) IP allocations.

PolicyManager grows NodeCapMap (per-node), NodeCapMaps (snapshot for
batched callers), and NodesWithChangedCapMap (drain buffer for the
self-broadcast diff). refreshNodeAttrsLocked appends to the drain
rather than overwriting so a SetUsers/SetNodes between SetPolicy and
the drain cannot lose the policy-reload diff.
This commit is contained in:
Kristoffer Dalby 2026-05-11 14:46:38 +00:00
parent c4ab267c36
commit a4f05b0962
5 changed files with 769 additions and 8 deletions

View file

@ -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