mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-26 18:24:54 +09:00
4cad0b564e
Sub-builders called after validateServerConfig (prefixV4, prefixV6,
allocation strategy, dns, oidc client secret/path, isSafeServerURL)
each returned the first error. So an operator that fixed one
issue saw the next one only on the next startup.
Lift one *configValidator across the entire LoadServerConfig flow.
validateServerConfigInto(v) populates it; each sub-builder's error
is wrapped in a structured *ConfigError (with the original sentinel
kept on the Cause field, so errors.Is against errOidcMutuallyExclusive,
errServerURLSame/Suffix, ErrNoPrefixConfigured, and
ErrInvalidAllocationStrategy keeps working). v.Err() is checked once,
right before the &Config{} construction.
TestReadConfig/base-domain-in-server-url-err matched the old sentinel
wording; flipped to the new structured Reason. Added
TestLoadServerConfig_CollectsAcrossSubBuilders to lock the wiring:
four sub-builder failures from a single config produce four
*ConfigErrors in the report.
Updates #3227
1751 lines
56 KiB
Go
1751 lines
56 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)
|
|
}
|
|
|
|
// validateServerConfig is the test-only entry point. Production code
|
|
// goes through LoadServerConfig, which lifts the validator to span
|
|
// every config-time sub-builder so the operator sees every problem
|
|
// in one render.
|
|
func validateServerConfig() error {
|
|
v := &configValidator{}
|
|
validateServerConfigInto(v)
|
|
|
|
return v.Err()
|
|
}
|
|
|
|
// validateServerConfigInto runs every viper-level validation rule
|
|
// against the provided validator. Caller owns the validator and is
|
|
// responsible for inspecting v.Err() once all sub-builders have run.
|
|
func validateServerConfigInto(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 {
|
|
v.Add(&ConfigError{
|
|
Reason: "OIDC configuration is invalid",
|
|
Detail: err.Error(),
|
|
Hint: "check oidc.issuer, oidc.client_id, oidc.client_secret, and oidc.pkce.method",
|
|
Cause: 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)
|
|
|
|
depr.Apply(v)
|
|
}
|
|
|
|
// 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) {
|
|
v := &configValidator{}
|
|
validateServerConfigInto(v)
|
|
|
|
logConfig := logConfig()
|
|
zerolog.SetGlobalLevel(logConfig.Level)
|
|
|
|
prefix4, v4NonStandard, err := parsePrefixConfig("prefixes.v4", tsaddr.CGNATRange(), "IPv4")
|
|
if err != nil {
|
|
v.Add(&ConfigError{
|
|
Reason: "prefixes.v4 is not a valid CIDR",
|
|
Current: []KV{{"prefixes.v4", viper.GetString("prefixes.v4")}},
|
|
Detail: err.Error(),
|
|
Hint: `use CIDR form, e.g. "100.64.0.0/10" (CGNAT, headscale default)`,
|
|
Cause: err,
|
|
})
|
|
}
|
|
|
|
prefix6, v6NonStandard, err := parsePrefixConfig("prefixes.v6", tsaddr.TailscaleULARange(), "IPv6")
|
|
if err != nil {
|
|
v.Add(&ConfigError{
|
|
Reason: "prefixes.v6 is not a valid CIDR",
|
|
Current: []KV{{"prefixes.v6", viper.GetString("prefixes.v6")}},
|
|
Detail: err.Error(),
|
|
Hint: `use CIDR form, e.g. "fd7a:115c:a1e0::/48" (Tailscale ULA, headscale default)`,
|
|
Cause: err,
|
|
})
|
|
}
|
|
|
|
trusted, err := trustedProxies()
|
|
if err != nil {
|
|
v.Add(&ConfigError{
|
|
Reason: "trusted_proxies contains an invalid CIDR",
|
|
Current: []KV{{"trusted_proxies", viper.GetStringSlice("trusted_proxies")}},
|
|
Detail: err.Error(),
|
|
Hint: "use specific proxy CIDRs; catch-all ranges are not allowed",
|
|
Cause: err,
|
|
})
|
|
}
|
|
|
|
if prefix4 == nil && prefix6 == nil {
|
|
v.Add(&ConfigError{
|
|
Reason: "no IP prefix configured for the tailnet",
|
|
Current: []KV{
|
|
{"prefixes.v4", viper.GetString("prefixes.v4")},
|
|
{"prefixes.v6", viper.GetString("prefixes.v6")},
|
|
},
|
|
Hint: `set at least one of prefixes.v4 (default: "100.64.0.0/10") or prefixes.v6 (default: "fd7a:115c:a1e0::/48")`,
|
|
Cause: 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:
|
|
v.Add(&ConfigError{
|
|
Reason: "prefixes.allocation has an unsupported value",
|
|
Current: []KV{{"prefixes.allocation", allocStr}},
|
|
Allowed: []string{
|
|
string(IPAllocationStrategySequential),
|
|
string(IPAllocationStrategyRandom),
|
|
},
|
|
Hint: "pick one of the allowed values; sequential is the default",
|
|
Cause: ErrInvalidAllocationStrategy,
|
|
})
|
|
}
|
|
|
|
dnsConfig, err := dns()
|
|
if err != nil {
|
|
v.Add(&ConfigError{
|
|
Reason: "dns.extra_records cannot be parsed",
|
|
Current: []KV{{"dns.extra_records", "<inline records>"}},
|
|
Detail: err.Error(),
|
|
Hint: "check the YAML syntax; see config-example.yaml for the expected shape",
|
|
Cause: err,
|
|
})
|
|
}
|
|
|
|
derpConfig := derpConfig()
|
|
logTailConfig := logtailConfig()
|
|
|
|
oidcClientSecret := viper.GetString("oidc.client_secret")
|
|
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
|
|
|
|
switch {
|
|
case oidcClientSecretPath != "" && oidcClientSecret != "":
|
|
v.Add(&ConfigError{
|
|
Reason: "oidc.client_secret and oidc.client_secret_path are mutually exclusive",
|
|
Current: []KV{
|
|
{"oidc.client_secret", "<redacted>"},
|
|
{"oidc.client_secret_path", oidcClientSecretPath},
|
|
},
|
|
Hint: "keep one source for the secret; the path form is recommended so the secret stays out of config files",
|
|
See: "https://headscale.net/stable/ref/oidc/",
|
|
Cause: errOidcMutuallyExclusive,
|
|
})
|
|
|
|
case oidcClientSecretPath != "":
|
|
secretBytes, err := os.ReadFile(os.ExpandEnv(oidcClientSecretPath))
|
|
if err != nil {
|
|
v.Add(&ConfigError{
|
|
Reason: "oidc.client_secret_path cannot be read",
|
|
Current: []KV{{"oidc.client_secret_path", oidcClientSecretPath}},
|
|
Detail: err.Error(),
|
|
Hint: "ensure the file exists and is readable by the headscale user",
|
|
Cause: err,
|
|
})
|
|
} else {
|
|
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 {
|
|
addServerURLConfigError(v, serverURL, dnsConfig.BaseDomain, err)
|
|
}
|
|
}
|
|
|
|
err = v.Err()
|
|
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
|
|
}
|
|
|
|
// addServerURLConfigError pushes a structured ConfigError onto v for
|
|
// each isSafeServerURL failure mode so the operator sees the issue
|
|
// rendered consistently with the rest of the config-load report.
|
|
func addServerURLConfigError(v *configValidator, serverURL, baseDomain string, err error) {
|
|
current := []KV{
|
|
{"server_url", serverURL},
|
|
{"dns.base_domain", baseDomain},
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, errServerURLSame):
|
|
v.Add(&ConfigError{
|
|
Reason: "server_url and dns.base_domain refer to the same hostname",
|
|
Current: current,
|
|
Hint: "give server_url a host distinct from dns.base_domain (Tailscale takes over base_domain via MagicDNS)",
|
|
See: "https://headscale.net/stable/ref/dns/",
|
|
Cause: errServerURLSame,
|
|
})
|
|
|
|
case errors.Is(err, errServerURLSuffix):
|
|
v.Add(&ConfigError{
|
|
Reason: "server_url is a subdomain of dns.base_domain",
|
|
Current: current,
|
|
Hint: "host headscale on a domain outside dns.base_domain (Tailscale takes over base_domain via MagicDNS)",
|
|
See: "https://headscale.net/stable/ref/dns/",
|
|
Cause: errServerURLSuffix,
|
|
})
|
|
|
|
default:
|
|
v.Add(&ConfigError{
|
|
Reason: "server_url cannot be parsed",
|
|
Current: []KV{{"server_url", serverURL}},
|
|
Detail: err.Error(),
|
|
Hint: "set server_url to an absolute URL, e.g. https://headscale.example.com",
|
|
Cause: err,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|