check / check (push) Canceled after 0s
The home page gets cards for the newest and oldest kept route, the newest and oldest updated AS, the AS with the most prefixes, the ASes with a single prefix and fifteen well-known networks. Two more cards count the bogon announcements and the prefixes with several origin ASes. They link to the new pages /bogons and /multiple-origins, which list up to 1,000 entries each. The new package internal/bogon tells reserved prefixes and AS numbers, from the IANA special-purpose and address space registries. Table.Overview walks the table and lets the read lock go every 10,000 prefixes. The server works the cards out once a minute and serves that copy. A new index on asns(last_seen) finds the newest and oldest updated AS. Model: opus-5-5
359 lines
10 KiB
Go
359 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
)
|
|
|
|
// The ASes of the routing table that newCardsTable fills. Each card test
|
|
// finds one of them on its card.
|
|
const (
|
|
twoPrefixASN = 13335 // originates two prefixes, the most
|
|
newestRouteASN = 15169 // the newest route
|
|
oldestRouteASN = 3 // the oldest route
|
|
secondOrigin = 16509 // a second origin AS of twoPrefixASN's prefix
|
|
privateUseASN = 64512 // reserved, so its route is a bogon announcement
|
|
)
|
|
|
|
// Paths of the pages that list what two of the home page's cards count.
|
|
const (
|
|
bogonsPath = "/bogons"
|
|
multipleOriginsPath = "/multiple-origins"
|
|
)
|
|
|
|
// cardsNotReadyText is what the pages say before the cards are first worked
|
|
// out.
|
|
const cardsNotReadyText = "being worked out"
|
|
|
|
// newCardsTable returns a routing table in which:
|
|
// - twoPrefixASN originates 1.0.0.0/24 and 1.0.4.0/22;
|
|
// - secondOrigin originates 1.0.0.0/24 too, which so has two origin ASes;
|
|
// - newestRouteASN's 8.8.8.0/24 has the newest route;
|
|
// - oldestRouteASN's 18.0.0.0/8 has the oldest;
|
|
// - privateUseASN originates 10.0.0.0/8, carried by two peers: a bogon
|
|
// announcement for both its prefix and its AS.
|
|
//
|
|
// The last four ASes originate one prefix each.
|
|
func newCardsTable() *routetable.Table {
|
|
table := emptyTable()
|
|
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(10))
|
|
announce(table, "1.0.4.0/22", twoPrefixASN, testPeer(1), testRouteTime(5))
|
|
announce(table, "1.0.0.0/24", secondOrigin, testPeer(3), testRouteTime(12))
|
|
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(20))
|
|
announce(table, "18.0.0.0/8", oldestRouteASN, testPeer(2), testRouteTime(1))
|
|
announce(table, "10.0.0.0/8", privateUseASN, testPeer(1), testRouteTime(15))
|
|
announce(table, "10.0.0.0/8", privateUseASN, testPeer(2), testRouteTime(15))
|
|
|
|
return table
|
|
}
|
|
|
|
// homeCard returns the HTML of the home page's card with heading, up to the
|
|
// end of the card.
|
|
func homeCard(t *testing.T, page, heading string) string {
|
|
t.Helper()
|
|
|
|
_, card, found := strings.Cut(page, "<h2>"+heading+"</h2>")
|
|
if !found {
|
|
t.Fatalf("the home page has no card %q", heading)
|
|
}
|
|
|
|
card, _, _ = strings.Cut(card, "</div>")
|
|
|
|
return card
|
|
}
|
|
|
|
// asLink returns the HTML attribute of a link to the AS page of asn.
|
|
func asLink(asn int) string {
|
|
return `href="/as/` + strconv.Itoa(asn) + `"`
|
|
}
|
|
|
|
// TestHomePageCardsShowTheTableFigures fills a routing table and the asns
|
|
// table, works out the cards, and checks that each card shows its figure.
|
|
func TestHomePageCardsShowTheTableFigures(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
newestSeenASN = 6939
|
|
oldestSeenASN = 174
|
|
)
|
|
|
|
s := newServerWithTable(t, newCardsTable())
|
|
|
|
err := s.db.GetOrCreateASNBatch(map[int]database.SeenTimes{
|
|
newestSeenASN: {First: testRouteTime(0), Last: testRouteTime(30)},
|
|
oldestSeenASN: {First: testRouteTime(-30), Last: testRouteTime(-30)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASNBatch: %v", err)
|
|
}
|
|
|
|
s.refreshHomeCards(t.Context())
|
|
|
|
page := get(t, s, "/").Body.String()
|
|
|
|
tests := []struct {
|
|
heading string
|
|
want []string
|
|
}{
|
|
{"Newest Updated Route", []string{
|
|
"8.8.8.0/24", asLink(newestRouteASN),
|
|
testRouteTime(20).Format(routeTimeFormat),
|
|
}},
|
|
{"Oldest Updated Route", []string{
|
|
"18.0.0.0/8", asLink(oldestRouteASN),
|
|
testRouteTime(1).Format(routeTimeFormat),
|
|
}},
|
|
{"Newest Updated AS", []string{
|
|
asLink(newestSeenASN), testRouteTime(30).Format(routeTimeFormat),
|
|
}},
|
|
{"Oldest Updated AS", []string{
|
|
asLink(oldestSeenASN), testRouteTime(-30).Format(routeTimeFormat),
|
|
}},
|
|
{"AS with the Most Prefixes", []string{
|
|
asLink(twoPrefixASN), "Prefixes it originates: 2",
|
|
}},
|
|
{"ASes with the Fewest Prefixes", []string{
|
|
"ASes that originate a single prefix: 4", asLink(newestRouteASN),
|
|
"8.8.8.0/24",
|
|
}},
|
|
{"Bogon Announcements", []string{`href="/bogons">1<`}},
|
|
{"Prefixes with Several Origins", []string{`href="/multiple-origins">1<`}},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
card := homeCard(t, page, tc.heading)
|
|
for _, want := range tc.want {
|
|
if !strings.Contains(card, want) {
|
|
t.Errorf("card %q does not hold %s: %s", tc.heading, want, card)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The well-known networks come newest update first, and those without
|
|
// a route after them.
|
|
networks := homeCard(t, page, "Well-Known Networks")
|
|
order := []int{newestRouteASN, secondOrigin, twoPrefixASN, oldestRouteASN, 714}
|
|
last := -1
|
|
|
|
for _, asn := range order {
|
|
i := strings.Index(networks, asLink(asn))
|
|
if i <= last {
|
|
t.Fatalf("well-known networks: AS%d is not after the ASes before "+
|
|
"it in %v: %s", asn, order, networks)
|
|
}
|
|
|
|
last = i
|
|
}
|
|
|
|
if !strings.Contains(networks, "<td>2</td>") {
|
|
t.Errorf("well-known networks: no network with 2 prefixes: %s", networks)
|
|
}
|
|
}
|
|
|
|
// TestListPagesListTheirEntries checks the rows of the bogon and multiple-origin
|
|
// pages for the table of newCardsTable.
|
|
func TestListPagesListTheirEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newServerWithTable(t, newCardsTable())
|
|
s.refreshHomeCards(t.Context())
|
|
|
|
pages := map[string][]string{
|
|
bogonsPath: {
|
|
"Bogon announcements: 1.", "10.0.0.0/8", asLink(privateUseASN),
|
|
"<td>2</td>",
|
|
"Private-Use (10.0.0.0/8); Private-Use (AS64512-AS65534)",
|
|
},
|
|
multipleOriginsPath: {
|
|
"Prefixes announced by more than one origin AS: 1.", "1.0.0.0/24",
|
|
asLink(twoPrefixASN), asLink(secondOrigin), ">1 peer<",
|
|
},
|
|
}
|
|
|
|
for path, wants := range pages {
|
|
page := get(t, s, path).Body.String()
|
|
for _, want := range wants {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("%s does not hold %s", path, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestListPagesListAtMostMaxListedEntries fills a table with more prefixes than
|
|
// the list pages list, each announced by two origin ASes in reserved address
|
|
// space. Each page lists maxListedEntries of them and gives how many there are
|
|
// in all.
|
|
func TestListPagesListAtMostMaxListedEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const prefixCount = maxListedEntries + 1
|
|
|
|
table := emptyTable()
|
|
addr := netip.MustParseAddr("10.0.0.0")
|
|
|
|
for range prefixCount {
|
|
prefix := netip.PrefixFrom(addr, addr.BitLen()).String()
|
|
announce(table, prefix, twoPrefixASN, testPeer(1), testRouteTime(0))
|
|
announce(table, prefix, secondOrigin, testPeer(2), testRouteTime(0))
|
|
|
|
addr = addr.Next()
|
|
}
|
|
|
|
s := newServerWithTable(t, table)
|
|
s.refreshHomeCards(t.Context())
|
|
|
|
listed := "The first " + strconv.Itoa(maxListedEntries) + " are listed"
|
|
tests := []struct {
|
|
path string
|
|
total string
|
|
}{
|
|
{bogonsPath, "Bogon announcements: " + strconv.Itoa(2*prefixCount) + "."},
|
|
{multipleOriginsPath, "Prefixes announced by more than one origin AS: " +
|
|
strconv.Itoa(prefixCount) + "."},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
page := get(t, s, tc.path).Body.String()
|
|
|
|
rows := strings.Count(page, `class="prefix-link"`)
|
|
if rows != maxListedEntries {
|
|
t.Errorf("%s lists %d entries, want %d", tc.path, rows,
|
|
maxListedEntries)
|
|
}
|
|
|
|
if !strings.Contains(page, tc.total) || !strings.Contains(page, listed) {
|
|
t.Errorf("%s does not say %q and %q", tc.path, tc.total, listed)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPagesServeTheCopyOfTheCards checks that the pages show the cards as they
|
|
// were last worked out, not as the routing table is now, and that they say so
|
|
// before the cards are first worked out.
|
|
func TestPagesServeTheCopyOfTheCards(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
table := emptyTable()
|
|
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
|
|
|
|
s := newServerWithTable(t, table)
|
|
|
|
for _, path := range []string{"/", bogonsPath, multipleOriginsPath} {
|
|
if !strings.Contains(get(t, s, path).Body.String(), cardsNotReadyText) {
|
|
t.Errorf("%s does not say the cards are %s", path, cardsNotReadyText)
|
|
}
|
|
}
|
|
|
|
s.refreshHomeCards(t.Context())
|
|
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
|
|
|
|
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
|
|
if !strings.Contains(card, "1.0.0.0/24") {
|
|
t.Errorf("before the cards are worked out again, the newest route "+
|
|
"is not the one they were worked out with: %s", card)
|
|
}
|
|
|
|
s.refreshHomeCards(t.Context())
|
|
|
|
card = homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
|
|
if !strings.Contains(card, "8.8.8.0/24") {
|
|
t.Errorf("after the cards are worked out again, the newest route is "+
|
|
"not the table's: %s", card)
|
|
}
|
|
}
|
|
|
|
// TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh with
|
|
// a short interval. It works the cards out at once, again after the table
|
|
// changes, and stops when its context ends.
|
|
func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
interval = 10 * time.Millisecond
|
|
maxWait = 5 * time.Second
|
|
)
|
|
|
|
table := emptyTable()
|
|
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
|
|
|
|
s := newServerWithTable(t, table)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
s.keepHomeCardsFresh(ctx, interval)
|
|
close(stopped)
|
|
}()
|
|
|
|
newestRouteASNIs := func(asn int) bool {
|
|
cards := s.cards.Load()
|
|
|
|
return cards != nil && cards.NewestRoute != nil &&
|
|
cards.NewestRoute.OriginASN == asn
|
|
}
|
|
|
|
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(twoPrefixASN) })
|
|
|
|
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
|
|
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(newestRouteASN) })
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(maxWait):
|
|
t.Fatal("keepHomeCardsFresh did not stop when its context ended")
|
|
}
|
|
}
|
|
|
|
// TestStartedServerWorksOutTheCards starts a server and waits until its home
|
|
// page shows the cards.
|
|
func TestStartedServerWorksOutTheCards(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const maxWait = 5 * time.Second
|
|
|
|
s := newServerWithTable(t, newCardsTable())
|
|
|
|
err := s.Start(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
defer func() { _ = s.Stop(context.Background()) }()
|
|
|
|
waitUntil(t, maxWait, func() bool {
|
|
return !strings.Contains(get(t, s, "/").Body.String(), cardsNotReadyText)
|
|
})
|
|
|
|
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
|
|
if !strings.Contains(card, "8.8.8.0/24") {
|
|
t.Errorf("the newest route is not the table's: %s", card)
|
|
}
|
|
}
|
|
|
|
// waitUntil fails the test unless done reports true within maxWait.
|
|
func waitUntil(t *testing.T, maxWait time.Duration, done func() bool) {
|
|
t.Helper()
|
|
|
|
const pollInterval = 5 * time.Millisecond
|
|
|
|
deadline := time.Now().Add(maxWait)
|
|
for !done() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("the cards did not change within %s", maxWait)
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
}
|