Files
jarl/internal/render/render_test.go
abrown38andClaude Opus 5.5 3b7bee67f8 Add optional query statistics
Off by default (stats in jarl.yaml). When enabled, Jarl adds a loopback
statistics channel and a response log to named's configuration (merged
into existing logging/statistics-channels statements) and switches
response logging with rndc responselog, because the responselog option
only counts when named starts. jarld polls named's counters every minute
and tails the response log (resuming after restarts and across
rotation). It stores aggregates, never individual queries, in a separate
SQLite database: per-minute, hourly and daily counters (queries, result
codes, record types, views, zones, cache hits, upstream latency, DNSSEC)
and hourly/daily top lists (names, clients, NXDOMAIN and SERVFAIL names,
refused clients), each with its own retention. Clients can be anonymized
or excluded.

- Web UI: Statistics page with KPI tiles, queries and failed answers over
  time, a weekday x hour heatmap, top lists and breakdowns; SVG charts
  with hover/keyboard readouts, a table view and light/dark palettes
  validated for colour-vision deficiency.
- CLI: jarl stats status|summary|top|timeline|heatmap|breakdown.
- API: /api/v1/stats/*, behind a new "stats" permission resource.
- jarld brings named's configuration up to date with jarl.yaml at
  startup (engine.SyncConfig), never overwriting hand-edited files.
- resync now refuses to overwrite drifted files without force, as
  documented.
- tmpfiles.d creates /var/log/named (allowed by named's AppArmor profile
  on Debian 13 and Ubuntu).
- The e2e harness runs named with -f like the Debian unit.
- docs/stats.md, plus API, CLI, configuration, permissions and README
  updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:21:34 +00:00

213 lines
7.8 KiB
Go

package render
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/testutil"
"jarl/internal/validate"
)
// sampleState builds a split-horizon configuration exercising most
// features.
func sampleState(t *testing.T) *model.State {
t.Helper()
s := model.NewState()
id := int64(0)
next := func() int64 { id++; return id }
s.ACLs[next()] = &model.ACL{ID: id, Name: "lan", Elements: []string{"192.168.1.0/24", "10/8"}, Comment: "home networks"}
s.Keys[next()] = &model.TSIGKey{ID: id, Name: "xfer-key", Algorithm: "hmac-sha256", Secret: "s0cvDUATzPDPntCVDfO3S9JjT8+51cRZSuWTNM9Eosc="}
internal := &model.View{ID: next(), Name: "internal", Position: 0, MatchClients: []string{"lan", "localhost"}}
external := &model.View{ID: next(), Name: "external", Position: 1, MatchClients: []string{"any"}}
s.Views[internal.ID], s.Views[external.ID] = internal, external
opt := func(scope string, scopeID int64, name, value string) {
o := &model.Option{ID: next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: int(id)}
s.Options[o.ID] = o
}
opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`)
opt(model.ScopeOptions, 0, "dnssec-validation", "auto")
opt(model.ScopeOptions, 0, "listen-on-v6", "{ any; }")
opt(model.ScopeView, internal.ID, "recursion", "yes")
opt(model.ScopeView, internal.ID, "allow-recursion", "{ lan; localhost; }")
opt(model.ScopeView, external.ID, "recursion", "no")
opt(model.ScopeTopLevel, 0, "logging", `{ channel default_log { stderr; severity info; }; category default { default_log; }; }`)
zone := func(name string, v *model.View, typ string) *model.Zone {
z := &model.Zone{ID: next(), Name: dnsname.Fqdn(name), ViewID: v.ID, Type: typ, DefaultTTL: 3600,
SOA: model.SOA{MName: "ns1.example.com.", RName: "hostmaster.example.com.", Serial: 2026100700, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}}
s.Zones[z.ID] = z
return z
}
rec := func(z *model.Zone, name, typ, data string) *model.Record {
owner, _ := dnsname.Owner(name, z.Name)
p, err := rr.Parse(z.Name, owner, typ, data)
if err != nil {
t.Fatalf("%s %s %s: %v", name, typ, data, err)
}
r := &model.Record{ID: next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data}
s.Records[r.ID] = r
return r
}
in := zone("example.com", internal, model.ZonePrimary)
in.Comment = "internal version"
rec(in, "@", "NS", "ns1")
rec(in, "ns1", "A", "192.168.1.2")
r := rec(in, "nas", "A", "192.168.1.20")
r.Comment = "storage box"
r.TTL = model.Ptr(uint32(300))
rec(in, "www", "CNAME", "nas")
rec(in, "@", "TXT", "v=spf1 -all")
rec(in, "@", "CAA", `0 issue "letsencrypt.org"`)
rec(in, "_acme-challenge", "TXT", "abc123")
ext := zone("example.com", external, model.ZonePrimary)
rec(ext, "@", "NS", "ns1")
rec(ext, "ns1", "A", "203.0.113.2")
rec(ext, "www", "A", "203.0.113.10")
opt(model.ScopeZone, ext.ID, "allow-transfer", "{ key xfer-key; }")
rev := zone("1.168.192.in-addr.arpa", internal, model.ZonePrimary)
rec(rev, "@", "NS", "ns1.example.com.")
rec(rev, "20", "PTR", "nas.example.com.")
fwd := zone("consul", internal, model.ZoneForward)
opt(model.ScopeZone, fwd.ID, "forwarders", "{ 127.0.0.1 port 8600; }")
opt(model.ScopeZone, fwd.ID, "forward", "only")
sec := zone("partner.example", external, model.ZoneSecondary)
opt(model.ScopeZone, sec.ID, "primaries", "{ 198.51.100.1 key xfer-key; }")
hint := zone(".", internal, model.ZoneHint)
hint.File = "/usr/share/dns/root.hints"
return s
}
func TestRenderDeterministicAndValid(t *testing.T) {
s := sampleState(t)
if ps := validate.Errors(validate.State(s, validate.Options{})); len(ps) > 0 {
t.Fatalf("sample state has errors: %v", ps)
}
dir := t.TempDir()
p := Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
out1 := Render(s, p, Extras{})
out2 := Render(s.Clone(), p, Extras{})
if len(out1.Files) != len(out2.Files) {
t.Fatal("file count differs")
}
for i := range out1.Files {
if string(out1.Files[i].Content) != string(out2.Files[i].Content) {
t.Errorf("rendering of %s is not deterministic", out1.Files[i].Path)
}
}
conf := string(out1.File(p.MainConfig()).Content)
for _, want := range []string{
`include "` + p.KeysConfig() + `";`,
"acl \"lan\" { 192.168.1.0/24; 10.0.0.0/8; };",
"view \"internal\" {\n\tmatch-clients { lan; localhost; };",
"\tzone \"example.com\" {\n\t\ttype primary;",
"zone \"consul\" {\n\t\ttype forward;",
"\t\tforwarders { 127.0.0.1 port 8600; };",
`file "/usr/share/dns/root.hints";`,
} {
if !strings.Contains(conf, want) {
t.Errorf("named.conf missing %q:\n%s", want, conf)
}
}
if strings.Contains(conf, "s0cvDUAT") {
t.Error("secret leaked into named.conf")
}
keys := out1.File(p.KeysConfig())
if !keys.Secret || !strings.Contains(string(keys.Content), `secret "s0cvDUAT`) {
t.Error("keys.conf wrong")
}
if len(out1.Dirs) != 1 || !strings.HasSuffix(out1.Dirs[0], "cache/external") {
t.Errorf("dirs = %v", out1.Dirs)
}
// Source map: the line with the internal zone maps to that zone.
lines := strings.Split(conf, "\n")
for i, l := range lines {
if strings.Contains(l, "forwarders { 127.0.0.1 port 8600; }") {
span, ok := out1.Lookup(p.MainConfig(), i+1)
if !ok || span.ObjectType != "option" || !strings.Contains(span.Label, "consul") {
t.Errorf("span for line %d = %+v %v", i+1, span, ok)
}
}
}
zf := ZoneFile(s, s.FindZone("example.com", s.ViewByName("internal").ID))
for _, want := range []string{"$ORIGIN example.com.", "$TTL 3600", "nas\t300\tIN\tA\t192.168.1.20\t; storage box", "www\t\tIN\tCNAME\tnas.example.com.", "@\t\tIN\tNS\tns1.example.com.", "2026100700\t; serial"} {
if !strings.Contains(zf, want) {
t.Errorf("zone file missing %q:\n%s", want, zf)
}
}
}
func TestRenderWithNamedCheckconf(t *testing.T) {
checkWithNamed(t, sampleState(t), Extras{})
}
// checkWithNamed renders s and checks the result with named-checkconf -z
// and named-checkzone.
func checkWithNamed(t *testing.T, s *model.State, x Extras) *Output {
t.Helper()
tools := testutil.BindTools(t)
dir := t.TempDir()
p := Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
// named-checkconf changes into the directory option, so it must exist.
s.FindOption(model.ScopeOptions, 0, "directory").Value = `"` + dir + `"`
// The hint zone's file must exist too.
hints := filepath.Join(dir, "root.hints")
if err := os.WriteFile(hints, []byte(".\t3600000\tNS\tA.ROOT-SERVERS.NET.\nA.ROOT-SERVERS.NET.\t3600000\tA\t198.41.0.4\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, z := range s.Zones {
if z.Type == model.ZoneHint {
z.File = hints
}
}
out := Render(s, p, x)
for _, f := range out.Files {
if err := os.MkdirAll(filepath.Dir(f.Path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f.Path, f.Content, 0o644); err != nil {
t.Fatal(err)
}
}
res, err := tools.CheckConfig(context.Background(), p.MainConfig(), true)
if err != nil {
t.Fatal(err)
}
if !res.OK {
t.Fatalf("named-checkconf failed:\n%s\n--- named.conf:\n%s", res.Output, out.File(p.MainConfig()).Content)
}
for _, f := range out.Files {
if f.Kind != KindZone {
continue
}
z := s.Zones[f.ZoneID]
r, err := tools.CheckZone(context.Background(), z.Name, f.Path)
if err != nil || !r.OK {
t.Errorf("named-checkzone %s: %v\n%s", z.Name, err, r.Output)
}
}
return out
}
func TestZoneContentHash(t *testing.T) {
s := sampleState(t)
z := s.FindZone("example.com", s.ViewByName("internal").ID)
h1 := ZoneContentHash(s, z)
z.SOA.Serial++
for _, r := range s.ZoneRecords(z.ID) {
r.Comment = "changed comment"
}
if ZoneContentHash(s, z) != h1 {
t.Error("serial or comments changed the content hash")
}
z.DefaultTTL = 7200
if ZoneContentHash(s, z) == h1 {
t.Error("default TTL change not reflected")
}
}