dns: add acme-dns provider

_acme-challenge is CNAMEd once to an acme-dns registration, so headscale
needs no DNS credentials. Node certificates need one registration per
name; CNAME chains are followed and writes never evict a live value.

Updates #2137
This commit is contained in:
Kristoffer Dalby committed 2026-10-18 20:53:18 +02:00
1 parent f16d5e1857
commit eef0d1fe47
12 files changed
+656 -16

No files matched your search

+16 -2
View File
@@ -376,11 +376,25 @@ dns:
# 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.
# acmedns, 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
#
# acmedns needs a registration per certificate name under config, and
# each _acme-challenge name CNAMEd to its registration's fulldomain. A
# node without one gets no certificate. A registration holds two
# pending challenges; more are refused until one expires.
# provider:
# name: acmedns
# config:
# laptop.example.com:
# username: <username from /register>
# password: <password from /register>
# subdomain: <subdomain from /register>
# server_url: https://auth.acme-dns.io
# Unix socket used for the CLI to connect without authentication
# Note: for production you will want to set this to something like:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"vendor": {
"goModSum": "sha256-X7Yx0jOestuniDSZ6Aup4MpL6ys5IdF0AOu9rS4Bzyo=",
"sri": "sha256-KpiRQbHBAPp2BB+NodeSih2/WIlg52dfoOpYxbrVmBI="
"goModSum": "sha256-mZW7/mudUF/yEjla+10vo9pTH/NGy5cpyOA7Educf9k=",
"sri": "sha256-LgnMChKtW6WnmpH5dEUxiLCRppyU3Rz21RZ6Q1MedKA="
}
}
+1
View File
@@ -24,6 +24,7 @@ require (
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jagottsicher/termcolor v1.0.2
github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974
github.com/libdns/acmedns v0.5.0
github.com/libdns/cloudflare v0.2.2
github.com/libdns/hetzner/v2 v2.0.1
github.com/libdns/libdns v1.1.1
+2
View File
@@ -325,6 +325,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE=
github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ=
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
github.com/libdns/hetzner/v2 v2.0.1 h1:phsNc9er8JbVOCsytLG7VuMbCdTPsv+WWwNX1WCbk4Q=
+51 -3
View File
@@ -14,9 +14,16 @@ import (
"github.com/rs/zerolog/log"
)
var ErrNotPropagated = errors.New("TXT record not visible on all authoritative nameservers")
var (
ErrNotPropagated = errors.New("TXT record not visible on all authoritative nameservers")
errCNAMEChain = errors.New("ACME challenge CNAME chain does not end")
)
const propagationInterval = 2 * time.Second
const (
propagationInterval = 2 * time.Second
// maxCNAMEHops is far more than delegating a challenge needs.
maxCNAMEHops = 8
)
// txtChecker waits until every authoritative nameserver of a zone serves
// a TXT value. The client asks the CA to validate as soon as set-dns
@@ -40,7 +47,7 @@ func newTXTChecker(resolvers []string, timeout time.Duration) txtChecker {
}
// wait returns nil once each nameserver of the zone holding fqdn answers
// it with value.
// it with value; for a CNAME, the chain's last name takes fqdn's place.
// A timeout of zero disables the check.
func (c txtChecker) wait(ctx context.Context, fqdn, value string) error {
if c.timeout <= 0 {
@@ -54,6 +61,17 @@ func (c txtChecker) wait(ctx context.Context, fqdn, value string) error {
recursive := resolverAt(c.resolvers...)
target, err := c.cnameTarget(ctx, recursive, fqdn)
if err != nil {
return err
}
if target != fqdn {
log.Debug().Str("fqdn", fqdn).Str("target", target).Msg("ACME challenge delegated by CNAME")
fqdn = target
}
nameservers, err := nameserversOf(ctx, recursive, fqdn)
if err != nil {
return fmt.Errorf("looking up nameservers of %s: %w", fqdn, c.via(err))
@@ -130,6 +148,36 @@ func (c txtChecker) wait(ctx context.Context, fqdn, value string) error {
}
}
// cnameTarget follows the CNAME chain from fqdn, as the CA does, and
// returns its last name, or fqdn without one. A CNAME delegates the
// challenge to another zone, as for acme-dns, sometimes through more.
func (c txtChecker) cnameTarget(ctx context.Context, r *net.Resolver, fqdn string) (string, error) {
chain := []string{fqdn}
for {
// Go reports only the first target of a chain.
target, err := r.LookupCNAME(ctx, fqdn)
if isNotFound(err) || err == nil && strings.EqualFold(target, fqdn) {
return fqdn, nil
}
if err != nil {
return "", fmt.Errorf("looking up CNAME of %s: %w", fqdn, c.via(err))
}
if slices.ContainsFunc(chain, func(n string) bool { return strings.EqualFold(n, target) }) {
return "", fmt.Errorf("%w: %s loops back to %s", errCNAMEChain, fqdn, target)
}
if len(chain) > maxCNAMEHops {
return "", fmt.Errorf("%w: more than %d hops from %s", errCNAMEChain, maxCNAMEHops, chain[0])
}
chain = append(chain, target)
fqdn = target
}
}
// nameserversOf returns the nameservers of the closest enclosing name of
// name that has any, which is the zone holding it.
func nameserversOf(ctx context.Context, r *net.Resolver, name string) ([]*net.NS, error) {
+64 -6
View File
@@ -14,11 +14,16 @@ import (
"github.com/stretchr/testify/require"
)
// fakeNameserver answers authoritatively for testZone: ns1 and ns2 are
// the zone's nameservers, and TXT answers come from txt.
// acmeZone stands in for an acme-dns server's zone.
const acmeZone = "acme.example.net."
// fakeNameserver answers authoritatively for testZone and acmeZone: ns1
// and ns2 are their nameservers, TXT answers come from txt and a name in
// cname is an alias.
type fakeNameserver struct {
mu sync.Mutex
txt map[string][]string
mu sync.Mutex
txt map[string][]string
cname map[string]string
// hideFor makes the first n TXT queries miss, like a nameserver that
// has not caught up yet.
hideFor atomic.Int32
@@ -39,8 +44,24 @@ func (f *fakeNameserver) ServeDNS(w mdns.ResponseWriter, r *mdns.Msg) {
q := r.Question[0]
hdr := mdns.RR_Header{Name: q.Name, Class: mdns.ClassINET, Ttl: 60, Rrtype: q.Qtype}
f.mu.Lock()
target, alias := f.cname[q.Name]
f.mu.Unlock()
if alias {
hdr.Rrtype = mdns.TypeCNAME
m.Answer = []mdns.RR{&mdns.CNAME{Hdr: hdr, Target: target}}
_ = w.WriteMsg(m)
return
}
switch q.Qtype {
case mdns.TypeNS:
if q.Name != testZone && q.Name != acmeZone {
break
}
m.Answer = []mdns.RR{
&mdns.NS{Hdr: hdr, Ns: "ns1." + testZone},
&mdns.NS{Hdr: hdr, Ns: "ns2." + testZone},
@@ -86,8 +107,8 @@ func startNameservers(t *testing.T) (txtChecker, *fakeNameserver, *fakeNameserve
t.Skip("needs 127.0.0.2, which only Linux routes by default")
}
ns1 := &fakeNameserver{txt: map[string][]string{}}
ns2 := &fakeNameserver{txt: map[string][]string{}}
ns1 := &fakeNameserver{txt: map[string][]string{}, cname: map[string]string{}}
ns2 := &fakeNameserver{txt: map[string][]string{}, cname: map[string]string{}}
var lc net.ListenConfig
@@ -146,6 +167,43 @@ func TestTXTCheckerWaitsForLaggingNameserver(t *testing.T) {
assert.Negative(t, ns2.hideFor.Load(), "ns2 was asked again after it missed")
}
// A challenge delegated by CNAME, as to acme-dns, is only served by the
// zone of the chain's last name; the names before it answer with a CNAME.
// The intermediate name sits in testZone, whose nameservers would answer
// without ever serving the value.
func TestTXTCheckerFollowsCNAMEChain(t *testing.T) {
c, ns1, ns2 := startNameservers(t)
relay := "relay." + testZone
target := "f0e3c7." + acmeZone
for _, ns := range []*fakeNameserver{ns1, ns2} {
ns.mu.Lock()
ns.cname[testName] = relay
ns.cname[relay] = target
ns.txt[target] = []string{"value"}
ns.mu.Unlock()
}
require.NoError(t, c.wait(context.Background(), testName, "value"))
c.timeout = 200 * time.Millisecond
err := c.wait(context.Background(), testName, "other")
require.ErrorIs(t, err, ErrNotPropagated)
require.ErrorContains(t, err, target)
// A loop fails at once instead of waiting out the timeout.
for _, ns := range []*fakeNameserver{ns1, ns2} {
ns.mu.Lock()
ns.cname[relay] = testName
ns.mu.Unlock()
}
err = c.wait(context.Background(), testName, "value")
require.ErrorIs(t, err, errCNAMEChain)
}
func TestTXTCheckerErrorNamesResolvers(t *testing.T) {
c := newTXTChecker([]string{"1.1.1.1:53", "8.8.8.8:53"}, time.Second)
err := c.via(&net.DNSError{Err: "no such host", Name: "ts.example.com.", Server: "127.0.0.53:53"})
+205 -1
View File
@@ -1,16 +1,21 @@
package dns
import (
"context"
"errors"
"fmt"
"maps"
"net/url"
"os"
"reflect"
"slices"
"strings"
"sync"
"time"
"github.com/go-viper/mapstructure/v2"
gigahost "github.com/kradalby/libdns-gigahost"
"github.com/libdns/acmedns"
"github.com/libdns/cloudflare"
"github.com/libdns/hetzner/v2"
"github.com/libdns/libdns"
@@ -18,6 +23,20 @@ import (
"github.com/libdns/rfc2136"
)
// Isolator is implemented by a provider that can answer several names'
// challenges from one record, as acme-dns does for names sharing a
// registration. The CA accepts a value found there for any of those
// names, so a node only gets a certificate for a name the provider
// isolates.
type Isolator interface {
// Isolates reports whether name's challenge is answered apart from
// every other name's, once CheckIsolation passed.
Isolates(name string) bool
// CheckIsolation reports why node certificate names would share a
// record.
CheckIsolation() error
}
// Provider writes ACME DNS-01 challenge records into a zone.
//
// It is append and delete only: a wildcard order puts two values on the
@@ -32,6 +51,7 @@ type Provider interface {
// plus its module; the set is curated because each provider is a
// dependency to keep current and its quirks become ours.
var providers = map[string]func() Provider{
"acmedns": func() Provider { return &acmeDNS{served: make(map[string][2]acmeDNSValue)} },
"cloudflare": func() Provider { return new(cloudflare.Provider) },
"gigahost": func() Provider { return new(gigahost.Provider) },
"hetzner": func() Provider { return new(hetzner.Provider) },
@@ -42,6 +62,12 @@ var providers = map[string]func() Provider{
var (
ErrUnknownProvider = errors.New("unknown DNS provider")
ErrProviderConfig = errors.New("invalid DNS provider configuration")
errAcmeDNSAccount = errors.New("acme-dns needs username, password, subdomain and server_url")
// The CA accepts a value found in a shared record for any of the
// names, so a node could publish another's under its own name.
errAcmeDNSOneAccount = errors.New("acme-dns with one account answers every name's challenge from one record; " +
"node certificates need a registration per name under config")
errAcmeDNSShared = errors.New("acme-dns registration mapped to more than one name")
)
// pathSuffix marks a setting whose value is read from a file, keeping
@@ -124,6 +150,7 @@ func NewProvider(
TagName: "json",
WeaklyTypedInput: true,
ErrorUnused: true,
Squash: true,
Result: provider,
})
if err != nil {
@@ -135,6 +162,13 @@ func NewProvider(
return nil, fmt.Errorf("%w for %s: %w", ErrProviderConfig, name, err)
}
if v, ok := provider.(interface{ validate() error }); ok {
err = v.validate()
if err != nil {
return nil, fmt.Errorf("%w for %s: %w", ErrProviderConfig, name, err)
}
}
return provider, nil
}
@@ -144,7 +178,7 @@ func jsonFields(provider Provider) map[string]reflect.Kind {
t := reflect.TypeOf(provider).Elem()
fields := make(map[string]reflect.Kind, t.NumField())
for f := range t.Fields() {
for _, f := range reflect.VisibleFields(t) {
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if !f.IsExported() || name == "" || name == "-" {
continue
@@ -155,3 +189,173 @@ func jsonFields(provider Provider) map[string]reflect.Kind {
return fields
}
// acmeDNSSpacing separates updates to a registration. acme-dns stamps an
// update with the second it lands in and overwrites the earlier stamp,
// either one on a tie, so which value is older holds only while updates
// land in distinct seconds. The margin absorbs clock slew.
const acmeDNSSpacing = time.Second + 100*time.Millisecond
// acmeDNS is the acme-dns provider. A registration serves the two values
// written to it last, and deleting is a no-op, so an update overwrites
// whichever is older even if a CA is still validating it. acmeDNS mirrors
// what each registration serves and refuses such an update.
type acmeDNS struct {
acmedns.Provider
mu sync.Mutex
// served is what each registration serves, oldest first.
served map[string][2]acmeDNSValue
}
// acmeDNSValue is a value acme-dns serves. It stays live, and must not
// be overwritten, until it is deleted here.
type acmeDNSValue struct {
text string
live bool
// written is when its update returned; acme-dns stamped it no later.
written time.Time
}
func (p *acmeDNS) AppendRecords(
ctx context.Context,
zone string,
recs []libdns.Record,
) ([]libdns.Record, error) {
p.mu.Lock()
defer p.mu.Unlock()
var appended []libdns.Record
for _, rec := range recs {
rr := rec.RR()
reg := p.registration(zone, rr.Name)
served := p.served[reg]
if served[0].live {
return appended, fmt.Errorf(
"%w: acme-dns registration %s would overwrite a value still in use",
ErrTooManyChallenges, reg,
)
}
select {
case <-ctx.Done():
return appended, ctx.Err()
case <-time.After(time.Until(served[1].written.Add(acmeDNSSpacing))):
}
got, err := p.Provider.AppendRecords(ctx, zone, []libdns.Record{rec})
appended = append(appended, got...)
// A failed update may still have reached acme-dns, so the older
// value counts as overwritten either way.
p.served[reg] = [2]acmeDNSValue{
served[1],
{text: rr.Data, live: err == nil, written: time.Now()},
}
if err != nil {
return appended, err
}
}
return appended, nil
}
func (p *acmeDNS) DeleteRecords(
ctx context.Context,
zone string,
recs []libdns.Record,
) ([]libdns.Record, error) {
p.mu.Lock()
defer p.mu.Unlock()
for _, rec := range recs {
rr := rec.RR()
reg := p.registration(zone, rr.Name)
served := p.served[reg]
// The older copy of a value written twice is the one to free, so
// the newer keeps serving it.
for i, v := range served {
if v.live && v.text == rr.Data {
served[i].live = false
break
}
}
p.served[reg] = served
}
return p.Provider.DeleteRecords(ctx, zone, recs)
}
// Isolates reports whether name has a registration of its own;
// CheckIsolation makes sure no other name is mapped to it.
func (p *acmeDNS) Isolates(name string) bool {
_, ok := p.Configs[name]
return ok
}
// CheckIsolation requires a registration per name: every name sharing a
// registration has its challenge answered from the same record.
func (p *acmeDNS) CheckIsolation() error {
if p.Configs == nil {
return errAcmeDNSOneAccount
}
names := make(map[string]string, len(p.Configs))
for _, name := range slices.Sorted(maps.Keys(p.Configs)) {
sub := p.Configs[name].Subdomain
if other, shared := names[sub]; shared {
return fmt.Errorf("%w: %s and %s share registration %s", errAcmeDNSShared, other, name, sub)
}
names[sub] = name
}
return nil
}
// registration returns the subdomain of the account acmedns writes a
// record with, picked the way acmedns picks it.
func (p *acmeDNS) registration(zone, name string) string {
if p.Configs == nil {
return p.Subdomain
}
domain := strings.Trim(libdns.AbsoluteName(name, zone), ".")
return p.Configs[strings.TrimPrefix(domain, "_acme-challenge.")].Subdomain
}
// validate reports at startup what acmedns would only report at the first
// certificate. It builds requests without checking the URL, so a bad
// server_url would panic.
func (p *acmeDNS) validate() error {
accounts := []acmedns.DomainConfig{{
Username: p.Username,
Password: p.Password,
Subdomain: p.Subdomain,
ServerURL: p.ServerURL,
}}
if p.Configs != nil {
accounts = slices.Collect(maps.Values(p.Configs))
}
for _, a := range accounts {
u, err := url.Parse(a.ServerURL)
missing := a.Username == "" || a.Password == "" || a.Subdomain == ""
if missing || err != nil || u.Host == "" {
return errAcmeDNSAccount
}
}
return nil
}
+203
View File
@@ -1,13 +1,21 @@
package dns
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
gigahost "github.com/kradalby/libdns-gigahost"
"github.com/libdns/acmedns"
"github.com/libdns/cloudflare"
"github.com/libdns/hetzner/v2"
"github.com/libdns/libdns"
"github.com/libdns/powerdns"
"github.com/libdns/rfc2136"
"github.com/stretchr/testify/assert"
@@ -102,6 +110,67 @@ func TestNewProvider(t *testing.T) {
settings: map[string]any{"server_url": "https://pdns.example.com", "api_token": "tok"},
want: &powerdns.Provider{ServerURL: "https://pdns.example.com", APIToken: "tok"},
},
{
name: "acmedns",
provider: "acmedns",
settings: map[string]any{
"username": "user",
"password_path": secret,
"subdomain": "reg",
"server_url": "https://auth.acme-dns.io",
},
want: &acmeDNS{
Username: "user",
Password: "from-file",
Subdomain: "reg",
ServerURL: "https://auth.acme-dns.io",
served: map[string][2]acmeDNSValue{},
},
},
{
name: "acmedns per domain",
provider: "acmedns",
settings: map[string]any{"config": map[string]any{
"node.ts.example.com": map[string]any{
"username": "user",
"password": "pass",
"subdomain": "reg",
"server_url": "https://auth.acme-dns.io",
},
}},
want: &acmeDNS{
Configs: map[string]acmedns.DomainConfig{
"node.ts.example.com": {
Username: "user",
Password: "pass",
Subdomain: "reg",
ServerURL: "https://auth.acme-dns.io",
},
},
served: map[string][2]acmeDNSValue{},
},
},
{
name: "acmedns server_url without scheme",
provider: "acmedns",
settings: map[string]any{
"username": "user",
"password": "pass",
"subdomain": "reg",
"server_url": "auth.acme-dns.io",
},
wantErr: ErrProviderConfig,
},
{
name: "acmedns without subdomain",
provider: "acmedns",
settings: map[string]any{
"username": "user",
"password": "pass",
"server_url": "https://auth.acme-dns.io",
},
wantErr: ErrProviderConfig,
},
{
name: "only a name",
provider: "gigahost",
@@ -124,3 +193,137 @@ func TestNewProvider(t *testing.T) {
})
}
}
// fakeAcmeDNS is an acme-dns server with one registration, reg. It has two
// rows, and an update overwrites the one stamped with the earlier second.
// acme-dns overwrites either on a tie; the fake picks the one written
// later, which a writer assuming first in, first out gets wrong.
type fakeAcmeDNS struct {
*httptest.Server
mu sync.Mutex
writes int
rows [2]fakeAcmeDNSRow
}
type fakeAcmeDNSRow struct {
txt string
stamp int64 // acme-dns's LastUpdate, in whole seconds
write int // the order of writes, which acme-dns does not keep
}
func newFakeAcmeDNS(t *testing.T) *fakeAcmeDNS {
t.Helper()
f := &fakeAcmeDNS{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var update struct{ Subdomain, Txt string }
err := json.NewDecoder(r.Body).Decode(&update)
if r.URL.Path != "/update" || err != nil || update.Subdomain != "reg" || r.Header.Get("X-Api-User") != "user" {
w.WriteHeader(http.StatusUnauthorized)
return
}
f.mu.Lock()
defer f.mu.Unlock()
i := 0
if f.rows[1].stamp < f.rows[0].stamp ||
(f.rows[1].stamp == f.rows[0].stamp && f.rows[1].write > f.rows[0].write) {
i = 1
}
f.writes++
f.rows[i] = fakeAcmeDNSRow{txt: update.Txt, stamp: time.Now().Unix(), write: f.writes}
}))
t.Cleanup(f.Close)
return f
}
func (f *fakeAcmeDNS) registration() map[string]any {
return map[string]any{
"username": "user",
"password": "pass",
"subdomain": "reg",
"server_url": f.URL,
}
}
func (f *fakeAcmeDNS) serving() []string {
f.mu.Lock()
defer f.mu.Unlock()
var txts []string
for _, row := range f.rows {
if row.txt != "" {
txts = append(txts, row.txt)
}
}
return txts
}
func acmeTXT(node, value string) []libdns.Record {
return []libdns.Record{libdns.TXT{Name: "_acme-challenge." + node, Text: value}}
}
// A registration serves the two values written last, and deleting is a
// no-op: an update overwrites the older value even if a CA may still be
// validating it, however many values were deleted. Two updates within one
// second carry the same stamp, leaving acme-dns free to overwrite either:
// after a then b, with a freed, c could overwrite b.
func TestAcmeDNSRefusesEviction(t *testing.T) {
t.Parallel()
srv := newFakeAcmeDNS(t)
p, err := NewProvider("acmedns", srv.registration(), noEnv)
require.NoError(t, err)
ctx := context.Background()
a, err := p.AppendRecords(ctx, testZone, acmeTXT("a", "value-a"))
require.NoError(t, err)
require.Len(t, a, 1, "certmagic needs the appended record back")
b, err := p.AppendRecords(ctx, testZone, acmeTXT("b", "value-b"))
require.NoError(t, err)
_, err = p.AppendRecords(ctx, testZone, acmeTXT("c", "value-c"))
require.ErrorIs(t, err, ErrTooManyChallenges)
// The newer value is free, but an update overwrites the older one.
_, err = p.DeleteRecords(ctx, testZone, b)
require.NoError(t, err)
_, err = p.AppendRecords(ctx, testZone, acmeTXT("c", "value-c"))
require.ErrorIs(t, err, ErrTooManyChallenges)
assert.ElementsMatch(t, []string{"value-a", "value-b"}, srv.serving(), "a refused value reached acme-dns")
_, err = p.DeleteRecords(ctx, testZone, a)
require.NoError(t, err)
c, err := p.AppendRecords(ctx, testZone, acmeTXT("c", "value-c"))
require.NoError(t, err)
assert.ElementsMatch(t, []string{"value-b", "value-c"}, srv.serving())
// b is free and now the older value; overwriting it leaves c alone.
_, err = p.AppendRecords(ctx, testZone, acmeTXT("d", "value-d"))
require.NoError(t, err)
assert.ElementsMatch(t, []string{"value-c", "value-d"}, srv.serving())
// Giving up while waiting to space updates writes nothing.
_, err = p.DeleteRecords(ctx, testZone, c)
require.NoError(t, err)
cancelled, cancel := context.WithCancel(ctx)
cancel()
_, err = p.AppendRecords(cancelled, testZone, acmeTXT("e", "value-e"))
require.ErrorIs(t, err, context.Canceled)
assert.ElementsMatch(t, []string{"value-c", "value-d"}, srv.serving())
}
+30 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change"
@@ -1070,13 +1071,39 @@ func TestGenerateMapResponseDebugOnlyOnFullMaps(t *testing.T) {
func TestCertDomains(t *testing.T) {
enabled := types.DNSConfig{Certificates: types.CertificatesConfig{Enabled: true}}
acmeDNS, err := dns.NewProvider("acmedns", map[string]any{"config": map[string]any{
"laptop.ts.example.com": map[string]any{
"username": "user",
"password": "pass",
"subdomain": "reg",
"server_url": "https://auth.acme-dns.io",
},
}}, func(string) (any, bool) { return nil, false })
require.NoError(t, err)
tests := []struct {
name string
node *types.Node
dns types.DNSConfig
provider dns.Provider
baseDomain string
want []string
}{
{
name: "own acme-dns registration",
node: &types.Node{GivenName: "laptop"},
dns: enabled,
provider: acmeDNS,
want: []string{"laptop.ts.example.com"},
},
{
// Without a registration of its own, the node's challenge would
// be answered from a record another name's challenge shares.
name: "no acme-dns registration of its own",
node: &types.Node{GivenName: "desktop"},
dns: enabled,
provider: acmeDNS,
},
{
name: "user owned",
node: &types.Node{GivenName: "laptop", UserID: new(uint(1))},
@@ -1136,8 +1163,9 @@ func TestCertDomains(t *testing.T) {
}
cfg := &types.Config{
BaseDomain: baseDomain,
DNSConfig: tt.dns,
BaseDomain: baseDomain,
DNSConfig: tt.dns,
DNSChallenge: types.DNSChallengeConfig{Provider: tt.provider},
TailcfgDNSConfig: &tailcfg.DNSConfig{
Proxied: true,
ExtraRecords: []tailcfg.DNSRecord{
+7
View File
@@ -975,6 +975,13 @@ func dnsChallengeConfig() (DNSChallengeConfig, error) {
return cfg, err
}
if iso, ok := provider.(hsdns.Isolator); ok && viper.GetBool("dns.certificates.enabled") {
err = iso.CheckIsolation()
if err != nil {
return cfg, fmt.Errorf("%w for %s: %w", hsdns.ErrProviderConfig, cfg.ProviderName, err)
}
}
cfg.Provider = provider
return cfg, nil
+67
View File
@@ -12,6 +12,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
hsdns "github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/libdns/cloudflare"
"github.com/spf13/viper"
@@ -1054,6 +1055,7 @@ dns:
wantToken string
wantNoProv bool
wantResolvers []string
wantIsolates string
}{
{
name: "disabled by default",
@@ -1124,6 +1126,63 @@ dns:
`,
wantErr: "dns.challenge.provider is invalid",
},
{
name: "acmedns one account for node certificates",
yaml: base + ` certificates:
enabled: true
challenge:
provider:
name: acmedns
username: user
password: pass
subdomain: reg
server_url: https://auth.acme-dns.io
`,
wantErr: "registration per name",
},
{
name: "acmedns registration per name",
yaml: base + ` certificates:
enabled: true
challenge:
provider:
name: acmedns
config:
laptop.ts.example.com:
username: user
password: pass
subdomain: reg-laptop
server_url: https://auth.acme-dns.io
desktop.ts.example.com:
username: user
password: pass
subdomain: reg-desktop
server_url: https://auth.acme-dns.io
`,
wantZone: "ts.example.com",
wantIsolates: "laptop.ts.example.com",
},
{
name: "acmedns registration shared by node names",
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
desktop.ts.example.com:
username: user
password: pass
subdomain: reg
server_url: https://auth.acme-dns.io
`,
wantErr: "desktop.ts.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:
@@ -1218,6 +1277,14 @@ dns:
return
}
if iso, ok := cfg.DNSChallenge.Provider.(hsdns.Isolator); ok {
if tt.wantIsolates != "" {
assert.True(t, iso.Isolates(tt.wantIsolates))
}
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)
+8
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
hsdns "github.com/juanfont/headscale/hscontrol/dns"
"github.com/juanfont/headscale/hscontrol/policy/matcher"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
@@ -814,6 +815,13 @@ func (nv NodeView) CertDomain(cfg *Config) (string, bool) {
return "", false
}
// A node may publish only its own name's challenge, which keeps it from
// other names' certificates only where the provider answers each name's
// challenge apart.
if iso, ok := cfg.DNSChallenge.Provider.(hsdns.Isolator); ok && !iso.Isolates(domain) {
return "", false
}
return domain, true
}