Read every route from the routing table in memory (part of #122)
check / check (push) Canceled after 0s

The IP lookups, the AS and prefix pages and their API answers, the prefix-length pages and the stats read routes only from the routing table the prefix handler keeps. fx reads the table at startup and gives it to both. AS names, WHOIS details and first-seen times still come from the database.

The IP lookup names the origin AS most peers carry, with the time of the newest kept route. The prefix answers list peers whose routes are not kept without a path or time. The stats gain origins_without_kept_route. They no longer read route times from the per-peer tables, so the two helpers that did are gone.

Model: opus-5-5
This commit is contained in:
2026-10-27 15:07:46 +00:00
parent 2c304a0022
commit 16db3250bf
18 changed files with 1335 additions and 656 deletions
+230 -246
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"runtime"
"slices"
"strconv"
@@ -23,6 +24,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
)
@@ -35,6 +37,38 @@ const (
testPeerV6 = "2001:db8::1"
)
// testPeer returns the address of the nth of the IPv4 peers that tests
// announce routes from: 198.51.100.n.
func testPeer(n int) string {
return "198.51.100." + strconv.Itoa(n)
}
// testRouteTime is the time the tests' routes are announced at, or a number of
// minutes after it.
func testRouteTime(minutes int) time.Time {
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
Add(time.Duration(minutes) * time.Minute)
}
// announce has peer announce prefix in table at time at, with an AS path
// that holds only the origin AS asn.
func announce(
table *routetable.Table, prefix string, asn int, peer string, at time.Time,
) {
table.Announce(netip.MustParsePrefix(prefix), asn,
routetable.Route{Peer: peer, ASPath: []int{asn}, Time: at})
}
// withdraw has peer withdraw prefix in table.
func withdraw(table *routetable.Table, prefix, peer string) {
table.Withdraw(netip.MustParsePrefix(prefix), peer)
}
// emptyTable returns a routing table that holds no route.
func emptyTable() *routetable.Table {
return routetable.New(routetable.Params{})
}
// Paths several tests request.
const (
asPagePath = "/as/64500"
@@ -76,7 +110,8 @@ func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, err
func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
release := make(chan struct{})
db := blockingStatsDB{release: release}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := New(db, emptyTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
handlers := map[string]http.HandlerFunc{
statusJSONHandlerName: s.handleStatusJSON(),
@@ -108,51 +143,78 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
}
}
// tableStats holds the figures of a stats answer that come from the routing
// table.
//
//nolint:tagliatelle // field names are the existing JSON API
type tableStats struct {
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
OldestRoute time.Time `json:"oldest_route"`
NewestRoute time.Time `json:"newest_route"`
IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
}
// TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200
// with the live route counts and the prefix distribution while the database is
// closed, so that any query would fail: the request path reads them from
// memory. The prefix distribution query it used to run read every live route
// and, on a large database, took the whole 4-second deadline, so
// with the routing table's figures while the database is closed, so that any
// query would fail. The prefix distribution query they used to run read every
// live route and, on a large database, took the whole 4-second deadline, so
// /api/v1/stats answered 500 (https://git.eeqj.de/sneak/routewatch/issues/30).
// The oldest and newest route times still come from one-row lookups at the ends
// of an index; with the database closed they are left out of the answer.
//
// The table holds two IPv4 prefixes and an IPv6 one. Of the three peers that
// carried 203.0.113.0/24, the two with kept routes withdrew it, so its origin
// keeps no route. The oldest and newest routes are those of the other two
// prefixes.
func TestStatsHandlersAnswerFromMemory(t *testing.T) {
t.Parallel()
const withdrawnPrefix = "203.0.113.0/24"
table := emptyTable()
announce(table, testPrefixV4, 64502, testPeer(1), testRouteTime(0))
for n := 1; n <= 3; n++ {
announce(table, withdrawnPrefix, 64500, testPeer(n), testRouteTime(n))
}
withdraw(table, withdrawnPrefix, testPeer(2))
withdraw(table, withdrawnPrefix, testPeer(3))
announce(table, testPrefixV6, 64501, testPeerV6, testRouteTime(4))
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
Prefix: "198.51.100.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500},
NextHop: "192.0.2.1", LastUpdated: ts,
},
{
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts,
},
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
err = db.Close()
if err != nil {
t.Fatalf("Close: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
handlers := map[string]http.HandlerFunc{
statusJSONHandlerName: s.handleStatusJSON(),
statsHandlerName: s.handleStats(),
}
want := tableStats{
Prefixes: 3, IPv4Prefixes: 2, IPv6Prefixes: 1,
LiveRoutes: 3, IPv4Routes: 2, IPv6Routes: 1,
OriginsWithoutKeptRoute: 1,
OldestRoute: testRouteTime(0),
NewestRoute: testRouteTime(4),
IPv4Distribution: []database.PrefixDistribution{{MaskLength: 24, Count: 2}},
IPv6Distribution: []database.PrefixDistribution{{MaskLength: 32, Count: 1}},
}
for name, handler := range handlers {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil))
@@ -164,14 +226,8 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
continue
}
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
} `json:"data"`
Data tableStats `json:"data"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
@@ -179,21 +235,16 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
t.Fatalf("%s: decoding the answer: %v", name, err)
}
if body.Data.IPv4Routes != 1 || body.Data.IPv6Routes != 1 {
t.Errorf("%s: routes = (v4 %d, v6 %d), want (1, 1)",
name, body.Data.IPv4Routes, body.Data.IPv6Routes)
got := body.Data
if !got.OldestRoute.Equal(want.OldestRoute) ||
!got.NewestRoute.Equal(want.NewestRoute) {
t.Errorf("%s: routes from %v to %v, want from %v to %v", name,
got.OldestRoute, got.NewestRoute, want.OldestRoute, want.NewestRoute)
}
wantV4 := []database.PrefixDistribution{{MaskLength: 24, Count: 1}}
if !slices.Equal(body.Data.IPv4Distribution, wantV4) {
t.Errorf("%s: IPv4 distribution = %v, want %v",
name, body.Data.IPv4Distribution, wantV4)
}
wantV6 := []database.PrefixDistribution{{MaskLength: 32, Count: 1}}
if !slices.Equal(body.Data.IPv6Distribution, wantV6) {
t.Errorf("%s: IPv6 distribution = %v, want %v",
name, body.Data.IPv6Distribution, wantV6)
got.OldestRoute, got.NewestRoute = want.OldestRoute, want.NewestRoute
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: figures %+v, want %+v", name, got, want)
}
}
}
@@ -243,11 +294,29 @@ func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
}
}
// newServerWithTestRoutes returns a server on a new database that holds a
// route for testPrefixV4 and one for testPrefixV6.
// newTestTable returns a routing table that holds a route for testPrefixV4,
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
func newTestTable() *routetable.Table {
table := emptyTable()
announce(table, testPrefixV4, 64500, testPeerV4, testRouteTime(0))
announce(table, testPrefixV6, 64501, testPeerV6, testRouteTime(0))
return table
}
// newServerWithTestRoutes returns a server on a new database that reads the
// routes of newTestTable.
func newServerWithTestRoutes(t *testing.T) *Server {
t.Helper()
return newServerWithTable(t, newTestTable())
}
// newServerWithTable returns a server on a new database that reads routes from
// table.
func newServerWithTable(t *testing.T, table *routetable.Table) *Server {
t.Helper()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
@@ -255,25 +324,7 @@ func newServerWithTestRoutes(t *testing.T) *Server {
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
NextHop: testPeerV4, LastUpdated: ts,
},
{
Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts,
},
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
return New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
return New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
}
@@ -402,34 +453,20 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
origins := []int{15169, 13335}
table := emptyTable()
for _, asn := range origins {
_, err := db.GetOrCreateASN(asn, ts)
for i, asn := range origins {
_, err := db.GetOrCreateASN(asn, testRouteTime(0))
if err != nil {
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
}
announce(table, testPrefixV4, asn, testPeer(i+1), testRouteTime(0))
}
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: origins[0], PeerIP: testPeerV4,
ASPath: []int{origins[0]}, NextHop: testPeerV4, LastUpdated: ts,
},
{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: origins[1], PeerIP: "198.51.100.2",
ASPath: []int{origins[1]}, NextHop: "198.51.100.2", LastUpdated: ts,
},
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(failingASDetailsDB{db}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
s := New(failingASDetailsDB{db}, table,
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
@@ -466,23 +503,14 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
table := emptyTable()
table.Announce(netip.MustParsePrefix(testPrefixV4), ristypes.NoOrigin,
routetable.Route{
Peer: testPeerV4, ASPath: []int{64500, 64501, 64502},
Time: testRouteTime(0),
})
t.Cleanup(func() { _ = db.Close() })
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: 0, PeerIP: testPeerV4, ASPath: []int{64500, 64501, 64502},
NextHop: testPeerV4, LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := newServerWithTable(t, table)
for _, path := range []string{"/prefix/" + testPrefixV4, prefixLengthPagePath} {
rec := httptest.NewRecorder()
@@ -533,8 +561,9 @@ func TestIndexShowsTheClientAddress(t *testing.T) {
}
for _, tt := range tests {
// The home page reads nothing from the database, so the server has none.
s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(),
// The home page reads no route and nothing from the database, so the
// server has neither a routing table nor a database.
s := New(nil, nil, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{TrustedProxies: tt.trustedProxies})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
@@ -571,13 +600,6 @@ func TestIndexShowsTheClientAddress(t *testing.T) {
func TestPagesShowAgesInWords(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Each age is well past a whole number of its unit, so the time the test
// takes to run does not change the words.
ages := []struct {
@@ -591,22 +613,13 @@ func TestPagesShowAgesInWords(t *testing.T) {
}
now := time.Now().UTC()
routes := make([]*database.LiveRoute, 0, len(ages))
table := emptyTable()
for _, a := range ages {
routes = append(routes, &database.LiveRoute{
Prefix: a.prefix, MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
NextHop: testPeerV4, LastUpdated: now.Add(-a.age),
})
announce(table, a.prefix, 64500, testPeerV4, now.Add(-a.age))
}
err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := newServerWithTable(t, table)
for _, a := range ages {
cell := `<td class="age">` + a.want + `</td>`
@@ -643,7 +656,7 @@ const shortRequestDeadline = 50 * time.Millisecond
func TestRequestPastTimeLimitAnswers504(t *testing.T) {
t.Parallel()
s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(),
s := New(nil, nil, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
waitForTimeLimit := func(_ http.ResponseWriter, r *http.Request) {
@@ -693,8 +706,8 @@ func TestStatsHandlersAnswer504WhenDatabaseReadRunsOut(t *testing.T) {
defer close(release)
db := blockingStatsDB{release: release}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(db, emptyTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
handlers := map[string]http.HandlerFunc{
statusJSONHandlerName: s.handleStatusJSON(),
@@ -720,10 +733,9 @@ func TestStatsHandlersAnswer504WhenDatabaseReadRunsOut(t *testing.T) {
// slowDB embeds database.Store (left nil) and overrides the reads the routes
// in TestRoutesAnswer504WhenDatabaseReadIsCutOff make. Each read waits until
// its context ends and then answers as the real store does when the time
// limit cuts it off: the IP read with the context's error, the prefix read
// that there is no route, and the others with what they had read, here
// nothing, and no error. Every other Store method is unused and would panic
// if called.
// limit cuts it off: the stats read with what it had read, here nothing, and
// no error, and the others with the context's error. Every other Store method
// is unused and would panic if called.
type slowDB struct {
database.Store
}
@@ -734,66 +746,44 @@ func (slowDB) GetStatsContext(ctx context.Context) (database.Stats, error) {
return database.Stats{}, nil
}
func (slowDB) GetIPInfoContext(
ctx context.Context, _ string,
) (*database.IPInfo, error) {
func (slowDB) GetAS(ctx context.Context, _ int) (*database.ASN, bool, error) {
<-ctx.Done()
return nil, ctx.Err()
return nil, false, ctx.Err()
}
func (slowDB) GetASDetailsContext(
ctx context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
func (slowDB) GetPrefixFirstSeen(
ctx context.Context, _ netip.Prefix,
) (time.Time, error) {
<-ctx.Done()
return &database.ASN{ASN: asn}, nil, nil
}
func (slowDB) GetPrefixDetailsContext(
ctx context.Context, _ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
<-ctx.Done()
return nil, nil, database.ErrNoRoute
}
func (slowDB) GetRandomPrefixesByLengthContext(
ctx context.Context, _, _, _ int,
) ([]database.LiveRoute, error) {
<-ctx.Done()
return nil, nil
return time.Time{}, ctx.Err()
}
// TestRoutesAnswer504WhenDatabaseReadIsCutOff sends a request through the
// server's router for each route that reads the database, with a store whose
// reads are cut off when the request's time limit passes. Each route answers
// 504 with the time limit message. Without its check after the read, a route
// answers with what the cut-off read returned: 500, 404, 200, or, on a page
// that stops before rendering once the request has ended, 504 with an empty
// body. The health check is left out: it answers 503 when its own 2-second
// ping runs out.
// answers with what the cut-off read returned: 500 or 200. The health check is
// left out: it answers 503 when its own 2-second ping runs out. The prefix
// length pages and the prefix API read only the routing table.
func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
t.Parallel()
s := New(slowDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(slowDB{}, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
tests := []struct {
path string
wantJSONBody bool
}{
{path: asPagePath},
{path: "/prefix/192.0.2.0/24"},
{path: prefixLengthPagePath},
{path: "/prefixlength6/48"},
{path: "/prefix/" + testPrefixV4},
{path: statusJSONPath, wantJSONBody: true},
{path: "/ip/192.0.2.1", wantJSONBody: true},
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: statsPath, wantJSONBody: true},
{path: "/api/v1/ip/192.0.2.1", wantJSONBody: true},
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: "/api/v1/as/64500", wantJSONBody: true},
{path: "/api/v1/prefix/192.0.2.0/24", wantJSONBody: true},
}
for _, tt := range tests {
@@ -810,10 +800,8 @@ type slowSecondReadDB struct {
database.Store
}
func (slowSecondReadDB) GetASDetailsContext(
_ context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
return &database.ASN{ASN: asn}, nil, nil
func (slowSecondReadDB) GetAS(_ context.Context, asn int) (*database.ASN, bool, error) {
return &database.ASN{ASN: asn}, true, nil
}
func (slowSecondReadDB) GetASPeersContext(
@@ -855,8 +843,8 @@ func (idleASNFetcher) GetStats() ASNFetcherStats {
func TestRoutesAnswer504WhenSecondDatabaseReadIsCutOff(t *testing.T) {
t.Parallel()
s := New(slowSecondReadDB{}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
s := New(slowSecondReadDB{}, emptyTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s.SetASNFetcher(idleASNFetcher{})
tests := []struct {
@@ -950,34 +938,27 @@ func (failingDB) GetStatsContext(_ context.Context) (database.Stats, error) {
return database.Stats{}, errDiskIO
}
func (failingDB) GetIPInfoContext(
_ context.Context, _ string,
) (*database.IPInfo, error) {
return nil, errDiskIO
func (failingDB) GetAS(_ context.Context, _ int) (*database.ASN, bool, error) {
return nil, false, errDiskIO
}
func (failingDB) GetASDetailsContext(
_ context.Context, _ int,
) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errDiskIO
func (failingDB) GetPrefixFirstSeen(
_ context.Context, _ netip.Prefix,
) (time.Time, error) {
return time.Time{}, errDiskIO
}
func (failingDB) GetPrefixDetailsContext(
_ context.Context, _ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return nil, nil, errDiskIO
}
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route and
// the health check with a store whose reads fail with the driver's error text,
// and a prefix that does not parse, and checks that no answer carries the
// driver's or the prefix parser's text. Before the fix the answers sent it to
// the client (https://git.eeqj.de/sneak/routewatch/issues/108).
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route that
// reads the database, and to the health check, with a store whose reads fail
// with the driver's error text. It also sends a prefix that does not parse.
// No answer may carry the driver's or the prefix parser's text. Before the fix
// the answers sent it to the client
// (https://git.eeqj.de/sneak/routewatch/issues/108).
func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
t.Parallel()
s := New(failingDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(failingDB{}, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
tests := []struct {
path string
@@ -987,8 +968,7 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
{statusJSONPath, http.StatusInternalServerError},
{statsPath, http.StatusInternalServerError},
{"/api/v1/as/64500", http.StatusInternalServerError},
{"/api/v1/prefix/192.0.2.0/24", http.StatusInternalServerError},
{"/api/v1/ip/192.0.2.1", http.StatusInternalServerError},
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
}
@@ -1011,45 +991,47 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
}
}
// The addresses failingIPLookupDB fails the read of and finds a route for.
// The address whose read failingIPLookupDB fails, in testPrefixV4, and one it
// reads, in testPrefixV6.
const (
failingLookupIP = "192.0.2.1"
routedLookupIP = "192.0.2.2"
failingLookupIP = addrInTestPrefixV4
routedLookupIP = addrInTestPrefixV6
)
// failingIPLookupDB embeds database.Store (left nil) and answers the IP lookup
// with errDiskIO for failingLookupIP, a route for routedLookupIP, and no route
// for any other address. Every other Store method is unused and would panic if
// called.
// failingIPLookupDB embeds database.Store (left nil). It fails the read of
// testPrefixV4's first-seen time with errDiskIO, and answers the IP lookup's
// other reads with nothing stored. Every other Store method is unused and
// would panic if called.
type failingIPLookupDB struct {
database.Store
}
func (failingIPLookupDB) GetIPInfoContext(
_ context.Context, ip string,
) (*database.IPInfo, error) {
switch ip {
case failingLookupIP:
return nil, errDiskIO
case routedLookupIP:
return &database.IPInfo{IP: ip}, nil
default:
return nil, database.ErrNoRoute
func (failingIPLookupDB) GetPrefixFirstSeen(
_ context.Context, prefix netip.Prefix,
) (time.Time, error) {
if prefix == netip.MustParsePrefix(testPrefixV4) {
return time.Time{}, errDiskIO
}
return time.Time{}, nil
}
func (failingIPLookupDB) GetASWHOIS(_ context.Context, asn int) (*database.ASN, error) {
return &database.ASN{ASN: asn}, nil
}
// TestIPLookupAnswers500WhenDatabaseReadFails sends IP lookups through the
// server's router with a store whose read of one address fails with the
// server's router with a store whose read for one address fails with the
// driver's error. A lookup that asks for that address answers 500 with the
// JSON error body, as the AS and prefix JSON routes do; an address with no
// route answers 200. Before the fix the failed read was reported as if the
// address had no route: 404 for that address alone, and 200 beside a routed
// address (https://git.eeqj.de/sneak/routewatch/issues/109).
// JSON error body, as the AS JSON route does; an address with no route answers
// 200. Before the fix the failed read was reported as if the address had no
// route: 404 for that address alone, and 200 beside a routed address
// (https://git.eeqj.de/sneak/routewatch/issues/109).
func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
t.Parallel()
s := New(failingIPLookupDB{}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
s := New(failingIPLookupDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s.resolver = &fakeResolver{}
tests := []struct {
@@ -1122,30 +1104,37 @@ func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, erro
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.
// countingIPLookupDB embeds database.Store (left nil) and answers the IP
// lookup's reads with nothing stored. It counts the lookups, as the reads of a
// prefix's first-seen time. 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) {
func (d *countingIPLookupDB) GetPrefixFirstSeen(
_ context.Context, _ netip.Prefix,
) (time.Time, error) {
d.lookups.Add(1)
return nil, database.ErrNoRoute
return time.Time{}, nil
}
func (d *countingIPLookupDB) GetASWHOIS(
_ context.Context, asn int,
) (*database.ASN, error) {
return &database.ASN{ASN: asn}, nil
}
// 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).
// addresses in testPrefixV4 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 for each
// 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()
@@ -1180,8 +1169,8 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
for _, tt := range tests {
db := &countingIPLookupDB{}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
resolver := &fakeResolver{hosts: hosts}
s.resolver = resolver
@@ -1901,16 +1890,11 @@ func TestNoOriginHasNoASAnswers(t *testing.T) {
const noOriginPrefix = "203.0.113.0/24"
s := newServerWithTestRoutes(t)
err := s.db.UpsertLiveRouteBatch([]*database.LiveRoute{{
Prefix: noOriginPrefix, MaskLength: 24, IPVersion: 4,
OriginASN: ristypes.NoOrigin, PeerIP: testPeerV4,
ASPath: []int{64500, 64501, 64502}, NextHop: testPeerV4,
LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s.table.Announce(netip.MustParsePrefix(noOriginPrefix), ristypes.NoOrigin,
routetable.Route{
Peer: testPeerV4, ASPath: []int{64500, 64501, 64502},
Time: testRouteTime(0),
})
asn := strconv.Itoa(ristypes.NoOrigin)