package engine import ( "fmt" "net" "sync" "testing" "time" "github.com/miekg/dns" "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" "git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog" ) // 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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.ActionAllow) } res := h.ask("rate2.example.test.", dns.TypeA) requireRcode(t, res, dns.RcodeRefused) requireAction(t, res, qlog.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, qlog.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 != qlog.ActionBlock { t.Fatalf("subdomain action = %q, want block", sub.Action) } if sib := h.ask("sibling.example.test.", dns.TypeA); sib.Action != qlog.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, qlog.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 != qlog.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 != qlog.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 != qlog.ActionBlock { t.Fatalf("non-allowlisted name: action = %q, want block", res.Action) } allowed := h.ask("only.example.test.", dns.TypeA) requireAction(t, allowed, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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, qlog.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 qlog.Action }{ { name: "upstream answer", opt: hopt{}, queries: 1, send: func(h *harness) { h.ask("once.example.test.", dns.TypeA) }, want: qlog.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: qlog.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: qlog.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: qlog.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("query 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 live ring") } 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 live ring holds %d entries, want %d: the hook fired more than once per query", len(entries), tc.queries) } for i := 0; i+1 < len(entries); i++ { if entries[i].ID-entries[i+1].ID != 1 { t.Fatalf("entries %d and %d are not adjacent (ids %d, %d): the hook fired more than once per query", i+1, i, entries[i+1].ID, entries[i].ID) } } }) } } // 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, qlog.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, qlog.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 deliberately not pinned here: // miekg/dns v1.1.73 reports an EDNS0_NSID option as zero length, so any // message carrying one fails to pack ("dns: overflow packing opt") and a // client that asks for NSID gets SERVFAIL. That is a library defect, not // a decision this package owns; see STATUS.md. } 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, qlog.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) } }