initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+690
View File
@@ -0,0 +1,690 @@
// Package cache implements the resolver cache: a sharded, LRU-bounded store of
// DNS responses with TTL decay, negative caching, stale serving and prefetch.
//
// The cache lives entirely in memory. It is never persisted, because a cache
// that survives a restart would serve answers whose TTLs it can no longer
// reason about.
package cache
import (
"container/list"
"hash/fnv"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
)
// shardCount must be a power of two.
const shardCount = 64
// Config controls cache behaviour. It is swapped in wholesale on change.
type Config struct {
Enabled bool
MaxEntries int
MinTTL uint32
MaxTTL uint32
NegativeTTL uint32
ServeStale bool
StaleTTL uint32
Prefetch bool
PrefetchPercent int
}
// Key identifies a cached response. The DO bit is part of the key because a
// DNSSEC-aware answer carries RRSIG records that a non-DO client must not see.
type Key struct {
Name string // lowercase FQDN
Type uint16
Class uint16
DO bool
}
// String renders the key in the form shown in the cache browser.
func (k Key) String() string {
s := k.Name + " " + dns.TypeToString[k.Type]
if k.DO {
s += " +dnssec"
}
return s
}
func (k Key) hash() uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(k.Name))
_, _ = h.Write([]byte{byte(k.Type >> 8), byte(k.Type), byte(k.Class >> 8), byte(k.Class)})
if k.DO {
_, _ = h.Write([]byte{1})
}
return h.Sum32()
}
// KeyFor builds a cache key from a question.
func KeyFor(q dns.Question, do bool) Key {
return Key{Name: strings.ToLower(dns.Fqdn(q.Name)), Type: q.Qtype, Class: q.Qclass, DO: do}
}
// entry is one cached response.
type entry struct {
key Key
msg *dns.Msg // stored with original TTLs
stored time.Time
ttl uint32 // seconds the answer is fresh for
origTTL uint32 // TTL at insertion, used for the prefetch threshold
rcode int
size int
elem *list.Element // position in the shard LRU
negative bool
}
// expiresAt returns the instant the entry stops being fresh.
func (e *entry) expiresAt() time.Time {
return e.stored.Add(time.Duration(e.ttl) * time.Second)
}
type shard struct {
mu sync.RWMutex
entries map[Key]*entry
lru *list.List // front = most recently used
bytes int64
}
// Cache is the resolver cache.
type Cache struct {
shards [shardCount]*shard
cfgMu sync.RWMutex
cfg Config
hits atomic.Int64
misses atomic.Int64
staleHits atomic.Int64
insertions atomic.Int64
evictions atomic.Int64
expiries atomic.Int64
// prefetch is invoked asynchronously when a fresh-but-ageing entry is hit.
prefetchMu sync.RWMutex
prefetch func(Key)
inflight sync.Map // Key -> struct{}, dedupes prefetch requests
}
// New creates a cache with the given configuration.
func New(cfg Config) *Cache {
c := &Cache{}
for i := range c.shards {
c.shards[i] = &shard{entries: map[Key]*entry{}, lru: list.New()}
}
c.SetConfig(cfg)
return c
}
// SetConfig replaces the cache configuration. Shrinking MaxEntries evicts down
// to the new bound, and disabling the cache flushes it.
func (c *Cache) SetConfig(cfg Config) {
if cfg.MaxEntries <= 0 {
cfg.MaxEntries = 10_000
}
if cfg.MaxTTL == 0 {
cfg.MaxTTL = 86400
}
if cfg.PrefetchPercent <= 0 || cfg.PrefetchPercent >= 100 {
cfg.PrefetchPercent = 10
}
c.cfgMu.Lock()
c.cfg = cfg
c.cfgMu.Unlock()
if !cfg.Enabled {
c.Flush()
return
}
c.enforceBound()
}
// Config returns the current configuration.
func (c *Cache) Config() Config {
c.cfgMu.RLock()
defer c.cfgMu.RUnlock()
return c.cfg
}
// SetPrefetcher registers the callback used to refresh ageing entries.
func (c *Cache) SetPrefetcher(fn func(Key)) {
c.prefetchMu.Lock()
c.prefetch = fn
c.prefetchMu.Unlock()
}
func (c *Cache) shardFor(k Key) *shard {
return c.shards[k.hash()&(shardCount-1)]
}
// Result describes a cache lookup outcome.
type Result struct {
Msg *dns.Msg
Hit bool
Stale bool
Age time.Duration
Expiry time.Time
}
// Get looks up a response. The returned message is a copy with TTLs decayed by
// the time the entry has spent in the cache, so clients never see a TTL that
// stands still.
func (c *Cache) Get(k Key, req *dns.Msg) Result {
cfg := c.Config()
if !cfg.Enabled {
return Result{}
}
sh := c.shardFor(k)
sh.mu.RLock()
e, ok := sh.entries[k]
if !ok {
sh.mu.RUnlock()
c.misses.Add(1)
return Result{}
}
stored, ttl, origTTL := e.stored, e.ttl, e.origTTL
msg := e.msg
sh.mu.RUnlock()
age := time.Since(stored)
elapsed := uint32(age / time.Second)
switch {
case elapsed < ttl:
remaining := ttl - elapsed
out := decayed(msg, req, remaining, elapsed)
c.hits.Add(1)
c.touch(sh, k)
if cfg.Prefetch && shouldPrefetch(remaining, origTTL, cfg.PrefetchPercent) {
c.triggerPrefetch(k)
}
return Result{Msg: out, Hit: true, Age: age, Expiry: stored.Add(time.Duration(ttl) * time.Second)}
case cfg.ServeStale && cfg.StaleTTL > 0 && elapsed < ttl+cfg.StaleTTL:
// RFC 8767: serve the expired answer with a short TTL while a fresh one
// is fetched, rather than failing the client outright.
const staleClientTTL = 30
out := decayed(msg, req, staleClientTTL, elapsed)
c.staleHits.Add(1)
c.hits.Add(1)
c.triggerPrefetch(k)
return Result{Msg: out, Hit: true, Stale: true, Age: age,
Expiry: stored.Add(time.Duration(ttl) * time.Second)}
default:
c.remove(sh, k)
c.expiries.Add(1)
c.misses.Add(1)
return Result{}
}
}
func shouldPrefetch(remaining, orig uint32, percent int) bool {
if orig == 0 {
return false
}
threshold := orig * uint32(percent) / 100
if threshold < 1 {
threshold = 1
}
return remaining <= threshold
}
func (c *Cache) triggerPrefetch(k Key) {
c.prefetchMu.RLock()
fn := c.prefetch
c.prefetchMu.RUnlock()
if fn == nil {
return
}
if _, loaded := c.inflight.LoadOrStore(k, struct{}{}); loaded {
return
}
go func() {
defer c.inflight.Delete(k)
fn(k)
}()
}
// decayed copies a stored message for a specific request, reducing every TTL by
// the number of seconds the entry has been cached.
func decayed(stored *dns.Msg, req *dns.Msg, remaining, elapsed uint32) *dns.Msg {
out := stored.Copy()
if req != nil {
out.Id = req.Id
out.Question = req.Question
out.RecursionDesired = req.RecursionDesired
}
adjust := func(rrs []dns.RR) {
for _, rr := range rrs {
if rr.Header().Rrtype == dns.TypeOPT {
continue
}
t := rr.Header().Ttl
if t <= elapsed {
rr.Header().Ttl = remaining
continue
}
nt := t - elapsed
if nt < 1 {
nt = 1
}
rr.Header().Ttl = nt
}
}
adjust(out.Answer)
adjust(out.Ns)
adjust(out.Extra)
return out
}
// Put stores a response. It returns the TTL the entry was stored with, or 0 if
// the response was not cacheable.
func (c *Cache) Put(k Key, msg *dns.Msg) uint32 {
cfg := c.Config()
if !cfg.Enabled || msg == nil {
return 0
}
if !cacheable(msg) {
return 0
}
negative := isNegative(msg)
ttl := responseTTL(msg, negative, cfg)
if ttl == 0 {
return 0
}
stored := msg.Copy()
// The OPT record describes the transport of one exchange, not the data, so
// it must not be replayed to a different client.
stored.Extra = stripOPT(stored.Extra)
stored.Id = 0
e := &entry{
key: k,
msg: stored,
stored: time.Now(),
ttl: ttl,
origTTL: ttl,
rcode: msg.Rcode,
size: estimateSize(k, stored),
negative: negative,
}
sh := c.shardFor(k)
sh.mu.Lock()
if old, ok := sh.entries[k]; ok {
sh.lru.Remove(old.elem)
sh.bytes -= int64(old.size)
}
e.elem = sh.lru.PushFront(k)
sh.entries[k] = e
sh.bytes += int64(e.size)
sh.mu.Unlock()
c.insertions.Add(1)
c.enforceBound()
return ttl
}
// cacheable rejects responses that must never be reused.
func cacheable(msg *dns.Msg) bool {
if msg.Truncated {
return false
}
switch msg.Rcode {
case dns.RcodeSuccess, dns.RcodeNameError:
return true
default:
// SERVFAIL, REFUSED and friends are transient or client specific.
return false
}
}
func isNegative(msg *dns.Msg) bool {
return msg.Rcode == dns.RcodeNameError || len(msg.Answer) == 0
}
// responseTTL derives the cache lifetime from the response, clamped to the
// configured bounds. Negative answers use the SOA MINIMUM per RFC 2308.
func responseTTL(msg *dns.Msg, negative bool, cfg Config) uint32 {
if negative {
ttl := cfg.NegativeTTL
if soa := findSOA(msg.Ns); soa != nil {
t := soa.Minttl
if soa.Hdr.Ttl < t {
t = soa.Hdr.Ttl
}
if t < ttl || ttl == 0 {
ttl = t
}
}
if ttl == 0 {
return 0
}
return clampTTL(ttl, cfg)
}
ttl := uint32(0)
first := true
for _, section := range [][]dns.RR{msg.Answer, msg.Ns} {
for _, rr := range section {
if rr.Header().Rrtype == dns.TypeOPT {
continue
}
t := rr.Header().Ttl
if first || t < ttl {
ttl = t
first = false
}
}
}
if first {
return 0 // nothing with a TTL to key off
}
return clampTTL(ttl, cfg)
}
func clampTTL(ttl uint32, cfg Config) uint32 {
if cfg.MinTTL > 0 && ttl < cfg.MinTTL {
ttl = cfg.MinTTL
}
if cfg.MaxTTL > 0 && ttl > cfg.MaxTTL {
ttl = cfg.MaxTTL
}
return ttl
}
func findSOA(rrs []dns.RR) *dns.SOA {
for _, rr := range rrs {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
return nil
}
func stripOPT(rrs []dns.RR) []dns.RR {
out := rrs[:0]
for _, rr := range rrs {
if rr.Header().Rrtype == dns.TypeOPT {
continue
}
out = append(out, rr)
}
return out
}
// estimateSize approximates the heap cost of an entry, for the memory readout.
func estimateSize(k Key, msg *dns.Msg) int {
const entryOverhead = 160 // struct, map bucket and list element
return entryOverhead + len(k.Name) + msg.Len()
}
func (c *Cache) touch(sh *shard, k Key) {
sh.mu.Lock()
if e, ok := sh.entries[k]; ok && e.elem != nil {
sh.lru.MoveToFront(e.elem)
}
sh.mu.Unlock()
}
func (c *Cache) remove(sh *shard, k Key) {
sh.mu.Lock()
if e, ok := sh.entries[k]; ok {
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
}
sh.mu.Unlock()
}
// Delete removes one entry. It reports whether the entry was present.
func (c *Cache) Delete(k Key) bool {
sh := c.shardFor(k)
sh.mu.Lock()
defer sh.mu.Unlock()
e, ok := sh.entries[k]
if !ok {
return false
}
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
return true
}
// Flush empties the cache and returns how many entries were dropped.
func (c *Cache) Flush() int {
total := 0
for _, sh := range c.shards {
sh.mu.Lock()
total += len(sh.entries)
sh.entries = map[Key]*entry{}
sh.lru.Init()
sh.bytes = 0
sh.mu.Unlock()
}
return total
}
// FlushName removes every entry for one name, across all types.
func (c *Cache) FlushName(name string) int {
name = strings.ToLower(dns.Fqdn(name))
removed := 0
for _, sh := range c.shards {
sh.mu.Lock()
for k, e := range sh.entries {
if k.Name == name {
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
removed++
}
}
sh.mu.Unlock()
}
return removed
}
// enforceBound evicts least-recently-used entries until the cache fits.
//
// The bound is applied per shard so that eviction never has to lock the whole
// cache at once.
func (c *Cache) enforceBound() {
cfg := c.Config()
if cfg.MaxEntries <= 0 {
return
}
perShard := cfg.MaxEntries / shardCount
if perShard < 1 {
perShard = 1
}
for _, sh := range c.shards {
sh.mu.Lock()
for len(sh.entries) > perShard {
back := sh.lru.Back()
if back == nil {
break
}
k := back.Value.(Key)
if e, ok := sh.entries[k]; ok {
sh.bytes -= int64(e.size)
delete(sh.entries, k)
}
sh.lru.Remove(back)
c.evictions.Add(1)
}
sh.mu.Unlock()
}
}
// Cleanup drops entries that are past both their TTL and their stale window.
// It returns the number removed.
func (c *Cache) Cleanup() int {
cfg := c.Config()
grace := time.Duration(0)
if cfg.ServeStale {
grace = time.Duration(cfg.StaleTTL) * time.Second
}
now := time.Now()
removed := 0
for _, sh := range c.shards {
sh.mu.Lock()
for k, e := range sh.entries {
if now.After(e.expiresAt().Add(grace)) {
if e.elem != nil {
sh.lru.Remove(e.elem)
}
sh.bytes -= int64(e.size)
delete(sh.entries, k)
removed++
}
}
sh.mu.Unlock()
}
c.expiries.Add(int64(removed))
return removed
}
// Run starts the periodic cleanup loop. It returns when done is closed.
func (c *Cache) Run(done <-chan struct{}, interval func() time.Duration) {
for {
d := interval()
if d <= 0 {
d = time.Minute
}
select {
case <-done:
return
case <-time.After(d):
c.Cleanup()
}
}
}
// Stats is a snapshot of cache counters for the dashboard and metrics.
type Stats struct {
Enabled bool `json:"enabled"`
Entries int `json:"entries"`
MaxEntries int `json:"max_entries"`
Bytes int64 `json:"bytes"`
Hits int64 `json:"hits"`
Misses int64 `json:"misses"`
StaleHits int64 `json:"stale_hits"`
Insertions int64 `json:"insertions"`
Evictions int64 `json:"evictions"`
Expirations int64 `json:"expirations"`
HitRate float64 `json:"hit_rate"`
}
// Stats returns the current counters.
func (c *Cache) Stats() Stats {
s := Stats{
Enabled: c.Config().Enabled,
MaxEntries: c.Config().MaxEntries,
Hits: c.hits.Load(),
Misses: c.misses.Load(),
StaleHits: c.staleHits.Load(),
Insertions: c.insertions.Load(),
Evictions: c.evictions.Load(),
Expirations: c.expiries.Load(),
}
for _, sh := range c.shards {
sh.mu.RLock()
s.Entries += len(sh.entries)
s.Bytes += sh.bytes
sh.mu.RUnlock()
}
if total := s.Hits + s.Misses; total > 0 {
s.HitRate = float64(s.Hits) / float64(total) * 100
}
return s
}
// ResetStats zeroes the counters without touching the cached data.
func (c *Cache) ResetStats() {
c.hits.Store(0)
c.misses.Store(0)
c.staleHits.Store(0)
c.insertions.Store(0)
c.evictions.Store(0)
c.expiries.Store(0)
}
// EntryView describes one cached entry for the cache browser.
type EntryView struct {
Name string `json:"name"`
Type string `json:"type"`
DO bool `json:"dnssec"`
Rcode string `json:"rcode"`
Answers int `json:"answers"`
TTL int64 `json:"ttl"`
Stored time.Time `json:"stored"`
Expires time.Time `json:"expires"`
Size int `json:"size"`
Negative bool `json:"negative"`
Stale bool `json:"stale"`
}
// Entries returns cached entries matching a substring of the name, newest
// first, capped at limit. It also returns the total number of matches.
func (c *Cache) Entries(search string, limit, offset int) ([]EntryView, int) {
search = strings.ToLower(strings.TrimSpace(search))
now := time.Now()
var out []EntryView
for _, sh := range c.shards {
sh.mu.RLock()
for k, e := range sh.entries {
if search != "" && !strings.Contains(k.Name, search) {
continue
}
expires := e.expiresAt()
out = append(out, EntryView{
Name: k.Name,
Type: dns.TypeToString[k.Type],
DO: k.DO,
Rcode: dns.RcodeToString[e.rcode],
Answers: len(e.msg.Answer),
TTL: int64(expires.Sub(now) / time.Second),
Stored: e.stored,
Expires: expires,
Size: e.size,
Negative: e.negative,
Stale: now.After(expires),
})
}
sh.mu.RUnlock()
}
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Type < out[j].Type
})
total := len(out)
if offset > total {
offset = total
}
out = out[offset:]
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, total
}
+331
View File
@@ -0,0 +1,331 @@
package cache
import (
"testing"
"time"
"github.com/miekg/dns"
)
func testConfig() Config {
return Config{
Enabled: true, MaxEntries: 1000, MinTTL: 0, MaxTTL: 86400,
NegativeTTL: 300, ServeStale: false, StaleTTL: 0,
}
}
func reply(name string, ttl uint32, ip string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
m = m.SetReply(m)
m.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: []byte{192, 0, 2, 1},
}}
return m
}
func keyFor(name string) Key {
return Key{Name: dns.Fqdn(name), Type: dns.TypeA, Class: dns.ClassINET}
}
func request(name string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
return m
}
func TestPutAndGet(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
if res := c.Get(k, request("example.com")); res.Hit {
t.Fatal("expected a miss on an empty cache")
}
if ttl := c.Put(k, reply("example.com", 300, "192.0.2.1")); ttl != 300 {
t.Fatalf("stored TTL = %d, want 300", ttl)
}
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit after storing")
}
if len(res.Msg.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(res.Msg.Answer))
}
s := c.Stats()
if s.Hits != 1 || s.Misses != 1 {
t.Errorf("hits/misses = %d/%d, want 1/1", s.Hits, s.Misses)
}
}
func TestTTLIsClamped(t *testing.T) {
tests := []struct {
name string
min, max uint32
ttl uint32
want uint32
}{
{"below minimum is raised", 60, 86400, 5, 60},
{"above maximum is capped", 0, 3600, 100000, 3600},
{"within bounds is unchanged", 60, 3600, 300, 300},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := testConfig()
cfg.MinTTL, cfg.MaxTTL = tc.min, tc.max
c := New(cfg)
if got := c.Put(keyFor("example.com"), reply("example.com", tc.ttl, "192.0.2.1")); got != tc.want {
t.Errorf("stored TTL = %d, want %d", got, tc.want)
}
})
}
}
// TestTTLDecays is the property that matters most: a cached answer must not
// hand out a TTL that stands still, or downstream caches never expire it.
func TestTTLDecays(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 300, "192.0.2.1"))
// Reach in and age the entry rather than sleeping for real.
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-100 * time.Second)
sh.mu.Unlock()
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit while still fresh")
}
got := res.Msg.Answer[0].Header().Ttl
if got > 201 || got < 199 {
t.Errorf("served TTL = %d, want about 200 after 100 seconds", got)
}
}
func TestExpiredEntryIsAMiss(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-30 * time.Second)
sh.mu.Unlock()
if res := c.Get(k, request("example.com")); res.Hit {
t.Error("an expired entry must not be served when stale serving is off")
}
}
func TestServeStale(t *testing.T) {
cfg := testConfig()
cfg.ServeStale = true
cfg.StaleTTL = 3600
c := New(cfg)
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-60 * time.Second)
sh.mu.Unlock()
res := c.Get(k, request("example.com"))
if !res.Hit || !res.Stale {
t.Fatalf("expected a stale hit, got hit=%v stale=%v", res.Hit, res.Stale)
}
if got := res.Msg.Answer[0].Header().Ttl; got == 0 || got > 60 {
t.Errorf("stale TTL = %d, want a short positive value", got)
}
// Past the stale window it must miss.
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-7200 * time.Second)
sh.mu.Unlock()
if res := c.Get(k, request("example.com")); res.Hit {
t.Error("an entry past the stale window must not be served")
}
}
func TestNegativeCachingUsesSOAMinimum(t *testing.T) {
c := New(testConfig())
m := new(dns.Msg)
m.SetQuestion("missing.example.com.", dns.TypeA)
m = m.SetReply(m)
m.Rcode = dns.RcodeNameError
m.Ns = []dns.RR{&dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "hostmaster.example.com.",
Minttl: 120,
}}
k := Key{Name: "missing.example.com.", Type: dns.TypeA, Class: dns.ClassINET}
// RFC 2308: the negative TTL is the lesser of the SOA TTL and its MINIMUM.
if ttl := c.Put(k, m); ttl != 120 {
t.Errorf("negative TTL = %d, want the SOA minimum of 120", ttl)
}
if res := c.Get(k, request("missing.example.com")); !res.Hit {
t.Error("a negative answer should be cached")
}
}
func TestUncacheableResponses(t *testing.T) {
c := New(testConfig())
t.Run("servfail is not cached", func(t *testing.T) {
m := reply("fail.example.com", 300, "192.0.2.1")
m.Rcode = dns.RcodeServerFailure
if ttl := c.Put(keyFor("fail.example.com"), m); ttl != 0 {
t.Errorf("stored a SERVFAIL with TTL %d; transient failures must not be cached", ttl)
}
})
t.Run("truncated is not cached", func(t *testing.T) {
m := reply("trunc.example.com", 300, "192.0.2.1")
m.Truncated = true
if ttl := c.Put(keyFor("trunc.example.com"), m); ttl != 0 {
t.Errorf("stored a truncated response with TTL %d", ttl)
}
})
}
// TestOPTIsNotReplayed guards a subtle correctness bug: the OPT record
// describes one client's transport, not the data, so replaying it to another
// client would advertise the wrong buffer size.
func TestOPTIsNotReplayed(t *testing.T) {
c := New(testConfig())
m := reply("example.com", 300, "192.0.2.1")
m.SetEdns0(4096, true)
k := keyFor("example.com")
c.Put(k, m)
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit")
}
if res.Msg.IsEdns0() != nil {
t.Error("the cached response still carries an OPT record from the original exchange")
}
}
// TestDOBitSeparatesEntries: a DNSSEC answer carries RRSIGs that a non-DO
// client must never receive, so the two must not share a cache entry.
func TestDOBitSeparatesEntries(t *testing.T) {
c := New(testConfig())
plain := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: false}
signed := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: true}
c.Put(plain, reply("example.com", 300, "192.0.2.1"))
if res := c.Get(signed, request("example.com")); res.Hit {
t.Error("a DO query was served from the non-DO cache entry")
}
}
func TestFlushAndDelete(t *testing.T) {
c := New(testConfig())
for _, n := range []string{"a.example.com", "b.example.com", "c.example.com"} {
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
}
if got := c.Stats().Entries; got != 3 {
t.Fatalf("entries = %d, want 3", got)
}
if !c.Delete(keyFor("a.example.com")) {
t.Error("Delete reported the entry was absent")
}
if got := c.Stats().Entries; got != 2 {
t.Errorf("entries after delete = %d, want 2", got)
}
if n := c.Flush(); n != 2 {
t.Errorf("Flush removed %d, want 2", n)
}
if got := c.Stats().Entries; got != 0 {
t.Errorf("entries after flush = %d, want 0", got)
}
}
func TestFlushName(t *testing.T) {
c := New(testConfig())
c.Put(Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
c.Put(Key{Name: "example.com.", Type: dns.TypeAAAA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
c.Put(Key{Name: "other.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("other.com", 300, "192.0.2.1"))
if n := c.FlushName("example.com"); n != 2 {
t.Errorf("FlushName removed %d entries, want 2 (both types)", n)
}
if got := c.Stats().Entries; got != 1 {
t.Errorf("entries remaining = %d, want 1", got)
}
}
func TestDisabledCacheStoresNothing(t *testing.T) {
cfg := testConfig()
cfg.Enabled = false
c := New(cfg)
if ttl := c.Put(keyFor("example.com"), reply("example.com", 300, "192.0.2.1")); ttl != 0 {
t.Error("a disabled cache must not store entries")
}
if res := c.Get(keyFor("example.com"), request("example.com")); res.Hit {
t.Error("a disabled cache must not report hits")
}
}
func TestEvictionRespectsBound(t *testing.T) {
cfg := testConfig()
// One entry per shard; the bound is applied per shard.
cfg.MaxEntries = shardCount
c := New(cfg)
for i := 0; i < shardCount*20; i++ {
name := dns.Fqdn("host" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ".example.com")
c.Put(Key{Name: name, Type: dns.TypeA, Class: dns.ClassINET}, reply(name, 300, "192.0.2.1"))
}
s := c.Stats()
if s.Entries > shardCount {
t.Errorf("entries = %d, want at most %d after eviction", s.Entries, shardCount)
}
if s.Evictions == 0 {
t.Error("expected evictions to be recorded")
}
}
func TestCleanupRemovesExpired(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-time.Hour)
sh.mu.Unlock()
if n := c.Cleanup(); n != 1 {
t.Errorf("Cleanup removed %d, want 1", n)
}
}
func TestEntriesBrowsing(t *testing.T) {
c := New(testConfig())
for _, n := range []string{"alpha.example.com", "beta.example.com", "gamma.test"} {
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
}
all, total := c.Entries("", 10, 0)
if total != 3 || len(all) != 3 {
t.Errorf("browse all: got %d of %d, want 3 of 3", len(all), total)
}
filtered, total := c.Entries("example.com", 10, 0)
if total != 2 || len(filtered) != 2 {
t.Errorf("browse filtered: got %d of %d, want 2 of 2", len(filtered), total)
}
}