types/node, mapper, policy/v2: assemble self CapMap inside TailNode

types.NodeView.TailNode takes a selfPolicyCaps tailcfg.NodeCapMap
parameter and merges it into the baseline. The mapper's WithSelfNode
hands it the policy result via state.NodeCapMap; peer-path callers
pass nil because peer-side CapMap is set downstream via
policyv2.PeerCapMap.

The nodeAttrs compat test now diffs the full TailNode self-view
output against captured SaaS netmaps. Before this change the test
compared compileNodeAttrs alone -- the policy-only output -- and
needed a strip list to compensate for the missing baseline. With
TailNode on the diff path, baseline emission is exercised end-to-end
by every capture; a regression in TailNode breaks the suite.

unmodelledTailnetStateCaps drops cap/ssh and cap/file-sharing now
that both sides emit them identically. The file header is rewritten
to read as 'caps SaaS emits where headscale has no equivalent yet'
rather than the more confusing 'shape divergence' framing.
This commit is contained in:
Kristoffer Dalby 2026-05-11 14:53:09 +00:00
parent b3f795f0b4
commit 5ebc53c29e
5 changed files with 77 additions and 64 deletions

View file

@ -3,6 +3,7 @@ package types
import (
"errors"
"fmt"
"maps"
"net/netip"
"slices"
"strconv"
@ -1127,7 +1128,9 @@ func TailNodes(
tNodes := make([]*tailcfg.Node, 0, nodes.Len())
for _, node := range nodes.All() {
tNode, err := node.TailNode(capVer, primaryRouteFunc, cfg)
// nil selfPolicyCaps: this batch builds peer views; the caller
// sets each peer's CapMap from [policyv2.PeerCapMap].
tNode, err := node.TailNode(capVer, primaryRouteFunc, cfg, nil)
if err != nil {
return nil, err
}
@ -1139,10 +1142,17 @@ func TailNodes(
}
// TailNode converts a NodeView into a Tailscale tailcfg.Node.
//
// selfPolicyCaps is the per-node CapMap from [policy.PolicyManager.NodeCapMap]
// and is merged into the baseline. Pass it when building the self view of the
// requesting node; pass nil when building peer views (peer-side
// [tailcfg.Node.CapMap] is set by the caller from
// [policyv2.PeerCapMap]).
func (nv NodeView) TailNode(
capVer tailcfg.CapabilityVersion,
primaryRouteFunc RouteFunc,
cfg *Config,
selfPolicyCaps tailcfg.NodeCapMap,
) (*tailcfg.Node, error) {
if !nv.Valid() {
return nil, ErrInvalidNodeView
@ -1204,6 +1214,10 @@ func (nv NodeView) TailNode(
capMap[tailcfg.CapabilityFileSharing] = []tailcfg.RawMessage{}
}
// Policy nodeAttrs overlay the baseline on the self view. Peers
// pass nil; their CapMap is replaced downstream by [policyv2.PeerCapMap].
maps.Copy(capMap, selfPolicyCaps)
tNode := tailcfg.Node{
//nolint:gosec // G115: NodeID values are within int64 range
ID: tailcfg.NodeID(nv.ID()),