Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
check / check (push) Successful in 4m39s
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:
1 parent
641798bb22
commit
0cbd9cd943
21 files changed
+1973
-69
No files matched your search
+13
-16
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user