diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index cfdcc1f2..f1c7b041 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -1,7 +1,6 @@ package mapper import ( - "maps" "net/netip" "slices" "sort" @@ -86,20 +85,14 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder { return slices.Concat(primaries, nv.ExitRoutes()) }, - b.mapper.cfg) + b.mapper.cfg, + b.mapper.state.NodeCapMap(nv.ID()), + ) if err != nil { b.addError(err) 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 @@ -276,7 +269,7 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( for _, peer := range changedViews.All() { tn, err := peer.TailNode(b.capVer, func(_ types.NodeID) []netip.Prefix { return b.mapper.state.RoutesForPeer(node, peer, matchers) - }, b.mapper.cfg) + }, b.mapper.cfg, nil) if err != nil { return nil, err } diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index 80d8f0aa..2ee34d7e 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -16,6 +16,8 @@ import ( ) func TestTailNode(t *testing.T) { + t.Parallel() + mustNK := func(str string) key.NodePublic { var k key.NodePublic @@ -51,7 +53,6 @@ func TestTailNode(t *testing.T) { tests := []struct { name string node *types.Node - pol []byte dnsConfig *tailcfg.DNSConfig baseDomain string want *tailcfg.Node @@ -208,6 +209,8 @@ func TestTailNode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := &types.Config{ BaseDomain: tt.baseDomain, TailcfgDNSConfig: tt.dnsConfig, @@ -233,6 +236,7 @@ func TestTailNode(t *testing.T) { return slices.Concat(primaries[id], nv.ExitRoutes()) }, cfg, + nil, ) if (err != nil) != tt.wantErr { @@ -289,6 +293,7 @@ func TestNodeExpiry(t *testing.T) { return []netip.Prefix{} }, &types.Config{Taildrop: types.TaildropConfig{Enabled: true}}, + nil, ) if err != nil { t.Fatalf("nodeExpiry() error = %v", err) diff --git a/hscontrol/policy/v2/tailnet_state_caps.go b/hscontrol/policy/v2/tailnet_state_caps.go index df78b321..cc77b18b 100644 --- a/hscontrol/policy/v2/tailnet_state_caps.go +++ b/hscontrol/policy/v2/tailnet_state_caps.go @@ -1,25 +1,22 @@ package v2 -// This file enumerates [tailcfg.NodeCapability] values that participate -// in [tailcfg.Node.CapMap] on the wire (both the SelfNode and the peer -// view) but where headscale's emission shape diverges from Tailscale's -// hosted control plane. The compat test in -// tailscale_nodeattrs_compat_test.go strips these from BOTH sides before -// [cmp.Diff] so the rest of the wire shape is compared in full, with no -// per-cap allowlist in the test itself. +// This file enumerates [tailcfg.NodeCapability] values that the +// compat test in tailscale_nodeattrs_compat_test.go strips from BOTH +// sides before [cmp.Diff]. The test builds the self-view CapMap via +// [types.NodeView.TailNode] -- the same call the mapper makes -- so +// every cap NOT in this list is compared in full as it lands on the +// wire. // -// Each entry is documented with: the cap's purpose (cross-referenced to -// Tailscale source), why headscale's shape diverges, and a tracking -// issue where one exists. Entries that are unlikely to ever be modelled -// in headscale (internal magicsock or SSH server tuning) land at the -// end of the list. +// Entries fall into two groups: +// 1. Caps SaaS emits that headscale has no concept of (admin / owner +// user roles, tailnet lock, services host, app connectors, +// tailnet-state metadata). +// 2. Caps headscale emits unconditionally where SaaS gates emission +// on a tailnet-config knob headscale does not surface (the +// taildrive pair). The feature works; the gating differs. // -// Coverage that this comparison loses lives elsewhere: -// - hscontrol/servertest/nodeattrs_test.go::TestNodeAttrsBaselineCapsAlwaysOn -// verifies the always-on baseline (admin, ssh, file-sharing, -// drive:share, drive:access). -// - per-feature tests in this package verify the policy compile -// output that feeds the merged CapMap. +// Each entry documents its purpose, the reason for divergence, and a +// tracking issue where one exists. import ( "slices" @@ -71,15 +68,15 @@ func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.No // // 1. Caps gated on a user-role concept headscale does not model. // 2. Caps gated on a tailnet feature headscale does not implement. -// 3. Caps where headscale emits an always-on baseline and the hosted -// control plane emits only when policy targets them. -// 4. Caps that are tailnet-state metadata (display name, key +// 3. Caps that are tailnet-state metadata (display name, key // duration, etc.) where the values are not derivable from // headscale config in a way that round-trips through the // anonymized capture. -// 5. Caps that are internal magicsock or embedded-SSH tuning with no -// headscale-side equivalent. Listed last — unlikely to be -// adopted. +// 4. Caps that are internal magicsock or embedded-SSH tuning with no +// headscale-side equivalent. +// 5. Baseline-divergence caps -- features headscale supports but +// emits unconditionally where SaaS gates on a tailnet-config +// toggle headscale does not surface yet. var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{ // --- 1. User-role gated --- @@ -123,31 +120,7 @@ var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{ // defensively in case a stale tailnet still emits it. tailcfg.CapabilityWarnFunnelNoHTTPS, - // --- 3. Headscale baseline; hosted control plane policy-driven --- - - // [tailcfg.CapabilitySSH] and [tailcfg.CapabilityFileSharing]: - // emitted unconditionally by [types.Node.TailNode] (file-sharing - // gated on cfg.Taildrop.Enabled). The compat test compares the - // policy compile output, not the full TailNode-merged shape, so - // these baseline keys sit only on the captured side and would diff - // on every scenario without stripping. Coverage for the headscale - // emit lives in TestNodeAttrsBaselineCapsAlwaysOn. - tailcfg.CapabilitySSH, - tailcfg.CapabilityFileSharing, - - // [tailcfg.NodeAttrsTaildriveShare] and - // [tailcfg.NodeAttrsTaildriveAccess]: the hosted control plane - // emits these only when policy targets them; headscale's - // [types.Node.TailNode] emits them as part of the always-on - // baseline so taildrive features work out of the box on - // self-hosted tailnets. Stripping on both sides keeps the diff - // from flagging this on every scenario; rewriting the drive - // baseline onto a policy-driven emit path is a separate - // follow-up. - tailcfg.NodeAttrsTaildriveShare, - tailcfg.NodeAttrsTaildriveAccess, - - // --- 4. Tailnet-state metadata not derivable from headscale config --- + // --- 3. Tailnet-state metadata not derivable from headscale config --- // [tailcfg.NodeAttrTailnetDisplayName]: tailnet display name // surfaced in the client UI. The hosted control plane emits the @@ -175,7 +148,7 @@ var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{ // PR). tailcfg.NodeAttrNativeIPV4, - // --- 5. Internal tuning, no headscale equivalent --- + // --- 4. Internal tuning, no headscale equivalent --- // [tailcfg.NodeAttrProbeUDPLifetime]: tunes magicsock's UDP // path-lifetime probe behavior. Internal performance knob; not @@ -191,6 +164,20 @@ var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{ // forwarding in the embedded SSH server. Internal; default chosen // by the server. tailcfg.NodeAttrSSHEnvironmentVariables, + + // --- 5. Baseline-divergence — feature supported, gating differs --- + + // [tailcfg.NodeAttrsTaildriveShare] and + // [tailcfg.NodeAttrsTaildriveAccess]: the hosted control plane + // emits these only when policy or a tailnet-config toggle grants + // them. [types.Node.TailNode] emits both unconditionally so + // taildrive works out of the box on self-hosted tailnets. The + // feature is supported on both sides; only the emission gating + // differs. Strip until headscale grows an equivalent operator + // toggle (analogous to cfg.Taildrop.Enabled gating + // CapabilityFileSharing). + tailcfg.NodeAttrsTaildriveShare, + tailcfg.NodeAttrsTaildriveAccess, } // strippedCapPrefixes lists URL/string prefixes for parameterized or diff --git a/hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go b/hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go index 99969470..994fc099 100644 --- a/hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go +++ b/hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go @@ -239,6 +239,20 @@ func testNodeAttrsSuccess( got, err := pol.compileNodeAttrs(users, nodes.ViewSlice()) require.NoErrorf(t, err, "%s: compileNodeAttrs", tf.TestID) + // Mirror the prod self-build: route function is irrelevant for CapMap; + // Taildrop.Enabled=true matches the SaaS-captured tailnets. + cfg := &types.Config{Taildrop: types.TaildropConfig{Enabled: true}} + emptyRoutes := func(types.NodeID) []netip.Prefix { return nil } + + selfCapMap := func(t *testing.T, node *types.Node) tailcfg.NodeCapMap { + t.Helper() + + tn, err := node.View().TailNode(0, emptyRoutes, cfg, got[node.ID]) + require.NoErrorf(t, err, "%s/%s: TailNode", tf.TestID, node.GivenName) + + return tn.CapMap + } + for nodeName, capture := range tf.Captures { if capture.Netmap == nil || !capture.Netmap.SelfNode.Valid() { continue @@ -249,7 +263,7 @@ func testNodeAttrsSuccess( require.NotNilf(t, node, "node %q from capture not found in test setup", nodeName) - gotSelf := stripUnmodelledTailnetStateCaps(got[node.ID]) + gotSelf := stripUnmodelledTailnetStateCaps(selfCapMap(t, node)) wantSelf := stripUnmodelledTailnetStateCaps( capMapFromView(capture.Netmap.SelfNode.CapMap()), ) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 46747414..56e7f747 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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()),