package ptr import ( "context" "database/sql" "errors" "io" "log/slog" "net/http" "net/http/httptest" "slices" "strconv" "sync" "testing" "testing/synctest" "time" "git.eeqj.de/sneak/routewatch/internal/cachedb" "github.com/miekg/dns" ) // These addresses are public, because a reserved address is not sent to the // resolvers. That rules out the documentation ranges. const ( testAddress = "8.8.4.4" testReverse = "4.4.8.8.in-addr.arpa." mappedAddress = "::ffff:" + testAddress otherAddress = "8.8.8.8" testV6Address = "2606:4700:4700::1111" testV6Reverse = "1.1.1.1.0.0.0.0.0.0.0.0.0.0.0.0." + "0.0.0.0.0.0.7.4.0.0.7.4.6.0.6.2.ip6.arpa." ) const ( testName = "host.example.com." otherName = "other.example.com." // testTimeLimit replaces LookupTimeout in the tests that use a real // server. A stall on a busy host cannot use it up, and no test waits for // it. testTimeLimit = 2 * time.Minute ) // classlessOwner is the name that holds the PTR records of a CNAME chain. const classlessOwner = "4.0-25.4.8.8.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, has testTimeLimit, 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 cache.timeLimit = testTimeLimit return cache, clock } // handlerTransport hands each request to the fake resolver for its host, with // no network between them. A test in a synctest bubble needs this: the bubble's // clock stands still while one of its goroutines waits on a real connection. As // over a network, a request whose time is up is not sent. type handlerTransport map[string]*fakeResolver func (h handlerTransport) RoundTrip(r *http.Request) (*http.Response, error) { recorder := httptest.NewRecorder() if r.Context().Err() == nil { h[r.URL.Host].serve(recorder, r) } err := r.Context().Err() if err != nil { return nil, err } return recorder.Result(), nil } // newBubbleCache returns a Cache with the real time limit, for a test in a // synctest bubble. It has one fake resolver for each reply, in order, reached // through a handlerTransport. func newBubbleCache( replies ...func(http.ResponseWriter, *http.Request, *dns.Msg), ) (*Cache, []*fakeResolver) { transport := make(handlerTransport, len(replies)) resolvers := make([]*fakeResolver, 0, len(replies)) urls := make([]string, 0, len(replies)) for i, reply := range replies { host := "resolver" + strconv.Itoa(i) + ".example.com" resolver := &fakeResolver{reply: reply} transport[host] = resolver resolvers = append(resolvers, resolver) urls = append(urls, "https://"+host+"/dns-query") } cache := New(Params{Resolvers: urls, Logger: slog.Default()}) cache.client = &http.Client{Transport: transport} return cache, resolvers } // 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}, {mappedAddress, 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) } } } // TestOnlyPublicAddressesAreSentToTheResolvers gives every resolver an answer // to send, which a reserved address must not get. func TestOnlyPublicAddressesAreSentToTheResolvers(t *testing.T) { t.Parallel() tests := []struct { name string address string want []string requests int }{ {"private IPv4", "10.0.0.1", []string{}, 0}, {"documentation IPv4", "192.0.2.1", []string{}, 0}, {"private IPv4 in IPv6 form", "::ffff:10.0.0.1", []string{}, 0}, {"unique-local IPv6", "fd00::1", []string{}, 0}, {"documentation IPv6", "2001:db8::1", []string{}, 0}, {"public IPv4", testAddress, []string{testName}, 1}, {"public IPv4 in IPv6 form", mappedAddress, []string{testName}, 1}, {"public IPv6", testV6Address, []string{testName}, 1}, } for _, tt := range tests { resolver := newFakeResolver(t, ptrReply(300, testName)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) names := lookupOK(t, cache, tt.address) if names == nil || !slices.Equal(names, tt.want) { t.Errorf("%s: names %#v, want %#v", tt.name, names, tt.want) } if got := resolver.requestCount(); got != tt.requests { t.Errorf("%s: %d requests, want %d", tt.name, got, tt.requests) } } } // 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. The first gets half of it, the // share of one resolver in two. The test runs in a synctest bubble, whose clock // is fake, so a stall on the host uses none of the limit. func TestResolverThatNeverAnswersLeavesTimeForTheNext(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { cache, _ := newBubbleCache(hangingReply, ptrReply(300, testName)) start := time.Now() names := lookupOK(t, cache, testAddress) elapsed := time.Since(start) if !slices.Equal(names, []string{testName}) { t.Errorf("names %v, want %s", names, testName) } if elapsed != time.Second { t.Errorf("took %s, want 1s", elapsed) } }) } // TestLookupStopsAtTheTimeLimit has no resolver answer. The lookup fails when // 2 seconds have passed, having asked every resolver. The test runs in a // synctest bubble, whose clock is fake, so a stall on the host uses none of the // limit. func TestLookupStopsAtTheTimeLimit(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { cache, resolvers := newBubbleCache( hangingReply, hangingReply, hangingReply, hangingReply) start := time.Now() _, err := cache.LookupAddr(t.Context(), testAddress) elapsed := time.Since(start) if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("error %v, want %v", err, context.DeadlineExceeded) } if elapsed != 2*time.Second { t.Errorf("took %s, want 2s", elapsed) } 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, mappedAddress) lookupOK(t, cache, testAddress) if got := resolver.requestCount(); got != 1 { t.Errorf("%d requests, want 1", got) } } // TestCallerCannotChangeACachedAnswer changes the list each lookup returned, as // the server does when it removes the trailing dots. The second lookup is the // first answered from the cache. The third shows that the cached list did not // change with the list the second returned. func TestCallerCannotChangeACachedAnswer(t *testing.T) { t.Parallel() resolver := newFakeResolver(t, ptrReply(300, testName)) cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) for range 3 { 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