diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index 2ee34d7e..08a5ad71 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -75,11 +75,9 @@ func TestTailNode(t *testing.T) { MachineAuthorized: true, CapMap: tailcfg.NodeCapMap{ - tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, - tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, - tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{}, + tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, + tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, + tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, }, }, wantErr: false, @@ -166,11 +164,9 @@ func TestTailNode(t *testing.T) { MachineAuthorized: true, CapMap: tailcfg.NodeCapMap{ - tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, - tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, - tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{}, + tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, + tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, + tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, }, }, wantErr: false, @@ -193,11 +189,9 @@ func TestTailNode(t *testing.T) { MachineAuthorized: true, CapMap: tailcfg.NodeCapMap{ - tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, - tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, - tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{}, + tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, + tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, + tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, }, }, wantErr: false, diff --git a/hscontrol/policy/v2/tailnet_state_caps.go b/hscontrol/policy/v2/tailnet_state_caps.go index cc77b18b..aab96ab5 100644 --- a/hscontrol/policy/v2/tailnet_state_caps.go +++ b/hscontrol/policy/v2/tailnet_state_caps.go @@ -1,22 +1,16 @@ package v2 // 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. +// Tailscale-hosted control plane emits where headscale has no +// equivalent concept yet. The compat test in +// tailscale_nodeattrs_compat_test.go builds the self-view CapMap via +// [types.NodeView.TailNode] -- the same call the mapper makes -- and +// strips these from BOTH sides before [cmp.Diff]; every other cap is +// compared in full as it lands on the wire. // -// 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. -// -// Each entry documents its purpose, the reason for divergence, and a -// tracking issue where one exists. +// Each entry documents its purpose (cross-referenced to Tailscale +// source), why headscale does not emit it, and a tracking issue where +// one exists. import ( "slices" @@ -74,9 +68,6 @@ func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.No // anonymized capture. // 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 --- @@ -164,20 +155,6 @@ 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/servertest/nodeattrs_test.go b/hscontrol/servertest/nodeattrs_test.go index 5e90b2d3..885a1c0e 100644 --- a/hscontrol/servertest/nodeattrs_test.go +++ b/hscontrol/servertest/nodeattrs_test.go @@ -166,10 +166,12 @@ func TestNodeAttrsRevokesWhenRemoved(t *testing.T) { } // TestNodeAttrsBaselineCapsAlwaysOn verifies that the SaaS-baseline caps -// (Admin, SSH, FileSharing, Taildrive share/access) are emitted on every -// node regardless of whether the policy mentions them. Tailscale clients -// expect these to be present, and Tailscale SaaS emits them -// unconditionally; headscale matches that shape. +// (Admin, SSH, FileSharing) are emitted on every node regardless of +// whether the policy mentions them. Tailscale SaaS emits these +// unconditionally for default tailnet settings; headscale matches that +// shape. Taildrive (drive:share / drive:access) is policy-driven per +// Tailscale's docs and is verified through TestNodeAttrsAddsToBaseline +// and the integration TestGrantCapDrive flow. func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) { t.Parallel() @@ -187,8 +189,6 @@ func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) { tailcfg.CapabilityAdmin, tailcfg.CapabilitySSH, tailcfg.CapabilityFileSharing, - tailcfg.NodeAttrsTaildriveShare, - tailcfg.NodeAttrsTaildriveAccess, } { if !hasCap(nm, w) { return false diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 56e7f747..ffc04b78 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -1191,23 +1191,12 @@ func (nv NodeView) TailNode( } } - // Baseline caps every node receives regardless of the ACL policy. - // The set matches what Tailscale SaaS emits with default tailnet - // settings, anchored against captured netmaps in - // hscontrol/policy/v2/testdata/nodeattrs_results — including the - // nodeattrs-tailnet-* probe captures which exercise individual - // tailnet-settings overlays (devices_auto_updates_on, magic_dns) - // against the SaaS API and confirm the CapMap shape stays stable. - // Where headscale exposes an equivalent operator knob it gates - // the cap accordingly: cfg.Taildrop.Enabled gates - // CapabilityFileSharing, matching the SaaS admin-console - // "Send Files" toggle. Policy nodeAttrs add to this baseline in - // the mapper; they cannot remove from it. + // Baseline caps every node receives, regardless of policy. Mirrors + // what Tailscale SaaS emits for a default tailnet. + // cfg.Taildrop.Enabled gates CapabilityFileSharing. capMap := tailcfg.NodeCapMap{ - tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, - tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{}, - tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{}, + tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, + tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, } if cfg.Taildrop.Enabled { diff --git a/integration/grant_cap_test.go b/integration/grant_cap_test.go index b81696ff..f5d00a72 100644 --- a/integration/grant_cap_test.go +++ b/integration/grant_cap_test.go @@ -553,6 +553,19 @@ func TestGrantCapDrive(t *testing.T) { policyv2.Tag("tag:ro-client"): policyv2.Owners{usernameOwner("roclient@")}, policyv2.Tag("tag:no-access"): policyv2.Owners{usernameOwner("noaccess@")}, }, + // Taildrive caps (drive:share / drive:access) are policy-driven + // per https://tailscale.com/docs/features/taildrive; no longer + // emitted as TailNode baseline. Stamp on every node so the + // SelfNode.CapMap assertions below remain meaningful. + NodeAttrs: []policyv2.NodeAttrGrant{ + { + Targets: policyv2.Aliases{policyv2.Wildcard}, + Attrs: []tailcfg.NodeCapability{ + tailcfg.NodeAttrsTaildriveShare, + tailcfg.NodeAttrsTaildriveAccess, + }, + }, + }, Grants: []policyv2.Grant{ // Grant 1: IP connectivity between ALL nodes. {