hscontrol: replace autocert with certmagic

Adds DNS-01 for headscale's own certificate through dns.challenge.provider,
with the same propagation check as node certificates. Storage format
changes: first start gets a new account and certificate.

Updates #2527
Updates #504
This commit is contained in:
Kristoffer Dalby committed 2026-10-18 20:53:18 +02:00
1 parent dcc01dc2b0
commit 5d15cc930d
13 files changed
+639 -134

No files matched your search

+21 -14
View File
@@ -278,7 +278,7 @@ var legacyTLSKeys = map[string]string{
var tlsDefaults = map[string]string{
"tls.acme.challenge_type": HTTP01ChallengeType,
"tls.acme.http_listen": ":http",
"tls.acme.cache_dir": "/var/www/.cache",
"tls.acme.cache_dir": "/var/lib/headscale/cache",
}
// warnLegacyTLSKeys points at the tls keys for flat keys in the config
@@ -749,11 +749,12 @@ func validateServerConfigInto(v *configValidator) {
validateListenerCollisions(v)
if ct := tlsKey("tls.acme.challenge_type"); ct != HTTP01ChallengeType && ct != TLSALPN01ChallengeType {
if ct := tlsKey("tls.acme.challenge_type"); ct != HTTP01ChallengeType &&
ct != TLSALPN01ChallengeType && ct != DNS01ChallengeType {
v.Add(&ConfigError{
Reason: "tls.acme.challenge_type has an unsupported value",
Current: []KV{{"tls.acme.challenge_type", ct}},
Allowed: []string{HTTP01ChallengeType, TLSALPN01ChallengeType},
Allowed: []string{HTTP01ChallengeType, TLSALPN01ChallengeType, DNS01ChallengeType},
Hint: "pick one of the allowed values; HTTP-01 is the default",
})
}
@@ -935,7 +936,21 @@ func validateCertificatesConfig(v *configValidator) {
})
}
if !viper.GetBool("dns.certificates.enabled") {
enabled := viper.GetBool("dns.certificates.enabled")
challengeType := tlsKey("tls.acme.challenge_type")
ownDNS01 := tlsKey("tls.acme.hostname") != "" && challengeType == DNS01ChallengeType
if (enabled || ownDNS01) && viper.GetString("dns.challenge.provider.name") == "" {
v.Add(&ConfigError{
Reason: "dns.certificates and tls.acme DNS-01 require dns.challenge.provider",
Current: []KV{{"dns.certificates.enabled", enabled}, {"tls.acme.challenge_type", challengeType}},
Allowed: hsdns.ProviderNames(),
Hint: "configure the DNS provider that hosts dns.base_domain or tls.acme.hostname under dns.challenge.provider",
See: dnsDocsURL,
})
}
if !enabled {
return
}
@@ -948,16 +963,6 @@ func validateCertificatesConfig(v *configValidator) {
})
}
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{
@@ -1017,6 +1022,8 @@ func dnsChallengeConfig() (DNSChallengeConfig, error) {
return cfg, err
}
// Only node certificates let a client pick what is published; a record
// shared by headscale's own certificate alone is harmless.
if iso, ok := provider.(hsdns.Isolator); ok && viper.GetBool("dns.certificates.enabled") {
err = iso.CheckIsolation()
if err != nil {
+54 -4
View File
@@ -1199,6 +1199,47 @@ dns:
`,
wantErr: "desktop.ts.example.com and laptop.ts.example.com share registration reg",
},
{
name: "acmedns one account for headscale's own certificate",
yaml: base + ` challenge:
provider:
name: acmedns
username: user
password: pass
subdomain: reg
server_url: https://auth.acme-dns.io
tls:
acme:
hostname: headscale.example.com
challenge_type: DNS-01
`,
wantZone: "ts.example.com",
},
{
name: "acmedns registration shared with headscale's own name",
yaml: base + ` certificates:
enabled: true
challenge:
provider:
name: acmedns
config:
laptop.ts.example.com:
username: user
password: pass
subdomain: reg
server_url: https://auth.acme-dns.io
headscale.example.com:
username: user
password: pass
subdomain: reg
server_url: https://auth.acme-dns.io
tls:
acme:
hostname: headscale.example.com
challenge_type: DNS-01
`,
wantErr: "headscale.example.com and laptop.ts.example.com share registration reg",
},
{
name: "enabled without magic dns",
yaml: strings.Replace(base, "magic_dns: true", "magic_dns: false", 1) + ` certificates:
@@ -1215,7 +1256,7 @@ dns:
yaml: base + ` certificates:
enabled: true
`,
wantErr: "dns.certificates requires dns.challenge.provider",
wantErr: "dns.certificates and tls.acme DNS-01 require dns.challenge.provider",
},
{
name: "base_domain outside zone",
@@ -1252,6 +1293,15 @@ dns:
`,
wantErr: "dns.base_domain must be a lowercase DNS name",
},
{
name: "tls DNS-01 without provider",
yaml: base + `tls:
acme:
hostname: headscale.example.com
challenge_type: DNS-01
`,
wantErr: "dns.certificates and tls.acme DNS-01 require dns.challenge.provider",
},
{
name: "negative propagation timeout",
yaml: base + ` challenge:
@@ -1337,7 +1387,7 @@ dns:
want: TLSConfig{ACME: ACMEConfig{
ChallengeType: HTTP01ChallengeType,
HTTPListen: ":http",
CacheDir: "/var/www/.cache",
CacheDir: "/var/lib/headscale/cache",
}},
},
{
@@ -1372,7 +1422,7 @@ tls:
ACME: ACMEConfig{
ChallengeType: HTTP01ChallengeType,
HTTPListen: ":http",
CacheDir: "/var/www/.cache",
CacheDir: "/var/lib/headscale/cache",
},
},
},
@@ -1404,7 +1454,7 @@ tls_letsencrypt_cache_dir: /var/lib/headscale/.cache
ACME: ACMEConfig{
ChallengeType: HTTP01ChallengeType,
HTTPListen: ":http",
CacheDir: "/var/www/.cache",
CacheDir: "/var/lib/headscale/cache",
},
},
},
+1
View File
@@ -7,6 +7,7 @@ const (
HTTPShutdownTimeout = 3 * time.Second
TLSALPN01ChallengeType = "TLS-ALPN-01"
HTTP01ChallengeType = "HTTP-01"
DNS01ChallengeType = "DNS-01"
JSONLogFormat = "json"
TextLogFormat = "text"