mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-21 14:06:32 +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.
|
# Defaults to 1.1.1.1 and 8.8.8.8.
|
||||||
# resolvers: []
|
# resolvers: []
|
||||||
|
|
||||||
# cloudflare, gigahost, hetzner, powerdns or rfc2136. The other keys are
|
# acmedns, cloudflare, gigahost, hetzner, powerdns or rfc2136. The other
|
||||||
# settings of that provider; add _path to a key to read it from a file.
|
# keys are settings of that provider; add _path to a key to read it from
|
||||||
|
# a file.
|
||||||
# provider:
|
# provider:
|
||||||
# name: cloudflare
|
# name: cloudflare
|
||||||
# api_token_path: /run/credentials/headscale.service/cloudflare-token
|
# 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
|
# Unix socket used for the CLI to connect without authentication
|
||||||
# Note: for production you will want to set this to something like:
|
# Note: for production you will want to set this to something like:
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"vendor": {
|
"vendor": {
|
||||||
"goModSum": "sha256-X7Yx0jOestuniDSZ6Aup4MpL6ys5IdF0AOu9rS4Bzyo=",
|
"goModSum": "sha256-mZW7/mudUF/yEjla+10vo9pTH/NGy5cpyOA7Educf9k=",
|
||||||
"sri": "sha256-KpiRQbHBAPp2BB+NodeSih2/WIlg52dfoOpYxbrVmBI="
|
"sri": "sha256-LgnMChKtW6WnmpH5dEUxiLCRppyU3Rz21RZ6Q1MedKA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ require (
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/jagottsicher/termcolor v1.0.2
|
github.com/jagottsicher/termcolor v1.0.2
|
||||||
github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974
|
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/cloudflare v0.2.2
|
||||||
github.com/libdns/hetzner/v2 v2.0.1
|
github.com/libdns/hetzner/v2 v2.0.1
|
||||||
github.com/libdns/libdns v1.1.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.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
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 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
|
||||||
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
||||||
github.com/libdns/hetzner/v2 v2.0.1 h1:phsNc9er8JbVOCsytLG7VuMbCdTPsv+WWwNX1WCbk4Q=
|
github.com/libdns/hetzner/v2 v2.0.1 h1:phsNc9er8JbVOCsytLG7VuMbCdTPsv+WWwNX1WCbk4Q=
|
||||||
|
|||||||
@@ -14,9 +14,16 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"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
|
// 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
|
// 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
|
// 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.
|
// A timeout of zero disables the check.
|
||||||
func (c txtChecker) wait(ctx context.Context, fqdn, value string) error {
|
func (c txtChecker) wait(ctx context.Context, fqdn, value string) error {
|
||||||
if c.timeout <= 0 {
|
if c.timeout <= 0 {
|
||||||
@@ -54,6 +61,17 @@ func (c txtChecker) wait(ctx context.Context, fqdn, value string) error {
|
|||||||
|
|
||||||
recursive := resolverAt(c.resolvers...)
|
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)
|
nameservers, err := nameserversOf(ctx, recursive, fqdn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("looking up nameservers of %s: %w", fqdn, c.via(err))
|
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
|
// nameserversOf returns the nameservers of the closest enclosing name of
|
||||||
// name that has any, which is the zone holding it.
|
// name that has any, which is the zone holding it.
|
||||||
func nameserversOf(ctx context.Context, r *net.Resolver, name string) ([]*net.NS, error) {
|
func nameserversOf(ctx context.Context, r *net.Resolver, name string) ([]*net.NS, error) {
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeNameserver answers authoritatively for testZone: ns1 and ns2 are
|
// acmeZone stands in for an acme-dns server's zone.
|
||||||
// the zone's nameservers, and TXT answers come from txt.
|
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 {
|
type fakeNameserver struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
txt map[string][]string
|
txt map[string][]string
|
||||||
|
cname map[string]string
|
||||||
// hideFor makes the first n TXT queries miss, like a nameserver that
|
// hideFor makes the first n TXT queries miss, like a nameserver that
|
||||||
// has not caught up yet.
|
// has not caught up yet.
|
||||||
hideFor atomic.Int32
|
hideFor atomic.Int32
|
||||||
@@ -39,8 +44,24 @@ func (f *fakeNameserver) ServeDNS(w mdns.ResponseWriter, r *mdns.Msg) {
|
|||||||
q := r.Question[0]
|
q := r.Question[0]
|
||||||
hdr := mdns.RR_Header{Name: q.Name, Class: mdns.ClassINET, Ttl: 60, Rrtype: q.Qtype}
|
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 {
|
switch q.Qtype {
|
||||||
case mdns.TypeNS:
|
case mdns.TypeNS:
|
||||||
|
if q.Name != testZone && q.Name != acmeZone {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
m.Answer = []mdns.RR{
|
m.Answer = []mdns.RR{
|
||||||
&mdns.NS{Hdr: hdr, Ns: "ns1." + testZone},
|
&mdns.NS{Hdr: hdr, Ns: "ns1." + testZone},
|
||||||
&mdns.NS{Hdr: hdr, Ns: "ns2." + 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")
|
t.Skip("needs 127.0.0.2, which only Linux routes by default")
|
||||||
}
|
}
|
||||||
|
|
||||||
ns1 := &fakeNameserver{txt: map[string][]string{}}
|
ns1 := &fakeNameserver{txt: map[string][]string{}, cname: map[string]string{}}
|
||||||
ns2 := &fakeNameserver{txt: map[string][]string{}}
|
ns2 := &fakeNameserver{txt: map[string][]string{}, cname: map[string]string{}}
|
||||||
|
|
||||||
var lc net.ListenConfig
|
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")
|
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) {
|
func TestTXTCheckerErrorNamesResolvers(t *testing.T) {
|
||||||
c := newTXTChecker([]string{"1.1.1.1:53", "8.8.8.8:53"}, time.Second)
|
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"})
|
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
|
package dns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
gigahost "github.com/kradalby/libdns-gigahost"
|
gigahost "github.com/kradalby/libdns-gigahost"
|
||||||
|
"github.com/libdns/acmedns"
|
||||||
"github.com/libdns/cloudflare"
|
"github.com/libdns/cloudflare"
|
||||||
"github.com/libdns/hetzner/v2"
|
"github.com/libdns/hetzner/v2"
|
||||||
"github.com/libdns/libdns"
|
"github.com/libdns/libdns"
|
||||||
@@ -18,6 +23,20 @@ import (
|
|||||||
"github.com/libdns/rfc2136"
|
"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.
|
// Provider writes ACME DNS-01 challenge records into a zone.
|
||||||
//
|
//
|
||||||
// It is append and delete only: a wildcard order puts two values on the
|
// 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
|
// plus its module; the set is curated because each provider is a
|
||||||
// dependency to keep current and its quirks become ours.
|
// dependency to keep current and its quirks become ours.
|
||||||
var providers = map[string]func() Provider{
|
var providers = map[string]func() Provider{
|
||||||
|
"acmedns": func() Provider { return &acmeDNS{served: make(map[string][2]acmeDNSValue)} },
|
||||||
"cloudflare": func() Provider { return new(cloudflare.Provider) },
|
"cloudflare": func() Provider { return new(cloudflare.Provider) },
|
||||||
"gigahost": func() Provider { return new(gigahost.Provider) },
|
"gigahost": func() Provider { return new(gigahost.Provider) },
|
||||||
"hetzner": func() Provider { return new(hetzner.Provider) },
|
"hetzner": func() Provider { return new(hetzner.Provider) },
|
||||||
@@ -42,6 +62,12 @@ var providers = map[string]func() Provider{
|
|||||||
var (
|
var (
|
||||||
ErrUnknownProvider = errors.New("unknown DNS provider")
|
ErrUnknownProvider = errors.New("unknown DNS provider")
|
||||||
ErrProviderConfig = errors.New("invalid DNS provider configuration")
|
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
|
// pathSuffix marks a setting whose value is read from a file, keeping
|
||||||
@@ -124,6 +150,7 @@ func NewProvider(
|
|||||||
TagName: "json",
|
TagName: "json",
|
||||||
WeaklyTypedInput: true,
|
WeaklyTypedInput: true,
|
||||||
ErrorUnused: true,
|
ErrorUnused: true,
|
||||||
|
Squash: true,
|
||||||
Result: provider,
|
Result: provider,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,6 +162,13 @@ func NewProvider(
|
|||||||
return nil, fmt.Errorf("%w for %s: %w", ErrProviderConfig, name, err)
|
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
|
return provider, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +178,7 @@ func jsonFields(provider Provider) map[string]reflect.Kind {
|
|||||||
t := reflect.TypeOf(provider).Elem()
|
t := reflect.TypeOf(provider).Elem()
|
||||||
fields := make(map[string]reflect.Kind, t.NumField())
|
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"), ",")
|
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||||
if !f.IsExported() || name == "" || name == "-" {
|
if !f.IsExported() || name == "" || name == "-" {
|
||||||
continue
|
continue
|
||||||
@@ -155,3 +189,173 @@ func jsonFields(provider Provider) map[string]reflect.Kind {
|
|||||||
|
|
||||||
return fields
|
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
|
package dns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
gigahost "github.com/kradalby/libdns-gigahost"
|
gigahost "github.com/kradalby/libdns-gigahost"
|
||||||
|
"github.com/libdns/acmedns"
|
||||||
"github.com/libdns/cloudflare"
|
"github.com/libdns/cloudflare"
|
||||||
"github.com/libdns/hetzner/v2"
|
"github.com/libdns/hetzner/v2"
|
||||||
|
"github.com/libdns/libdns"
|
||||||
"github.com/libdns/powerdns"
|
"github.com/libdns/powerdns"
|
||||||
"github.com/libdns/rfc2136"
|
"github.com/libdns/rfc2136"
|
||||||
"github.com/stretchr/testify/assert"
|
"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"},
|
settings: map[string]any{"server_url": "https://pdns.example.com", "api_token": "tok"},
|
||||||
want: &powerdns.Provider{ServerURL: "https://pdns.example.com", APIToken: "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",
|
name: "only a name",
|
||||||
provider: "gigahost",
|
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"
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/juanfont/headscale/hscontrol/db"
|
"github.com/juanfont/headscale/hscontrol/db"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/dns"
|
||||||
"github.com/juanfont/headscale/hscontrol/state"
|
"github.com/juanfont/headscale/hscontrol/state"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||||
@@ -1070,13 +1071,39 @@ func TestGenerateMapResponseDebugOnlyOnFullMaps(t *testing.T) {
|
|||||||
func TestCertDomains(t *testing.T) {
|
func TestCertDomains(t *testing.T) {
|
||||||
enabled := types.DNSConfig{Certificates: types.CertificatesConfig{Enabled: true}}
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
node *types.Node
|
node *types.Node
|
||||||
dns types.DNSConfig
|
dns types.DNSConfig
|
||||||
|
provider dns.Provider
|
||||||
baseDomain string
|
baseDomain string
|
||||||
want []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",
|
name: "user owned",
|
||||||
node: &types.Node{GivenName: "laptop", UserID: new(uint(1))},
|
node: &types.Node{GivenName: "laptop", UserID: new(uint(1))},
|
||||||
@@ -1136,8 +1163,9 @@ func TestCertDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &types.Config{
|
cfg := &types.Config{
|
||||||
BaseDomain: baseDomain,
|
BaseDomain: baseDomain,
|
||||||
DNSConfig: tt.dns,
|
DNSConfig: tt.dns,
|
||||||
|
DNSChallenge: types.DNSChallengeConfig{Provider: tt.provider},
|
||||||
TailcfgDNSConfig: &tailcfg.DNSConfig{
|
TailcfgDNSConfig: &tailcfg.DNSConfig{
|
||||||
Proxied: true,
|
Proxied: true,
|
||||||
ExtraRecords: []tailcfg.DNSRecord{
|
ExtraRecords: []tailcfg.DNSRecord{
|
||||||
|
|||||||
@@ -975,6 +975,13 @@ func dnsChallengeConfig() (DNSChallengeConfig, error) {
|
|||||||
return cfg, err
|
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
|
cfg.Provider = provider
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
hsdns "github.com/juanfont/headscale/hscontrol/dns"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/libdns/cloudflare"
|
"github.com/libdns/cloudflare"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -1054,6 +1055,7 @@ dns:
|
|||||||
wantToken string
|
wantToken string
|
||||||
wantNoProv bool
|
wantNoProv bool
|
||||||
wantResolvers []string
|
wantResolvers []string
|
||||||
|
wantIsolates string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "disabled by default",
|
name: "disabled by default",
|
||||||
@@ -1124,6 +1126,63 @@ dns:
|
|||||||
`,
|
`,
|
||||||
wantErr: "dns.challenge.provider is invalid",
|
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",
|
name: "enabled without magic dns",
|
||||||
yaml: strings.Replace(base, "magic_dns: true", "magic_dns: false", 1) + ` certificates:
|
yaml: strings.Replace(base, "magic_dns: true", "magic_dns: false", 1) + ` certificates:
|
||||||
@@ -1218,6 +1277,14 @@ dns:
|
|||||||
return
|
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)
|
provider, ok := cfg.DNSChallenge.Provider.(*cloudflare.Provider)
|
||||||
require.True(t, ok, "provider is %T", cfg.DNSChallenge.Provider)
|
require.True(t, ok, "provider is %T", cfg.DNSChallenge.Provider)
|
||||||
assert.Equal(t, tt.wantToken, provider.APIToken)
|
assert.Equal(t, tt.wantToken, provider.APIToken)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
hsdns "github.com/juanfont/headscale/hscontrol/dns"
|
||||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||||
@@ -814,6 +815,13 @@ func (nv NodeView) CertDomain(cfg *Config) (string, bool) {
|
|||||||
return "", false
|
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
|
return domain, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user