210 lines
6.2 KiB
Go
210 lines
6.2 KiB
Go
// 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
|
|
}
|