mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-23 23:06:49 +09:00
types: add dns.challenge and dns.certificates config
dns.challenge.provider builds a libdns provider at load, so configtest catches typos. dns.certificates gates node certs; zone defaults to base_domain. Updates #2137
This commit is contained in:
1 parent
6cb950cd9e
commit
0efe4b7798
3 files changed
+399
-5
No files matched your search
@@ -358,6 +358,30 @@ dns:
|
|||||||
# Headscale processes this file on each change.
|
# Headscale processes this file on each change.
|
||||||
# extra_records_path: /var/lib/headscale/extra-records.json
|
# extra_records_path: /var/lib/headscale/extra-records.json
|
||||||
|
|
||||||
|
# Let nodes get TLS certificates for their MagicDNS name with
|
||||||
|
# `tailscale cert`. Requires dns.challenge.provider.
|
||||||
|
certificates:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# DNS zone the provider writes to. Defaults to base_domain.
|
||||||
|
# zone: example.com
|
||||||
|
|
||||||
|
# DNS provider for ACME DNS-01 challenges, used by dns.certificates.
|
||||||
|
challenge:
|
||||||
|
# How long to wait for a record to show up on every authoritative
|
||||||
|
# nameserver. 0 disables the check.
|
||||||
|
propagation_timeout: 2m
|
||||||
|
|
||||||
|
# Resolvers used to find the zone's nameservers.
|
||||||
|
# Defaults to 1.1.1.1 and 8.8.8.8.
|
||||||
|
# resolvers: []
|
||||||
|
|
||||||
|
# cloudflare, gigahost, hetzner, powerdns or rfc2136. The other keys are
|
||||||
|
# settings of that provider; add _path to a key to read it from a file.
|
||||||
|
# provider:
|
||||||
|
# name: cloudflare
|
||||||
|
# api_token_path: /run/credentials/headscale.service/cloudflare-token
|
||||||
|
|
||||||
# Unix socket used for the CLI to connect without authentication
|
# Unix socket used for the CLI to connect without authentication
|
||||||
# Note: for production you will want to set this to something like:
|
# Note: for production you will want to set this to something like:
|
||||||
unix_socket: /var/run/headscale/headscale.sock
|
unix_socket: /var/run/headscale/headscale.sock
|
||||||
|
|||||||
+167
-4
@@ -1,17 +1,21 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
hsdns "github.com/juanfont/headscale/hscontrol/dns"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/prometheus/common/model"
|
"github.com/prometheus/common/model"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -21,6 +25,7 @@ import (
|
|||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/dnstype"
|
"tailscale.com/types/dnstype"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
"tailscale.com/util/set"
|
"tailscale.com/util/set"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +34,8 @@ const (
|
|||||||
PKCEMethodS256 string = "S256"
|
PKCEMethodS256 string = "S256"
|
||||||
|
|
||||||
defaultNodeStoreBatchSize = 100
|
defaultNodeStoreBatchSize = 100
|
||||||
|
|
||||||
|
dnsDocsURL = "https://headscale.net/stable/ref/dns/"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -44,6 +51,12 @@ var (
|
|||||||
ErrInvalidAllocationStrategy = errors.New("invalid prefix allocation strategy")
|
ErrInvalidAllocationStrategy = errors.New("invalid prefix allocation strategy")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// defaultChallengeResolvers find a zone's nameservers when
|
||||||
|
// dns.challenge.resolvers is empty. The system resolver is not used: on a
|
||||||
|
// host running MagicDNS it answers for base_domain itself instead of
|
||||||
|
// asking the public zone.
|
||||||
|
var defaultChallengeResolvers = []string{"1.1.1.1:53", "8.8.8.8:53"}
|
||||||
|
|
||||||
type IPAllocationStrategy string
|
type IPAllocationStrategy string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -139,6 +152,8 @@ type Config struct {
|
|||||||
// it can be used directly when sending Netmaps to clients.
|
// it can be used directly when sending Netmaps to clients.
|
||||||
TailcfgDNSConfig *tailcfg.DNSConfig
|
TailcfgDNSConfig *tailcfg.DNSConfig
|
||||||
|
|
||||||
|
DNSChallenge DNSChallengeConfig
|
||||||
|
|
||||||
UnixSocket string
|
UnixSocket string
|
||||||
UnixSocketPermission fs.FileMode
|
UnixSocketPermission fs.FileMode
|
||||||
|
|
||||||
@@ -163,6 +178,29 @@ type DNSConfig struct {
|
|||||||
SearchDomains []string `mapstructure:"search_domains"`
|
SearchDomains []string `mapstructure:"search_domains"`
|
||||||
ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"`
|
ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"`
|
||||||
ExtraRecordsPath string `mapstructure:"extra_records_path"`
|
ExtraRecordsPath string `mapstructure:"extra_records_path"`
|
||||||
|
Certificates CertificatesConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificatesConfig lets nodes get TLS certificates for their MagicDNS
|
||||||
|
// name with `tailscale cert`. The node runs ACME itself; headscale
|
||||||
|
// publishes its DNS-01 challenge through [DNSChallengeConfig].
|
||||||
|
type CertificatesConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
// Zone is the public DNS zone challenge records are written to. It is
|
||||||
|
// base_domain unless base_domain sits below the zone apex.
|
||||||
|
Zone string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSChallengeConfig is the DNS provider answering ACME DNS-01
|
||||||
|
// challenges for node certificates and headscale's own certificate.
|
||||||
|
type DNSChallengeConfig struct {
|
||||||
|
PropagationTimeout time.Duration
|
||||||
|
Resolvers []string
|
||||||
|
ProviderName string
|
||||||
|
// Provider is nil when no provider is configured. It is built while
|
||||||
|
// loading so `headscale configtest` reports bad provider settings,
|
||||||
|
// and never serialised because it holds credentials.
|
||||||
|
Provider hsdns.Provider `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Nameservers struct {
|
type Nameservers struct {
|
||||||
@@ -449,6 +487,9 @@ func LoadConfig(path string, isFile bool) error {
|
|||||||
viper.SetDefault("dns.nameservers.global", []string{})
|
viper.SetDefault("dns.nameservers.global", []string{})
|
||||||
viper.SetDefault("dns.nameservers.split", map[string]string{})
|
viper.SetDefault("dns.nameservers.split", map[string]string{})
|
||||||
viper.SetDefault("dns.search_domains", []string{})
|
viper.SetDefault("dns.search_domains", []string{})
|
||||||
|
viper.SetDefault("dns.certificates.enabled", false)
|
||||||
|
|
||||||
|
viper.SetDefault("dns.challenge.propagation_timeout", "2m")
|
||||||
|
|
||||||
viper.SetDefault("derp.server.enabled", false)
|
viper.SetDefault("derp.server.enabled", false)
|
||||||
viper.SetDefault("derp.server.verify_clients", true)
|
viper.SetDefault("derp.server.verify_clients", true)
|
||||||
@@ -707,7 +748,7 @@ func validateServerConfigInto(v *configValidator) {
|
|||||||
{"dns.nameservers.global", "[]"},
|
{"dns.nameservers.global", "[]"},
|
||||||
},
|
},
|
||||||
Hint: "list at least one upstream nameserver, or set dns.override_local_dns: false",
|
Hint: "list at least one upstream nameserver, or set dns.override_local_dns: false",
|
||||||
See: "https://headscale.net/stable/ref/dns/",
|
See: dnsDocsURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,6 +806,7 @@ func validateServerConfigInto(v *configValidator) {
|
|||||||
validateDERPConfig(v)
|
validateDERPConfig(v)
|
||||||
validateDatabaseConfig(v)
|
validateDatabaseConfig(v)
|
||||||
validateMagicDNSConfig(v)
|
validateMagicDNSConfig(v)
|
||||||
|
validateCertificatesConfig(v)
|
||||||
|
|
||||||
depr.Apply(v)
|
depr.Apply(v)
|
||||||
}
|
}
|
||||||
@@ -834,11 +876,110 @@ func validateMagicDNSConfig(v *configValidator) {
|
|||||||
{"dns.base_domain", ""},
|
{"dns.base_domain", ""},
|
||||||
},
|
},
|
||||||
Hint: `set dns.base_domain to a domain you control (e.g. "ts.example.net"), or set dns.magic_dns: false`,
|
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/",
|
See: dnsDocsURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateCertificatesConfig records ConfigErrors when node certificates
|
||||||
|
// are enabled without what the Tailscale client needs: a MagicDNS name
|
||||||
|
// it can match exactly, and a provider to publish the challenge.
|
||||||
|
func validateCertificatesConfig(v *configValidator) {
|
||||||
|
if timeout := viper.GetDuration("dns.challenge.propagation_timeout"); timeout < 0 {
|
||||||
|
v.Add(&ConfigError{
|
||||||
|
Reason: "dns.challenge.propagation_timeout must not be negative",
|
||||||
|
Current: []KV{{"dns.challenge.propagation_timeout", timeout.String()}},
|
||||||
|
Hint: "use 0 to skip waiting for the record on the zone's nameservers",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viper.GetBool("dns.certificates.enabled") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viper.GetBool("dns.magic_dns") {
|
||||||
|
v.Add(&ConfigError{
|
||||||
|
Reason: "dns.certificates requires dns.magic_dns",
|
||||||
|
Current: []KV{{"dns.certificates.enabled", true}, {"dns.magic_dns", false}},
|
||||||
|
Hint: "certificates are issued for MagicDNS names; set dns.magic_dns: true",
|
||||||
|
See: dnsDocsURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if viper.GetString("dns.challenge.provider.name") == "" {
|
||||||
|
v.Add(&ConfigError{
|
||||||
|
Reason: "dns.certificates requires dns.challenge.provider",
|
||||||
|
Current: []KV{{"dns.certificates.enabled", true}},
|
||||||
|
Allowed: hsdns.ProviderNames(),
|
||||||
|
Hint: "configure the DNS provider that hosts dns.base_domain under dns.challenge.provider",
|
||||||
|
See: dnsDocsURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSuffix(viper.GetString("dns.base_domain"), ".")
|
||||||
|
if base != strings.ToLower(base) || dnsname.ValidHostname(base) != nil {
|
||||||
|
v.Add(&ConfigError{
|
||||||
|
Reason: "dns.base_domain must be a lowercase DNS name for dns.certificates",
|
||||||
|
Current: []KV{{"dns.base_domain", base}},
|
||||||
|
Hint: "Tailscale clients match certificate names exactly; use a lowercase public domain you control",
|
||||||
|
See: dnsDocsURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
zone := strings.TrimSuffix(viper.GetString("dns.certificates.zone"), ".")
|
||||||
|
if zone != "" && base != zone && !strings.HasSuffix(base, "."+zone) {
|
||||||
|
v.Add(&ConfigError{
|
||||||
|
Reason: "dns.base_domain is not inside dns.certificates.zone",
|
||||||
|
Current: []KV{{"dns.base_domain", base}, {"dns.certificates.zone", zone}},
|
||||||
|
Hint: "set zone to the DNS zone that contains base_domain, or leave it empty to use base_domain",
|
||||||
|
See: dnsDocsURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsChallengeConfig builds the DNS-01 provider. Provider keys are read
|
||||||
|
// one by one, because environment variables never reach into a map.
|
||||||
|
func dnsChallengeConfig() (DNSChallengeConfig, error) {
|
||||||
|
cfg := DNSChallengeConfig{
|
||||||
|
PropagationTimeout: viper.GetDuration("dns.challenge.propagation_timeout"),
|
||||||
|
Resolvers: viper.GetStringSlice("dns.challenge.resolvers"),
|
||||||
|
ProviderName: viper.GetString("dns.challenge.provider.name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Resolvers) == 0 {
|
||||||
|
cfg.Resolvers = slices.Clone(defaultChallengeResolvers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like dns.nameservers, a resolver may be a bare IP.
|
||||||
|
for i, r := range cfg.Resolvers {
|
||||||
|
_, _, err := net.SplitHostPort(r)
|
||||||
|
if err != nil {
|
||||||
|
cfg.Resolvers[i] = net.JoinHostPort(r, "53")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ProviderName == "" {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := hsdns.NewProvider(
|
||||||
|
cfg.ProviderName,
|
||||||
|
viper.GetStringMap("dns.challenge.provider"),
|
||||||
|
func(key string) (any, bool) {
|
||||||
|
key = "dns.challenge.provider." + key
|
||||||
|
|
||||||
|
return viper.Get(key), viper.IsSet(key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Provider = provider
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
func tlsConfig() TLSConfig {
|
func tlsConfig() TLSConfig {
|
||||||
return TLSConfig{
|
return TLSConfig{
|
||||||
LetsEncrypt: LetsEncryptConfig{
|
LetsEncrypt: LetsEncryptConfig{
|
||||||
@@ -1032,6 +1173,8 @@ func dns() (DNSConfig, error) {
|
|||||||
dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")
|
dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")
|
||||||
dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
|
dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
|
||||||
dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")
|
dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")
|
||||||
|
dns.Certificates.Enabled = viper.GetBool("dns.certificates.enabled")
|
||||||
|
dns.Certificates.Zone = cmp.Or(viper.GetString("dns.certificates.zone"), dns.BaseDomain)
|
||||||
|
|
||||||
if viper.IsSet("dns.extra_records") {
|
if viper.IsSet("dns.extra_records") {
|
||||||
var extraRecords []tailcfg.DNSRecord
|
var extraRecords []tailcfg.DNSRecord
|
||||||
@@ -1344,6 +1487,25 @@ func LoadServerConfig() (*Config, error) {
|
|||||||
derpConfig := derpConfig()
|
derpConfig := derpConfig()
|
||||||
logTailConfig := logtailConfig()
|
logTailConfig := logtailConfig()
|
||||||
|
|
||||||
|
dnsChallenge, err := dnsChallengeConfig()
|
||||||
|
if err != nil {
|
||||||
|
// The provider names only help when the name is what is wrong.
|
||||||
|
var allowed []string
|
||||||
|
if errors.Is(err, hsdns.ErrUnknownProvider) {
|
||||||
|
allowed = hsdns.ProviderNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Add(&ConfigError{
|
||||||
|
Reason: "dns.challenge.provider is invalid",
|
||||||
|
Current: []KV{{"dns.challenge.provider.name", dnsChallenge.ProviderName}},
|
||||||
|
Detail: err.Error(),
|
||||||
|
Allowed: allowed,
|
||||||
|
Hint: "check the provider name and its settings; any string setting can be read from a file with <setting>_path",
|
||||||
|
See: dnsDocsURL,
|
||||||
|
Cause: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
oidcClientSecret := viper.GetString("oidc.client_secret")
|
oidcClientSecret := viper.GetString("oidc.client_secret")
|
||||||
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
|
oidcClientSecretPath := viper.GetString("oidc.client_secret_path")
|
||||||
|
|
||||||
@@ -1437,6 +1599,7 @@ func LoadServerConfig() (*Config, error) {
|
|||||||
|
|
||||||
DNSConfig: dnsConfig,
|
DNSConfig: dnsConfig,
|
||||||
TailcfgDNSConfig: dnsToTailcfgDNS(dnsConfig),
|
TailcfgDNSConfig: dnsToTailcfgDNS(dnsConfig),
|
||||||
|
DNSChallenge: dnsChallenge,
|
||||||
|
|
||||||
ACMEEmail: viper.GetString("acme_email"),
|
ACMEEmail: viper.GetString("acme_email"),
|
||||||
ACMEURL: viper.GetString("acme_url"),
|
ACMEURL: viper.GetString("acme_url"),
|
||||||
@@ -1519,7 +1682,7 @@ func addServerURLConfigError(v *configValidator, serverURL, baseDomain string, e
|
|||||||
Reason: "server_url and dns.base_domain refer to the same hostname",
|
Reason: "server_url and dns.base_domain refer to the same hostname",
|
||||||
Current: current,
|
Current: current,
|
||||||
Hint: "give server_url a host distinct from dns.base_domain (Tailscale takes over base_domain via MagicDNS)",
|
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/",
|
See: dnsDocsURL,
|
||||||
Cause: errServerURLSame,
|
Cause: errServerURLSame,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1528,7 +1691,7 @@ func addServerURLConfigError(v *configValidator, serverURL, baseDomain string, e
|
|||||||
Reason: "server_url is a subdomain of dns.base_domain",
|
Reason: "server_url is a subdomain of dns.base_domain",
|
||||||
Current: current,
|
Current: current,
|
||||||
Hint: "host headscale on a domain outside dns.base_domain (Tailscale takes over base_domain via MagicDNS)",
|
Hint: "host headscale on a domain outside dns.base_domain (Tailscale takes over base_domain via MagicDNS)",
|
||||||
See: "https://headscale.net/stable/ref/dns/",
|
See: dnsDocsURL,
|
||||||
Cause: errServerURLSuffix,
|
Cause: errServerURLSuffix,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
|
"github.com/libdns/cloudflare"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -61,6 +62,7 @@ func TestReadConfig(t *testing.T) {
|
|||||||
{Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
|
{Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
|
||||||
},
|
},
|
||||||
SearchDomains: []string{"test.com", "bar.com"},
|
SearchDomains: []string{"test.com", "bar.com"},
|
||||||
|
Certificates: CertificatesConfig{Zone: "example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,6 +129,7 @@ func TestReadConfig(t *testing.T) {
|
|||||||
{Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
|
{Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
|
||||||
},
|
},
|
||||||
SearchDomains: []string{"test.com", "bar.com"},
|
SearchDomains: []string{"test.com", "bar.com"},
|
||||||
|
Certificates: CertificatesConfig{Zone: "example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -331,6 +334,7 @@ func TestReadConfigFromEnv(t *testing.T) {
|
|||||||
// {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
|
// {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"},
|
||||||
// },
|
// },
|
||||||
SearchDomains: []string{"test.com", "bar.com"},
|
SearchDomains: []string{"test.com", "bar.com"},
|
||||||
|
Certificates: CertificatesConfig{Zone: "example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -873,6 +877,7 @@ func TestConfigJSONOmitsSecrets(t *testing.T) {
|
|||||||
secretPostgresPass = "p0stgres-secret-marker"
|
secretPostgresPass = "p0stgres-secret-marker"
|
||||||
secretClientSecret = "oidc-client-secret-marker" //nolint:gosec // test marker, not a real credential
|
secretClientSecret = "oidc-client-secret-marker" //nolint:gosec // test marker, not a real credential
|
||||||
secretAPIKey = "headscale-cli-api-key-marker" //nolint:gosec // test marker, not a real credential
|
secretAPIKey = "headscale-cli-api-key-marker" //nolint:gosec // test marker, not a real credential
|
||||||
|
secretDNSToken = "dns-provider-token-marker" //nolint:gosec // test marker, not a real credential
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
@@ -887,13 +892,17 @@ func TestConfigJSONOmitsSecrets(t *testing.T) {
|
|||||||
CLI: CLIConfig{
|
CLI: CLIConfig{
|
||||||
APIKey: secretAPIKey,
|
APIKey: secretAPIKey,
|
||||||
},
|
},
|
||||||
|
DNSChallenge: DNSChallengeConfig{
|
||||||
|
ProviderName: "cloudflare",
|
||||||
|
Provider: &cloudflare.Provider{APIToken: secretDNSToken},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := json.Marshal(cfg)
|
out, err := json.Marshal(cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
body := string(out)
|
body := string(out)
|
||||||
for _, secret := range []string{secretPostgresPass, secretClientSecret, secretAPIKey} {
|
for _, secret := range []string{secretPostgresPass, secretClientSecret, secretAPIKey, secretDNSToken} {
|
||||||
assert.NotContains(t, body, secret,
|
assert.NotContains(t, body, secret,
|
||||||
"marshalled Config must not contain secret %q", secret)
|
"marshalled Config must not contain secret %q", secret)
|
||||||
}
|
}
|
||||||
@@ -1017,3 +1026,201 @@ func TestExtraRecordsAreLowercased(t *testing.T) {
|
|||||||
t.Errorf("SetExtraRecords mismatch (-want +got):\n%s", diff)
|
t.Errorf("SetExtraRecords mismatch (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDNSChallengeConfig(t *testing.T) {
|
||||||
|
secret := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(secret, []byte("file-token\n"), 0o600))
|
||||||
|
|
||||||
|
base := `---
|
||||||
|
server_url: https://headscale.example.com
|
||||||
|
noise:
|
||||||
|
private_key_path: noise_private.key
|
||||||
|
prefixes:
|
||||||
|
v4: 100.64.0.0/10
|
||||||
|
database:
|
||||||
|
type: sqlite
|
||||||
|
dns:
|
||||||
|
magic_dns: true
|
||||||
|
override_local_dns: false
|
||||||
|
base_domain: ts.example.com
|
||||||
|
`
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
env map[string]string
|
||||||
|
wantErr string
|
||||||
|
wantZone string
|
||||||
|
wantToken string
|
||||||
|
wantNoProv bool
|
||||||
|
wantResolvers []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled by default",
|
||||||
|
yaml: base,
|
||||||
|
// Zone follows base_domain even when unused, so it is never empty.
|
||||||
|
wantZone: "ts.example.com",
|
||||||
|
wantNoProv: true,
|
||||||
|
wantResolvers: []string{"1.1.1.1:53", "8.8.8.8:53"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolvers default to port 53",
|
||||||
|
yaml: base + ` challenge:
|
||||||
|
resolvers: ["9.9.9.9", "2620:fe::fe", "127.0.0.1:5353"]
|
||||||
|
`,
|
||||||
|
wantZone: "ts.example.com",
|
||||||
|
wantNoProv: true,
|
||||||
|
wantResolvers: []string{"9.9.9.9:53", "[2620:fe::fe]:53", "127.0.0.1:5353"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled with inline token",
|
||||||
|
yaml: base + ` certificates:
|
||||||
|
enabled: true
|
||||||
|
challenge:
|
||||||
|
provider:
|
||||||
|
name: cloudflare
|
||||||
|
api_token: yaml-token
|
||||||
|
`,
|
||||||
|
wantZone: "ts.example.com",
|
||||||
|
wantToken: "yaml-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env only",
|
||||||
|
yaml: base,
|
||||||
|
env: map[string]string{
|
||||||
|
"HEADSCALE_DNS_CERTIFICATES_ENABLED": "true",
|
||||||
|
"HEADSCALE_DNS_CERTIFICATES_ZONE": "example.com",
|
||||||
|
"HEADSCALE_DNS_CHALLENGE_PROVIDER_NAME": "cloudflare",
|
||||||
|
"HEADSCALE_DNS_CHALLENGE_PROVIDER_API_TOKEN_PATH": secret,
|
||||||
|
},
|
||||||
|
wantZone: "example.com",
|
||||||
|
wantToken: "file-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env overrides yaml",
|
||||||
|
yaml: base + ` challenge:
|
||||||
|
provider:
|
||||||
|
name: cloudflare
|
||||||
|
api_token: yaml-token
|
||||||
|
`,
|
||||||
|
env: map[string]string{"HEADSCALE_DNS_CHALLENGE_PROVIDER_API_TOKEN": "env-token"},
|
||||||
|
wantZone: "ts.example.com",
|
||||||
|
wantToken: "env-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typo in provider key",
|
||||||
|
yaml: base + ` challenge:
|
||||||
|
provider:
|
||||||
|
name: cloudflare
|
||||||
|
auth_token: tok
|
||||||
|
`,
|
||||||
|
wantErr: "dns.challenge.provider is invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown provider",
|
||||||
|
yaml: base + ` challenge:
|
||||||
|
provider:
|
||||||
|
name: route53
|
||||||
|
`,
|
||||||
|
wantErr: "dns.challenge.provider is invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled without magic dns",
|
||||||
|
yaml: strings.Replace(base, "magic_dns: true", "magic_dns: false", 1) + ` certificates:
|
||||||
|
enabled: true
|
||||||
|
challenge:
|
||||||
|
provider:
|
||||||
|
name: cloudflare
|
||||||
|
api_token: tok
|
||||||
|
`,
|
||||||
|
wantErr: "dns.certificates requires dns.magic_dns",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled without provider",
|
||||||
|
yaml: base + ` certificates:
|
||||||
|
enabled: true
|
||||||
|
`,
|
||||||
|
wantErr: "dns.certificates requires dns.challenge.provider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "base_domain outside zone",
|
||||||
|
yaml: base + ` certificates:
|
||||||
|
enabled: true
|
||||||
|
zone: notexample.com
|
||||||
|
challenge:
|
||||||
|
provider:
|
||||||
|
name: cloudflare
|
||||||
|
api_token: tok
|
||||||
|
`,
|
||||||
|
wantErr: "dns.base_domain is not inside dns.certificates.zone",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zone suffix without label boundary",
|
||||||
|
yaml: base + ` certificates:
|
||||||
|
enabled: true
|
||||||
|
zone: ample.com
|
||||||
|
challenge:
|
||||||
|
provider:
|
||||||
|
name: cloudflare
|
||||||
|
api_token: tok
|
||||||
|
`,
|
||||||
|
wantErr: "dns.base_domain is not inside dns.certificates.zone",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase base_domain",
|
||||||
|
yaml: strings.Replace(base, "ts.example.com", "TS.example.com", 1) + ` certificates:
|
||||||
|
enabled: true
|
||||||
|
challenge:
|
||||||
|
provider:
|
||||||
|
name: cloudflare
|
||||||
|
api_token: tok
|
||||||
|
`,
|
||||||
|
wantErr: "dns.base_domain must be a lowercase DNS name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative propagation timeout",
|
||||||
|
yaml: base + ` challenge:
|
||||||
|
propagation_timeout: -1s
|
||||||
|
`,
|
||||||
|
wantErr: "dns.challenge.propagation_timeout must not be negative",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
|
||||||
|
for k, v := range tt.env {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(tt.yaml), 0o600))
|
||||||
|
require.NoError(t, LoadConfig(tmpDir, false))
|
||||||
|
|
||||||
|
cfg, err := LoadServerConfig()
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantZone, cfg.DNSConfig.Certificates.Zone)
|
||||||
|
|
||||||
|
if tt.wantResolvers != nil {
|
||||||
|
assert.Equal(t, tt.wantResolvers, cfg.DNSChallenge.Resolvers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantNoProv {
|
||||||
|
assert.Nil(t, cfg.DNSChallenge.Provider)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, ok := cfg.DNSChallenge.Provider.(*cloudflare.Provider)
|
||||||
|
require.True(t, ok, "provider is %T", cfg.DNSChallenge.Provider)
|
||||||
|
assert.Equal(t, tt.wantToken, provider.APIToken)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user