Files
fancy-dns/internal/cache/cache_test.go
T
developer 26134ba437
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Failing after 22s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped
Fix the bbolt round trip, the blocklist gaps and the deploy set
The cache snapshot never loaded, so every restart was a cold cache. Snapshot
packed the whole key while Load decoded the record header out of the key's
remainder instead of the value, so every entry was skipped. The two sides are
now an explicit encodeRecord/decodeRecord pair with a version byte and a CRC-32C
trailer, so a future format change is detected instead of silently misparsed.
Load also drops entries that are already past ExpiresAt and rewrites the TTL to
the time actually left, so a served TTL never jumps back up across a restart.

Blocklist, all six gaps the failing tests were pointing at: a leading UTF-8 BOM
is stripped, "!allow example.com" works, SourceMeta exists for a source that
only ever contributed a rule, Pattern() reports the bare name again (the new
WireName() produces the wildcard form for the API), an RPZ record with no
explicit TTL inherits the zone default instead of whatever the reader left in
the RR, a hosts line with no address in the first field says so, and the
adblock parser handles CRLF and adblock-flavoured lines pasted into a plain
list. TestHolderSwapIsAtomic was asserting the wrong invariant — the swap was
always atomic, a name blocked by the old set is legitimately unblocked by the
new one.

Tests added for acl and resolver, both of which turned up real bugs:

- a PTR suffix matched across label boundaries, so a group scoped to
  example.com. also swallowed ample.com. and notexample.com. — a client could
  land in a group it does not belong to;
- the "*" catch-all was dropped from the rendered domain patterns, so the
  engine's per-query recompile lost the "everything" case;
- Upstream.Success never closed an open breaker, so a recovered upstream stayed
  excluded for the full cooldown;
- Exchange fanned out in parallel even under policy: failover.

Deploy set: go.mod requires Go 1.27.1 but CI pinned 1.23 and the Dockerfile
pinned golang:1.23-alpine, so neither could have built. The Dockerfile also
copied the source tree after the console bundle, which clobbered it.

The k8s set asked for hostNetwork with two replicas, but the server never sets
SO_REUSEPORT, so the second pod could not bind :53 and would crash-loop. It now
runs without hostNetwork behind a LoadBalancer Service, with a separate
ClusterIP Service for the Ingress (a headless Service cannot back one), a
ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA
is gone: it cannot drive a StatefulSet. To let the API key come from that
Secret, config.Load now expands ${VAR} / ${VAR:-default} / ${NAME-default} and
fails to start on an unset variable with no default, so a missing secret is a
crash loop rather than an unauthenticated console. Expansion skips YAML
comments, so documenting ${VAR} in the ConfigMap does not break the file.

Verified: gofmt, go build, go vet clean; go test -race ./internal/... green
(acl, app, blocklist, cache, config, resolver, zonefile).
2026-09-27 13:30:43 +00:00

1101 lines
34 KiB
Go

