mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-21 05:56:34 +09:00
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:
1 parent
f16d5e1857
commit
eef0d1fe47
12 files changed
+656
-16
No files matched your search
+16
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"vendor": {
|
||||
"goModSum": "sha256-X7Yx0jOestuniDSZ6Aup4MpL6ys5IdF0AOu9rS4Bzyo=",
|
||||
"sri": "sha256-KpiRQbHBAPp2BB+NodeSih2/WIlg52dfoOpYxbrVmBI="
|
||||
"goModSum": "sha256-mZW7/mudUF/yEjla+10vo9pTH/NGy5cpyOA7Educf9k=",
|
||||
"sri": "sha256-LgnMChKtW6WnmpH5dEUxiLCRppyU3Rz21RZ6Q1MedKA="
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user