Files
headscale/hscontrol/dns/challenge.go
T
Kristoffer Dalby 6cb950cd9e dns: add ACME challenge tracker and propagation check
Exact appends and deletes; values go 10m after last use or on Close.
Limits per node, one lock for provider writes. Waits for every
authoritative NS and holds a new value until the CA cache has expired.

Updates #2137
2026-10-18 20:53:18 +02:00

303 lines
8.6 KiB
Go

package dns
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/libdns/libdns"
"github.com/rs/zerolog/log"
)
var (
ErrTooManyChallenges = errors.New("too many pending ACME challenges for this node")
ErrChallengeProvider = errors.New("DNS provider failed")
ErrEmptyChallenge = errors.New("empty ACME challenge value")
ErrChallengesClosed = errors.New("ACME challenges are shutting down")
)
const (
// ChallengeTTL is the TTL of every challenge record headscale writes.
ChallengeTTL = time.Minute
// challengeLifetime outlives both authorizations of a wildcard order
// and the CA's validation retries. A retry of the same order resends
// the same value, which restarts the clock.
challengeLifetime = 10 * time.Minute
// maxChallengesPerNode covers a wildcard order (two values) racing a
// plain order. It also bounds how many records one node can make
// headscale write. The limit is per node, not per name: a node renames
// itself by changing its hostname, and every new name would come with
// fresh slots.
maxChallengesPerNode = 4
providerTimeout = 30 * time.Second
// caLookupDelay is how long after Present returns the CA may still
// make its first lookup of the value.
caLookupDelay = 5 * time.Second
)
// Challenges publishes ACME DNS-01 TXT values through a [Provider] and
// removes them again. Clients never ask for removal, so every value is
// deleted [challengeLifetime], plus the propagation timeout, after it was
// last published, or on [Challenges.Close]. Nothing is persisted: a crash,
// or a failed delete, leaves a few random TXT values behind.
type Challenges struct {
provider Provider
zone string
lifetime time.Duration
wait func(ctx context.Context, fqdn, value string) error
// mu guards the fields below and is held across provider calls.
// Providers that read-modify-write a record set lose a value when two
// changes to one name overlap, and the changes can come from different
// nodes: one renames away while its values are live, another takes the
// name. Issuance is rare, so one lock for every name is enough.
mu sync.Mutex
values map[challengeKey]*challengeValue
// waiting counts each node's Present calls waiting for propagation, so
// duplicate requests cannot pile up DNS queries.
waiting map[uint64]int
// closed refuses new values once Close has run. Noise connections
// outlive the HTTP server's shutdown, so Present still gets called.
closed bool
}
type challengeKey struct{ fqdn, value string }
type challengeValue struct {
// owner is the node the value counts against.
owner uint64
// record is what the provider returned from AppendRecords; deleting
// exactly it matters for providers that match on TTL or record ID.
record libdns.Record
timer *time.Timer
// expires moves on every use instead of resetting timer: a timer that
// already fired, blocked on the lock behind that use, must not delete
// the value.
expires time.Time
// shownAt is when Present returned, or will return, for this value.
shownAt time.Time
}
// NewChallenges returns a [Challenges] writing into zone. Present waits up
// to propagationTimeout for the zone's nameservers, found through
// resolvers, to serve a value.
func NewChallenges(
provider Provider,
zone string,
resolvers []string,
propagationTimeout time.Duration,
) *Challenges {
return &Challenges{
provider: provider,
zone: fqdn(zone),
// A value must outlive its own wait and hold, or it is deleted
// while Present still waits on it, and its hold with it.
lifetime: challengeLifetime + propagationTimeout,
wait: newTXTChecker(resolvers, propagationTimeout).wait,
values: make(map[challengeKey]*challengeValue),
waiting: make(map[uint64]int),
}
}
// Present publishes value as a TXT record at name for the node owner and
// returns once the zone's nameservers serve it. Values are added next to
// existing ones, never replacing them, and publishing a value again is a
// no-op.
func (c *Challenges) Present(ctx context.Context, owner uint64, name, value string) error {
if value == "" {
return ErrEmptyChallenge
}
k := challengeKey{fqdn: fqdn(name), value: value}
err := c.append(ctx, owner, k)
if err != nil {
return err
}
err = c.wait(ctx, k.fqdn, value)
if err == nil {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(time.Until(c.show(k))):
}
}
c.mu.Lock()
defer c.mu.Unlock()
c.waiting[owner]--
if c.waiting[owner] == 0 {
delete(c.waiting, owner)
}
// A failed wait keeps the value until its lifetime ends: deleting it
// at once would let a client that cancels requests in a loop make
// headscale call the provider without bound. A success restarts the
// lifetime, which is the CA's window to validate.
if v, ok := c.values[k]; ok && err == nil {
v.expires = time.Now().Add(c.lifetime)
}
return err
}
// append publishes k for owner unless it already is, and counts the caller
// as waiting.
func (c *Challenges) append(ctx context.Context, owner uint64, k challengeKey) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return ErrChallengesClosed
}
if c.waiting[owner] >= maxChallengesPerNode {
return ErrTooManyChallenges
}
// A node that resends a value another node published, on a name it has
// taken over, keeps that value alive, so the value moves to its count.
v, ok := c.values[k]
if (!ok || v.owner != owner) && c.count(owner) >= maxChallengesPerNode {
return ErrTooManyChallenges
}
if ok {
v.owner = owner
v.expires = time.Now().Add(c.lifetime)
c.waiting[owner]++
return nil
}
record := libdns.TXT{
Name: libdns.RelativeName(k.fqdn, c.zone),
TTL: ChallengeTTL,
Text: k.value,
}
// The call must finish even if the client goes away, or a record
// could be created that nothing tracks and deletes.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), providerTimeout)
defer cancel()
got, err := c.provider.AppendRecords(ctx, c.zone, []libdns.Record{record})
if err != nil {
return fmt.Errorf("%w: %w", ErrChallengeProvider, err)
}
stored := libdns.Record(record)
if len(got) == 1 && got[0].RR().Type == "TXT" && got[0].RR().Data == k.value {
stored = got[0]
}
c.values[k] = &challengeValue{
owner: owner,
record: stored,
expires: time.Now().Add(c.lifetime),
timer: time.AfterFunc(c.lifetime, func() { c.expire(k) }), //nolint:contextcheck // outlives the request
}
c.waiting[owner]++
return nil
}
// count returns how many values owner has published; c.mu must be held.
func (c *Challenges) count(owner uint64) int {
n := 0
for _, v := range c.values {
if v.owner == owner {
n++
}
}
return n
}
// show reserves when Present may return for k. The client asks the CA to
// validate at once, and the CA's resolvers cache the record set they find
// for its TTL. A resolver that cached this name for an earlier value, such
// as the first of a wildcard order or one left by the node that had the
// name before, would not see a new one, so a new value is held back until
// that cache has expired. The hold counts from every other value on the name,
// so a resend cannot cut its own hold short. Another name, such as the
// node's name before a rename, is cached on its own.
func (c *Challenges) show(k challengeKey) time.Time {
c.mu.Lock()
defer c.mu.Unlock()
at := time.Now()
for other, v := range c.values {
expired := v.shownAt.Add(ChallengeTTL + caLookupDelay)
if other.fqdn == k.fqdn && other != k && expired.After(at) {
at = expired
}
}
if v, ok := c.values[k]; ok {
v.shownAt = at
}
return at
}
// expire removes k once it has gone unused for the lifetime.
func (c *Challenges) expire(k challengeKey) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.values[k]
if !ok {
return
}
if d := time.Until(v.expires); d > 0 {
v.timer.Reset(d)
return
}
c.remove(k)
}
// remove deletes one tracked value from the provider; c.mu must be held.
// It never deletes by name alone: providers treat an empty value as
// "every record here", which would take out the operator's records and
// the other value of a wildcard order.
func (c *Challenges) remove(k challengeKey) {
v := c.values[k]
v.timer.Stop()
delete(c.values, k)
ctx, cancel := context.WithTimeout(context.Background(), providerTimeout)
defer cancel()
_, err := c.provider.DeleteRecords(ctx, c.zone, []libdns.Record{v.record})
if err != nil {
log.Warn().Err(err).Str("fqdn", k.fqdn).Msg("removing ACME challenge record")
}
}
// Close deletes every pending value and refuses new ones.
func (c *Challenges) Close() {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
for k := range c.values {
c.remove(k)
}
}
func fqdn(name string) string {
return strings.ToLower(strings.TrimSuffix(name, ".")) + "."
}