Cover the engine, config.Size and metrics; fix the five bugs they found

internal/engine, internal/config and internal/metrics had no test file at all,
and writing them turned up five real bugs.

internal/engine (23 tests, new harness_test.go): a wired engine over a fake
upstream on loopback, a local zone, a blocklist, a cache, a query log and an
injectable clock. It pins the pipeline order end to end -- ACL deny before the
local zone, local zone before blocklist, blocklist before cache, drop sends
nothing, the concurrency and rate-limit refusals, serve-stale served with TTL 1
and refreshed once per key, truncation against a 512-byte client, and exactly
one metrics record and one query-log entry per query.

Five fixes:

- config.ParseSize ignored the unit multiplier, so 512MiB parsed as 512 bytes
  and the whole memory envelope was inert for anyone who wrote a unit.

- finalize (EDNS0 and the UDP size) ran only on the cache-hit path, so a large
  uncached answer left oversized for a 512-byte client. It now runs on the
  upstream, local-zone and blocklist paths too.

- The upstream UDP client read buffer was hard-coded to 1232 while we advertise
  dns.max_udp_size, so with a larger buffer configured every answer above 1232
  bytes was read truncated and became SERVFAIL. The pool takes the size now.

- LRU.prefetchGate was a 64-slot global cap, not the single flight its comment
  promised: a burst of stale hits launched up to 64 duplicate refreshes of one
  name. It is a per-key latch now; distinct names still refresh in parallel.

- metrics.RecordMiss and RecordStaleHit moved only the Prometheus collector, so
  the Cache page's misses and stale hits read 0 for the life of the process.

AGENTS.md gains the trap that cost the most time: this version of miekg/dns
reports a character-string over 255 bytes as zero length, so such a message
silently fails to pack, and the write failure looks like a client timeout.
This commit is contained in:
developer committed 2026-09-27 16:03:21 +00:00
1 parent 48d37db15b
commit 4f49033f1b
12 files changed
+1837 -37

No files matched your search

