From 0efe4b77980f79dda367161405725bd2ea8083ce Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 14 Oct 2026 13:12:06 +0000 Subject: [PATCH] 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 --- config-example.yaml | 24 ++++ hscontrol/types/config.go | 171 ++++++++++++++++++++++++++- hscontrol/types/config_test.go | 209 ++++++++++++++++++++++++++++++++- 3 files changed, 399 insertions(+), 5 deletions(-) diff --git a/config-example.yaml b/config-example.yaml index 0210de3f7..2d2c93907 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -358,6 +358,30 @@ dns: # Headscale processes this file on each change. # 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 # Note: for production you will want to set this to something like: unix_socket: /var/run/headscale/headscale.sock diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 50476aae7..205b656f2 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -1,17 +1,21 @@ package types import ( + "cmp" "errors" "fmt" "io/fs" + "net" "net/netip" "net/url" "os" + "slices" "strings" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" + hsdns "github.com/juanfont/headscale/hscontrol/dns" "github.com/juanfont/headscale/hscontrol/util" "github.com/prometheus/common/model" "github.com/rs/zerolog" @@ -21,6 +25,7 @@ import ( "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/dnstype" + "tailscale.com/util/dnsname" "tailscale.com/util/set" ) @@ -29,6 +34,8 @@ const ( PKCEMethodS256 string = "S256" defaultNodeStoreBatchSize = 100 + + dnsDocsURL = "https://headscale.net/stable/ref/dns/" ) var ( @@ -44,6 +51,12 @@ var ( 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 const ( @@ -139,6 +152,8 @@ type Config struct { // it can be used directly when sending Netmaps to clients. TailcfgDNSConfig *tailcfg.DNSConfig + DNSChallenge DNSChallengeConfig + UnixSocket string UnixSocketPermission fs.FileMode @@ -163,6 +178,29 @@ type DNSConfig struct { SearchDomains []string `mapstructure:"search_domains"` ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"` 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 { @@ -449,6 +487,9 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("dns.nameservers.global", []string{}) viper.SetDefault("dns.nameservers.split", map[string]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.verify_clients", true) @@ -707,7 +748,7 @@ func validateServerConfigInto(v *configValidator) { {"dns.nameservers.global", "[]"}, }, 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) validateDatabaseConfig(v) validateMagicDNSConfig(v) + validateCertificatesConfig(v) depr.Apply(v) } @@ -834,11 +876,110 @@ func validateMagicDNSConfig(v *configValidator) { {"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/", + 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 { return TLSConfig{ LetsEncrypt: LetsEncryptConfig{ @@ -1032,6 +1173,8 @@ func dns() (DNSConfig, error) { dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split") dns.SearchDomains = viper.GetStringSlice("dns.search_domains") 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") { var extraRecords []tailcfg.DNSRecord @@ -1344,6 +1487,25 @@ func LoadServerConfig() (*Config, error) { derpConfig := derpConfig() 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 _path", + See: dnsDocsURL, + Cause: err, + }) + } + oidcClientSecret := viper.GetString("oidc.client_secret") oidcClientSecretPath := viper.GetString("oidc.client_secret_path") @@ -1437,6 +1599,7 @@ func LoadServerConfig() (*Config, error) { DNSConfig: dnsConfig, TailcfgDNSConfig: dnsToTailcfgDNS(dnsConfig), + DNSChallenge: dnsChallenge, ACMEEmail: viper.GetString("acme_email"), 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", 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/", + See: dnsDocsURL, Cause: errServerURLSame, }) @@ -1528,7 +1691,7 @@ func addServerURLConfigError(v *configValidator, serverURL, baseDomain string, e 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/", + See: dnsDocsURL, Cause: errServerURLSuffix, }) diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index 10f808134..09dacc2a2 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -13,6 +13,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/juanfont/headscale/hscontrol/util" + "github.com/libdns/cloudflare" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "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"}, }, 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"}, }, 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"}, // }, SearchDomains: []string{"test.com", "bar.com"}, + Certificates: CertificatesConfig{Zone: "example.com"}, }, }, } @@ -873,6 +877,7 @@ func TestConfigJSONOmitsSecrets(t *testing.T) { secretPostgresPass = "p0stgres-secret-marker" 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 + secretDNSToken = "dns-provider-token-marker" //nolint:gosec // test marker, not a real credential ) cfg := &Config{ @@ -887,13 +892,17 @@ func TestConfigJSONOmitsSecrets(t *testing.T) { CLI: CLIConfig{ APIKey: secretAPIKey, }, + DNSChallenge: DNSChallengeConfig{ + ProviderName: "cloudflare", + Provider: &cloudflare.Provider{APIToken: secretDNSToken}, + }, } out, err := json.Marshal(cfg) require.NoError(t, err) body := string(out) - for _, secret := range []string{secretPostgresPass, secretClientSecret, secretAPIKey} { + for _, secret := range []string{secretPostgresPass, secretClientSecret, secretAPIKey, secretDNSToken} { assert.NotContains(t, body, 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) } } + +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) + }) + } +}