Files
headscale/hscontrol/types/config.go
T
Kristoffer Dalby 8a12c572ef types: route deprecation fatals through configValidator
deprecator.Log() called log.Fatal directly, so a single deprecated
key killed the process before any other validation rule could
report. Operators fixing one issue at a time.

Replace Log() with Apply(*configValidator). Warns still go to
log.Warn; fatals are pushed onto the validator as *ConfigError so
they merge with the rest of the config-load report. The free-form
strings collected via set.Set are gone; deprecator now stores
typed deprecation{OldKey, NewKey} records and Apply renders them
into the structured ConfigError shape.

Move the early-return PKCE check into a new validatePKCEConfig
modular validator for the same reason.

Updates #3227
2026-09-23 15:18:34 +02:00

1665 lines
52 KiB
Go

package types
import (
"errors"
"fmt"
"io/fs"
"net/netip"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/prometheus/common/model"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"go4.org/netipx"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
"tailscale.com/util/set"
)
const (
PKCEMethodPlain string = "plain"
PKCEMethodS256 string = "S256"
defaultNodeStoreBatchSize = 100
)
var (
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errOIDCIssuerInvalid = errors.New("oidc.issuer must be a valid http(s) URL")
errOIDCClientIDRequired = errors.New("oidc.client_id is required when oidc.issuer is set")
errOIDCClientSecretRequired = errors.New("oidc.client_secret or oidc.client_secret_path is required when oidc.issuer is set")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
errTrustedProxyZeroRange = errors.New("0.0.0.0/0 and ::/0 are not allowed")
ErrNoPrefixConfigured = errors.New("no IPv4 or IPv6 prefix configured, minimum one prefix is required")
ErrInvalidAllocationStrategy = errors.New("invalid prefix allocation strategy")
)
type IPAllocationStrategy string
const (
IPAllocationStrategySequential IPAllocationStrategy = "sequential"
IPAllocationStrategyRandom IPAllocationStrategy = "random"
)
type PolicyMode string
const (
PolicyModeDB = "database"
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
}
// HARouteConfig contains configuration for HA subnet router health probing.
type HARouteConfig struct {
// ProbeInterval is how often HA subnet routers are probed.
// A zero or negative duration disables probing.
ProbeInterval time.Duration
// ProbeTimeout is the maximum time to wait for a probe response
// before declaring a node unhealthy. Must be less than [HARouteConfig.ProbeInterval].
ProbeTimeout time.Duration
}
// RouteConfig contains configuration for route behaviour.
type RouteConfig struct {
HA HARouteConfig
}
// PreAuthKeysConfig contains configuration for pre-auth key lifecycle.
type PreAuthKeysConfig struct {
// RevokedRetention is how long a soft-revoked pre-auth key (revoked via the
// v2 API's DELETE) is kept retrievable before the background collector
// hard-deletes it. A zero or negative duration disables the collector.
RevokedRetention 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
// Routes contains configuration for route behaviour.
Routes RouteConfig
}
// Config contains the initial Headscale configuration.
type Config struct {
ServerURL string
Addr string
MetricsAddr string
TrustedProxies []netip.Prefix
Node NodeConfig
PreAuthKeys PreAuthKeysConfig
PrefixV4 *netip.Prefix
PrefixV6 *netip.Prefix
IPAllocation IPAllocationStrategy
NoisePrivateKeyPath string
BaseDomain string
Log LogConfig
DisableUpdateCheck bool
Database DatabaseConfig
DERP DERPConfig
TLS TLSConfig
ACMEURL string
ACMEEmail string
// DNSConfig is the headscale representation of the DNS configuration.
// It is kept in the config update for some settings that are
// not directly converted into a [tailcfg.DNSConfig].
DNSConfig DNSConfig
// TailcfgDNSConfig is the tailcfg representation of the DNS configuration,
// it can be used directly when sending Netmaps to clients.
TailcfgDNSConfig *tailcfg.DNSConfig
UnixSocket string
UnixSocketPermission fs.FileMode
OIDC OIDCConfig
LogTail LogTailConfig
Taildrop TaildropConfig
AutoUpdate AutoUpdateConfig
CLI CLIConfig
Policy PolicyConfig
Tuning Tuning
}
type DNSConfig struct {
MagicDNS bool `mapstructure:"magic_dns"`
BaseDomain string `mapstructure:"base_domain"`
OverrideLocalDNS bool `mapstructure:"override_local_dns"`
Nameservers Nameservers
SearchDomains []string `mapstructure:"search_domains"`
ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"`
ExtraRecordsPath string `mapstructure:"extra_records_path"`
}
type Nameservers struct {
Global []string
Split map[string][]string
}
type SqliteConfig struct {
Path string
WriteAheadLog bool
WALAutoCheckPoint int
}
type PostgresConfig struct {
Host string
Port int
Name string
User string
Pass string `json:"-"` // never serialise the database password
Ssl string
MaxOpenConnections int
MaxIdleConnections int
ConnMaxIdleTimeSecs int
}
type GormConfig struct {
Debug bool
SlowThreshold time.Duration
SkipErrRecordNotFound bool
ParameterizedQueries bool
PrepareStmt bool
}
type DatabaseConfig struct {
// Type sets the database type, either "sqlite3" or "postgres"
Type string
Debug bool
// Type sets the gorm configuration
Gorm GormConfig
Sqlite SqliteConfig
Postgres PostgresConfig
}
type TLSConfig struct {
CertPath string
KeyPath string
LetsEncrypt LetsEncryptConfig
}
type LetsEncryptConfig struct {
Listen string
Hostname string
CacheDir string
ChallengeType string
}
type PKCEConfig struct {
Enabled bool
Method string
}
type OIDCConfig struct {
OnlyStartIfOIDCIsAvailable bool
Issuer string
ClientID string
ClientSecret string `json:"-"` // never serialise the OIDC client secret
Scope []string
ExtraParams map[string]string
AllowedDomains []string
AllowedUsers []string
AllowedGroups []string
EmailVerifiedRequired bool
UseExpiryFromToken bool
PKCE PKCEConfig
}
type DERPConfig struct {
ServerEnabled bool
AutomaticallyAddEmbeddedDerpRegion bool
ServerRegionID tailcfg.DERPRegionID
ServerRegionCode string
ServerRegionName string
ServerPrivateKeyPath string
ServerVerifyClients bool
STUNAddr string
URLs []url.URL
Paths []string
DERPMap *tailcfg.DERPMap
AutoUpdate bool
UpdateFrequency time.Duration
IPv4 string
IPv6 string
}
type LogTailConfig struct {
Enabled bool
}
type TaildropConfig struct {
Enabled bool
}
// AutoUpdateConfig controls the tailnet-wide default for client
// auto-update. When Enabled is true, headscale emits the
// [tailcfg.NodeAttrDefaultAutoUpdate] cap with value [true] on every
// node's CapMap; clients fall back to that default unless they have
// opted in or out locally.
type AutoUpdateConfig struct {
Enabled bool
}
type CLIConfig struct {
Address string
APIKey string `json:"-"` // never serialise the headscale admin API key
Timeout time.Duration
Insecure bool
}
type PolicyConfig struct {
Path string
Mode PolicyMode
}
func (p *PolicyConfig) IsEmpty() bool {
return p.Mode == PolicyModeFile && p.Path == ""
}
type LogConfig struct {
Format string
Level zerolog.Level
}
// Tuning contains advanced performance tuning parameters for Headscale.
// These settings control internal batching, timeouts, and resource allocation.
// The defaults are carefully chosen for typical deployments and should rarely
// need adjustment. Changes to these values can significantly impact performance
// and resource usage.
type Tuning struct {
// NotifierSendTimeout is the maximum time to wait when sending notifications
// to connected clients about network changes.
NotifierSendTimeout time.Duration
// BatchChangeDelay controls how long to wait before sending batched updates
// to clients when multiple changes occur in rapid succession.
BatchChangeDelay time.Duration
// NodeMapSessionBufferedChanSize sets the buffer size for the channel that
// queues map updates to be sent to connected clients.
NodeMapSessionBufferedChanSize int
// BatcherWorkers controls the number of parallel workers processing map
// updates for connected clients.
BatcherWorkers int
// RegisterCacheExpiration is how long registration cache entries remain
// valid before being eligible for eviction.
RegisterCacheExpiration time.Duration
// RegisterCacheMaxEntries bounds the number of pending registration
// entries the auth cache will hold. Older entries are evicted (LRU)
// when the cap is reached, preventing unauthenticated cache-fill DoS.
// A value of 0 falls back to defaultRegisterCacheMaxEntries (1024).
RegisterCacheMaxEntries int
// NodeStoreBatchSize controls how many write operations are accumulated
// before rebuilding the in-memory node snapshot.
//
// The NodeStore batches write operations (add/update/delete nodes) before
// rebuilding its in-memory data structures. Rebuilding involves recalculating
// peer relationships between all nodes based on the current ACL policy, which
// is computationally expensive and scales with the square of the number of nodes.
//
// By batching writes, Headscale can process N operations but only rebuild once,
// rather than rebuilding N times. This significantly reduces CPU usage during
// bulk operations like initial sync or policy updates.
//
// Trade-off: Higher values reduce CPU usage from rebuilds but increase latency
// for individual operations waiting for their batch to complete.
NodeStoreBatchSize int
// NodeStoreBatchTimeout is the maximum time to wait before processing a
// partial batch of node operations.
//
// When [Tuning.NodeStoreBatchSize] operations haven't accumulated, this timeout ensures
// writes don't wait indefinitely. The batch processes when either the size
// threshold is reached OR this timeout expires, whichever comes first.
//
// Trade-off: Lower values provide faster response for individual operations
// but trigger more frequent (expensive) peer map rebuilds. Higher values
// optimize for bulk throughput at the cost of individual operation latency.
NodeStoreBatchTimeout time.Duration
}
func validatePKCEMethod(method string) error {
if method != PKCEMethodPlain && method != PKCEMethodS256 {
return errInvalidPKCEMethod
}
return nil
}
// validateOIDCConfig validates the OIDC settings, called when oidc.issuer is
// set. It fails fast on a setup that cannot work: an invalid PKCE method, a
// malformed issuer URL (which would otherwise surface as an opaque discovery
// error or, worse, resolve to an unintended provider), or a missing client
// id/secret.
func validateOIDCConfig() error {
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
if err != nil {
return err
}
issuer := viper.GetString("oidc.issuer")
u, err := url.Parse(issuer)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return fmt.Errorf("%w: got %q", errOIDCIssuerInvalid, issuer)
}
if viper.GetString("oidc.client_id") == "" {
return errOIDCClientIDRequired
}
if viper.GetString("oidc.client_secret") == "" && viper.GetString("oidc.client_secret_path") == "" {
return errOIDCClientSecretRequired
}
return nil
}
// Domain returns the hostname/domain part of the [Config.ServerURL].
// If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain].
func (c *Config) Domain() string {
u, err := url.Parse(c.ServerURL)
if err != nil {
return c.BaseDomain
}
return u.Hostname()
}
// LoadConfig prepares and loads the Headscale configuration into Viper.
// This means it sets the default values, reads the configuration file and
// environment variables, and handles deprecated configuration options.
// It has to be called before [LoadServerConfig] and [LoadCLIConfig].
// The configuration is not validated and the caller should check for errors
// using a validation function.
func LoadConfig(path string, isFile bool) error {
if isFile {
viper.SetConfigFile(path)
} else {
viper.SetConfigName("config")
if path == "" {
viper.AddConfigPath("/etc/headscale/")
viper.AddConfigPath("$HOME/.headscale")
viper.AddConfigPath(".")
} else {
// For testing
viper.AddConfigPath(path)
}
}
envPrefix := "headscale"
viper.SetEnvPrefix(envPrefix)
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
viper.SetDefault("policy.mode", "file")
viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache")
viper.SetDefault("tls_letsencrypt_challenge_type", HTTP01ChallengeType)
viper.SetDefault("tls_letsencrypt_listen", ":http")
viper.SetDefault("log.level", "info")
viper.SetDefault("log.format", TextLogFormat)
viper.SetDefault("dns.magic_dns", true)
viper.SetDefault("dns.base_domain", "")
viper.SetDefault("dns.override_local_dns", true)
viper.SetDefault("dns.nameservers.global", []string{})
viper.SetDefault("dns.nameservers.split", map[string]string{})
viper.SetDefault("dns.search_domains", []string{})
viper.SetDefault("derp.server.enabled", false)
viper.SetDefault("derp.server.verify_clients", true)
viper.SetDefault("derp.server.stun.enabled", true)
viper.SetDefault("derp.server.automatically_add_embedded_derp_region", true)
viper.SetDefault("derp.update_frequency", "3h")
viper.SetDefault("unix_socket", "/var/run/headscale/headscale.sock")
viper.SetDefault("unix_socket_permission", "0o770")
viper.SetDefault("cli.timeout", "5s")
viper.SetDefault("cli.insecure", false)
viper.SetDefault("database.postgres.ssl", false)
viper.SetDefault("database.postgres.max_open_conns", 10)
viper.SetDefault("database.postgres.max_idle_conns", 10)
viper.SetDefault("database.postgres.conn_max_idle_time_secs", 3600)
viper.SetDefault("database.sqlite.write_ahead_log", true)
viper.SetDefault("database.sqlite.wal_autocheckpoint", 1000) // SQLite default
viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
viper.SetDefault("oidc.only_start_if_oidc_is_available", true)
viper.SetDefault("oidc.use_expiry_from_token", false)
viper.SetDefault("oidc.pkce.enabled", false)
viper.SetDefault("oidc.pkce.method", "S256")
viper.SetDefault("oidc.email_verified_required", true)
viper.SetDefault("logtail.enabled", false)
viper.SetDefault("taildrop.enabled", true)
viper.SetDefault("auto_update.enabled", false)
viper.SetDefault("node.expiry", "0")
viper.SetDefault("node.ephemeral.inactivity_timeout", "120s")
viper.SetDefault("preauth_keys.revoked_retention", "168h")
viper.SetDefault("node.routes.ha.probe_interval", "10s")
viper.SetDefault("node.routes.ha.probe_timeout", "5s")
viper.SetDefault("tuning.notifier_send_timeout", "800ms")
viper.SetDefault("tuning.batch_change_delay", "800ms")
viper.SetDefault("tuning.node_mapsession_buffered_chan_size", 30)
viper.SetDefault("tuning.node_store_batch_size", defaultNodeStoreBatchSize)
viper.SetDefault("tuning.node_store_batch_timeout", "500ms")
viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential))
err := viper.ReadInConfig()
if err != nil {
if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok {
log.Warn().Msg("no config file found, using defaults")
return nil
}
return fmt.Errorf("fatal error reading config file: %w", err)
}
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 {
v := &configValidator{}
depr := deprecator{seen: make(set.Set[string])}
// Register aliases for backward compatibility
// Has to be called _after_ viper.ReadInConfig()
// https://github.com/spf13/viper/issues/560
// Alias the old ACL Policy path with the new configuration option.
depr.fatalIfNewKeyIsNotUsed("policy.path", "acl_policy_path")
// Move dns_config -> dns
depr.fatalIfNewKeyIsNotUsed("dns.magic_dns", "dns_config.magic_dns")
depr.fatalIfNewKeyIsNotUsed("dns.base_domain", "dns_config.base_domain")
depr.fatalIfNewKeyIsNotUsed("dns.override_local_dns", "dns_config.override_local_dns")
depr.fatalIfNewKeyIsNotUsed("dns.nameservers.global", "dns_config.nameservers")
depr.fatalIfNewKeyIsNotUsed("dns.nameservers.split", "dns_config.restricted_nameservers")
depr.fatalIfNewKeyIsNotUsed("dns.search_domains", "dns_config.domains")
depr.fatalIfNewKeyIsNotUsed("dns.extra_records", "dns_config.extra_records")
depr.fatal("dns.use_username_in_magic_dns")
depr.fatal("dns_config.use_username_in_magic_dns")
// Removed since version v0.26.0
depr.fatal("oidc.strip_email_domain")
depr.fatal("oidc.map_legacy_users")
// Removed since v0.29.0: `randomize_client_port` moved to the ACL
// policy as a top-level `randomizeClientPort` field, matching the
// Tailscale-hosted control plane schema. Per-node `nodeAttrs`
// entries granting `https://tailscale.com/cap/randomize-client-port`
// also work.
depr.fatalWithHint("randomize_client_port",
`Set "randomizeClientPort": true at the top level of your policy file `+
`(see policy.path / policy.mode), or grant the cap per-node via a `+
`"nodeAttrs" entry. See CHANGELOG.md (BREAKING / Configuration).`)
// 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")
// OIDC is activated by setting oidc.issuer (see app.go), not by a
// dedicated oidc.enabled key. Gate validation on the real activation
// condition so a misconfiguration fails at startup.
if viper.GetString("oidc.issuer") != "" {
err := validateOIDCConfig()
if err != nil {
return err
}
}
if viper.IsSet("dns.extra_records") && viper.IsSet("dns.extra_records_path") {
v.Add(&ConfigError{
Reason: "dns.extra_records and dns.extra_records_path are mutually exclusive",
Current: []KV{
{"dns.extra_records_path", viper.GetString("dns.extra_records_path")},
{"dns.extra_records", "<inline records>"},
},
Hint: "keep one (a path is recommended for production); remove the other",
})
}
if (viper.GetString("tls_letsencrypt_hostname") != "") &&
((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) {
v.Add(&ConfigError{
Reason: "tls_letsencrypt_hostname and tls_cert_path/tls_key_path are mutually exclusive",
Current: []KV{{"tls_letsencrypt_hostname", viper.GetString("tls_letsencrypt_hostname")}},
ConflictsWith: []KV{
{"tls_cert_path", viper.GetString("tls_cert_path")},
{"tls_key_path", viper.GetString("tls_key_path")},
},
Hint: "choose one TLS strategy and unset the other (Let's Encrypt OR a static keypair)",
See: "https://headscale.net/stable/ref/tls/",
})
}
if viper.GetString("noise.private_key_path") == "" {
v.Add(&ConfigError{
Reason: "noise.private_key_path is required",
Hint: "set noise.private_key_path: /var/lib/headscale/noise_private.key (or any writable path)",
See: "https://headscale.net/stable/setup/install/official/",
})
}
if (viper.GetString("tls_letsencrypt_hostname") != "") &&
(viper.GetString("tls_letsencrypt_challenge_type") == TLSALPN01ChallengeType) &&
(!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) {
// this is only a warning because there could be something sitting in front of headscale that redirects the traffic (e.g. an iptables rule)
log.Warn().
Msg("Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443")
}
validateListenerCollisions(v)
if ct := viper.GetString("tls_letsencrypt_challenge_type"); ct != HTTP01ChallengeType && ct != TLSALPN01ChallengeType {
v.Add(&ConfigError{
Reason: "tls_letsencrypt_challenge_type has an unsupported value",
Current: []KV{{"tls_letsencrypt_challenge_type", ct}},
Allowed: []string{HTTP01ChallengeType, TLSALPN01ChallengeType},
Hint: "pick one of the allowed values; HTTP-01 is the default",
})
}
serverURL := viper.GetString("server_url")
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
v.Add(&ConfigError{
Reason: "server_url is missing a scheme",
Current: []KV{{"server_url", serverURL}},
Hint: "prefix the URL with https:// (recommended) or http://",
})
}
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
// to avoid races
minInactivityTimeout := 65 * time.Second
ephemeralTimeout := resolveEphemeralInactivityTimeout()
if ephemeralTimeout <= minInactivityTimeout {
v.Add(&ConfigError{
Reason: "node.ephemeral.inactivity_timeout is below the minimum",
Current: []KV{{"node.ephemeral.inactivity_timeout", ephemeralTimeout.String()}},
Minimum: minInactivityTimeout.String(),
Hint: "raise the value above the keepalive interval (60s) plus a safety margin",
})
}
if viper.GetBool("dns.override_local_dns") {
if global := viper.GetStringSlice("dns.nameservers.global"); len(global) == 0 {
v.Add(&ConfigError{
Reason: "dns.nameservers.global is required when dns.override_local_dns is true",
Current: []KV{
{"dns.override_local_dns", true},
{"dns.nameservers.global", "[]"},
},
Hint: "list at least one upstream nameserver, or set dns.override_local_dns: false",
See: "https://headscale.net/stable/ref/dns/",
})
}
}
// Validate HA health probing parameters
if haInterval := viper.GetDuration("node.routes.ha.probe_interval"); haInterval > 0 {
if haInterval < 2*time.Second {
v.Add(&ConfigError{
Reason: "node.routes.ha.probe_interval is below the minimum",
Current: []KV{{"node.routes.ha.probe_interval", haInterval.String()}},
Minimum: "2s",
Hint: "raise the value to at least the minimum",
})
}
haTimeout := viper.GetDuration("node.routes.ha.probe_timeout")
if haTimeout < 1*time.Second {
v.Add(&ConfigError{
Reason: "node.routes.ha.probe_timeout is below the minimum",
Current: []KV{{"node.routes.ha.probe_timeout", haTimeout.String()}},
Minimum: "1s",
Hint: "raise the value to at least the minimum",
})
}
if haTimeout >= haInterval {
v.Add(&ConfigError{
Reason: "node.routes.ha.probe_timeout must be less than node.routes.ha.probe_interval",
Current: []KV{
{"node.routes.ha.probe_timeout", haTimeout.String()},
{"node.routes.ha.probe_interval", haInterval.String()},
},
Hint: "lower probe_timeout below probe_interval (a probe must finish before the next one starts)",
})
}
}
// Validate tuning parameters
if size := viper.GetInt("tuning.node_store_batch_size"); size <= 0 {
v.Add(&ConfigError{
Reason: "tuning.node_store_batch_size must be positive",
Current: []KV{{"tuning.node_store_batch_size", size}},
Hint: fmt.Sprintf("set to a positive integer (default: %d)", defaultNodeStoreBatchSize),
})
}
if timeout := viper.GetDuration("tuning.node_store_batch_timeout"); timeout <= 0 {
v.Add(&ConfigError{
Reason: "tuning.node_store_batch_timeout must be positive",
Current: []KV{{"tuning.node_store_batch_timeout", timeout.String()}},
Hint: "set to a positive duration (default: 500ms)",
})
}
validateDERPConfig(v)
validateDatabaseConfig(v)
validateMagicDNSConfig(v)
validatePKCEConfig(v)
depr.Apply(v)
return v.Err()
}
// validatePKCEConfig records a ConfigError when oidc.enabled is true
// and oidc.pkce.method is not one of the allowed values.
func validatePKCEConfig(v *configValidator) {
if !viper.GetBool("oidc.enabled") {
return
}
method := viper.GetString("oidc.pkce.method")
err := validatePKCEMethod(method)
if err != nil {
v.Add(&ConfigError{
Reason: "oidc.pkce.method has an unsupported value",
Current: []KV{{"oidc.pkce.method", method}},
Allowed: []string{PKCEMethodPlain, PKCEMethodS256},
Hint: "pick one of the allowed values; S256 is recommended",
Cause: err,
})
}
}
// validateDERPConfig records ConfigErrors when the embedded DERP server
// is enabled without the addresses or paths it needs.
func validateDERPConfig(v *configValidator) {
if !viper.GetBool("derp.server.enabled") {
return
}
if viper.GetString("derp.server.stun_listen_addr") == "" {
v.Add(&ConfigError{
Reason: "derp.server.stun_listen_addr is required when the embedded DERP server is enabled",
Current: []KV{
{"derp.server.enabled", true},
{"derp.server.stun_listen_addr", ""},
},
Hint: `set derp.server.stun_listen_addr (e.g. "0.0.0.0:3478"), or set derp.server.enabled: false`,
See: "https://headscale.net/stable/ref/integration/derp/",
})
}
if !viper.GetBool("derp.server.automatically_add_embedded_derp_region") &&
len(viper.GetStringSlice("derp.paths")) == 0 {
v.Add(&ConfigError{
Reason: "derp.paths is required when derp.server.automatically_add_embedded_derp_region is false",
Current: []KV{
{"derp.server.automatically_add_embedded_derp_region", false},
{"derp.paths", "[]"},
},
Hint: "list at least one DERP map JSON file in derp.paths, or set automatically_add_embedded_derp_region: true",
})
}
}
// validateDatabaseConfig records a ConfigError when database.type is not
// one of the supported backends.
func validateDatabaseConfig(v *configValidator) {
t := viper.GetString("database.type")
switch t {
case DatabaseSqlite, DatabasePostgres, "sqlite":
return
}
v.Add(&ConfigError{
Reason: "database.type has an unsupported value",
Current: []KV{{"database.type", t}},
Allowed: []string{"sqlite", "sqlite3", "postgres"},
Hint: "pick one of the allowed values; sqlite is the default for single-host deployments",
See: "https://headscale.net/stable/ref/database/",
})
}
// validateMagicDNSConfig records a ConfigError when MagicDNS is enabled
// without a base_domain.
func validateMagicDNSConfig(v *configValidator) {
if !viper.GetBool("dns.magic_dns") {
return
}
if viper.GetString("dns.base_domain") == "" {
v.Add(&ConfigError{
Reason: "dns.base_domain is required when dns.magic_dns is true",
Current: []KV{
{"dns.magic_dns", true},
{"dns.base_domain", ""},
},
Hint: `set dns.base_domain to a domain you control (e.g. "ts.example.net"), or set dns.magic_dns: false`,
See: "https://headscale.net/stable/ref/dns/",
})
}
}
func tlsConfig() TLSConfig {
return TLSConfig{
LetsEncrypt: LetsEncryptConfig{
Hostname: viper.GetString("tls_letsencrypt_hostname"),
Listen: viper.GetString("tls_letsencrypt_listen"),
CacheDir: util.AbsolutePathFromConfigPath(
viper.GetString("tls_letsencrypt_cache_dir"),
),
ChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
},
CertPath: util.AbsolutePathFromConfigPath(
viper.GetString("tls_cert_path"),
),
KeyPath: util.AbsolutePathFromConfigPath(
viper.GetString("tls_key_path"),
),
}
}
func derpConfig() DERPConfig {
serverEnabled := viper.GetBool("derp.server.enabled")
serverRegionID := viper.GetInt64("derp.server.region_id")
serverRegionCode := viper.GetString("derp.server.region_code")
serverRegionName := viper.GetString("derp.server.region_name")
serverVerifyClients := viper.GetBool("derp.server.verify_clients")
stunAddr := viper.GetString("derp.server.stun_listen_addr")
privateKeyPath := util.AbsolutePathFromConfigPath(
viper.GetString("derp.server.private_key_path"),
)
ipv4 := viper.GetString("derp.server.ipv4")
ipv6 := viper.GetString("derp.server.ipv6")
automaticallyAddEmbeddedDerpRegion := viper.GetBool(
"derp.server.automatically_add_embedded_derp_region",
)
urlStrs := viper.GetStringSlice("derp.urls")
urls := make([]url.URL, 0, len(urlStrs))
for _, urlStr := range urlStrs {
urlAddr, err := url.Parse(urlStr)
if err != nil {
log.Error().
Caller().
Str("url", urlStr).
Err(err).
Msg("Failed to parse url, ignoring...")
continue
}
urls = append(urls, *urlAddr)
}
paths := viper.GetStringSlice("derp.paths")
autoUpdate := viper.GetBool("derp.auto_update_enabled")
updateFrequency := viper.GetDuration("derp.update_frequency")
return DERPConfig{
ServerEnabled: serverEnabled,
ServerRegionID: tailcfg.DERPRegionID(serverRegionID),
ServerRegionCode: serverRegionCode,
ServerRegionName: serverRegionName,
ServerVerifyClients: serverVerifyClients,
ServerPrivateKeyPath: privateKeyPath,
STUNAddr: stunAddr,
URLs: urls,
Paths: paths,
AutoUpdate: autoUpdate,
UpdateFrequency: updateFrequency,
IPv4: ipv4,
IPv6: ipv6,
AutomaticallyAddEmbeddedDerpRegion: automaticallyAddEmbeddedDerpRegion,
}
}
func logtailConfig() LogTailConfig {
enabled := viper.GetBool("logtail.enabled")
return LogTailConfig{
Enabled: enabled,
}
}
func policyConfig() PolicyConfig {
policyPath := viper.GetString("policy.path")
policyMode := viper.GetString("policy.mode")
return PolicyConfig{
Path: policyPath,
Mode: PolicyMode(policyMode),
}
}
func logConfig() LogConfig {
logLevelStr := viper.GetString("log.level")
logLevel, err := zerolog.ParseLevel(logLevelStr)
if err != nil {
logLevel = zerolog.DebugLevel
}
logFormatOpt := viper.GetString("log.format")
var logFormat string
switch logFormatOpt {
case JSONLogFormat:
logFormat = JSONLogFormat
case TextLogFormat:
logFormat = TextLogFormat
case "":
logFormat = TextLogFormat
default:
log.Error().
Caller().
Str("func", "GetLogConfig").
Msgf("Could not parse log format: %s. Valid choices are 'json' or 'text'", logFormatOpt)
}
return LogConfig{
Format: logFormat,
Level: logLevel,
}
}
func databaseConfig() DatabaseConfig {
debug := viper.GetBool("database.debug")
type_ := viper.GetString("database.type")
skipErrRecordNotFound := viper.GetBool("database.gorm.skip_err_record_not_found")
slowThreshold := time.Duration(viper.GetInt64("database.gorm.slow_threshold")) * time.Millisecond
parameterizedQueries := viper.GetBool("database.gorm.parameterized_queries")
prepareStmt := viper.GetBool("database.gorm.prepare_stmt")
switch type_ {
case DatabaseSqlite, DatabasePostgres:
break
case "sqlite":
type_ = "sqlite3"
}
return DatabaseConfig{
Type: type_,
Debug: debug,
Gorm: GormConfig{
Debug: debug,
SkipErrRecordNotFound: skipErrRecordNotFound,
SlowThreshold: slowThreshold,
ParameterizedQueries: parameterizedQueries,
PrepareStmt: prepareStmt,
},
Sqlite: SqliteConfig{
Path: util.AbsolutePathFromConfigPath(
viper.GetString("database.sqlite.path"),
),
WriteAheadLog: viper.GetBool("database.sqlite.write_ahead_log"),
WALAutoCheckPoint: viper.GetInt("database.sqlite.wal_autocheckpoint"),
},
Postgres: PostgresConfig{
Host: viper.GetString("database.postgres.host"),
Port: viper.GetInt("database.postgres.port"),
Name: viper.GetString("database.postgres.name"),
User: viper.GetString("database.postgres.user"),
Pass: viper.GetString("database.postgres.pass"),
Ssl: viper.GetString("database.postgres.ssl"),
MaxOpenConnections: viper.GetInt("database.postgres.max_open_conns"),
MaxIdleConnections: viper.GetInt("database.postgres.max_idle_conns"),
ConnMaxIdleTimeSecs: viper.GetInt(
"database.postgres.conn_max_idle_time_secs",
),
},
}
}
func dns() (DNSConfig, error) {
var dns DNSConfig
// TODO: Use this instead of manually getting settings when
// UnmarshalKey is compatible with Environment Variables.
// err := viper.UnmarshalKey("dns", &dns)
// if err != nil {
// return DNSConfig{}, fmt.Errorf("unmarshalling dns config: %w", err)
// }
dns.MagicDNS = viper.GetBool("dns.magic_dns")
dns.BaseDomain = viper.GetString("dns.base_domain")
dns.OverrideLocalDNS = viper.GetBool("dns.override_local_dns")
dns.Nameservers.Global = viper.GetStringSlice("dns.nameservers.global")
dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")
dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")
if viper.IsSet("dns.extra_records") {
var extraRecords []tailcfg.DNSRecord
err := viper.UnmarshalKey("dns.extra_records", &extraRecords)
if err != nil {
return DNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
}
dns.ExtraRecords = extraRecords
}
return dns, nil
}
// parseResolvers converts nameserver strings into DNS resolvers.
// If a nameserver is a valid IP, it will be used as a regular resolver.
// If a nameserver is a valid URL, it will be used as a DoH resolver.
// If a nameserver is neither a valid URL nor a valid IP, it will be ignored.
// When domain is non-empty, it is included in the warning for invalid entries.
func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver {
var resolvers []*dnstype.Resolver
for _, nsStr := range nameservers {
if _, err := netip.ParseAddr(nsStr); err == nil { //nolint:noinlineerr
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
}
if _, err := url.Parse(nsStr); err == nil { //nolint:noinlineerr
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
}
e := log.Warn().Str("nameserver", nsStr)
if domain != "" {
e = e.Str("domain", domain)
}
e.Msg("invalid nameserver, ignoring")
}
return resolvers
}
// globalResolvers returns the global DNS resolvers
// defined in the config file.
func (d *DNSConfig) globalResolvers() []*dnstype.Resolver {
return parseResolvers(d.Nameservers.Global, "")
}
// splitResolvers returns a map of domain to DNS resolvers.
func (d *DNSConfig) splitResolvers() map[string][]*dnstype.Resolver {
routes := make(map[string][]*dnstype.Resolver)
for domain, nameservers := range d.Nameservers.Split {
routes[domain] = parseResolvers(nameservers, domain)
}
return routes
}
func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
cfg := tailcfg.DNSConfig{}
cfg.Proxied = dns.MagicDNS
cfg.ExtraRecords = lowercaseRecordNames(dns.ExtraRecords)
if dns.OverrideLocalDNS {
cfg.Resolvers = dns.globalResolvers()
} else {
cfg.FallbackResolvers = dns.globalResolvers()
}
routes := dns.splitResolvers()
cfg.Routes = routes
if dns.BaseDomain != "" {
cfg.Domains = []string{dns.BaseDomain}
}
cfg.Domains = append(cfg.Domains, dns.SearchDomains...)
return &cfg
}
// warnBanner prints a highly visible warning banner to the log output.
// It wraps the provided lines in an ASCII-art box with a "Warning!" header.
// This is intended for critical configuration issues that users must not ignore.
func warnBanner(lines []string) {
var b strings.Builder
b.WriteString("\n")
b.WriteString("################################################################\n")
b.WriteString("### __ __ _ _ ###\n")
b.WriteString("### \\ \\ / / (_) | | ###\n")
b.WriteString("### \\ \\ /\\ / /_ _ _ __ _ __ _ _ __ __ _| | ###\n")
b.WriteString("### \\ \\/ \\/ / _` | '__| '_ \\| | '_ \\ / _` | | ###\n")
b.WriteString("### \\ /\\ / (_| | | | | | | | | | | (_| |_| ###\n")
b.WriteString("### \\/ \\/ \\__,_|_| |_| |_|_|_| |_|\\__, (_) ###\n")
b.WriteString("### __/ | ###\n")
b.WriteString("### |___/ ###\n")
b.WriteString("################################################################\n")
b.WriteString("### ###\n")
for _, line := range lines {
fmt.Fprintf(&b, "### %-54s ###\n", line)
}
b.WriteString("### ###\n")
b.WriteString("################################################################")
log.Warn().Msg(b.String())
}
func parsePrefixConfig(key string, standardRange netip.Prefix, family string) (*netip.Prefix, bool, error) {
s := viper.GetString(key)
if s == "" {
return nil, false, nil
}
prefix, err := netip.ParsePrefix(s)
if err != nil {
return nil, false, fmt.Errorf("parsing %s prefix from config: %w", family, err)
}
builder := netipx.IPSetBuilder{}
builder.AddPrefix(standardRange)
ipSet, _ := builder.IPSet()
return &prefix, !ipSet.ContainsPrefix(prefix), nil
}
// trustedProxies rejects 0.0.0.0/0 and ::/0 because they defeat the
// peer-trust gate and almost always indicate misconfiguration.
func trustedProxies() ([]netip.Prefix, error) {
raw := viper.GetStringSlice("trusted_proxies")
if len(raw) == 0 {
return nil, nil
}
out := make([]netip.Prefix, 0, len(raw))
for i, s := range raw {
p, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("trusted_proxies[%d] %q: %w", i, s, err)
}
if p.Bits() == 0 {
return nil, fmt.Errorf("trusted_proxies[%d] %q: %w", i, s, errTrustedProxyZeroRange)
}
out = append(out, p.Masked())
}
return out, nil
}
// LoadCLIConfig returns the needed configuration for the CLI client
// of Headscale to connect to a Headscale server.
func LoadCLIConfig() (*Config, error) {
logConfig := logConfig()
zerolog.SetGlobalLevel(logConfig.Level)
return &Config{
DisableUpdateCheck: viper.GetBool("disable_check_updates"),
UnixSocket: viper.GetString("unix_socket"),
CLI: CLIConfig{
Address: viper.GetString("cli.address"),
APIKey: viper.GetString("cli.api_key"),
Timeout: viper.GetDuration("cli.timeout"),
Insecure: viper.GetBool("cli.insecure"),
},
Log: logConfig,
}, nil
}
// LoadServerConfig returns the full Headscale configuration to
// host a Headscale server. This is called as part of `headscale serve`.
func LoadServerConfig() (*Config, error) {
if err := validateServerConfig(); err != nil { //nolint:noinlineerr
return nil, err
}
logConfig := logConfig()
zerolog.SetGlobalLevel(logConfig.Level)
prefix4, v4NonStandard, err := parsePrefixConfig("prefixes.v4", tsaddr.CGNATRange(), "IPv4")
if err != nil {
return nil, err
}
prefix6, v6NonStandard, err := parsePrefixConfig("prefixes.v6", tsaddr.TailscaleULARange(), "IPv6")
if err != nil {
return nil, err
}
trusted, err := trustedProxies()
if err != nil {
return nil, err
}
if prefix4 == nil && prefix6 == nil {
return nil, ErrNoPrefixConfigured
}
if v4NonStandard || v6NonStandard {
warnBanner([]string{
"You have overridden the default Headscale IP prefixes",
"with a range outside of the standard CGNAT and/or ULA",
"ranges. This is NOT a supported configuration.",
"",
"Using subsets of the default ranges (100.64.0.0/10 for",
"IPv4, fd7a:115c:a1e0::/48 for IPv6) is fine. Using",
"ranges outside of these will cause undefined behaviour",
"as the Tailscale client is NOT designed to operate on",
"any other ranges.",
"",
"Please revert your prefixes to subsets of the standard",
"ranges as described in the example configuration.",
"",
"Any issue raised using a range outside of the",
"supported range will be labelled as wontfix",
"and closed.",
})
}
allocStr := viper.GetString("prefixes.allocation")
var alloc IPAllocationStrategy
switch allocStr {
case string(IPAllocationStrategySequential):
alloc = IPAllocationStrategySequential
case string(IPAllocationStrategyRandom):
alloc = IPAllocationStrategyRandom
default:
return nil, fmt.Errorf(
"%w: %q, allowed options: %s, %s",
ErrInvalidAllocationStrategy,
allocStr,
IPAllocationStrategySequential,
IPAllocationStrategyRandom,
)
}
dnsConfig, err := dns()
if err != nil {
return nil, err
}
derpConfig := derpConfig()
logTailConfig := logtailConfig()
oidcClientSecret := viper.GetString("oidc.client_secret")
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
if oidcClientSecretPath != "" && oidcClientSecret != "" {
return nil, errOidcMutuallyExclusive
}
if oidcClientSecretPath != "" {
secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath))
if err != nil {
return nil, err
}
oidcClientSecret = strings.TrimSpace(string(secretBytes))
}
serverURL := viper.GetString("server_url")
// BaseDomain cannot be the same as the server URL.
// This is because Tailscale takes over the domain in BaseDomain,
// causing the headscale server and DERP to be unreachable.
// For Tailscale upstream, the following is true:
// - DERP run on their own domains
// - Control plane runs on login.tailscale.com/controlplane.tailscale.com
// - MagicDNS (BaseDomain) for users is on a *.ts.net domain per tailnet (e.g. tail-scale.ts.net)
if dnsConfig.BaseDomain != "" {
err := isSafeServerURL(serverURL, dnsConfig.BaseDomain)
if err != nil {
return nil, err
}
}
return &Config{
ServerURL: serverURL,
Addr: viper.GetString("listen_addr"),
MetricsAddr: viper.GetString("metrics_listen_addr"),
TrustedProxies: trusted,
DisableUpdateCheck: false,
PrefixV4: prefix4,
PrefixV6: prefix6,
IPAllocation: alloc,
NoisePrivateKeyPath: util.AbsolutePathFromConfigPath(
viper.GetString("noise.private_key_path"),
),
BaseDomain: dnsConfig.BaseDomain,
DERP: derpConfig,
Node: NodeConfig{
Expiry: resolveNodeExpiry(),
Ephemeral: EphemeralConfig{
InactivityTimeout: resolveEphemeralInactivityTimeout(),
},
Routes: RouteConfig{
HA: HARouteConfig{
ProbeInterval: viper.GetDuration("node.routes.ha.probe_interval"),
ProbeTimeout: viper.GetDuration("node.routes.ha.probe_timeout"),
},
},
},
PreAuthKeys: PreAuthKeysConfig{
RevokedRetention: viper.GetDuration("preauth_keys.revoked_retention"),
},
Database: databaseConfig(),
TLS: tlsConfig(),
DNSConfig: dnsConfig,
TailcfgDNSConfig: dnsToTailcfgDNS(dnsConfig),
ACMEEmail: viper.GetString("acme_email"),
ACMEURL: viper.GetString("acme_url"),
UnixSocket: viper.GetString("unix_socket"),
UnixSocketPermission: util.GetFileMode("unix_socket_permission"),
OIDC: OIDCConfig{
OnlyStartIfOIDCIsAvailable: viper.GetBool(
"oidc.only_start_if_oidc_is_available",
),
Issuer: viper.GetString("oidc.issuer"),
ClientID: viper.GetString("oidc.client_id"),
ClientSecret: oidcClientSecret,
Scope: viper.GetStringSlice("oidc.scope"),
ExtraParams: viper.GetStringMapString("oidc.extra_params"),
AllowedDomains: viper.GetStringSlice("oidc.allowed_domains"),
AllowedUsers: viper.GetStringSlice("oidc.allowed_users"),
AllowedGroups: viper.GetStringSlice("oidc.allowed_groups"),
EmailVerifiedRequired: viper.GetBool("oidc.email_verified_required"),
UseExpiryFromToken: viper.GetBool("oidc.use_expiry_from_token"),
PKCE: PKCEConfig{
Enabled: viper.GetBool("oidc.pkce.enabled"),
Method: viper.GetString("oidc.pkce.method"),
},
},
LogTail: logTailConfig,
Taildrop: TaildropConfig{
Enabled: viper.GetBool("taildrop.enabled"),
},
AutoUpdate: AutoUpdateConfig{
Enabled: viper.GetBool("auto_update.enabled"),
},
Policy: policyConfig(),
CLI: CLIConfig{
Address: viper.GetString("cli.address"),
APIKey: viper.GetString("cli.api_key"),
Timeout: viper.GetDuration("cli.timeout"),
Insecure: viper.GetBool("cli.insecure"),
},
Log: logConfig,
Tuning: Tuning{
NotifierSendTimeout: viper.GetDuration("tuning.notifier_send_timeout"),
BatchChangeDelay: viper.GetDuration("tuning.batch_change_delay"),
NodeMapSessionBufferedChanSize: viper.GetInt(
"tuning.node_mapsession_buffered_chan_size",
),
BatcherWorkers: func() int {
if workers := viper.GetInt("tuning.batcher_workers"); workers > 0 {
return workers
}
return DefaultBatcherWorkers()
}(),
RegisterCacheExpiration: viper.GetDuration("tuning.register_cache_expiration"),
RegisterCacheMaxEntries: viper.GetInt("tuning.register_cache_max_entries"),
NodeStoreBatchSize: viper.GetInt("tuning.node_store_batch_size"),
NodeStoreBatchTimeout: viper.GetDuration("tuning.node_store_batch_timeout"),
},
}, nil
}
// BaseDomain cannot be a suffix of the server URL.
// This is because Tailscale takes over the domain in BaseDomain,
// causing the headscale server and DERP to be unreachable.
// For Tailscale upstream, the following is true:
// - DERP run on their own domains.
// - Control plane runs on login.tailscale.com/controlplane.tailscale.com.
// - MagicDNS (BaseDomain) for users is on a *.ts.net domain per tailnet (e.g. tail-scale.ts.net).
func isSafeServerURL(serverURL, baseDomain string) error {
server, err := url.Parse(serverURL)
if err != nil {
return err
}
if server.Hostname() == baseDomain {
return errServerURLSame
}
if strings.HasSuffix(server.Hostname(), "."+baseDomain) {
return errServerURLSuffix
}
return nil
}
// deprecation describes a configuration key that is no longer
// supported. NewKey is the replacement (when one exists) or empty
// when the key has been removed without a replacement.
type deprecation struct {
OldKey string
NewKey string
Hint string
}
// deprecator collects deprecated configuration keys observed in the
// current viper config. Warns are non-blocking and surface via
// log.Warn; fatals are pushed onto a configValidator as ConfigError
// entries so they merge with the rest of the validation report
// instead of pre-empting it via log.Fatal.
type deprecator struct {
seen set.Set[string]
warns []deprecation
fatals []deprecation
}
func (d *deprecator) addWarn(dep deprecation) {
if d.seen.Contains(dep.OldKey) {
return
}
d.seen.Add(dep.OldKey)
d.warns = append(d.warns, dep)
}
func (d *deprecator) addFatal(dep deprecation) {
if d.seen.Contains(dep.OldKey) {
return
}
d.seen.Add(dep.OldKey)
d.fatals = append(d.fatals, dep)
}
// warnWithAlias registers an alias from newKey to oldKey and records
// a non-blocking warning when oldKey is set.
//
//nolint:unused
func (d *deprecator) warnWithAlias(newKey, oldKey string) {
// NOTE: RegisterAlias is called with NEW KEY -> OLD KEY
viper.RegisterAlias(newKey, oldKey)
if viper.IsSet(oldKey) {
d.addWarn(deprecation{OldKey: oldKey, NewKey: newKey})
}
}
// fatal records a removed key (no replacement) as a config error.
func (d *deprecator) fatal(oldKey string) {
if viper.IsSet(oldKey) {
d.addFatal(deprecation{OldKey: oldKey})
}
}
// fatalWithHint behaves like fatal but appends a remediation pointer to
// the message so operators see exactly what to do without leaving the
// terminal. Use it when the removed key has a clean replacement on the
// policy side.
func (d *deprecator) fatalWithHint(oldKey, hint string) {
if viper.IsSet(oldKey) {
d.addFatal(deprecation{OldKey: oldKey, Hint: hint})
}
}
// fatalIfNewKeyIsNotUsed records a config error when oldKey is set
// without newKey, or a warning when both are set (oldKey ignored,
// newKey takes precedence).
func (d *deprecator) fatalIfNewKeyIsNotUsed(newKey, oldKey string) {
if !viper.IsSet(oldKey) {
return
}
if !viper.IsSet(newKey) {
d.addFatal(deprecation{OldKey: oldKey, NewKey: newKey})
return
}
d.addWarn(deprecation{OldKey: oldKey, NewKey: newKey})
}
// fatalIfSet records a config error any time oldKey is set, naming
// newKey as the replacement.
func (d *deprecator) fatalIfSet(oldKey, newKey string) {
if viper.IsSet(oldKey) {
d.addFatal(deprecation{OldKey: oldKey, NewKey: newKey})
}
}
// warnNoAlias records a non-blocking warning when oldKey is set,
// pointing operators at newKey.
//
//nolint:unused
func (d *deprecator) warnNoAlias(newKey, oldKey string) {
if viper.IsSet(oldKey) {
d.addWarn(deprecation{OldKey: oldKey, NewKey: newKey})
}
}
// warn records a non-blocking warning when oldKey is set, with no
// replacement to point at.
//
//nolint:unused
func (d *deprecator) warn(oldKey string) {
if viper.IsSet(oldKey) {
d.addWarn(deprecation{OldKey: oldKey})
}
}
// Apply emits warns via log.Warn and pushes one *ConfigError per
// fatal onto v. Run after the rest of validation so deprecated-key
// failures surface alongside the other config errors in a single
// pass.
func (d *deprecator) Apply(v *configValidator) {
for _, w := range d.warns {
if w.NewKey != "" {
log.Warn().Msgf(
"configuration key %q is deprecated; use %q instead",
w.OldKey, w.NewKey)
continue
}
log.Warn().Msgf("configuration key %q is deprecated", w.OldKey)
}
for _, f := range d.fatals {
ce := &ConfigError{
Current: []KV{{f.OldKey, viper.Get(f.OldKey)}},
}
if f.NewKey != "" {
ce.Reason = fmt.Sprintf(
"configuration key %s has been removed; use %s instead",
f.OldKey, f.NewKey)
ce.Hint = fmt.Sprintf("remove %s and set %s", f.OldKey, f.NewKey)
} else {
ce.Reason = fmt.Sprintf("configuration key %s has been removed", f.OldKey)
if f.Hint != "" {
ce.Hint = f.Hint
} else {
ce.Hint = fmt.Sprintf("remove %s; see the CHANGELOG for context", f.OldKey)
}
}
v.Add(ce)
}
}
// tailcfgDNSMu guards concurrent access to the mutable ExtraRecords of
// [Config.TailcfgDNSConfig] between the extra-records file watcher (writer)
// and the per-client map builds that clone it (readers). It is a package-level
// lock so [Config] stays freely copyable during construction.
var tailcfgDNSMu sync.RWMutex
// CloneTailcfgDNSConfig returns a deep copy of [Config.TailcfgDNSConfig], or
// nil if none is set. Safe for concurrent use with [Config.SetExtraRecords].
func (c *Config) CloneTailcfgDNSConfig() *tailcfg.DNSConfig {
tailcfgDNSMu.RLock()
defer tailcfgDNSMu.RUnlock()
if c.TailcfgDNSConfig == nil {
return nil
}
return c.TailcfgDNSConfig.Clone()
}
// SetExtraRecords replaces the ExtraRecords of [Config.TailcfgDNSConfig]. Safe
// for concurrent use with [Config.CloneTailcfgDNSConfig].
func (c *Config) SetExtraRecords(records []tailcfg.DNSRecord) {
tailcfgDNSMu.Lock()
defer tailcfgDNSMu.Unlock()
if c.TailcfgDNSConfig != nil {
c.TailcfgDNSConfig.ExtraRecords = lowercaseRecordNames(records)
}
}
// lowercaseRecordNames normalizes DNS record names to lowercase, as DNS names
// are case-insensitive and clients match extra records by exact name.
func lowercaseRecordNames(records []tailcfg.DNSRecord) []tailcfg.DNSRecord {
if len(records) == 0 {
return records
}
normalized := make([]tailcfg.DNSRecord, len(records))
for i, record := range records {
record.Name = strings.ToLower(record.Name)
normalized[i] = record
}
return normalized
}