package cache
import (
"encoding/binary"
"errors"
"math/rand/v2"
"net"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
"github.com/miekg/dns"
bolt "go.etcd.io/bbolt"
)
// fakeClock is a manually advanced clock so TTL, ageing and serve-stale
// behaviour is tested without sleeping.
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func newClock() *fakeClock { return &fakeClock{t: time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)} }
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *fakeClock) Advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
// answer builds a packed A response for name.
func answer(t *testing.T, name string, ttl uint32, ip string) *dns.Msg {
t.Helper()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
m.Response = true
m.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: net.ParseIP(ip).To4(),
}}
return m
}
func key(name string) Key { return QueryKey(name, dns.TypeA, dns.ClassINET, false, 0) }
func TestLRUGetPutAndHitKinds(t *testing.T) {
clk := newClock()
l := NewLRU(100, 4)
m := answer(t, "example.com", 60, "1.2.3.4")
wire, err := m.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
k := key("example.com")
now := clk.Now()
l.Put(k, &Entry{Wire: wire, TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: len(wire)})
if e, kind := l.Get(k, now); e == nil || kind != Fresh {
t.Fatalf("fresh hit: got entry=%v kind=%v, want non-nil/Fresh", e, kind)
}
// One second later the entry is still fresh; after the TTL it is stale but
// answerable until StaleUntil, and past that it is a miss and dropped.
clk.Advance(59 * time.Second)
if _, kind := l.Get(k, clk.Now()); kind != Fresh {
t.Errorf("at TTL-1s: kind = %v, want Fresh", kind)
}
clk.Advance(2 * time.Second)
e, kind := l.Get(k, clk.Now())
if e == nil || kind != Stale {
t.Fatalf("just past TTL: got entry=%v kind=%v, want non-nil/Stale", e, kind)
}
if got := e.Hits.Load(); got != 3 {
t.Errorf("entry Hits = %d, want 3 (every Get counts, fresh or stale)", got)
}
clk.Advance(31 * time.Second) // now past StaleUntil (TTL+30s)
if e, kind := l.Get(k, clk.Now()); e != nil || kind != Miss {
t.Fatalf("past stale window: got entry=%v kind=%v, want nil/Miss", e, kind)
}
if got := l.Stats().Entries; got != 0 {
t.Errorf("entries after a dead read = %d, want 0 (the entry must be dropped)", got)
}
st := l.Stats()
if st.Hits != 2 || st.StaleHits != 1 || st.Misses != 1 {
t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=1", st)
}
}
func TestLRUEvictionKeepsRecentlyUsed(t *testing.T) {
// One shard so the budget is exactly what we set: eviction order is then
// fully determined and the test can assert it.
l := NewLRU(4, 1)
clk := newClock()
now := clk.Now()
put := func(name string) {
l.Put(key(name), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(time.Minute), Bytes: 8})
}
put("a.example.com")
put("b.example.com")
put("c.example.com")
// Touch a so it is the most recent, then overflow the 4-entry budget.
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
t.Fatalf("a: kind = %v, want Fresh", kind)
}
put("d.example.com")
put("e.example.com") // 5 inserts, budget 4
if got := l.Stats().Entries; got > 4 {
t.Fatalf("entries = %d, want <= 4 (per-shard budget)", got)
}
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
t.Errorf("a was recently used, want it kept")
}
if _, kind := l.Get(key("b.example.com"), now); kind != Miss {
t.Errorf("b was the least recently used, want it evicted")
}
if l.Stats().Evictions == 0 {
t.Error("evictions = 0, want > 0")
}
}
func TestLRUDeleteWhereAndFlush(t *testing.T) {
l := NewLRU(100, 2)
now := newClock().Now()
names := []string{"x.example.com", "y.example.com", "z.other.net"}
for _, n := range names {
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
dropped := l.DeleteWhere(func(k Key, _ *Entry) bool {
return k.Name == "x.example.com" || k.Name == "y.example.com"
})
if dropped != 2 {
t.Errorf("DeleteWhere = %d, want 2", dropped)
}
if _, kind := l.Get(key("x.example.com"), now); kind != Miss {
t.Error("x.example.com still present after DeleteWhere")
}
if _, kind := l.Get(key("z.other.net"), now); kind != Fresh {
t.Error("z.other.net must survive an unrelated DeleteWhere")
}
l.Flush()
if got := l.Stats().Entries; got != 0 {
t.Errorf("entries after Flush = %d, want 0", got)
}
if l.Stats().Bytes != 0 {
t.Errorf("bytes after Flush = %d, want 0", l.Stats().Bytes)
}
}
func TestLRUWalkVisitsEveryEntryOnce(t *testing.T) {
l := NewLRU(400, 4) // budget is per shard: 100 entries per shard
now := newClock().Now()
for i := range 200 {
n := "n" + strconv.Itoa(i) + ".example.com"
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
seen := make(map[string]int, 200)
l.Walk(func(k Key, _ *Entry) bool {
seen[k.Name]++
return true
})
if len(seen) != 200 {
t.Fatalf("Walk visited %d names, want 200", len(seen))
}
for n, c := range seen {
if c != 1 {
t.Fatalf("%s visited %d times, want 1", n, c)
}
}
// Returning false stops the walk.
count := 0
l.Walk(func(Key, *Entry) bool { count++; return false })
if count != 1 {
t.Errorf("Walk with stop visited %d entries, want 1", count)
}
}
func TestSetCapacityEvictsDownToTheNewBudget(t *testing.T) {
l := NewLRU(100, 1)
now := newClock().Now()
for i := range 50 {
n := "n" + strconv.Itoa(i) + ".example.com"
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
l.SetCapacity(10, 1)
if got := l.Stats().Entries; got > 10 {
t.Errorf("entries after SetCapacity(10) = %d, want <= 10", got)
}
if got := l.Stats().Shards; got != 1 {
t.Errorf("shards after SetCapacity(10, 1) = %d, want 1", got)
}
// The cache must still work after a resize.
l.Put(key("after-resize.example.com"), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
if _, kind := l.Get(key("after-resize.example.com"), now); kind != Fresh {
t.Error("a Put after a resize must be readable")
}
}
func TestLRUConcurrentPutGetResize(t *testing.T) {
// Exercises the resizing path: SetCapacity rewrites the per-shard budget
// while other goroutines insert and read. Run with -race.
l := NewLRU(512, 8)
now := newClock().Now()
var wg sync.WaitGroup
stop := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.SetCapacity(200, 4)
l.SetCapacity(4096, 16)
}
}
}()
for g := range 8 {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := range 500 {
n := "g" + strconv.Itoa(g) + "-n" + strconv.Itoa(i) + ".example.com"
k := key(n)
l.Put(k, &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
e, _ := l.Get(k, now)
if e != nil {
_ = e.Hits.Load()
}
}
}(g)
}
// Readers keep walking while the resizer runs.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.Walk(func(Key, *Entry) bool { return true })
}
}
}()
// Let the background loops run, then release them and wait for every
// goroutine: closing before the wait is what keeps this from deadlocking.
time.Sleep(20 * time.Millisecond)
close(stop)
wg.Wait()
if l.Stats().Bytes < 0 {
t.Errorf("byte accounting went negative: %d", l.Stats().Bytes)
}
}
func TestKeyPackUnpackRoundTrip(t *testing.T) {
cases := []Key{
{Name: "example.com", Type: dns.TypeA, Class: dns.ClassINET},
{Name: "a.b.c.d.example.com", Type: dns.TypeAAAA, Class: dns.ClassINET, DO: true},
{Name: "x", Type: dns.TypeMX, Class: dns.ClassCHAOS, Scope: 3},
{Name: "wildcard.example.com", Type: dns.TypeTXT, Class: dns.ClassINET, DO: true, Scope: 7},
}
for _, k := range cases {
buf, err := packKey(nil, k)
if err != nil {
t.Fatalf("packKey(%v): %v", k, err)
}
got, rest, err := unpackKey(buf)
if err != nil {
t.Fatalf("unpackKey(%v): %v", k, err)
}
if len(rest) != 0 {
t.Errorf("unpackKey(%v) left %d trailing bytes, want 0", k, len(rest))
}
if got != k {
t.Errorf("round trip: got %+v, want %+v", got, k)
}
}
// The wire name survives, so the canonical form comes back normalised.
buf, err := packKey(nil, Key{Name: "Example.COM", Type: dns.TypeA, Class: dns.ClassINET})
if err != nil {
t.Fatalf("packKey: %v", err)
}
got, _, err := unpackKey(buf)
if err != nil {
t.Fatalf("unpackKey: %v", err)
}
if got.Name != "example.com" {
t.Errorf("unpacked name = %q, want %q", got.Name, "example.com")
}
}
func TestKeyPackRejectsBadInput(t *testing.T) {
if _, err := packKey(nil, Key{Name: string(make([]byte, 256*4+1))}); !errors.Is(err, errKeyTooLong) {
t.Errorf("packKey(oversized name) error = %v, want errKeyTooLong", err)
}
if _, err := packKey(nil, Key{Name: "a..b", Type: dns.TypeA}); err == nil {
t.Error("packKey(name with an empty label) error = nil, want an error")
}
if _, _, err := unpackKey([]byte{0x05, 0x00}); !errors.Is(err, errBadKey) {
t.Errorf("unpackKey(truncated) error = %v, want errBadKey", err)
}
if _, _, err := unpackKey(nil); !errors.Is(err, errBadKey) {
t.Errorf("unpackKey(empty) error = %v, want errBadKey", err)
}
}
func TestQueryKeyCanonicalises(t *testing.T) {
a := QueryKey("Example.COM.", dns.TypeA, dns.ClassINET, false, 0)
b := QueryKey("example.com", dns.TypeA, dns.ClassINET, false, 0)
if a != b {
t.Errorf("QueryKey did not canonicalise: %+v != %+v", a, b)
}
if a.Name != "example.com" {
t.Errorf("canonical name = %q, want %q", a.Name, "example.com")
}
}
func TestEntryAgeTTL(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
e Entry
now time.Time
want uint32
}{
{"fresh", Entry{TTL: 100, CreatedAt: now}, now.Add(30 * time.Second), 70},
{"expiring", Entry{TTL: 100, CreatedAt: now}, now.Add(100 * time.Second), 0},
{"past", Entry{TTL: 100, CreatedAt: now}, now.Add(500 * time.Second), 0},
{"zero created keeps the stored TTL", Entry{TTL: 100}, now, 100},
{"sub-second ageing truncates to 0 elapsed", Entry{TTL: 1, CreatedAt: now}, now.Add(400 * time.Millisecond), 1},
}
for i := range cases {
// Take the address: Entry now carries an atomic counter, so the
// struct must never be copied.
tc := &cases[i]
got := tc.e.AgeTTL(tc.now)
if got != tc.want {
t.Errorf("%s: AgeTTL = %d, want %d", tc.name, got, tc.want)
}
}
}
func TestManagerTTLFor(t *testing.T) {
clk := newClock()
m := NewManager(Options{MinTTL: 0, MaxTTL: time.Hour, NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
soa := func(ttl uint32) *dns.SOA {
return &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, Minttl: ttl}
}
t.Run("answers use the lowest TTL and clamp", func(t *testing.T) {
m := NewManager(Options{MinTTL: 10 * time.Second, MaxTTL: time.Hour, Now: clk.Now}, nil)
msg := answer(t, "a.example.com", 300, "1.2.3.4")
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "a.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 5},
A: net.ParseIP("1.2.3.5").To4(),
})
ttl, neg := m.TTLFor(msg)
if neg {
t.Error("neg = true for a positive answer")
}
if ttl != 10 {
t.Errorf("ttl = %d, want 10 (5s clamped up to MinTTL)", ttl)
}
})
t.Run("clamp down to MaxTTL", func(t *testing.T) {
m := NewManager(Options{MaxTTL: 60 * time.Second, Now: clk.Now}, nil)
ttl, _ := m.TTLFor(answer(t, "a.example.com", 86400, "1.2.3.4"))
if ttl != 60 {
t.Errorf("ttl = %d, want 60 (clamped to MaxTTL)", ttl)
}
})
t.Run("NXDOMAIN takes the SOA TTL", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
msg.Rcode = dns.RcodeNameError
msg.Ns = []dns.RR{soa(45)}
ttl, neg := m.TTLFor(msg)
if !neg {
t.Error("neg = false for NXDOMAIN")
}
if ttl != 45 {
t.Errorf("ttl = %d, want 45", ttl)
}
})
t.Run("NODATA takes the SOA TTL and counts as negative", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
msg.Ns = []dns.RR{soa(12)}
ttl, neg := m.TTLFor(msg)
if !neg || ttl != 12 {
t.Errorf("NODATA: ttl = %d neg = %v, want 12/true", ttl, neg)
}
})
t.Run("NODATA without an SOA falls back to NegativeTTL", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
ttl, neg := m.TTLFor(msg)
if !neg || ttl != 30 {
t.Errorf("ttl = %d neg = %v, want 30/true", ttl, neg)
}
})
t.Run("truncated answers are never cached", func(t *testing.T) {
msg := answer(t, "big.example.com", 600, "1.2.3.4")
msg.Truncated = true
if ttl, _ := m.TTLFor(msg); ttl != 0 {
t.Errorf("ttl = %d for a truncated answer, want 0", ttl)
}
})
t.Run("transient rcodes are never cached", func(t *testing.T) {
for _, rc := range []int{dns.RcodeServerFailure, dns.RcodeRefused, dns.RcodeNotAuth} {
msg := answer(t, "a.example.com", 600, "1.2.3.4")
msg.Rcode = rc
if ttl, _ := m.TTLFor(msg); ttl != 0 {
t.Errorf("rcode %d: ttl = %d, want 0", rc, ttl)
}
}
})
t.Run("nil message", func(t *testing.T) {
if ttl, _ := m.TTLFor(nil); ttl != 0 {
t.Errorf("ttl = %d, want 0", ttl)
}
})
}
func TestManagerPutResponseGetAndAgeing(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("www.example.com")
m.PutResponse(k, answer(t, "www.example.com", 120, "93.184.216.34"), "127.0.0.53:53")
res := m.Get(k)
if res.Kind != Fresh {
t.Fatalf("kind = %v, want Fresh", res.Kind)
}
if res.TTL != 120 {
t.Errorf("TTL = %d, want 120", res.TTL)
}
if res.Upstream != "127.0.0.53:53" {
t.Errorf("upstream = %q, want the address it came from", res.Upstream)
}
if got := res.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
t.Errorf("answer = %s, want 93.184.216.34", got)
}
// The stored copy is private: mutating the returned message must not
// change what the next reader sees.
res.Msg.Answer[0].(*dns.A).A = net.ParseIP("9.9.9.9").To4()
again := m.Get(k)
if got := again.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
t.Errorf("answer after mutating the first result = %s, want an independent copy", got)
}
// TTLs age down monotonically.
clk.Advance(30 * time.Second)
if got := m.Get(k).TTL; got != 90 {
t.Errorf("TTL after 30s = %d, want 90", got)
}
if got := m.Get(k).Msg.Answer[0].Header().Ttl; got != 90 {
t.Errorf("record TTL after 30s = %d, want 90", got)
}
}
func TestManagerServeStale(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, ServeStale: time.Minute, Now: clk.Now}, nil)
k := key("stale.example.com")
m.PutResponse(k, answer(t, "stale.example.com", 60, "10.0.0.1"), "up")
clk.Advance(90 * time.Second) // 30s past the TTL, inside the stale window
res := m.Get(k)
if res.Kind != Stale {
t.Fatalf("kind = %v, want Stale", res.Kind)
}
if !res.ServedStale {
t.Error("ServedStale = false on a stale hit")
}
if res.TTL != 0 {
t.Errorf("TTL = %d on a stale hit, want 0 (the handler forces TTL 1)", res.TTL)
}
clk.Advance(61 * time.Second) // now past TTL+ServeStale
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v past the stale window, want Miss", res.Kind)
}
}
func TestManagerServeStaleDisabled(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("nostale.example.com")
m.PutResponse(k, answer(t, "nostale.example.com", 60, "10.0.0.2"), "up")
clk.Advance(61 * time.Second)
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v, want Miss when ServeStale is 0", res.Kind)
}
}
func TestManagerPutResponseRespectsZeroTTL(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("nocache.example.com")
m.PutResponse(k, answer(t, "nocache.example.com", 0, "10.0.0.3"), "up")
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v, want Miss: a zero TTL means do not cache", res.Kind)
}
}
func TestManagerDeleteNameAndSubtree(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
names := []string{"blocked.example.com", "deep.sub.blocked.example.com", "keep.example.com", "notblocked.example.com"}
for _, n := range names {
m.PutResponse(key(n), answer(t, n, 600, "10.0.0.4"), "up")
}
// "sub.blocked.example.com" must not be caught by the "blocked.example.com"
// subtree walk, but "blocked.example.com" must not catch
// "notblocked.example.com" either.
if n := m.DeleteName("Blocked.Example.Com."); n != 1 {
t.Errorf("DeleteName = %d, want 1 (canonicalised, one name)", n)
}
if res := m.Get(key("blocked.example.com")); res.Kind != Miss {
t.Error("deleted name still served")
}
if n := m.DeleteSubtree("deep.sub.blocked.example.com"); n != 1 {
t.Errorf("DeleteSubtree = %d, want 1", n)
}
for _, n := range []string{"keep.example.com", "notblocked.example.com"} {
if res := m.Get(key(n)); res.Kind != Fresh {
t.Errorf("%s was dropped by an unrelated delete", n)
}
}
}
func TestManagerTopEntriesCountsAndFlush(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
for range 5 {
m.PutResponse(key("hot.example.com"), answer(t, "hot.example.com", 300, "10.0.0.5"), "up")
}
for range 2 {
m.PutResponse(key("warm.example.com"), answer(t, "warm.example.com", 300, "10.0.0.6"), "up")
}
top := m.TopEntries(10)
if len(top) < 2 {
t.Fatalf("TopEntries returned %d rows, want at least 2", len(top))
}
// Each Put counts too, so a name queried once still shows up.
var hot, warm uint64
for _, e := range top {
switch e.Name {
case "hot.example.com":
hot = e.Hits
case "warm.example.com":
warm = e.Hits
}
}
if hot < 5 || warm < 2 {
t.Errorf("top hits: hot=%d warm=%d, want >=5 and >=2", hot, warm)
}
if top[0].Name != "hot.example.com" {
t.Errorf("first row = %s, want the hottest name first", top[0].Name)
}
m.Flush()
if got := len(m.TopEntries(10)); got != 0 {
t.Errorf("TopEntries after Flush = %d rows, want 0", got)
}
}
func TestManagerStatsShape(t *testing.T) {
clk := newClock()
m := NewManager(Options{Size: 100, Shards: 4, MaxTTL: time.Hour, MinTTL: time.Second, Now: clk.Now}, nil)
m.PutResponse(key("a.example.com"), answer(t, "a.example.com", 60, "10.0.0.7"), "up")
m.Get(key("a.example.com"))
m.Get(key("miss.example.com"))
s := m.Stats()
if !s.Enabled {
t.Error("Enabled = false on a live manager")
}
if s.Entries != 1 {
t.Errorf("Entries = %d, want 1", s.Entries)
}
if s.Hits != 1 || s.Misses != 1 {
t.Errorf("hits = %d misses = %d, want 1/1", s.Hits, s.Misses)
}
if s.Size != 100 || s.Shards != 4 {
t.Errorf("size/shards = %d/%d, want 100/4", s.Size, s.Shards)
}
if s.MemoryHuman == "" || s.Bytes <= 0 {
t.Errorf("memory accounting: bytes = %d human = %q, want a positive byte count and a human string", s.Bytes, s.MemoryHuman)
}
if s.Persist.Enabled {
t.Error("Persist.Enabled = true without a store")
}
}
func TestHumanBytes(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1024 * 1024, "1.0 MiB"},
{3 * 1024 * 1024 * 1024 / 2, "1.50 GiB"},
{-1024, "-1.0 KiB"},
}
for _, tc := range cases {
if got := HumanBytes(tc.in); got != tc.want {
t.Errorf("HumanBytes(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStoreSnapshotAndLoad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 2)
for i := range 25 {
n := "n" + strconv.Itoa(i) + ".example.com"
wire, err := answer(t, n, 600, "10.0.0.8").Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
l.Put(key(n), &Entry{
Wire: wire, TTL: 600, Origin: "upstream", Upstream: "1.1.1.1:53",
CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire),
})
}
written, err := st.Snapshot(l, now)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if written != 25 {
t.Errorf("Snapshot wrote %d entries, want 25", written)
}
if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
t.Fatalf("snapshot file missing or empty: %v", err)
}
// A fresh LRU restored from the file keeps the answers and their metadata.
restored := NewLRU(100, 2)
loaded, skipped, err := st.Load(restored, now)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 25 || skipped != 0 {
t.Errorf("Load = %d loaded, %d skipped, want 25/0", loaded, skipped)
}
e, kind := restored.Get(key("n7.example.com"), now)
if e == nil || kind != Fresh {
t.Fatalf("restored entry: e = %v kind = %v, want non-nil/Fresh", e, kind)
}
if e.Upstream != "1.1.1.1:53" || e.Origin != "upstream" {
t.Errorf("restored metadata: origin = %q upstream = %q, want upstream/1.1.1.1:53", e.Origin, e.Upstream)
}
// The wire is intact.
msg := new(dns.Msg)
if err := msg.Unpack(e.Wire); err != nil {
t.Fatalf("unpack restored wire: %v", err)
}
if len(msg.Answer) != 1 {
t.Errorf("restored answer has %d records, want 1", len(msg.Answer))
}
}
func TestStoreLoadSkipsExpiredEntries(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 1)
for i, ttl := range []uint32{600, 600, 5} {
n := "n" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(t, n, ttl, "10.0.0.9").Pack()
l.Put(key(n), &Entry{
Wire: wire, TTL: ttl, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
StaleUntil: now.Add(time.Duration(ttl)*time.Second + 30*time.Second), Bytes: len(wire),
})
}
if _, err := st.Snapshot(l, now); err != nil {
t.Fatalf("Snapshot: %v", err)
}
// Load 10s later: the 5s entry is dead, the two 600s entries are fresh.
restored := NewLRU(100, 1)
loaded, skipped, err := st.Load(restored, now.Add(10*time.Second))
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 2 {
t.Errorf("loaded = %d, want 2", loaded)
}
if skipped != 1 {
t.Errorf("skipped = %d, want 1", skipped)
}
if _, kind := restored.Get(key("n2.example.com"), now.Add(10*time.Second)); kind != Miss {
t.Errorf("expired entry came back after a reload (kind = %v)", kind)
}
}
func TestStoreTruncateAndStats(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 1)
wire, _ := answer(t, "a.example.com", 600, "10.0.0.10").Pack()
l.Put(key("a.example.com"), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
if _, err := st.Snapshot(l, now); err != nil {
t.Fatalf("Snapshot: %v", err)
}
s := st.Stats()
if !s.Enabled || s.Path != path || s.Entries != 1 || s.Writes == 0 || s.Bytes <= 0 {
t.Errorf("Stats = %+v, want enabled with 1 entry and at least one write", s)
}
if !s.LastSnapshot.IsZero() {
t.Logf("LastSnapshot = %v", s.LastSnapshot)
}
if err := st.Truncate(); err != nil {
t.Fatalf("Truncate: %v", err)
}
if s := st.Stats(); s.Entries != 0 {
t.Errorf("entries after Truncate = %d, want 0", s.Entries)
}
restored := NewLRU(100, 1)
loaded, _, err := st.Load(restored, now)
if err != nil {
t.Fatalf("Load after Truncate: %v", err)
}
if loaded != 0 {
t.Errorf("loaded = %d after Truncate, want 0", loaded)
}
}
// TestManagerFlushDropsSnapshot checks the interaction the blocklist API
// depends on: flushing the cache must also clear the persisted snapshot, or a
// restart would resurrect answers an operator just invalidated.
func TestManagerFlushDropsSnapshot(t *testing.T) {
dir := t.TempDir()
st, err := OpenStore(StoreOptions{Path: filepath.Join(dir, "cache.db")})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, st)
k := key("flushed.example.com")
m.PutResponse(k, answer(t, "flushed.example.com", 600, "10.0.0.11"), "up")
if _, err := st.Snapshot(m.LRU(), clk.Now()); err != nil {
t.Fatalf("Snapshot: %v", err)
}
m.Flush()
restored := NewLRU(10, 1)
loaded, _, err := st.Load(restored, clk.Now())
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 0 {
t.Errorf("loaded = %d entries after Flush, want 0", loaded)
}
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v after Flush, want Miss", res.Kind)
}
}
func TestLRUStatsHitRate(t *testing.T) {
l := NewLRU(100, 1)
now := newClock().Now()
l.Put(key("a.example.com"), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: 4})
for range 3 {
l.Get(key("a.example.com"), now)
}
for range 1 {
l.Get(key("nothere.example.com"), now)
}
s := l.Stats()
if s.Hits != 3 || s.Misses != 1 {
t.Fatalf("hits/misses = %d/%d, want 3/1", s.Hits, s.Misses)
}
if s.HitRate < 0.74 || s.HitRate > 0.76 {
t.Errorf("HitRate = %v, want ~0.75", s.HitRate)
}
}
func BenchmarkLRUGet(b *testing.B) {
l := NewLRU(1<<16, 16)
now := time.Now()
names := make([]string, 4096)
for i := range names {
names[i] = "n" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(&testing.T{}, names[i], 600, "10.0.0.12").Pack()
l.Put(key(names[i]), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := rand.Int()
for pb.Next() {
l.Get(key(names[i%len(names)]), now)
i += 7
}
})
}
func BenchmarkLRUPut(b *testing.B) {
l := NewLRU(1<<16, 16)
now := time.Now()
b.ResetTimer()
for i := 0; b.Loop(); i++ {
l.Put(key("n"+strconv.Itoa(i)+".example.com"), &Entry{TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: 64})
}
}
func BenchmarkManagerGetUnpack(b *testing.B) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
names := make([]string, 4096)
for i := range names {
names[i] = "n" + strconv.Itoa(i) + ".example.com"
m.PutResponse(key(names[i]), answer(&testing.T{}, names[i], 600, "10.0.0.13"), "up")
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := rand.Int()
for pb.Next() {
m.Get(key(names[i%len(names)]))
i += 7
}
})
}
// TestStoreSurvivesProcessRestart is the test the warm-restart path exists for:
// write a snapshot, close the file completely, open a brand new Store on the
// same path and load it into a fresh LRU. Anything that only works because the
// same *Store is still holding the file open fails here.
func TestStoreSurvivesProcessRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.db")
clk := newClock()
now := clk.Now()
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
warm := NewLRU(100, 2)
for i := range 5 {
n := "r" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(t, n, 600, "10.1.0."+strconv.Itoa(i)).Pack()
warm.Put(key(n), &Entry{
Wire: wire, TTL: 600, Origin: "upstream", Upstream: "9.9.9.9:53",
CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second),
Bytes: len(wire),
})
}
if written, err := st.Snapshot(warm, now); err != nil || written != 5 {
t.Fatalf("Snapshot = %d, %v; want 5, nil", written, err)
}
if err := st.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Second "process".
restarted, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer restarted.Close()
cold := NewLRU(100, 2)
loaded, skipped, err := restarted.Load(cold, clk.Now())
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 5 || skipped != 0 {
t.Fatalf("Load = %d loaded, %d skipped, want 5/0", loaded, skipped)
}
e, kind := cold.Get(key("r3.example.com"), clk.Now())
if e == nil || kind != Fresh {
t.Fatalf("restored entry: entry=%v kind=%v, want non-nil/Fresh", e, kind)
}
if e.Upstream != "9.9.9.9:53" {
t.Errorf("Upstream = %q, want 9.9.9.9:53", e.Upstream)
}
// The TTL a client sees must not jump back up across the restart: the
// loader rewrites it to the time actually left.
clk.Advance(120 * time.Second)
if got, want := e.AgeTTL(clk.Now()), uint32(480); got != want {
t.Errorf("AgeTTL after 120s = %d, want %d", got, want)
}
}
// TestStoreLoadSkipsDamagedRecords feeds Load a file where individual records
// have been scribbled on, truncated or written by a future version. A snapshot
// is a cache, not a source of truth: every one of these must cost exactly one
// entry and none may fail the whole load.
func TestStoreLoadSkipsDamagedRecords(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.db")
clk := newClock()
now := clk.Now()
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
l := NewLRU(100, 1)
for i := range 4 {
n := "d" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(t, n, 600, "10.2.0."+strconv.Itoa(i)).Pack()
l.Put(key(n), &Entry{
Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second),
StaleUntil: now.Add(630 * time.Second), Bytes: len(wire),
})
}
if written, err := st.Snapshot(l, now); err != nil || written != 4 {
t.Fatalf("Snapshot = %d, %v; want 4, nil", written, err)
}
// Three different kinds of damage, applied to the first three records
// (the keys are packed, so the cursor is the only way to find them).
damaged := map[int][]byte{
0: futureVersionRecord, // written by a hypothetical v2
1: {recordVersion, 0, 0x01, 0x02, 0x03}, // truncated mid-varint
2: corruptRecord(t, encodeRecord(nil, &Entry{ // flipped bit
Wire: []byte{0xde, 0xad}, TTL: 1, CreatedAt: now, ExpiresAt: now, StaleUntil: now,
})),
}
if err := st.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCache)
cur := b.Cursor()
k, _ := cur.First()
for i := 0; k != nil; i++ {
if bad, ok := damaged[i]; ok {
if err := b.Put(k, bad); err != nil {
return err
}
}
k, _ = cur.Next()
}
return nil
}); err != nil {
t.Fatalf("seed damaged records: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
again, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer again.Close()
restored := NewLRU(100, 1)
loaded, skipped, err := again.Load(restored, clk.Now())
if err != nil {
t.Fatalf("Load on a damaged file returned %v, want the good records to survive", err)
}
if loaded != 1 {
t.Errorf("loaded = %d, want 1 (only the untouched record)", loaded)
}
if skipped != 3 {
t.Errorf("skipped = %d, want 3", skipped)
}
if _, kind := restored.Get(key("d3.example.com"), clk.Now()); kind != Fresh {
t.Errorf("intact record did not survive: kind = %v", kind)
}
if _, kind := restored.Get(key("d0.example.com"), clk.Now()); kind != Miss {
t.Errorf("damaged record was loaded anyway: kind = %v", kind)
}
}
// futureVersionRecord is a well-formed record from a hypothetical v2 writer.
var futureVersionRecord = func() []byte {
buf := []byte{recordVersion + 1, 0}
buf = binary.AppendUvarint(buf, 0)
return buf
}()
// corruptRecord flips one bit inside the body so the checksum no longer matches.
func corruptRecord(t *testing.T, rec []byte) []byte {
t.Helper()
out := append([]byte(nil), rec...)
out[len(out)/2] ^= 0x40
return out
}
// TestRecordEncodeDecode pins the on-disk record format itself. The round trip
// through bbolt used to break because the encoder and decoder disagreed; this
// test fails at compile-review time rather than at warm-restart time.
func TestRecordEncodeDecode(t *testing.T) {
now := newClock().Now()
in := &Entry{
Wire: []byte{0x00, 0x01, 0x02, 0x03},
TTL: 600,
Origin: "upstream",
Upstream: "1.1.1.1:53",
CreatedAt: now,
ExpiresAt: now.Add(600 * time.Second),
StaleUntil: now.Add(630 * time.Second),
Neg: true,
Truncated: true,
}
out, err := decodeRecord(encodeRecord(nil, in))
if err != nil {
t.Fatalf("decodeRecord: %v", err)
}
if string(out.Wire) != string(in.Wire) {
t.Errorf("Wire = % x, want % x", out.Wire, in.Wire)
}
for _, f := range []struct {
name string
got, want any
}{
{"TTL", out.TTL, in.TTL},
{"Origin", out.Origin, in.Origin},
{"Upstream", out.Upstream, in.Upstream},
{"Neg", out.Neg, in.Neg},
{"Truncated", out.Truncated, in.Truncated},
} {
if f.got != f.want {
t.Errorf("%s = %v, want %v", f.name, f.got, f.want)
}
}
// Times come back as wall-clock instants rebuilt from Unix nanoseconds, so
// they compare with Equal rather than == (the location bits differ).
for _, f := range []struct {
name string
got, want time.Time
}{
{"CreatedAt", out.CreatedAt, in.CreatedAt},
{"ExpiresAt", out.ExpiresAt, in.ExpiresAt},
{"StaleUntil", out.StaleUntil, in.StaleUntil},
} {
if !f.got.Equal(f.want) {
t.Errorf("%s = %v, want %v", f.name, f.got, f.want)
}
}
// A record whose times are all zero must survive too: the varints encode
// them as 0 and come back as the zero time, not 1970.
zero, err := decodeRecord(encodeRecord(nil, &Entry{Wire: []byte{0xff}}))
if err != nil {
t.Fatalf("decodeRecord(zero times): %v", err)
}
if !zero.ExpiresAt.IsZero() || !zero.CreatedAt.IsZero() {
t.Errorf("zero times came back as %v/%v", zero.ExpiresAt, zero.CreatedAt)
}
for name, bad := range map[string][]byte{
"empty": nil,
"header only": {recordVersion, 0},
"no wire": encodeRecord(nil, &Entry{}),
"bad checksum": corruptRecord(t, encodeRecord(nil, in)),
"bad version": futureVersionRecord,
"truncated tail": {recordVersion, 0, 0x80},
} {
if _, err := decodeRecord(bad); !errors.Is(err, errBadRecord) {
t.Errorf("decodeRecord(%s) error = %v, want errBadRecord", name, err)
}
}
}