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:
Kristoffer Dalby committed 2026-10-18 20:53:18 +02:00
1 parent 6cb950cd9e
commit 0efe4b7798
3 files changed
+399 -5

No files matched your search

+24
View File
@@ -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
View File
@@ -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,
}) })
+208 -1
View File
@@ -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)
})
}
}