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

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