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 was merged in pull request #134.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user