types: add node.expiry config, deprecate oidc.expiry

Introduce a structured NodeConfig that replaces the flat
EphemeralNodeInactivityTimeout field with a nested Node section.

Add node.expiry config (default: no expiry) as the unified default key
expiry for all non-tagged nodes regardless of registration method.

Remove oidc.expiry entirely — node.expiry now applies to OIDC nodes
the same as all other registration methods. Using oidc.expiry in the
config is a hard error. determineNodeExpiry() returns nil (no expiry)
unless use_expiry_from_token is enabled, letting state.go apply the
node.expiry default uniformly.

The old ephemeral_node_inactivity_timeout key is preserved for
backwards compatibility.

Updates #1711
This commit is contained in:
Kristoffer Dalby
2026-03-01 22:53:26 +00:00
parent 23a5f1b628
commit 4d0b273b90
7 changed files with 171 additions and 64 deletions

View File

@@ -145,8 +145,25 @@ derp:
# Disables the automatic check for headscale updates on startup # Disables the automatic check for headscale updates on startup
disable_check_updates: false disable_check_updates: false
# Time before an inactive ephemeral node is deleted? # Node lifecycle configuration.
ephemeral_node_inactivity_timeout: 30m 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:
# Database type. Available options: sqlite, postgres # Database type. Available options: sqlite, postgres
@@ -355,15 +372,11 @@ unix_socket_permission: "0770"
# # `LoadCredential` straightforward: # # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" # 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 # # 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 # # in. This will typically lead to frequent need to reauthenticate and should
# # only be enabled if you know what you are doing. # # 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 # use_expiry_from_token: false
# #
# # The OIDC scopes to use, defaults to "openid", "profile" and "email". # # The OIDC scopes to use, defaults to "openid", "profile" and "email".

View File

@@ -145,16 +145,12 @@ oidc:
### Customize node expiration ### Customize node expiration
The node expiration is the amount of time a node is authenticated with OpenID Connect until it expires and needs to 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 reauthenticate. The default node expiration can be configured via the top-level `node.expiry` setting.
Access Token.
=== "Customize node expiration" === "Customize node expiration"
```yaml hl_lines="5" ```yaml hl_lines="2"
oidc: node:
issuer: "https://sso.example.com"
client_id: "headscale"
client_secret: "generated-secret"
expiry: 30d # Use 0 to disable node expiration expiry: 30d # Use 0 to disable node expiration
``` ```

View File

