Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
check / check (push) Successful in 4m39s

`ptr` keeps its field and shape. It now comes from Google, Cloudflare, AdGuard and Quad9 over RFC 8484, tried in turn within 2 seconds, in place of the host's resolver with no cache. Answers stay in memory and in a new `cache.db` for the record's TTL, at most 250,000 addresses. A reserved address is not sent to any resolver and has an empty `ptr`.

Judgement call: each resolver gets an equal share of the time left, so a hung one does not use up the limit.
Judgement call: the negative TTL is the smaller of the SOA minimum and the SOA's own TTL.
Deviation: the cache is SQLite, by sneak's words, not the JSON file the standing rule asks for.

Model: opus-5-5
This commit was merged in pull request #244.
This commit is contained in:
clawbot committed 2026-11-10 13:48:06 +01:00
1 parent 641798bb22
commit 0cbd9cd943
21 files changed
+1973 -69

No files matched your search

+13 -16
View File
@@ -39,12 +39,9 @@ const (
const (
// maxLookupTargets is the most addresses and host names an IP lookup may
// list, and the most addresses it may have once the names are resolved:
// each address costs two database reads and a PTR lookup.
// each address costs two database reads and a PTR lookup. Ten PTR lookups
// that run to ptr.LookupTimeout fit in the request's 30-second limit.
maxLookupTargets = 10
// ptrLookupTimeout is each PTR lookup's own time limit, so that ten
// addresses whose PTR lookups never answer fit in the 30-second limit.
ptrLookupTimeout = 2 * time.Second
)
// The Cache-Control header of an IP lookup answer, such as
@@ -1032,16 +1029,14 @@ func ipLookupFlags(ip string, listings []iplist.Listing) IPLookupFlags {
}
}
// isBogon says whether the address ip is reserved. An IPv4 address written in
// IPv6 form counts as the IPv4 address it holds.
// isBogon says whether the address ip is reserved, as package bogon tells.
func isBogon(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
addr = addr.Unmap()
_, reserved := bogon.Prefix(netip.PrefixFrom(addr, addr.BitLen()))
_, reserved := bogon.Addr(addr)
return reserved
}
@@ -1220,15 +1215,17 @@ func publicAddresses(addrs []string) []string {
}
// lookupPTRNames returns the PTR records of ip without their trailing dots. It
// returns an empty list, never nil, when there are none, when the lookup fails
// and when it runs past ptrLookupTimeout, so that an answer's ptr is [] rather
// than null.
// returns an empty list, never nil, when there are none and when the lookup
// fails, so that an answer's ptr is [] rather than null.
func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string {
ctx, cancel := context.WithTimeout(ctx, ptrLookupTimeout)
defer cancel()
ptrs, err := s.ptrResolver.LookupAddr(ctx, ip)
if err != nil {
s.logger.Warn("PTR lookup failed", "ip", ip, "error", err)
ptrs, err := s.resolver.LookupAddr(ctx, ip)
if err != nil || ptrs == nil {
return []string{}
}
if ptrs == nil {
return []string{}
}
+71 -24
View File
@@ -25,6 +25,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"
@@ -557,8 +558,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
@@ -1252,7 +1256,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
@@ -1288,16 +1292,19 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
}
}
// errNoSuchHost is what fakeResolver answers a lookup it has no answer for
// with.
// errNoSuchHost is what fakeResolver answers a host name lookup it has no
// addresses for with.
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.
// the name. It answers a PTR lookup with the names ptrs lists for the address,
// and with no names and no error, as the real one does, when ptrs lists none.
// When ptrNeverAnswers is set, a PTR lookup fails 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
ptrs map[string][]string
ptrNeverAnswers bool
hostLookups atomic.Int64
@@ -1314,14 +1321,23 @@ func (r *fakeResolver) LookupHost(_ context.Context, host string) ([]string, err
return addrs, nil
}
func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, error) {
func (r *fakeResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
if r.ptrNeverAnswers {
ctx, cancel := context.WithTimeout(ctx, ptr.LookupTimeout)
defer cancel()
<-ctx.Done()
return nil, ctx.Err()
}
return nil, errNoSuchHost
return r.ptrs[addr], nil
}
// 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
@@ -1392,7 +1408,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(
@@ -1427,18 +1443,19 @@ const addrInTestPrefixV4 = "192.0.2.1"
// TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer looks up a routed address
// with a resolver that never answers its PTR lookup. Once the PTR lookup's own
// limit passes, the lookup answers 200 with the route. Before the fix the PTR
// lookup ran until the request's time limit, and the lookup answered 504
// without the route (https://git.eeqj.de/sneak/routewatch/issues/110).
// limit passes, the lookup answers 200 with the route and an empty ptr. Before
// the fix the PTR lookup ran until the request's time limit, and the lookup
// answered 504 without the route
// (https://git.eeqj.de/sneak/routewatch/issues/110).
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 well after the PTR lookup's, so that a
// stall on a busy host does not use up the difference.
const requestTimeLimit = ptrLookupTimeout + 15*time.Second
const requestTimeLimit = ptr.LookupTimeout + 15*time.Second
ctx, cancel := context.WithTimeout(t.Context(), requestTimeLimit)
defer cancel()
@@ -1464,6 +1481,10 @@ func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
*body.Data.Results[0].Netblock != testPrefixV4 {
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
}
if !strings.Contains(rec.Body.String(), `"ptr":[]`) {
t.Errorf("answer %s, want an empty ptr list", rec.Body)
}
}
// addrInTestPrefixV6 is an address in testPrefixV6, which
@@ -1541,7 +1562,7 @@ func TestIPLookupFlags(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{})
tests := []struct {
@@ -1654,7 +1675,7 @@ func TestIPLookupReportsIPLists(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{})
rec := httptest.NewRecorder()
@@ -1731,7 +1752,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
@@ -1809,9 +1830,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.
@@ -1844,7 +1865,7 @@ func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
path := "/api/v1/ip/" + addrInTestPrefixV4
@@ -1866,6 +1887,32 @@ func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
}
}
// TestIPLookupGivesPTRNamesWithoutTrailingDots looks up an address whose PTR
// name the resolver gives fully qualified. The result's ptr holds the name
// without its trailing dot.
func TestIPLookupGivesPTRNamesWithoutTrailingDots(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
useFakeResolver(s, &fakeResolver{ptrs: map[string][]string{
addrInTestPrefixV4: {"host.example.com."},
}})
path := "/api/v1/ip/" + addrInTestPrefixV4
results := resultFields(t, get(t, s, path).Body.Bytes())
if len(results) != 1 {
t.Fatalf("%s: %d results, want 1", path, len(results))
}
got := string(results[0]["ptr"])
want := `["host.example.com"]`
if got != want {
t.Errorf("%s: ptr is %s, want %s", path, got, want)
}
}
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
// lookup answers. Caches may keep them for the IP lists' shortest interval,
// and only the caller's own browser may keep the answer about the caller's
@@ -1874,7 +1921,7 @@ func TestIPLookupAnswersMayBeCached(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetIPLists(fakeIPLists{})
tests := []struct {
@@ -1925,10 +1972,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
@@ -85,7 +85,7 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
firstSeen := testRouteTime(-1)
+4 -4
View File
@@ -115,7 +115,7 @@ func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
`^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second})
paths := []string{
@@ -179,7 +179,7 @@ func TestJSONAnswersCarryMeta(t *testing.T) {
)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
s.streamer.GetMetricsTracker().SetConnected(true)
s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {})).ServeHTTP)
@@ -188,7 +188,7 @@ func TestJSONAnswersCarryMeta(t *testing.T) {
failing := New(failingDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
failing.resolver = &fakeResolver{}
useFakeResolver(failing, &fakeResolver{})
tests := []struct {
method string
@@ -521,7 +521,7 @@ func newServerWithConfig(
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
&logger.Logger{Logger: slog.New(slog.NewJSONHandler(requestLog, nil))}, cfg)
s.resolver = &fakeResolver{}
useFakeResolver(s, &fakeResolver{})
return s
}
+16 -3
View File
@@ -52,10 +52,14 @@ type IPLists interface {
ShortestInterval() time.Duration
}
// dnsResolver looks up the addresses of host names and the names of addresses
// in DNS, as *net.Resolver does.
// dnsResolver looks up the addresses of host names in DNS, as *net.Resolver
// does.
type dnsResolver interface {
LookupHost(ctx context.Context, host string) ([]string, error)
}
// PTRResolver looks up the PTR names of addresses, as *ptr.Cache does.
type PTRResolver interface {
LookupAddr(ctx context.Context, addr string) ([]string, error)
}
@@ -75,10 +79,13 @@ type Server struct {
// RealIPMiddleware believes
trustedProxies []netip.Prefix
// resolver makes the IP lookup's DNS lookups: net.DefaultResolver, or a
// resolver looks up the IP lookup's host names: net.DefaultResolver, or a
// fake in tests
resolver dnsResolver
// ptrResolver looks up the IP lookup's PTR names. SetPTRResolver sets it.
ptrResolver PTRResolver
// cards is the copy of the home page's cards that the pages serve, nil
// until the server first works them out
cards atomic.Pointer[homeCards]
@@ -196,6 +203,12 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
// SetPTRResolver sets what looks up the IP lookup's PTR names. Call it before
// Start: the handlers read it without a lock.
func (s *Server) SetPTRResolver(resolver PTRResolver) {
s.ptrResolver = resolver
}
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. Call it
// before Start: the handlers read the fetcher without a lock.
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {