config, types: move randomize_client_port from server config to policy file

Tailscale models the randomize-client-port toggle as a top-level
field on the ACL policy. Headscale now matches that shape: the
server-config randomize_client_port key is removed, the toggle
lives in the policy file as randomizeClientPort, and per-node
opt-in via nodeAttrs is also supported.

Operators upgrading from a config-set randomize_client_port hit
depr.fatalWithHint at startup, which prints the deprecation message
and points at the new policy field rather than silently dropping
the toggle. The default carries over (false) so operators who never
set it are unaffected. config-example.yaml ships a REMOVED stanza
showing the migration.

types/node.go drops the cfg.RandomizeClientPort read from
TailNode -- the cap is now policy-driven through compileNodeAttrs
and the tail_test.go expectations follow.
This commit is contained in:
Kristoffer Dalby
2026-05-11 14:49:36 +00:00
parent 6fcff9e352
commit 3f73ed5404
5 changed files with 57 additions and 35 deletions

View File

@@ -73,5 +73,4 @@ func TestConfigLoading(t *testing.T) {
assert.Equal(t, "HTTP-01", viper.GetString("tls_letsencrypt_challenge_type"))
assert.Equal(t, fs.FileMode(0o770), util.GetFileMode("unix_socket_permission"))
assert.False(t, viper.GetBool("logtail.enabled"))
assert.False(t, viper.GetBool("randomize_client_port"))
}

View File

@@ -452,18 +452,15 @@ logtail:
# disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
enabled: false
# Enabling this option makes devices prefer a random port for WireGuard traffic over the
# default static port 41641. This option is intended as a workaround for some buggy
# firewall devices. See https://tailscale.com/docs/integrations/firewalls for more information.
randomize_client_port: false
# Taildrop configuration
# Taildrop is the file sharing feature of Tailscale, allowing nodes to send files to each other.
# Taildrop is the file sharing feature of Tailscale, allowing nodes to
# send files to each other.
# https://tailscale.com/docs/features/taildrop
taildrop:
# Enable or disable Taildrop for all nodes.
# When enabled, nodes can send files to other nodes owned by the same user.
# Tagged devices and cross-user transfers are not permitted by Tailscale clients.
# 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.
enabled: true
# Advanced performance tuning parameters.
# The defaults are carefully chosen and should rarely need adjustment.

View File

@@ -74,9 +74,9 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
},
@@ -165,9 +165,9 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
},
@@ -192,9 +192,9 @@ func TestTailNode(t *testing.T) {
MachineAuthorized: true,
CapMap: tailcfg.NodeCapMap{
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
},
@@ -209,10 +209,9 @@ func TestTailNode(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &types.Config{
BaseDomain: tt.baseDomain,
TailcfgDNSConfig: tt.dnsConfig,
RandomizeClientPort: false,
Taildrop: types.TaildropConfig{Enabled: true},
BaseDomain: tt.baseDomain,
TailcfgDNSConfig: tt.dnsConfig,
Taildrop: types.TaildropConfig{Enabled: true},
}
// Stub primary-route lookup: tt.node owns its SubnetRoutes,

View File

@@ -130,9 +130,8 @@ type Config struct {
OIDC OIDCConfig
LogTail LogTailConfig
RandomizeClientPort bool
Taildrop TaildropConfig
LogTail LogTailConfig
Taildrop TaildropConfig
CLI CLIConfig
@@ -428,7 +427,6 @@ func LoadConfig(path string, isFile bool) error {
viper.SetDefault("oidc.email_verified_required", true)
viper.SetDefault("logtail.enabled", false)
viper.SetDefault("randomize_client_port", false)
viper.SetDefault("taildrop.enabled", true)
viper.SetDefault("node.expiry", "0")
@@ -530,6 +528,16 @@ func validateServerConfig() error {
depr.fatal("oidc.strip_email_domain")
depr.fatal("oidc.map_legacy_users")
// Removed since v0.29.0: `randomize_client_port` moved to the ACL
// policy as a top-level `randomizeClientPort` field, matching the
// Tailscale-hosted control plane schema. Per-node `nodeAttrs`
// entries granting `https://tailscale.com/cap/randomize-client-port`
// also work.
depr.fatalWithHint("randomize_client_port",
`Set "randomizeClientPort": true at the top level of your policy file `+
`(see policy.path / policy.mode), or grant the cap per-node via a `+
`"nodeAttrs" entry. See CHANGELOG.md (BREAKING / Configuration).`)
// Deprecated: ephemeral_node_inactivity_timeout -> node.ephemeral.inactivity_timeout
depr.warnNoAlias("node.ephemeral.inactivity_timeout", "ephemeral_node_inactivity_timeout")
@@ -1120,7 +1128,6 @@ func LoadServerConfig() (*Config, error) {
derpConfig := derpConfig()
logTailConfig := logtailConfig()
randomizeClientPort := viper.GetBool("randomize_client_port")
oidcClientSecret := viper.GetString("oidc.client_secret")
@@ -1219,8 +1226,7 @@ func LoadServerConfig() (*Config, error) {
},
},
LogTail: logTailConfig,
RandomizeClientPort: randomizeClientPort,
LogTail: logTailConfig,
Taildrop: TaildropConfig{
Enabled: viper.GetBool("taildrop.enabled"),
},
@@ -1330,6 +1336,22 @@ func (d *deprecator) fatal(oldKey string) {
}
}
// fatalWithHint behaves like fatal but appends a remediation pointer to
// the message so operators see exactly what to do without leaving the
// terminal. Use it when the removed key has a clean replacement on the
// policy side.
func (d *deprecator) fatalWithHint(oldKey, hint string) {
if viper.IsSet(oldKey) {
d.fatals.Add(
fmt.Sprintf(
"The %q configuration key has been removed. %s",
oldKey,
hint,
),
)
}
}
// fatalIfNewKeyIsNotUsed deprecates and adds an entry to the fatal list of options if the oldKey is set and the new key is _not_ set.
// If the new key is set, a warning is emitted instead.
func (d *deprecator) fatalIfNewKeyIsNotUsed(newKey, oldKey string) {

View File

@@ -1181,24 +1181,29 @@ 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.
capMap := tailcfg.NodeCapMap{
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
}
if cfg.RandomizeClientPort {
capMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{}
tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveShare: []tailcfg.RawMessage{},
tailcfg.NodeAttrsTaildriveAccess: []tailcfg.RawMessage{},
}
if cfg.Taildrop.Enabled {
capMap[tailcfg.CapabilityFileSharing] = []tailcfg.RawMessage{}
}
// Enable Taildrive sharing and access on all nodes. The actual
// access control is enforced by cap/drive grants in FilterRules;
// without a matching grant these attributes alone do nothing.
capMap[tailcfg.NodeAttrsTaildriveShare] = []tailcfg.RawMessage{}
capMap[tailcfg.NodeAttrsTaildriveAccess] = []tailcfg.RawMessage{}
tNode := tailcfg.Node{
//nolint:gosec // G115: NodeID values are within int64 range
ID: tailcfg.NodeID(nv.ID()),