diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go index 01eb09b2..08532aee 100644 --- a/cmd/headscale/headscale_test.go +++ b/cmd/headscale/headscale_test.go @@ -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")) } diff --git a/config-example.yaml b/config-example.yaml index 6cd529c5..54a5f1f3 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -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. diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index 505c9de0..80d8f0aa 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -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, diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 779380fc..ced79a01 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -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) { diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 2a4e0e7d..46747414 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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()),