check / check (push) Waiting to run
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
544 lines
16 KiB
Go
544 lines
16 KiB
Go
package routetable_test
|
|
|
|
import (
|
|
"net/netip"
|
|
"reflect"
|
|
"slices"
|
|
"sync"
|
|
"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{})
|
|
}
|
|
|
|
func TestTakeChangesHoldsOnlyPairsThatChanged(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
table := routetable.New(routetable.Params{})
|
|
v4 := netip.MustParsePrefix(prefixV4)
|
|
v6 := netip.MustParsePrefix(prefixV6)
|
|
|
|
// peerA, peerB and peerC are numbered 0, 1 and 2. The routes of peerC and
|
|
// peerB to v4 are kept; peerA keeps only its bit.
|
|
announceAll(table, prefixV4, asA, 10, peerA, peerB, peerC)
|
|
table.Announce(v6, asA, routeOf(peerB, asA, 10))
|
|
table.TakeChanges()
|
|
|
|
// peerA announces again, with a time older than both kept routes.
|
|
table.Announce(v4, asA, routeOf(peerA, asA, 15))
|
|
checkChanges(t, table, []change{}, map[string]uint{})
|
|
|
|
// peerC announces its kept route again.
|
|
table.Announce(v4, asA, routeOf(peerC, asA, 30))
|
|
checkChanges(t, table, []change{}, map[string]uint{})
|
|
|
|
// peerC announces the same path with a newer time, so its kept route
|
|
// changes.
|
|
table.Announce(v4, asA, routeOf(peerC, asA, 40))
|
|
checkChanges(t, table, []change{
|
|
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{0, 1, 2},
|
|
Routes: []routetable.Route{
|
|
routeOf(peerC, asA, 40), routeOf(peerB, asA, 20),
|
|
}},
|
|
}, map[string]uint{})
|
|
|
|
// peerA withdraws v6, which it does not carry.
|
|
table.Withdraw(v6, peerA)
|
|
checkChanges(t, table, []change{}, map[string]uint{})
|
|
|
|
// peerA going down changes v4, the one prefix it carries.
|
|
table.PeerDown(peerA)
|
|
checkChanges(t, table, []change{
|
|
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{1, 2},
|
|
Routes: []routetable.Route{
|
|
routeOf(peerC, asA, 40), routeOf(peerB, asA, 20),
|
|
}},
|
|
}, map[string]uint{})
|
|
|
|
// peerA, which carries nothing now, goes down again.
|
|
table.PeerDown(peerA)
|
|
checkChanges(t, table, []change{}, map[string]uint{})
|
|
}
|
|
|
|
// TestConcurrentUse has goroutines update one table while others read it. The
|
|
// race detector, which make test runs, fails it when a method uses the table
|
|
// without its lock.
|
|
func TestConcurrentUse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
// rounds is how many times each goroutine calls its method.
|
|
rounds = 1000
|
|
ipVersion = 4
|
|
sampleSize = 10
|
|
)
|
|
|
|
table := routetable.New(routetable.Params{})
|
|
prefix := netip.MustParsePrefix(prefixV4)
|
|
|
|
uses := []func(round int){
|
|
func(round int) { table.Announce(prefix, asA, routeOf(peerA, asA, round)) },
|
|
func(round int) { table.Announce(prefix, asB, routeOf(peerB, asB, round)) },
|
|
func(int) { table.Withdraw(prefix, peerA) },
|
|
func(int) { table.PeerDown(peerB) },
|
|
func(int) { table.TakeChanges() },
|
|
func(int) { table.Lookup(prefix.Addr()) },
|
|
func(int) { table.Origins(prefix) },
|
|
func(int) { table.OriginatedPrefixes(asA) },
|
|
func(int) { table.SamplePrefixes(ipVersion, prefix.Bits(), sampleSize) },
|
|
func(int) { table.Counts() },
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for _, use := range uses {
|
|
wg.Go(func() {
|
|
for round := range rounds {
|
|
use(round)
|
|
}
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|