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:
1 parent
dd1117fd91
commit
c6c5ca4ff7
31 files changed
+818
-3389
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user