types/config, types/node: model default-auto-update from auto_update.enabled

Tailscale stamps tailcfg.NodeAttrDefaultAutoUpdate on every node's
CapMap with a JSON bool reflecting the tailnet-wide auto-update
default. Headscale grows an auto_update.enabled config option and
emits the cap accordingly from TailNode -- the cap leaves the
unmodelledTailnetStateCaps strip list and is compared in full by the
nodeAttrs compat suite.

testNodeAttrsSuccess drives cfg.AutoUpdate.Enabled from
tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn so each capture's
expected emission matches the SaaS state it was taken under. Two
captures cover both branches:

  - nodeattrs-tailnet-devices-auto-updates-on  -> [true]
  - nodeattrs-tailnet-devices-auto-updates-off -> [false]

The Tailscale v2 TailnetSettings API does not expose the Send Files
toggle, so the compat suite cannot vary cfg.Taildrop.Enabled per
capture. TestTaildropDisabledWithholdsFileSharingCap covers the off
path directly in servertest.
This commit is contained in:
Kristoffer Dalby 2026-05-11 16:28:09 +00:00
parent 408f4022e4
commit 64d13f77e8
10 changed files with 9519 additions and 854 deletions

View File

@ -85,6 +85,11 @@ reaches clients unchanged.
`randomizeClientPort` also lands as a top-level policy field that toggles `randomizeClientPort` also lands as a top-level policy field that toggles
the default for every node, replacing the old server-config knob. the default for every node, replacing the old server-config knob.
A new `auto_update.enabled` config option controls the tailnet-wide
default for client auto-update. When true, every node's CapMap carries
`default-auto-update: [true]` so fresh clients pick up the default
unless they make a local opt-in / opt-out choice.
Policies that use the `funnel` cap, `ipPool` blocks, or Policies that use the `funnel` cap, `ipPool` blocks, or
`autogroup:admin` / `autogroup:owner` targets are rejected at load — `autogroup:admin` / `autogroup:owner` targets are rejected at load —
those features depend on machinery headscale does not yet ship. those features depend on machinery headscale does not yet ship.

View File

@ -458,10 +458,18 @@ logtail:
# https://tailscale.com/docs/features/taildrop # https://tailscale.com/docs/features/taildrop
taildrop: taildrop:
# Enable or disable Taildrop tailnet-wide. When disabled, headscale # Enable or disable Taildrop tailnet-wide. When disabled, headscale
# withholds `https://tailscale.com/cap/file-sharing` from every node's # withholds `https://tailscale.com/cap/file-sharing` from every
# CapMap, matching the admin-console "Send Files" toggle on the # node's CapMap.
# Tailscale-hosted control plane.
enabled: true enabled: true
# Default node auto-update behaviour. When enabled, every node's
# CapMap carries `default-auto-update: [true]` so clients that have
# not made a local opt-in / opt-out choice run auto-updates by
# default. Setting it back to false flips the default for future
# clients; clients that already stored the value locally keep their
# choice.
auto_update:
enabled: false
# Advanced performance tuning parameters. # Advanced performance tuning parameters.
# The defaults are carefully chosen and should rarely need adjustment. # The defaults are carefully chosen and should rarely need adjustment.
# Only modify these if you have identified a specific performance issue. # Only modify these if you have identified a specific performance issue.

View File

