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>
101 lines
3.8 KiB
Go
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)
|
|
}
|
|
}
|