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
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
`autogroup:admin` / `autogroup:owner` targets are rejected at load —
those features depend on machinery headscale does not yet ship.

View File

@ -458,10 +458,18 @@ logtail:
# https://tailscale.com/docs/features/taildrop
taildrop:
# Enable or disable Taildrop tailnet-wide. When disabled, headscale
# withholds `https://tailscale.com/cap/file-sharing` from every node's
# CapMap, matching the admin-console "Send Files" toggle on the
# Tailscale-hosted control plane.
# withholds `https://tailscale.com/cap/file-sharing` from every
# node's CapMap.
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.
# The defaults are carefully chosen and should rarely need adjustment.
# Only modify these if you have identified a specific performance issue.

View File

@ -75,9 +75,10 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@ -164,9 +165,10 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("false")},
},
},
wantErr: false,
@ -189,9 +191,10 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrDefaultAutoUpdate: []tailcfg.RawMessage{tailcfg.RawMessage("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) {
tp := func(t time.Time) *time.Time {
return &t

View File

@ -121,12 +121,6 @@ var unmodelledTailnetStateCaps = []tailcfg.NodeCapability{
// with no real-world equivalent.
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
// value. Headscale has cfg.Node.Expiry but does not surface it
// 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())
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.
// Mirror the prod self-build: route function is irrelevant for CapMap.
//
// 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}}
if v := tf.Input.Tailnet.Settings.DevicesAutoUpdatesOn; v != nil && *v {
cfg.AutoUpdate = types.AutoUpdateConfig{Enabled: true}
}
emptyRoutes := func(types.NodeID) []netip.Prefix { return nil }
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
// (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.
// TestNodeAttrsBaselineCapsAlwaysOn verifies that the baseline caps
// (Admin, SSH, FileSharing, DefaultAutoUpdate) are emitted on every
// node regardless of whether the policy mentions them. Taildrive
// (drive:share / drive:access) is policy-driven and is verified
// through TestNodeAttrsAddsToBaseline and the integration
// TestGrantCapDrive flow.
func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
t.Parallel()
@ -189,6 +188,7 @@ func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) {
tailcfg.CapabilityAdmin,
tailcfg.CapabilitySSH,
tailcfg.CapabilityFileSharing,
tailcfg.NodeAttrDefaultAutoUpdate,
} {
if !hasCap(nm, w) {
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
// nodes alongside the always-on baseline. The baseline caps remain
// regardless of policy contents.

View File

@ -130,8 +130,9 @@ type Config struct {
OIDC OIDCConfig
LogTail LogTailConfig
Taildrop TaildropConfig
LogTail LogTailConfig
Taildrop TaildropConfig
AutoUpdate AutoUpdateConfig
CLI CLIConfig
@ -253,6 +254,15 @@ type TaildropConfig struct {
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 {
Address string
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("taildrop.enabled", true)
viper.SetDefault("auto_update.enabled", false)
viper.SetDefault("node.expiry", "0")
viper.SetDefault("node.ephemeral.inactivity_timeout", "120s")
@ -1230,6 +1241,9 @@ func LoadServerConfig() (*Config, error) {
Taildrop: TaildropConfig{
Enabled: viper.GetBool("taildrop.enabled"),
},
AutoUpdate: AutoUpdateConfig{
Enabled: viper.GetBool("auto_update.enabled"),
},
Policy: policyConfig(),

View File

@ -1203,6 +1203,18 @@ func (nv NodeView) TailNode(
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
// pass nil; their CapMap is replaced downstream by [policyv2.PeerCapMap].
maps.Copy(capMap, selfPolicyCaps)