check / check (push) Canceled after 0s
Every result of /ip/, /ip/{addr} and /api/v1/ip/{ip} now has every
field. A field without a value is null instead of left out, so an
address with no live route has all its route and AS fields set to null.
ptr is an empty list when there are no PTR names.
Two new fields, num_origins and origin_asns, give how many origin ASes
peers carry the netblock with, and which. The routing table's prefix
summary now lists them. asn still names the origin that most peers
carry.
first_seen is null, no longer the zero time, when the database has not
stored the netblock yet.
Model: opus-5-5
414 lines
13 KiB
Go
414 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
)
|
|
|
|
// The origin ASes in the routing table that newTwoOriginTable fills.
|
|
const (
|
|
mostPeersASN = 64500
|
|
fewerPeersASN = 64501
|
|
v6OriginASN = 64502
|
|
)
|
|
|
|
// noKeptRoutePrefix is the prefix of newTwoOriginTable whose origin keeps no
|
|
// route.
|
|
const noKeptRoutePrefix = "198.51.100.0/24"
|
|
|
|
// routeTimeFormat is how the pages write a route's time.
|
|
const routeTimeFormat = "2006-01-02 15:04:05"
|
|
|
|
// newTwoOriginTable returns a routing table that holds:
|
|
// - testPrefixV4 with two origin ASes. Peers 1, 2 and 3 carry it with
|
|
// mostPeersASN, which keeps the routes of peers 3 and 2. Peer 4 carries
|
|
// it with fewerPeersASN, in the prefix's newest route.
|
|
// - noKeptRoutePrefix with mostPeersASN. Only peer 7 carries it, and its
|
|
// route is not kept: peers 5 and 6, whose routes were, withdrew it.
|
|
// - testPrefixV6 with v6OriginASN.
|
|
func newTwoOriginTable() *routetable.Table {
|
|
table := emptyTable()
|
|
|
|
for n := 1; n <= 3; n++ {
|
|
announce(table, testPrefixV4, mostPeersASN, testPeer(n), testRouteTime(n))
|
|
}
|
|
|
|
announce(table, testPrefixV4, fewerPeersASN, testPeer(4), testRouteTime(5))
|
|
|
|
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(5), testRouteTime(4))
|
|
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(6), testRouteTime(6))
|
|
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(7), testRouteTime(0))
|
|
withdraw(table, noKeptRoutePrefix, testPeer(5))
|
|
withdraw(table, noKeptRoutePrefix, testPeer(6))
|
|
|
|
announce(table, testPrefixV6, v6OriginASN, testPeerV6, testRouteTime(0))
|
|
|
|
return table
|
|
}
|
|
|
|
// get sends a GET for path through s's router and fails the test unless it
|
|
// answers 200.
|
|
func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, path, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: status %d, want %d; body %s",
|
|
path, rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
|
|
return rec
|
|
}
|
|
|
|
// TestIPLookupGivesTheOriginMostPeersCarry looks up an address in a prefix
|
|
// with two origin ASes, through each IP lookup route. The answer names the
|
|
// origin AS that more peers carry, counts every peer, lists both origin ASes,
|
|
// and gives the time of the prefix's newest kept route. The prefix's
|
|
// first-seen time and the AS's WHOIS details come from the database. A WHOIS
|
|
// detail the database does not hold is null.
|
|
func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTable(t, newTwoOriginTable())
|
|
s.resolver = &fakeResolver{}
|
|
|
|
firstSeen := testRouteTime(-1)
|
|
|
|
err := s.db.UpdatePrefixesBatch(map[string]time.Time{testPrefixV4: firstSeen})
|
|
if err != nil {
|
|
t.Fatalf("UpdatePrefixesBatch: %v", err)
|
|
}
|
|
|
|
_, err = s.db.GetOrCreateASN(mostPeersASN, firstSeen)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN: %v", err)
|
|
}
|
|
|
|
whois := &database.ASNWHOISUpdate{
|
|
ASN: mostPeersASN, ASName: "TEST-AS", CountryCode: "DE",
|
|
}
|
|
|
|
err = s.db.UpdateASNWHOIS(t.Context(), whois)
|
|
if err != nil {
|
|
t.Fatalf("UpdateASNWHOIS: %v", err)
|
|
}
|
|
|
|
// The details the database does not hold, such as org_name, are null.
|
|
want := IPInfo{
|
|
Netblock: new(testPrefixV4),
|
|
MaskLength: new(netip.MustParsePrefix(testPrefixV4).Bits()),
|
|
IPVersion: new(ipVersionV4),
|
|
NumPeers: new(4),
|
|
ASN: new(mostPeersASN),
|
|
NumOrigins: new(2),
|
|
OriginASNs: []int{mostPeersASN, fewerPeersASN},
|
|
ASName: &whois.ASName,
|
|
CountryCode: &whois.CountryCode,
|
|
FirstSeen: &firstSeen,
|
|
LastSeen: new(testRouteTime(5)),
|
|
}
|
|
|
|
wantJSON, err := json.Marshal(want)
|
|
if err != nil {
|
|
t.Fatalf("encoding %+v: %v", want, err)
|
|
}
|
|
|
|
// ownAddressPath looks up the request's own address, which httptest
|
|
// gives as addrInTestPrefixV4.
|
|
for _, path := range []string{
|
|
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
|
|
ownAddressPath,
|
|
} {
|
|
var body struct {
|
|
Data IPLookupResponse `json:"data"`
|
|
}
|
|
|
|
answer := get(t, s, path).Body.Bytes()
|
|
|
|
err := json.Unmarshal(answer, &body)
|
|
if err != nil || len(body.Data.Results) != 1 {
|
|
t.Fatalf("%s: answer %s (%v), want one result", path, answer, err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(body.Data.Results[0].IPInfo, want) {
|
|
t.Errorf("%s: answer %s, want the fields %s", path, answer, wantJSON)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIPLookupAsksForNoWHOISLookupOfOriginZero checks that the IP lookup asks
|
|
// for no WHOIS lookup of AS 0, which means no known origin. It asks for one of
|
|
// an AS whose WHOIS details were never fetched or are stale, and not of one
|
|
// whose details are fresh.
|
|
func TestIPLookupAsksForNoWHOISLookupOfOriginZero(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fresh := time.Now().Add(-time.Hour)
|
|
stale := time.Now().Add(-2 * whoisStaleThreshold)
|
|
|
|
tests := []struct {
|
|
name string
|
|
asInfo database.ASN
|
|
want bool
|
|
}{
|
|
{"no known origin", database.ASN{ASN: ristypes.NoOrigin}, false},
|
|
{"details never fetched", database.ASN{ASN: mostPeersASN}, true},
|
|
{"stale details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &stale}, true},
|
|
{"fresh details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &fresh}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := needsWHOISRefresh(&tt.asInfo); got != tt.want {
|
|
t.Errorf("%s: needsWHOISRefresh = %t, want %t", tt.name, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// prefixRoute is one route of the prefix API's answer. A field the answer
|
|
// leaves out stays nil.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type prefixRoute struct {
|
|
OriginASN int `json:"origin_asn"`
|
|
PeerIP string `json:"peer_ip"`
|
|
ASPath []int `json:"as_path"`
|
|
NextHop *string `json:"next_hop"`
|
|
LastUpdated *time.Time `json:"last_updated"`
|
|
}
|
|
|
|
// TestPrefixAnswersListEveryPeer asks the prefix API and page for a prefix
|
|
// with two origin ASes, and for one whose origin keeps no route. Each answer
|
|
// lists every peer that carries the prefix, by origin AS. Only the kept routes
|
|
// have an AS path, a next hop and a time, newest first. The other peers follow
|
|
// without them.
|
|
func TestPrefixAnswersListEveryPeer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTable(t, newTwoOriginTable())
|
|
|
|
// keptAt[peer] is the time of the peer's kept route; a peer not in it is
|
|
// listed without a route.
|
|
keptAt := map[string]time.Time{
|
|
testPeer(3): testRouteTime(3),
|
|
testPeer(2): testRouteTime(2),
|
|
testPeer(4): testRouteTime(5),
|
|
}
|
|
|
|
tests := []struct {
|
|
prefix string
|
|
wantPeers []string
|
|
wantOrigins []int
|
|
}{
|
|
{
|
|
testPrefixV4,
|
|
[]string{testPeer(3), testPeer(2), testPeer(1), testPeer(4)},
|
|
[]int{mostPeersASN, mostPeersASN, mostPeersASN, fewerPeersASN},
|
|
},
|
|
{noKeptRoutePrefix, []string{testPeer(7)}, []int{mostPeersASN}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
var body struct {
|
|
Data struct {
|
|
Routes []prefixRoute `json:"routes"`
|
|
PeerCount int `json:"peer_count"`
|
|
OriginCount int `json:"origin_count"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
path := "/api/v1/prefix/" + tt.prefix
|
|
|
|
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("%s: decoding the answer: %v", path, err)
|
|
}
|
|
|
|
wantOriginCount := len(slices.Compact(slices.Clone(tt.wantOrigins)))
|
|
if body.Data.PeerCount != len(tt.wantPeers) ||
|
|
body.Data.OriginCount != wantOriginCount {
|
|
t.Errorf("%s: %d peers and %d origins, want %d and %d", path,
|
|
body.Data.PeerCount, body.Data.OriginCount, len(tt.wantPeers),
|
|
wantOriginCount)
|
|
}
|
|
|
|
checkPrefixRoutes(t, path, body.Data.Routes, tt.wantPeers, tt.wantOrigins,
|
|
keptAt)
|
|
checkPrefixPage(t, s, tt.prefix, tt.wantPeers, keptAt)
|
|
}
|
|
}
|
|
|
|
// checkPrefixRoutes checks that routes come from wantPeers, in that order,
|
|
// with wantOrigins as their origin ASes. The route of a peer in keptAt must
|
|
// have an AS path, the peer as next hop, and the time keptAt gives. Any other
|
|
// route must have none of the three.
|
|
func checkPrefixRoutes(
|
|
t *testing.T, path string, routes []prefixRoute, wantPeers []string,
|
|
wantOrigins []int, keptAt map[string]time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
if len(routes) != len(wantPeers) {
|
|
t.Fatalf("%s: routes %+v, want one each from %v", path, routes, wantPeers)
|
|
}
|
|
|
|
for i, route := range routes {
|
|
if route.PeerIP != wantPeers[i] || route.OriginASN != wantOrigins[i] {
|
|
t.Errorf("%s: route %d from %s with AS%d, want from %s with AS%d",
|
|
path, i, route.PeerIP, route.OriginASN, wantPeers[i], wantOrigins[i])
|
|
}
|
|
|
|
at, kept := keptAt[route.PeerIP]
|
|
if !kept {
|
|
if route.ASPath != nil || route.NextHop != nil || route.LastUpdated != nil {
|
|
t.Errorf("%s: route from %s is not kept but has %+v",
|
|
path, route.PeerIP, route)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if route.ASPath == nil || route.NextHop == nil ||
|
|
*route.NextHop != route.PeerIP || route.LastUpdated == nil ||
|
|
!route.LastUpdated.Equal(at) {
|
|
t.Errorf("%s: kept route from %s is %+v, want its AS path, the peer "+
|
|
"as next hop and the time %v", path, route.PeerIP, route, at)
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkPrefixPage checks that the page of prefix shows each of wantPeers, the
|
|
// time of each kept route among them, and no time that was never set.
|
|
func checkPrefixPage(
|
|
t *testing.T, s *Server, prefix string, wantPeers []string,
|
|
keptAt map[string]time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
path := "/prefix/" + prefix
|
|
page := get(t, s, path).Body.String()
|
|
|
|
for _, peer := range wantPeers {
|
|
want := `<td class="peer-ip">` + peer + `</td>`
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("%s does not list the peer %s", path, peer)
|
|
}
|
|
|
|
at, kept := keptAt[peer]
|
|
if kept && !strings.Contains(page, at.Format(routeTimeFormat)) {
|
|
t.Errorf("%s does not show the time %v of the route from %s",
|
|
path, at, peer)
|
|
}
|
|
}
|
|
|
|
if strings.Contains(page, zeroDate) {
|
|
t.Errorf("%s shows the date %s", path, zeroDate)
|
|
}
|
|
}
|
|
|
|
// TestASAnswersGiveEachPrefixItsNewestKeptRoute asks the AS API and page for
|
|
// mostPeersASN. Its prefixes are testPrefixV4, whose newest kept route with
|
|
// that origin AS is peer 3's, and noKeptRoutePrefix, which keeps no route. The
|
|
// first has that route's time. The second is listed without a time.
|
|
func TestASAnswersGiveEachPrefixItsNewestKeptRoute(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTable(t, newTwoOriginTable())
|
|
path := "/api/v1/as/" + strconv.Itoa(mostPeersASN)
|
|
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
var body struct {
|
|
Data struct {
|
|
IPv4Prefixes []struct {
|
|
Prefix string `json:"prefix"`
|
|
LastUpdated *time.Time `json:"last_updated"`
|
|
} `json:"ipv4_prefixes"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
|
|
if err != nil {
|
|
t.Fatalf("%s: decoding the answer: %v", path, err)
|
|
}
|
|
|
|
prefixes := body.Data.IPv4Prefixes
|
|
if len(prefixes) != 2 || prefixes[0].Prefix != testPrefixV4 ||
|
|
prefixes[0].LastUpdated == nil ||
|
|
!prefixes[0].LastUpdated.Equal(testRouteTime(3)) ||
|
|
prefixes[1].Prefix != noKeptRoutePrefix || prefixes[1].LastUpdated != nil {
|
|
t.Errorf("%s: prefixes %+v, want %s updated %v and %s without a time",
|
|
path, prefixes, testPrefixV4, testRouteTime(3), noKeptRoutePrefix)
|
|
}
|
|
|
|
path = "/as/" + strconv.Itoa(mostPeersASN)
|
|
page := get(t, s, path).Body.String()
|
|
|
|
for _, want := range []string{
|
|
testPrefixV4, noKeptRoutePrefix, testRouteTime(3).Format(routeTimeFormat),
|
|
} {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("%s does not show %s", path, want)
|
|
}
|
|
}
|
|
|
|
if strings.Contains(page, zeroDate) {
|
|
t.Errorf("%s shows the date %s", path, zeroDate)
|
|
}
|
|
}
|
|
|
|
// TestPrefixLengthPagesShowTheOriginMostPeersCarry asks for the IPv4 /24
|
|
// page and the IPv6 /32 page. The first lists both /24 prefixes with
|
|
// mostPeersASN, the origin AS more peers carry testPrefixV4 with, and gives
|
|
// noKeptRoutePrefix no age. The second lists testPrefixV6.
|
|
func TestPrefixLengthPagesShowTheOriginMostPeersCarry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTable(t, newTwoOriginTable())
|
|
|
|
page := get(t, s, prefixLengthPagePath).Body.String()
|
|
|
|
for _, want := range []string{
|
|
">" + testPrefixV4 + "<", ">" + noKeptRoutePrefix + "<",
|
|
`href="/as/` + strconv.Itoa(mostPeersASN) + `"`,
|
|
} {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("%s does not show %s", prefixLengthPagePath, want)
|
|
}
|
|
}
|
|
|
|
if strings.Contains(page, `href="/as/`+strconv.Itoa(fewerPeersASN)+`"`) {
|
|
t.Errorf("%s links to AS%d, which fewer peers carry",
|
|
prefixLengthPagePath, fewerPeersASN)
|
|
}
|
|
|
|
const noAge = `<td class="age"></td>`
|
|
if strings.Count(page, noAge) != 1 {
|
|
t.Errorf("%s gives %d prefixes no age, want 1 (%s)",
|
|
prefixLengthPagePath, strings.Count(page, noAge), noKeptRoutePrefix)
|
|
}
|
|
|
|
const v6PagePath = "/prefixlength6/32"
|
|
|
|
page = get(t, s, v6PagePath).Body.String()
|
|
if !strings.Contains(page, ">"+testPrefixV6+"<") ||
|
|
!strings.Contains(page, `href="/as/`+strconv.Itoa(v6OriginASN)+`"`) {
|
|
t.Errorf("%s does not show %s with AS%d", v6PagePath, testPrefixV6,
|
|
v6OriginASN)
|
|
}
|
|
}
|