Bound the IP lookup's address list and name lookups (closes #110) #134

Merged
clawbot merged 1 commits from issue-110-ip-lookup-bounds into next 2026-10-20 02:23:50 +02:00
4 changed files with 401 additions and 34 deletions
+10 -5
View File
@@ -64,11 +64,16 @@ DEBUG=routewatch ./routewatch
collector, stream and handler queue statistics
- `GET /api/v1/ip/{ip}` - Looks up each address: its most specific live route
(`netblock`), that route's origin AS with its WHOIS details, and the address's
PTR names. `{ip}` is an IP address, a host name (each address it resolves to
is looked up), or a comma-separated list of these. `results` has one entry per
address with a live route and `errors` one per address or name that failed.
The answer is 400 when nothing in `{ip}` is an IP address or a host name that
resolves, and 404 when none of the addresses has a live route.
PTR names. `{ip}` is an IP address, a host name, or a comma-separated list of
at most 10 of these. Of the addresses a host name resolves to, only public
ones are looked up: a name with none, such as one that resolves to a private
address, is reported as `host name has no public address`, without its
addresses. `results` has one entry per address with a live route and `errors`
one per address or name that failed. The answer is 400 when `{ip}` lists more
than 10, when it comes to more than 10 addresses once its names are resolved,
or when nothing in it is an IP address or a host name with a public address;
and 404 when none of the addresses has a live route. A PTR lookup gets 2
seconds; an address whose lookup takes longer is answered without PTR names.
- `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6
apart, each with when its newest route was updated
- `GET /api/v1/prefix/{prefix}/{length}` - Every live route for the prefix, one
+130 -29
View File
@@ -31,6 +31,18 @@ const (
healthCheckTimeout = 2 * time.Second
)
// Limits of one IP lookup.
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 up to 113 database reads and a PTR lookup.
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
)
// Keys of the JSON envelope the handlers and middleware write, and the status
// value of an error answer.
const (
@@ -41,10 +53,13 @@ const (
jsonStatusError = "error"
)
// Error messages an answer sends in place of the error that caused it.
// Error messages an answer sends, in place of the error behind it where there
// is one.
const (
invalidPrefixMessage = "Invalid prefix"
hostNotResolvedMessage = "could not resolve host name"
hostNotPublicMessage = "host name has no public address"
tooManyTargetsMessage = "Too many addresses or host names"
)
// IP versions as database.LiveRoute.IPVersion records them.
@@ -678,17 +693,23 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
response := IPLookupResponse{Query: target, Results: make([]*database.IPInfo, 0)}
// Collect all IPs to look up
ipsToLookup, resolveErrors := s.resolveLookupTargets(ctx, target)
ipsToLookup, resolveErrors, err := s.resolveLookupTargets(ctx, target)
if deadlinePassed(ctx) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return
}
if err != nil {
s.writeJSONError(w, http.StatusBadRequest, tooManyTargetsMessage)
return
}
response.Errors = append(response.Errors, resolveErrors...)
if len(ipsToLookup) == 0 {
s.writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided")
s.writeJSONError(w, http.StatusBadRequest, noTargetsMessage(resolveErrors))
return
}
@@ -718,7 +739,7 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
}
// Do PTR lookup for this IP
ptrs := lookupPTRNames(ctx, ip)
ptrs := s.lookupPTRNames(ctx, ip)
if deadlinePassed(ctx) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
@@ -739,17 +760,37 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
// Queue WHOIS refresh for stale ASNs (non-blocking)
s.queueWHOISRefresh(refreshASNs)
// Return response (even if no results, include errors)
if len(response.Results) == 0 && len(response.Errors) > 0 {
s.writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0])
s.writeIPLookupAnswer(w, response)
}
}
return
}
// noTargetsMessage returns the message of the 400 an IP lookup answers when
// no address is left to look up, followed by the first of resolveErrors, which
// says why, when there is one.
func noTargetsMessage(resolveErrors []string) string {
const message = "No valid IPs or hostnames provided"
err := writeJSONSuccess(w, response)
if err != nil {
s.logger.Error("Failed to encode IP lookup response", "error", err)
}
if len(resolveErrors) == 0 {
return message
}
return message + ": " + resolveErrors[0]
}
// writeIPLookupAnswer sends response, or 404 with its first error when no
// address had a route.
func (s *Server) writeIPLookupAnswer(
w http.ResponseWriter, response IPLookupResponse,
) {
if len(response.Results) == 0 && len(response.Errors) > 0 {
s.writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0])
return
}
err := writeJSONSuccess(w, response)
if err != nil {
s.logger.Error("Failed to encode IP lookup response", "error", err)
}
}
@@ -781,15 +822,16 @@ func (s *Server) queueWHOISRefresh(asns map[int]bool) {
}
}
// resolveLookupTargets splits a comma-separated list of IP addresses and
// hostnames and returns the IP addresses to look up, resolving each hostname.
// A hostname that does not resolve is reported in the returned errors instead,
// with the resolver's own error only in the log, since it names the DNS server.
// It stops once ctx's deadline has passed; the caller then answers 504.
func (s *Server) resolveLookupTargets(
ctx context.Context, target string,
) ([]string, []string) {
var ipsToLookup, resolveErrors []string
// errTooManyTargets is what resolveLookupTargets returns for a list of more
// than maxLookupTargets addresses and host names, or of names that resolve to
// more than maxLookupTargets addresses.
var errTooManyTargets = errors.New("too many addresses or host names")
// splitLookupTargets returns the addresses and host names in a comma-separated
// list, leaving out blank entries, or errTooManyTargets when there are more
// than maxLookupTargets.
func splitLookupTargets(target string) ([]string, error) {
var targets []string
for t := range strings.SplitSeq(target, ",") {
t = strings.TrimSpace(t)
@@ -797,6 +839,36 @@ func (s *Server) resolveLookupTargets(
continue
}
if len(targets) == maxLookupTargets {
return nil, errTooManyTargets
}
targets = append(targets, t)
}
return targets, nil
}
// resolveLookupTargets splits a comma-separated list of IP addresses and
// hostnames and returns the IP addresses to look up, resolving each hostname.
// A hostname that does not resolve is reported in the returned errors instead,
// with the resolver's own error only in the log, since it names the DNS server.
// Only the public addresses a hostname resolves to are kept, so the answer
// does not show the deployment's private addresses; a hostname with none is
// reported in the errors. More than maxLookupTargets entries, or addresses once
// resolved, return errTooManyTargets. It stops once ctx's deadline has passed;
// the caller then answers 504.
func (s *Server) resolveLookupTargets(
ctx context.Context, target string,
) ([]string, []string, error) {
targets, err := splitLookupTargets(target)
if err != nil {
return nil, nil, err
}
var ipsToLookup, resolveErrors []string
for _, t := range targets {
// Check if this target is an IP address
if net.ParseIP(t) != nil {
ipsToLookup = append(ipsToLookup, t)
@@ -805,9 +877,9 @@ func (s *Server) resolveLookupTargets(
}
// It's a hostname - resolve it
resolved, err := net.DefaultResolver.LookupHost(ctx, t)
resolved, err := s.resolver.LookupHost(ctx, t)
if deadlinePassed(ctx) {
return nil, nil
return nil, nil, nil
}
if err != nil {
@@ -817,16 +889,45 @@ func (s *Server) resolveLookupTargets(
continue
}
ipsToLookup = append(ipsToLookup, resolved...)
public := publicAddresses(resolved)
if len(public) == 0 {
resolveErrors = append(resolveErrors, t+": "+hostNotPublicMessage)
continue
}
ipsToLookup = append(ipsToLookup, public...)
}
return ipsToLookup, resolveErrors
if len(ipsToLookup) > maxLookupTargets {
return nil, nil, errTooManyTargets
}
return ipsToLookup, resolveErrors, nil
}
// publicAddresses returns the addresses in addrs that are global unicast and
// not private; Go counts the private ranges as global unicast.
func publicAddresses(addrs []string) []string {
var public []string
for _, a := range addrs {
ip, err := netip.ParseAddr(a)
if err == nil && ip.IsGlobalUnicast() && !ip.IsPrivate() {
public = append(public, a)
}
}
return public
}
// lookupPTRNames returns the PTR records of ip without their trailing dots, or
// nil when the lookup fails.
func lookupPTRNames(ctx context.Context, ip string) []string {
ptrs, err := net.DefaultResolver.LookupAddr(ctx, ip)
// nil when the lookup fails or runs past ptrLookupTimeout.
func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string {
ctx, cancel := context.WithTimeout(ctx, ptrLookupTimeout)
defer cancel()
ptrs, err := s.resolver.LookupAddr(ctx, ip)
if err != nil {
return nil
}
+249
View File
@@ -9,7 +9,9 @@ import (
"net/http/httptest"
"runtime"
"slices"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
@@ -917,6 +919,253 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
}
}
// 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, 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.
type fakeResolver struct {
hosts map[string][]string
ptrNeverAnswers bool
hostLookups atomic.Int64
}
func (r *fakeResolver) LookupHost(_ context.Context, host string) ([]string, error) {
r.hostLookups.Add(1)
addrs, ok := r.hosts[host]
if !ok {
return nil, errNoSuchHost
}
return addrs, nil
}
func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, error) {
if r.ptrNeverAnswers {
<-ctx.Done()
return nil, ctx.Err()
}
return nil, errNoSuchHost
}
// countingIPLookupDB embeds database.Store (left nil) and counts its IP
// lookups, answering each that the address has no route. Every other Store
// method is unused and would panic if called.
type countingIPLookupDB struct {
database.Store
lookups atomic.Int64
}
func (d *countingIPLookupDB) GetIPInfoContext(
_ context.Context, _ string,
) (*database.IPInfo, error) {
d.lookups.Add(1)
return nil, database.ErrNoRoute
}
// TestIPLookupRefusesTooManyAddresses sends IP lookups of maxLookupTargets
// addresses and of one more, listed or as a host name that resolves to them,
// and a lookup listing maxLookupTargets+1 host names. The longer lookups answer
// 400 without reading the database, and the list of host names without
// resolving any of them; the others read the database once per address. Before
// the fix every address was read and every name resolved, however many there
// were (https://git.eeqj.de/sneak/routewatch/issues/110).
func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
t.Parallel()
addrs := make([]string, maxLookupTargets+1)
names := make([]string, maxLookupTargets+1)
for i := range addrs {
addrs[i] = "192.0.2." + strconv.Itoa(i+1)
names[i] = "host" + strconv.Itoa(i+1) + ".test"
}
hosts := map[string][]string{
"most.test": addrs[:maxLookupTargets],
"too-many.test": addrs,
}
tests := []struct {
target string
wantCode int
wantLookups int64
wantHostLookups int64
}{
{
strings.Join(addrs[:maxLookupTargets], ","),
http.StatusNotFound, maxLookupTargets, 0,
},
{strings.Join(addrs, ","), http.StatusBadRequest, 0, 0},
{"most.test", http.StatusNotFound, maxLookupTargets, 1},
{"too-many.test", http.StatusBadRequest, 0, 1},
{strings.Join(names, ","), http.StatusBadRequest, 0, 0},
}
for _, tt := range tests {
db := &countingIPLookupDB{}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
resolver := &fakeResolver{hosts: hosts}
s.resolver = resolver
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/api/v1/ip/"+tt.target, nil))
if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s",
tt.target, rec.Code, tt.wantCode, rec.Body)
}
checkJSONErrorCode(t, tt.target, rec, tt.wantCode)
got := db.lookups.Load()
if got != tt.wantLookups {
t.Errorf("%s: %d database reads, want %d",
tt.target, got, tt.wantLookups)
}
gotHostLookups := resolver.hostLookups.Load()
if gotHostLookups != tt.wantHostLookups {
t.Errorf("%s: %d host name lookups, want %d",
tt.target, gotHostLookups, tt.wantHostLookups)
}
}
}
// addrInTestPrefixV4 is an address in testPrefixV4, which
// newServerWithTestRoutes stores a route for.
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).
func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{ptrNeverAnswers: true}
// The request's time limit passes after the PTR lookup's.
ctx, cancel := context.WithTimeout(t.Context(), 2*ptrLookupTimeout)
defer cancel()
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
ctx, http.MethodGet, "/api/v1/ip/"+addrInTestPrefixV4, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
}
var body struct {
Data IPLookupResponse `json:"data"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
}
if len(body.Data.Results) != 1 || body.Data.Results[0].Netblock != testPrefixV4 {
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
}
}
// The host name TestIPLookupHidesPrivateAddresses resolves to a private
// address, and that address.
const (
internalHostName = "internal.test"
internalHostAddr = "10.0.0.5"
)
// TestIPLookupHidesPrivateAddresses looks up host names that resolve to a
// private address, alone or beside a routed address. No answer carries the
// private address, and a name with no other address is reported as having no
// public address, in the 400 when nothing else is left to look up. Before the
// fix the private address was looked up and named in the answer, "No routes
// found: 10.0.0.5: no route found for IP 10.0.0.5", so the API showed what the
// deployment's own DNS knows (https://git.eeqj.de/sneak/routewatch/issues/110).
func TestIPLookupHidesPrivateAddresses(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{hosts: map[string][]string{
internalHostName: {internalHostAddr},
"mixed.test": {internalHostAddr, addrInTestPrefixV4},
}}
notPublic := internalHostName + ": " + hostNotPublicMessage
tests := []struct {
target string
wantCode int
wantMessage string
wantErrors []string
}{
{
internalHostName, http.StatusBadRequest,
"No valid IPs or hostnames provided: " + notPublic, nil,
},
{
internalHostName + "," + addrInTestPrefixV4, http.StatusOK, "",
[]string{notPublic},
},
{"mixed.test", http.StatusOK, "", nil},
}
for _, tt := range tests {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/api/v1/ip/"+tt.target, nil))
if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s",
tt.target, rec.Code, tt.wantCode, rec.Body)
}
if strings.Contains(rec.Body.String(), internalHostAddr) {
t.Errorf("%s: body %s carries %s", tt.target, rec.Body, internalHostAddr)
}
var body struct {
Data IPLookupResponse `json:"data"`
Error struct {
Msg string `json:"msg"`
} `json:"error"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Errorf("%s: decoding the answer %q: %v", tt.target, rec.Body, err)
continue
}
if body.Error.Msg != tt.wantMessage {
t.Errorf("%s: error message %q, want %q",
tt.target, body.Error.Msg, tt.wantMessage)
}
if !slices.Equal(body.Data.Errors, tt.wantErrors) {
t.Errorf("%s: errors %q, want %q",
tt.target, body.Data.Errors, tt.wantErrors)
}
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the
// current count.
func settledGoroutineCount() int {
+12
View File
@@ -32,6 +32,13 @@ type ASNFetcher interface {
GetStats() ASNFetcherStats
}
// dnsResolver looks up the addresses of host names and the names of addresses
// in DNS, as *net.Resolver does.
type dnsResolver interface {
LookupHost(ctx context.Context, host string) ([]string, error)
LookupAddr(ctx context.Context, addr string) ([]string, error)
}
// Server provides HTTP endpoints for status monitoring
type Server struct {
router *chi.Mux
@@ -45,6 +52,10 @@ type Server struct {
// trustedProxies are the reverse proxies whose X-Forwarded-For header
// RealIPMiddleware believes
trustedProxies []netip.Prefix
// resolver makes the IP lookup's DNS lookups: net.DefaultResolver, or a
// fake in tests
resolver dnsResolver
}
// New creates a new HTTP server
@@ -60,6 +71,7 @@ func New(
logger: logger,
port: cfg.Port,
trustedProxies: cfg.TrustedProxies,
resolver: net.DefaultResolver,
}
s.setupRoutes()