Files
fancy-dns/internal/engine/blockttl_test.go
T
matsandClaude Sonnet 5.5 0fdc828a10 Keep list copies on disk, start from them, and stop logging address overrides
Found by rolling the resolver out to the real cluster.

BlockList objects have no path, deliberately, so the first rollout downloaded
the list at every start. The cluster DNS that the download needs is this very
resolver, which was not up yet, so the first compile failed after 14 seconds,
held the DNS listeners closed for that long, and left the pod with no ad
blocking at all. Three changes:

* Lists from cluster objects get a copy under the runtime's own data directory
  (data/lists/<name>), chosen by the runtime from the object's name, never by the
  object.
* The copy is used as the answer only while it is younger than the list's
  refresh interval, and otherwise the list is downloaded again, with the old copy
  as the fallback if that fails. Before, any copy on disk was treated as current
  for ever, so a list with a path was frozen at its first download. A source now
  ages from its file, an operator's "refresh now" forces a download, and a
  failed download keeps the stale copy.
* Startup loads only what is on disk (RefreshOffline), opens the listeners, and
  fetches stale or missing lists in the background. The resolver answers at once
  and the network is never on its critical path.

An address override answered as a redirect and so counted as a policy action:
with the access log logging redirects, every lookup of an internal name wrote a
line. Overrides from the managed source are local answers now; a redirect a list
imposes is still logged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:41:20 +02:00

101 lines
3.8 KiB
Go

package engine
import (
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/accesslog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// soaTTLs returns the TTL and minimum of the SOA in a response's authority
// section.
func soaTTLs(t *testing.T, m *dns.Msg) (ttl, min uint32) {
t.Helper()
for _, rr := range m.Ns {
if soa, ok := rr.(*dns.SOA); ok {
return soa.Hdr.Ttl, soa.Minttl
}
}
t.Fatalf("no SOA in %v", m)
return 0, 0
}
// TestBlockedAnswersCarryTheConfiguredTTLInSeconds: response_ttl is a duration
// ("60s"), and the TTL on the wire is seconds. The conversion once took the
// duration's nanoseconds as seconds and truncated them, so a blocked name's
// negative answer told every client to remember it for about thirty years.
func TestBlockedAnswersCarryTheConfiguredTTLInSeconds(t *testing.T) {
const want = 60 // config.Default's response_ttl
t.Run("nxdomain", func(t *testing.T) {
h := newHarness(t, hopt{rules: []blocklist.Rule{{Name: "blocked.example.test"}}})
res := h.ask("blocked.example.test.", dns.TypeA)
requireRcode(t, res, dns.RcodeNameError)
if ttl, min := soaTTLs(t, res.Msg); ttl != want || min != want {
t.Errorf("SOA ttl=%d minimum=%d, want %d", ttl, min, want)
}
})
t.Run("zero", func(t *testing.T) {
h := newHarness(t, hopt{
blockCfg: func(b *config.Block) { b.DefaultAction = "zero" },
rules: []blocklist.Rule{{Name: "blocked.example.test"}},
})
res := h.ask("blocked.example.test.", dns.TypeA)
if len(res.Msg.Answer) != 1 || res.Msg.Answer[0].Header().Ttl != want {
t.Errorf("answer = %v, want one record with TTL %d", res.Msg.Answer, want)
}
})
t.Run("a rule's own ttl", func(t *testing.T) {
h := newHarness(t, hopt{rules: []blocklist.Rule{{Name: "blocked.example.test", TTL: 120}}})
res := h.ask("blocked.example.test.", dns.TypeA)
if ttl, _ := soaTTLs(t, res.Msg); ttl != 120 {
t.Errorf("SOA ttl = %d, want the rule's 120", ttl)
}
})
t.Run("redirect", func(t *testing.T) {
h := newHarness(t, hopt{rules: []blocklist.Rule{{Name: "blocked.example.test", Action: blocklist.ActionRedirect, Response: "10.9.9.9"}}})
res := h.ask("blocked.example.test.", dns.TypeA)
if len(res.Msg.Answer) != 1 || res.Msg.Answer[0].Header().Ttl != want {
t.Errorf("answer = %v, want one record with TTL %d", res.Msg.Answer, want)
}
})
}
func TestTTLSecondsClamps(t *testing.T) {
for in, want := range map[time.Duration]uint32{
0: 0, 60 * time.Second: 60, 1500 * time.Millisecond: 1, 24 * time.Hour: 86400,
-time.Second: 0, 1000000 * time.Hour: 1<<31 - 1,
} {
if got := blocklist.TTLSeconds(in); got != want {
t.Errorf("TTLSeconds(%v) = %d, want %d", in, got, want)
}
}
}
// An address override is the answer the operator wants for a name, not a policy
// action. With the access log logging redirects by default, logging each one
// would fill it with every lookup of an internal name.
func TestAddressOverridesAreLocalAnswersNotLoggedRedirects(t *testing.T) {
h := newHarness(t, hopt{})
b := blocklist.NewBuilder()
b.Add(blocklist.Rule{Name: "**.pinned.test", Action: blocklist.ActionRedirect, Response: "10.9.9.9"}, "managed:address-overrides", nil)
b.Add(blocklist.Rule{Name: "**.sinkholed.test", Action: blocklist.ActionRedirect, Response: "10.8.8.8"}, "somelist", nil)
h.blocks.Set(b.Build())
res := h.ask("www.pinned.test.", dns.TypeA)
if res.Action != accesslog.ActionAllow || !res.Local {
t.Errorf("override: action=%q local=%v, want allow/local", res.Action, res.Local)
}
if len(res.Msg.Answer) != 1 {
t.Errorf("override answer = %v", res.Msg.Answer)
}
// A redirect that a list imposes is still a policy action.
if res := h.ask("www.sinkholed.test.", dns.TypeA); res.Action != accesslog.ActionRedirect {
t.Errorf("list redirect: action=%q, want redirect", res.Action)
}
}