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