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
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".

View File

@@ -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
```

View File

@@ -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 != "" {

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 {
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 {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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 [