mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-23 10:42:30 +09:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user