@@ -583,7 +583,7 @@ func (h *Headscale) Serve() error {
ephmNodes := h.state.ListEphemeralNodes() ephmNodes := h.state.ListEphemeralNodes()
for _, node := range ephmNodes.All() { 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 != "" { if h.cfg.DNSConfig.ExtraRecordsPath != "" {

View File

@@ -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 { if a.cfg.UseExpiryFromToken {
return idTokenExpiration return &idTokenExpiration
} }
return time.Now().Add(a.cfg.Expiry) return nil
} }
func extractCodeAndStateParamFromRequest( func extractCodeAndStateParamFromRequest(
@@ -602,12 +602,12 @@ func (a *AuthProviderOIDC) createOrUpdateUserFromClaim(
func (a *AuthProviderOIDC) handleRegistration( func (a *AuthProviderOIDC) handleRegistration(
user *types.User, user *types.User,
registrationID types.AuthID, registrationID types.AuthID,
expiry time.Time, expiry *time.Time,
) (bool, error) { ) (bool, error) {
node, nodeChange, err := a.h.state.HandleNodeFromAuthPath( node, nodeChange, err := a.h.state.HandleNodeFromAuthPath(
registrationID, registrationID,
types.UserID(user.ID), types.UserID(user.ID),
&expiry, expiry,
util.RegisterMethodOIDC, util.RegisterMethodOIDC,
) )
if err != nil { if err != nil {

View File

@@ -106,7 +106,7 @@ func (m *mapSession) beforeServeLongPoll() {
// is disconnected. // is disconnected.
func (m *mapSession) afterServeLongPoll() { func (m *mapSession) afterServeLongPoll() {
if m.node.IsEphemeral() { 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)
} }
} }

View File

@@ -24,10 +24,8 @@ import (
) )
const ( const (
defaultOIDCExpiryTime = 180 * 24 * time.Hour // 180 Days PKCEMethodPlain string = "plain"
maxDuration time.Duration = 1<<63 - 1 PKCEMethodS256 string = "S256"
PKCEMethodPlain string = "plain"
PKCEMethodS256 string = "S256"
defaultNodeStoreBatchSize = 100 defaultNodeStoreBatchSize = 100
) )
@@ -55,21 +53,40 @@ const (
PolicyModeFile = "file" 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. // Config contains the initial Headscale configuration.
type Config struct { type Config struct {
ServerURL string ServerURL string
Addr string Addr string
MetricsAddr string MetricsAddr string
GRPCAddr string GRPCAddr string
GRPCAllowInsecure bool GRPCAllowInsecure bool
EphemeralNodeInactivityTimeout time.Duration Node NodeConfig
PrefixV4 *netip.Prefix PrefixV4 *netip.Prefix
PrefixV6 *netip.Prefix PrefixV6 *netip.Prefix
IPAllocation IPAllocationStrategy IPAllocation IPAllocationStrategy
NoisePrivateKeyPath string NoisePrivateKeyPath string
BaseDomain string BaseDomain string
Log LogConfig Log LogConfig
DisableUpdateCheck bool DisableUpdateCheck bool
Database DatabaseConfig Database DatabaseConfig
@@ -188,7 +205,6 @@ type OIDCConfig struct {
AllowedUsers []string AllowedUsers []string
AllowedGroups []string AllowedGroups []string
EmailVerifiedRequired bool EmailVerifiedRequired bool
Expiry time.Duration
UseExpiryFromToken bool UseExpiryFromToken bool
PKCE PKCEConfig 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.scope", []string{oidc.ScopeOpenID, "profile", "email"})
viper.SetDefault("oidc.only_start_if_oidc_is_available", true) 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.use_expiry_from_token", false)
viper.SetDefault("oidc.pkce.enabled", false) viper.SetDefault("oidc.pkce.enabled", false)
viper.SetDefault("oidc.pkce.method", "S256") 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("randomize_client_port", false)
viper.SetDefault("taildrop.enabled", true) 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.notifier_send_timeout", "800ms")
viper.SetDefault("tuning.batch_change_delay", "800ms") viper.SetDefault("tuning.batch_change_delay", "800ms")
@@ -418,6 +434,51 @@ func LoadConfig(path string, isFile bool) error {
return nil 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 { func validateServerConfig() error {
depr := deprecator{ depr := deprecator{
warns: make(set.Set[string]), warns: make(set.Set[string]),
@@ -446,6 +507,12 @@ func validateServerConfig() error {
depr.fatal("oidc.strip_email_domain") depr.fatal("oidc.strip_email_domain")
depr.fatal("oidc.map_legacy_users") 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") { if viper.GetBool("oidc.enabled") {
err := validatePKCEMethod(viper.GetString("oidc.pkce.method")) err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
if err != nil { if err != nil {
@@ -491,10 +558,12 @@ func validateServerConfig() error {
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds // Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
// to avoid races // to avoid races
minInactivityTimeout, _ := time.ParseDuration("65s") minInactivityTimeout, _ := time.ParseDuration("65s")
if viper.GetDuration("ephemeral_node_inactivity_timeout") <= minInactivityTimeout {
ephemeralTimeout := resolveEphemeralInactivityTimeout()
if ephemeralTimeout <= minInactivityTimeout {
errorText += fmt.Sprintf( errorText += fmt.Sprintf(
"Fatal config error: ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s", "Fatal config error: node.ephemeral.inactivity_timeout (%s) is set too low, must be more than %s",
viper.GetString("ephemeral_node_inactivity_timeout"), ephemeralTimeout,
minInactivityTimeout, minInactivityTimeout,
) )
} }
@@ -1053,9 +1122,12 @@ func LoadServerConfig() (*Config, error) {
DERP: derpConfig, DERP: derpConfig,
EphemeralNodeInactivityTimeout: viper.GetDuration( Node: NodeConfig{
"ephemeral_node_inactivity_timeout", Expiry: resolveNodeExpiry(),
), Ephemeral: EphemeralConfig{
InactivityTimeout: resolveEphemeralInactivityTimeout(),
},
},
Database: databaseConfig(), Database: databaseConfig(),
@@ -1083,22 +1155,7 @@ func LoadServerConfig() (*Config, error) {
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"), AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"), AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"), EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"),
Expiry: func() time.Duration { UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"),
// 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"),
PKCE: PKCEConfig{ PKCE: PKCEConfig{
Enabled: viper.GetBool("oidc.pkce.enabled"), Enabled: viper.GetBool("oidc.pkce.enabled"),
Method: viper.GetString("oidc.pkce.method"), 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. // warn deprecates and adds an option to log a warning if the oldKey is set.
// //
//nolint:unused //nolint:unused

View File

@@ -207,10 +207,36 @@ in
default = "30m"; default = "30m";
description = '' description = ''
Time before an inactive ephemeral node is deleted. Time before an inactive ephemeral node is deleted.
Deprecated: use node.ephemeral.inactivity_timeout instead.
''; '';
example = "5m"; 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 = { database = {
type = lib.mkOption { type = lib.mkOption {
type = lib.types.enum [ type = lib.types.enum [