Make the resolver pure DNS: JSON access log on stdout instead of a log store

The resolver kept its own query-log store (a bbolt ring, a live ring with SSE
fanout, rolling per-domain counters and dashboard time-series) for the console.
In a multi-node deployment each pod held a different slice of that, so every
view disagreed with itself, and the cost sat on the query path. Charts belong
in Grafana and log search in the log collector, so the store is gone.

internal/accesslog writes one JSON line per notable query to stdout. The default
is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped,
refused); log_cached and sample_rate add ordinary answers for a debugging
session. Lines go through a bounded buffer to a single writer goroutine and are
dropped, and counted in fancydns_access_log_dropped_total, when the stream
cannot keep up, so a stalled log pipe can never stall a query. The config key is
access_log (the old qlog block is ignored, like any unknown key).

The metrics gain what the dashboard used to compute: fancydns_group_queries_total
by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the
store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear,
/api/cache/top, the series rings, and the cache's per-name hit table, which took
a mutex and built a string on every cache hit.

Tests that read the old log now read the access-log lines; the contract test
drops the removed endpoints. The web console still calls them and is changed in
the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 09:53:04 +02:00
1 parent dd1117fd91
commit c6c5ca4ff7
31 files changed
+818 -3389

No files matched your search

+308
View File
@@ -0,0 +1,308 @@
// Package accesslog writes one JSON line per notable DNS query to a stream
// (stdout by default), for whatever collects the pod's logs to index.
//
// It is deliberately not a log store: nothing is kept, searched or aggregated
// here. Counting belongs to the metrics, searching to the log collector. What
// this package guarantees is that logging can never slow or stop a query:
// lines go through a bounded buffer to a single writer goroutine, and when the
// buffer is full (a blocked pipe, a stalled collector) the line is dropped and
// counted rather than waited for.
package accesslog
import (
"context"
"io"
"log/slog"
"math/rand/v2"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
)
// Action is the outcome classification of a query.
type Action string
const (
// ActionAllow is a normal resolution.
ActionAllow Action = "allow"
// ActionBlock is a blocklist hit.
ActionBlock Action = "block"
// ActionRedirect is a blocklist rewrite to an address or CNAME target.
ActionRedirect Action = "redirect"
// 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 {
TS time.Time
Client string
Group string
Name string
QType uint16
RCode int
Action Action
Duration time.Duration
Upstream string
List string
Protocol string
}
// 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.RCode = m.Rcode
}
// Options configures a Logger.
type Options struct {
// Writer receives the lines; nil means os.Stdout.
Writer io.Writer
// Buffer is how many lines may wait for the writer; beyond that they are
// dropped. Zero means 4096.
Buffer int
// LogBlocked, LogErrors and LogCached select whole categories. Refusals
// and drops count with errors, redirects with blocks.
LogBlocked bool
LogErrors bool
LogCached bool
// SampleRate is the fraction of ordinary answers (allowed, and cached when
// LogCached is off) to log. 0 logs none, 1 logs all.
SampleRate float64
}
// Logger is the access log.
type Logger struct {
opt Options
h slog.Handler
w *asyncWriter
written atomic.Uint64
}
// New starts a Logger. Close it to flush.
func New(opt Options) *Logger {
if opt.Writer == nil {
opt.Writer = os.Stdout
}
aw := newAsyncWriter(opt.Writer, opt.Buffer)
h := slog.NewJSONHandler(aw, &slog.HandlerOptions{
Level: slog.LevelInfo,
// The level carries no information here, and a collector would index
// a constant. "log":"access" says which stream the line belongs to.
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.LevelKey {
return slog.Attr{}
}
return a
},
}).WithAttrs([]slog.Attr{slog.String("log", "access")})
return &Logger{opt: opt, h: h, w: aw}
}
// ShouldLog reports whether a query with this outcome is logged. A nil Logger
// logs nothing.
func (l *Logger) ShouldLog(a Action) bool {
if l == nil {
return false
}
switch a {
case ActionBlock, ActionRedirect:
return l.opt.LogBlocked
case ActionError, ActionDrop, ActionRefused:
return l.opt.LogErrors
case ActionCached:
if l.opt.LogCached {
return true
}
}
switch {
case l.opt.SampleRate >= 1:
return true
case l.opt.SampleRate <= 0:
return false
default:
return rand.Float64() < l.opt.SampleRate
}
}
// Log writes one line. It never blocks.
func (l *Logger) Log(e Entry) {
if l == nil {
return
}
ts := e.TS
if ts.IsZero() {
ts = time.Now()
}
rec := slog.NewRecord(ts, slog.LevelInfo, "query", 0)
rec.AddAttrs(
slog.String("client", e.Client),
slog.String("client_group", e.Group),
slog.String("name", e.Name),
slog.String("qtype", dns.Type(e.QType).String()),
slog.String("rcode", rcodeName(e.RCode)),
slog.String("action", string(e.Action)),
slog.Float64("duration_ms", float64(e.Duration.Microseconds())/1000),
)
if e.Upstream != "" {
rec.AddAttrs(slog.String("upstream", e.Upstream))
}
if e.List != "" {
rec.AddAttrs(slog.String("list", e.List))
}
if e.Protocol != "" {
rec.AddAttrs(slog.String("protocol", e.Protocol))
}
_ = l.h.Handle(context.Background(), rec)
l.written.Add(1)
}
func rcodeName(c int) string {
if s, ok := dns.RcodeToString[c]; ok {
return s
}
return "RCODE" + strconv.Itoa(c)
}
// Dropped is how many lines were discarded because the writer fell behind.
func (l *Logger) Dropped() uint64 {
if l == nil {
return 0
}
return l.w.dropped.Load()
}
// Flush waits for the lines logged so far to be written.
func (l *Logger) Flush() {
if l != nil {
l.w.flush(2 * time.Second)
}
}
// Close writes what is buffered (for at most two seconds) and stops.
func (l *Logger) Close() error {
if l == nil {
return nil
}
l.w.close()
return nil
}
// asyncWriter hands each Write to one goroutine through a bounded channel. A
// Write never waits: a full channel drops the line.
type item struct {
b []byte
ack chan struct{} // set on a flush marker: closed when everything before it is written
}
type asyncWriter struct {
ch chan item
w io.Writer
dropped atomic.Uint64
closed atomic.Bool
stop chan struct{}
done chan struct{}
once sync.Once
}
func newAsyncWriter(w io.Writer, buffer int) *asyncWriter {
if buffer <= 0 {
buffer = 4096
}
a := &asyncWriter{
ch: make(chan item, buffer), w: w,
stop: make(chan struct{}), done: make(chan struct{}),
}
go a.run()
return a
}
// Write implements io.Writer for slog's handler, which calls it once per line
// with a buffer it reuses, so the bytes are copied.
func (a *asyncWriter) Write(p []byte) (int, error) {
if a.closed.Load() {
return len(p), nil
}
b := append(make([]byte, 0, len(p)), p...)
select {
case a.ch <- item{b: b}:
default:
a.dropped.Add(1)
metrics.RecordAccessLogDropped()
}
return len(p), nil
}
func (a *asyncWriter) run() {
defer close(a.done)
for {
select {
case it := <-a.ch:
a.write(it)
case <-a.stop:
for {
select {
case it := <-a.ch:
a.write(it)
default:
return
}
}
}
}
}
func (a *asyncWriter) write(it item) {
if it.ack != nil {
close(it.ack)
return
}
_, _ = a.w.Write(it.b)
}
// flush waits until every line accepted before the call has been written, or
// the timeout passes. It is for tests and shutdown, never the query path.
func (a *asyncWriter) flush(timeout time.Duration) {
if a.closed.Load() {
return
}
ack := make(chan struct{})
t := time.NewTimer(timeout)
defer t.Stop()
select {
case a.ch <- item{ack: ack}:
case <-t.C:
return
}
select {
case <-ack:
case <-t.C:
}
}
func (a *asyncWriter) close() {
a.once.Do(func() {
a.closed.Store(true)
close(a.stop)
select {
case <-a.done:
case <-time.After(2 * time.Second):
}
})
}