1 Commits
Author SHA1 Message Date
sneak e886ac64a8 Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
check / check (push) Failing after 8m42s
`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`. A reserved address is not sent to the resolvers.

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
2026-11-10 11:03:01 +00:00
6 changed files with 24 additions and 96 deletions

No files matched your search

+2 -11
View File
@@ -1,5 +1,5 @@
// Package bogon tells whether a prefix, an address or an AS number is reserved:
// set aside for a special purpose, so that no network should announce it on the
// Package bogon tells whether a prefix or an AS number is reserved: set aside
// for a special purpose, so that no network should announce it on the
// internet. A route is a bogon announcement when its prefix is reserved or its
// origin AS is.
//
@@ -42,15 +42,6 @@ func Prefix(prefix netip.Prefix) (string, bool) {
return holder.name + " (" + holder.prefix.String() + ")", true
}
// Addr returns why addr is reserved, and true, when it lies inside a reserved
// block, as Prefix decides. An IPv4 address written in IPv6 form counts as the
// IPv4 address it holds. It returns "" and false when addr is not reserved.
func Addr(addr netip.Addr) (string, bool) {
addr = addr.Unmap()
return Prefix(netip.PrefixFrom(addr, addr.BitLen()))
}
// ASN returns why asn is reserved, and true, when it is. It returns "" and
// false when asn is not reserved.
func ASN(asn int) (string, bool) {
-33
View File
@@ -56,39 +56,6 @@ func TestPrefix(t *testing.T) {
}
}
func TestAddr(t *testing.T) {
t.Parallel()
tests := []struct {
addr string
// want is why the address is reserved, or "" when it is not.
want string
}{
{"10.0.0.1", "Private-Use (10.0.0.0/8)"},
{"fd00::1", "Unique-Local (fc00::/7)"},
// An IPv4 address written in IPv6 form counts as the IPv4 address.
{"::ffff:10.0.0.1", "Private-Use (10.0.0.0/8)"},
{"::ffff:8.8.8.8", ""},
// An address that may be announced, inside a reserved block.
{"192.0.0.9", ""},
// Addresses anyone may announce.
{"8.8.8.8", ""},
{"2001:4860:4860::8888", ""},
}
for _, tc := range tests {
t.Run(tc.addr, func(t *testing.T) {
t.Parallel()
reason, reserved := bogon.Addr(netip.MustParseAddr(tc.addr))
if reason != tc.want || reserved != (tc.want != "") {
t.Errorf("Addr(%s) = %q, %v; want %q", tc.addr, reason,
reserved, tc.want)
}
})
}
}
func TestASN(t *testing.T) {
t.Parallel()
+4 -2
View File
@@ -95,12 +95,14 @@ func (c *Cache) LookupAddr(ctx context.Context, addr string) ([]string, error) {
return nil, fmt.Errorf("parsing the address: %w", err)
}
_, reserved := bogon.Addr(parsed)
parsed = parsed.Unmap()
_, reserved := bogon.Prefix(netip.PrefixFrom(parsed, parsed.BitLen()))
if reserved {
return []string{}, nil
}
address := parsed.Unmap().String()
address := parsed.String()
cached, ok := c.current(address)
if ok {
+4 -2
View File
@@ -1003,14 +1003,16 @@ func ipLookupFlags(ip string, listings []iplist.Listing) IPLookupFlags {
}
}
// isBogon says whether the address ip is reserved, as package bogon tells.
// isBogon says whether the address ip is reserved. An IPv4 address written in
// IPv6 form counts as the IPv4 address it holds.
func isBogon(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
_, reserved := bogon.Addr(addr)
addr = addr.Unmap()
_, reserved := bogon.Prefix(netip.PrefixFrom(addr, addr.BitLen()))
return reserved
}
+10 -44
View File
@@ -1253,19 +1253,16 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
}
}
// errNoSuchHost is what fakeResolver answers a host name lookup it has no
// addresses for with.
// errNoSuchHost is what fakeResolver answers a lookup it has no answer for
// with.
var errNoSuchHost = errors.New("no such host")
// fakeResolver answers a host name lookup with the addresses hosts lists for
// 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.
// the name, and a PTR lookup with no names or, when ptrNeverAnswers is set,
// 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
ptrs map[string][]string
ptrNeverAnswers bool
hostLookups atomic.Int64
@@ -1282,7 +1279,7 @@ func (r *fakeResolver) LookupHost(_ context.Context, host string) ([]string, err
return addrs, nil
}
func (r *fakeResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, error) {
if r.ptrNeverAnswers {
ctx, cancel := context.WithTimeout(ctx, ptr.LookupTimeout)
defer cancel()
@@ -1292,7 +1289,7 @@ func (r *fakeResolver) LookupAddr(ctx context.Context, addr string) ([]string, e
return nil, ctx.Err()
}
return r.ptrs[addr], nil
return nil, errNoSuchHost
}
// useFakeResolver makes r answer the server's host name and PTR lookups.
@@ -1404,10 +1401,9 @@ 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 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).
// 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).
func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
t.Parallel()
@@ -1439,10 +1435,6 @@ 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
@@ -1765,32 +1757,6 @@ 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
+4 -4
View File
@@ -58,8 +58,8 @@ 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 {
// ptrResolver looks up the PTR names of addresses, as *ptr.Cache does.
type ptrResolver interface {
LookupAddr(ctx context.Context, addr string) ([]string, error)
}
@@ -84,7 +84,7 @@ type Server struct {
resolver dnsResolver
// ptrResolver looks up the IP lookup's PTR names. SetPTRResolver sets it.
ptrResolver PTRResolver
ptrResolver ptrResolver
// cards is the copy of the home page's cards that the pages serve, nil
// until the server first works them out
@@ -205,7 +205,7 @@ func (s *Server) Stop(ctx context.Context) error {
// 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) {
func (s *Server) SetPTRResolver(resolver ptrResolver) {
s.ptrResolver = resolver
}