check / check (push) Canceled after 0s
A new package holds every prefix some peer carries. For each prefix and origin AS it keeps the set of peers that carry it, one bit per peer, and the two newest of their routes in full. It applies announcements, withdrawals and peers' sessions going down by the rule confirmed on the issue, and records which pairs changed for the database writes to come. It answers the IP lookup, an AS's prefixes, a prefix's origins, a sample of one mask length, and the counts the stats show. Nothing uses it yet. The peer sets use github.com/bits-and-blooms/bitset. The kept routes' times are held in order in github.com/google/btree, so the oldest and newest need no scan. Model: opus-5-5
453 lines
13 KiB
Go
453 lines
13 KiB
Go
package routetable_test
|
|
|
|
import (
|
|
"net/netip"
|
|
"reflect"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bits-and-blooms/bitset"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
)
|
|
|
|
const (
|
|
peerA = "192.0.2.1"
|
|
peerB = "192.0.2.2"
|
|
peerC = "2001:db8::3"
|
|
|
|
asA = 64500
|
|
asB = 64501
|
|
asC = 64502
|
|
// transitAS is the first AS of every test path, the peer's own.
|
|
transitAS = 64496
|
|
|
|
prefixV4 = "10.1.2.0/24"
|
|
prefixV6 = "2001:db8:1::/48"
|
|
)
|
|
|
|
// at returns the time second seconds after the start of every test.
|
|
func at(second int) time.Time {
|
|
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
|
|
Add(time.Duration(second) * time.Second)
|
|
}
|
|
|
|
// timeAt returns at(second) for a field that may be nil.
|
|
func timeAt(second int) *time.Time {
|
|
t := at(second)
|
|
|
|
return &t
|
|
}
|
|
|
|
// routeOf returns the route peer announces with origin AS asn second seconds
|
|
// after the start.
|
|
func routeOf(peer string, asn, second int) routetable.Route {
|
|
return routetable.Route{
|
|
Peer: peer, ASPath: []int{transitAS, asn}, Time: at(second),
|
|
}
|
|
}
|
|
|
|
// step is one update a test applies to a table for one prefix.
|
|
type step func(table *routetable.Table, prefix netip.Prefix)
|
|
|
|
// announce is the step of peer announcing prefix with origin AS asn second
|
|
// seconds after the start.
|
|
func announce(peer string, asn, second int) step {
|
|
return func(table *routetable.Table, prefix netip.Prefix) {
|
|
table.Announce(prefix, asn, routeOf(peer, asn, second))
|
|
}
|
|
}
|
|
|
|
// withdraw is the step of peer withdrawing prefix.
|
|
func withdraw(peer string) step {
|
|
return func(table *routetable.Table, prefix netip.Prefix) {
|
|
table.Withdraw(prefix, peer)
|
|
}
|
|
}
|
|
|
|
// peerDown is the step of peer's session going down.
|
|
func peerDown(peer string) step {
|
|
return func(table *routetable.Table, _ netip.Prefix) {
|
|
table.PeerDown(peer)
|
|
}
|
|
}
|
|
|
|
// rule is one case of the rule that decides what the table holds for a prefix:
|
|
// the steps applied to an empty table, and the prefix's origins after them.
|
|
// The peers are numbered in the order they first announce, which is the order
|
|
// Origin.Peers lists them in.
|
|
type rule struct {
|
|
name string
|
|
steps []step
|
|
want []routetable.Origin
|
|
}
|
|
|
|
// checkRules runs each case for an IPv4 and an IPv6 prefix.
|
|
func checkRules(t *testing.T, rules []rule) {
|
|
t.Helper()
|
|
|
|
for _, tc := range rules {
|
|
for _, text := range []string{prefixV4, prefixV6} {
|
|
t.Run(tc.name+" "+text, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefix := netip.MustParsePrefix(text)
|
|
table := routetable.New(routetable.Params{})
|
|
|
|
for _, apply := range tc.steps {
|
|
apply(table, prefix)
|
|
}
|
|
|
|
got := table.Origins(prefix)
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("Origins(%s) = %+v, want %+v", prefix, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnnouncementsKeepTheTwoNewestRoutes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
checkRules(t, []rule{
|
|
{
|
|
name: "an announcement is kept",
|
|
steps: []step{announce(peerA, asA, 10)},
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
|
|
},
|
|
{
|
|
name: "a lagging peer's older route is not kept",
|
|
steps: []step{announce(peerA, asA, 20), announce(peerB, asA, 30),
|
|
announce(peerC, asA, 10)},
|
|
want: []routetable.Origin{{ASN: asA,
|
|
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
|
|
routeOf(peerB, asA, 30), routeOf(peerA, asA, 20),
|
|
}}},
|
|
},
|
|
{
|
|
name: "a third newer route drops the older kept one, whose peer " +
|
|
"keeps its bit",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
announce(peerC, asA, 30)},
|
|
want: []routetable.Origin{{ASN: asA,
|
|
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
|
|
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
|
|
}}},
|
|
},
|
|
{
|
|
name: "a route as old as the older kept one replaces it",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
announce(peerC, asA, 10)},
|
|
want: []routetable.Origin{{ASN: asA,
|
|
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
|
|
routeOf(peerB, asA, 20), routeOf(peerC, asA, 10),
|
|
}}},
|
|
},
|
|
{
|
|
name: "a peer's new route replaces its kept one",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
announce(peerA, asA, 30)},
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
|
|
Routes: []routetable.Route{
|
|
routeOf(peerA, asA, 30), routeOf(peerB, asA, 20),
|
|
}}},
|
|
},
|
|
{
|
|
name: "a peer's new route replaces its kept one even when older",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
announce(peerB, asA, 5)},
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
|
|
Routes: []routetable.Route{
|
|
routeOf(peerA, asA, 10), routeOf(peerB, asA, 5),
|
|
}}},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestAPeerCarriesAPrefixWithOneOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
checkRules(t, []rule{
|
|
{
|
|
name: "a peer moving to a new origin leaves the old one",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
announce(peerA, asB, 30)},
|
|
want: []routetable.Origin{
|
|
{ASN: asA, Peers: []string{peerB},
|
|
Routes: []routetable.Route{routeOf(peerB, asA, 20)}},
|
|
{ASN: asB, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asB, 30)}},
|
|
},
|
|
},
|
|
{
|
|
name: "a peer whose route is not kept moves to a new origin",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
announce(peerC, asA, 30), announce(peerA, asB, 40)},
|
|
want: []routetable.Origin{
|
|
{ASN: asA, Peers: []string{peerB, peerC},
|
|
Routes: []routetable.Route{
|
|
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
|
|
}},
|
|
{ASN: asB, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asB, 40)}},
|
|
},
|
|
},
|
|
{
|
|
name: "origins are listed in AS number order",
|
|
steps: []step{announce(peerA, asC, 10), announce(peerB, asA, 20),
|
|
announce(peerC, asB, 30)},
|
|
want: []routetable.Origin{
|
|
{ASN: asA, Peers: []string{peerB},
|
|
Routes: []routetable.Route{routeOf(peerB, asA, 20)}},
|
|
{ASN: asB, Peers: []string{peerC},
|
|
Routes: []routetable.Route{routeOf(peerC, asB, 30)}},
|
|
{ASN: asC, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asC, 10)}},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestWithdrawalsAndPeerDowns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// threePeers is peerA, peerB and peerC announcing in turn; the routes of
|
|
// peerC and peerB are kept.
|
|
threePeers := []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
announce(peerC, asA, 30)}
|
|
|
|
checkRules(t, []rule{
|
|
{
|
|
name: "a withdrawal drops the peer's kept route",
|
|
steps: append(slices.Clone(threePeers), withdraw(peerC)),
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
|
|
Routes: []routetable.Route{routeOf(peerB, asA, 20)}}},
|
|
},
|
|
{
|
|
name: "a withdrawal of a route that is not kept leaves the kept ones",
|
|
steps: append(slices.Clone(threePeers), withdraw(peerA)),
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerB, peerC},
|
|
Routes: []routetable.Route{
|
|
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
|
|
}}},
|
|
},
|
|
{
|
|
name: "an origin whose kept routes are withdrawn keeps its other " +
|
|
"peers",
|
|
steps: append(slices.Clone(threePeers), withdraw(peerB),
|
|
withdraw(peerC)),
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA}}},
|
|
},
|
|
{
|
|
name: "an origin without kept routes keeps the next update",
|
|
steps: append(slices.Clone(threePeers), withdraw(peerB),
|
|
withdraw(peerC), announce(peerA, asA, 5)),
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asA, 5)}}},
|
|
},
|
|
{
|
|
name: "an origin no peer carries is removed",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asB, 20),
|
|
withdraw(peerA)},
|
|
want: []routetable.Origin{{ASN: asB, Peers: []string{peerB},
|
|
Routes: []routetable.Route{routeOf(peerB, asB, 20)}}},
|
|
},
|
|
{
|
|
name: "a prefix no peer carries is removed",
|
|
steps: []step{announce(peerA, asA, 10), withdraw(peerA)},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "a withdrawal from a peer that does not carry it changes nothing",
|
|
steps: []step{announce(peerA, asA, 10), withdraw(peerB)},
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
|
|
},
|
|
{
|
|
name: "a peer going down leaves the prefix",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
peerDown(peerB)},
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
|
|
},
|
|
{
|
|
name: "a peer going down after it withdrew changes nothing",
|
|
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
|
|
withdraw(peerB), peerDown(peerB)},
|
|
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
|
|
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestPeerDownLeavesEveryPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
table := routetable.New(routetable.Params{})
|
|
prefixes := []netip.Prefix{
|
|
netip.MustParsePrefix(prefixV4),
|
|
netip.MustParsePrefix("10.2.0.0/16"),
|
|
netip.MustParsePrefix(prefixV6),
|
|
}
|
|
|
|
// peerA carries every prefix with origin asA, peerB the first one with
|
|
// origin asB.
|
|
for _, prefix := range prefixes {
|
|
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
|
|
}
|
|
|
|
table.Announce(prefixes[0], asB, routeOf(peerB, asB, 20))
|
|
table.PeerDown(peerA)
|
|
|
|
want := []routetable.Origin{{ASN: asB, Peers: []string{peerB},
|
|
Routes: []routetable.Route{routeOf(peerB, asB, 20)}}}
|
|
|
|
got := table.Origins(prefixes[0])
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("Origins(%s) = %+v, want %+v", prefixes[0], got, want)
|
|
}
|
|
|
|
for _, prefix := range prefixes[1:] {
|
|
got := table.Origins(prefix)
|
|
if got != nil {
|
|
t.Errorf("Origins(%s) = %+v after the only peer went down, want nil",
|
|
prefix, got)
|
|
}
|
|
}
|
|
|
|
counts := table.Counts()
|
|
if counts.IPv4Prefixes != 1 || counts.IPv6Prefixes != 0 ||
|
|
counts.IPv4Routes != 1 || counts.IPv6Routes != 0 {
|
|
t.Errorf("Counts() = %+v, want one IPv4 prefix and route", counts)
|
|
}
|
|
|
|
if len(table.OriginatedPrefixes(asA)) != 0 {
|
|
t.Errorf("OriginatedPrefixes(%d) = %+v, want none",
|
|
asA, table.OriginatedPrefixes(asA))
|
|
}
|
|
}
|
|
|
|
func TestCountsOriginsWithoutKeptRoute(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefix := netip.MustParsePrefix(prefixV4)
|
|
table := routetable.New(routetable.Params{})
|
|
|
|
steps := []struct {
|
|
apply step
|
|
want int
|
|
}{
|
|
{announce(peerA, asA, 10), 0},
|
|
{announce(peerB, asA, 20), 0},
|
|
{announce(peerC, asA, 30), 0},
|
|
{withdraw(peerC), 0},
|
|
{withdraw(peerB), 1},
|
|
// An update from the last peer is kept.
|
|
{announce(peerA, asA, 40), 0},
|
|
{withdraw(peerA), 0},
|
|
// Once more without kept routes, then without peers: the origin
|
|
// is removed.
|
|
{announce(peerA, asA, 10), 0},
|
|
{announce(peerB, asA, 20), 0},
|
|
{announce(peerC, asA, 30), 0},
|
|
{withdraw(peerB), 0},
|
|
{withdraw(peerC), 1},
|
|
{peerDown(peerA), 0},
|
|
}
|
|
|
|
for i, s := range steps {
|
|
s.apply(table, prefix)
|
|
|
|
got := table.Counts().OriginsWithoutKeptRoute
|
|
if got != s.want {
|
|
t.Errorf("after step %d, OriginsWithoutKeptRoute = %d, want %d",
|
|
i, got, s.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// change is a routetable.Change with its peer set as a list of peer numbers.
|
|
type change struct {
|
|
Prefix string
|
|
OriginASN int
|
|
Removed bool
|
|
Peers []uint
|
|
Routes []routetable.Route
|
|
}
|
|
|
|
// peerNumbers returns the numbers in set, or nil for a nil set.
|
|
func peerNumbers(set *bitset.BitSet) []uint {
|
|
if set == nil {
|
|
return nil
|
|
}
|
|
|
|
return slices.Collect(set.EachSet())
|
|
}
|
|
|
|
func TestTakeChanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// peerA was numbered 5 before; the next new peer is 6.
|
|
table := routetable.New(routetable.Params{
|
|
PeerNumbers: map[string]uint{peerA: 5},
|
|
})
|
|
v4 := netip.MustParsePrefix(prefixV4)
|
|
v6 := netip.MustParsePrefix(prefixV6)
|
|
|
|
table.Announce(v6, asA, routeOf(peerA, asA, 10))
|
|
table.Announce(v4, asA, routeOf(peerA, asA, 10))
|
|
table.Announce(v4, asA, routeOf(peerB, asA, 20))
|
|
|
|
checkChanges(t, table, []change{
|
|
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{5, 6},
|
|
Routes: []routetable.Route{
|
|
routeOf(peerB, asA, 20), routeOf(peerA, asA, 10),
|
|
}},
|
|
{Prefix: prefixV6, OriginASN: asA, Peers: []uint{5},
|
|
Routes: []routetable.Route{routeOf(peerA, asA, 10)}},
|
|
}, map[string]uint{peerB: 6})
|
|
|
|
// TakeChanges cleared the record.
|
|
checkChanges(t, table, []change{}, map[string]uint{})
|
|
|
|
// peerB moves to asB; peerA withdraws, which removes the pair of v4 and
|
|
// asA.
|
|
table.Announce(v4, asB, routeOf(peerB, asB, 30))
|
|
table.Withdraw(v4, peerA)
|
|
|
|
checkChanges(t, table, []change{
|
|
{Prefix: prefixV4, OriginASN: asA, Removed: true},
|
|
{Prefix: prefixV4, OriginASN: asB, Peers: []uint{6},
|
|
Routes: []routetable.Route{routeOf(peerB, asB, 30)}},
|
|
}, map[string]uint{})
|
|
}
|
|
|
|
// checkChanges checks that TakeChanges returns want and wantPeers.
|
|
func checkChanges(
|
|
t *testing.T, table *routetable.Table, want []change,
|
|
wantPeers map[string]uint,
|
|
) {
|
|
t.Helper()
|
|
|
|
changes, newPeers := table.TakeChanges()
|
|
|
|
got := make([]change, 0, len(changes))
|
|
for _, c := range changes {
|
|
got = append(got, change{
|
|
Prefix: c.Prefix.String(), OriginASN: c.OriginASN, Removed: c.Removed,
|
|
Peers: peerNumbers(c.Peers), Routes: c.Routes,
|
|
})
|
|
}
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("TakeChanges() changes = %+v, want %+v", got, want)
|
|
}
|
|
|
|
if !reflect.DeepEqual(newPeers, wantPeers) {
|
|
t.Errorf("TakeChanges() new peers = %v, want %v", newPeers, wantPeers)
|
|
}
|
|
}
|