Files
vibedns/internal/cache/cache.go
T
2026-08-16 21:18:45 -05:00

691 lines
16 KiB
Go

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