mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-27 08:46:49 +09:00
_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
362 lines
10 KiB
Go
362 lines
10 KiB
Go
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"
|
|
"github.com/libdns/powerdns"
|
|
"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
|
|
// same name, so SetRecords would replace the value the CA is validating.
|
|
type Provider interface {
|
|
libdns.RecordAppender
|
|
libdns.RecordDeleter
|
|
}
|
|
|
|
// providers is the compiled-in provider registry. Providers are decoded
|
|
// from configuration through their json tags, so adding one is one line
|
|
// 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) },
|
|
"powerdns": func() Provider { return new(powerdns.Provider) },
|
|
"rfc2136": func() Provider { return new(rfc2136.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
|
|
// secrets out of the configuration file.
|
|
const pathSuffix = "_path"
|
|
|
|
// ProviderNames returns the names of the compiled-in providers.
|
|
func ProviderNames() []string {
|
|
return slices.Sorted(maps.Keys(providers))
|
|
}
|
|
|
|
// NewProvider builds the named provider from its settings.
|
|
//
|
|
// settings holds the provider's keys as written in the configuration
|
|
// file. get looks up a single key and is consulted for every field of
|
|
// the provider, because environment variables only reach individual
|
|
// keys, never a map. Any string field can instead be read from a file
|
|
// named by "<field>_path". Unknown keys are an error so a typo does not
|
|
// silently leave a credential empty.
|
|
func NewProvider(
|
|
name string,
|
|
settings map[string]any,
|
|
get func(key string) (any, bool),
|
|
) (Provider, error) {
|
|
newProvider, ok := providers[strings.ToLower(name)]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %q", ErrUnknownProvider, name)
|
|
}
|
|
|
|
provider := newProvider()
|
|
|
|
input := make(map[string]any, len(settings))
|
|
for k, v := range settings {
|
|
input[strings.ToLower(k)] = v
|
|
}
|
|
|
|
delete(input, "name")
|
|
|
|
fields := jsonFields(provider)
|
|
for tag, kind := range fields {
|
|
if v, ok := get(tag); ok {
|
|
input[tag] = v
|
|
}
|
|
|
|
pathKey := tag + pathSuffix
|
|
if _, isField := fields[pathKey]; isField || kind != reflect.String {
|
|
continue
|
|
}
|
|
|
|
if v, ok := get(pathKey); ok {
|
|
input[pathKey] = v
|
|
}
|
|
|
|
path, ok := input[pathKey]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if _, both := input[tag]; both {
|
|
return nil, fmt.Errorf("%w: %s and %s are mutually exclusive", ErrProviderConfig, tag, pathKey)
|
|
}
|
|
|
|
value, err := os.ReadFile(os.ExpandEnv(fmt.Sprint(path)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: reading %s: %w", ErrProviderConfig, pathKey, err)
|
|
}
|
|
|
|
input[tag] = strings.TrimSpace(string(value))
|
|
delete(input, pathKey)
|
|
}
|
|
|
|
// Every provider needs a credential or a server: a block with only a
|
|
// name is a secret that was not set, and would fail at the first
|
|
// certificate instead of at startup.
|
|
if len(input) == 0 {
|
|
return nil, fmt.Errorf("%w for %s: no settings", ErrProviderConfig, name)
|
|
}
|
|
|
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
|
TagName: "json",
|
|
WeaklyTypedInput: true,
|
|
ErrorUnused: true,
|
|
Squash: true,
|
|
Result: provider,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating decoder: %w", err)
|
|
}
|
|
|
|
err = decoder.Decode(input)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
// jsonFields returns the json key and kind of each configurable field of
|
|
// the provider struct.
|
|
func jsonFields(provider Provider) map[string]reflect.Kind {
|
|
t := reflect.TypeOf(provider).Elem()
|
|
fields := make(map[string]reflect.Kind, t.NumField())
|
|
|
|
for _, f := range reflect.VisibleFields(t) {
|
|
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
if !f.IsExported() || name == "" || name == "-" {
|
|
continue
|
|
}
|
|
|
|
fields[name] = f.Type.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
|
|
}
|