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>
784 lines
27 KiB
Go
784 lines
27 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"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/acl"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
// testZone is the local zone the harness loads when a test asks for one.
|
|
const testZone = `$TTL 60
|
|
local. SOA ns.local. hostmaster.local. 1 3600 600 604800 60
|
|
local. NS ns.local.
|
|
ns.local. A 192.168.1.2
|
|
host.local. A 192.168.1.5
|
|
*.wild.local. A 192.168.1.50
|
|
`
|
|
|
|
// loopbackGroup builds an ACL with one group that matches this test's client
|
|
// (127.0.0.1), so a test can attach a policy without inventing CIDRs.
|
|
func loopbackGroup(g config.Group) config.ACL {
|
|
if g.Name == "" {
|
|
g.Name = "test"
|
|
}
|
|
if g.Policy == "" {
|
|
g.Policy = "allow"
|
|
}
|
|
if len(g.Match.CIDRs) == 0 {
|
|
g.Match.CIDRs = []string{"127.0.0.0/8"}
|
|
}
|
|
return config.ACL{DefaultPolicy: "allow", Groups: []config.Group{g}}
|
|
}
|
|
|
|
// TestACLDenyWinsOverLocalZone pins the first ordering rule: a denied client
|
|
// is refused before the local zone, the blocklist or the cache is consulted.
|
|
func TestACLDenyWinsOverLocalZone(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{Policy: "deny"}) },
|
|
rules: []blocklist.Rule{{Name: "denied.local"}},
|
|
localZone: testZone,
|
|
})
|
|
|
|
// host.local. is in the local zone and denied.local. is on the
|
|
// blocklist: both must come back REFUSED, which is only possible if the
|
|
// ACL ran first.
|
|
for _, name := range []string{"host.local.", "denied.local."} {
|
|
res := h.ask(name, dns.TypeA)
|
|
requireRcode(t, res, dns.RcodeRefused)
|
|
requireAction(t, res, accesslog.ActionRefused)
|
|
if res.Group != "test" {
|
|
t.Fatalf("group = %q, want %q", res.Group, "test")
|
|
}
|
|
if res.GroupPolicy != "deny" {
|
|
t.Fatalf("group policy = %q, want deny", res.GroupPolicy)
|
|
}
|
|
if res.Client != "127.0.0.1" {
|
|
t.Fatalf("client = %q, want 127.0.0.1", res.Client)
|
|
}
|
|
requireNoUpstreamHit(t, h, name)
|
|
}
|
|
}
|
|
|
|
// TestLocalZoneWinsOverBlocklist pins the second rule: locally defined
|
|
// authoritative data is answered before the blocklist, so an operator can
|
|
// un-block a name in a zone file.
|
|
func TestLocalZoneWinsOverBlocklist(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
rules: []blocklist.Rule{{Name: "host.local"}, {Name: "*.wild.local"}},
|
|
localZone: testZone,
|
|
})
|
|
|
|
res := h.ask("host.local.", dns.TypeA)
|
|
requireAction(t, res, accesslog.ActionAllow)
|
|
if got := answerA(res.Msg); got != "192.168.1.5" {
|
|
t.Fatalf("answer = %q, want 192.168.1.5 (blocked instead?)", got)
|
|
}
|
|
if res.Msg == nil || !res.Msg.Authoritative {
|
|
t.Fatal("local zone answer is not authoritative")
|
|
}
|
|
requireNoUpstreamHit(t, h, "host.local.")
|
|
|
|
// A wildcard rule covers the subdomains of wild.local but the zone's
|
|
// own wildcard record answers first.
|
|
w := h.ask("anything.wild.local.", dns.TypeA)
|
|
if got := answerA(w.Msg); got != "192.168.1.50" {
|
|
t.Fatalf("wildcard answer = %q, want 192.168.1.50", got)
|
|
}
|
|
}
|
|
|
|
// TestLocalZoneNxDomainForAZoneItOwns pins the negative case: a name inside a
|
|
// loaded zone that does not exist is NXDOMAIN with the zone's SOA, and never
|
|
// reaches the upstream.
|
|
func TestLocalZoneNxDomainForAZoneItOwns(t *testing.T) {
|
|
h := newHarness(t, hopt{localZone: testZone})
|
|
res := h.ask("nope.local.", dns.TypeA)
|
|
requireRcode(t, res, dns.RcodeNameError)
|
|
if len(res.Msg.Ns) == 0 {
|
|
t.Fatal("NXDOMAIN without an SOA in the authority section")
|
|
}
|
|
requireNoUpstreamHit(t, h, "nope.local.")
|
|
}
|
|
|
|
// TestBlocklistWinsOverCache pins the third rule: a cached answer must not
|
|
// outrank a blocklist rule. A name cached with a real address is still
|
|
// blocked, and the blocklist is answered without touching the network.
|
|
func TestBlocklistWinsOverCache(t *testing.T) {
|
|
h := newHarness(t, hopt{rules: []blocklist.Rule{{Name: "blocked.example.test"}}})
|
|
|
|
// Seed the cache with the exact key the engine will compute, so a pass
|
|
// would be visible as a cache hit.
|
|
seed := new(dns.Msg)
|
|
seed.SetQuestion("blocked.example.test.", dns.TypeA)
|
|
rr, _ := dns.NewRR("blocked.example.test. 60 IN A 192.0.2.55")
|
|
seed.Answer = append(seed.Answer, rr)
|
|
key := cache.QueryKey("blocked.example.test.", dns.TypeA, dns.ClassINET, false, 0)
|
|
h.cache.PutResponse(key, seed, "seed")
|
|
if got := h.cache.Get(key); got.Kind != cache.Fresh {
|
|
t.Fatalf("seeded cache entry is %v, want Fresh", got.Kind)
|
|
}
|
|
|
|
res := h.ask("blocked.example.test.", dns.TypeA)
|
|
requireAction(t, res, accesslog.ActionBlock)
|
|
requireRcode(t, res, dns.RcodeNameError)
|
|
if res.CacheHit {
|
|
t.Fatal("a blocked query reported a cache hit: the cache outranked the blocklist")
|
|
}
|
|
if res.BlockSource != "test" {
|
|
t.Fatalf("block source = %q, want %q", res.BlockSource, "test")
|
|
}
|
|
if answerA(res.Msg) == "192.0.2.55" {
|
|
t.Fatal("the cached address was served for a blocked name")
|
|
}
|
|
requireNoUpstreamHit(t, h, "blocked.example.test.")
|
|
}
|
|
|
|
// TestBlocklistDropSendsNothing pins the "drop" action: no response at all,
|
|
// not an empty one.
|
|
func TestBlocklistDropSendsNothing(t *testing.T) {
|
|
h := newHarness(t, hopt{rules: []blocklist.Rule{
|
|
{Name: "drop.example.test", Action: blocklist.ActionDrop},
|
|
}})
|
|
res := h.ask("drop.example.test.", dns.TypeA)
|
|
if !res.Drop {
|
|
t.Fatal("Drop = false, want true")
|
|
}
|
|
if res.Msg != nil {
|
|
t.Fatal("a dropped query produced a message to send")
|
|
}
|
|
requireAction(t, res, accesslog.ActionDrop)
|
|
requireNoUpstreamHit(t, h, "drop.example.test.")
|
|
}
|
|
|
|
// TestAllowRuleBeatsBlockRule pins the whitelist: an explicit allow rule wins
|
|
// over a list entry, and the name resolves.
|
|
func TestAllowRuleBeatsBlockRule(t *testing.T) {
|
|
h := newHarness(t, hopt{rules: []blocklist.Rule{
|
|
{Name: "*.example.test"},
|
|
{Name: "ok.example.test", Action: blocklist.ActionAllow},
|
|
}})
|
|
res := h.ask("ok.example.test.", dns.TypeA)
|
|
requireAction(t, res, accesslog.ActionAllow)
|
|
if got := answerA(res.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("answer = %q, want the upstream address 203.0.113.7", got)
|
|
}
|
|
blocked := h.ask("ads.example.test.", dns.TypeA)
|
|
requireAction(t, blocked, accesslog.ActionBlock)
|
|
}
|
|
|
|
// TestGlobalConcurrencyCapRefuses pins the overload refusal: a saturated
|
|
// global budget is answered SERVFAIL and counted as dropped, not queued.
|
|
func TestGlobalConcurrencyCapRefuses(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
server: func(s *config.Server) { s.MaxConcurrent = 1 },
|
|
})
|
|
open := h.up.hold()
|
|
|
|
var wg sync.WaitGroup
|
|
first := make(chan Response, 1)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
first <- h.ask("slow.example.test.", dns.TypeA)
|
|
}()
|
|
|
|
// The first query is now parked inside the upstream exchange, holding the
|
|
// one global slot.
|
|
h.up.waitArrived(t)
|
|
second := h.ask("other.example.test.", dns.TypeA)
|
|
requireRcode(t, second, dns.RcodeServerFailure)
|
|
requireAction(t, second, accesslog.ActionError)
|
|
if h.eng.Dropped() != 1 {
|
|
t.Fatalf("dropped = %d, want 1", h.eng.Dropped())
|
|
}
|
|
// The refusal must not have reached the network.
|
|
requireNoUpstreamHit(t, h, "other.example.test.")
|
|
|
|
open()
|
|
wg.Wait()
|
|
res := <-first
|
|
requireAction(t, res, accesslog.ActionAllow)
|
|
if got := answerA(res.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("first answer = %q, want 203.0.113.7", got)
|
|
}
|
|
if n := h.eng.Inflight(); n != 0 {
|
|
t.Fatalf("inflight = %d after both queries finished, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestPerClientConcurrencyCapRefuses pins the per-client cap, which is checked
|
|
// before the global one so one noisy client cannot take the whole budget.
|
|
func TestPerClientConcurrencyCapRefuses(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) {
|
|
*a = loopbackGroup(config.Group{MaxConcurrent: 1})
|
|
},
|
|
// A generous global budget, so a refusal can only come from the
|
|
// per-client limiter.
|
|
server: func(s *config.Server) { s.MaxConcurrent = 64 },
|
|
})
|
|
open := h.up.hold()
|
|
|
|
var wg sync.WaitGroup
|
|
first := make(chan Response, 1)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
first <- h.ask("slow.example.test.", dns.TypeA)
|
|
}()
|
|
h.up.waitArrived(t)
|
|
|
|
second := h.ask("other.example.test.", dns.TypeA)
|
|
requireRcode(t, second, dns.RcodeServerFailure)
|
|
// A per-client refusal is not a global overload.
|
|
if h.eng.Dropped() != 0 {
|
|
t.Fatalf("dropped = %d, want 0: the global cap was not the one that refused", h.eng.Dropped())
|
|
}
|
|
|
|
open()
|
|
wg.Wait()
|
|
requireAction(t, <-first, accesslog.ActionAllow)
|
|
}
|
|
|
|
// TestRateLimitRefuses pins the fixed-window rate limit: the first N queries
|
|
// in a window go through, the next is REFUSED.
|
|
func TestRateLimitRefuses(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{RateLimit: 2}) },
|
|
})
|
|
for i := 0; i < 2; i++ {
|
|
// Distinct names, so the cache cannot turn the second query into a
|
|
// hit and mask what is being asserted.
|
|
res := h.ask(fmt.Sprintf("rate%d.example.test.", i), dns.TypeA)
|
|
requireAction(t, res, accesslog.ActionAllow)
|
|
}
|
|
res := h.ask("rate2.example.test.", dns.TypeA)
|
|
requireRcode(t, res, dns.RcodeRefused)
|
|
requireAction(t, res, accesslog.ActionRefused)
|
|
}
|
|
|
|
// TestGroupDomainPolicy pins the per-group allow/deny lists, which run after
|
|
// the rate limit and before the local zone.
|
|
func TestGroupDomainPolicy(t *testing.T) {
|
|
t.Run("deny list", func(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) {
|
|
// "**." is the spelling that covers a domain and its
|
|
// subdomains; a bare name is exact (see acl.NewDomainSet).
|
|
*a = loopbackGroup(config.Group{DenyDomains: []string{"**.denied.example.test."}})
|
|
},
|
|
})
|
|
res := h.ask("denied.example.test.", dns.TypeA)
|
|
requireAction(t, res, accesslog.ActionBlock)
|
|
if res.BlockSource != "acl:test" {
|
|
t.Fatalf("block source = %q, want acl:test", res.BlockSource)
|
|
}
|
|
// A subdomain of a denied name is denied too.
|
|
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != accesslog.ActionBlock {
|
|
t.Fatalf("subdomain action = %q, want block", sub.Action)
|
|
}
|
|
if sib := h.ask("sibling.example.test.", dns.TypeA); sib.Action != accesslog.ActionAllow {
|
|
t.Fatalf("sibling action = %q, want allow: the deny list leaked", sib.Action)
|
|
}
|
|
allowed := h.ask("allowed.example.test.", dns.TypeA)
|
|
requireAction(t, allowed, accesslog.ActionAllow)
|
|
if got := answerA(allowed.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("answer = %q, want 203.0.113.7", got)
|
|
}
|
|
})
|
|
|
|
t.Run("exact deny is exact", func(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) {
|
|
*a = loopbackGroup(config.Group{DenyDomains: []string{"denied.example.test."}})
|
|
},
|
|
})
|
|
if res := h.ask("denied.example.test.", dns.TypeA); res.Action != accesslog.ActionBlock {
|
|
t.Fatalf("exact name: action = %q, want block", res.Action)
|
|
}
|
|
// Documented behaviour of a rule without a wildcard: the apex only.
|
|
if sub := h.ask("sub.denied.example.test.", dns.TypeA); sub.Action != accesslog.ActionAllow {
|
|
t.Fatalf("subdomain of an exact rule: action = %q, want allow", sub.Action)
|
|
}
|
|
})
|
|
|
|
t.Run("allow list is exclusive", func(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) {
|
|
*a = loopbackGroup(config.Group{AllowDomains: []string{"only.example.test."}})
|
|
},
|
|
})
|
|
if res := h.ask("other.example.test.", dns.TypeA); res.Action != accesslog.ActionBlock {
|
|
t.Fatalf("non-allowlisted name: action = %q, want block", res.Action)
|
|
}
|
|
allowed := h.ask("only.example.test.", dns.TypeA)
|
|
requireAction(t, allowed, accesslog.ActionAllow)
|
|
if got := answerA(allowed.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("answer = %q, want 203.0.113.7", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSkipToggles pins the two per-group bypasses. Both are security-relevant
|
|
// switches, so a test that pins them must fail if the flag stops working.
|
|
func TestSkipToggles(t *testing.T) {
|
|
t.Run("skip blocklist", func(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{SkipBlocklist: true}) },
|
|
rules: []blocklist.Rule{{Name: "blocked.example.test"}},
|
|
})
|
|
res := h.ask("blocked.example.test.", dns.TypeA)
|
|
requireAction(t, res, accesslog.ActionAllow)
|
|
if got := answerA(res.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("answer = %q, want 203.0.113.7 (the blocklist still applies)", got)
|
|
}
|
|
})
|
|
|
|
t.Run("skip cache", func(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{SkipCache: true}) },
|
|
})
|
|
for i := 0; i < 2; i++ {
|
|
res := h.ask("nocache.example.test.", dns.TypeA)
|
|
requireAction(t, res, accesslog.ActionAllow)
|
|
if res.CacheKind != "" {
|
|
t.Fatalf("query %d: cache kind = %q, want none", i, res.CacheKind)
|
|
}
|
|
}
|
|
if n := h.up.hitCount("nocache.example.test."); n != 2 {
|
|
t.Fatalf("upstream saw %d queries, want 2: the cache was used", n)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCacheHitDoesNotReachUpstream pins the cache fast path.
|
|
func TestCacheHitDoesNotReachUpstream(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
first := h.ask("cached.example.test.", dns.TypeA)
|
|
requireAction(t, first, accesslog.ActionAllow)
|
|
if first.CacheHit {
|
|
t.Fatal("the first query reported a cache hit")
|
|
}
|
|
if n := h.up.hitCount("cached.example.test."); n != 1 {
|
|
t.Fatalf("upstream saw %d queries, want 1", n)
|
|
}
|
|
|
|
second := h.ask("cached.example.test.", dns.TypeA)
|
|
if !second.CacheHit || second.CacheKind != "hit" {
|
|
t.Fatalf("second query: hit=%v kind=%q, want a fresh hit", second.CacheHit, second.CacheKind)
|
|
}
|
|
requireAction(t, second, accesslog.ActionCached)
|
|
if got := answerA(second.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("answer = %q, want 203.0.113.7", got)
|
|
}
|
|
if ttl := firstTTL(second.Msg); ttl == 0 || ttl > 60 {
|
|
t.Fatalf("cached TTL = %d, want 1..60", ttl)
|
|
}
|
|
if second.Upstream == "" {
|
|
t.Fatal("the cached answer lost its upstream attribution")
|
|
}
|
|
if n := h.up.hitCount("cached.example.test."); n != 1 {
|
|
t.Fatalf("upstream saw %d queries after a cache hit, want 1", n)
|
|
}
|
|
}
|
|
|
|
// TestServeStaleAgesToOneAndRefreshesOnce pins serve-stale: the stale answer
|
|
// is served with TTL 1, and the background refresh is single-flight however
|
|
// many queries pile up while it is running.
|
|
func TestServeStaleAgesToOneAndRefreshesOnce(t *testing.T) {
|
|
h := newHarness(t, hopt{serveStale: 10 * time.Minute, clock: true})
|
|
|
|
first := h.ask("stale.example.test.", dns.TypeA)
|
|
requireAction(t, first, accesslog.ActionAllow)
|
|
|
|
// Expire the entry: the upstream TTL is 60s.
|
|
h.advance(61 * time.Second)
|
|
|
|
open := h.up.hold()
|
|
stale := h.ask("stale.example.test.", dns.TypeA)
|
|
if !stale.CacheHit || stale.CacheKind != "stale" {
|
|
t.Fatalf("hit=%v kind=%q, want a stale hit", stale.CacheHit, stale.CacheKind)
|
|
}
|
|
if got := answerA(stale.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("stale answer = %q, want 203.0.113.7", got)
|
|
}
|
|
if ttl := firstTTL(stale.Msg); ttl != 1 {
|
|
t.Fatalf("stale TTL = %d, want 1: a stale answer must not be cached by the client", ttl)
|
|
}
|
|
|
|
// The prefetch is now parked in the upstream. More stale queries must not
|
|
// start a second one.
|
|
h.up.waitArrived(t)
|
|
for i := 0; i < 3; i++ {
|
|
if res := h.ask("stale.example.test.", dns.TypeA); res.CacheKind != "stale" {
|
|
t.Fatalf("query %d during refresh: cache kind = %q, want stale", i, res.CacheKind)
|
|
}
|
|
}
|
|
// Give a second prefetch, if one were wrongly launched, time to arrive.
|
|
time.Sleep(250 * time.Millisecond)
|
|
if n := h.up.hitCount("stale.example.test."); n != 2 {
|
|
t.Fatalf("upstream saw %d queries, want 2 (one resolve, one refresh)", n)
|
|
}
|
|
|
|
open()
|
|
// After the refresh lands the entry is fresh again.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
if res := h.ask("stale.example.test.", dns.TypeA); res.CacheKind == "hit" {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the entry never came back fresh after the background refresh")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// TestTruncationAgainstClientBuffer pins RFC 2181 truncation on the uncached
|
|
// path: a 4 KiB answer over a 512-byte client buffer comes back with TC and no
|
|
// records, and the same answer is delivered in full over TCP or with a large
|
|
// EDNS0 buffer.
|
|
func TestTruncationAgainstClientBuffer(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
|
|
small := h.ask("big.test.", dns.TypeTXT)
|
|
if small.Msg == nil || !small.Msg.Truncated {
|
|
t.Fatal("a 4 KiB answer over a 512-byte buffer was not truncated")
|
|
}
|
|
if len(small.Msg.Answer) != 0 {
|
|
t.Fatalf("truncated answer carries %d records, want none", len(small.Msg.Answer))
|
|
}
|
|
if small.Msg.Rcode != dns.RcodeSuccess {
|
|
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[small.Msg.Rcode])
|
|
}
|
|
|
|
// A different name, so the 4 KiB answer in the cache is not the one being
|
|
// served: TCP clients are not size-limited.
|
|
overTCP := h.askTCP("big2.test.", dns.TypeTXT)
|
|
if overTCP.Msg == nil || overTCP.Msg.Truncated {
|
|
t.Fatal("a TCP client got a truncated answer")
|
|
}
|
|
if firstTTL(overTCP.Msg) == 0 {
|
|
t.Fatal("the TCP answer has no records")
|
|
}
|
|
|
|
// A large advertised EDNS0 buffer gets the whole thing too.
|
|
edns := h.askEDNS("big3.test.", dns.TypeTXT, 4096)
|
|
if edns.Msg == nil || edns.Msg.Truncated {
|
|
t.Fatal("a 4096-byte EDNS0 client got a truncated answer")
|
|
}
|
|
if len(edns.Msg.Answer) == 0 {
|
|
t.Fatal("the EDNS0 answer has no records")
|
|
}
|
|
if b, err := edns.Msg.Pack(); err != nil || len(b) > 4096 {
|
|
t.Fatalf("packed size = %d (err %v), want <= 4096", len(b), err)
|
|
}
|
|
}
|
|
|
|
// TestSmallClientDoesNotPoisonTheCache pins the reason the engine caches the
|
|
// untruncated upstream answer: a 512-byte client must not leave a truncated
|
|
// copy in the cache for every later client.
|
|
func TestSmallClientDoesNotPoisonTheCache(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
|
|
small := h.ask("big.test.", dns.TypeTXT)
|
|
if small.Msg == nil || !small.Msg.Truncated {
|
|
t.Fatal("precondition: the 512-byte client was not truncated")
|
|
}
|
|
full := h.askEDNS("big.test.", dns.TypeTXT, 4096)
|
|
if full.Msg == nil || full.Msg.Truncated {
|
|
t.Fatal("a large-buffer client got a truncated answer: the cache entry was poisoned")
|
|
}
|
|
if len(full.Msg.Answer) == 0 {
|
|
t.Fatal("the cached answer has no records")
|
|
}
|
|
if n := h.up.hitCount("big.test."); n != 1 {
|
|
t.Fatalf("upstream saw %d queries, want 1: the second query was not served from cache", n)
|
|
}
|
|
}
|
|
|
|
// TestTruncatedUpstreamAnswerIsNotCached pins the other half of the rule: an
|
|
// answer the upstream flagged TC is never stored, because it would be served
|
|
// truncated forever.
|
|
func TestTruncatedUpstreamAnswerIsNotCached(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
for i := 0; i < 2; i++ {
|
|
res := h.ask("tc.test.", dns.TypeA)
|
|
if res.CacheHit {
|
|
t.Fatalf("query %d was served from cache: a TC answer was cached", i)
|
|
}
|
|
}
|
|
if n := h.up.hitCount("tc.test."); n != 2 {
|
|
t.Fatalf("upstream saw %d queries, want 2", n)
|
|
}
|
|
}
|
|
|
|
// TestMalformedQueriesAreFormErr pins the validation step, which runs before
|
|
// the ACL: a query with no question, two questions, or the response bit set is
|
|
// FORMERR, and still counts as a query.
|
|
func TestMalformedQueriesAreFormErr(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{Policy: "deny"}) },
|
|
})
|
|
|
|
twoQuestions := new(dns.Msg)
|
|
twoQuestions.SetQuestion("a.example.test.", dns.TypeA)
|
|
twoQuestions.Question = append(twoQuestions.Question, dns.Question{Name: "b.example.test.", Qtype: dns.TypeA, Qclass: dns.ClassINET})
|
|
|
|
alreadyResponse := new(dns.Msg)
|
|
alreadyResponse.SetQuestion("a.example.test.", dns.TypeA)
|
|
alreadyResponse.Response = true
|
|
|
|
empty := new(dns.Msg)
|
|
|
|
for name, q := range map[string]*dns.Msg{"no question": empty, "two questions": twoQuestions, "response bit set": alreadyResponse} {
|
|
before := h.totals()
|
|
res := h.askWith(Request{Msg: q, Remote: h.request("a.example.test.", dns.TypeA).Remote, Transport: "udp"})
|
|
requireRcode(t, res, dns.RcodeFormatError)
|
|
requireAction(t, res, accesslog.ActionError)
|
|
if got := h.totals().Queries - before.Queries; got != 1 {
|
|
t.Fatalf("%s: metrics recorded %d queries, want 1", name, got)
|
|
}
|
|
}
|
|
if h.up.totalHits() != 0 {
|
|
t.Fatal("a malformed query reached the upstream")
|
|
}
|
|
}
|
|
|
|
// TestMetricsAndQueryLogFireOncePerQuery pins the hook contract: every query
|
|
// that reaches Handle is counted exactly once and logged exactly once,
|
|
// including the ones refused before resolution.
|
|
func TestMetricsAndQueryLogFireOncePerQuery(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
opt hopt
|
|
// queries is how many queries the case sends: every one of them must
|
|
// be counted exactly once.
|
|
queries int
|
|
send func(*harness)
|
|
want accesslog.Action
|
|
}{
|
|
{
|
|
name: "upstream answer",
|
|
opt: hopt{},
|
|
queries: 1,
|
|
send: func(h *harness) { h.ask("once.example.test.", dns.TypeA) },
|
|
want: accesslog.ActionAllow,
|
|
},
|
|
{
|
|
name: "cache hit",
|
|
opt: hopt{},
|
|
queries: 2,
|
|
send: func(h *harness) {
|
|
h.ask("once.example.test.", dns.TypeA)
|
|
h.ask("once.example.test.", dns.TypeA)
|
|
},
|
|
want: accesslog.ActionCached,
|
|
},
|
|
{
|
|
name: "blocked",
|
|
opt: hopt{rules: []blocklist.Rule{{Name: "blocked.example.test"}}},
|
|
queries: 1,
|
|
send: func(h *harness) { h.ask("blocked.example.test.", dns.TypeA) },
|
|
want: accesslog.ActionBlock,
|
|
},
|
|
{
|
|
name: "refused",
|
|
opt: hopt{acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{Policy: "deny"}) }},
|
|
queries: 1,
|
|
send: func(h *harness) {
|
|
h.ask("denied.example.test.", dns.TypeA)
|
|
},
|
|
want: accesslog.ActionRefused,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newHarness(t, tc.opt)
|
|
before := h.totals()
|
|
beforeLog := h.logged()
|
|
tc.send(h)
|
|
after := h.totals()
|
|
if got := after.Queries - before.Queries; got != uint64(tc.queries) {
|
|
t.Fatalf("metrics counted %d queries, want exactly %d", got, tc.queries)
|
|
}
|
|
if got := h.logged() - beforeLog; got != tc.queries {
|
|
t.Fatalf("access log holds %d new entries, want exactly %d", got, tc.queries)
|
|
}
|
|
entries := h.entries()
|
|
if len(entries) == 0 {
|
|
t.Fatal("the newest entry is missing from the access log")
|
|
}
|
|
e := entries[0]
|
|
if e.Action != tc.want {
|
|
t.Fatalf("logged action = %q, want %q", e.Action, tc.want)
|
|
}
|
|
if e.Client != "127.0.0.1" {
|
|
t.Fatalf("logged client = %q, want 127.0.0.1", e.Client)
|
|
}
|
|
if e.Protocol != "udp" {
|
|
t.Fatalf("logged protocol = %q, want udp", e.Protocol)
|
|
}
|
|
if len(entries) != tc.queries {
|
|
t.Fatalf("the access log holds %d entries, want %d: the hook fired more than once per query",
|
|
len(entries), tc.queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNoUpstreamIsServfail pins the degenerate wiring: a pool-less engine
|
|
// answers SERVFAIL rather than panicking or hanging.
|
|
func TestNoUpstreamIsServfail(t *testing.T) {
|
|
h := newHarness(t, hopt{noPool: true})
|
|
res := h.ask("nowhere.example.test.", dns.TypeA)
|
|
requireRcode(t, res, dns.RcodeServerFailure)
|
|
requireAction(t, res, accesslog.ActionError)
|
|
}
|
|
|
|
// TestDeadUpstreamIsServfail pins upstream failure handling: the exchange
|
|
// error becomes a SERVFAIL to the client and an error in the metrics.
|
|
func TestDeadUpstreamIsServfail(t *testing.T) {
|
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("reserve port: %v", err)
|
|
}
|
|
dead := pc.LocalAddr().String()
|
|
_ = pc.Close()
|
|
|
|
h := newHarness(t, hopt{
|
|
timeout: 500 * time.Millisecond,
|
|
upstreams: []config.UpstreamServer{{Addr: dead, Proto: "udp", Weight: 1}},
|
|
})
|
|
before := h.totals()
|
|
res := h.ask("dead.example.test.", dns.TypeA)
|
|
requireRcode(t, res, dns.RcodeServerFailure)
|
|
requireAction(t, res, accesslog.ActionError)
|
|
if got := h.totals().Errors - before.Errors; got != 1 {
|
|
t.Fatalf("errors rose by %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestForwardedQueryShape pins what goes upstream: our own OPT (so an
|
|
// upstream can answer EDNS0), the client's ECS forwarded verbatim, our own
|
|
// NSID rather than the client's, and never the client's AD bit.
|
|
func TestForwardedQueryShape(t *testing.T) {
|
|
t.Run("plain query", func(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
h.ask("plain.example.test.", dns.TypeA)
|
|
got := h.up.received()
|
|
if len(got) != 1 {
|
|
t.Fatalf("upstream saw %d queries, want 1", len(got))
|
|
}
|
|
if got[0].IsEdns0() == nil {
|
|
t.Fatal("the forwarded query has no OPT record")
|
|
}
|
|
if opt := findOption(got[0], dns.EDNS0NSID); opt != nil {
|
|
t.Fatal("we leaked our own NSID to a client that did not ask for one")
|
|
}
|
|
if !got[0].RecursionDesired {
|
|
t.Fatal("the forwarded query lost RD")
|
|
}
|
|
})
|
|
|
|
t.Run("client subnet is forwarded", func(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
req := h.request("ecs.example.test.", dns.TypeA)
|
|
req.Msg.SetEdns0(1232, false)
|
|
sub := &dns.EDNS0_SUBNET{Code: dns.EDNS0SUBNET, Family: 1, SourceNetmask: 24, Address: net.IP{203, 0, 113, 0}}
|
|
req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option, sub)
|
|
h.askWith(req)
|
|
got := h.up.received()
|
|
if len(got) != 1 {
|
|
t.Fatalf("upstream saw %d queries, want 1", len(got))
|
|
}
|
|
opt := findOption(got[0], dns.EDNS0SUBNET)
|
|
if opt == nil {
|
|
t.Fatal("the client's EDNS0 client subnet was not forwarded")
|
|
}
|
|
fwd, ok := opt.(*dns.EDNS0_SUBNET)
|
|
if !ok || !fwd.Address.Equal(sub.Address) || fwd.SourceNetmask != 24 {
|
|
t.Fatalf("forwarded subnet = %+v, want %+v", fwd, sub)
|
|
}
|
|
})
|
|
|
|
// The NSID branch of prepareUpstream is covered in pins_test.go: the
|
|
// option is hex on the wire (dns.EDNS0_NSID.Nsid is a hex string), a
|
|
// plain string there does not pack, and a client that asks for NSID
|
|
// used to get SERVFAIL for exactly that reason.
|
|
}
|
|
|
|
func findOption(m *dns.Msg, code uint16) dns.EDNS0 {
|
|
opt := m.IsEdns0()
|
|
if opt == nil {
|
|
return nil
|
|
}
|
|
for _, o := range opt.Option {
|
|
if o.Option() == code {
|
|
return o
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestReplyEchoesTheClientQuestionAndID pins the wire contract every resolver
|
|
// owes a client: the reply carries the client's question verbatim (including
|
|
// its case, for 0x20 style checks) and the client's transaction ID.
|
|
func TestReplyEchoesTheClientQuestionAndID(t *testing.T) {
|
|
h := newHarness(t, hopt{})
|
|
req := h.request("HoSt.ExAmPlE.TeSt.", dns.TypeA)
|
|
req.Msg.Id = 0x1234
|
|
res := h.askWith(req)
|
|
requireAction(t, res, accesslog.ActionAllow)
|
|
if res.Msg.Id != 0x1234 {
|
|
t.Fatalf("reply id = %#x, want 0x1234", res.Msg.Id)
|
|
}
|
|
if len(res.Msg.Question) != 1 {
|
|
t.Fatalf("reply carries %d questions, want 1", len(res.Msg.Question))
|
|
}
|
|
if got := res.Msg.Question[0].Name; got != "HoSt.ExAmPlE.TeSt." {
|
|
t.Fatalf("reply question = %q, want the client's name verbatim", got)
|
|
}
|
|
if got := answerA(res.Msg); got != "203.0.113.7" {
|
|
t.Fatalf("answer = %q, want 203.0.113.7", got)
|
|
}
|
|
}
|
|
|
|
// TestGroupStatsCountEveryQuery pins the per-group counters the UI reads.
|
|
func TestGroupStatsCountEveryQuery(t *testing.T) {
|
|
h := newHarness(t, hopt{
|
|
acl: func(a *config.ACL) { *a = loopbackGroup(config.Group{}) },
|
|
rules: []blocklist.Rule{{Name: "blocked.example.test"}},
|
|
})
|
|
h.ask("stats.example.test.", dns.TypeA)
|
|
h.ask("blocked.example.test.", dns.TypeA)
|
|
h.ask("blocked.example.test.", dns.TypeA)
|
|
|
|
var found *acl.GroupSummary
|
|
for i, g := range h.eng.GroupStats() {
|
|
if g.Name == "test" {
|
|
found = &h.eng.GroupStats()[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("group %q missing from the stats table: %+v", "test", h.eng.GroupStats())
|
|
}
|
|
if found.QueryCount != 3 {
|
|
t.Fatalf("query count = %d, want 3", found.QueryCount)
|
|
}
|
|
if found.BlockedCount != 2 {
|
|
t.Fatalf("blocked count = %d, want 2", found.BlockedCount)
|
|
}
|
|
}
|