check / check (push) Successful in 1m34s
`internal/ptr` asks Google, Cloudflare, AdGuard and Quad9 in turn, 2 seconds in all per address. It keeps answers in memory and writes them through to `cache.db`, opened by the new `internal/cachedb`. A reserved address is not sent to the resolvers. Judgement calls: - Each resolver gets an equal share of the time left, so a hung resolver does not use up the 2 seconds. - The negative TTL is the smaller of the SOA minimum and the SOA's own TTL (RFC 2308). - Names that are not host names are dropped, as `net.Resolver.LookupAddr` does. 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())
|
|
useFakeResolver(s, &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)
|
|
}
|
|
}
|