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

+47 -33
View File
@@ -23,13 +23,13 @@ import (
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/accesslog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
)
@@ -69,7 +69,7 @@ type Request struct {
type Response struct {
Msg *dns.Msg
// Action is the pipeline classification.
Action qlog.Action
Action accesslog.Action
// Group is the matched ACL group.
Group string
// BlockSource is the blocklist/rule that blocked the query, for the log.
@@ -111,7 +111,7 @@ type Engine struct {
aclH *acl.Holder
local *localzone.Holder
pool atomic.Pointer[resolver.Pool]
logger *qlog.Logger
logger *accesslog.Logger
reverse *ReverseCache
// concurrency limiter: a buffered channel as a semaphore, which is
@@ -161,7 +161,7 @@ type Options struct {
// Config is the hot-reloadable config store.
Config *config.Store
// Logger receives query entries; may be nil.
Logger *qlog.Logger
Logger *accesslog.Logger
// Blocklists is the compiled blocklist holder.
Blocklists *blocklist.Holder
// Cache is the answer cache.
@@ -236,7 +236,7 @@ func (e *Engine) applyConfig(cfg *config.Config) {
}
// Logger exposes the query logger (may be nil).
func (e *Engine) Logger() *qlog.Logger { return e.logger }
func (e *Engine) Logger() *accesslog.Logger { return e.logger }
// Cache exposes the answer cache (may be nil).
func (e *Engine) Cache() *cache.Manager { return e.cache }
@@ -288,7 +288,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
if err == errNotImplemented {
res.Msg = notImpl(q)
}
res.Action = qlog.ActionError
res.Action = accesslog.ActionError
e.finish(req, res, start, decision)
return res
}
@@ -300,7 +300,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
// denied a client means it.
if !decision.Allows() {
res.Msg = refuse(q, decision)
res.Action = qlog.ActionRefused
res.Action = accesslog.ActionRefused
e.finish(req, res, start, decision)
return res
}
@@ -310,7 +310,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
if cl := decision.MaxConcurrent; cl > 0 {
if !e.acquireClient(client, cl) {
res.Msg = servFail(q, "client concurrency limit")
res.Action = qlog.ActionError
res.Action = accesslog.ActionError
e.finish(req, res, start, decision)
return res
}
@@ -327,7 +327,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
default:
e.dropped.Add(1)
res.Msg = servFail(q, "server overloaded")
res.Action = qlog.ActionError
res.Action = accesslog.ActionError
e.finish(req, res, start, decision)
return res
}
@@ -336,7 +336,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
// Rate limiting.
if decision.RateLimit > 0 && !e.allowRate(client, decision.RateLimit) {
res.Msg = refuse(q, decision)
res.Action = qlog.ActionRefused
res.Action = accesslog.ActionRefused
e.finish(req, res, start, decision)
return res
}
@@ -344,7 +344,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
// Group-level domain policy.
if e.groupBlocks(decision, qname) {
res.Msg = e.finalize(blockedResponse(q, e.blockCfg(), nil), q, req)
res.Action = qlog.ActionBlock
res.Action = accesslog.ActionBlock
res.BlockSource = "acl:" + decision.Group
e.finish(req, res, start, decision)
return res
@@ -354,7 +354,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
if store := e.local.Get(); store != nil && store.Count() > 0 {
if local, ok := e.answerLocal(q, store); ok {
res.Msg = e.finalize(local, q, req)
res.Action = qlog.ActionAllow
res.Action = accesslog.ActionAllow
// Authoritative local data is ActionAllow for the query log (the
// query was permitted and answered), but it is its own metrics
// outcome: counting it as upstream inflated the upstream column
@@ -375,7 +375,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
if blocked != nil && !blocked.IsAllow() {
switch blocked.Action {
case blocklist.ActionDrop:
res.Action = qlog.ActionDrop
res.Action = accesslog.ActionDrop
res.BlockSource = blocked.Source
res.Drop = true
res.Duration = time.Since(start)
@@ -383,14 +383,14 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
return res
case blocklist.ActionRedirect, blocklist.ActionCNAME:
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
res.Action = qlog.ActionRedirect
res.Action = accesslog.ActionRedirect
res.BlockSource = blocked.Source
res.CacheHit = false
e.finish(req, res, start, decision)
return res
default:
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
res.Action = qlog.ActionBlock
res.Action = accesslog.ActionBlock
res.BlockSource = blocked.Source
res.CacheHit = false
e.finish(req, res, start, decision)
@@ -414,7 +414,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
metrics.RecordStaleHit()
}
res.Msg = e.finalize(res.Msg, q, req)
res.Action = qlog.ActionCached
res.Action = accesslog.ActionCached
if hit.Kind == cache.Stale {
// The cached answer is expired, so its TTL is 0; hand back a
// short one so clients do not cache the stale copy forever.
@@ -432,7 +432,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
pool := e.pool.Load()
if pool == nil {
res.Msg = servFail(q, "no upstream configured")
res.Action = qlog.ActionError
res.Action = accesslog.ActionError
e.finish(req, res, start, decision)
return res
}
@@ -450,7 +450,7 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
if err != nil || up == nil {
metrics.RecordUpstreamError()
res.Msg = servFail(q, "upstream failure: "+shortErr(err))
res.Action = qlog.ActionError
res.Action = accesslog.ActionError
e.finish(req, res, start, decision)
return res
}
@@ -466,13 +466,13 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
reply := e.finalize(e.fromUpstream(q, up, req), q, req)
res.Msg = reply
res.Upstream = addr
res.Action = qlog.ActionAllow
res.Action = accesslog.ActionAllow
// A non-success RCODE is reported as an error through finish's metrics
// call, so the counters stay in one place.
if reply.Rcode != dns.RcodeServerFailure && reply.Rcode != dns.RcodeSuccess {
res.Action = qlog.ActionError
res.Action = accesslog.ActionError
} else if reply.Rcode == dns.RcodeServerFailure {
res.Action = qlog.ActionError
res.Action = accesslog.ActionError
}
if e.cache != nil && !decision.SkipCache {
key := cache.QueryKey(qname, qtype, q.Question[0].Qclass, cache.Do(q), 0)
@@ -486,27 +486,33 @@ func (e *Engine) Handle(ctx context.Context, req Request) Response {
return res
}
// finish attaches duration, records metrics and writes the query log.
// finish attaches duration, records metrics and writes the access log line when the outcome calls for one.
func (e *Engine) finish(req Request, res Response, start time.Time, d acl.Decision) {
if res.Duration == 0 {
res.Duration = time.Since(start)
}
metrics.Record(req.Transport, outcomeFor(res), res.Duration)
if res.Action == qlog.ActionAllow && res.CacheHit {
if res.Action == accesslog.ActionAllow && res.CacheHit {
metrics.Record(req.Transport, metrics.OutcomeCache, res.Duration)
}
e.groupStats.observe(d.Group, res)
metrics.RecordGroupQuery(d.Group, outcomeFor(res))
rcode := "none"
if res.Msg != nil {
rcode = rcodeLabel(res.Msg.Rcode)
}
metrics.RecordRcode(rcode)
if e.logger == nil {
return
}
if !e.logger.ShouldLog(res.Action) && !d.LogQueries {
return
}
entry := qlog.Entry{
entry := accesslog.Entry{
TS: start,
Client: res.Client,
Group: res.Group,
Duration: res.Duration.Milliseconds(),
Duration: res.Duration,
Action: res.Action,
Upstream: res.Upstream,
List: res.BlockSource,
@@ -518,6 +524,14 @@ func (e *Engine) finish(req Request, res Response, start time.Time, d acl.Decisi
e.logger.Log(entry)
}
// rcodeLabel is the metric label for an RCODE.
func rcodeLabel(c int) string {
if s, ok := dns.RcodeToString[c]; ok {
return s
}
return "OTHER"
}
// outcomeFor maps a finished query onto its metrics outcome. The action alone
// is not enough: ActionAllow covers both an upstream answer and a local-zone
// answer, and the two are different things for an operator reading the
@@ -527,15 +541,15 @@ func outcomeFor(res Response) string {
return metrics.OutcomeLocal
}
switch res.Action {
case qlog.ActionCached:
case accesslog.ActionCached:
return metrics.OutcomeCache
case qlog.ActionBlock:
case accesslog.ActionBlock:
return metrics.OutcomeBlocked
case qlog.ActionRedirect:
case accesslog.ActionRedirect:
return metrics.OutcomeLocal
case qlog.ActionError, qlog.ActionDrop:
case accesslog.ActionError, accesslog.ActionDrop:
return metrics.OutcomeError
case qlog.ActionRefused:
case accesslog.ActionRefused:
return metrics.OutcomeRefused
default:
return metrics.OutcomeUpstream
@@ -1174,11 +1188,11 @@ func (t *groupStatTable) observe(group string, res Response) {
}
s.queries++
switch res.Action {
case qlog.ActionBlock:
case accesslog.ActionBlock:
s.blocked++
case qlog.ActionError, qlog.ActionDrop:
case accesslog.ActionError, accesslog.ActionDrop:
s.errors++
case qlog.ActionCached:
case accesslog.ActionCached:
s.cache++
}
t.mu.Unlock()
+37 -43
View File
@@ -9,11 +9,11 @@ import (
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/accesslog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
)
// testZone is the local zone the harness loads when a test asks for one.
@@ -55,7 +55,7 @@ func TestACLDenyWinsOverLocalZone(t *testing.T) {
for _, name := range []string{"host.local.", "denied.local."} {
res := h.ask(name, dns.TypeA)
requireRcode(t, res, dns.RcodeRefused)
requireAction(t, res, qlog.ActionRefused)
requireAction(t, res, accesslog.ActionRefused)
if res.Group != "test" {
t.Fatalf("group = %q, want %q", res.Group, "test")
}
@@ -79,7 +79,7 @@ func TestLocalZoneWinsOverBlocklist(t *testing.T) {
})
res := h.ask("host.local.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
if got := answerA(res.Msg); got != "192.168.1.5" {
t.Fatalf("answer = %q, want 192.168.1.5 (blocked instead?)", got)
}
@@ -128,7 +128,7 @@ func TestBlocklistWinsOverCache(t *testing.T) {
}
res := h.ask("blocked.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionBlock)
requireAction(t, res, accesslog.ActionBlock)
requireRcode(t, res, dns.RcodeNameError)
if res.CacheHit {
t.Fatal("a blocked query reported a cache hit: the cache outranked the blocklist")
@@ -155,7 +155,7 @@ func TestBlocklistDropSendsNothing(t *testing.T) {
if res.Msg != nil {
t.Fatal("a dropped query produced a message to send")
}
requireAction(t, res, qlog.ActionDrop)
requireAction(t, res, accesslog.ActionDrop)
requireNoUpstreamHit(t, h, "drop.example.test.")
}
@@ -167,12 +167,12 @@ func TestAllowRuleBeatsBlockRule(t *testing.T) {
{Name: "ok.example.test", Action: blocklist.ActionAllow},
}})
res := h.ask("ok.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
if got := answerA(res.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want the upstream address 203.0.113.7", got)
}
blocked := h.ask("ads.example.test.", dns.TypeA)
requireAction(t, blocked, qlog.ActionBlock)
requireAction(t, blocked, accesslog.ActionBlock)
}
// TestGlobalConcurrencyCapRefuses pins the overload refusal: a saturated
@@ -196,7 +196,7 @@ func TestGlobalConcurrencyCapRefuses(t *testing.T) {
h.up.waitArrived(t)
second := h.ask("other.example.test.", dns.TypeA)
requireRcode(t, second, dns.RcodeServerFailure)
requireAction(t, second, qlog.ActionError)
requireAction(t, second, accesslog.ActionError)
if h.eng.Dropped() != 1 {
t.Fatalf("dropped = %d, want 1", h.eng.Dropped())
}
@@ -206,7 +206,7 @@ func TestGlobalConcurrencyCapRefuses(t *testing.T) {
open()
wg.Wait()
res := <-first
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
if got := answerA(res.Msg); got != "203.0.113.7" {
t.Fatalf("first answer = %q, want 203.0.113.7", got)
}
@@ -246,7 +246,7 @@ func TestPerClientConcurrencyCapRefuses(t *testing.T) {
open()
wg.Wait()
requireAction(t, <-first, qlog.ActionAllow)
requireAction(t, <-first, accesslog.ActionAllow)
}
// TestRateLimitRefuses pins the fixed-window rate limit: the first N queries
@@ -259,11 +259,11 @@ func TestRateLimitRefuses(t *testing.T) {
// Distinct names, so the cache cannot turn the second query into a
// hit and mask what is being asserted.
res := h.ask(fmt.Sprintf("rate%d.example.test.", i), dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
}
res := h.ask("rate2.example.test.", dns.TypeA)
requireRcode(t, res, dns.RcodeRefused)
requireAction(t, res, qlog.ActionRefused)
requireAction(t, res, accesslog.ActionRefused)
}
// TestGroupDomainPolicy pins the per-group allow/deny lists, which run after
@@ -278,19 +278,19 @@ func TestGroupDomainPolicy(t *testing.T) {
},
})
res := h.ask("denied.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionBlock)
requireAction(t, res, accesslog.ActionBlock)
if res.BlockSource != "acl:test" {
t.Fatalf("block source = %q, want acl:test", res.BlockSource)
}
// A subdomain of a denied name is denied too.
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != qlog.ActionBlock {
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != accesslog.ActionBlock {
t.Fatalf("subdomain action = %q, want block", sub.Action)
}
if sib := h.ask("sibling.example.test.", dns.TypeA); sib.Action != qlog.ActionAllow {
if sib := h.ask("sibling.example.test.", dns.TypeA); sib.Action != accesslog.ActionAllow {
t.Fatalf("sibling action = %q, want allow: the deny list leaked", sib.Action)
}
allowed := h.ask("allowed.example.test.", dns.TypeA)
requireAction(t, allowed, qlog.ActionAllow)
requireAction(t, allowed, accesslog.ActionAllow)
if got := answerA(allowed.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7", got)
}
@@ -302,11 +302,11 @@ func TestGroupDomainPolicy(t *testing.T) {
*a = loopbackGroup(config.Group{DenyDomains: []string{"denied.example.test."}})
},
})
if res := h.ask("denied.example.test.", dns.TypeA); res.Action != qlog.ActionBlock {
if res := h.ask("denied.example.test.", dns.TypeA); res.Action != accesslog.ActionBlock {
t.Fatalf("exact name: action = %q, want block", res.Action)
}
// Documented behaviour of a rule without a wildcard: the apex only.
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != qlog.ActionAllow {
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != accesslog.ActionAllow {
t.Fatalf("subdomain of an exact rule: action = %q, want allow", sub.Action)
}
})
@@ -317,11 +317,11 @@ func TestGroupDomainPolicy(t *testing.T) {
*a = loopbackGroup(config.Group{AllowDomains: []string{"only.example.test."}})
},
})
if res := h.ask("other.example.test.", dns.TypeA); res.Action != qlog.ActionBlock {
if res := h.ask("other.example.test.", dns.TypeA); res.Action != accesslog.ActionBlock {
t.Fatalf("non-allowlisted name: action = %q, want block", res.Action)
}
allowed := h.ask("only.example.test.", dns.TypeA)
requireAction(t, allowed, qlog.ActionAllow)
requireAction(t, allowed, accesslog.ActionAllow)
if got := answerA(allowed.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7", got)
}
@@ -337,7 +337,7 @@ func TestSkipToggles(t *testing.T) {
rules: []blocklist.Rule{{Name: "blocked.example.test"}},
})
res := h.ask("blocked.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
if got := answerA(res.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7 (the blocklist still applies)", got)
}
@@ -349,7 +349,7 @@ func TestSkipToggles(t *testing.T) {
})
for i := 0; i < 2; i++ {
res := h.ask("nocache.example.test.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
if res.CacheKind != "" {
t.Fatalf("query %d: cache kind = %q, want none", i, res.CacheKind)
}
@@ -364,7 +364,7 @@ func TestSkipToggles(t *testing.T) {
func TestCacheHitDoesNotReachUpstream(t *testing.T) {
h := newHarness(t, hopt{})
first := h.ask("cached.example.test.", dns.TypeA)
requireAction(t, first, qlog.ActionAllow)
requireAction(t, first, accesslog.ActionAllow)
if first.CacheHit {
t.Fatal("the first query reported a cache hit")
}
@@ -376,7 +376,7 @@ func TestCacheHitDoesNotReachUpstream(t *testing.T) {
if !second.CacheHit || second.CacheKind != "hit" {
t.Fatalf("second query: hit=%v kind=%q, want a fresh hit", second.CacheHit, second.CacheKind)
}
requireAction(t, second, qlog.ActionCached)
requireAction(t, second, accesslog.ActionCached)
if got := answerA(second.Msg); got != "203.0.113.7" {
t.Fatalf("answer = %q, want 203.0.113.7", got)
}
@@ -398,7 +398,7 @@ func TestServeStaleAgesToOneAndRefreshesOnce(t *testing.T) {
h := newHarness(t, hopt{serveStale: 10 * time.Minute, clock: true})
first := h.ask("stale.example.test.", dns.TypeA)
requireAction(t, first, qlog.ActionAllow)
requireAction(t, first, accesslog.ActionAllow)
// Expire the entry: the upstream TTL is 60s.
h.advance(61 * time.Second)
@@ -544,7 +544,7 @@ func TestMalformedQueriesAreFormErr(t *testing.T) {
before := h.totals()
res := h.askWith(Request{Msg: q, Remote: h.request("a.example.test.", dns.TypeA).Remote, Transport: "udp"})
requireRcode(t, res, dns.RcodeFormatError)
requireAction(t, res, qlog.ActionError)
requireAction(t, res, accesslog.ActionError)
if got := h.totals().Queries - before.Queries; got != 1 {
t.Fatalf("%s: metrics recorded %d queries, want 1", name, got)
}
@@ -565,14 +565,14 @@ func TestMetricsAndQueryLogFireOncePerQuery(t *testing.T) {
// be counted exactly once.
queries int
send func(*harness)
want qlog.Action
want accesslog.Action
}{
{
name: "upstream answer",
opt: hopt{},
queries: 1,
send: func(h *harness) { h.ask("once.example.test.", dns.TypeA) },
want: qlog.ActionAllow,
want: accesslog.ActionAllow,
},
{
name: "cache hit",
@@ -582,14 +582,14 @@ func TestMetricsAndQueryLogFireOncePerQuery(t *testing.T) {
h.ask("once.example.test.", dns.TypeA)
h.ask("once.example.test.", dns.TypeA)
},
want: qlog.ActionCached,
want: accesslog.ActionCached,
},
{
name: "blocked",
opt: hopt{rules: []blocklist.Rule{{Name: "blocked.example.test"}}},
queries: 1,
send: func(h *harness) { h.ask("blocked.example.test.", dns.TypeA) },
want: qlog.ActionBlock,
want: accesslog.ActionBlock,
},
{
name: "refused",
@@ -598,7 +598,7 @@ func TestMetricsAndQueryLogFireOncePerQuery(t *testing.T) {
send: func(h *harness) {
h.ask("denied.example.test.", dns.TypeA)
},
want: qlog.ActionRefused,
want: accesslog.ActionRefused,
},
}
@@ -613,11 +613,11 @@ func TestMetricsAndQueryLogFireOncePerQuery(t *testing.T) {
t.Fatalf("metrics counted %d queries, want exactly %d", got, tc.queries)
}
if got := h.logged() - beforeLog; got != tc.queries {
t.Fatalf("query log holds %d new entries, want exactly %d", got, tc.queries)
t.Fatalf("access log holds %d new entries, want exactly %d", got, tc.queries)
}
entries := h.entries()
if len(entries) == 0 {
t.Fatal("the newest entry is missing from the live ring")
t.Fatal("the newest entry is missing from the access log")
}
e := entries[0]
if e.Action != tc.want {
@@ -630,15 +630,9 @@ func TestMetricsAndQueryLogFireOncePerQuery(t *testing.T) {
t.Fatalf("logged protocol = %q, want udp", e.Protocol)
}
if len(entries) != tc.queries {
t.Fatalf("the live ring holds %d entries, want %d: the hook fired more than once per query",
t.Fatalf("the access log holds %d entries, want %d: the hook fired more than once per query",
len(entries), tc.queries)
}
for i := 0; i+1 < len(entries); i++ {
if entries[i].ID-entries[i+1].ID != 1 {
t.Fatalf("entries %d and %d are not adjacent (ids %d, %d): the hook fired more than once per query",
i+1, i, entries[i+1].ID, entries[i].ID)
}
}
})
}
}
@@ -649,7 +643,7 @@ func TestNoUpstreamIsServfail(t *testing.T) {
h := newHarness(t, hopt{noPool: true})
res := h.ask("nowhere.example.test.", dns.TypeA)
requireRcode(t, res, dns.RcodeServerFailure)
requireAction(t, res, qlog.ActionError)
requireAction(t, res, accesslog.ActionError)
}
// TestDeadUpstreamIsServfail pins upstream failure handling: the exchange
@@ -669,7 +663,7 @@ func TestDeadUpstreamIsServfail(t *testing.T) {
before := h.totals()
res := h.ask("dead.example.test.", dns.TypeA)
requireRcode(t, res, dns.RcodeServerFailure)
requireAction(t, res, qlog.ActionError)
requireAction(t, res, accesslog.ActionError)
if got := h.totals().Errors - before.Errors; got != 1 {
t.Fatalf("errors rose by %d, want 1", got)
}
@@ -745,7 +739,7 @@ func TestReplyEchoesTheClientQuestionAndID(t *testing.T) {
req := h.request("HoSt.ExAmPlE.TeSt.", dns.TypeA)
req.Msg.Id = 0x1234
res := h.askWith(req)
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
if res.Msg.Id != 0x1234 {
t.Fatalf("reply id = %#x, want 0x1234", res.Msg.Id)
}
+77 -19
View File
@@ -1,7 +1,9 @@
package engine
import (
"bytes"
"context"
"encoding/json"
"net"
"net/netip"
"os"
@@ -13,13 +15,13 @@ import (
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/accesslog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
)
@@ -266,7 +268,8 @@ type harness struct {
store *config.Store
pool *resolver.Pool
cache *cache.Manager
log *qlog.Logger
log *accesslog.Logger
lines *lineSink
up *fakeUpstream
clock *testClock
blocks *blocklist.Holder
@@ -353,20 +356,12 @@ func newHarness(t *testing.T, o hopt) *harness {
Now: clock.Now,
}, nil)
log, err := qlog.New(qlog.Options{
LiveBuffer: 64,
SampleRate: 1,
// Every action is logged, so a test can count entries without
// reasoning about the sampling policy.
LogBlocked: true,
LogCached: true,
LogErrors: true,
TopDomains: 16,
TopTTL: time.Minute,
lines := &lineSink{}
// Every action is logged, so a test can count entries without reasoning
// about the sampling policy.
log := accesslog.New(accesslog.Options{
Writer: lines, SampleRate: 1, LogBlocked: true, LogCached: true, LogErrors: true,
})
if err != nil {
t.Fatalf("qlog: %v", err)
}
t.Cleanup(func() { _ = log.Close() })
blocks := blocklist.NewHolder(buildSet(o.rules...))
@@ -387,7 +382,7 @@ func newHarness(t *testing.T, o hopt) *harness {
}
h := &harness{
t: t, eng: eng, cfg: cfg, store: store, cache: cm, log: log,
t: t, eng: eng, cfg: cfg, store: store, cache: cm, log: log, lines: lines,
up: up, clock: clock, blocks: blocks, local: localH, aclH: aclH,
}
if !o.noPool {
@@ -446,9 +441,16 @@ func (h *harness) askTCP(name string, qtype uint16) Response {
return h.askWith(req)
}
func (h *harness) entries() []qlog.Entry { return h.log.Recent(64) }
// entries returns the access-log lines written so far, newest first.
func (h *harness) entries() []accesslog.Entry {
h.log.Flush()
return h.lines.entries()
}
func (h *harness) logged() int { return h.log.Count() }
func (h *harness) logged() int {
h.log.Flush()
return h.lines.count()
}
func (h *harness) totals() metrics.Totals { return metrics.TotalsNow() }
@@ -484,7 +486,7 @@ func requireRcode(t *testing.T, res Response, rcode int) {
}
}
func requireAction(t *testing.T, res Response, want qlog.Action) {
func requireAction(t *testing.T, res Response, want accesslog.Action) {
t.Helper()
if res.Action != want {
t.Fatalf("action = %q, want %q", res.Action, want)
@@ -497,3 +499,59 @@ func requireNoUpstreamHit(t *testing.T, h *harness, name string) {
t.Fatalf("upstream saw %d queries for %s, want 0", n, name)
}
}
// lineSink collects the access log's output so a test can read it back.
type lineSink struct {
mu sync.Mutex
buf []byte
rows []accesslog.Entry
}
func (s *lineSink) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.buf = append(s.buf, p...)
for {
i := bytes.IndexByte(s.buf, '\n')
if i < 0 {
break
}
line := s.buf[:i]
s.buf = s.buf[i+1:]
var raw struct {
Client string `json:"client"`
Group string `json:"client_group"`
Name string `json:"name"`
Action string `json:"action"`
Protocol string `json:"protocol"`
List string `json:"list"`
Upstream string `json:"upstream"`
Duration float64 `json:"duration_ms"`
}
if err := json.Unmarshal(line, &raw); err != nil {
panic("access log line is not JSON: " + string(line))
}
s.rows = append(s.rows, accesslog.Entry{
Client: raw.Client, Group: raw.Group, Name: raw.Name,
Action: accesslog.Action(raw.Action), Protocol: raw.Protocol,
List: raw.List, Upstream: raw.Upstream,
})
}
return len(p), nil
}
func (s *lineSink) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.rows)
}
func (s *lineSink) entries() []accesslog.Entry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]accesslog.Entry, len(s.rows))
for i, r := range s.rows {
out[len(s.rows)-1-i] = r
}
return out
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/accesslog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"github.com/miekg/dns"
)
@@ -47,7 +47,7 @@ func outcomeCount(t *testing.T, outcome string) uint64 {
// TestPinnedLocalAnswersAreCountedAsUpstream pins the gap STATUS.md called
// "metrics.OutcomeLocal has no producer": a query answered from the local
// zone reports qlog.ActionAllow, and outcomeFor maps ActionAllow to
// zone reports accesslog.ActionAllow, and outcomeFor maps ActionAllow to
// OutcomeUpstream, so every locally answered query was counted as an upstream
// one. The outcome="local" series was therefore never published and the
// dashboard's local column was permanently empty.
@@ -64,7 +64,7 @@ func TestLocallyAnsweredQueryIsCountedAsLocal(t *testing.T) {
upBefore := outcomeCount(t, metrics.OutcomeUpstream)
localBefore := outcomeCount(t, metrics.OutcomeLocal)
res := h.ask("host.local.", dns.TypeA)
requireAction(t, res, qlog.ActionAllow)
requireAction(t, res, accesslog.ActionAllow)
if got := answerA(res.Msg); got != "192.168.1.5" {
t.Fatalf("answer = %q, want the local zone's 192.168.1.5", got)
}