Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
check / check (push) Failing after 1m22s

`internal/ptr` asks Google, Cloudflare, AdGuard and Quad9 in turn, 2 seconds in all per address. It keeps answers in memory and writes them through to `cache.db`, opened by the new `internal/cachedb`.

Judgement calls:
- Each resolver gets an equal share of the time left, so a hung resolver does not use up the 2 seconds.
- The negative TTL is the smaller of the SOA minimum and the SOA's own TTL (RFC 2308).
- Names that are not host names are dropped, as `net.Resolver.LookupAddr` does.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-10 10:03:08 +00:00
1 parent cf50d195ad
commit 018372363f
18 files changed
+1608 -53

No files matched your search

+29 -16
View File
@@ -24,6 +24,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/iplist"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ptr"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
@@ -523,8 +524,11 @@ func newServerWithTable(t *testing.T, table *routetable.Table) *Server {
t.Cleanup(func() { _ = db.Close() })
return New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
s := New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
useFakeResolver(s, &fakeResolver{})
return s
}
// TestPrefixDetailHandlersParsePrefix checks that both prefix pages find a
@@ -1213,7 +1217,7 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
s := New(failingIPLookupDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
tests := []struct {
path string
@@ -1255,8 +1259,8 @@ var errNoSuchHost = errors.New("no such host")
// fakeResolver answers a host name lookup with the addresses hosts lists for
// the name, and a PTR lookup with no names or, when ptrNeverAnswers is set,
// not at all: it waits until the lookup's context ends. hostLookups counts the
// host name lookups.
// with a failure after ptr.LookupTimeout, as the real one does when no
// resolver answers. hostLookups counts the host name lookups.
type fakeResolver struct {
hosts map[string][]string
ptrNeverAnswers bool
@@ -1277,6 +1281,9 @@ func (r *fakeResolver) LookupHost(_ context.Context, host string) ([]string, err
func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, error) {
if r.ptrNeverAnswers {
ctx, cancel := context.WithTimeout(ctx, ptr.LookupTimeout)
defer cancel()
<-ctx.Done()
return nil, ctx.Err()
@@ -1285,6 +1292,12 @@ func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, erro
return nil, errNoSuchHost
}
// useFakeResolver makes r answer the server's host name and PTR lookups.
func useFakeResolver(s *Server, r *fakeResolver) {
s.resolver = r
s.ptrResolver = r
}
// countingIPLookupDB embeds database.Store (left nil) and answers the IP
// lookup's reads with nothing stored. It counts the lookups, as the reads of a
// prefix's first-seen time. Every other Store method is unused and would panic
@@ -1353,7 +1366,7 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
resolver := &fakeResolver{hosts: hosts}
s.resolver = resolver
useFakeResolver(s, resolver)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
@@ -1395,10 +1408,10 @@ func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{ptrNeverAnswers: true}
useFakeResolver(s, &fakeResolver{ptrNeverAnswers: true})
// The request's time limit passes after the PTR lookup's.
ctx, cancel := context.WithTimeout(t.Context(), 2*ptrLookupTimeout)
ctx, cancel := context.WithTimeout(t.Context(), 2*ptr.LookupTimeout)
defer cancel()
rec := httptest.NewRecorder()
@@ -1476,7 +1489,7 @@ func TestIPLookupFlags(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{})
tests := []struct {
@@ -1532,7 +1545,7 @@ func TestIPLookupReportsIPLists(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{})
rec := httptest.NewRecorder()
@@ -1609,7 +1622,7 @@ func TestIPLookupReportsIPListsOfAnAddressWithNoRoute(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{})
// routeFields are the fields of a result that come from the address's
@@ -1687,9 +1700,9 @@ func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{hosts: map[string][]string{
useFakeResolver(s, &fakeResolver{hosts: map[string][]string{
twoAddressHostName: {addrInTestPrefixV4, listedUnroutedAddr},
}}
}})
s.SetIPLists(fakeIPLists{})
// want is the fields of the first result.
@@ -1722,7 +1735,7 @@ func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
path := "/api/v1/ip/" + addrInTestPrefixV4
@@ -1752,7 +1765,7 @@ func TestIPLookupAnswersMayBeCached(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{})
tests := []struct {
@@ -1803,10 +1816,10 @@ func TestIPLookupHidesPrivateAddresses(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{hosts: map[string][]string{
useFakeResolver(s, &fakeResolver{hosts: map[string][]string{
internalHostName: {internalHostAddr},
"mixed.test": {internalHostAddr, addrInTestPrefixV4},
}}
}})
notPublic := internalHostName + ": " + hostNotPublicMessage