@ -75,9 +75,10 @@ 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.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
}, },
}, },
wantErr: false, wantErr: false,
@ -164,9 +165,10 @@ 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.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
}, },
}, },
wantErr: false, wantErr: false,
@ -189,9 +191,10 @@ 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.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
}, },
}, },
wantErr: false, wantErr: false,
@ -246,6 +249,102 @@ func TestTailNode(t *testing.T) {
} }
} }
// TestTailNodeBaselineGates focuses on the cfg-driven baseline cap
// emission: cfg.Taildrop.Enabled gates [tailcfg.CapabilityFileSharing]
// and cfg.AutoUpdate.Enabled controls the value of
// [tailcfg.NodeAttrDefaultAutoUpdate]. Admin and SSH are unconditional
// baseline.
func TestTailNodeBaselineGates(t *testing.T) {
t.Parallel()
autoUpdate := func(b bool) []tailcfg.RawMessage {
if b {
return []tailcfg.RawMessage{tailcfg.RawMessage("true")}
}
return []tailcfg.RawMessage{tailcfg.RawMessage("false")}
}
tests := []struct {
name string
cfg *types.Config
want tailcfg.NodeCapMap
}{
{
name: "taildrop_on_autoupdate_off",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: true},
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
},
},
{
name: "taildrop_off_autoupdate_off",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: false},
AutoUpdate: types.AutoUpdateConfig{Enabled: false},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(false),
},
},
{
name: "taildrop_on_autoupdate_on",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: true},
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
},
},
{
name: "taildrop_off_autoupdate_on",
cfg: &types.Config{
Taildrop: types.TaildropConfig{Enabled: false},
AutoUpdate: types.AutoUpdateConfig{Enabled: true},
},
want: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: autoUpdate(true),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
node := &types.Node{GivenName: "baseline-node", Hostinfo: &tailcfg.Hostinfo{}}
got, err := node.View().TailNode(
0,
func(types.NodeID) []netip.Prefix { return nil },
tt.cfg,
nil,
)
if err != nil {
t.Fatalf("TailNode: %v", err)
}
if diff := cmp.Diff(tt.want, got.CapMap, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("CapMap mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestNodeExpiry(t *testing.T) { func TestNodeExpiry(t *testing.T) {
tp := func(t time.Time) *time.Time { tp := func(t time.Time) *time.Time {
return &t return &t

View File

@ -121,12 +121,6 @@ var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{
// with no real-world equivalent. // with no real-world equivalent.
tailcfg.NodeAttrTailnetDisplayName, tailcfg.NodeAttrTailnetDisplayName,
// [tailcfg.NodeAttrDefaultAutoUpdate]: tailnet-wide default for
// client auto-update behavior. Headscale has no equivalent
// tailnet setting and would emit an invented constant. Skip
// until the auto-update default lands as a real config knob.
tailcfg.NodeAttrDefaultAutoUpdate,
// [tailcfg.NodeAttrMaxKeyDuration]: tailnet-wide max key duration // [tailcfg.NodeAttrMaxKeyDuration]: tailnet-wide max key duration
// value. Headscale has cfg.Node.Expiry but does not surface it // value. Headscale has cfg.Node.Expiry but does not surface it
// as a cap today; the hosted control plane emits this only when // as a cap today; the hosted control plane emits this only when

View File

@ -239,9 +239,20 @@ func testNodeAttrsSuccess(
got, err := pol.compileNodeAttrs(users, nodes.ViewSlice()) got, err := pol.compileNodeAttrs(users, nodes.ViewSlice())
require.NoErrorf(t, err, "%s: compileNodeAttrs", tf.TestID) require.NoErrorf(t, err, "%s: compileNodeAttrs", tf.TestID)
// Mirror the prod self-build: route function is irrelevant for CapMap; // Mirror the prod self-build: route function is irrelevant for CapMap.
// Taildrop.Enabled=true matches the SaaS-captured tailnets. //
// Taildrop.Enabled defaults to true here because every capture is
// taken with the SaaS default Send Files state. The Tailscale v2
// TailnetSettings API does not expose the Send Files toggle, so
// tscap cannot vary it; the off-path is covered directly by
// TestTaildropDisabledWithholdsFileSharingCap in servertest.
// TODO: wire Taildrop.Enabled from tf.Input.Tailnet.Settings.FileSharing
// once the field is added to the public TailnetSettings API.
cfg := &types.Config{Taildrop: types.TaildropConfig{Enabled: true}} cfg := &types.Config{Taildrop: types.TaildropConfig{Enabled: true}}
if v := tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn; v != nil && *v {
cfg.AutoUpdate = types.AutoUpdateConfig{Enabled: true}
}
emptyRoutes := func(types.NodeID) []netip.Prefix { return nil } emptyRoutes := func(types.NodeID) []netip.Prefix { return nil }
selfCapMap := func(t *testing.T, node *types.Node) tailcfg.NodeCapMap { selfCapMap := func(t *testing.T, node *types.Node) tailcfg.NodeCapMap {

View File

@ -165,13 +165,12 @@ func TestNodeAttrsRevokesWhenRemoved(t *testing.T) {
}) })
} }
// TestNodeAttrsBaselineCapsAlwaysOn verifies that the SaaS-baseline caps // TestNodeAttrsBaselineCapsAlwaysOn verifies that the baseline caps
// (Admin, SSH, FileSharing) are emitted on every node regardless of // (Admin, SSH, FileSharing, DefaultAutoUpdate) are emitted on every
// whether the policy mentions them. Tailscale SaaS emits these // node regardless of whether the policy mentions them. Taildrive
// unconditionally for default tailnet settings; headscale matches that // (drive:share / drive:access) is policy-driven and is verified
// shape. Taildrive (drive:share / drive:access) is policy-driven per // through TestNodeAttrsAddsToBaseline and the integration
// Tailscale's docs and is verified through TestNodeAttrsAddsToBaseline // TestGrantCapDrive flow.
// and the integration TestGrantCapDrive flow.
func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) { func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
t.Parallel() t.Parallel()
@ -189,6 +188,7 @@ func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
tailcfg.CapabilityAdmin, tailcfg.CapabilityAdmin,
tailcfg.CapabilitySSH, tailcfg.CapabilitySSH,
tailcfg.CapabilityFileSharing, tailcfg.CapabilityFileSharing,
tailcfg.NodeAttrDefaultAutoUpdate,
} { } {
if !hasCap(nm, w) { if !hasCap(nm, w) {
return false return false
@ -199,6 +199,30 @@ func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
}) })
} }
// TestTaildropDisabledWithholdsFileSharingCap asserts the off path of
// the Taildrop config gate. The Tailscale v2 API does not expose the
// equivalent tailnet setting, so the nodeAttrs compat suite cannot
// vary it; this test covers the headscale side directly.
func TestTaildropDisabledWithholdsFileSharingCap(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t, servertest.WithTaildropEnabled(false))
user := srv.CreateUser(t, "taildrop-off-user")
c := servertest.NewClient(t, srv, "taildrop-off-node", servertest.WithUser(user))
c.WaitForCondition(t, "file-sharing absent when taildrop disabled",
10*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil || !nm.SelfNode.Valid() {
return false
}
return !hasCap(nm, tailcfg.CapabilityFileSharing) &&
hasCap(nm, tailcfg.CapabilityAdmin) &&
hasCap(nm, tailcfg.CapabilitySSH)
})
}
// TestNodeAttrsAddsToBaseline verifies that policy nodeAttrs caps land on // TestNodeAttrsAddsToBaseline verifies that policy nodeAttrs caps land on
// nodes alongside the always-on baseline. The baseline caps remain // nodes alongside the always-on baseline. The baseline caps remain
// regardless of policy contents. // regardless of policy contents.

