package engine import ( "context" "net" "net/netip" "os" "path/filepath" "strings" "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/localzone" "git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics" "git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog" "git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver" ) // fakeUpstream is a UDP resolver that answers every name under .test. with a // fixed address, so a test can tell "resolved upstream" from "answered locally" // without touching the internet. It records the queries it received, which is // how the forwarded-message tests inspect what the engine put on the wire. type fakeUpstream struct { *dns.Server addr string mu sync.Mutex hits map[string]int queries []*dns.Msg // writeErr is the last error the handler's write returned: a fake upstream // that silently fails to pack looks exactly like a client-side timeout. writeErr error // gate, when set, parks handlers until it is closed. arrived is signalled // before parking, so a test can wait for the query to be *inside* the // pipeline rather than guessing with a sleep. gate chan struct{} arrived chan struct{} // release is closed at cleanup so a held gate can never wedge shutdown. release chan struct{} } func newFakeUpstream(t *testing.T) *fakeUpstream { t.Helper() pc, err := net.ListenPacket("udp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } u := &fakeUpstream{ addr: pc.LocalAddr().String(), hits: make(map[string]int), arrived: make(chan struct{}, 64), release: make(chan struct{}), } u.Server = &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(u.handle)} go func() { _ = u.ActivateAndServe() }() t.Cleanup(func() { close(u.release) _ = u.Shutdown() }) return u } func (u *fakeUpstream) handle(w dns.ResponseWriter, q *dns.Msg) { u.mu.Lock() if len(q.Question) > 0 { u.hits[q.Question[0].Name]++ } u.queries = append(u.queries, q.Copy()) u.mu.Unlock() select { case u.arrived <- struct{}{}: default: } if g := u.gateCh(); g != nil { select { case <-g: case <-u.release: } } if len(q.Question) == 0 { return } m := new(dns.Msg) m.SetReply(q) name := q.Question[0].Name switch { case !strings.HasSuffix(strings.ToLower(name), ".test."): m.Rcode = dns.RcodeNameError m.Ns = []dns.RR{testSOA("test.", 60)} default: // A "tc*" name answers *and* sets TC, which is how a real upstream // reports an answer that did not fit; it must not be cached. if strings.HasPrefix(strings.ToLower(name), "tc.") { m.Truncated = true } switch q.Question[0].Qtype { case dns.TypeTXT: // "big*" names answer with a 4 KiB TXT so a 512-byte client // buffer has something to overflow. if strings.HasPrefix(strings.ToLower(name), "big") { // Built as a record rather than parsed, and as many short // strings rather than one long one: this version of miekg/dns // reports a character-string over 255 bytes as zero length, so a // single 4 KiB TXT never packs (a silent write failure that looks // like a client timeout). 15 x 200 lands just over 3 KiB, inside // the 4 KiB buffer the engine advertises, and far outside a // 512-byte client buffer. txt := make([]string, 15) for i := range txt { txt[i] = strings.Repeat("x", 200) } rr := &dns.TXT{ Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, Txt: txt, } m.Answer = append(m.Answer, rr) } else { rr, _ := dns.NewRR(name + " 60 IN A 203.0.113.9") m.Answer = append(m.Answer, rr) } case dns.TypeA: rr, _ := dns.NewRR(name + " 60 IN A 203.0.113.7") m.Answer = append(m.Answer, rr) } m.Ns = []dns.RR{testSOA("test.", 60)} } if err := w.WriteMsg(m); err != nil { u.mu.Lock() u.writeErr = err u.mu.Unlock() } } // lastWriteErr reports the last write failure, or nil. func (u *fakeUpstream) lastWriteErr() error { u.mu.Lock() defer u.mu.Unlock() return u.writeErr } func (u *fakeUpstream) gateCh() chan struct{} { u.mu.Lock() defer u.mu.Unlock() return u.gate } // hold parks every handler until the returned func is called. Handlers that // arrive after it is released are not parked. func (u *fakeUpstream) hold() func() { u.mu.Lock() defer u.mu.Unlock() if u.gate != nil { return func() {} } g := make(chan struct{}) u.gate = g return func() { close(g) } } func (u *fakeUpstream) hitCount(name string) int { u.mu.Lock() defer u.mu.Unlock() return u.hits[dns.Fqdn(name)] } func (u *fakeUpstream) totalHits() int { u.mu.Lock() defer u.mu.Unlock() n := 0 for _, c := range u.hits { n += c } return n } // received returns the queries the upstream saw, in order. func (u *fakeUpstream) received() []*dns.Msg { u.mu.Lock() defer u.mu.Unlock() return append([]*dns.Msg(nil), u.queries...) } // waitArrived blocks until at least one query has reached the handler. func (u *fakeUpstream) waitArrived(t *testing.T) { t.Helper() select { case <-u.arrived: case <-time.After(5 * time.Second): t.Fatal("upstream never received a query") } } // testSOA is the authority record the fake upstream attaches to every answer, // which is what a real resolver does and what makes NXDOMAIN well-formed. func testSOA(name string, ttl uint32) dns.RR { return &dns.SOA{ Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, Ns: "ns.test.", Mbox: "hostmaster.test.", Serial: 1, Refresh: 3600, Retry: 600, Expire: 604800, Minttl: 60, } } // testClock is the injectable time source the cache manager uses. Advancing it // is how a test makes an entry expire without sleeping. type testClock struct { mu sync.Mutex t time.Time } func newTestClock() *testClock { return &testClock{t: time.Now()} } func (c *testClock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.t } func (c *testClock) Advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.t = c.t.Add(d) } // hopt tunes a harness. The zero value is a fully working engine: fake // upstream, local zone, cache, blocklist and query log. type hopt struct { acl func(*config.ACL) blockCfg func(*config.Block) server func(*config.Server) cacheCfg func(*config.Cache) dnsCfg func(*config.DNS) // rules are compiled into the blocklist set the engine holds. rules []blocklist.Rule // localZone is a master file; empty means no local zone at all. localZone string // upstreams replaces the default single fake upstream. A nil slice with // noPool false means "use the fake upstream". upstreams []config.UpstreamServer // noPool wires a nil pool, which is the "no upstream configured" case. noPool bool // serveStale enables the stale window and prefetching. serveStale time.Duration // clock installs the injectable clock into the cache manager. clock bool // timeout is the per-exchange upstream timeout. timeout time.Duration } type harness struct { t *testing.T eng *Engine cfg *config.Config store *config.Store pool *resolver.Pool cache *cache.Manager log *qlog.Logger up *fakeUpstream clock *testClock blocks *blocklist.Holder local *localzone.Holder aclH *acl.Holder } func newHarness(t *testing.T, o hopt) *harness { t.Helper() up := newFakeUpstream(t) timeout := o.timeout if timeout <= 0 { timeout = 5 * time.Second } cfg := config.Default() cfg.Cache.Persist.Enabled = false cfg.Server.Timeouts.Upstre = config.Duration(timeout) // A generous upstream buffer: an upstream cannot send us more than we // advertise, so the truncation tests need room for a 4 KiB answer to // arrive whole and then be trimmed for a small client. cfg.DNS.MaxUDPSize = 4096 cfg.Cache.ServeStale = config.Duration(o.serveStale) cfg.Block.Lists = nil if o.acl != nil { o.acl(&cfg.ACL) } if o.blockCfg != nil { o.blockCfg(&cfg.Block) } if o.server != nil { o.server(&cfg.Server) } if o.cacheCfg != nil { o.cacheCfg(&cfg.Cache) } if o.dnsCfg != nil { o.dnsCfg(&cfg.DNS) } // The default pool is the fake upstream; a dead address is what the // upstream-failure test needs, and noPool needs no pool at all. if !o.noPool { servers := o.upstreams if servers == nil { servers = []config.UpstreamServer{{Addr: up.addr, Proto: "udp", Weight: 1}} } cfg.Upstream.Servers = servers cfg.Upstream.Policy = "parallel" cfg.Upstream.MaxAttempts = 1 cfg.Upstream.HealthCheck = 0 cfg.Upstream.BreakerThreshold = 5 cfg.Upstream.BreakerCooldown = config.Duration(30 * time.Second) } else { cfg.Upstream.Servers = nil } // A local zone file, if asked for. var local *localzone.Store if o.localZone != "" { path := filepath.Join(t.TempDir(), "local.zone") if err := os.WriteFile(path, []byte(o.localZone), 0o600); err != nil { t.Fatalf("write zone: %v", err) } l, err := localzone.Load([]string{path}, localzone.Options{}) if err != nil { t.Fatalf("load zone: %v", err) } local = l } else { l, err := localzone.Load(nil, localzone.Options{}) if err != nil { t.Fatalf("load empty zone set: %v", err) } local = l } clock := newTestClock() cm := cache.NewManager(cache.Options{ Size: 1000, Shards: 4, ServeStale: o.serveStale, Prefetch: true, Now: clock.Now, }, nil) log, err := qlog.New(qlog.Options{ LiveBuffer: 64, SampleRate: 1, // Every action is logged, so a test can count entries without // reasoning about the sampling policy. LogBlocked: true, LogCached: true, LogErrors: true, TopDomains: 16, TopTTL: time.Minute, }) if err != nil { t.Fatalf("qlog: %v", err) } t.Cleanup(func() { _ = log.Close() }) blocks := blocklist.NewHolder(buildSet(o.rules...)) aclH := acl.NewHolder(acl.Compile(cfg.ACL)) localH := localzone.NewHolder(local) store := config.NewStore(cfg, "") eng := New(Options{ Config: store, Logger: log, Blocklists: blocks, Cache: cm, ACL: aclH, Local: localH, }) if !o.noPool { eng.pool.Store(resolver.NewPoolWithUDPSize(cfg.Upstream, cfg.DNS.MaxUDPSize)) } h := &harness{ t: t, eng: eng, cfg: cfg, store: store, cache: cm, log: log, up: up, clock: clock, blocks: blocks, local: localH, aclH: aclH, } if !o.noPool { h.pool = eng.Pool() } return h } func buildSet(rules ...blocklist.Rule) *blocklist.Set { b := blocklist.NewBuilder() for _, r := range rules { b.Add(r, "test", nil) } return b.Build() } // setRules swaps in a new compiled set, as a reload would. func (h *harness) setRules(rules ...blocklist.Rule) { h.blocks.Set(buildSet(rules...)) } func (h *harness) advance(d time.Duration) { h.clock.Advance(d) } // request builds a Request the way the UDP listener would. func (h *harness) request(name string, qtype uint16) Request { q := new(dns.Msg) q.SetQuestion(dns.Fqdn(name), qtype) q.RecursionDesired = true return Request{Msg: q, Remote: netip.MustParseAddr("127.0.0.1"), Transport: "udp", Start: time.Now()} } func (h *harness) ask(name string, qtype uint16) Response { h.t.Helper() return h.eng.Handle(context.Background(), h.request(name, qtype)) } func (h *harness) askWith(req Request) Response { h.t.Helper() return h.eng.Handle(context.Background(), req) } // askEDNS asks with an EDNS0 buffer of the given size. func (h *harness) askEDNS(name string, qtype uint16, size uint16) Response { h.t.Helper() req := h.request(name, qtype) req.Msg.SetEdns0(size, false) return h.askWith(req) } // askTCP asks with the TCP flag set, so the UDP size cap does not apply. func (h *harness) askTCP(name string, qtype uint16) Response { h.t.Helper() req := h.request(name, qtype) req.TCP = true req.Transport = "tcp" return h.askWith(req) } func (h *harness) entries() []qlog.Entry { return h.log.Recent(64) } func (h *harness) logged() int { return h.log.Count() } func (h *harness) totals() metrics.Totals { return metrics.TotalsNow() } // answerA returns the first A record's address, or "" when the answer has no // address record. func answerA(m *dns.Msg) string { if m == nil { return "" } for _, rr := range m.Answer { if a, ok := rr.(*dns.A); ok { return a.A.String() } } return "" } func firstTTL(m *dns.Msg) uint32 { if m == nil || len(m.Answer) == 0 { return 0 } return m.Answer[0].Header().Ttl } // requireRcode fails unless the response carries rcode. func requireRcode(t *testing.T, res Response, rcode int) { t.Helper() if res.Msg == nil { t.Fatalf("no response message (drop=%v)", res.Drop) } if res.Msg.Rcode != rcode { t.Fatalf("rcode = %s, want %s", dns.RcodeToString[res.Msg.Rcode], dns.RcodeToString[rcode]) } } func requireAction(t *testing.T, res Response, want qlog.Action) { t.Helper() if res.Action != want { t.Fatalf("action = %q, want %q", res.Action, want) } } func requireNoUpstreamHit(t *testing.T, h *harness, name string) { t.Helper() if n := h.up.hitCount(name); n != 0 { t.Fatalf("upstream saw %d queries for %s, want 0", n, name) } }