Bound the IP lookup's address list and name lookups (closes #110)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
An IP lookup took any number of addresses and host names, gave each PTR lookup until the request's 30-second limit, and looked up and named every address a host name resolved to. It now answers 400 for more than 10 entries, or more than 10 addresses once the names are resolved, before reading the database. Each PTR lookup gets 2 seconds; one that runs out leaves the address's result without PTR names. Of a host name's addresses only public ones are kept: global unicast and not private. A name with none is reported as having no public address. The tests give the server a fake resolver. Model: opus-5-5
This commit is contained in:
@@ -9,7 +9,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -917,6 +919,224 @@ 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.
|
||||
type fakeResolver struct {
|
||||
hosts map[string][]string
|
||||
ptrNeverAnswers bool
|
||||
}
|
||||
|
||||
func (r fakeResolver) LookupHost(_ context.Context, host string) ([]string, error) {
|
||||
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.
|
||||
// The longer lookups answer 400 without reading the database; the others read
|
||||
// it once per address. Before the fix every address was read, however many
|
||||
// there were (https://git.eeqj.de/sneak/routewatch/issues/110).
|
||||
func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addrs := make([]string, maxLookupTargets+1)
|
||||
for i := range addrs {
|
||||
addrs[i] = "192.0.2." + strconv.Itoa(i+1)
|
||||
}
|
||||
|
||||
resolver := fakeResolver{hosts: map[string][]string{
|
||||
"most.test": addrs[:maxLookupTargets],
|
||||
"too-many.test": addrs,
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
target string
|
||||
wantCode int
|
||||
wantLookups int64
|
||||
}{
|
||||
{
|
||||
strings.Join(addrs[:maxLookupTargets], ","),
|
||||
http.StatusNotFound, maxLookupTargets,
|
||||
},
|
||||
{strings.Join(addrs, ","), http.StatusBadRequest, 0},
|
||||
{"most.test", http.StatusNotFound, maxLookupTargets},
|
||||
{"too-many.test", http.StatusBadRequest, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
db := &countingIPLookupDB{}
|
||||
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
|
||||
&config.Config{})
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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. 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},
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
target string
|
||||
wantCode int
|
||||
wantErrors []string
|
||||
}{
|
||||
{internalHostName, http.StatusBadRequest, nil},
|
||||
{
|
||||
internalHostName + "," + addrInTestPrefixV4, http.StatusOK,
|
||||
[]string{internalHostName + ": " + hostNotPublicMessage},
|
||||
},
|
||||
{"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)
|
||||
}
|
||||
|
||||
if tt.wantCode != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data IPLookupResponse `json:"data"`
|
||||
}
|
||||
|
||||
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 !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 {
|
||||
|
||||
Reference in New Issue
Block a user