package ptr import ( "database/sql" "errors" "io" "log/slog" "net/http" "net/http/httptest" "slices" "sync" "testing" "time" "git.eeqj.de/sneak/routewatch/internal/cachedb" "github.com/miekg/dns" ) const ( testAddress = "192.0.2.1" testReverse = "1.2.0.192.in-addr.arpa." testName = "host.example.com." otherName = "other.example.com." testV6Address = "2001:db8::1" testV6Reverse = "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0." + "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa." // timingAllowance is how far past its limit a loaded machine may run a // lookup that has a time limit. timingAllowance = 8 * time.Second ) // classlessOwner is the name that holds the PTR records of a CNAME chain. const classlessOwner = "1.0-25.2.0.192.in-addr.arpa." // fixedTime returns the time a test's clock starts at. func fixedTime() time.Time { return time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) } // fakeResolver is a DNS-over-HTTPS server. It answers each query with reply's // result, and counts its requests. type fakeResolver struct { server *httptest.Server mu sync.Mutex requests int reply func(http.ResponseWriter, *http.Request, *dns.Msg) } // newFakeResolver starts a fake resolver that answers with reply. func newFakeResolver( t *testing.T, reply func(http.ResponseWriter, *http.Request, *dns.Msg), ) *fakeResolver { t.Helper() resolver := &fakeResolver{reply: reply} resolver.server = httptest.NewServer(http.HandlerFunc(resolver.serve)) t.Cleanup(resolver.server.Close) return resolver } // serve checks that the request is RFC 8484 wire format and passes the // question on to the current reply. func (f *fakeResolver) serve(w http.ResponseWriter, r *http.Request) { f.mu.Lock() f.requests++ reply := f.reply f.mu.Unlock() body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var query dns.Msg err = query.Unpack(body) if err != nil || r.Header.Get("Content-Type") != dnsMessageType || r.Header.Get("Accept") != dnsMessageType || r.Method != http.MethodPost { http.Error(w, "not an RFC 8484 request", http.StatusBadRequest) return } reply(w, r, &query) } func (f *fakeResolver) setReply( reply func(http.ResponseWriter, *http.Request, *dns.Msg), ) { f.mu.Lock() defer f.mu.Unlock() f.reply = reply } func (f *fakeResolver) requestCount() int { f.mu.Lock() defer f.mu.Unlock() return f.requests } // writeReply packs reply and writes it as the answer to query. func writeReply(w http.ResponseWriter, query, reply *dns.Msg) { reply.SetReply(query) wire, err := reply.Pack() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", dnsMessageType) _, _ = w.Write(wire) } // ptrReply answers with one PTR record for each name, each with the TTL ttl. func ptrReply( ttl uint32, names ...string, ) func(http.ResponseWriter, *http.Request, *dns.Msg) { return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) { reply := new(dns.Msg) for _, name := range names { reply.Answer = append(reply.Answer, &dns.PTR{ Hdr: dns.RR_Header{ Name: query.Question[0].Name, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: ttl, }, Ptr: name, }) } writeReply(w, query, reply) } } // rcodeReply answers with the response code and nothing else. func rcodeReply(rcode int) func(http.ResponseWriter, *http.Request, *dns.Msg) { return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) { reply := new(dns.Msg) reply.SetRcode(query, rcode) wire, err := reply.Pack() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _, _ = w.Write(wire) } } // soaReply answers with the response code and a SOA record with these TTLs. func soaReply( rcode int, soaTTL, minimum uint32, ) func(http.ResponseWriter, *http.Request, *dns.Msg) { return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) { reply := new(dns.Msg) reply.SetRcode(query, rcode) reply.Ns = []dns.RR{&dns.SOA{ Hdr: dns.RR_Header{ Name: "arpa.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: soaTTL, }, Ns: "ns.example.com.", Mbox: "hostmaster.example.com.", Minttl: minimum, }} wire, err := reply.Pack() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _, _ = w.Write(wire) } } // hangingReply never answers: it waits until the client gives up. func hangingReply(_ http.ResponseWriter, r *http.Request, _ *dns.Msg) { <-r.Context().Done() } // statusReply answers with an HTTP status. func statusReply(status int) func(http.ResponseWriter, *http.Request, *dns.Msg) { return func(w http.ResponseWriter, _ *http.Request, _ *dns.Msg) { w.WriteHeader(status) } } // openTestDB opens a cache.db in dir. func openTestDB(t *testing.T, dir string) *sql.DB { t.Helper() db, err := cachedb.Open(t.Context(), dir) if err != nil { t.Fatalf("opening the cache database: %v", err) } t.Cleanup(func() { _ = db.Close() }) return db } // testClock is a clock a test moves by hand. type testClock struct { mu sync.Mutex now time.Time } func (c *testClock) time() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now } func (c *testClock) advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.now = c.now.Add(d) } // newTestCache returns a Cache that asks the resolvers in order, reads the // time from a clock the test holds, and is not started. func newTestCache( t *testing.T, db *sql.DB, resolvers ...*fakeResolver, ) (*Cache, *testClock) { t.Helper() urls := make([]string, 0, len(resolvers)) for _, resolver := range resolvers { urls = append(urls, resolver.server.URL) } cache := New(Params{DB: db, Resolvers: urls, Logger: slog.Default()}) clock := &testClock{now: fixedTime()} cache.now = clock.time return cache, clock } // lookupOK looks up address and fails the test on an error. func lookupOK(t *testing.T, cache *Cache, address string) []string { t.Helper() names, err := cache.LookupAddr(t.Context(), address) if err != nil { t.Fatalf("LookupAddr(%s): %v", address, err) } return names } func countRows(t *testing.T, db *sql.DB) int { t.Helper() var rows int err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM hostnames").Scan(&rows) if err != nil { t.Fatalf("counting rows: %v", err) } return rows } func TestLookupAsksForTheReverseName(t *testing.T) { t.Parallel() tests := []struct { address string want string }{ {testAddress, testReverse}, {"::ffff:" + testAddress, testReverse}, {testV6Address, testV6Reverse}, } for _, tt := range tests { var asked string resolver := newFakeResolver(t, func(w http.ResponseWriter, r *http.Request, query *dns.Msg) { asked = query.Question[0].Name if query.Question[0].Qtype != dns.TypePTR { t.Errorf("query type %d, want PTR", query.Question[0].Qtype) } ptrReply(300, testName)(w, r, query) }) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) names := lookupOK(t, cache, tt.address) if !slices.Equal(names, []string{testName}) { t.Errorf("%s: names %v, want %s", tt.address, names, testName) } if asked != tt.want { t.Errorf("%s: asked for %s, want %s", tt.address, asked, tt.want) } } } // TestResolversAreTriedInOrderUntilOneAnswers has the first two resolvers fail, // in different ways, and the third answer. The fourth is not asked. func TestResolversAreTriedInOrderUntilOneAnswers(t *testing.T) { t.Parallel() var ( mu sync.Mutex order []int ) asked := func( index int, reply func(http.ResponseWriter, *http.Request, *dns.Msg), ) func(http.ResponseWriter, *http.Request, *dns.Msg) { return func(w http.ResponseWriter, r *http.Request, query *dns.Msg) { mu.Lock() order = append(order, index) mu.Unlock() reply(w, r, query) } } resolvers := []*fakeResolver{ newFakeResolver(t, asked(0, statusReply(http.StatusServiceUnavailable))), newFakeResolver(t, asked(1, rcodeReply(dns.RcodeServerFailure))), newFakeResolver(t, asked(2, ptrReply(300, testName))), newFakeResolver(t, asked(3, ptrReply(300, otherName))), } cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolvers...) names := lookupOK(t, cache, testAddress) if !slices.Equal(names, []string{testName}) { t.Errorf("names %v, want %s", names, testName) } if !slices.Equal(order, []int{0, 1, 2}) { t.Errorf("resolvers asked %v, want [0 1 2]", order) } } // TestResolverThatNeverAnswersLeavesTimeForTheNext would fail if the first // resolver could take the whole time limit. func TestResolverThatNeverAnswersLeavesTimeForTheNext(t *testing.T) { t.Parallel() cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), newFakeResolver(t, hangingReply), newFakeResolver(t, ptrReply(300, testName))) names := lookupOK(t, cache, testAddress) if !slices.Equal(names, []string{testName}) { t.Errorf("names %v, want %s", names, testName) } } // TestLookupStopsAtTheTimeLimit has no resolver answer. The lookup fails once // LookupTimeout has passed, having asked every resolver. func TestLookupStopsAtTheTimeLimit(t *testing.T) { t.Parallel() resolvers := []*fakeResolver{ newFakeResolver(t, hangingReply), newFakeResolver(t, hangingReply), newFakeResolver(t, hangingReply), newFakeResolver(t, hangingReply), } cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolvers...) start := time.Now() _, err := cache.LookupAddr(t.Context(), testAddress) elapsed := time.Since(start) if err == nil { t.Error("LookupAddr succeeded with no resolver answering") } if elapsed < LookupTimeout || elapsed > LookupTimeout+timingAllowance { t.Errorf("took %s, want about %s", elapsed, LookupTimeout) } for i, resolver := range resolvers { if resolver.requestCount() != 1 { t.Errorf("resolver %d asked %d times, want once", i, resolver.requestCount()) } } } // TestAnswerIsServedFromMemoryUntilItExpires makes a second lookup within the // TTL, which asks nobody, and a third after it, which asks again. func TestAnswerIsServedFromMemoryUntilItExpires(t *testing.T) { t.Parallel() resolver := newFakeResolver(t, ptrReply(300, testName)) cache, clock := newTestCache(t, openTestDB(t, t.TempDir()), resolver) lookupOK(t, cache, testAddress) names := lookupOK(t, cache, testAddress) if !slices.Equal(names, []string{testName}) { t.Errorf("cached names %v, want %s", names, testName) } if got := resolver.requestCount(); got != 1 { t.Errorf("%d requests after the second lookup, want 1", got) } clock.advance(300 * time.Second) lookupOK(t, cache, testAddress) if got := resolver.requestCount(); got != 2 { t.Errorf("%d requests after the TTL passed, want 2", got) } } // TestAnAddressHasOneCacheEntryHoweverItIsWritten looks up an IPv4 address in // its IPv4-mapped IPv6 form, then in its usual form. func TestAnAddressHasOneCacheEntryHoweverItIsWritten(t *testing.T) { t.Parallel() resolver := newFakeResolver(t, ptrReply(300, testName)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) lookupOK(t, cache, "::ffff:"+testAddress) lookupOK(t, cache, testAddress) if got := resolver.requestCount(); got != 1 { t.Errorf("%d requests, want 1", got) } } // TestCallerCannotChangeACachedAnswer changes the list a lookup returned, as // the server does when it removes the trailing dots. func TestCallerCannotChangeACachedAnswer(t *testing.T) { t.Parallel() resolver := newFakeResolver(t, ptrReply(300, testName)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) for range 2 { names := lookupOK(t, cache, testAddress) if !slices.Equal(names, []string{testName}) { t.Errorf("names %v, want %s", names, testName) } names[0] = "changed" } } func TestTTLIsClamped(t *testing.T) { t.Parallel() tests := []struct { name string ttl uint32 want time.Duration }{ {"below the minimum", 1, time.Minute}, {"zero", 0, time.Minute}, {"at the minimum", 60, time.Minute}, {"in range", 300, 300 * time.Second}, {"at the maximum", 86400, 24 * time.Hour}, {"above the maximum", 604800, 24 * time.Hour}, } for _, tt := range tests { resolver := newFakeResolver(t, ptrReply(tt.ttl, testName)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) lookupOK(t, cache, testAddress) got := cache.entries[testAddress].expires.Sub(fixedTime()) if got != tt.want { t.Errorf("%s: kept for %s, want %s", tt.name, got, tt.want) } } } // TestSmallestTTLOfTheChainCounts answers with a CNAME record and two PTR // records, whose TTLs all differ. func TestSmallestTTLOfTheChainCounts(t *testing.T) { t.Parallel() reply := func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) { owner := query.Question[0].Name answer := new(dns.Msg) answer.Answer = []dns.RR{ &dns.CNAME{ Hdr: dns.RR_Header{ Name: owner, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 200, }, Target: classlessOwner, }, &dns.PTR{ Hdr: dns.RR_Header{ Name: classlessOwner, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 900, }, Ptr: testName, }, &dns.PTR{ Hdr: dns.RR_Header{ Name: classlessOwner, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 600, }, Ptr: otherName, }, } writeReply(w, query, answer) } cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), newFakeResolver(t, reply)) names := lookupOK(t, cache, testAddress) if !slices.Equal(names, []string{testName, otherName}) { t.Errorf("names %v, want both PTR names", names) } got := cache.entries[testAddress].expires.Sub(fixedTime()) if got != 200*time.Second { t.Errorf("kept for %s, want 200s", got) } } func TestNegativeTTL(t *testing.T) { t.Parallel() tests := []struct { name string rcode int soaTTL uint32 minimum uint32 want time.Duration }{ {"name does not exist", dns.RcodeNameError, 3600, 300, 300 * time.Second}, {"no PTR record", dns.RcodeSuccess, 3600, 300, 300 * time.Second}, {"limited to an hour", dns.RcodeNameError, 86400, 7200, time.Hour}, {"SOA's own TTL is smaller", dns.RcodeNameError, 100, 300, 100 * time.Second}, } for _, tt := range tests { resolver := newFakeResolver(t, soaReply(tt.rcode, tt.soaTTL, tt.minimum)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) names := lookupOK(t, cache, testAddress) if len(names) != 0 { t.Errorf("%s: names %v, want none", tt.name, names) } got := cache.entries[testAddress].expires.Sub(fixedTime()) if got != tt.want { t.Errorf("%s: kept for %s, want %s", tt.name, got, tt.want) } lookupOK(t, cache, testAddress) if count := resolver.requestCount(); count != 1 { t.Errorf("%s: %d requests, want 1", tt.name, count) } } } // TestAnswerWithoutNamesOrSOAIsNotCached has nothing to say how long the // answer may be kept. func TestAnswerWithoutNamesOrSOAIsNotCached(t *testing.T) { t.Parallel() resolver := newFakeResolver(t, rcodeReply(dns.RcodeNameError)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) lookupOK(t, cache, testAddress) lookupOK(t, cache, testAddress) if got := resolver.requestCount(); got != 2 { t.Errorf("%d requests, want 2", got) } } // TestFailedQueryIsNotCached has the only resolver fail, and then recover. func TestFailedQueryIsNotCached(t *testing.T) { t.Parallel() resolver := newFakeResolver(t, rcodeReply(dns.RcodeServerFailure)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) _, err := cache.LookupAddr(t.Context(), testAddress) if !errors.Is(err, errBadResponse) { t.Fatalf("error %v, want %v", err, errBadResponse) } if len(cache.entries)+len(cache.pending) != 0 { t.Error("a failed query left an entry in the cache") } resolver.setReply(ptrReply(300, testName)) names := lookupOK(t, cache, testAddress) if !slices.Equal(names, []string{testName}) { t.Errorf("names %v, want %s", names, testName) } } // TestNamesThatAreNotHostNamesAreDropped answers with a name that holds markup // beside a good one, as a PTR record can. func TestNamesThatAreNotHostNamesAreDropped(t *testing.T) { t.Parallel() resolver := newFakeResolver(t, ptrReply(300, "bad