mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-27 16:56:49 +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
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package dns
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/dns/dnstest"
|
|
"github.com/libdns/libdns"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
testZone = "ts.example.com."
|
|
testName = "_acme-challenge.node.ts.example.com."
|
|
// renamedName is testName's node after it changed its hostname.
|
|
renamedName = "_acme-challenge.renamed.ts.example.com."
|
|
testOwner = 1
|
|
// operatorValue is a record headscale did not create and must keep.
|
|
operatorValue = "operator-owned"
|
|
)
|
|
|
|
var errAPIDown = errors.New("api down")
|
|
|
|
func newTestChallenges(p *dnstest.Provider) *Challenges {
|
|
return NewChallenges(p, "TS.example.com", nil, 0)
|
|
}
|
|
|
|
func TestChallengesAppendNotReplace(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
p.Add(testName, operatorValue)
|
|
c := newTestChallenges(p)
|
|
ctx := context.Background()
|
|
|
|
// A wildcard order sends two values for one name, one after the other.
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-1"))
|
|
require.NoError(t, c.Present(ctx, testOwner, "_acme-challenge.NODE.ts.example.com", "value-2"))
|
|
assert.ElementsMatch(t, []string{operatorValue, "value-1", "value-2"}, p.Values(testName))
|
|
|
|
// A retry of the same order resends the same value.
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-1"))
|
|
assert.Len(t, p.Calls(), 2, "resent value must not be appended again")
|
|
|
|
for _, call := range p.Calls() {
|
|
assert.Equal(t, testZone, call.Zone)
|
|
}
|
|
|
|
time.Sleep(challengeLifetime) //nolint:forbidigo // synctest fake clock
|
|
synctest.Wait()
|
|
|
|
assert.Equal(t, []string{operatorValue}, p.Values(testName))
|
|
})
|
|
}
|
|
|
|
// TestChallengesResendExtendsLifetime also pins that an expiry timer that
|
|
// fired just before a resend, and then waited for the lock, leaves the
|
|
// refreshed value alone.
|
|
func TestChallengesResendExtendsLifetime(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
c := newTestChallenges(p)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value"))
|
|
time.Sleep(challengeLifetime - time.Minute) //nolint:forbidigo // synctest fake clock
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value"))
|
|
|
|
c.expire(challengeKey{fqdn: testName, value: "value"})
|
|
time.Sleep(2 * time.Minute) //nolint:forbidigo // synctest fake clock
|
|
synctest.Wait()
|
|
assert.Equal(t, []string{"value"}, p.Values(testName))
|
|
|
|
time.Sleep(challengeLifetime) //nolint:forbidigo // synctest fake clock
|
|
synctest.Wait()
|
|
assert.Empty(t, p.Values(testName))
|
|
})
|
|
}
|
|
|
|
func TestChallengesLimitPerNode(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
c := newTestChallenges(p)
|
|
ctx := context.Background()
|
|
|
|
for i := range maxChallengesPerNode {
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-"+string(rune('a'+i))))
|
|
}
|
|
|
|
err := c.Present(ctx, testOwner, testName, "one-too-many")
|
|
require.ErrorIs(t, err, ErrTooManyChallenges)
|
|
|
|
// Renaming gives the node a new name, not new slots.
|
|
err = c.Present(ctx, testOwner, renamedName, "value")
|
|
require.ErrorIs(t, err, ErrTooManyChallenges)
|
|
assert.Len(t, p.Calls(), maxChallengesPerNode)
|
|
|
|
// Other nodes have their own limit.
|
|
require.NoError(t, c.Present(ctx, testOwner+1, "_acme-challenge.other.ts.example.com", "value"))
|
|
|
|
// A node that took the name over and resends a value there takes
|
|
// over its slot.
|
|
require.NoError(t, c.Present(ctx, testOwner+1, testName, "value-a"))
|
|
require.NoError(t, c.Present(ctx, testOwner, renamedName, "value"))
|
|
|
|
c.Close()
|
|
})
|
|
}
|
|
|
|
// TestChallengesHoldsNewValueForCACache pins that a second value on a name
|
|
// is answered only once the CA's cache of the first has expired: the CA
|
|
// validates the second value of a wildcard order seconds after the first.
|
|
func TestChallengesHoldsNewValueForCACache(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
c := newTestChallenges(&dnstest.Provider{})
|
|
ctx := context.Background()
|
|
start := time.Now()
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-1"))
|
|
assert.Equal(t, start, time.Now(), "first value is not held")
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner, renamedName, "value-2"))
|
|
assert.Equal(t, start, time.Now(), "other names, such as after a rename, are not held")
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-2"))
|
|
assert.Equal(t, start.Add(ChallengeTTL+caLookupDelay), time.Now())
|
|
|
|
shown := time.Now()
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-2"))
|
|
assert.Equal(t, shown, time.Now(), "a resent value is not held")
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner+1, testName, "value-3"))
|
|
assert.Equal(t, shown.Add(ChallengeTTL+caLookupDelay), time.Now(), "a node that took the name over is held too")
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
require.ErrorIs(t, c.Present(ctx, testOwner, testName, "value-4"), context.Canceled)
|
|
|
|
c.Close()
|
|
})
|
|
}
|
|
|
|
// TestChallengesResendKeepsHold pins that resending a value while its hold
|
|
// is pending does not cut the hold short.
|
|
func TestChallengesResendKeepsHold(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
c := newTestChallenges(&dnstest.Provider{})
|
|
ctx := context.Background()
|
|
start := time.Now()
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-1"))
|
|
|
|
go func() { _ = c.Present(ctx, testOwner, testName, "value-2") }()
|
|
|
|
time.Sleep(time.Second) //nolint:forbidigo // synctest fake clock
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-2"))
|
|
assert.Equal(t, start.Add(ChallengeTTL+caLookupDelay), time.Now())
|
|
|
|
c.Close()
|
|
})
|
|
}
|
|
|
|
// TestChallengesFailedWaitKeepsValue pins that a client that went away
|
|
// still gets its value appended and tracked, and that a failed or
|
|
// cancelled wait leaves the value for its lifetime: deleting at once would
|
|
// let a client that cancels in a loop drive unbounded provider calls.
|
|
func TestChallengesFailedWaitKeepsValue(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
c := newTestChallenges(p)
|
|
c.wait = func(ctx context.Context, _, _ string) error { return ctx.Err() }
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
require.ErrorIs(t, c.Present(ctx, testOwner, testName, "value"), context.Canceled)
|
|
assert.Equal(t, []string{"value"}, p.Values(testName), "append must finish for a gone client")
|
|
|
|
// A retry of the same value is not appended again.
|
|
require.NoError(t, c.Present(context.Background(), testOwner, testName, "value"))
|
|
assert.Len(t, p.Calls(), 1)
|
|
|
|
time.Sleep(challengeLifetime) //nolint:forbidigo // synctest fake clock
|
|
synctest.Wait()
|
|
assert.Empty(t, p.Values(testName))
|
|
})
|
|
}
|
|
|
|
// TestChallengesLimitsWaiters pins that duplicate requests for one name
|
|
// cannot pile up propagation waits.
|
|
func TestChallengesLimitsWaiters(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
c := newTestChallenges(p)
|
|
|
|
release := make(chan struct{})
|
|
c.wait = func(context.Context, string, string) error {
|
|
<-release
|
|
|
|
return nil
|
|
}
|
|
|
|
for range maxChallengesPerNode {
|
|
go func() { _ = c.Present(context.Background(), testOwner, testName, "value") }()
|
|
}
|
|
|
|
synctest.Wait()
|
|
require.ErrorIs(t, c.Present(context.Background(), testOwner, testName, "value"), ErrTooManyChallenges)
|
|
|
|
close(release)
|
|
synctest.Wait()
|
|
|
|
c.wait = func(context.Context, string, string) error { return nil }
|
|
require.NoError(t, c.Present(context.Background(), testOwner, testName, "value"))
|
|
c.Close()
|
|
})
|
|
}
|
|
|
|
func TestChallengesProviderError(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
p.SetError(errAPIDown)
|
|
c := newTestChallenges(p)
|
|
|
|
err := c.Present(context.Background(), testOwner, testName, "value")
|
|
require.ErrorIs(t, err, ErrChallengeProvider)
|
|
|
|
p.SetError(nil)
|
|
c.Close()
|
|
assert.Len(t, p.Calls(), 1, "failed append must not be tracked for deletion")
|
|
}
|
|
|
|
func TestChallengesClose(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
p.Add(testName, operatorValue)
|
|
c := newTestChallenges(p)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-1"))
|
|
require.NoError(t, c.Present(ctx, testOwner, testName, "value-2"))
|
|
c.Close()
|
|
|
|
assert.Equal(t, []string{operatorValue}, p.Values(testName))
|
|
|
|
for _, call := range p.Calls() {
|
|
assert.NotEmpty(t, call.Value, "a delete without value wipes the record set")
|
|
}
|
|
})
|
|
}
|
|
|
|
// appendGate holds AppendRecords until release is closed.
|
|
type appendGate struct {
|
|
*dnstest.Provider
|
|
|
|
entered, release chan struct{}
|
|
}
|
|
|
|
func (p appendGate) AppendRecords(
|
|
ctx context.Context,
|
|
zone string,
|
|
recs []libdns.Record,
|
|
) ([]libdns.Record, error) {
|
|
close(p.entered)
|
|
<-p.release
|
|
|
|
return p.Provider.AppendRecords(ctx, zone, recs)
|
|
}
|
|
|
|
// TestChallengesCloseRefusesNewValues pins that Close removes an append
|
|
// already under way and refuses later ones: Noise connections outlive
|
|
// the HTTP server's shutdown, so Present can still be called.
|
|
func TestChallengesCloseRefusesNewValues(t *testing.T) {
|
|
p := appendGate{Provider: &dnstest.Provider{}, entered: make(chan struct{}), release: make(chan struct{})}
|
|
c := NewChallenges(p, testZone, nil, 0)
|
|
|
|
presented := make(chan error)
|
|
|
|
go func() { presented <- c.Present(context.Background(), testOwner, testName, "in-flight") }()
|
|
|
|
<-p.entered
|
|
|
|
closed := make(chan struct{})
|
|
|
|
go func() {
|
|
c.Close()
|
|
close(closed)
|
|
}()
|
|
|
|
close(p.release)
|
|
<-closed
|
|
require.NoError(t, <-presented)
|
|
assert.Empty(t, p.Values(testName), "append in flight during Close must be removed")
|
|
|
|
err := c.Present(context.Background(), testOwner+1, testName, "late")
|
|
require.ErrorIs(t, err, ErrChallengesClosed)
|
|
assert.Len(t, p.Calls(), 2, "only the in-flight append and its delete")
|
|
}
|
|
|
|
func TestChallengesEmptyValue(t *testing.T) {
|
|
p := &dnstest.Provider{}
|
|
c := newTestChallenges(p)
|
|
|
|
require.ErrorIs(t, c.Present(context.Background(), testOwner, testName, ""), ErrEmptyChallenge)
|
|
assert.Empty(t, p.Calls())
|
|
}
|
|
|
|
// serialProvider records whether two provider calls overlapped. Providers
|
|
// that read a record set and, one API round trip later, write back what
|
|
// they computed from it, as PowerDNS does, lose one of two overlapping
|
|
// changes to a name.
|
|
type serialProvider struct {
|
|
*dnstest.Provider
|
|
|
|
busy, overlapped atomic.Bool
|
|
// entered gets a value as each call starts.
|
|
entered chan struct{}
|
|
}
|
|
|
|
func (p *serialProvider) call() func() {
|
|
if !p.busy.CompareAndSwap(false, true) {
|
|
p.overlapped.Store(true)
|
|
}
|
|
|
|
p.entered <- struct{}{}
|
|
|
|
time.Sleep(20 * time.Millisecond) //nolint:forbidigo // the API round trip
|
|
|
|
return func() { p.busy.Store(false) }
|
|
}
|
|
|
|
func (p *serialProvider) AppendRecords(
|
|
ctx context.Context,
|
|
zone string,
|
|
recs []libdns.Record,
|
|
) ([]libdns.Record, error) {
|
|
defer p.call()()
|
|
|
|
return p.Provider.AppendRecords(ctx, zone, recs)
|
|
}
|
|
|
|
func (p *serialProvider) DeleteRecords(
|
|
ctx context.Context,
|
|
zone string,
|
|
recs []libdns.Record,
|
|
) ([]libdns.Record, error) {
|
|
defer p.call()()
|
|
|
|
return p.Provider.DeleteRecords(ctx, zone, recs)
|
|
}
|
|
|
|
// TestChallengesNameTakenOver pins that a node that took over a name
|
|
// cannot interleave its provider writes with the removal of a value the
|
|
// node that had the name left there, whichever starts first. It runs on
|
|
// the real clock: synctest cannot advance time while a goroutine waits on
|
|
// a mutex.
|
|
func TestChallengesNameTakenOver(t *testing.T) {
|
|
p := &serialProvider{Provider: &dnstest.Provider{}, entered: make(chan struct{}, 8)}
|
|
c := NewChallenges(p, testZone, nil, 0)
|
|
ctx := context.Background()
|
|
|
|
for i, name := range []string{testName, renamedName} {
|
|
require.NoError(t, c.Present(ctx, testOwner, name, "old"))
|
|
<-p.entered
|
|
|
|
// testOwner renamed away, and "old" expires while the node that
|
|
// took the name publishes there. It was shown long ago, so nothing
|
|
// is held.
|
|
old := challengeKey{fqdn: name, value: "old"}
|
|
|
|
c.mu.Lock()
|
|
c.values[old].expires = time.Time{}
|
|
c.values[old].shownAt = time.Time{}
|
|
c.mu.Unlock()
|
|
|
|
first := func() { c.expire(old) }
|
|
second := func() { assert.NoError(t, c.Present(ctx, testOwner+1, name, "new")) }
|
|
|
|
if i == 1 {
|
|
first, second = second, first
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
first()
|
|
close(done)
|
|
}()
|
|
|
|
<-p.entered
|
|
second()
|
|
<-done
|
|
<-p.entered
|
|
|
|
assert.Equal(t, []string{"new"}, p.Values(name))
|
|
}
|
|
|
|
assert.False(t, p.overlapped.Load(), "provider calls overlapped")
|
|
c.Close()
|
|
}
|