diff --git a/config-example.yaml b/config-example.yaml index b94f3c8a..751e9517 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -145,8 +145,25 @@ derp: # Disables the automatic check for headscale updates on startup disable_check_updates: false -# Time before an inactive ephemeral node is deleted? -ephemeral_node_inactivity_timeout: 30m +# Node lifecycle configuration. +node: + # Default key expiry for non-tagged nodes, regardless of registration method + # (auth key, CLI, web auth). Tagged nodes are exempt and never expire. + # + # This is the base default. OIDC can override this via oidc.expiry. + # If a client explicitly requests a specific expiry, the client value is used. + # + # Setting the value to "0" means no default expiry (nodes never expire unless + # explicitly expired via `headscale nodes expire`). + # + # Tailscale SaaS uses 180d; set to a positive duration to match that behaviour. + # + # Default: 0 (no default expiry) + expiry: 0 + + ephemeral: + # Time before an inactive ephemeral node is deleted. + inactivity_timeout: 30m database: # Database type. Available options: sqlite, postgres @@ -355,15 +372,11 @@ unix_socket_permission: "0770" # # `LoadCredential` straightforward: # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" # -# # The amount of time a node is authenticated with OpenID until it expires -# # and needs to reauthenticate. -# # Setting the value to "0" will mean no expiry. -# expiry: 180d -# # # Use the expiry from the token received from OpenID when the user logged # # in. This will typically lead to frequent need to reauthenticate and should # # only be enabled if you know what you are doing. -# # Note: enabling this will cause `oidc.expiry` to be ignored. +# # Note: enabling this will cause `node.expiry` to be ignored for +# # OIDC-authenticated nodes. # use_expiry_from_token: false # # # The OIDC scopes to use, defaults to "openid", "profile" and "email". diff --git a/docs/ref/oidc.md b/docs/ref/oidc.md index cdbbebc2..af909ff3 100644 --- a/docs/ref/oidc.md +++ b/docs/ref/oidc.md @@ -145,16 +145,12 @@ oidc: ### Customize node expiration The node expiration is the amount of time a node is authenticated with OpenID Connect until it expires and needs to -reauthenticate. The default node expiration is 180 days. This can either be customized or set to the expiration from the -Access Token. +reauthenticate. The default node expiration can be configured via the top-level `node.expiry` setting. === "Customize node expiration" - ```yaml hl_lines="5" - oidc: - issuer: "https://sso.example.com" - client_id: "headscale" - client_secret: "generated-secret" + ```yaml hl_lines="2" + node: expiry: 30d # Use 0 to disable node expiration ``` diff --git a/hscontrol/app.go b/hscontrol/app.go index ab017c47..b2152914 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -583,7 +583,7 @@ func (h *Headscale) Serve() error { ephmNodes := h.state.ListEphemeralNodes() for _, node := range ephmNodes.All() { - h.ephemeralGC.Schedule(node.ID(), h.cfg.EphemeralNodeInactivityTimeout) + h.ephemeralGC.Schedule(node.ID(), h.cfg.Node.Ephemeral.InactivityTimeout) } if h.cfg.DNSConfig.ExtraRecordsPath != "" { diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 72c4c1d0..304245de 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -383,12 +383,12 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( } } -func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) time.Time { +func (a *AuthProviderOIDC) determineNodeExpiry(idTokenExpiration time.Time) *time.Time { if a.cfg.UseExpiryFromToken { - return idTokenExpiration + return &idTokenExpiration } - return time.Now().Add(a.cfg.Expiry) + return nil } func extractCodeAndStateParamFromRequest( @@ -602,12 +602,12 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim( func (a *AuthProviderOIDC) handleRegistration( user *types.User, registrationID types.AuthID, - expiry time.Time, + expiry *time.Time, ) (bool, error) { node, nodeChange, err := a.h.state.HandleNodeFromAuthPath( registrationID, types.UserID(user.ID), - &expiry, + expiry, util.RegisterMethodOIDC, ) if err != nil { diff --git a/hscontrol/poll.go b/hscontrol/poll.go index 7dd4051f..bb98f6ac 100644 --- a/hscontrol/poll.go +++ b/hscontrol/poll.go @@ -106,7 +106,7 @@ func (m *mapSession) beforeServeLongPoll() { // is disconnected. func (m *mapSession) afterServeLongPoll() { if m.node.IsEphemeral() { - m.h.ephemeralGC.Schedule(m.node.ID, m.h.cfg.EphemeralNodeInactivityTimeout) + m.h.ephemeralGC.Schedule(m.node.ID, m.h.cfg.Node.Ephemeral.InactivityTimeout) } } diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index bc0c9e1e..22abdd5e 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -24,10 +24,8 @@ import ( ) const ( - defaultOIDCExpiryTime = 180 * 24 * time.Hour // 180 Days - maxDuration time.Duration = 1<<63 - 1 - PKCEMethodPlain string = "plain" - PKCEMethodS256 string = "S256" + PKCEMethodPlain string = "plain" + PKCEMethodS256 string = "S256" defaultNodeStoreBatchSize = 100 ) @@ -55,21 +53,40 @@ const ( PolicyModeFile = "file" ) +// EphemeralConfig contains configuration for ephemeral node lifecycle. +type EphemeralConfig struct { + // InactivityTimeout is how long an ephemeral node can be offline + // before it is automatically deleted. + InactivityTimeout time.Duration +} + +// NodeConfig contains configuration for node lifecycle and expiry. +type NodeConfig struct { + // Expiry is the default key expiry duration for non-tagged nodes. + // Applies to all registration methods (auth key, CLI, web, OIDC). + // Tagged nodes are exempt and never expire. + // A zero/negative duration means no default expiry (nodes never expire). + Expiry time.Duration + + // Ephemeral contains configuration for ephemeral node lifecycle. + Ephemeral EphemeralConfig +} + // Config contains the initial Headscale configuration. type Config struct { - ServerURL string - Addr string - MetricsAddr string - GRPCAddr string - GRPCAllowInsecure bool - EphemeralNodeInactivityTimeout time.Duration - PrefixV4 *netip.Prefix - PrefixV6 *netip.Prefix - IPAllocation IPAllocationStrategy - NoisePrivateKeyPath string - BaseDomain string - Log LogConfig - DisableUpdateCheck bool + ServerURL string + Addr string + MetricsAddr string + GRPCAddr string + GRPCAllowInsecure bool + Node NodeConfig + PrefixV4 *netip.Prefix + PrefixV6 *netip.Prefix + IPAllocation IPAllocationStrategy + NoisePrivateKeyPath string + BaseDomain string + Log LogConfig + DisableUpdateCheck bool Database DatabaseConfig @@ -188,7 +205,6 @@ type OIDCConfig struct { AllowedUsers []string AllowedGroups []string EmailVerifiedRequired bool - Expiry time.Duration UseExpiryFromToken bool PKCE PKCEConfig } @@ -385,7 +401,6 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"}) viper.SetDefault("oidc.only_start_if_oidc_is_available", true) - viper.SetDefault("oidc.expiry", "180d") viper.SetDefault("oidc.use_expiry_from_token", false) viper.SetDefault("oidc.pkce.enabled", false) viper.SetDefault("oidc.pkce.method", "S256") @@ -395,7 +410,8 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("randomize_client_port", false) viper.SetDefault("taildrop.enabled", true) - viper.SetDefault("ephemeral_node_inactivity_timeout", "120s") + viper.SetDefault("node.expiry", "0") + viper.SetDefault("node.ephemeral.inactivity_timeout", "120s") viper.SetDefault("tuning.notifier_send_timeout", "800ms") viper.SetDefault("tuning.batch_change_delay", "800ms") @@ -418,6 +434,51 @@ func LoadConfig(path string, isFile bool) error { return nil } +// resolveEphemeralInactivityTimeout resolves the ephemeral inactivity timeout +// from config, supporting both the new key (node.ephemeral.inactivity_timeout) +// and the old key (ephemeral_node_inactivity_timeout) for backwards compatibility. +// +// We cannot use viper.RegisterAlias here because aliases silently ignore +// config values set under the alias name. If a user writes the new key in +// their config file, RegisterAlias redirects reads to the old key (which +// has no config value), returning only the default and discarding the +// user's setting. +func resolveEphemeralInactivityTimeout() time.Duration { + // New key takes precedence if explicitly set in config. + if viper.IsSet("node.ephemeral.inactivity_timeout") && + viper.GetString("node.ephemeral.inactivity_timeout") != "" { + return viper.GetDuration("node.ephemeral.inactivity_timeout") + } + + // Fall back to old key for backwards compatibility. + if viper.IsSet("ephemeral_node_inactivity_timeout") { + return viper.GetDuration("ephemeral_node_inactivity_timeout") + } + + // Default + return viper.GetDuration("node.ephemeral.inactivity_timeout") +} + +// resolveNodeExpiry parses the node.expiry config value. +// Returns 0 if set to "0" (no default expiry) or on parse failure. +func resolveNodeExpiry() time.Duration { + value := viper.GetString("node.expiry") + if value == "" || value == "0" { + return 0 + } + + expiry, err := model.ParseDuration(value) + if err != nil { + log.Warn(). + Str("value", value). + Msg("failed to parse node.expiry, defaulting to no expiry") + + return 0 + } + + return time.Duration(expiry) +} + func validateServerConfig() error { depr := deprecator{ warns: make(set.Set[string]), @@ -446,6 +507,12 @@ func validateServerConfig() error { depr.fatal("oidc.strip_email_domain") depr.fatal("oidc.map_legacy_users") + // Deprecated: ephemeral_node_inactivity_timeout -> node.ephemeral.inactivity_timeout + depr.warnNoAlias("node.ephemeral.inactivity_timeout", "ephemeral_node_inactivity_timeout") + + // Removed: oidc.expiry -> node.expiry + depr.fatalIfSet("oidc.expiry", "node.expiry") + if viper.GetBool("oidc.enabled") { err := validatePKCEMethod(viper.GetString("oidc.pkce.method")) if err != nil { @@ -491,10 +558,12 @@ func validateServerConfig() error { // Minimum inactivity time out is keepalive timeout (60s) plus a few seconds // to avoid races minInactivityTimeout, _ := time.ParseDuration("65s") - if viper.GetDuration("ephemeral_node_inactivity_timeout") <= minInactivityTimeout { + + ephemeralTimeout := resolveEphemeralInactivityTimeout() + if ephemeralTimeout <= minInactivityTimeout { errorText += fmt.Sprintf( - "Fatal config error: ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s", - viper.GetString("ephemeral_node_inactivity_timeout"), + "Fatal config error: node.ephemeral.inactivity_timeout (%s) is set too low, must be more than %s", + ephemeralTimeout, minInactivityTimeout, ) } @@ -1053,9 +1122,12 @@ func LoadServerConfig() (*Config, error) { DERP: derpConfig, - EphemeralNodeInactivityTimeout: viper.GetDuration( - "ephemeral_node_inactivity_timeout", - ), + Node: NodeConfig{ + Expiry: resolveNodeExpiry(), + Ephemeral: EphemeralConfig{ + InactivityTimeout: resolveEphemeralInactivityTimeout(), + }, + }, Database: databaseConfig(), @@ -1083,22 +1155,7 @@ func LoadServerConfig() (*Config, error) { AllowedUsers: viper.GetStringSlice("oidc.allowed_users"), AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"), EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"), - Expiry: func() time.Duration { - // if set to 0, we assume no expiry - if value := viper.GetString("oidc.expiry"); value == "0" { - return maxDuration - } else { - expiry, err := model.ParseDuration(value) - if err != nil { - log.Warn().Msg("failed to parse oidc.expiry, defaulting back to 180 days") - - return defaultOIDCExpiryTime - } - - return time.Duration(expiry) - } - }(), - UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"), + UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"), PKCE: PKCEConfig{ Enabled: viper.GetBool("oidc.pkce.enabled"), Method: viper.GetString("oidc.pkce.method"), @@ -1233,6 +1290,21 @@ func (d *deprecator) fatalIfNewKeyIsNotUsed(newKey, oldKey string) { } } +// fatalIfSet fatals if the oldKey is set at all, regardless of whether +// the newKey is set. Use this when the old key has been fully removed +// and any use of it should be a hard error. +func (d *deprecator) fatalIfSet(oldKey, newKey string) { + if viper.IsSet(oldKey) { + d.fatals.Add( + fmt.Sprintf( + "The %q configuration key has been removed. Please use %q instead.", + oldKey, + newKey, + ), + ) + } +} + // warn deprecates and adds an option to log a warning if the oldKey is set. // //nolint:unused diff --git a/nix/module.nix b/nix/module.nix index 4dda34df..156a6a35 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -207,10 +207,36 @@ in default = "30m"; description = '' Time before an inactive ephemeral node is deleted. + Deprecated: use node.ephemeral.inactivity_timeout instead. ''; example = "5m"; }; + node = { + expiry = lib.mkOption { + type = lib.types.str; + default = "0"; + description = '' + Default key expiry for non-tagged nodes, regardless of + registration method (auth key, CLI, web auth, OIDC). + Tagged nodes are exempt and never expire. Set to "0" + for no default expiry. + ''; + example = "90d"; + }; + + ephemeral = { + inactivity_timeout = lib.mkOption { + type = lib.types.str; + default = "30m"; + description = '' + Time before an inactive ephemeral node is deleted. + ''; + example = "5m"; + }; + }; + }; + database = { type = lib.mkOption { type = lib.types.enum [