initial commit
This commit is contained in:
Vendored
+690
@@ -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
|
||||
}
|
||||
Vendored
+331
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user