// 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 }