mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-28 01:06:50 +09:00
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
303 lines
8.6 KiB
Go
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, ".")) + "."
|
|
}
|