check / check (push) Canceled after 0s
The routing table in memory now holds every route, so the two per-peer route tables go, with every write, delete, count and read that served them. The prefix handler applies each update to the table, writes the table's changes every second, and stores each new prefix with its first-seen time. The peer delete, its retry on the next flush and the updates held for it go too. The types and errors of the server's answers move to internal/server. The image's memory settings fit a full table of about 1 GB: GOMEMLIMIT of 2.5 GiB, a 512 MiB page cache for the writing connection, and SQLite heap limits of 768 MiB and 1 GiB. The README describes the new table, its database copy and the memory budget. Model: opus-5-5
315 lines
9.4 KiB
Go
315 lines
9.4 KiB
Go
package routewatch
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
)
|
|
|
|
const testPeerIP = "2001:db8:ffff::1"
|
|
|
|
// An IPv4 and an IPv6 prefix the tests announce.
|
|
const (
|
|
testV4Prefix = "192.0.2.0/24"
|
|
testV6Prefix = "2001:db8:1::/48"
|
|
)
|
|
|
|
// secondUpdateAfter is how long after the first update the tests of first_seen
|
|
// and last_seen send the second, in the same batch.
|
|
const secondUpdateAfter = 5 * time.Second
|
|
|
|
// TestPrefixHandlerStoresPrefixesTheIPLookupFinds runs announcements and
|
|
// withdrawals through the prefix handler into a real database. The prefixes
|
|
// are written the way the feed sends them: IPv6 announcements compressed, IPv6
|
|
// withdrawals uncompressed. The IP lookup must find each announced prefix in
|
|
// the routing table, and its first-seen time in the prefix tables. The
|
|
// withdrawal must remove the peer's route and store no second form of the
|
|
// prefix.
|
|
func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The two prefixes the announcement names.
|
|
const wantPrefixes = 2
|
|
|
|
db := newTestDatabase(t)
|
|
h := prefixHandlerWithoutTimer(db)
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
h.HandleMessage(&ristypes.RISMessage{
|
|
Peer: testPeerIP,
|
|
Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}},
|
|
ParsedTimestamp: ts,
|
|
Announcements: []ristypes.RISAnnouncement{{
|
|
NextHop: testPeerIP,
|
|
Prefixes: []string{testV6Prefix, testV4Prefix},
|
|
}},
|
|
})
|
|
h.flushBatch()
|
|
|
|
for ip, want := range map[string]string{
|
|
"2001:db8:1::1": testV6Prefix,
|
|
"192.0.2.1": testV4Prefix,
|
|
} {
|
|
summary, found := h.table.Lookup(netip.MustParseAddr(ip))
|
|
if !found || summary.Prefix.String() != want ||
|
|
summary.OriginASN != testASNB {
|
|
t.Errorf("Lookup(%s) after announcement = %+v, %t; want %s AS%d",
|
|
ip, summary, found, want, testASNB)
|
|
}
|
|
|
|
firstSeen, err := db.GetPrefixFirstSeen(t.Context(),
|
|
netip.MustParsePrefix(want))
|
|
if err != nil || !firstSeen.Equal(ts) {
|
|
t.Errorf("GetPrefixFirstSeen(%s) = %v, %v; want %v",
|
|
want, firstSeen, err, ts)
|
|
}
|
|
}
|
|
|
|
h.HandleMessage(&ristypes.RISMessage{
|
|
Peer: testPeerIP,
|
|
ParsedTimestamp: ts.Add(time.Minute),
|
|
Withdrawals: []string{"2001:db8:1:0:0:0:0:0/48", testV4Prefix},
|
|
})
|
|
h.flushBatch()
|
|
|
|
for _, ip := range []string{"2001:db8:1::1", "192.0.2.1"} {
|
|
summary, found := h.table.Lookup(netip.MustParseAddr(ip))
|
|
if found {
|
|
t.Errorf("Lookup(%s) after withdrawal = %+v, want none", ip, summary)
|
|
}
|
|
}
|
|
|
|
stats, err := db.GetStatsContext(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
if stats.Prefixes != wantPrefixes {
|
|
t.Errorf("the prefix tables hold %d prefixes after the withdrawal, "+
|
|
"want %d", stats.Prefixes, wantPrefixes)
|
|
}
|
|
}
|
|
|
|
// TestPrefixHandlerStoresANewPrefixFirstSeenAtItsEarliestUpdate announces a
|
|
// prefix the database does not hold yet twice in one batch, the second time 5
|
|
// seconds after the first, through the prefix handler into a real database. The
|
|
// prefix is stored first seen at the time of the first announcement, whether
|
|
// the two come from two peers or from one.
|
|
func TestPrefixHandlerStoresANewPrefixFirstSeenAtItsEarliestUpdate(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const otherPeerIP = "2001:db8:ffff::2"
|
|
|
|
tests := []struct {
|
|
name string
|
|
firstPeer, secondPeer string
|
|
}{
|
|
{name: "two peers", firstPeer: testPeerIP, secondPeer: otherPeerIP},
|
|
{name: "one peer", firstPeer: testPeerIP, secondPeer: testPeerIP},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
first := peerAnnouncement(tt.firstPeer, testV4Prefix)
|
|
second := peerAnnouncement(tt.secondPeer, testV4Prefix)
|
|
second.ParsedTimestamp = first.ParsedTimestamp.Add(secondUpdateAfter)
|
|
db := handleBatches(t, [][]*ristypes.RISMessage{{first, second}})
|
|
|
|
firstSeen, err := db.GetPrefixFirstSeen(t.Context(),
|
|
netip.MustParsePrefix(testV4Prefix))
|
|
if err != nil {
|
|
t.Fatalf("GetPrefixFirstSeen(%s): %v", testV4Prefix, err)
|
|
}
|
|
|
|
if !firstSeen.Equal(first.ParsedTimestamp) {
|
|
t.Errorf("first_seen of %s = %s, want %s", testV4Prefix,
|
|
firstSeen, first.ParsedTimestamp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrefixHandlerDoesNotStoreAPrefixThatDoesNotParse runs an announcement of
|
|
// "not-a-prefix" and a withdrawal of "also/bad" through the prefix handler into
|
|
// a real database. Neither is stored in the prefix tables: the prefix count in
|
|
// the stats stays 0, both as kept in memory and as counted from the tables when
|
|
// the database is opened again.
|
|
func TestPrefixHandlerDoesNotStoreAPrefixThatDoesNotParse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
|
|
db, err := database.New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
h := prefixHandlerWithoutTimer(db)
|
|
|
|
withdrawal := &ristypes.RISMessage{
|
|
Type: ristypes.MessageTypeUpdate,
|
|
Peer: testPeerIP,
|
|
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
Withdrawals: []string{"also/bad"},
|
|
}
|
|
h.HandleMessage(peerAnnouncement(testPeerIP, "not-a-prefix"))
|
|
h.HandleMessage(withdrawal)
|
|
h.flushBatch()
|
|
|
|
checkNoPrefixes := func(when string) {
|
|
t.Helper()
|
|
|
|
stats, err := db.GetStatsContext(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext %s: %v", when, err)
|
|
}
|
|
|
|
if stats.Prefixes != 0 {
|
|
t.Errorf("prefix count %s = %d, want 0", when, stats.Prefixes)
|
|
}
|
|
}
|
|
|
|
checkNoPrefixes("after the flush")
|
|
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
db, err = database.New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to open the database again: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
checkNoPrefixes("after opening the database again")
|
|
}
|
|
|
|
// TestStateMessagesGoToThePrefixHandlerNotThePeerHandler checks that STATE
|
|
// messages go to the prefix handler, which acts on a down, and not to the peer
|
|
// handler, which would record a peer whose session never comes up as active.
|
|
func TestStateMessagesGoToThePrefixHandlerNotThePeerHandler(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !(&PrefixHandler{}).WantsMessage(ristypes.MessageTypeState) {
|
|
t.Error("the prefix handler does not want STATE messages")
|
|
}
|
|
|
|
if (&PeerHandler{}).WantsMessage(ristypes.MessageTypeState) {
|
|
t.Error("the peer handler wants STATE messages")
|
|
}
|
|
|
|
if !(&PeerHandler{}).WantsMessage(ristypes.MessageTypeUpdate) {
|
|
t.Error("the peer handler does not want UPDATE messages")
|
|
}
|
|
}
|
|
|
|
// TestPrefixHandlerStopLogsNothingWhenItWroteEverything stops a prefix
|
|
// handler, with no deadline, after a peer announced a prefix and its session
|
|
// went down. Its last flush writes the routing table's changes and the prefix,
|
|
// so Stop must log nothing.
|
|
func TestPrefixHandlerStopLogsNothingWhenItWroteEverything(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var logs bytes.Buffer
|
|
|
|
h := NewPrefixHandler(newTestDatabase(t), newErrorLogger(&logs),
|
|
routetable.New(routetable.Params{}))
|
|
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
|
|
h.HandleMessage(sessionDown())
|
|
h.Stop(t.Context())
|
|
|
|
if logs.Len() != 0 {
|
|
t.Errorf("Stop logged %q after it wrote everything", logs.String())
|
|
}
|
|
}
|
|
|
|
// longerThanAnyTest is a flush timer interval that no test lasts. A test whose
|
|
// prefix, AS or peer handler must flush only when its batch is full and on Stop
|
|
// gives it to newPrefixHandler, newASHandler or newPeerHandler.
|
|
const longerThanAnyTest = time.Hour
|
|
|
|
// prefixHandlerWithoutTimer returns a prefix handler that writes to store and
|
|
// applies the updates to an empty routing table. It is built without
|
|
// NewPrefixHandler's flush goroutine, so the test flushes itself.
|
|
func prefixHandlerWithoutTimer(store database.Store) *PrefixHandler {
|
|
return &PrefixHandler{
|
|
db: store,
|
|
logger: logger.New(),
|
|
table: routetable.New(routetable.Params{}),
|
|
}
|
|
}
|
|
|
|
// newTestDatabase opens a database in a new temporary directory. It is closed
|
|
// when the test ends.
|
|
func newTestDatabase(t *testing.T) *database.Database {
|
|
t.Helper()
|
|
|
|
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db
|
|
}
|
|
|
|
// handleBatches runs each batch of messages through a prefix handler into a
|
|
// new database, flushing after each batch, and returns the database.
|
|
func handleBatches(
|
|
t *testing.T, batches [][]*ristypes.RISMessage,
|
|
) *database.Database {
|
|
t.Helper()
|
|
|
|
db := newTestDatabase(t)
|
|
h := prefixHandlerWithoutTimer(db)
|
|
|
|
for _, batch := range batches {
|
|
for _, msg := range batch {
|
|
h.HandleMessage(msg)
|
|
}
|
|
|
|
h.flushBatch()
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
// peerAnnouncement returns an UPDATE message in which peer announces prefixes
|
|
// with origin AS testASNB.
|
|
func peerAnnouncement(peer string, prefixes ...string) *ristypes.RISMessage {
|
|
return &ristypes.RISMessage{
|
|
Type: ristypes.MessageTypeUpdate,
|
|
Peer: peer,
|
|
Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}},
|
|
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
|
Announcements: []ristypes.RISAnnouncement{
|
|
{NextHop: peer, Prefixes: prefixes},
|
|
},
|
|
}
|
|
}
|
|
|
|
// sessionDown returns the STATE message that says the BGP session of the peer
|
|
// testPeerIP went down.
|
|
func sessionDown() *ristypes.RISMessage {
|
|
return &ristypes.RISMessage{
|
|
Type: ristypes.MessageTypeState,
|
|
Peer: testPeerIP,
|
|
State: ristypes.StateDown,
|
|
}
|
|
}
|