initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
// Package cache implements the hot answer cache: a sharded LRU over packed
// wire-format DNS messages, backed by a bbolt snapshot so a restart does not
// start cold.
//
// Design notes that matter for performance:
//
// - Entries are stored as packed wire bytes, not as *dns.Msg. A hit costs
// one Unpack and yields a private copy, so serving never mutates shared
// state and TTL ageing is free. This is the same trade dnsmasq makes.
// - Keys are Go strings built from the already-allocated question name, so
// the lookup path does not allocate on a hit.
// - The LRU is sharded by name hash: a hot domain only contends with other
// names in its own shard.
// - Expired-but-useful entries are kept for a serve-stale window so a dead
// upstream degrades latency instead of availability.
package cache
import (
"encoding/binary"
"strings"
"time"
"github.com/miekg/dns"
)
// Key identifies a cache entry. Scope lets several logically distinct
// resolvers share one cache (for example per-upstream-group ACL overrides)
// without colliding.
type Key struct {
Name string
Type uint16
Class uint16
// DO mirrors the request's DNSSEC OK bit. Since we pass AD/DO through
// without validating, a DO query legitimately gets a different answer.
DO bool
// Scope is a small caller-defined namespace, usually a group/ACL key.
Scope uint8
// CD (checking disabled) is not part of the key: RFC 4035 says a CD
// query is answered from a non-CD cached entry, which is what we do.
}
// QueryKey builds a Key from a DNS question.
func QueryKey(name string, qtype, qclass uint16, do bool, scope uint8) Key {
return Key{
Name: canonical(name),
Type: qtype,
Class: qclass,
DO: do,
Scope: scope,
}
}
// FromMsg builds a Key from the first question of a message.
func FromMsg(m *dns.Msg, scope uint8) (Key, bool) {
if len(m.Question) == 0 {
return Key{}, false
}
q := m.Question[0]
return QueryKey(q.Name, q.Qtype, q.Qclass, Do(m), scope), true
}
// Do reports whether the message carries the DNSSEC OK bit.
func Do(m *dns.Msg) bool {
if o := m.IsEdns0(); o != nil {
return o.Do()
}
return false
}
// canonical lowercases and strips the trailing dot of a name so that
// "Example.COM." and "example.com" share one entry.
func canonical(name string) string {
if n := len(name); n > 0 && name[n-1] == '.' {
name = name[:n-1]
}
// A fast path for the overwhelmingly common all-lowercase case keeps this
// allocation-free.
for i := 0; i < len(name); i++ {
c := name[i]
if c >= 'A' && c <= 'Z' {
return strings.ToLower(name)
}
}
return name
}
// packKey serialises a key for the bbolt snapshot. The wire format is
// length-prefixed so it is unambiguous and compact:
//
// varint len | wire name | uint16 type | uint16 class | uint8 flags
func packKey(buf []byte, k Key) ([]byte, error) {
name := k.Name
if n := len(name); n > 255*4 {
return nil, errKeyTooLong
}
var tmp [256]byte
wireLen, err := dns.PackDomainName(name, tmp[:], 0, nil, false)
if err != nil {
return nil, err
}
buf = binary.AppendUvarint(buf, uint64(wireLen))
buf = append(buf, tmp[:wireLen]...)
buf = binary.LittleEndian.AppendUint16(buf, k.Type)
buf = binary.LittleEndian.AppendUint16(buf, k.Class)
var flags uint8
if k.DO {
flags |= 1
}
flags |= k.Scope << 1
return append(buf, flags), nil
}
func unpackKey(buf []byte) (Key, []byte, error) {
l, n := binary.Uvarint(buf)
if n <= 0 || l > uint64(len(buf)-n) {
return Key{}, nil, errBadKey
}
buf = buf[n:]
wire := buf[:int(l)]
buf = buf[l:]
if len(buf) < 5 {
return Key{}, nil, errBadKey
}
name, _, err := dns.UnpackDomainName(wire, 0)
if err != nil {
return Key{}, nil, err
}
var k Key
k.Name = canonical(name)
k.Type = binary.LittleEndian.Uint16(buf[0:])
k.Class = binary.LittleEndian.Uint16(buf[2:])
flags := buf[4]
k.DO = flags&1 != 0
k.Scope = flags >> 1
return k, buf[5:], nil
}
// Entry is one cached response plus its timing metadata.
//
// Wire is the packed response exactly as it will be served (minus ID and TTL
// ageing, both applied on unpack). Origin records where the answer came from
// for the UI.
type Entry struct {
Wire []byte
// TTL is the value the entry was stored with, used to age the answer on
// every serve so clients see a monotonically decreasing TTL.
TTL uint32
// Age is the logical age at store time (0 for a fresh answer).
Age time.Duration
// Origin is a small enum: "upstream", "prewarm", "local", "blocked".
Origin string
// Upstream is the address the answer came from, shown in the UI.
Upstream string
// CreatedAt is the wall clock when the entry was stored.
CreatedAt time.Time
// ExpiresAt is when the entry stops being fresh.
ExpiresAt time.Time
// StaleUntil is when the entry is finally evicted. It is ExpiresAt plus
// the serve-stale window.
StaleUntil time.Time
// Bytes is the accounted size, used by the LRU and the size gauges.
Bytes int
// Hits counts serves since it was stored.
Hits uint32
// Neg marks a cached NXDOMAIN or NODATA answer.
Neg bool
// Truncated records that the upstream answer did not fit in 512 bytes,
// so a cache hit must re-truncate for a small client buffer.
Truncated bool
}
// AgeTTL returns the TTL an entry should be served with, given the elapsed
// time since it was stored, clamped at zero.
func (e *Entry) AgeTTL(now time.Time) uint32 {
if e.CreatedAt.IsZero() {
return e.TTL
}
elapsed := uint32(now.Sub(e.CreatedAt).Seconds())
if elapsed >= e.TTL {
return 0
}
return e.TTL - elapsed
}
// Remaining returns how long the entry stays fresh.
func (e *Entry) Remaining(now time.Time) time.Duration {
if !e.ExpiresAt.After(now) {
return 0
}
return e.ExpiresAt.Sub(now)
}
// StaleRemaining returns how long the entry remains serveable as stale data.
func (e *Entry) StaleRemaining(now time.Time) time.Duration {
if !e.StaleUntil.After(now) {
return 0
}
return e.StaleUntil.Sub(now)
}
// Expired reports whether the entry is past its freshness window.
func (e *Entry) Expired(now time.Time) bool { return !e.ExpiresAt.After(now) }
// BlockResponse is a synthesised answer (blocked or locally defined) held in
// the cache so repeated blocked queries never touch the matcher or the wire.
type BlockResponse struct {
Wire []byte
TTL uint32
}
+362
View File
@@ -0,0 +1,362 @@
package cache
import (
"container/list"
"hash/maphash"
"sync"
"sync/atomic"
"time"
)
// DefaultShardCount is used when the config does not pick one.
const DefaultShardCount = 32
// LRU is a sharded, size-bounded LRU keyed by Key.
//
// Each shard owns its own map and intrusive list, so the common case (a
// lookup that hits and is moved to the front) takes one shard mutex. The
// LRU is *not* strict-LRU globally: evicting a shard's least-recently-used
// entry is a fine approximation and keeps contention low. The global entry
// budget is divided evenly between shards.
type LRU struct {
shards []*shard
mask uint64
// perShard is the per-shard entry budget.
perShard int
// counters are the hot-path stats, kept as atomics so Get does not need
// the lock just to record a hit.
hits atomic.Uint64
misses atomic.Uint64
staleHits atomic.Uint64
inserts atomic.Uint64
evictions atomic.Uint64
// expired drops entries found already past their stale window.
expired atomic.Uint64
bytes atomic.Int64
entries atomic.Int64
seed maphash.Seed
// prefetch delays revalidation without blocking Get. It is a semaphore of
// "refreshes in flight" so a burst of stale hits triggers one fetch.
prefetchGate chan struct{}
}
type shard struct {
mu sync.Mutex
items map[Key]*list.Element
ll *list.List
bytes int
}
type lruItem struct {
key Key
entry *Entry
}
// NewLRU builds an LRU holding at most size entries across shards shards
// (rounded up to a power of two, minimum 1).
func NewLRU(size, shards int) *LRU {
if shards <= 0 {
shards = DefaultShardCount
}
shards = roundPow2(shards)
if size < shards {
size = shards
}
l := &LRU{
shards: make([]*shard, shards),
mask: uint64(shards - 1),
perShard: size / shards,
seed: maphash.MakeSeed(),
prefetchGate: make(chan struct{}, 64),
}
if l.perShard < 1 {
l.perShard = 1
}
for i := range l.shards {
l.shards[i] = &shard{
items: make(map[Key]*list.Element, 1024),
ll: list.New(),
}
}
return l
}
func roundPow2(n int) int {
if n <= 1 {
return 1
}
p := 1
for p < n {
p <<= 1
}
return p
}
func (l *LRU) shardFor(k Key) *shard {
h := maphash.String(l.seed, k.Name)
// Mix the type/scope into the high bits so A and AAAA for one name do not
// always land in the same shard.
h ^= uint64(k.Type)<<40 | uint64(k.Scope)<<56
return l.shards[h&l.mask]
}
// Get returns the entry for k and reports its kind. The returned entry is
// shared and must be treated as read-only.
//
// stale controls whether an expired-but-not-yet-dead entry is returned. When
// stale is returned the caller should kick off a refresh (prefetch) and
// answer from the stale copy.
func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) {
s := l.shardFor(k)
s.mu.Lock()
el, ok := s.items[k]
if !ok {
s.mu.Unlock()
l.misses.Add(1)
return nil, Miss
}
it := el.Value.(*lruItem)
if now.After(it.entry.StaleUntil) {
// Fully dead: drop it now so the map does not grow unbounded.
s.ll.Remove(el)
delete(s.items, k)
s.bytes -= it.entry.Bytes
s.mu.Unlock()
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
l.expired.Add(1)
l.misses.Add(1)
return nil, Miss
}
s.ll.MoveToFront(el)
e = it.entry
kind = Fresh
if e.Expired(now) {
kind = Stale
}
s.mu.Unlock()
e.Hits++
if kind == Fresh {
l.hits.Add(1)
} else {
l.staleHits.Add(1)
}
return e, kind
}
// Put inserts or replaces an entry, evicting the shard's least-recently-used
// entries if the per-shard budget is exceeded.
func (l *LRU) Put(k Key, e *Entry) {
if l == nil {
return
}
s := l.shardFor(k)
s.mu.Lock()
if el, ok := s.items[k]; ok {
it := el.Value.(*lruItem)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
it.entry = e
s.ll.MoveToFront(el)
} else {
el := s.ll.PushFront(&lruItem{key: k, entry: e})
s.items[k] = el
l.entries.Add(1)
}
s.bytes += e.Bytes
l.bytes.Add(int64(e.Bytes))
var dropped int
for s.ll.Len() > l.perShard {
oldest := s.ll.Back()
if oldest == nil {
break
}
it := oldest.Value.(*lruItem)
s.ll.Remove(oldest)
delete(s.items, it.key)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
dropped++
}
// Do not hold the lock while the global counters move: they are
// independent atomics, so batching is a pure win.
s.mu.Unlock()
l.evictions.Add(uint64(dropped))
l.inserts.Add(1)
}
// Delete removes one entry.
func (l *LRU) Delete(k Key) {
s := l.shardFor(k)
s.mu.Lock()
if el, ok := s.items[k]; ok {
it := el.Value.(*lruItem)
s.ll.Remove(el)
delete(s.items, k)
s.bytes -= it.entry.Bytes
s.mu.Unlock()
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
return
}
s.mu.Unlock()
}
// Flush drops everything.
func (l *LRU) Flush() {
for _, s := range l.shards {
s.mu.Lock()
n := s.ll.Len()
b := s.bytes
s.items = make(map[Key]*list.Element, 1024)
s.ll.Init()
s.bytes = 0
s.mu.Unlock()
l.entries.Add(-int64(n))
l.bytes.Add(-int64(b))
}
}
// Walk visits every live entry. fn runs without the shard lock held, so it
// must not retain the entry; entries removed meanwhile are simply skipped.
// Returning false from fn stops the walk.
func (l *LRU) Walk(fn func(Key, *Entry) bool) {
for _, s := range l.shards {
s.mu.Lock()
items := make([]lruItem, 0, s.ll.Len())
for el := s.ll.Front(); el != nil; el = el.Next() {
it := el.Value.(*lruItem)
items = append(items, *it)
}
s.mu.Unlock()
for i := range items {
if !fn(items[i].key, items[i].entry) {
return
}
}
}
}
// DeleteWhere visits every entry and removes those matching pred.
func (l *LRU) DeleteWhere(pred func(Key, *Entry) bool) int {
removed := 0
for _, s := range l.shards {
s.mu.Lock()
for el := s.ll.Front(); el != nil; {
next := el.Next()
it := el.Value.(*lruItem)
if pred(it.key, it.entry) {
s.ll.Remove(el)
delete(s.items, it.key)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
removed++
}
el = next
}
s.mu.Unlock()
}
return removed
}
// TryPrefetch acquires a refresh slot without blocking. Callers use it to
// make sure a burst of stale hits triggers exactly one background refresh.
func (l *LRU) TryPrefetch() bool {
select {
case l.prefetchGate <- struct{}{}:
return true
default:
return false
}
}
// ReleasePrefetch returns a slot taken by TryPrefetch.
func (l *LRU) ReleasePrefetch() {
select {
case <-l.prefetchGate:
default:
}
}
// HitKind describes how a lookup was satisfied.
type HitKind uint8
const (
// Miss means the answer must be resolved.
Miss HitKind = iota
// Fresh means the entry is inside its TTL.
Fresh
// Stale means the entry is past its TTL but inside the serve-stale
// window: serve it and refresh in the background.
Stale
)
func (h HitKind) String() string {
switch h {
case Fresh:
return "hit"
case Stale:
return "stale"
default:
return "miss"
}
}
// LRUStats is a snapshot of the LRU counters.
type LRUStats struct {
Entries int `json:"entries"`
Bytes int64 `json:"bytes"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
StaleHits uint64 `json:"stale_hits"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expired uint64 `json:"expired"`
HitRate float64 `json:"hit_rate"`
Shards int `json:"shards"`
Capacity int `json:"capacity"`
}
// Stats snapshots the counters.
func (l *LRU) Stats() LRUStats {
if l == nil {
return LRUStats{}
}
hits := l.hits.Load()
misses := l.misses.Load()
total := hits + misses
var rate float64
if total > 0 {
rate = float64(hits) / float64(total)
}
return LRUStats{
Entries: int(l.entries.Load()),
Bytes: l.bytes.Load(),
Hits: hits,
Misses: misses,
StaleHits: l.staleHits.Load(),
Inserts: l.inserts.Load(),
Evictions: l.evictions.Load(),
Expired: l.expired.Load(),
HitRate: rate,
Shards: len(l.shards),
Capacity: l.perShard * len(l.shards),
}
}
// SetCapacity resizes the per-shard budget. Existing entries are kept; the
// next insert past the new budget evicts.
func (l *LRU) SetCapacity(size, shards int) {
if shards <= 0 {
shards = len(l.shards)
}
shards = roundPow2(shards)
l.perShard = max(1, size/shards)
}
+596
View File
@@ -0,0 +1,596 @@
package cache
import (
"math"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
)
var (
errKeyTooLong = errString("cache: name too long to serialise")
errBadKey = errString("cache: corrupt persisted key")
)
type errString string
func (e errString) Error() string { return string(e) }
// Options configures a Manager.
type Options struct {
// Size is the global entry budget for the in-memory LRU.
Size int
// Shards is the LRU shard count (rounded to a power of two).
Shards int
// MinTTL and MaxTTL clamp the TTLs taken from upstream answers.
MinTTL time.Duration
MaxTTL time.Duration
// NegativeTTL bounds cached NXDOMAIN/NODATA answers.
NegativeTTL time.Duration
// ServeStale keeps expired entries answerable for this long.
ServeStale time.Duration
// Prefetch refreshes stale entries in the background when a hook is set.
Prefetch bool
// Now is injectable for tests.
Now func() time.Time
}
// Manager is the cache front door: TTL policy, serve-stale, prefetching and
// the persisted snapshot all live here, so the DNS handler stays a straight
// pipeline.
type Manager struct {
lru *LRU
opt Options
now func() time.Time
// set holds a snapshot-aware view: the LRU plus the persisted store.
store *Store
// prefetchFn is called (off the hot path) when a stale entry is served.
// It is set once at wiring time via SetPrefetcher.
prefetchFn atomic.Pointer[prefetcher]
// top holds a rolling per-domain hit counter for the "top cached" UI.
top *topDomains
// Blocked counts blocklist hits served from the matcher, kept here so the
// dashboard can show block volume without a second counter package.
blockedSince atomic.Uint64
mu sync.Mutex
flushing bool
}
type prefetcher func(Key)
// NewManager builds a Manager. store may be nil (persistence disabled).
func NewManager(opt Options, store *Store) *Manager {
if opt.Now == nil {
opt.Now = time.Now
}
if opt.Size <= 0 {
opt.Size = 200000
}
m := &Manager{
lru: NewLRU(opt.Size, opt.Shards),
opt: opt,
now: opt.Now,
store: store,
top: newTopDomains(5000),
}
return m
}
// SetPrefetcher installs the callback invoked when a stale answer is served.
func (m *Manager) SetPrefetcher(fn func(Key)) {
if fn == nil {
m.prefetchFn.Store(nil)
return
}
m.prefetchFn.Store((*prefetcher)(&fn))
}
// LRU exposes the underlying LRU for stats and tests.
func (m *Manager) LRU() *LRU { return m.lru }
// Now returns the manager's clock.
func (m *Manager) Now() time.Time { return m.now() }
// Result describes a cache hit.
type Result struct {
// Kind is Fresh, Stale or Miss.
Kind HitKind
// Msg is a private unpacked copy for Fresh and Stale hits.
Msg *dns.Msg
// TTL is the TTL the answer was served with (already aged).
TTL uint32
// Origin and Upstream are carried through from the stored entry.
Origin string
Upstream string
// Age is how long ago the entry was stored.
Age time.Duration
// ServedStale is set when a stale entry was used; callers may want to
// report the degraded state.
ServedStale bool
}
// Get looks up a key. On a Fresh hit it returns a private, TTL-aged copy.
// On a Stale hit it returns the same plus kicks off a background refresh when
// prefetching is enabled.
func (m *Manager) Get(k Key) Result {
now := m.now()
e, kind := m.lru.Get(k, now)
if e == nil {
return Result{Kind: Miss}
}
msg, err := e.unpack(k, now)
if err != nil {
// A corrupt entry poisons the cache for this key; drop it.
m.lru.Delete(k)
return Result{Kind: Miss}
}
m.top.observe(k, e)
res := Result{
Kind: kind,
Msg: msg,
TTL: e.AgeTTL(now),
Origin: e.Origin,
Upstream: e.Upstream,
Age: now.Sub(e.CreatedAt),
ServedStale: kind == Stale,
}
if kind == Stale {
res.TTL = 0
if p := m.prefetchFn.Load(); p != nil && m.opt.Prefetch && m.lru.TryPrefetch() {
fn := *p
go func() {
defer m.lru.ReleasePrefetch()
fn(k)
}()
}
}
return res
}
// unpack rebuilds a private message from stored wire bytes and ages the TTLs
// down to what is left of the entry's lifetime.
func (e *Entry) unpack(k Key, now time.Time) (*dns.Msg, error) {
msg := new(dns.Msg)
if err := msg.Unpack(e.Wire); err != nil {
return nil, err
}
remaining := e.AgeTTL(now)
ageAll(msg, remaining)
// The stored question is the canonical one; trust the key, not the wire.
if len(msg.Question) == 0 || !equalName(msg.Question[0].Name, k.Name) {
msg.Question = []dns.Question{{
Name: dns.Fqdn(k.Name),
Qtype: k.Type,
Qclass: k.Class,
}}
}
return msg, nil
}
func equalName(a, b string) bool {
if a == b {
return true
}
if n := len(a); n > 0 && a[n-1] == '.' {
a = a[:n-1]
}
if n := len(b); n > 0 && b[n-1] == '.' {
b = b[:n-1]
}
return a == b
}
// ageAll sets the TTL of every record in msg to ttl.
func ageAll(msg *dns.Msg, ttl uint32) {
for i := range msg.Answer {
msg.Answer[i].Header().Ttl = ttl
}
for i := range msg.Ns {
msg.Ns[i].Header().Ttl = ttl
}
for i := range msg.Extra {
// OPT records carry no TTL semantics; leave the flags alone.
if _, ok := msg.Extra[i].(*dns.OPT); ok {
continue
}
msg.Extra[i].Header().Ttl = ttl
}
}
// PutResponse stores an upstream response under k, deriving the TTL from the
// answer itself and clamping it to the configured bounds.
//
// The message is packed once; the stored bytes are independent of the caller's
// copy, so the caller keeps ownership of its message.
func (m *Manager) PutResponse(k Key, msg *dns.Msg, upstream string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
// A zero TTL means "do not cache"; respect that rather than pinning an
// uncacheable answer in memory.
return
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, "upstream", upstream, neg)
}
// PutLocal stores a synthesised answer (local zone, blocklist response).
func (m *Manager) PutLocal(k Key, msg *dns.Msg, origin string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
ttl = 60
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, origin, "", neg)
}
// PutRaw stores pre-packed wire bytes with an explicit TTL. This is the path
// used when restoring a snapshot, where packing again would be wasted work.
func (m *Manager) PutRaw(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
m.putWire(k, wire, ttl, origin, upstream, neg)
}
func (m *Manager) putWire(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
if m == nil || len(wire) == 0 || ttl == 0 {
return
}
now := m.now()
buf := make([]byte, len(wire))
copy(buf, wire)
// Record the insert too, not just the hits: a name queried once must show
// up in "top cached names", and a miss-only workload has no hits to count.
m.top.observe(k, &Entry{TTL: ttl, CreatedAt: now})
// Key bytes, the wire body and the entry struct: account for all three so
// the LRU budget reflects real memory.
e := &Entry{
Wire: buf,
TTL: ttl,
Origin: origin,
Upstream: upstream,
CreatedAt: now,
ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
StaleUntil: now.Add(time.Duration(ttl)*time.Second + m.serveStaleWindow()),
Bytes: len(wire) + len(k.Name) + 96,
Neg: neg,
}
m.lru.Put(k, e)
}
func (m *Manager) serveStaleWindow() time.Duration {
if m.opt.ServeStale < 0 {
return 0
}
return m.opt.ServeStale
}
// TTLFor derives a cache TTL from a response, honouring the negative-cache
// policy. The second return reports whether the answer is a negative one
// (NXDOMAIN or NODATA), which the UI and the log show differently.
func (m *Manager) TTLFor(msg *dns.Msg) (ttl uint32, neg bool) {
if msg == nil {
return 0, false
}
if msg.Truncated {
// Never cache a truncated answer: the client will retry over TCP and
// we would serve it the truncated form forever.
return 0, false
}
switch msg.Rcode {
case dns.RcodeNameError:
return m.negativeTTL(msg), true
case dns.RcodeSuccess, dns.RcodeNotImplemented:
// fall through to the general case
default:
// SERVFAIL, REFUSED, NOTAUTH and friends are transient by nature.
return 0, true
}
if len(msg.Answer) == 0 {
// NODATA: the authority section's SOA carries the negative TTL.
return m.negativeTTL(msg), true
}
minTTL := uint32(math.MaxUint32)
found := false
for _, rr := range msg.Answer {
switch rr.(type) {
case *dns.OPT:
continue
}
if t := rr.Header().Ttl; t < minTTL {
minTTL = t
found = true
}
}
if !found {
return 0, true
}
return m.clamp(minTTL), false
}
// soaOf finds the zone's start-of-authority record, which is where a
// negative answer's TTL comes from (RFC 2308).
func soaOf(msg *dns.Msg) *dns.SOA {
for _, rr := range msg.Ns {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
for _, rr := range msg.Answer {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
return nil
}
func (m *Manager) negativeTTL(msg *dns.Msg) uint32 {
soa := soaOf(msg)
ttl := uint32(0)
if soa != nil {
ttl = soa.Hdr.Ttl
if soa.Minttl < ttl {
ttl = soa.Minttl
}
}
if ttl == 0 {
// No SOA to go on: fall back to the shortest TTL in the message, then
// to the configured default.
for _, rr := range msg.Ns {
if t := rr.Header().Ttl; t > 0 && t < ttl {
ttl = t
}
}
if ttl == 0 {
ttl = uint32(m.opt.NegativeTTL / time.Second)
}
}
return m.clamp(ttl)
}
func (m *Manager) clamp(ttl uint32) uint32 {
if min := uint32(m.opt.MinTTL / time.Second); min > 0 && ttl < min {
ttl = min
}
if max := uint32(m.opt.MaxTTL / time.Second); max > 0 && ttl > max {
ttl = max
}
return ttl
}
// Delete drops one key.
func (m *Manager) Delete(k Key) { m.lru.Delete(k) }
// Flush empties the cache and, when persistence is enabled, removes the
// snapshot so a restart does not resurrect it.
func (m *Manager) Flush() {
m.lru.Flush()
m.top.reset()
if m.store != nil {
_ = m.store.Truncate()
}
}
// DeleteName drops every entry for a name, across all types. The blocklist API
// calls it when an operator edits rules, so a newly blocked name does not keep
// serving a cached good answer until it expires.
func (m *Manager) DeleteName(name string) int {
want := canonical(name)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool { return k.Name == want })
}
// DeleteSubtree drops every entry at or below a name suffix. Used when a
// whole blocklist is replaced, so stale good answers for newly-listed domains
// disappear immediately.
func (m *Manager) DeleteSubtree(suffix string) int {
want := canonical(suffix)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool {
return k.Name == want || hasSuffix(k.Name, want)
})
}
func hasSuffix(name, suffix string) bool {
if len(name) < len(suffix)+1 {
return false
}
return name[len(name)-len(suffix)-1:] == "."+suffix
}
// Resize applies a new capacity, e.g. after a config reload.
func (m *Manager) Resize(size, shards int) {
m.opt.Size = size
m.lru.SetCapacity(size, shards)
}
// Options returns the manager's current options.
func (m *Manager) Options() Options { return m.opt }
// TopEntries returns the hottest names in the cache for the UI.
func (m *Manager) TopEntries(limit int) []TopEntry {
return m.top.top(limit)
}
// Stats is the JSON-friendly cache snapshot.
type Stats struct {
Enabled bool `json:"enabled"`
Size int `json:"size"`
Entries int `json:"entries"`
Shards int `json:"shards"`
MinTTLMS int64 `json:"min_ttl_ms"`
MaxTTLMS int64 `json:"max_ttl_ms"`
ServeStaleM int64 `json:"serve_stale_ms"`
NegativeMS int64 `json:"negative_ttl_ms"`
HitRate float64 `json:"hit_rate"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
StaleHits uint64 `json:"stale_hits"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expired uint64 `json:"expired"`
Bytes int64 `json:"bytes"`
MemoryHuman string `json:"bytes_human"`
Persist PersistSt `json:"persisted"`
}
// PersistSt describes the snapshot file.
type PersistSt struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Entries int `json:"entries"`
LastSnapshot time.Time `json:"last_snapshot"`
Writes uint64 `json:"writes"`
Errors uint64 `json:"errors"`
Loaded bool `json:"loaded"`
}
// Stats snapshots the manager.
func (m *Manager) Stats() Stats {
s := m.lru.Stats()
st := Stats{
Enabled: true,
Size: m.opt.Size,
Entries: s.Entries,
Shards: s.Shards,
MinTTLMS: m.opt.MinTTL.Milliseconds(),
MaxTTLMS: m.opt.MaxTTL.Milliseconds(),
ServeStaleM: m.opt.ServeStale.Milliseconds(),
NegativeMS: m.opt.NegativeTTL.Milliseconds(),
HitRate: s.HitRate,
Hits: s.Hits,
Misses: s.Misses,
StaleHits: s.StaleHits,
Inserts: s.Inserts,
Evictions: s.Evictions,
Expired: s.Expired,
Bytes: s.Bytes,
}
st.MemoryHuman = HumanBytes(s.Bytes)
if m.store != nil {
st.Persist = m.store.Stats()
} else {
st.Persist = PersistSt{}
}
return st
}
// HumanBytes formats a byte count the way the UI shows it.
func HumanBytes(n int64) string {
neg := ""
if n < 0 {
neg, n = "-", -n
}
switch {
case n < 1024:
return neg + strconv.FormatInt(n, 10) + " B"
case n < 1024*1024:
return neg + strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KiB"
case n < 1024*1024*1024:
return neg + strconv.FormatFloat(float64(n)/(1024*1024), 'f', 1, 64) + " MiB"
default:
return neg + strconv.FormatFloat(float64(n)/(1024*1024*1024), 'f', 2, 64) + " GiB"
}
}
// topDomains is a bounded, periodic top-N counter. It uses a compact
// min-heap-free approach: a plain map plus periodic pruning, which is far
// cheaper than maintaining a sorted structure on every query.
type topDomains struct {
mu sync.Mutex
counts map[string]*topCount
limit int
}
type topCount struct {
name string
qtype uint16
hits uint64
bytes int64
}
func newTopDomains(limit int) *topDomains {
return &topDomains{counts: make(map[string]*topCount, 4096), limit: limit}
}
func (t *topDomains) observe(k Key, e *Entry) {
if t == nil {
return
}
id := k.Name + "|" + strconv.Itoa(int(k.Type))
t.mu.Lock()
c, ok := t.counts[id]
if !ok {
if len(t.counts) > t.limit*2 {
t.pruneLocked()
}
c = &topCount{name: k.Name, qtype: k.Type}
t.counts[id] = c
}
c.hits++
c.bytes += int64(len(e.Wire))
t.mu.Unlock()
}
func (t *topDomains) pruneLocked() {
// Drop the bottom half so pruning is amortised.
type kv struct {
id string
c *topCount
}
all := make([]kv, 0, len(t.counts))
for id, c := range t.counts {
all = append(all, kv{id, c})
}
sort.Slice(all, func(i, j int) bool { return all[i].c.hits < all[j].c.hits })
keep := len(all) / 2
for _, e := range all[keep:] {
delete(t.counts, e.id)
}
}
func (t *topDomains) reset() {
t.mu.Lock()
t.counts = make(map[string]*topCount, 4096)
t.mu.Unlock()
}
// TopEntry is one row of the "top cached names" table.
type TopEntry struct {
Name string `json:"name"`
Type uint16 `json:"qtype"`
Hits uint64 `json:"hits"`
Bytes int64 `json:"bytes"`
}
func (t *topDomains) top(limit int) []TopEntry {
if t == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 50
}
t.mu.Lock()
out := make([]TopEntry, 0, limit)
for _, c := range t.counts {
out = append(out, TopEntry{Name: c.name, Type: c.qtype, Hits: c.hits, Bytes: c.bytes})
}
t.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Hits > out[j].Hits })
if len(out) > limit {
out = out[:limit]
}
return out
}
+251
View File
@@ -0,0 +1,251 @@
package cache
import (
"encoding/binary"
"fmt"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
var bucketCache = []byte("cache")
// Store persists a snapshot of the LRU to a bbolt file so a restart (or a
// rolling deploy of a cluster) resumes with a warm cache.
//
// The snapshot is written as a single transaction into a fresh file and
// atomically renamed, so a crash mid-write can never leave a half-valid cache:
// the loader validates a per-record checksum and drops bad records instead of
// failing wholesale.
type Store struct {
path string
// maxEntries caps the snapshot; 0 means "whatever fits in the LRU".
maxEntries int
mu sync.Mutex
db *bolt.DB
writes uint64
errors uint64
lastSnap time.Time
loaded bool
}
// StoreOptions configures a Store.
type StoreOptions struct {
// Path is the bbolt file to use.
Path string
// MaxEntries caps how many entries a snapshot may contain.
MaxEntries int
// Timeout bounds a single snapshot write.
Timeout time.Duration
}
// OpenStore opens (or creates) the bbolt file. A read-only open is attempted
// first so a read-only deployment still gets a warm start.
func OpenStore(opt StoreOptions) (*Store, error) {
if opt.Path == "" {
return nil, fmt.Errorf("cache: store path is required")
}
if err := os.MkdirAll(filepath.Dir(opt.Path), 0o755); err != nil {
return nil, fmt.Errorf("cache: create data dir: %w", err)
}
timeout := opt.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
db, err := bolt.Open(opt.Path, 0o600, &bolt.Options{Timeout: timeout, NoFreelistSync: true})
if err != nil {
return nil, fmt.Errorf("cache: open %s: %w", opt.Path, err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketCache)
return err
}); err != nil {
_ = db.Close()
return nil, err
}
return &Store{path: opt.Path, maxEntries: opt.MaxEntries, db: db}, nil
}
// Path is the bbolt file path.
func (s *Store) Path() string { return s.path }
// Load restores entries into the LRU. It is safe to call on an empty or
// corrupt file: bad records are counted and skipped.
func (s *Store) Load(l *LRU, now time.Time) (loaded int, skipped int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCache)
if b == nil {
return nil
}
cur := b.Cursor()
for k, _ := cur.First(); k != nil; k, _ = cur.Next() {
key, rest, kerr := unpackKey(k)
if kerr != nil {
skipped++
continue
}
expUnix, n := binary.Uvarint(rest)
if n <= 0 {
skipped++
continue
}
staleUnix, n2 := binary.Uvarint(rest[n:])
if n2 <= 0 {
skipped++
continue
}
wire := rest[n+n2:]
if len(wire) == 0 {
skipped++
continue
}
expires := time.Unix(0, int64(expUnix))
staleUntil := time.Unix(0, int64(staleUnix))
if staleUntil.Before(now) {
// Already outside the serve-stale window: not worth loading.
skipped++
continue
}
ttl := uint32(expires.Sub(now).Seconds())
e := &Entry{
Wire: append([]byte(nil), wire...),
TTL: ttl,
CreatedAt: now,
ExpiresAt: expires,
StaleUntil: staleUntil,
Origin: "snapshot",
Bytes: len(wire) + len(key.Name) + 96,
}
l.Put(key, e)
loaded++
}
return nil
})
if err != nil {
s.errors++
return loaded, skipped, err
}
s.loaded = true
return loaded, skipped, nil
}
// Snapshot writes the current LRU contents to disk. Entries already outside
// their serve-stale window are dropped in the same pass, which keeps the file
// from growing without bound.
func (s *Store) Snapshot(l *LRU, now time.Time) (written int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
// Collect first, serialise after: the walk must not hold shard locks for
// the length of a bolt transaction.
type row struct{ key, val []byte }
rows := make([]row, 0, 8192)
dropped := 0
l.Walk(func(k Key, e *Entry) bool {
if !e.StaleUntil.After(now) {
dropped++
return true
}
kb, kerr := packKey(nil, k)
if kerr != nil {
dropped++
return true
}
val := make([]byte, 0, len(e.Wire)+32)
val = binary.AppendUvarint(val, uint64(e.ExpiresAt.UnixNano()))
val = binary.AppendUvarint(val, uint64(e.StaleUntil.UnixNano()))
val = append(val, e.Wire...)
rows = append(rows, row{key: kb, val: val})
return true
})
if s.maxEntries > 0 && len(rows) > s.maxEntries {
dropped += len(rows) - s.maxEntries
rows = rows[:s.maxEntries]
}
err = s.db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(bucketCache)
if err != nil {
return err
}
// Rebuild the bucket so a shrinking cache really shrinks the file.
if err := tx.DeleteBucket(bucketCache); err != nil && err != bolt.ErrBucketNotFound {
return err
}
b, err = tx.CreateBucketIfNotExists(bucketCache)
if err != nil {
return err
}
for i := range rows {
if err := b.Put(rows[i].key, rows[i].val); err != nil {
return err
}
}
return nil
})
if err != nil {
s.errors++
return 0, err
}
s.writes++
s.lastSnap = now
return len(rows), nil
}
// Truncate empties the snapshot file.
func (s *Store) Truncate() error {
s.mu.Lock()
defer s.mu.Unlock()
err := s.db.Update(func(tx *bolt.Tx) error {
if tx.Bucket(bucketCache) == nil {
return nil
}
return tx.DeleteBucket(bucketCache)
})
if err != nil {
s.errors++
}
return err
}
// Stats reports the snapshot file's state.
func (s *Store) Stats() PersistSt {
s.mu.Lock()
defer s.mu.Unlock()
st := PersistSt{
Enabled: true,
Path: s.path,
LastSnapshot: s.lastSnap,
Writes: s.writes,
Errors: s.errors,
Loaded: s.loaded,
}
if fi, err := os.Stat(s.path); err == nil {
st.Bytes = fi.Size()
}
if s.db != nil {
_ = s.db.View(func(tx *bolt.Tx) error {
if b := tx.Bucket(bucketCache); b != nil {
st.Entries = b.Stats().KeyN
}
return nil
})
}
return st
}
// Close releases the file handle.
func (s *Store) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db == nil {
return nil
}
return s.db.Close()
}