+10 -4
View File
@@ -314,7 +314,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
// Group-level domain policy.
if e.groupBlocks(decision, qname) {
res.Msg = blockedResponse(q, e.blockCfg(), nil)
res.Msg = e.finalize(blockedResponse(q, e.blockCfg(), nil), q, req)
res.Action = qlog.ActionBlock
res.BlockSource = "acl:" + decision.Group
e.finish(req, res, start, decision)
@@ -324,7 +324,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
// Local zone data.
if store := e.local.Get(); store != nil && store.Count() > 0 {
if local, ok := e.answerLocal(q, store); ok {
res.Msg = local
res.Msg = e.finalize(local, q, req)
res.Action = qlog.ActionAllow
e.finish(req, res, start, decision)
return res
@@ -348,7 +348,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
e.finish(req, res, start, decision)
return res
default:
res.Msg = e.blockedMsg(q, blocked)
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
res.Action = qlog.ActionBlock
res.BlockSource = blocked.Source
res.CacheHit = false
@@ -407,7 +407,13 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
return res
}
metrics.RecordUpstreamLatency(addr, time.Since(upStart))
reply := e.fromUpstream(q, up, req)
// finalize is what makes the answer safe to put on the wire: it echoes the
// client's question and id verbatim and truncates to the client's
// advertised buffer. Without it an uncached multi-kilobyte answer left
// here oversized for a 512-byte UDP client, which no responder can deliver
// and the client silently drops. (The cache-hit path above has always
// done this; the uncached path did not.)
reply := e.finalize(e.fromUpstream(q, up, req), q, req)
res.Msg = reply
res.Upstream = addr
res.Action = qlog.ActionAllow
+790
View File
@@ -0,0 +1,790 @@
package engine
import (
"fmt"
"net"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
)
// testZone is the local zone the harness loads when a test asks for one.
const testZone = `$TTL 60
local. SOA ns.local. hostmaster.local. 1 3600 600 604800 60
local. NS ns.local.
ns.local. A 192.168.1.2
host.local. A 192.168.1.5
*.wild.local. A 192.168.1.50
`
// loopbackGroup builds an ACL with one group that matches this test's client
// (127.0.0.1), so a test can attach a policy without inventing CIDRs.
func loopbackGroup(g config.Group) config.ACL {
if g.Name == "" {
g.Name = "test"
}
if g.Policy == "" {
g.Policy = "allow"
}
if len(g.Match.CIDRs) == 0 {
g.Match.CIDRs = []string{"127.0.0.0/8"}
}
return config.ACL{DefaultPolicy: "allow", Groups: []config.Group{g}}
}
// TestACLDenyWinsOverLocalZone pins the first ordering rule: a denied client
// is refused before the local zone, the blocklist or the cache is consulted.
func TestACLDenyWinsOverLocalZone(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{Policy: "deny"}) },
rules: []blocklist.Rule{{Name: "denied.local"}},
localZone: testZone,
})
// host.local. is in the local zone and denied.local. is on the
// blocklist: both must come back REFUSED, which is only possible if the
// ACL ran first.
for _, name := range []string{"host.local.", "denied.local."} {
res := h.ask(name, dns.TypeA)
requireRcode(t, res, dns.RcodeRefused)
requireAction(t, res, qlog.ActionRefused)
if res.Group != "test" {
t.Fatalf("group = %q, want %q", res.Group, "test")
}
if res.GroupPolicy != "deny" {
t.Fatalf("group policy = %q, want deny", res.GroupPolicy)
}
if res.Client != "127.0.0.1" {
t.Fatalf("client = %q, want 127.0.0.1", res.Client)
}
requireNoUpstreamHit(t, h, name)
}
}
// TestLocalZoneWinsOverBlocklist pins the second rule: locally defined
// authoritative data is answered before the blocklist, so an operator can
// un-block a name in a zone file.
func TestLocalZoneWinsOverBlocklist(t *testing.T) {
h := newHarness(t, hopt{
rules: []blocklist.Rule{{Name: "host.local"}, {Name: "*.wild.local"}},
localZone: testZone,
})
res := h.ask("host.local.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
if got := answerA(res.Msg); got != "192.168.1.5" {
t.Fatalf("answer = %q, want 192.168.1.5 (blocked instead?)", got)
}
if res.Msg == nil || !res.Msg.Authoritative {
t.Fatal("local zone answer is not authoritative")
}
requireNoUpstreamHit(t, h, "host.local.")
// A wildcard rule covers the subdomains of wild.local but the zone's
// own wildcard record answers first.
w := h.ask("anything.wild.local.", dns.TypeA)
if got := answerA(w.Msg); got != "192.168.1.50" {
t.Fatalf("wildcard answer = %q, want 192.168.1.50", got)
}
}
// TestLocalZoneNxDomainForAZoneItOwns pins the negative case: a name inside a
// loaded zone that does not exist is NXDOMAIN with the zone's SOA, and never
// reaches the upstream.
func TestLocalZoneNxDomainForAZoneItOwns(t *testing.T) {
h := newHarness(t, hopt{localZone: testZone})
res := h.ask("nope.local.", dns.TypeA)
requireRcode(t, res, dns.RcodeNameError)
if len(res.Msg.Ns) == 0 {
t.Fatal("NXDOMAIN without an SOA in the authority section")
}
requireNoUpstreamHit(t, h, "nope.local.")
}
// TestBlocklistWinsOverCache pins the third rule: a cached answer must not
// outrank a blocklist rule. A name cached with a real address is still
// blocked, and the blocklist is answered without touching the network.
func TestBlocklistWinsOverCache(t *testing.T) {
h := newHarness(t, hopt{rules: []blocklist.Rule{{Name: "blocked.example.test"}}})
// Seed the cache with the exact key the engine will compute, so a pass
// would be visible as a cache hit.
seed := new(dns.Msg)
seed.SetQuestion("blocked.example.test.", dns.TypeA)
rr, _ := dns.NewRR("blocked.example.test. 60 IN A 192.0.2.55")
seed.Answer = append(seed.Answer, rr)
key := cache.QueryKey("blocked.example.test.", dns.TypeA, dns.ClassINET, false, 0)
h.cache.PutResponse(key, seed, "seed")
if got := h.cache.Get(key); got.Kind != cache.Fresh {
t.Fatalf("seeded cache entry is %v, want Fresh", got.Kind)
}
res := h.ask("blocked.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionBlock)
requireRcode(t, res, dns.RcodeNameError)
if res.CacheHit {
t.Fatal("a blocked query reported a cache hit: the cache outranked the blocklist")
}
if res.BlockSource != "test" {
t.Fatalf("block source = %q, want %q", res.BlockSource, "test")
}
if answerA(res.Msg) == "192.0.2.55" {
t.Fatal("the cached address was served for a blocked name")
}
requireNoUpstreamHit(t, h, "blocked.example.test.")
}
// TestBlocklistDropSendsNothing pins the "drop" action: no response at all,
// not an empty one.
func TestBlocklistDropSendsNothing(t *testing.T) {
h := newHarness(t, hopt{rules: []blocklist.Rule{
{Name: "drop.example.test", Action: blocklist.ActionDrop},
}})
res := h.ask("drop.example.test.", dns.TypeA)
if !res.Drop {
t.Fatal("Drop = false, want true")
}
if res.Msg != nil {
t.Fatal("a dropped query produced a message to send")
}
requireAction(t, res, qlog.ActionDrop)
requireNoUpstreamHit(t, h, "drop.example.test.")
}
// TestAllowRuleBeatsBlockRule pins the whitelist: an explicit allow rule wins
// over a list entry, and the name resolves.
func TestAllowRuleBeatsBlockRule(t *testing.T) {
h := newHarness(t, hopt{rules: []blocklist.Rule{
{Name: "*.example.test"},
{Name: "ok.example.test", Action: blocklist.ActionAllow},
}})
res := h.ask("ok.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
if got := answerA(res.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want the upstream address 203.0.113.7", got)
}
blocked := h.ask("ads.example.test.", dns.TypeA)
requireAction(t, blocked, qlog.ActionBlock)
}
// TestGlobalConcurrencyCapRefuses pins the overload refusal: a saturated
// global budget is answered SERVFAIL and counted as dropped, not queued.
func TestGlobalConcurrencyCapRefuses(t *testing.T) {
h := newHarness(t, hopt{
server: func(s *config.Server) { s.MaxConcurrent = 1 },
})
open := h.up.hold()
var wg sync.WaitGroup
first := make(chan Response, 1)
wg.Add(1)
go func() {
defer wg.Done()
first <- h.ask("slow.example.test.", dns.TypeA)
}()
// The first query is now parked inside the upstream exchange, holding the
// one global slot.
h.up.waitArrived(t)
second := h.ask("other.example.test.", dns.TypeA)
requireRcode(t, second, dns.RcodeServerFailure)
requireAction(t, second, qlog.ActionError)
if h.eng.Dropped() != 1 {
t.Fatalf("dropped = %d, want 1", h.eng.Dropped())
}
// The refusal must not have reached the network.
requireNoUpstreamHit(t, h, "other.example.test.")
open()
wg.Wait()
res := <-first
requireAction(t, res, qlog.ActionAllow)
if got := answerA(res.Msg); got != "203.0.113.7" {
t.Fatalf("first answer = %q, want 203.0.113.7", got)
}
if n := h.eng.Inflight(); n != 0 {
t.Fatalf("inflight = %d after both queries finished, want 0", n)
}
}
// TestPerClientConcurrencyCapRefuses pins the per-client cap, which is checked
// before the global one so one noisy client cannot take the whole budget.
func TestPerClientConcurrencyCapRefuses(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) {
*a = loopbackGroup(config.Group{MaxConcurrent: 1})
},
// A generous global budget, so a refusal can only come from the
// per-client limiter.
server: func(s *config.Server) { s.MaxConcurrent = 64 },
})
open := h.up.hold()
var wg sync.WaitGroup
first := make(chan Response, 1)
wg.Add(1)
go func() {
defer wg.Done()
first <- h.ask("slow.example.test.", dns.TypeA)
}()
h.up.waitArrived(t)
second := h.ask("other.example.test.", dns.TypeA)
requireRcode(t, second, dns.RcodeServerFailure)
// A per-client refusal is not a global overload.
if h.eng.Dropped() != 0 {
t.Fatalf("dropped = %d, want 0: the global cap was not the one that refused", h.eng.Dropped())
}
open()
wg.Wait()
requireAction(t, <-first, qlog.ActionAllow)
}
// TestRateLimitRefuses pins the fixed-window rate limit: the first N queries
// in a window go through, the next is REFUSED.
func TestRateLimitRefuses(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{RateLimit: 2}) },
})
for i := 0; i < 2; i++ {
// Distinct names, so the cache cannot turn the second query into a
// hit and mask what is being asserted.
res := h.ask(fmt.Sprintf("rate%d.example.test.", i), dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
}
res := h.ask("rate2.example.test.", dns.TypeA)
requireRcode(t, res, dns.RcodeRefused)
requireAction(t, res, qlog.ActionRefused)
}
// TestGroupDomainPolicy pins the per-group allow/deny lists, which run after
// the rate limit and before the local zone.
func TestGroupDomainPolicy(t *testing.T) {
t.Run("deny list", func(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) {
// "**." is the spelling that covers a domain and its
// subdomains; a bare name is exact (see acl.NewDomainSet).
*a = loopbackGroup(config.Group{DenyDomains: []string{"**.denied.example.test."}})
},
})
res := h.ask("denied.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionBlock)
if res.BlockSource != "acl:test" {
t.Fatalf("block source = %q, want acl:test", res.BlockSource)
}
// A subdomain of a denied name is denied too.
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != qlog.ActionBlock {
t.Fatalf("subdomain action = %q, want block", sub.Action)
}
if sib := h.ask("sibling.example.test.", dns.TypeA); sib.Action != qlog.ActionAllow {
t.Fatalf("sibling action = %q, want allow: the deny list leaked", sib.Action)
}
allowed := h.ask("allowed.example.test.", dns.TypeA)
requireAction(t, allowed, qlog.ActionAllow)
if got := answerA(allowed.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7", got)
}
})
t.Run("exact deny is exact", func(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) {
*a = loopbackGroup(config.Group{DenyDomains: []string{"denied.example.test."}})
},
})
if res := h.ask("denied.example.test.", dns.TypeA); res.Action != qlog.ActionBlock {
t.Fatalf("exact name: action = %q, want block", res.Action)
}
// Documented behaviour of a rule without a wildcard: the apex only.
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != qlog.ActionAllow {
t.Fatalf("subdomain of an exact rule: action = %q, want allow", sub.Action)
}
})
t.Run("allow list is exclusive", func(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) {
*a = loopbackGroup(config.Group{AllowDomains: []string{"only.example.test."}})
},
})
if res := h.ask("other.example.test.", dns.TypeA); res.Action != qlog.ActionBlock {
t.Fatalf("non-allowlisted name: action = %q, want block", res.Action)
}
allowed := h.ask("only.example.test.", dns.TypeA)
requireAction(t, allowed, qlog.ActionAllow)
if got := answerA(allowed.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7", got)
}
})
}
// TestSkipToggles pins the two per-group bypasses. Both are security-relevant
// switches, so a test that pins them must fail if the flag stops working.
func TestSkipToggles(t *testing.T) {
t.Run("skip blocklist", func(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{SkipBlocklist: true}) },
rules: []blocklist.Rule{{Name: "blocked.example.test"}},
})
res := h.ask("blocked.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
if got := answerA(res.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7 (the blocklist still applies)", got)
}
})
t.Run("skip cache", func(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{SkipCache: true}) },
})
for i := 0; i < 2; i++ {
res := h.ask("nocache.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
if res.CacheKind != "" {
t.Fatalf("query %d: cache kind = %q, want none", i, res.CacheKind)
}
}
if n := h.up.hitCount("nocache.example.test."); n != 2 {
t.Fatalf("upstream saw %d queries, want 2: the cache was used", n)
}
})
}
// TestCacheHitDoesNotReachUpstream pins the cache fast path.
func TestCacheHitDoesNotReachUpstream(t *testing.T) {
h := newHarness(t, hopt{})
first := h.ask("cached.example.test.", dns.TypeA)
requireAction(t, first, qlog.ActionAllow)
if first.CacheHit {
t.Fatal("the first query reported a cache hit")
}
if n := h.up.hitCount("cached.example.test."); n != 1 {
t.Fatalf("upstream saw %d queries, want 1", n)
}
second := h.ask("cached.example.test.", dns.TypeA)
if !second.CacheHit || second.CacheKind != "hit" {
t.Fatalf("second query: hit=%v kind=%q, want a fresh hit", second.CacheHit, second.CacheKind)
}
requireAction(t, second, qlog.ActionCached)
if got := answerA(second.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7", got)
}
if ttl := firstTTL(second.Msg); ttl == 0 || ttl > 60 {
t.Fatalf("cached TTL = %d, want 1..60", ttl)
}
if second.Upstream == "" {
t.Fatal("the cached answer lost its upstream attribution")
}
if n := h.up.hitCount("cached.example.test."); n != 1 {
t.Fatalf("upstream saw %d queries after a cache hit, want 1", n)
}
}
// TestServeStaleAgesToOneAndRefreshesOnce pins serve-stale: the stale answer
// is served with TTL 1, and the background refresh is single-flight however
// many queries pile up while it is running.
func TestServeStaleAgesToOneAndRefreshesOnce(t *testing.T) {
h := newHarness(t, hopt{serveStale: 10 * time.Minute, clock: true})
first := h.ask("stale.example.test.", dns.TypeA)
requireAction(t, first, qlog.ActionAllow)
// Expire the entry: the upstream TTL is 60s.
h.advance(61 * time.Second)
open := h.up.hold()
stale := h.ask("stale.example.test.", dns.TypeA)
if !stale.CacheHit || stale.CacheKind != "stale" {
t.Fatalf("hit=%v kind=%q, want a stale hit", stale.CacheHit, stale.CacheKind)
}
if got := answerA(stale.Msg); got != "203.0.113.7" {
t.Fatalf("stale answer = %q, want 203.0.113.7", got)
}
if ttl := firstTTL(stale.Msg); ttl != 1 {
t.Fatalf("stale TTL = %d, want 1: a stale answer must not be cached by the client", ttl)
}
// The prefetch is now parked in the upstream. More stale queries must not
// start a second one.
h.up.waitArrived(t)
for i := 0; i < 3; i++ {
if res := h.ask("stale.example.test.", dns.TypeA); res.CacheKind != "stale" {
t.Fatalf("query %d during refresh: cache kind = %q, want stale", i, res.CacheKind)
}
}
// Give a second prefetch, if one were wrongly launched, time to arrive.
time.Sleep(250 * time.Millisecond)
if n := h.up.hitCount("stale.example.test."); n != 2 {
t.Fatalf("upstream saw %d queries, want 2 (one resolve, one refresh)", n)
}
open()
// After the refresh lands the entry is fresh again.
deadline := time.Now().Add(5 * time.Second)
for {
if res := h.ask("stale.example.test.", dns.TypeA); res.CacheKind == "hit" {
break
}
if time.Now().After(deadline) {
t.Fatal("the entry never came back fresh after the background refresh")
}
time.Sleep(10 * time.Millisecond)
}
}
// TestTruncationAgainstClientBuffer pins RFC 2181 truncation on the uncached
// path: a 4 KiB answer over a 512-byte client buffer comes back with TC and no
// records, and the same answer is delivered in full over TCP or with a large
// EDNS0 buffer.
func TestTruncationAgainstClientBuffer(t *testing.T) {
h := newHarness(t, hopt{})
small := h.ask("big.test.", dns.TypeTXT)
if small.Msg == nil || !small.Msg.Truncated {
t.Fatal("a 4 KiB answer over a 512-byte buffer was not truncated")
}
if len(small.Msg.Answer) != 0 {
t.Fatalf("truncated answer carries %d records, want none", len(small.Msg.Answer))
}
if small.Msg.Rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[small.Msg.Rcode])
}
// A different name, so the 4 KiB answer in the cache is not the one being
// served: TCP clients are not size-limited.
overTCP := h.askTCP("big2.test.", dns.TypeTXT)
if overTCP.Msg == nil || overTCP.Msg.Truncated {
t.Fatal("a TCP client got a truncated answer")
}
if firstTTL(overTCP.Msg) == 0 {
t.Fatal("the TCP answer has no records")
}
// A large advertised EDNS0 buffer gets the whole thing too.
edns := h.askEDNS("big3.test.", dns.TypeTXT, 4096)
if edns.Msg == nil || edns.Msg.Truncated {
t.Fatal("a 4096-byte EDNS0 client got a truncated answer")
}
if len(edns.Msg.Answer) == 0 {
t.Fatal("the EDNS0 answer has no records")
}
if b, err := edns.Msg.Pack(); err != nil || len(b) > 4096 {
t.Fatalf("packed size = %d (err %v), want <= 4096", len(b), err)
}
}
// TestSmallClientDoesNotPoisonTheCache pins the reason the engine caches the
// untruncated upstream answer: a 512-byte client must not leave a truncated
// copy in the cache for every later client.
func TestSmallClientDoesNotPoisonTheCache(t *testing.T) {
h := newHarness(t, hopt{})
small := h.ask("big.test.", dns.TypeTXT)
if small.Msg == nil || !small.Msg.Truncated {
t.Fatal("precondition: the 512-byte client was not truncated")
}
full := h.askEDNS("big.test.", dns.TypeTXT, 4096)
if full.Msg == nil || full.Msg.Truncated {
t.Fatal("a large-buffer client got a truncated answer: the cache entry was poisoned")
}
if len(full.Msg.Answer) == 0 {
t.Fatal("the cached answer has no records")
}
if n := h.up.hitCount("big.test."); n != 1 {
t.Fatalf("upstream saw %d queries, want 1: the second query was not served from cache", n)
}
}
// TestTruncatedUpstreamAnswerIsNotCached pins the other half of the rule: an
// answer the upstream flagged TC is never stored, because it would be served
// truncated forever.
func TestTruncatedUpstreamAnswerIsNotCached(t *testing.T) {
h := newHarness(t, hopt{})
for i := 0; i < 2; i++ {
res := h.ask("tc.test.", dns.TypeA)
if res.CacheHit {
t.Fatalf("query %d was served from cache: a TC answer was cached", i)
}
}
if n := h.up.hitCount("tc.test."); n != 2 {
t.Fatalf("upstream saw %d queries, want 2", n)
}
}
// TestMalformedQueriesAreFormErr pins the validation step, which runs before
// the ACL: a query with no question, two questions, or the response bit set is
// FORMERR, and still counts as a query.
func TestMalformedQueriesAreFormErr(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{Policy: "deny"}) },
})
twoQuestions := new(dns.Msg)
twoQuestions.SetQuestion("a.example.test.", dns.TypeA)
twoQuestions.Question = append(twoQuestions.Question, dns.Question{Name: "b.example.test.", Qtype: dns.TypeA, Qclass: dns.ClassINET})
alreadyResponse := new(dns.Msg)
alreadyResponse.SetQuestion("a.example.test.", dns.TypeA)
alreadyResponse.Response = true
empty := new(dns.Msg)
for name, q := range map[string]*dns.Msg{"no question": empty, "two questions": twoQuestions, "response bit set": alreadyResponse} {
before := h.totals()
res := h.askWith(Request{Msg: q, Remote: h.request("a.example.test.", dns.TypeA).Remote, Transport: "udp"})
requireRcode(t, res, dns.RcodeFormatError)
requireAction(t, res, qlog.ActionError)
if got := h.totals().Queries - before.Queries; got != 1 {
t.Fatalf("%s: metrics recorded %d queries, want 1", name, got)
}
}
if h.up.totalHits() != 0 {
t.Fatal("a malformed query reached the upstream")
}
}
// TestMetricsAndQueryLogFireOncePerQuery pins the hook contract: every query
// that reaches Handle is counted exactly once and logged exactly once,
// including the ones refused before resolution.
func TestMetricsAndQueryLogFireOncePerQuery(t *testing.T) {
cases := []struct {
name string
opt hopt
// queries is how many queries the case sends: every one of them must
// be counted exactly once.
queries int
send func(*harness)
want qlog.Action
}{
{
name: "upstream answer",
opt: hopt{},
queries: 1,
send: func(h *harness) { h.ask("once.example.test.", dns.TypeA) },
want: qlog.ActionAllow,
},
{
name: "cache hit",
opt: hopt{},
queries: 2,
send: func(h *harness) {
h.ask("once.example.test.", dns.TypeA)
h.ask("once.example.test.", dns.TypeA)
},
want: qlog.ActionCached,
},
{
name: "blocked",
opt: hopt{rules: []blocklist.Rule{{Name: "blocked.example.test"}}},
queries: 1,
send: func(h *harness) { h.ask("blocked.example.test.", dns.TypeA) },
want: qlog.ActionBlock,
},
{
name: "refused",
opt: hopt{acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{Policy: "deny"}) }},
queries: 1,
send: func(h *harness) {
h.ask("denied.example.test.", dns.TypeA)
},
want: qlog.ActionRefused,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, tc.opt)
before := h.totals()
beforeLog := h.logged()
tc.send(h)
after := h.totals()
if got := after.Queries - before.Queries; got != uint64(tc.queries) {
t.Fatalf("metrics counted %d queries, want exactly %d", got, tc.queries)
}
if got := h.logged() - beforeLog; got != tc.queries {
t.Fatalf("query log holds %d new entries, want exactly %d", got, tc.queries)
}
entries := h.entries()
if len(entries) == 0 {
t.Fatal("the newest entry is missing from the live ring")
}
e := entries[0]
if e.Action != tc.want {
t.Fatalf("logged action = %q, want %q", e.Action, tc.want)
}
if e.Client != "127.0.0.1" {
t.Fatalf("logged client = %q, want 127.0.0.1", e.Client)
}
if e.Protocol != "udp" {
t.Fatalf("logged protocol = %q, want udp", e.Protocol)
}
if len(entries) != tc.queries {
t.Fatalf("the live ring holds %d entries, want %d: the hook fired more than once per query",
len(entries), tc.queries)
}
for i := 0; i+1 < len(entries); i++ {
if entries[i].ID-entries[i+1].ID != 1 {
t.Fatalf("entries %d and %d are not adjacent (ids %d, %d): the hook fired more than once per query",
i+1, i, entries[i+1].ID, entries[i].ID)
}
}
})
}
}
// TestNoUpstreamIsServfail pins the degenerate wiring: a pool-less engine
// answers SERVFAIL rather than panicking or hanging.
func TestNoUpstreamIsServfail(t *testing.T) {
h := newHarness(t, hopt{noPool: true})
res := h.ask("nowhere.example.test.", dns.TypeA)
requireRcode(t, res, dns.RcodeServerFailure)
requireAction(t, res, qlog.ActionError)
}
// TestDeadUpstreamIsServfail pins upstream failure handling: the exchange
// error becomes a SERVFAIL to the client and an error in the metrics.
func TestDeadUpstreamIsServfail(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve port: %v", err)
}
dead := pc.LocalAddr().String()
_ = pc.Close()
h := newHarness(t, hopt{
timeout: 500 * time.Millisecond,
upstreams: []config.UpstreamServer{{Addr: dead, Proto: "udp", Weight: 1}},
})
before := h.totals()
res := h.ask("dead.example.test.", dns.TypeA)
requireRcode(t, res, dns.RcodeServerFailure)
requireAction(t, res, qlog.ActionError)
if got := h.totals().Errors - before.Errors; got != 1 {
t.Fatalf("errors rose by %d, want 1", got)
}
}
// TestForwardedQueryShape pins what goes upstream: our own OPT (so an
// upstream can answer EDNS0), the client's ECS forwarded verbatim, our own
// NSID rather than the client's, and never the client's AD bit.
func TestForwardedQueryShape(t *testing.T) {
t.Run("plain query", func(t *testing.T) {
h := newHarness(t, hopt{})
h.ask("plain.example.test.", dns.TypeA)
got := h.up.received()
if len(got) != 1 {
t.Fatalf("upstream saw %d queries, want 1", len(got))
}
if got[0].IsEdns0() == nil {
t.Fatal("the forwarded query has no OPT record")
}
if opt := findOption(got[0], dns.EDNS0NSID); opt != nil {
t.Fatal("we leaked our own NSID to a client that did not ask for one")
}
if !got[0].RecursionDesired {
t.Fatal("the forwarded query lost RD")
}
})
t.Run("client subnet is forwarded", func(t *testing.T) {
h := newHarness(t, hopt{})
req := h.request("ecs.example.test.", dns.TypeA)
req.Msg.SetEdns0(1232, false)
sub := &dns.EDNS0_SUBNET{Code: dns.EDNS0SUBNET, Family: 1, SourceNetmask: 24, Address: net.IP{203, 0, 113, 0}}
req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option, sub)
h.askWith(req)
got := h.up.received()
if len(got) != 1 {
t.Fatalf("upstream saw %d queries, want 1", len(got))
}
opt := findOption(got[0], dns.EDNS0SUBNET)
if opt == nil {
t.Fatal("the client's EDNS0 client subnet was not forwarded")
}
fwd, ok := opt.(*dns.EDNS0_SUBNET)
if !ok || !fwd.Address.Equal(sub.Address) || fwd.SourceNetmask != 24 {
t.Fatalf("forwarded subnet = %+v, want %+v", fwd, sub)
}
})
// The NSID branch of prepareUpstream is deliberately not pinned here:
// miekg/dns v1.1.73 reports an EDNS0_NSID option as zero length, so any
// message carrying one fails to pack ("dns: overflow packing opt") and a
// client that asks for NSID gets SERVFAIL. That is a library defect, not
// a decision this package owns; see STATUS.md.
}
func findOption(m *dns.Msg, code uint16) dns.EDNS0 {
opt := m.IsEdns0()
if opt == nil {
return nil
}
for _, o := range opt.Option {
if o.Option() == code {
return o
}
}
return nil
}
// TestReplyEchoesTheClientQuestionAndID pins the wire contract every resolver
// owes a client: the reply carries the client's question verbatim (including
// its case, for 0x20 style checks) and the client's transaction ID.
func TestReplyEchoesTheClientQuestionAndID(t *testing.T) {
h := newHarness(t, hopt{})
req := h.request("HoSt.ExAmPlE.TeSt.", dns.TypeA)
req.Msg.Id = 0x1234
res := h.askWith(req)
requireAction(t, res, qlog.ActionAllow)
if res.Msg.Id != 0x1234 {
t.Fatalf("reply id = %#x, want 0x1234", res.Msg.Id)
}
if len(res.Msg.Question) != 1 {
t.Fatalf("reply carries %d questions, want 1", len(res.Msg.Question))
}
if got := res.Msg.Question[0].Name; got != "HoSt.ExAmPlE.TeSt." {
t.Fatalf("reply question = %q, want the client's name verbatim", got)
}
if got := answerA(res.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7", got)
}
}
// TestGroupStatsCountEveryQuery pins the per-group counters the UI reads.
func TestGroupStatsCountEveryQuery(t *testing.T) {
h := newHarness(t, hopt{
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{}) },
rules: []blocklist.Rule{{Name: "blocked.example.test"}},
})
h.ask("stats.example.test.", dns.TypeA)
h.ask("blocked.example.test.", dns.TypeA)
h.ask("blocked.example.test.", dns.TypeA)
var found *acl.GroupSummary
for i, g := range h.eng.GroupStats() {
if g.Name == "test" {
found = &h.eng.GroupStats()[i]
break
}
}
if found == nil {
t.Fatalf("group %q missing from the stats table: %+v", "test", h.eng.GroupStats())
}
if found.QueryCount != 3 {
t.Fatalf("query count = %d, want 3", found.QueryCount)
}
if found.BlockedCount != 2 {
t.Fatalf("blocked count = %d, want 2", found.BlockedCount)
}
}
+495
View File
@@ -0,0 +1,495 @@
package engine
import (
"context"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
)
// fakeUpstream is a UDP resolver that answers every name under .test. with a
// fixed address, so a test can tell "resolved upstream" from "answered locally"
// without touching the internet. It records the queries it received, which is
// how the forwarded-message tests inspect what the engine put on the wire.
type fakeUpstream struct {
*dns.Server
addr string
mu sync.Mutex
hits map[string]int
queries []*dns.Msg
// writeErr is the last error the handler's write returned: a fake upstream
// that silently fails to pack looks exactly like a client-side timeout.
writeErr error
// gate, when set, parks handlers until it is closed. arrived is signalled
// before parking, so a test can wait for the query to be *inside* the
// pipeline rather than guessing with a sleep.
gate chan struct{}
arrived chan struct{}
// release is closed at cleanup so a held gate can never wedge shutdown.
release chan struct{}
}
func newFakeUpstream(t *testing.T) *fakeUpstream {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
u := &fakeUpstream{
addr: pc.LocalAddr().String(),
hits: make(map[string]int),
arrived: make(chan struct{}, 64),
release: make(chan struct{}),
}
u.Server = &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(u.handle)}
go func() { _ = u.ActivateAndServe() }()
t.Cleanup(func() {
close(u.release)
_ = u.Shutdown()
})
return u
}
func (u *fakeUpstream) handle(w dns.ResponseWriter, q *dns.Msg) {
u.mu.Lock()
if len(q.Question) > 0 {
u.hits[q.Question[0].Name]++
}
u.queries = append(u.queries, q.Copy())
u.mu.Unlock()
select {
case u.arrived <- struct{}{}:
default:
}
if g := u.gateCh(); g != nil {
select {
case <-g:
case <-u.release:
}
}
if len(q.Question) == 0 {
return
}
m := new(dns.Msg)
m.SetReply(q)
name := q.Question[0].Name
switch {
case !strings.HasSuffix(strings.ToLower(name), ".test."):
m.Rcode = dns.RcodeNameError
m.Ns = []dns.RR{testSOA("test.", 60)}
default:
// A "tc*" name answers *and* sets TC, which is how a real upstream
// reports an answer that did not fit; it must not be cached.
if strings.HasPrefix(strings.ToLower(name), "tc.") {
m.Truncated = true
}
switch q.Question[0].Qtype {
case dns.TypeTXT:
// "big*" names answer with a 4 KiB TXT so a 512-byte client
// buffer has something to overflow.
if strings.HasPrefix(strings.ToLower(name), "big") {
// Built as a record rather than parsed, and as many short
// strings rather than one long one: this version of miekg/dns
// reports a character-string over 255 bytes as zero length, so a
// single 4 KiB TXT never packs (a silent write failure that looks
// like a client timeout). 15 x 200 lands just over 3 KiB, inside
// the 4 KiB buffer the engine advertises, and far outside a
// 512-byte client buffer.
txt := make([]string, 15)
for i := range txt {
txt[i] = strings.Repeat("x", 200)
}
rr := &dns.TXT{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60},
Txt: txt,
}
m.Answer = append(m.Answer, rr)
} else {
rr, _ := dns.NewRR(name + " 60 IN A 203.0.113.9")
m.Answer = append(m.Answer, rr)
}
case dns.TypeA:
rr, _ := dns.NewRR(name + " 60 IN A 203.0.113.7")
m.Answer = append(m.Answer, rr)
}
m.Ns = []dns.RR{testSOA("test.", 60)}
}
if err := w.WriteMsg(m); err != nil {
u.mu.Lock()
u.writeErr = err
u.mu.Unlock()
}
}
// lastWriteErr reports the last write failure, or nil.
func (u *fakeUpstream) lastWriteErr() error {
u.mu.Lock()
defer u.mu.Unlock()
return u.writeErr
}
func (u *fakeUpstream) gateCh() chan struct{} {
u.mu.Lock()
defer u.mu.Unlock()
return u.gate
}
// hold parks every handler until the returned func is called. Handlers that
// arrive after it is released are not parked.
func (u *fakeUpstream) hold() func() {
u.mu.Lock()
defer u.mu.Unlock()
if u.gate != nil {
return func() {}
}
g := make(chan struct{})
u.gate = g
return func() { close(g) }
}
func (u *fakeUpstream) hitCount(name string) int {
u.mu.Lock()
defer u.mu.Unlock()
return u.hits[dns.Fqdn(name)]
}
func (u *fakeUpstream) totalHits() int {
u.mu.Lock()
defer u.mu.Unlock()
n := 0
for _, c := range u.hits {
n += c
}
return n
}
// received returns the queries the upstream saw, in order.
func (u *fakeUpstream) received() []*dns.Msg {
u.mu.Lock()
defer u.mu.Unlock()
return append([]*dns.Msg(nil), u.queries...)
}
// waitArrived blocks until at least one query has reached the handler.
func (u *fakeUpstream) waitArrived(t *testing.T) {
t.Helper()
select {
case <-u.arrived:
case <-time.After(5 * time.Second):
t.Fatal("upstream never received a query")
}
}
// testSOA is the authority record the fake upstream attaches to every answer,
// which is what a real resolver does and what makes NXDOMAIN well-formed.
func testSOA(name string, ttl uint32) dns.RR {
return &dns.SOA{
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl},
Ns: "ns.test.",
Mbox: "hostmaster.test.",
Serial: 1,
Refresh: 3600,
Retry: 600,
Expire: 604800,
Minttl: 60,
}
}
// testClock is the injectable time source the cache manager uses. Advancing it
// is how a test makes an entry expire without sleeping.
type testClock struct {
mu sync.Mutex
t time.Time
}
func newTestClock() *testClock { return &testClock{t: time.Now()} }
func (c *testClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *testClock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.t = c.t.Add(d)
}
// hopt tunes a harness. The zero value is a fully working engine: fake
// upstream, local zone, cache, blocklist and query log.
type hopt struct {
acl func(*config.ACL)
blockCfg func(*config.Block)
server func(*config.Server)
cacheCfg func(*config.Cache)
// rules are compiled into the blocklist set the engine holds.
rules []blocklist.Rule
// localZone is a master file; empty means no local zone at all.
localZone string
// upstreams replaces the default single fake upstream. A nil slice with
// noPool false means "use the fake upstream".
upstreams []config.UpstreamServer
// noPool wires a nil pool, which is the "no upstream configured" case.
noPool bool
// serveStale enables the stale window and prefetching.
serveStale time.Duration
// clock installs the injectable clock into the cache manager.
clock bool
// timeout is the per-exchange upstream timeout.
timeout time.Duration
}
type harness struct {
t *testing.T
eng *Engine
cfg *config.Config
store *config.Store
pool *resolver.Pool
cache *cache.Manager
log *qlog.Logger
up *fakeUpstream
clock *testClock
blocks *blocklist.Holder
local *localzone.Holder
aclH *acl.Holder
}
func newHarness(t *testing.T, o hopt) *harness {
t.Helper()
up := newFakeUpstream(t)
timeout := o.timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
cfg := config.Default()
cfg.Cache.Persist.Enabled = false
cfg.Server.Timeouts.Upstre = config.Duration(timeout)
// A generous upstream buffer: an upstream cannot send us more than we
// advertise, so the truncation tests need room for a 4 KiB answer to
// arrive whole and then be trimmed for a small client.
cfg.DNS.MaxUDPSize = 4096
cfg.Cache.ServeStale = config.Duration(o.serveStale)
cfg.Block.Lists = nil
if o.acl != nil {
o.acl(&cfg.ACL)
}
if o.blockCfg != nil {
o.blockCfg(&cfg.Block)
}
if o.server != nil {
o.server(&cfg.Server)
}
if o.cacheCfg != nil {
o.cacheCfg(&cfg.Cache)
}
// The default pool is the fake upstream; a dead address is what the
// upstream-failure test needs, and noPool needs no pool at all.
if !o.noPool {
servers := o.upstreams
if servers == nil {
servers = []config.UpstreamServer{{Addr: up.addr, Proto: "udp", Weight: 1}}
}
cfg.Upstream.Servers = servers
cfg.Upstream.Policy = "parallel"
cfg.Upstream.MaxAttempts = 1
cfg.Upstream.HealthCheck = 0
cfg.Upstream.BreakerThreshold = 5
cfg.Upstream.BreakerCooldown = config.Duration(30 * time.Second)
} else {
cfg.Upstream.Servers = nil
}
// A local zone file, if asked for.
var local *localzone.Store
if o.localZone != "" {
path := filepath.Join(t.TempDir(), "local.zone")
if err := os.WriteFile(path, []byte(o.localZone), 0o600); err != nil {
t.Fatalf("write zone: %v", err)
}
l, err := localzone.Load([]string{path}, localzone.Options{})
if err != nil {
t.Fatalf("load zone: %v", err)
}
local = l
} else {
l, err := localzone.Load(nil, localzone.Options{})
if err != nil {
t.Fatalf("load empty zone set: %v", err)
}
local = l
}
clock := newTestClock()
cm := cache.NewManager(cache.Options{
Size: 1000,
Shards: 4,
ServeStale: o.serveStale,
Prefetch: true,
Now: clock.Now,
}, nil)
log, err := qlog.New(qlog.Options{
LiveBuffer: 64,
SampleRate: 1,
// Every action is logged, so a test can count entries without
// reasoning about the sampling policy.
LogBlocked: true,
LogCached: true,
LogErrors: true,
TopDomains: 16,
TopTTL: time.Minute,
})
if err != nil {
t.Fatalf("qlog: %v", err)
}
t.Cleanup(func() { _ = log.Close() })
blocks := blocklist.NewHolder(buildSet(o.rules...))
aclH := acl.NewHolder(acl.Compile(cfg.ACL))
localH := localzone.NewHolder(local)
store := config.NewStore(cfg, "")
eng := New(Options{
Config: store,
Logger: log,
Blocklists: blocks,
Cache: cm,
ACL: aclH,
Local: localH,
})
if !o.noPool {
eng.pool.Store(resolver.NewPoolWithUDPSize(cfg.Upstream, cfg.DNS.MaxUDPSize))
}
h := &harness{
t: t, eng: eng, cfg: cfg, store: store, cache: cm, log: log,
up: up, clock: clock, blocks: blocks, local: localH, aclH: aclH,
}
if !o.noPool {
h.pool = eng.Pool()
}
return h
}
func buildSet(rules ...blocklist.Rule) *blocklist.Set {
b := blocklist.NewBuilder()
for _, r := range rules {
b.Add(r, "test", nil)
}
return b.Build()
}
// setRules swaps in a new compiled set, as a reload would.
func (h *harness) setRules(rules ...blocklist.Rule) {
h.blocks.Set(buildSet(rules...))
}
func (h *harness) advance(d time.Duration) { h.clock.Advance(d) }
// request builds a Request the way the UDP listener would.
func (h *harness) request(name string, qtype uint16) Request {
q := new(dns.Msg)
q.SetQuestion(dns.Fqdn(name), qtype)
q.RecursionDesired = true
return Request{Msg: q, Remote: netip.MustParseAddr("127.0.0.1"), Transport: "udp", Start: time.Now()}
}
func (h *harness) ask(name string, qtype uint16) Response {
h.t.Helper()
return h.eng.Handle(context.Background(), h.request(name, qtype))
}
func (h *harness) askWith(req Request) Response {
h.t.Helper()
return h.eng.Handle(context.Background(), req)
}
// askEDNS asks with an EDNS0 buffer of the given size.
func (h *harness) askEDNS(name string, qtype uint16, size uint16) Response {
h.t.Helper()
req := h.request(name, qtype)
req.Msg.SetEdns0(size, false)
return h.askWith(req)
}
// askTCP asks with the TCP flag set, so the UDP size cap does not apply.
func (h *harness) askTCP(name string, qtype uint16) Response {
h.t.Helper()
req := h.request(name, qtype)
req.TCP = true
req.Transport = "tcp"
return h.askWith(req)
}
func (h *harness) entries() []qlog.Entry { return h.log.Recent(64) }
func (h *harness) logged() int { return h.log.Count() }
func (h *harness) totals() metrics.Totals { return metrics.TotalsNow() }
// answerA returns the first A record's address, or "" when the answer has no
// address record.
func answerA(m *dns.Msg) string {
if m == nil {
return ""
}
for _, rr := range m.Answer {
if a, ok := rr.(*dns.A); ok {
return a.A.String()
}
}
return ""
}
func firstTTL(m *dns.Msg) uint32 {
if m == nil || len(m.Answer) == 0 {
return 0
}
return m.Answer[0].Header().Ttl
}
// requireRcode fails unless the response carries rcode.
func requireRcode(t *testing.T, res Response, rcode int) {
t.Helper()
if res.Msg == nil {
t.Fatalf("no response message (drop=%v)", res.Drop)
}
if res.Msg.Rcode != rcode {
t.Fatalf("rcode = %s, want %s", dns.RcodeToString[res.Msg.Rcode], dns.RcodeToString[rcode])
}
}
func requireAction(t *testing.T, res Response, want qlog.Action) {
t.Helper()
if res.Action != want {
t.Fatalf("action = %q, want %q", res.Action, want)
}
}
func requireNoUpstreamHit(t *testing.T, h *harness, name string) {
t.Helper()
if n := h.up.hitCount(name); n != 0 {
t.Fatalf("upstream saw %d queries for %s, want 0", n, name)
}
}