mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-17 09:46:10 +02:00
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:
parent
408f4022e4
commit
64d13f77e8
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
8818
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-tailnet-devices-auto-updates-off.hujson
vendored
Normal file
8818
hscontrol/policy/v2/testdata/nodeattrs_results/nodeattrs-tailnet-devices-auto-updates-off.hujson
vendored
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user