mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-17 16:36:02 +00:00
types/node, policy/v2: drop taildrive caps from baseline emission
Taildrive (drive:share and drive:access) is policy-driven per Tailscale's documented behaviour (https://tailscale.com/docs/features/taildrive). The previous always-on baseline emission diverged from SaaS for every node not targeted by a drive nodeAttr -- a real semantic divergence that the compat suite caught once the test moved to comparing TailNode output against the captured netmaps. types.Node.TailNode no longer stamps the drive pair. Operators wanting taildrive add a nodeAttrs entry: "nodeAttrs": [ { "target": ["*"], "attr": ["drive:share", "drive:access"] } ] unmodelledTailnetStateCaps shrinks accordingly. The baseline-divergence group is gone; every entry left in the list is genuinely unmodelled (user-role caps, unimplemented features, tailnet metadata, internal tuning). servertest's TestNodeAttrsBaselineCapsAlwaysOn expects the smaller baseline (admin + ssh + file-sharing). Integration TestGrantCapDrive grants the drive caps explicitly via NodeAttrs to exercise the policy-driven emission path.
This commit is contained in:
parent
5ebc53c29e
commit
8ea4cd3faa
5 changed files with 42 additions and 69 deletions
|
|
@ -75,11 +75,9 @@ func TestTailNode(t *testing.T) {
|
||||||
MachineAuthorized: true,
|
MachineAuthorized: true,
|
||||||
|
|
||||||
CapMap: tailcfg.NodeCapMap{
|
CapMap: tailcfg.NodeCapMap{
|
||||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||||
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
|
|
||||||
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
|
|
@ -166,11 +164,9 @@ func TestTailNode(t *testing.T) {
|
||||||
MachineAuthorized: true,
|
MachineAuthorized: true,
|
||||||
|
|
||||||
CapMap: tailcfg.NodeCapMap{
|
CapMap: tailcfg.NodeCapMap{
|
||||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||||
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
|
|
||||||
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
|
|
@ -193,11 +189,9 @@ func TestTailNode(t *testing.T) {
|
||||||
MachineAuthorized: true,
|
MachineAuthorized: true,
|
||||||
|
|
||||||
CapMap: tailcfg.NodeCapMap{
|
CapMap: tailcfg.NodeCapMap{
|
||||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||||
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
|
||||||
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
|
|
||||||
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
package v2
|
package v2
|
||||||
|
|
||||||
// This file enumerates [tailcfg.NodeCapability] values that the
|
// This file enumerates [tailcfg.NodeCapability] values that the
|
||||||
// compat test in tailscale_nodeattrs_compat_test.go strips from BOTH
|
// Tailscale-hosted control plane emits where headscale has no
|
||||||
// sides before [cmp.Diff]. The test builds the self-view CapMap via
|
// equivalent concept yet. The compat test in
|
||||||
// [types.NodeView.TailNode] -- the same call the mapper makes -- so
|
// tailscale_nodeattrs_compat_test.go builds the self-view CapMap via
|
||||||
// every cap NOT in this list is compared in full as it lands on the
|
// [types.NodeView.TailNode] -- the same call the mapper makes -- and
|
||||||
// wire.
|
// 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:
|
// Each entry documents its purpose (cross-referenced to Tailscale
|
||||||
// 1. Caps SaaS emits that headscale has no concept of (admin / owner
|
// source), why headscale does not emit it, and a tracking issue where
|
||||||
// user roles, tailnet lock, services host, app connectors,
|
// one exists.
|
||||||
// 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.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
|
@ -74,9 +68,6 @@ func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.No
|
||||||
// anonymized capture.
|
// anonymized capture.
|
||||||
// 4. Caps that are internal magicsock or embedded-SSH tuning with no
|
// 4. Caps that are internal magicsock or embedded-SSH tuning with no
|
||||||
// headscale-side equivalent.
|
// 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{
|
var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{
|
||||||
// --- 1. User-role gated ---
|
// --- 1. User-role gated ---
|
||||||
|
|
||||||
|
|
@ -164,20 +155,6 @@ var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{
|
||||||
// forwarding in the embedded SSH server. Internal; default chosen
|
// forwarding in the embedded SSH server. Internal; default chosen
|
||||||
// by the server.
|
// by the server.
|
||||||
tailcfg.NodeAttrSSHEnvironmentVariables,
|
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
|
// strippedCapPrefixes lists URL/string prefixes for parameterized or
|
||||||
|
|
|
||||||
|
|
@ -166,10 +166,12 @@ func TestNodeAttrsRevokesWhenRemoved(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNodeAttrsBaselineCapsAlwaysOn verifies that the SaaS-baseline caps
|
// TestNodeAttrsBaselineCapsAlwaysOn verifies that the SaaS-baseline caps
|
||||||
// (Admin, SSH, FileSharing, Taildrive share/access) are emitted on every
|
// (Admin, SSH, FileSharing) are emitted on every node regardless of
|
||||||
// node regardless of whether the policy mentions them. Tailscale clients
|
// whether the policy mentions them. Tailscale SaaS emits these
|
||||||
// expect these to be present, and Tailscale SaaS emits them
|
// unconditionally for default tailnet settings; headscale matches that
|
||||||
// unconditionally; headscale matches that shape.
|
// 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) {
|
func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|
@ -187,8 +189,6 @@ func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
|
||||||
tailcfg.CapabilityAdmin,
|
tailcfg.CapabilityAdmin,
|
||||||
tailcfg.CapabilitySSH,
|
tailcfg.CapabilitySSH,
|
||||||
tailcfg.CapabilityFileSharing,
|
tailcfg.CapabilityFileSharing,
|
||||||
tailcfg.NodeAttrsTaildriveShare,
|
|
||||||
tailcfg.NodeAttrsTaildriveAccess,
|
|
||||||
} {
|
} {
|
||||||
if !hasCap(nm, w) {
|
if !hasCap(nm, w) {
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -1191,23 +1191,12 @@ func (nv NodeView) TailNode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baseline caps every node receives regardless of the ACL policy.
|
// Baseline caps every node receives, regardless of policy. Mirrors
|
||||||
// The set matches what Tailscale SaaS emits with default tailnet
|
// what Tailscale SaaS emits for a default tailnet.
|
||||||
// settings, anchored against captured netmaps in
|
// cfg.Taildrop.Enabled gates CapabilityFileSharing.
|
||||||
// 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.
|
|
||||||
capMap := tailcfg.NodeCapMap{
|
capMap := tailcfg.NodeCapMap{
|
||||||
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
|
||||||
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
|
||||||
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
|
|
||||||
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Taildrop.Enabled {
|
if cfg.Taildrop.Enabled {
|
||||||
|
|
|
||||||
|
|
@ -553,6 +553,19 @@ func TestGrantCapDrive(t *testing.T) {
|
||||||
policyv2.Tag("tag:ro-client"): policyv2.Owners{usernameOwner("roclient@")},
|
policyv2.Tag("tag:ro-client"): policyv2.Owners{usernameOwner("roclient@")},
|
||||||
policyv2.Tag("tag:no-access"): policyv2.Owners{usernameOwner("noaccess@")},
|
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{
|
Grants: []policyv2.Grant{
|
||||||
// Grant 1: IP connectivity between ALL nodes.
|
// Grant 1: IP connectivity between ALL nodes.
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue