diff --git a/config-example.yaml b/config-example.yaml index 2d2c93907..d955350df 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -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: + # password: + # subdomain: + # 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: diff --git a/flakehashes.json b/flakehashes.json index 35438cd06..607687962 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-X7Yx0jOestuniDSZ6Aup4MpL6ys5IdF0AOu9rS4Bzyo=", - "sri": "sha256-KpiRQbHBAPp2BB+NodeSih2/WIlg52dfoOpYxbrVmBI=" + "goModSum": "sha256-mZW7/mudUF/yEjla+10vo9pTH/NGy5cpyOA7Educf9k=", + "sri": "sha256-LgnMChKtW6WnmpH5dEUxiLCRppyU3Rz21RZ6Q1MedKA=" } } diff --git a/go.mod b/go.mod index 80b7d50c9..1714ae0f6 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ef7c9f9a7..74402a27e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/hscontrol/dns/propagation.go b/hscontrol/dns/propagation.go index d779f9a47..e5052480f 100644 --- a/hscontrol/dns/propagation.go +++ b/hscontrol/dns/propagation.go @@ -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) { diff --git a/hscontrol/dns/propagation_test.go b/hscontrol/dns/propagation_test.go index a921f8179..e5ae9c3d1 100644 --- a/hscontrol/dns/propagation_test.go +++ b/hscontrol/dns/propagation_test.go @@ -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"}) diff --git a/hscontrol/dns/provider.go b/hscontrol/dns/provider.go index b87fdfc0e..dd4170c9e 100644 --- a/hscontrol/dns/provider.go +++ b/hscontrol/dns/provider.go @@ -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 +} diff --git a/hscontrol/dns/provider_test.go b/hscontrol/dns/provider_test.go index 665fabd93..37dd2843b 100644 --- a/hscontrol/dns/provider_test.go +++ b/hscontrol/dns/provider_test.go @@ -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()) +} diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 2eb63e479..df2492b23 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -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{ diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 1df599b3b..74ce4a307 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -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 diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index 09dacc2a2..e70e1142e 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -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) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index ae34927c1..06d255ebe 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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 }