Files
fancy-dns/internal/qlog/qlog.go
T
2026-09-27 11:34:26 +00:00

723 lines
17 KiB
Go

// Package qlog records DNS queries for the admin console: a live in-memory
// ring for the SSE stream, a bbolt-backed ring for the query log, and rolling
// per-domain counters that back the "top blocked" and "top queried" panels.
//
// The write path is deliberately allocation-light: entries are value types, the
// live ring is a fixed array, and bbolt writes are batched on a background
// goroutine so a burst of queries never blocks a DNS response.
package qlog
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
bolt "go.etcd.io/bbolt"
)
// Action is the outcome classification shown in the log.
type Action string
const (
// ActionAllow is a normal resolution.
ActionAllow Action = "allow"
// ActionBlock is a blocklist hit.
ActionBlock Action = "block"
// ActionCached is a cache hit.
ActionCached Action = "cached"
// ActionError is a SERVFAIL or other failure.
ActionError Action = "error"
// ActionDrop is a query answered with silence.
ActionDrop Action = "drop"
// ActionRefused is an ACL refusal.
ActionRefused Action = "refused"
)
// Entry is one logged query.
type Entry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group,omitempty"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
Action Action `json:"action"`
Response string `json:"response,omitempty"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream,omitempty"`
List string `json:"list,omitempty"`
Protocol string `json:"protocol,omitempty"`
// Blocked is a convenience mirror of Action == ActionBlock so the UI can
// filter without parsing the action string.
Blocked bool `json:"blocked"`
}
// FromMsg fills the question-derived fields from a DNS message.
func (e *Entry) FromMsg(m *dns.Msg) {
if len(m.Question) > 0 {
q := m.Question[0]
e.Name = q.Name
e.QType = q.Qtype
e.QTypeStr = dns.Type(q.Qtype).String()
}
e.RCode = m.Rcode
}
// RCodeName renders the RCODE as its mnemonic.
func (e Entry) RCodeName() string {
if s, ok := dns.RcodeToString[e.RCode]; ok {
return s
}
return fmt.Sprintf("RCODE%d", e.RCode)
}
// liveEntry is a decoded copy kept in memory for the SSE stream.
type liveEntry struct {
entry Entry
}
// Logger batches query logging to bbolt and maintains the live ring.
type Logger struct {
opts Options
// seq assigns monotonic ids so the UI can detect drops.
seq atomic.Uint64
// live is a fixed-size ring of the most recent entries. Each slot is a
// pointer, so a writer on the query path never races a reader walking the
// ring: publishing is one atomic word, and a reader sees either the whole
// entry or the one it replaced.
live []atomic.Pointer[liveEntry]
livePos atomic.Uint32
liveMu sync.RWMutex
liveCond *sync.Cond
liveFull atomic.Bool
// pending batches entries between bbolt transactions.
pendingMu sync.Mutex
pending []Entry
// counts are rolling per-domain counters.
counts *domainCounts
// logErrors counts persistence failures, which the API surfaces so a
// silently-failing query log never looks healthy.
logErrors atomic.Uint64
// subscribers is the set of live SSE consumers.
subMu sync.Mutex
subs map[int]chan Entry
subSeq int
db *bolt.DB
stop chan struct{}
done chan struct{}
wg sync.WaitGroup
}
// Options configures a Logger.
type Options struct {
// Path is the bbolt file; empty disables persistence.
Path string
// MaxEntries bounds the persisted log ring.
MaxEntries int
// LiveBuffer is the in-memory ring size for the SSE stream.
LiveBuffer int
// FlushInterval is the bbolt batch window.
FlushInterval time.Duration
// SampleRate is the fraction of non-blocked, non-error queries to keep.
SampleRate float64
// LogBlocked, LogCached, LogErrors are forced categories.
LogBlocked bool
LogCached bool
LogErrors bool
// TopDomains is the size of the rolling counter set.
TopDomains int
// TopTTL is how long a counter survives without being touched.
TopTTL time.Duration
// Now is injectable for tests.
Now func() time.Time
}
// DefaultOptions returns sane defaults.
func DefaultOptions() Options {
return Options{
Path: "data/queries.db",
MaxEntries: 200000,
LiveBuffer: 2000,
FlushInterval: time.Second,
SampleRate: 1,
LogBlocked: true,
LogCached: true,
LogErrors: true,
TopDomains: 5000,
TopTTL: 5 * time.Minute,
}
}
// New opens a Logger, creating the bbolt file when a path is given.
func New(opt Options) (*Logger, error) {
if opt.Now == nil {
opt.Now = time.Now
}
if opt.LiveBuffer <= 0 {
opt.LiveBuffer = 2000
}
if opt.FlushInterval <= 0 {
opt.FlushInterval = time.Second
}
if opt.SampleRate <= 0 {
opt.SampleRate = 1
}
if opt.SampleRate > 1 {
opt.SampleRate = 1
}
if opt.TopDomains <= 0 {
opt.TopDomains = 5000
}
if opt.TopTTL <= 0 {
opt.TopTTL = 5 * time.Minute
}
l := &Logger{
opts: opt,
live: make([]atomic.Pointer[liveEntry], opt.LiveBuffer),
counts: newDomainCounts(opt.TopDomains, opt.TopTTL),
subs: make(map[int]chan Entry),
stop: make(chan struct{}),
done: make(chan struct{}),
pending: make([]Entry, 0, 4096),
}
l.liveCond = sync.NewCond(&l.liveMu)
if opt.Path != "" {
if err := os.MkdirAll(filepath.Dir(opt.Path), 0o755); err != nil {
return nil, fmt.Errorf("qlog: create data dir: %w", err)
}
db, err := bolt.Open(opt.Path, 0o600, &bolt.Options{Timeout: 5 * time.Second, NoFreelistSync: true})
if err != nil {
return nil, fmt.Errorf("qlog: open %s: %w", opt.Path, err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketLog)
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketMeta)
return err
}); err != nil {
_ = db.Close()
return nil, err
}
l.db = db
l.wg.Add(1)
go l.flushLoop()
} else {
close(l.done)
}
return l, nil
}
var (
bucketLog = []byte("log")
bucketMeta = []byte("meta")
keyNextID = []byte("next_id")
)
// ShouldLog reports whether an entry with this action passes the sampling
// policy. Blocked, cached and error entries are always kept when their
// category flag is set; ordinary resolutions are sampled.
func (l *Logger) ShouldLog(a Action) bool {
switch a {
case ActionBlock:
return l.opts.LogBlocked
case ActionCached:
return l.opts.LogCached
case ActionError, ActionDrop:
return l.opts.LogErrors
default:
if l.opts.SampleRate >= 1 {
return true
}
return randFloat() < l.opts.SampleRate
}
}
// Log records a query. It never blocks on bbolt: the entry goes to the live
// ring and a batch, and a background goroutine does the disk write.
func (l *Logger) Log(e Entry) {
if e.TS.IsZero() {
e.TS = l.opts.Now()
}
if e.QTypeStr == "" && e.QType != 0 {
e.QTypeStr = dns.Type(e.QType).String()
}
e.Blocked = e.Action == ActionBlock
e.ID = l.seq.Add(1)
l.counts.observe(e)
// Live ring. The slot is published with a single atomic store; a reader
// walking the ring concurrently gets either the old or the new entry,
// never a half-written one.
pos := l.livePos.Add(1) - 1
l.live[pos%uint32(len(l.live))].Store(&liveEntry{entry: e})
// Fan out to SSE subscribers (non-blocking: a slow consumer drops entries
// rather than stalling the query path).
l.subMu.Lock()
for _, ch := range l.subs {
select {
case ch <- e:
default:
}
}
l.subMu.Unlock()
if l.db == nil {
return
}
l.pendingMu.Lock()
l.pending = append(l.pending, e)
n := len(l.pending)
l.pendingMu.Unlock()
if n >= 2048 {
go l.Flush()
}
}
// flushLoop writes pending batches on a timer.
func (l *Logger) flushLoop() {
defer l.wg.Done()
t := time.NewTicker(l.opts.FlushInterval)
defer t.Stop()
for {
select {
case <-l.stop:
l.Flush()
return
case <-t.C:
l.Flush()
}
}
}
// Flush writes any pending entries to bbolt and trims the ring.
func (l *Logger) Flush() {
if l.db == nil {
return
}
l.pendingMu.Lock()
if len(l.pending) == 0 {
l.pendingMu.Unlock()
return
}
batch := l.pending
l.pending = l.pending[:0]
l.pendingMu.Unlock()
err := l.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketLog)
if b == nil {
return nil
}
meta := tx.Bucket(bucketMeta)
nextID := uint64(1)
if meta != nil {
if v := meta.Get(keyNextID); len(v) == 8 {
nextID = beUint64(v)
}
}
for _, e := range batch {
data, err := json.Marshal(e)
if err != nil {
continue
}
if err := b.Put(be64b(nextID), data); err != nil {
return err
}
nextID++
}
if meta != nil {
meta.Put(keyNextID, be64b(nextID))
}
return l.trim(b, nextID)
})
if err != nil {
// A logging failure must never take the resolver down; surface it via
// the error counter the API reads.
l.logErrors.Add(1)
}
}
// trim enforces the entry cap by deleting the oldest ids.
func (l *Logger) trim(b *bolt.Bucket, nextID uint64) error {
if l.opts.MaxEntries <= 0 {
return nil
}
stats := b.Stats()
if stats.KeyN <= l.opts.MaxEntries {
return nil
}
excess := stats.KeyN - l.opts.MaxEntries
cur := b.Cursor()
for k, _ := cur.First(); k != nil && excess > 0; k, _ = cur.Next() {
if err := b.Delete(k); err != nil {
return err
}
excess--
}
return nil
}
// ErrorCount reports how many batch writes have failed.
func (l *Logger) ErrorCount() uint64 { return l.logErrors.Load() }
// be64b encodes a uint64 big-endian for use as a bbolt key.
func be64b(v uint64) []byte {
return []byte{
byte(v >> 56), byte(v >> 48), byte(v >> 40), byte(v >> 32),
byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v),
}
}
// beUint64 decodes what be64b wrote.
func beUint64(b []byte) uint64 {
var v uint64
for _, c := range b {
v = v<<8 | uint64(c)
}
return v
}
// Query filters the persisted log.
type Query struct {
// Limit is the page size.
Limit int
// Offset skips the newest Offset entries.
Offset int
// Text matches name, client or list.
Text string
// Action filters by classification.
Action string
// Client filters by client address.
Client string
// RCode filters by numeric rcode (0 = no filter).
RCode int
// BlockedOnly keeps only blocklist hits.
BlockedOnly bool
// Since keeps only entries at or after this time.
Since time.Time
}
// Page is a page of log entries plus the total match count.
type Page struct {
Items []Entry `json:"items"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
// Recent returns the newest entries from the in-memory ring, newest first.
// It powers the SSE initial burst so a client connecting mid-stream sees
// recent history immediately.
func (l *Logger) Recent(limit int) []Entry {
if limit <= 0 {
limit = 200
}
if limit > len(l.live) {
limit = len(l.live)
}
out := make([]Entry, 0, limit)
pos := l.livePos.Load()
total := pos
if total > uint32(len(l.live)) {
total = uint32(len(l.live))
}
for i := uint32(0); i < total; i++ {
idx := (pos - 1 - i) % uint32(len(l.live))
le := l.live[idx].Load()
if le == nil {
// Never written (or already aged out of the window).
continue
}
out = append(out, le.entry)
if len(out) >= limit {
break
}
}
return out
}
// Search queries the persisted log.
func (l *Logger) Search(q Query) (Page, error) {
l.Flush()
page := Page{Offset: q.Offset, Limit: q.Limit}
if q.Limit <= 0 {
q.Limit = 200
}
if q.Limit > 1000 {
q.Limit = 1000
}
if l.db == nil {
// No persistence: serve from the live ring.
items := l.Recent(1000)
filtered := l.filter(items, q)
page.Total = len(filtered)
page.Items = paginate(filtered, q.Offset, q.Limit)
return page, nil
}
var (
matched []Entry
total int
)
err := l.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketLog)
if b == nil {
return nil
}
// Walk newest-first.
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
var e Entry
if err := json.Unmarshal(v, &e); err != nil {
continue
}
if !matchEntry(e, q) {
continue
}
total++
if total > q.Offset && len(matched) < q.Limit {
matched = append(matched, e)
}
if total >= q.Offset+q.Limit+1 && q.Offset+q.Limit > 0 {
// We have enough to fill the page and know the total is at
// least this high; keep scanning only if we still need an
// exact total. For the UI, a "has more" hint is enough.
break
}
}
return nil
})
if err != nil {
return page, err
}
page.Items = matched
page.Total = total
return page, nil
}
func (l *Logger) filter(items []Entry, q Query) []Entry {
out := make([]Entry, 0, len(items))
for _, e := range items {
if matchEntry(e, q) {
out = append(out, e)
}
}
return out
}
func matchEntry(e Entry, q Query) bool {
if q.BlockedOnly && !e.Blocked {
return false
}
if q.Action != "" && string(e.Action) != q.Action {
return false
}
if q.Client != "" && e.Client != q.Client {
return false
}
if q.RCode != 0 && e.RCode != q.RCode {
return false
}
if !q.Since.IsZero() && e.TS.Before(q.Since) {
return false
}
if q.Text != "" {
t := q.Text
if !containsFold(e.Name, t) && !containsFold(e.Client, t) && !containsFold(e.List, t) {
return false
}
}
return true
}
func paginate(items []Entry, offset, limit int) []Entry {
if offset >= len(items) {
return []Entry{}
}
end := offset + limit
if end > len(items) {
end = len(items)
}
return items[offset:end]
}
func containsFold(hay, needle string) bool {
if len(needle) == 0 {
return true
}
return indexFold(hay, needle) >= 0
}
func indexFold(hay, needle string) int {
n := len(needle)
if n == 0 {
return 0
}
if n > len(hay) {
return -1
}
for i := 0; i+n <= len(hay); i++ {
match := true
for j := 0; j < n; j++ {
if lower(hay[i+j]) != lower(needle[j]) {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func lower(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + 32
}
return b
}
// Subscribe registers a live-stream consumer. The returned cancel func must
// be called. Entries are delivered best-effort: a full channel drops rather
// than blocking the DNS path.
func (l *Logger) Subscribe() (<-chan Entry, func()) {
l.subMu.Lock()
defer l.subMu.Unlock()
l.subSeq++
id := l.subSeq
ch := make(chan Entry, 256)
l.subs[id] = ch
return ch, func() {
l.subMu.Lock()
if c, ok := l.subs[id]; ok {
delete(l.subs, id)
close(c)
}
l.subMu.Unlock()
}
}
// SubscriberCount reports how many live streams are attached.
func (l *Logger) SubscriberCount() int {
l.subMu.Lock()
defer l.subMu.Unlock()
return len(l.subs)
}
// Clear removes every persisted entry.
func (l *Logger) Clear() error {
if l.db == nil {
return nil
}
err := l.db.Update(func(tx *bolt.Tx) error {
if tx.Bucket(bucketLog) == nil {
return nil
}
return tx.DeleteBucket(bucketLog)
})
if err != nil {
l.logErrors.Add(1)
}
return err
}
// Count is the number of persisted entries.
func (l *Logger) Count() int {
if l.db == nil {
return int(l.seq.Load())
}
n := 0
_ = l.db.View(func(tx *bolt.Tx) error {
if b := tx.Bucket(bucketLog); b != nil {
n = b.Stats().KeyN
}
return nil
})
return n
}
// TopDomains returns the hottest domains, optionally only blocked ones.
type DomainCount struct {
Name string `json:"domain"`
Count uint64 `json:"count"`
Blocked uint64 `json:"blocked"`
QType uint16 `json:"qtype"`
QTypeS string `json:"qtype_name"`
}
// TopDomains returns the top n domains by query count.
func (l *Logger) TopDomains(n int, blockedOnly bool) []DomainCount {
return l.counts.top(n, blockedOnly)
}
// TopBlocked returns the top blocked domains.
func (l *Logger) TopBlocked(n int) []DomainCount { return l.counts.top(n, true) }
// TopQueried returns the top queried domains.
func (l *Logger) TopQueried(n int) []DomainCount { return l.counts.top(n, false) }
// Stats is a JSON-friendly summary.
type Stats struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
Entries int `json:"entries"`
MaxEntries int `json:"max_entries"`
LiveBuffer int `json:"live_buffer"`
Subscribers int `json:"subscribers"`
SampleRate float64 `json:"sample_rate"`
TotalLogged uint64 `json:"total_logged"`
Pending int `json:"pending"`
Errors uint64 `json:"errors"`
}
// Stats snapshots the logger.
func (l *Logger) Stats() Stats {
l.pendingMu.Lock()
pending := len(l.pending)
l.pendingMu.Unlock()
return Stats{
Enabled: l.db != nil,
Path: l.opts.Path,
Entries: l.Count(),
MaxEntries: l.opts.MaxEntries,
LiveBuffer: l.opts.LiveBuffer,
Subscribers: l.SubscriberCount(),
SampleRate: l.opts.SampleRate,
TotalLogged: l.seq.Load(),
Pending: pending,
Errors: l.logErrors.Load(),
}
}
// Close flushes and releases resources.
func (l *Logger) Close() error {
select {
case <-l.stop:
default:
close(l.stop)
}
l.wg.Wait()
if l.db != nil {
l.Flush()
return l.db.Close()
}
return nil
}