mirror of
https://github.com/juanfont/headscale
synced 2026-04-25 17:15:33 +02: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
|
# 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".
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
Reference in New Issue
Block a user