Files
adamandClaude Opus 5.5 403f85486d Mark signed zones with a padlock, and name clients in statistics
The zones list shows a padlock after signed zones, linking to their
DNSSEC tab. Client lists in the statistics show the host name Jarl's
records have for each address (PTR, else A/AAAA); no DNS queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:27:53 +00:00

236 lines
8.2 KiB
Go

package stats
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// fakeNamed serves a statistics channel whose counters the test sets.
type fakeNamed struct {
mu sync.Mutex
queries int64
nx int64
srv *httptest.Server
}
func newFakeNamed(t *testing.T) *fakeNamed {
f := &fakeNamed{}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
fmt.Fprintf(w, `{"boot-time":"2026-10-05T08:00:00Z","opcodes":{"QUERY":%d},"rcodes":{"NOERROR":%d,"NXDOMAIN":%d},"qtypes":{"A":%d},"nsstats":{},"views":{}}`,
f.queries, f.queries-f.nx, f.nx, f.queries)
}))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeNamed) add(q, nx int64) {
f.mu.Lock()
f.queries += q
f.nx += nx
f.mu.Unlock()
}
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func logLine(t time.Time, client, view, name, typ, rcode string) string {
v := ""
if view != "" {
v = "view " + view + ": "
}
return fmt.Sprintf("%s client @0x7f0000000000 %s#5353 (%s): %sresponse: %s IN %s %s 1 0 1 +E(0)K (10.10.0.2)\n",
t.Format(logTimeLayout), client, name, v, name, typ, rcode)
}
func TestCollector(t *testing.T) {
ctx := context.Background()
loc := time.FixedZone("test", 2*3600)
dir := t.TempDir()
logFile := filepath.Join(dir, "responses.log")
store, err := OpenStore(filepath.Join(dir, "stats.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
named := newFakeNamed(t)
clk := &clock{time.Date(2026, 10, 5, 10, 0, 30, 0, loc)} // a Monday
zones := func() map[string][]string {
return map[string][]string{"internal": {"adbraun.lol", "0.10.10.in-addr.arpa"}}
}
opts := Options{QueryLog: true, LogFile: logFile, StatisticsChannel: "unused", TopN: 3,
RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour,
Exclude: []netip.Prefix{netip.MustParsePrefix("10.10.0.250/32")}, Location: loc}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
newCollector := func() *Collector {
c := NewCollector(store, opts, zones, log)
c.poller.URL = named.srv.URL
c.now = clk.now
c.start(ctx)
return c
}
write := func(lines ...string) {
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
t.Fatal(err)
}
for _, l := range lines {
f.WriteString(l)
}
f.Close()
}
c := newCollector()
// Minute 10:00: four responses (one from an excluded monitor).
base := time.Date(2026, 10, 5, 10, 0, 40, 0, loc)
write(logLine(base, "10.10.0.37", "internal", "nas.adbraun.lol", "A", "NOERROR"),
logLine(base, "10.10.0.37", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"),
logLine(base, "10.10.0.42", "internal", "nope.adbraun.lol", "A", "NXDOMAIN"),
logLine(base, "10.10.0.42", "internal", "debian.org", "A", "NOERROR"),
logLine(base, "10.10.0.250", "internal", "nas.adbraun.lol", "A", "NOERROR"))
c.readLog()
named.add(5, 1)
clk.t = time.Date(2026, 10, 5, 10, 1, 2, 0, loc)
c.flush(ctx, false)
m := time.Date(2026, 10, 5, 10, 0, 0, 0, loc).Unix()
got, err := store.sums(ctx, ResMinute, m, m+60, "")
if err != nil {
t.Fatal(err)
}
want := map[string]int64{"queries": 5, "rcode:NOERROR": 4, "rcode:NXDOMAIN": 1, "qtype:A": 5, "responses": 4,
"view:internal": 4, "zone:adbraun.lol": 3, "zone:-": 1}
for k, v := range want {
if got[k] != v {
t.Errorf("minute %s = %d, want %d (all: %v)", k, got[k], v, got)
}
}
names, _ := store.TopsAt(ctx, ResHour, m, KindName)
if len(names) != 3 || names[0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) {
t.Errorf("hour names = %v", names)
}
if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "nope.adbraun.lol" {
t.Errorf("hour nxdomain = %v", nx)
}
// Into the next hour: the 10:00 hour is finished and added to the day.
write(logLine(time.Date(2026, 10, 5, 10, 59, 50, 0, loc), "10.10.0.37", "internal", "nas.adbraun.lol", "A", "NOERROR"),
logLine(time.Date(2026, 10, 5, 11, 0, 1, 0, loc), "10.10.0.99", "internal", "printer.adbraun.lol", "A", "NOERROR"))
c.readLog()
named.add(2, 0)
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
c.flush(ctx, false)
day := time.Date(2026, 10, 5, 0, 0, 0, 0, loc).Unix()
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); len(d) == 0 || d[0] != (Entry{Key: "nas.adbraun.lol", Count: 3}) {
t.Errorf("day names after the hour = %v", d)
}
if v, _ := store.sums(ctx, ResDay, day, day+1, "queries"); v["queries"] != 7 {
t.Errorf("day queries = %v", v)
}
// Restart in the middle of hour 11. Lines from hour 10 that were
// still unread are added to hour 10 and the day, not replacing them.
c.flush(ctx, true)
c2 := newCollector()
write(logLine(time.Date(2026, 10, 5, 10, 59, 59, 0, loc), "10.10.0.37", "internal", "nas.adbraun.lol", "A", "NOERROR"),
logLine(time.Date(2026, 10, 5, 11, 2, 0, 0, loc), "10.10.0.99", "internal", "printer.adbraun.lol", "A", "NOERROR"))
c2.readLog()
clk.t = time.Date(2026, 10, 5, 11, 3, 2, 0, loc)
c2.flush(ctx, false)
if h, _ := store.TopsAt(ctx, ResHour, m, KindName); len(h) == 0 || h[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) {
t.Errorf("hour 10 after late line = %v", h)
}
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); d[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) {
t.Errorf("day after late line = %v", d)
}
h11 := time.Date(2026, 10, 5, 11, 0, 0, 0, loc).Unix()
if h, _ := store.TopsAt(ctx, ResHour, h11, KindName); len(h) != 1 || h[0] != (Entry{Key: "printer.adbraun.lol", Count: 2}) {
t.Errorf("hour 11 continued after restart = %v", h)
}
// The read side.
q := &Querier{Store: store, Opts: opts, Now: clk.now}
s, err := q.Summary(ctx, 24*time.Hour, 5)
if err != nil {
t.Fatal(err)
}
if s.Queries != 7 || s.Resolution != ResMinute || s.Tops[KindName][0].Key != "nas.adbraun.lol" || s.Peak == nil || s.Peak.Queries != 5 {
t.Errorf("summary = %+v", s)
}
if s.NXDomainRatio == nil || *s.NXDomainRatio != 1.0/7 {
t.Errorf("nxdomain ratio = %v", s.NXDomainRatio)
}
tl, err := q.Timeline(ctx, 2*time.Hour, "auto", []string{"queries", "rcode:*"})
if err != nil {
t.Fatal(err)
}
if tl.Step != "1m" || len(tl.Series) != 3 {
t.Fatalf("timeline step %s series %d", tl.Step, len(tl.Series))
}
var total int64
for _, v := range tl.Series[0].Values {
total += v
}
if tl.Series[0].Key != "queries" || total != 7 {
t.Errorf("timeline queries = %v", tl.Series[0])
}
if tl, _ := q.Timeline(ctx, 30*24*time.Hour, "auto", []string{"queries"}); tl.Step != "6h" {
t.Errorf("30d step = %s", tl.Step)
}
hm, err := q.Heatmap(ctx, "", 7*24*time.Hour)
if err != nil {
t.Fatal(err)
}
// The counters polled at 11:00:02 belong to the minute before.
if hm.Avg[0][10] != 7 || hm.Avg[0][11] != 0 || hm.Samples[0][11] != 1 || hm.Avg[3][3] != -1 {
t.Errorf("heatmap Monday 10h=%v 11h=%v Thu 3h=%v", hm.Avg[0][10], hm.Avg[0][11], hm.Avg[3][3])
}
b, _ := q.Breakdown(ctx, "zone", 24*time.Hour)
if len(b) != 2 || b[0] != (Entry{Key: "adbraun.lol", Count: 7}) || b[1] != (Entry{Key: OtherZone, Count: 1}) {
t.Errorf("zone breakdown = %v", b)
}
// Retention: minute data older than the retention disappears.
opts.RetainMinute = time.Hour
c3 := NewCollector(store, opts, zones, log)
c3.now = func() time.Time { return clk.t.Add(2 * time.Hour) }
c3.prune(ctx)
if v, _ := store.sums(ctx, ResMinute, 0, 1<<40, "queries"); v["queries"] != 0 {
t.Errorf("minute data survived pruning: %v", v)
}
if v, _ := store.sums(ctx, ResHour, 0, 1<<40, "queries"); v["queries"] != 7 {
t.Errorf("hour data was pruned: %v", v)
}
}
func TestAnonymizeAndParseSince(t *testing.T) {
if k := clientKey(netip.MustParseAddr("10.10.0.37"), true); k != "10.10.0.0/24" {
t.Errorf("v4 = %s", k)
}
if k := clientKey(netip.MustParseAddr("2001:db8:1:2::7"), true); k != "2001:db8:1::/48" {
t.Errorf("v6 = %s", k)
}
for in, want := range map[string]time.Duration{"": 24 * time.Hour, "90m": 90 * time.Minute, "7d": 7 * 24 * time.Hour, "2w": 14 * 24 * time.Hour} {
if d, err := ParseSince(in); err != nil || d != want {
t.Errorf("ParseSince(%q) = %v, %v", in, d, err)
}
}
for _, bad := range []string{"x", "-1h", "0d", "1.5d"} {
if _, err := ParseSince(bad); err == nil {
t.Errorf("ParseSince(%q) accepted", bad)
}
}
}