mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-09 06:27:48 +09:00
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:
@@ -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".
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user