View File

@ -130,8 +130,9 @@ type Config struct {
OIDC OIDCConfig OIDC OIDCConfig
LogTail LogTailConfig LogTail LogTailConfig
Taildrop TaildropConfig Taildrop TaildropConfig
AutoUpdate AutoUpdateConfig
CLI CLIConfig CLI CLIConfig
@ -253,6 +254,15 @@ type TaildropConfig struct {
Enabled bool Enabled bool
} }
// AutoUpdateConfig controls the tailnet-wide default for client
// auto-update. When Enabled is true, headscale emits the
// [tailcfg.NodeAttrDefaultAutoUpdate] cap with value [true] on every
// node's CapMap; clients fall back to that default unless they have
// opted in or out locally.
type AutoUpdateConfig struct {
Enabled bool
}
type CLIConfig struct { type CLIConfig struct {
Address string Address string
APIKey string `json:"-"` // never serialise the headscale admin API key APIKey string `json:"-"` // never serialise the headscale admin API key
@ -428,6 +438,7 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("logtail.enabled", false) viper.SetDefault("logtail.enabled", false)
viper.SetDefault("taildrop.enabled", true) viper.SetDefault("taildrop.enabled", true)
viper.SetDefault("auto_update.enabled", false)
viper.SetDefault("node.expiry", "0") viper.SetDefault("node.expiry", "0")
viper.SetDefault("node.ephemeral.inactivity_timeout", "120s") viper.SetDefault("node.ephemeral.inactivity_timeout", "120s")
@ -1230,6 +1241,9 @@ func LoadServerConfig() (*Config, error) {
Taildrop: TaildropConfig{ Taildrop: TaildropConfig{
Enabled: viper.GetBool("taildrop.enabled"), Enabled: viper.GetBool("taildrop.enabled"),
}, },
AutoUpdate: AutoUpdateConfig{
Enabled: viper.GetBool("auto_update.enabled"),
},
Policy: policyConfig(), Policy: policyConfig(),

View File

@ -1203,6 +1203,18 @@ func (nv NodeView) TailNode(
capMap[tailcfg.CapabilityFileSharing] = []tailcfg.RawMessage{} capMap[tailcfg.CapabilityFileSharing] = []tailcfg.RawMessage{}
} }
// default-auto-update is always emitted; the value is a JSON bool
// reflecting cfg.AutoUpdate.Enabled. Clients read this on first
// netmap and store the default locally; subsequent control-plane
// changes are ignored unless the client has not yet opted in or
// out.
autoUpdateVal := tailcfg.RawMessage("false")
if cfg.AutoUpdate.Enabled {
autoUpdateVal = tailcfg.RawMessage("true")
}
capMap[tailcfg.NodeAttrDefaultAutoUpdate] = []tailcfg.RawMessage{autoUpdateVal}
// Policy nodeAttrs overlay the baseline on the self view. Peers // Policy nodeAttrs overlay the baseline on the self view. Peers
// pass nil; their CapMap is replaced downstream by [policyv2.PeerCapMap]. // pass nil; their CapMap is replaced downstream by [policyv2.PeerCapMap].
maps.Copy(capMap, selfPolicyCaps) maps.Copy(capMap, selfPolicyCaps)