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

+17 -133
View File
@@ -801,87 +801,34 @@ func TestBlocklistToggle(t *testing.T) {
})
}
// TestDashboardAndStatus checks the aggregate endpoints return the fields the
// UI reads, so a frontend change cannot silently drift from the API.
func TestDashboardAndStatus(t *testing.T) {
// TestStatus checks the status and health endpoints carry the sections the
// console's status page reads.
func TestStatus(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
code, out := h.get("/api/dashboard?range=1h")
if code != 200 {
t.Fatalf("dashboard = %d", code)
code, out := h.get("/api/healthz")
if code != 200 || !strings.Contains(string(out), `"status":"ok"`) {
t.Fatalf("healthz = %d %s", code, out)
}
var d struct {
Health string `json:"health"`
Queries uint64 `json:"queries_total"`
Blocked uint64 `json:"blocked_total"`
HitRate any `json:"cache_hit_rate"`
Series []any `json:"series"`
TopBlocked []struct {
Domain string `json:"domain"`
Count uint64 `json:"count"`
} `json:"top_blocked"`
Upstreams []struct {
Addr string `json:"addr"`
Health string `json:"health"`
} `json:"upstreams"`
}
if err := json.Unmarshal(out, &d); err != nil {
t.Fatal(err)
}
if d.Health != "ok" {
t.Errorf("health = %q, want ok", d.Health)
}
if d.Queries == 0 {
t.Error("queries_total is zero after two queries")
}
if d.Blocked == 0 {
t.Error("blocked_total is zero after a blocked query")
}
if len(d.Upstreams) == 0 {
t.Error("upstreams empty in dashboard")
}
if len(d.Series) == 0 {
t.Error("series empty in dashboard")
}
_ = d.HitRate
_ = d.TopBlocked
code, out = h.get("/api/status")
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(string(out), "listeners") {
t.Error("status has no listeners section")
var st struct {
Components map[string]json.RawMessage `json:"components"`
}
}
// TestLogsAndClear covers the query log endpoint and the clear action.
func TestLogsAndClear(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
waitFor(t, 3*time.Second, func() bool {
code, out := h.get("/api/logs?limit=50")
if code != 200 {
return false
if err := json.Unmarshal(out, &st); err != nil {
t.Fatal(err)
}
for _, want := range []string{"listeners", "blocklist", "cache", "upstream", "access_log"} {
if _, ok := st.Components[want]; !ok {
t.Errorf("status has no %q section", want)
}
return strings.Contains(string(out), "example.test")
})
code, out := h.get("/api/logs?q=ads.example.com&blocked=1")
if code != 200 {
t.Fatalf("logs = %d", code)
}
if !strings.Contains(string(out), "ads.example.com") {
t.Error("filtered log did not include the blocked query")
}
if code, _ := h.do("POST", "/api/logs/clear", ""); code != 200 {
t.Errorf("clear logs = %d, want 200", code)
}
}
// TestCacheEndpoints covers the cache stats/top/flush trio.
// TestCacheEndpoints covers the cache stats and flush.
func TestCacheEndpoints(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
@@ -900,13 +847,6 @@ func TestCacheEndpoints(t *testing.T) {
if c.Shards <= 0 {
t.Error("shards is zero")
}
code, out = h.get("/api/cache/top?limit=10")
if code != 200 {
t.Fatalf("cache top = %d", code)
}
if !strings.Contains(string(out), "example.test") {
t.Error("cache top does not list the queried name")
}
if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 {
t.Errorf("flush = %d, want 200", code)
}
@@ -949,62 +889,6 @@ func TestReloadFromDisk(t *testing.T) {
}
}
// TestLogStream checks the SSE endpoint emits a ready frame and at least one
// entry frame, and that it terminates when the client disconnects.
func TestLogStream(t *testing.T) {
h := newHarness(t, nil)
req, err := httpNewRequest("GET", "http://"+h.api+"/api/logs/stream", h.key)
if err != nil {
t.Fatal(err)
}
// A streaming client must not time out while idle, so give it a generous
// deadline; the test closes the body when it has seen what it needs.
streamClient := &http.Client{Timeout: 20 * time.Second}
resp, err := streamClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("stream = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Errorf("content-type = %q, want text/event-stream", ct)
}
if resp.Header.Get("X-Accel-Buffering") != "no" {
t.Error("missing X-Accel-Buffering: no, a proxy would buffer the stream")
}
// Generate a query and read frames until we see an entry.
go func() {
time.Sleep(150 * time.Millisecond)
h.query("www.example.test", dns.TypeA)
}()
buf := make([]byte, 4096)
seen := map[string]bool{}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := resp.Body.Read(buf)
if err != nil {
break
}
chunk := string(buf[:n])
for _, ev := range []string{"event: ready", "event: entry", "event: stats"} {
if strings.Contains(chunk, ev) {
seen[ev] = true
}
}
if seen["event: ready"] && seen["event: entry"] {
break
}
}
if !seen["event: ready"] {
t.Error("no ready event")
}
if !seen["event: entry"] {
t.Error("no entry event for a query issued while streaming")
}
}
// TestMetricsEndpoint checks Prometheus output is served and protected.
func TestMetricsEndpoint(t *testing.T) {
h := newHarness(t, nil)
@@ -1066,13 +950,13 @@ func TestConcurrentQueries(t *testing.T) {
apiDone := make(chan error, workers)
for w := 0; w < workers; w++ {
go func() {
res, err := httpGet("http://"+h.api+"/api/dashboard", h.key)
res, err := httpGet("http://"+h.api+"/api/status", h.key)
if err != nil {
apiDone <- err
return
}
if res.status != 200 {
apiDone <- fmt.Errorf("dashboard = %d", res.status)
apiDone <- fmt.Errorf("status = %d", res.status)
return
}
apiDone <- nil