Both found by running the resolver locally against real upstreams. response_ttl is a duration (60s) and a DNS TTL is seconds, but eight places converted with a bare uint32(), which keeps the low 32 bits of the nanosecond count. A blocked name's negative answer carried an SOA TTL and minimum of 997195776 (about 31 years), so any client or downstream cache that honoured it would remember the block, and keep remembering it after the name was unblocked. blocklist.TTLSeconds converts properly (whole seconds, clamped to 2^31-1) and is used by the rule parser and the block, sinkhole and redirect answers. The tests read the TTL off the wire; with the old conversion they fail with 4165425152. An ordinary upstream NXDOMAIN was classified as an error. A name that does not exist is the normal result of a typo, a search-domain expansion or a leaked mDNS query, so on a real network the error metric counted mostly those and, with the access log's errors-only default, every one wrote a line. Only the upstream failing or refusing (SERVFAIL, REFUSED, FORMERR, NOTIMP) is an error now. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
235 lines
6.9 KiB
Go
235 lines
6.9 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/accesslog"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
func waitInflight(t *testing.T, h *harness, n int64) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for h.eng.Inflight() < n {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("only %d of %d queries reached the engine", h.eng.Inflight(), n)
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestIdenticalMissesShareOneUpstreamExchange(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
release := h.up.hold()
|
|
const n = 25
|
|
results := make([]Response, n)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
results[i] = h.ask("burst.test.", dns.TypeA)
|
|
}()
|
|
}
|
|
h.up.waitArrived(t)
|
|
waitInflight(t, h, n)
|
|
release()
|
|
wg.Wait()
|
|
|
|
if got := h.up.hitCount("burst.test."); got != 1 {
|
|
t.Fatalf("upstream saw %d queries for %d identical misses, want 1", got, n)
|
|
}
|
|
seen := map[*dns.Msg]bool{}
|
|
for i, r := range results {
|
|
requireRcode(t, r, dns.RcodeSuccess)
|
|
if len(r.Msg.Answer) != 1 {
|
|
t.Fatalf("client %d got %v", i, r.Msg)
|
|
}
|
|
if seen[r.Msg] {
|
|
t.Fatal("two clients were handed the same message; one's edits would reach the other")
|
|
}
|
|
seen[r.Msg] = true
|
|
}
|
|
// The answer was cached once, by the leader.
|
|
h.ask("burst.test.", dns.TypeA)
|
|
if got := h.up.hitCount("burst.test."); got != 1 {
|
|
t.Fatalf("a later query went upstream again: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestDifferentQuestionsDoNotShare(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
release := h.up.hold()
|
|
var wg sync.WaitGroup
|
|
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
|
for _, name := range []string{"one.test.", "two.test."} {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); h.ask(name, qt) }()
|
|
}
|
|
}
|
|
waitInflight(t, h, 4)
|
|
release()
|
|
wg.Wait()
|
|
if got := h.up.totalHits(); got != 4 {
|
|
t.Fatalf("upstream saw %d queries, want 4 (distinct names and types must not share)", got)
|
|
}
|
|
}
|
|
|
|
// TestWaitersSurviveTheLeaderLeaving: the client whose query started the
|
|
// exchange hanging up says nothing about the upstream, so the others must not
|
|
// be failed with its cancellation.
|
|
func TestWaitersSurviveTheLeaderLeaving(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
release := h.up.hold()
|
|
leaderCtx, cancelLeader := context.WithCancel(context.Background())
|
|
done := make(chan Response, 1)
|
|
go func() { done <- h.eng.Handle(leaderCtx, h.request("leave.test.", dns.TypeA)) }()
|
|
h.up.waitArrived(t)
|
|
|
|
var follower atomic.Pointer[Response]
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
r := h.ask("leave.test.", dns.TypeA)
|
|
follower.Store(&r)
|
|
}()
|
|
waitInflight(t, h, 2)
|
|
cancelLeader()
|
|
<-done
|
|
release()
|
|
wg.Wait()
|
|
requireRcode(t, *follower.Load(), dns.RcodeSuccess)
|
|
}
|
|
|
|
func TestFlightGroupCopiesAndIsolatesKeys(t *testing.T) {
|
|
var g flightGroup
|
|
var calls atomic.Int32
|
|
gate := make(chan struct{})
|
|
mk := func() (*dns.Msg, string, error) {
|
|
calls.Add(1)
|
|
<-gate
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("x.test.", dns.TypeA)
|
|
return m, "up", nil
|
|
}
|
|
k := flightKey{name: "x.test.", qtype: dns.TypeA}
|
|
msgs := make(chan *dns.Msg, 3)
|
|
for i := 0; i < 3; i++ {
|
|
go func() {
|
|
m, _, _, _ := g.do(context.Background(), k, mk)
|
|
msgs <- m
|
|
}()
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
close(gate)
|
|
a, b, c := <-msgs, <-msgs, <-msgs
|
|
if calls.Load() != 1 {
|
|
t.Fatalf("fn ran %d times", calls.Load())
|
|
}
|
|
if a == b || b == c || a == c {
|
|
t.Fatal("callers share a message")
|
|
}
|
|
// A different key (another subnet, say) is its own exchange.
|
|
k2 := k
|
|
k2.ecs = "192.0.2.0/24/0"
|
|
if _, _, shared, _ := g.do(context.Background(), k2, func() (*dns.Msg, string, error) { return new(dns.Msg), "up", nil }); shared {
|
|
t.Fatal("a different ECS shared an exchange")
|
|
}
|
|
}
|
|
|
|
func TestNonQueryOpcodesAreNotImplemented(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
for name, mut := range map[string]func(*dns.Msg){
|
|
"update": func(m *dns.Msg) { m.Opcode = dns.OpcodeUpdate },
|
|
"notify": func(m *dns.Msg) { m.Opcode = dns.OpcodeNotify },
|
|
"status": func(m *dns.Msg) { m.Opcode = dns.OpcodeStatus; m.Question = nil },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
req := h.request("zone.test.", dns.TypeSOA)
|
|
mut(req.Msg)
|
|
res := h.askWith(req)
|
|
requireRcode(t, res, dns.RcodeNotImplemented)
|
|
if n := h.up.totalHits(); n != 0 {
|
|
t.Fatalf("%d queries reached the upstream", n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPopularEntryIsRefreshedBeforeItExpires: a name that is being asked for
|
|
// is replaced in the cache before its TTL runs out, so no client is the one
|
|
// that finds it expired.
|
|
func TestPopularEntryIsRefreshedBeforeItExpires(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
clock: true,
|
|
serveStale: time.Minute,
|
|
cacheCfg: func(c *config.Cache) { c.Prefetch = true },
|
|
})
|
|
h.ask("hot.test.", dns.TypeA) // miss, stored with a 60s TTL
|
|
h.ask("hot.test.", dns.TypeA)
|
|
h.ask("hot.test.", dns.TypeA)
|
|
if got := h.up.hitCount("hot.test."); got != 1 {
|
|
t.Fatalf("setup: %d upstream queries", got)
|
|
}
|
|
|
|
h.clock.Advance(30 * time.Second) // half way: nothing yet
|
|
h.ask("hot.test.", dns.TypeA)
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := h.up.hitCount("hot.test."); got != 1 {
|
|
t.Fatalf("refreshed too early: %d upstream queries", got)
|
|
}
|
|
|
|
h.clock.Advance(25 * time.Second) // 5s left of 60: under a tenth
|
|
res := h.ask("hot.test.", dns.TypeA)
|
|
if !res.CacheHit || res.CacheKind != "hit" {
|
|
t.Fatalf("the query that triggers the refresh must still be served fresh: %+v", res)
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for h.up.hitCount("hot.test.") < 2 {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the entry was not refreshed ahead of its expiry")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// TestUnpopularEntryIsNotRefreshed: an entry nobody came back for is allowed
|
|
// to expire; refreshing everything would turn the cache into a poller.
|
|
func TestUnpopularEntryIsNotRefreshed(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
clock: true,
|
|
serveStale: time.Minute,
|
|
cacheCfg: func(c *config.Cache) { c.Prefetch = true },
|
|
})
|
|
h.ask("once.test.", dns.TypeA)
|
|
h.clock.Advance(57 * time.Second)
|
|
h.ask("once.test.", dns.TypeA) // a single hit, near expiry
|
|
time.Sleep(100 * time.Millisecond)
|
|
if got := h.up.hitCount("once.test."); got != 1 {
|
|
t.Fatalf("a once-asked name was refreshed: %d upstream queries", got)
|
|
}
|
|
}
|
|
|
|
// TestNXDOMAINIsAnAnswerNotAnError: a name that does not exist is a normal
|
|
// result. Counting it as an error buried real errors in the metrics and, with
|
|
// the access log logging errors by default, wrote a line for every typo.
|
|
func TestNXDOMAINIsAnAnswerNotAnError(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
before := h.totals()
|
|
res := h.ask("no-such-name.example.com.", dns.TypeA) // the fake upstream says NXDOMAIN outside .test
|
|
requireRcode(t, res, dns.RcodeNameError)
|
|
if res.Action != accesslog.ActionAllow {
|
|
t.Errorf("action = %q, want allow", res.Action)
|
|
}
|
|
if got := h.totals().Errors - before.Errors; got != 0 {
|
|
t.Errorf("the error counter moved by %d for an NXDOMAIN", got)
|
|
}
|
|
}
|