Compare commits
1
Commits
prod
...
3edf68f6af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3edf68f6af |
@@ -1,9 +1,11 @@
|
|||||||
# RouteWatch
|
# RouteWatch
|
||||||
|
|
||||||
RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing
|
RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing
|
||||||
table in real time: it streams BGP UPDATE messages from the RIPE RIS Live
|
table in real time. It works like this:
|
||||||
service, maintains a live routing table in SQLite, and provides HTTP APIs for
|
|
||||||
querying routing information.
|
- It streams BGP UPDATE messages from the RIPE RIS Live service.
|
||||||
|
- It keeps a live routing table in SQLite.
|
||||||
|
- It serves web pages and a JSON API for looking routes up.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -233,6 +235,7 @@ routewatch/
|
|||||||
│ ├── routewatch/ # Core application logic
|
│ ├── routewatch/ # Core application logic
|
||||||
│ ├── server/ # HTTP server and handlers
|
│ ├── server/ # HTTP server and handlers
|
||||||
│ ├── database/ # SQLite storage layer
|
│ ├── database/ # SQLite storage layer
|
||||||
|
│ ├── routetable/ # The whole routing table in memory
|
||||||
│ ├── streamer/ # RIPE RIS Live client
|
│ ├── streamer/ # RIPE RIS Live client
|
||||||
│ ├── ristypes/ # BGP message data structures
|
│ ├── ristypes/ # BGP message data structures
|
||||||
│ ├── logger/ # Structured logging wrapper
|
│ ├── logger/ # Structured logging wrapper
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-27: a new package, `internal/routetable`, holds the whole routing
|
||||||
|
table in memory. For each prefix and origin AS it keeps the peers that carry
|
||||||
|
it, one bit per peer, and the two newest of their routes. Nothing uses it yet
|
||||||
|
(part of #122)
|
||||||
- 2026-10-27: each result of the IP lookup lists the public blocklists the
|
- 2026-10-27: each result of the IP lookup lists the public blocklists the
|
||||||
address is on, in `blocklists`. The lists are DShield, Feodo Tracker, two
|
address is on, in `blocklists`. The lists are DShield, Feodo Tracker, two
|
||||||
FireHOL lists, Spamhaus DROP and the Tor exit list. RouteWatch downloads each
|
FireHOL lists, Spamhaus DROP and the Tor exit list. RouteWatch downloads each
|
||||||
@@ -244,4 +248,6 @@ tested.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
None open.
|
- the rest of https://git.eeqj.de/sneak/routewatch/issues/122, one PR each: the
|
||||||
|
routing table in the database, the prefix handler writing to it, the pages and
|
||||||
|
the API reading from it, then removing the per-peer route tables
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ module git.eeqj.de/sneak/routewatch
|
|||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bits-and-blooms/bitset v1.26.0
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.2
|
||||||
|
github.com/google/btree v1.1.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.29
|
github.com/mattn/go-sqlite3 v1.14.29
|
||||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9
|
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
github.com/bits-and-blooms/bitset v1.26.0 h1:WPvcPZbP1yuKofmjs2SYIFO1EjzIGhCP9ducElLsh5Q=
|
||||||
|
github.com/bits-and-blooms/bitset v1.26.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||||
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
|
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package routetable_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/netip"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parameters for BenchmarkApplyMixedBatch.
|
||||||
|
const (
|
||||||
|
// benchPeers is how many peers there are, about as many as RIS has.
|
||||||
|
benchPeers = 1400
|
||||||
|
// benchPrefixes is how many IPv4 /24 prefixes the table holds, and
|
||||||
|
// benchPeersPerPrefix how many peers, picked at random, carry each.
|
||||||
|
benchPrefixes = 100000
|
||||||
|
benchPeersPerPrefix = 20
|
||||||
|
// benchBatchSize is how many updates the timed batch holds, as many as
|
||||||
|
// the prefix handler's largest batch (prefixBatchMaxSize in
|
||||||
|
// internal/routewatch).
|
||||||
|
benchBatchSize = 250000
|
||||||
|
// In the timed batch every benchWithdrawalEvery-th update is a
|
||||||
|
// withdrawal, and every benchNewOriginEvery-th announcement has an
|
||||||
|
// origin AS other than the prefix's.
|
||||||
|
benchWithdrawalEvery = 5
|
||||||
|
benchNewOriginEvery = 7
|
||||||
|
// benchOrigins is how many origin ASes the prefixes are spread over,
|
||||||
|
// from benchFirstASN on: 20 prefixes each, about as many as an origin AS
|
||||||
|
// has on average in the full table.
|
||||||
|
benchOrigins = 5000
|
||||||
|
benchFirstASN = 64500
|
||||||
|
// benchUpdatesPerSecond is how many updates share one time.
|
||||||
|
benchUpdatesPerSecond = 1000
|
||||||
|
benchMaskLength = 24
|
||||||
|
)
|
||||||
|
|
||||||
|
// benchUpdate is one update of the benchmark.
|
||||||
|
type benchUpdate struct {
|
||||||
|
prefix netip.Prefix
|
||||||
|
origin int
|
||||||
|
route routetable.Route
|
||||||
|
withdraw bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkApplyMixedBatch times one batch of 250,000 updates of routes picked
|
||||||
|
// at random: announcements, withdrawals and announcements with a new origin AS.
|
||||||
|
// The table it applies them to holds 100,000 IPv4 prefixes from 5,000 origin
|
||||||
|
// ASes, each carried by 20 of 1,400 peers. It reports the heap bytes that table
|
||||||
|
// takes per prefix and origin AS pair. It is run by hand, not by make test, for
|
||||||
|
// example in the test image:
|
||||||
|
//
|
||||||
|
// go test -run '^$' -bench MixedBatch -benchtime 10x ./internal/routetable/
|
||||||
|
func BenchmarkApplyMixedBatch(b *testing.B) {
|
||||||
|
//nolint:gosec // The benchmark needs the same updates every run.
|
||||||
|
rng := rand.New(rand.NewPCG(1, 2))
|
||||||
|
peers := make([]string, benchPeers)
|
||||||
|
|
||||||
|
for i := range peers {
|
||||||
|
peers[i] = fmt.Sprintf("198.18.%d.%d", i/256, i%256)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := heapBytes()
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
updates := 0
|
||||||
|
|
||||||
|
for prefix := range benchPrefixes {
|
||||||
|
for range benchPeersPerPrefix {
|
||||||
|
peer := peers[rng.IntN(benchPeers)]
|
||||||
|
apply(table, benchAnnouncement(rng, prefix, peer, updates, false))
|
||||||
|
updates++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The record of changes is handed over every second while routewatch
|
||||||
|
// runs, so it is not part of the table's size.
|
||||||
|
table.TakeChanges()
|
||||||
|
|
||||||
|
bytesPerPair := float64(heapBytes()-before) / benchPrefixes
|
||||||
|
|
||||||
|
batch := make([]benchUpdate, 0, benchBatchSize)
|
||||||
|
for i := range benchBatchSize {
|
||||||
|
update := benchAnnouncement(rng, rng.IntN(benchPrefixes),
|
||||||
|
peers[rng.IntN(benchPeers)], updates+i, i%benchNewOriginEvery == 0)
|
||||||
|
update.withdraw = i%benchWithdrawalEvery == 0
|
||||||
|
batch = append(batch, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
for _, update := range batch {
|
||||||
|
apply(table, update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the loop: b.Loop drops the metrics reported before it starts.
|
||||||
|
b.ReportMetric(bytesPerPair, "bytes/pair")
|
||||||
|
}
|
||||||
|
|
||||||
|
// benchAnnouncement returns the announcement of the prefix numbered prefix, a
|
||||||
|
// /24 from 1.0.0.0/24 on, by peer. It is the updates-th update of the
|
||||||
|
// benchmark, and its AS path holds four ASes from the peer's AS to the origin
|
||||||
|
// AS. newOrigin gives it an origin AS other than the prefix's.
|
||||||
|
func benchAnnouncement(
|
||||||
|
rng *rand.Rand, prefix int, peer string, updates int, newOrigin bool,
|
||||||
|
) benchUpdate {
|
||||||
|
origin := benchFirstASN + prefix%benchOrigins
|
||||||
|
if newOrigin {
|
||||||
|
origin += benchOrigins
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
|
||||||
|
return benchUpdate{
|
||||||
|
prefix: netip.MustParsePrefix(fmt.Sprintf("%d.%d.%d.0/%d",
|
||||||
|
1+prefix>>16, (prefix>>8)%256, prefix%256, benchMaskLength)),
|
||||||
|
origin: origin,
|
||||||
|
route: routetable.Route{
|
||||||
|
Peer: peer,
|
||||||
|
ASPath: []int{
|
||||||
|
benchFirstASN + rng.IntN(benchOrigins),
|
||||||
|
benchFirstASN + rng.IntN(benchOrigins),
|
||||||
|
benchFirstASN + rng.IntN(benchOrigins),
|
||||||
|
origin,
|
||||||
|
},
|
||||||
|
Time: start.Add(time.Duration(updates/benchUpdatesPerSecond) *
|
||||||
|
time.Second),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply applies update to table.
|
||||||
|
func apply(table *routetable.Table, update benchUpdate) {
|
||||||
|
if update.withdraw {
|
||||||
|
table.Withdraw(update.prefix, update.route.Peer)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Announce(update.prefix, update.origin, update.route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heapBytes returns the bytes of the live heap objects, after a collection.
|
||||||
|
func heapBytes() uint64 {
|
||||||
|
runtime.GC()
|
||||||
|
|
||||||
|
var stats runtime.MemStats
|
||||||
|
|
||||||
|
runtime.ReadMemStats(&stats)
|
||||||
|
|
||||||
|
return stats.HeapAlloc
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package routetable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bits-and-blooms/bitset"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrefixSummary is what the table answers about one prefix.
|
||||||
|
type PrefixSummary struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
// OriginASN is the origin AS that the most peers carry the prefix with.
|
||||||
|
// When several have as many peers, it is the lowest AS number.
|
||||||
|
OriginASN int
|
||||||
|
// Peers is how many peers carry the prefix.
|
||||||
|
Peers int
|
||||||
|
// Newest is the time of the newest kept route of the prefix, or nil when
|
||||||
|
// it keeps none.
|
||||||
|
Newest *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// OriginatedPrefix is one prefix an AS originates.
|
||||||
|
type OriginatedPrefix struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
// Newest is the time of the newest kept route of the prefix with that
|
||||||
|
// origin AS, or nil when it keeps none.
|
||||||
|
Newest *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts are the table's figures for the stats.
|
||||||
|
type Counts struct {
|
||||||
|
IPv4Prefixes int
|
||||||
|
IPv6Prefixes int
|
||||||
|
// IPv4PrefixesByLength[n] and IPv6PrefixesByLength[n] are how many
|
||||||
|
// prefixes of mask length n some peer carries.
|
||||||
|
IPv4PrefixesByLength []int
|
||||||
|
IPv6PrefixesByLength []int
|
||||||
|
// IPv4Routes and IPv6Routes count peer and prefix pairs: each peer that
|
||||||
|
// carries a prefix counts once for it.
|
||||||
|
IPv4Routes int
|
||||||
|
IPv6Routes int
|
||||||
|
// OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers
|
||||||
|
// carry but whose kept routes were all withdrawn.
|
||||||
|
OriginsWithoutKeptRoute int
|
||||||
|
// OldestKeptRoute and NewestKeptRoute are the times of the oldest and the
|
||||||
|
// newest kept routes, or nil when the table keeps none.
|
||||||
|
OldestKeptRoute *time.Time
|
||||||
|
NewestKeptRoute *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the most specific prefix that holds addr and that some peer
|
||||||
|
// carries. It ignores prefixes shorter than /8 for IPv4 and /16 for IPv6. It
|
||||||
|
// makes one map lookup per mask length, and reports false when no prefix
|
||||||
|
// holds addr.
|
||||||
|
func (t *Table) Lookup(addr netip.Addr) (PrefixSummary, bool) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
// An IPv4 address written in IPv6 form (::ffff:192.0.2.1) is looked up as
|
||||||
|
// IPv4.
|
||||||
|
addr = addr.Unmap()
|
||||||
|
f := t.familyOf(addr)
|
||||||
|
|
||||||
|
for bits := addr.BitLen(); bits >= f.minLookupLength; bits-- {
|
||||||
|
prefix := netip.PrefixFrom(addr, bits).Masked()
|
||||||
|
|
||||||
|
origins, found := f.byLength[bits][prefix]
|
||||||
|
if found {
|
||||||
|
return summarize(prefix, origins), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PrefixSummary{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// OriginatedPrefixes returns the prefixes AS asn originates, in prefix order.
|
||||||
|
func (t *Table) OriginatedPrefixes(asn int) []OriginatedPrefix {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
prefixes := make([]OriginatedPrefix, 0, len(t.byOrigin[asn]))
|
||||||
|
for prefix, o := range t.byOrigin[asn] {
|
||||||
|
prefixes = append(prefixes,
|
||||||
|
OriginatedPrefix{Prefix: prefix, Newest: o.newestTime()})
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(prefixes, func(a, b OriginatedPrefix) int {
|
||||||
|
return a.Prefix.Compare(b.Prefix)
|
||||||
|
})
|
||||||
|
|
||||||
|
return prefixes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origins returns each origin AS of prefix, in AS number order, with its peers
|
||||||
|
// and its kept routes. It returns nil when no peer carries the prefix.
|
||||||
|
func (t *Table) Origins(prefix netip.Prefix) []Origin {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
if !prefix.IsValid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var origins []Origin
|
||||||
|
|
||||||
|
for _, o := range t.origins(prefix.Masked()) {
|
||||||
|
origins = append(origins, Origin{
|
||||||
|
ASN: o.asn,
|
||||||
|
Peers: t.addresses(&o.peers),
|
||||||
|
Routes: t.exportRoutes(o.routes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return origins
|
||||||
|
}
|
||||||
|
|
||||||
|
// SamplePrefixes returns up to limit of the prefixes of IP version ipVersion
|
||||||
|
// (4 or 6) and mask length maskLength. It reads only the prefixes it returns.
|
||||||
|
// Go starts each walk over a map at a random place, so each call returns
|
||||||
|
// another sample.
|
||||||
|
func (t *Table) SamplePrefixes(ipVersion, maskLength, limit int) []PrefixSummary {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
var f *family
|
||||||
|
|
||||||
|
switch ipVersion {
|
||||||
|
case ipVersion4:
|
||||||
|
f = &t.ipv4
|
||||||
|
case ipVersion6:
|
||||||
|
f = &t.ipv6
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if maskLength < 0 || maskLength >= len(f.byLength) || limit <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes := f.byLength[maskLength]
|
||||||
|
sample := make([]PrefixSummary, 0, min(limit, len(prefixes)))
|
||||||
|
|
||||||
|
for prefix, origins := range prefixes {
|
||||||
|
if len(sample) == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
sample = append(sample, summarize(prefix, origins))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts returns the table's figures for the stats. The writes keep them up to
|
||||||
|
// date, so this reads no prefix.
|
||||||
|
func (t *Table) Counts() Counts {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
counts := Counts{
|
||||||
|
IPv4PrefixesByLength: t.ipv4.prefixesByLength(),
|
||||||
|
IPv6PrefixesByLength: t.ipv6.prefixesByLength(),
|
||||||
|
IPv4Routes: t.ipv4.routes,
|
||||||
|
IPv6Routes: t.ipv6.routes,
|
||||||
|
OriginsWithoutKeptRoute: t.originsWithoutKeptRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range counts.IPv4PrefixesByLength {
|
||||||
|
counts.IPv4Prefixes += n
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range counts.IPv6PrefixesByLength {
|
||||||
|
counts.IPv6Prefixes += n
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest, found := t.keptTimes.Min()
|
||||||
|
if found {
|
||||||
|
counts.OldestKeptRoute = &oldest.time
|
||||||
|
}
|
||||||
|
|
||||||
|
newest, found := t.keptTimes.Max()
|
||||||
|
if found {
|
||||||
|
counts.NewestKeptRoute = &newest.time
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
// addresses returns the addresses of the peers in peers, in number order.
|
||||||
|
func (t *Table) addresses(peers *bitset.BitSet) []string {
|
||||||
|
addresses := make([]string, 0, peers.Count())
|
||||||
|
for number := range peers.EachSet() {
|
||||||
|
addresses = append(addresses, t.peerAddresses[number])
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarize returns the summary of prefix, whose origins are in AS number
|
||||||
|
// order.
|
||||||
|
func summarize(prefix netip.Prefix, origins []*origin) PrefixSummary {
|
||||||
|
summary := PrefixSummary{Prefix: prefix}
|
||||||
|
mostPeers := 0
|
||||||
|
|
||||||
|
for _, o := range origins {
|
||||||
|
peers := int(o.peers.Count())
|
||||||
|
summary.Peers += peers
|
||||||
|
|
||||||
|
if peers > mostPeers {
|
||||||
|
mostPeers = peers
|
||||||
|
summary.OriginASN = o.asn
|
||||||
|
}
|
||||||
|
|
||||||
|
newest := o.newestTime()
|
||||||
|
if newest != nil && (summary.Newest == nil || newest.After(*summary.Newest)) {
|
||||||
|
summary.Newest = newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package routetable_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// announceAll has each listed peer announce prefix with origin AS asn, the
|
||||||
|
// peer at index i i*10 seconds after start.
|
||||||
|
func announceAll(
|
||||||
|
table *routetable.Table, prefix string, asn, start int, peers ...string,
|
||||||
|
) {
|
||||||
|
const secondsBetweenPeers = 10
|
||||||
|
|
||||||
|
for i, peer := range peers {
|
||||||
|
table.Announce(netip.MustParsePrefix(prefix), asn,
|
||||||
|
routeOf(peer, asn, start+i*secondsBetweenPeers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
announceAll(table, "10.0.0.0/8", asA, 10, peerA)
|
||||||
|
// Three peers with two origins: asB has more of them.
|
||||||
|
announceAll(table, "10.1.0.0/16", asA, 10, peerA)
|
||||||
|
announceAll(table, "10.1.0.0/16", asB, 20, peerB, peerC)
|
||||||
|
// Two origins with one peer each: the lower AS number wins.
|
||||||
|
announceAll(table, prefixV4, asB, 20, peerB)
|
||||||
|
announceAll(table, prefixV4, asA, 10, peerA)
|
||||||
|
// An origin whose kept routes were withdrawn: no newest time.
|
||||||
|
announceAll(table, "10.9.0.0/16", asA, 10, peerA, peerB, peerC)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerB)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerC)
|
||||||
|
announceAll(table, "2001:db8::/32", asA, 10, peerA)
|
||||||
|
announceAll(table, prefixV6, asB, 20, peerB)
|
||||||
|
announceAll(table, "3000::/16", asA, 10, peerA)
|
||||||
|
// Shorter than the lookup uses.
|
||||||
|
announceAll(table, "192.0.0.0/7", asA, 10, peerA)
|
||||||
|
announceAll(table, "2000::/15", asA, 10, peerA)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
addr string
|
||||||
|
// want is the summary's prefix, or empty when none is found.
|
||||||
|
want string
|
||||||
|
originASN int
|
||||||
|
peers int
|
||||||
|
newest int // in seconds after the start; 0 for none
|
||||||
|
}{
|
||||||
|
{"10.1.2.3", prefixV4, asA, 2, 20},
|
||||||
|
{"::ffff:10.1.2.3", prefixV4, asA, 2, 20},
|
||||||
|
{"10.1.3.4", "10.1.0.0/16", asB, 3, 30},
|
||||||
|
{"10.2.0.1", "10.0.0.0/8", asA, 1, 10},
|
||||||
|
{"10.9.0.1", "10.9.0.0/16", asA, 1, 0},
|
||||||
|
{"192.0.2.1", "", 0, 0, 0},
|
||||||
|
{"2001:db8:1::5", prefixV6, asB, 1, 20},
|
||||||
|
{"2001:db8:2::1", "2001:db8::/32", asA, 1, 10},
|
||||||
|
{"3000::1", "3000::/16", asA, 1, 10},
|
||||||
|
{"2001:db9::1", "", 0, 0, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.addr, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, found := table.Lookup(netip.MustParseAddr(tc.addr))
|
||||||
|
if tc.want == "" {
|
||||||
|
if found {
|
||||||
|
t.Errorf("Lookup(%s) = %+v, want none", tc.addr, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
want := routetable.PrefixSummary{
|
||||||
|
Prefix: netip.MustParsePrefix(tc.want),
|
||||||
|
OriginASN: tc.originASN,
|
||||||
|
Peers: tc.peers,
|
||||||
|
}
|
||||||
|
if tc.newest != 0 {
|
||||||
|
want.Newest = timeAt(tc.newest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Lookup(%s) = %+v, %v; want %+v", tc.addr, got, found, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginatedPrefixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
announceAll(table, "2001:db8::/32", asA, 30, peerA)
|
||||||
|
announceAll(table, prefixV4, asA, 10, peerA)
|
||||||
|
// asB's newer route for the same prefix does not count for asA.
|
||||||
|
announceAll(table, prefixV4, asB, 40, peerB)
|
||||||
|
announceAll(table, "10.1.0.0/16", asA, 10, peerA, peerB)
|
||||||
|
announceAll(table, "10.9.0.0/16", asA, 10, peerA, peerB, peerC)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerB)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerC)
|
||||||
|
|
||||||
|
want := []routetable.OriginatedPrefix{
|
||||||
|
{Prefix: netip.MustParsePrefix("10.1.0.0/16"), Newest: timeAt(20)},
|
||||||
|
{Prefix: netip.MustParsePrefix(prefixV4), Newest: timeAt(10)},
|
||||||
|
{Prefix: netip.MustParsePrefix("10.9.0.0/16")},
|
||||||
|
{Prefix: netip.MustParsePrefix("2001:db8::/32"), Newest: timeAt(30)},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := table.OriginatedPrefixes(asA)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("OriginatedPrefixes(%d) = %+v, want %+v", asA, got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = table.OriginatedPrefixes(asC)
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("OriginatedPrefixes(%d) = %+v, want none", asC, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSamplePrefixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
|
||||||
|
announced := []string{
|
||||||
|
"10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",
|
||||||
|
"10.0.4.0/24", "10.0.0.0/16",
|
||||||
|
"2001:db8::/48", "2001:db8:1::/48", "2001:db8:2::/48",
|
||||||
|
}
|
||||||
|
for _, prefix := range announced {
|
||||||
|
announceAll(table, prefix, asA, 10, peerA)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ipVersion, maskLength, limit int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"fewer than there are", 4, 24, 3, 3},
|
||||||
|
{"more than there are", 4, 24, 10, 5},
|
||||||
|
{"IPv6", 6, 48, 10, 3},
|
||||||
|
{"a mask length with none", 4, 20, 10, 0},
|
||||||
|
{"no such IP version", 5, 24, 10, 0},
|
||||||
|
{"a mask length too long", 4, 33, 10, 0},
|
||||||
|
{"a negative mask length", 4, -1, 10, 0},
|
||||||
|
{"a limit of zero", 4, 24, 0, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sample := table.SamplePrefixes(tc.ipVersion, tc.maskLength, tc.limit)
|
||||||
|
if len(sample) != tc.want {
|
||||||
|
t.Fatalf("SamplePrefixes(%d, %d, %d) returned %d prefixes, want %d",
|
||||||
|
tc.ipVersion, tc.maskLength, tc.limit, len(sample), tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[netip.Prefix]bool)
|
||||||
|
|
||||||
|
for _, got := range sample {
|
||||||
|
want := routetable.PrefixSummary{
|
||||||
|
Prefix: got.Prefix, OriginASN: asA, Peers: 1, Newest: timeAt(10),
|
||||||
|
}
|
||||||
|
if seen[got.Prefix] || got.Prefix.Bits() != tc.maskLength ||
|
||||||
|
!reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("SamplePrefixes(%d, %d, %d) returned %+v",
|
||||||
|
tc.ipVersion, tc.maskLength, tc.limit, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[got.Prefix] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
checkCounts(t, table, routetable.Counts{
|
||||||
|
IPv4PrefixesByLength: byLength(ipv4Bits, nil),
|
||||||
|
IPv6PrefixesByLength: byLength(ipv6Bits, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
announceAll(table, prefixV4, asA, 10, peerA, peerB)
|
||||||
|
announceAll(table, "10.2.0.0/16", asA, 15, peerA)
|
||||||
|
announceAll(table, "2001:db8::/32", asA, 5, peerA)
|
||||||
|
announceAll(table, "2001:db8::/32", asA, 25, peerC)
|
||||||
|
checkCounts(t, table, routetable.Counts{
|
||||||
|
IPv4Prefixes: 2,
|
||||||
|
IPv6Prefixes: 1,
|
||||||
|
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
|
||||||
|
IPv6PrefixesByLength: byLength(ipv6Bits, map[int]int{32: 1}),
|
||||||
|
IPv4Routes: 3,
|
||||||
|
IPv6Routes: 2,
|
||||||
|
OldestKeptRoute: timeAt(5),
|
||||||
|
NewestKeptRoute: timeAt(25),
|
||||||
|
})
|
||||||
|
|
||||||
|
// peerC's route drops peerA's from the kept ones, and peerA leaves the
|
||||||
|
// IPv6 prefix.
|
||||||
|
announceAll(table, prefixV4, asA, 30, peerC)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("2001:db8::/32"), peerA)
|
||||||
|
checkCounts(t, table, routetable.Counts{
|
||||||
|
IPv4Prefixes: 2,
|
||||||
|
IPv6Prefixes: 1,
|
||||||
|
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
|
||||||
|
IPv6PrefixesByLength: byLength(ipv6Bits, map[int]int{32: 1}),
|
||||||
|
IPv4Routes: 4,
|
||||||
|
IPv6Routes: 1,
|
||||||
|
OldestKeptRoute: timeAt(15),
|
||||||
|
NewestKeptRoute: timeAt(30),
|
||||||
|
})
|
||||||
|
|
||||||
|
// peerC was the last peer of the IPv6 prefix.
|
||||||
|
table.PeerDown(peerC)
|
||||||
|
checkCounts(t, table, routetable.Counts{
|
||||||
|
IPv4Prefixes: 2,
|
||||||
|
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
|
||||||
|
IPv6PrefixesByLength: byLength(ipv6Bits, nil),
|
||||||
|
IPv4Routes: 3,
|
||||||
|
OldestKeptRoute: timeAt(15),
|
||||||
|
NewestKeptRoute: timeAt(20),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The address lengths of the two IP versions.
|
||||||
|
const (
|
||||||
|
ipv4Bits = 32
|
||||||
|
ipv6Bits = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
// byLength returns the prefix counts by mask length, for addresses of bits
|
||||||
|
// bits, with counts[n] prefixes of mask length n and none of any other.
|
||||||
|
func byLength(bits int, counts map[int]int) []int {
|
||||||
|
all := make([]int, bits+1)
|
||||||
|
for n, count := range counts {
|
||||||
|
all[n] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCounts checks that the table's counts are want.
|
||||||
|
func checkCounts(t *testing.T, table *routetable.Table, want routetable.Counts) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := table.Counts()
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Counts() = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
// Package routetable holds the whole routing table in memory. For each prefix
|
||||||
|
// and origin AS, it keeps the peers whose current route to the prefix has that
|
||||||
|
// origin AS. It also keeps the two newest of those routes in full. It applies
|
||||||
|
// announcements, withdrawals and peers' sessions going down. It answers the
|
||||||
|
// route reads that the pages and the API need.
|
||||||
|
package routetable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bits-and-blooms/bitset"
|
||||||
|
"github.com/google/btree"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// keptRoutesPerOrigin is how many routes of each prefix and origin AS are
|
||||||
|
// kept in full: the newest ones. Every other peer that carries the prefix
|
||||||
|
// with that origin AS keeps only its bit in the peer set.
|
||||||
|
keptRoutesPerOrigin = 2
|
||||||
|
|
||||||
|
// keptTimesDegree is the degree of the B-tree that holds the times of the
|
||||||
|
// kept routes in order: each of its nodes holds up to 63 times.
|
||||||
|
keptTimesDegree = 32
|
||||||
|
|
||||||
|
ipv4Bits = 32
|
||||||
|
ipv6Bits = 128
|
||||||
|
|
||||||
|
// The address lookup ignores prefixes shorter than these mask lengths. A
|
||||||
|
// default route or a route this short only says where a peer sends traffic
|
||||||
|
// it has no better route for; it does not say who holds the address.
|
||||||
|
minLookupMaskLengthV4 = 8
|
||||||
|
minLookupMaskLengthV6 = 16
|
||||||
|
|
||||||
|
// The IP versions SamplePrefixes takes.
|
||||||
|
ipVersion4 = 4
|
||||||
|
ipVersion6 = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// Route is one peer's route to one prefix. The next hop is the peer itself, so
|
||||||
|
// it is not stored.
|
||||||
|
type Route struct {
|
||||||
|
// Peer is the peer's address.
|
||||||
|
Peer string
|
||||||
|
ASPath []int
|
||||||
|
// Time is when the peer announced the route.
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin is one origin AS of a prefix: the peers whose current route to the
|
||||||
|
// prefix has that origin AS, and the newest of those routes in full.
|
||||||
|
type Origin struct {
|
||||||
|
// ASN is the origin AS. 0 means no known origin: the path ends in an AS
|
||||||
|
// set of several members.
|
||||||
|
ASN int
|
||||||
|
// Peers are the peers' addresses, in the order of their numbers: the
|
||||||
|
// order they were first seen in.
|
||||||
|
Peers []string
|
||||||
|
// Routes are the kept routes, newest first: at most two, and none when
|
||||||
|
// both were withdrawn while other peers still carry the prefix.
|
||||||
|
Routes []Route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change is the current content of one prefix and origin AS pair that changed:
|
||||||
|
// what a database row for the pair holds.
|
||||||
|
type Change struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
OriginASN int
|
||||||
|
// Removed says that no peer carries the prefix with this origin AS any
|
||||||
|
// more. Peers and Routes are then nil.
|
||||||
|
Removed bool
|
||||||
|
// Peers holds the number of each peer that carries the prefix with this
|
||||||
|
// origin AS.
|
||||||
|
Peers *bitset.BitSet
|
||||||
|
// Routes are the kept routes, newest first.
|
||||||
|
Routes []Route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params configures New.
|
||||||
|
type Params struct {
|
||||||
|
// PeerNumbers holds the number each peer was given before, by address. A
|
||||||
|
// peer not in it gets the next number above all of them the first time it
|
||||||
|
// announces a route.
|
||||||
|
PeerNumbers map[string]uint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table is the routing table. Its methods are safe for concurrent use.
|
||||||
|
type Table struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
ipv4 family
|
||||||
|
ipv6 family
|
||||||
|
// byOrigin holds, for each origin AS, the prefixes it originates.
|
||||||
|
byOrigin map[int]map[netip.Prefix]*origin
|
||||||
|
|
||||||
|
// peerNumbers and peerAddresses give each peer's number by its address
|
||||||
|
// and its address by its number. nextPeerNumber is the number the next
|
||||||
|
// new peer gets.
|
||||||
|
peerNumbers map[string]uint
|
||||||
|
peerAddresses map[uint]string
|
||||||
|
nextPeerNumber uint
|
||||||
|
// peerPrefixes counts the prefixes each peer carries, by its number.
|
||||||
|
peerPrefixes map[uint]int
|
||||||
|
|
||||||
|
// newPeers and changed are what TakeChanges hands over next: the peers
|
||||||
|
// numbered, and the prefix and origin AS pairs changed, since it last ran.
|
||||||
|
newPeers map[string]uint
|
||||||
|
changed map[pair]struct{}
|
||||||
|
|
||||||
|
// keptTimes counts the kept routes at each time, in time order, so its
|
||||||
|
// first and last items are the oldest and newest kept routes.
|
||||||
|
keptTimes *btree.BTreeG[keptTime]
|
||||||
|
// originsWithoutKeptRoute counts the origins that peers carry but that
|
||||||
|
// keep no route.
|
||||||
|
originsWithoutKeptRoute int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an empty table that numbers new peers on from
|
||||||
|
// params.PeerNumbers.
|
||||||
|
func New(params Params) *Table {
|
||||||
|
t := &Table{
|
||||||
|
ipv4: newFamily(ipv4Bits, minLookupMaskLengthV4),
|
||||||
|
ipv6: newFamily(ipv6Bits, minLookupMaskLengthV6),
|
||||||
|
byOrigin: make(map[int]map[netip.Prefix]*origin),
|
||||||
|
peerNumbers: make(map[string]uint, len(params.PeerNumbers)),
|
||||||
|
peerAddresses: make(map[uint]string, len(params.PeerNumbers)),
|
||||||
|
peerPrefixes: make(map[uint]int),
|
||||||
|
newPeers: make(map[string]uint),
|
||||||
|
changed: make(map[pair]struct{}),
|
||||||
|
keptTimes: btree.NewG(keptTimesDegree, func(a, b keptTime) bool {
|
||||||
|
return a.time.Before(b.time)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for address, number := range params.PeerNumbers {
|
||||||
|
t.peerNumbers[address] = number
|
||||||
|
t.peerAddresses[number] = address
|
||||||
|
t.nextPeerNumber = max(t.nextPeerNumber, number+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce applies an announcement: the peer's current route to prefix is now
|
||||||
|
// r, with origin AS originASN. A peer that carried the prefix with another
|
||||||
|
// origin AS leaves that origin's peer set, and its route there is dropped. r
|
||||||
|
// is kept in full when it is one of the two newest routes of its prefix and
|
||||||
|
// origin AS; otherwise only the peer's bit is.
|
||||||
|
//
|
||||||
|
// An announcement whose path is empty or holds AS 0 is a withdrawal, and the
|
||||||
|
// caller passes it to Withdraw instead.
|
||||||
|
func (t *Table) Announce(prefix netip.Prefix, originASN int, r Route) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !prefix.IsValid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = prefix.Masked()
|
||||||
|
peer := t.numberPeer(r.Peer)
|
||||||
|
|
||||||
|
current := originWithPeer(t.origins(prefix), peer)
|
||||||
|
if current != nil && current.asn != originASN {
|
||||||
|
t.leave(prefix, current, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.join(prefix, t.findOrAddOrigin(prefix, originASN), route{
|
||||||
|
peer: peer,
|
||||||
|
path: slices.Clone(r.ASPath),
|
||||||
|
time: r.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withdraw applies a withdrawal: the peer no longer carries prefix. It leaves
|
||||||
|
// the prefix's peer set, and its kept route, if any, is dropped.
|
||||||
|
func (t *Table) Withdraw(prefix netip.Prefix, peer string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
number, known := t.peerNumbers[peer]
|
||||||
|
if !known || !prefix.IsValid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = prefix.Masked()
|
||||||
|
|
||||||
|
o := originWithPeer(t.origins(prefix), number)
|
||||||
|
if o != nil {
|
||||||
|
t.leave(prefix, o, number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerDown applies a peer's session going down: the peer leaves every peer
|
||||||
|
// set, and every route of it is dropped. When the peer carries any prefix, it
|
||||||
|
// visits every prefix in the table.
|
||||||
|
func (t *Table) PeerDown(peer string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
number, known := t.peerNumbers[peer]
|
||||||
|
if !known || t.peerPrefixes[number] == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range []*family{&t.ipv4, &t.ipv6} {
|
||||||
|
for _, prefixes := range f.byLength {
|
||||||
|
for prefix, origins := range prefixes {
|
||||||
|
o := originWithPeer(origins, number)
|
||||||
|
if o != nil {
|
||||||
|
t.leave(prefix, o, number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeChanges returns the prefix and origin AS pairs that changed since it last
|
||||||
|
// ran, in prefix order, each with its current content. It also returns the
|
||||||
|
// peers numbered since then, by address. It then clears both records.
|
||||||
|
func (t *Table) TakeChanges() ([]Change, map[string]uint) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
changes := make([]Change, 0, len(t.changed))
|
||||||
|
for key := range t.changed {
|
||||||
|
changes = append(changes, t.currentContent(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(changes, func(a, b Change) int {
|
||||||
|
return cmp.Or(a.Prefix.Compare(b.Prefix),
|
||||||
|
cmp.Compare(a.OriginASN, b.OriginASN))
|
||||||
|
})
|
||||||
|
|
||||||
|
newPeers := t.newPeers
|
||||||
|
t.newPeers = make(map[string]uint)
|
||||||
|
t.changed = make(map[pair]struct{})
|
||||||
|
|
||||||
|
return changes, newPeers
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberPeer returns the peer's number, and gives it the next one the first
|
||||||
|
// time the table sees it.
|
||||||
|
func (t *Table) numberPeer(address string) uint {
|
||||||
|
number, known := t.peerNumbers[address]
|
||||||
|
if known {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
|
||||||
|
number = t.nextPeerNumber
|
||||||
|
t.nextPeerNumber++
|
||||||
|
t.peerNumbers[address] = number
|
||||||
|
t.peerAddresses[number] = address
|
||||||
|
t.newPeers[address] = number
|
||||||
|
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
|
||||||
|
// familyOf returns the part of the table for addr's IP version.
|
||||||
|
func (t *Table) familyOf(addr netip.Addr) *family {
|
||||||
|
if addr.Is4() {
|
||||||
|
return &t.ipv4
|
||||||
|
}
|
||||||
|
|
||||||
|
return &t.ipv6
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixesOfLength returns the prefixes of the IP version and the mask length
|
||||||
|
// of prefix, a valid masked prefix.
|
||||||
|
func (t *Table) prefixesOfLength(prefix netip.Prefix) map[netip.Prefix][]*origin {
|
||||||
|
return t.familyOf(prefix.Addr()).byLength[prefix.Bits()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// origins returns the origins of prefix, a valid masked prefix.
|
||||||
|
func (t *Table) origins(prefix netip.Prefix) []*origin {
|
||||||
|
return t.prefixesOfLength(prefix)[prefix]
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrAddOrigin returns the origin of prefix with AS asn, added with no
|
||||||
|
// peers when the prefix has none with that AS.
|
||||||
|
func (t *Table) findOrAddOrigin(prefix netip.Prefix, asn int) *origin {
|
||||||
|
prefixes := t.prefixesOfLength(prefix)
|
||||||
|
origins := prefixes[prefix]
|
||||||
|
|
||||||
|
i, found := slices.BinarySearchFunc(origins, asn,
|
||||||
|
func(o *origin, asn int) int { return cmp.Compare(o.asn, asn) })
|
||||||
|
if found {
|
||||||
|
return origins[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
o := &origin{asn: asn}
|
||||||
|
prefixes[prefix] = slices.Insert(origins, i, o)
|
||||||
|
|
||||||
|
if t.byOrigin[asn] == nil {
|
||||||
|
t.byOrigin[asn] = make(map[netip.Prefix]*origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.byOrigin[asn][prefix] = o
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeOrigin removes o, which no peer carries any more, from prefix, and the
|
||||||
|
// prefix from the table when it has no origin left.
|
||||||
|
func (t *Table) removeOrigin(prefix netip.Prefix, o *origin) {
|
||||||
|
prefixes := t.prefixesOfLength(prefix)
|
||||||
|
|
||||||
|
origins := slices.DeleteFunc(prefixes[prefix],
|
||||||
|
func(other *origin) bool { return other == o })
|
||||||
|
if len(origins) == 0 {
|
||||||
|
delete(prefixes, prefix)
|
||||||
|
} else {
|
||||||
|
prefixes[prefix] = origins
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(t.byOrigin[o.asn], prefix)
|
||||||
|
|
||||||
|
if len(t.byOrigin[o.asn]) == 0 {
|
||||||
|
delete(t.byOrigin, o.asn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// join puts the peer of r in the peer set of o, an origin of prefix, and keeps
|
||||||
|
// r in full when it is one of the two newest routes of o. The peer's earlier
|
||||||
|
// route, if o keeps one, is replaced: it is no longer the peer's current route.
|
||||||
|
// The pair is recorded as changed only when its peer set or kept routes change.
|
||||||
|
func (t *Table) join(prefix netip.Prefix, o *origin, r route) {
|
||||||
|
if slices.ContainsFunc(o.routes, r.equal) {
|
||||||
|
// The peer announced its kept route again.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wasWithoutKeptRoute := o.withoutKeptRoute()
|
||||||
|
|
||||||
|
joined := !o.peers.Test(r.peer)
|
||||||
|
if joined {
|
||||||
|
o.peers.Set(r.peer)
|
||||||
|
|
||||||
|
t.familyOf(prefix.Addr()).routes++
|
||||||
|
t.peerPrefixes[r.peer]++
|
||||||
|
}
|
||||||
|
|
||||||
|
dropped := t.dropKeptRoute(o, r.peer)
|
||||||
|
kept := t.keepIfNewest(o, r)
|
||||||
|
|
||||||
|
if joined || dropped || kept {
|
||||||
|
t.recordChange(prefix, o, wasWithoutKeptRoute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// leave takes peer out of the peer set of o, an origin of prefix, and drops
|
||||||
|
// its kept route, if any.
|
||||||
|
func (t *Table) leave(prefix netip.Prefix, o *origin, peer uint) {
|
||||||
|
wasWithoutKeptRoute := o.withoutKeptRoute()
|
||||||
|
|
||||||
|
o.peers.Clear(peer)
|
||||||
|
t.dropKeptRoute(o, peer)
|
||||||
|
|
||||||
|
t.familyOf(prefix.Addr()).routes--
|
||||||
|
t.peerPrefixes[peer]--
|
||||||
|
|
||||||
|
t.recordChange(prefix, o, wasWithoutKeptRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepIfNewest keeps r in o unless both kept routes of o are newer, and says
|
||||||
|
// whether it kept r. When o already keeps two routes, the older one is
|
||||||
|
// dropped, and its peer keeps only its bit.
|
||||||
|
func (t *Table) keepIfNewest(o *origin, r route) bool {
|
||||||
|
// The kept routes stay newest first: r goes before the first one that is
|
||||||
|
// not newer than it.
|
||||||
|
i := 0
|
||||||
|
for i < len(o.routes) && o.routes[i].time.After(r.time) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == keptRoutesPerOrigin {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(o.routes) == keptRoutesPerOrigin {
|
||||||
|
t.dropKeptRoute(o, o.routes[keptRoutesPerOrigin-1].peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
o.routes = slices.Insert(o.routes, i, r)
|
||||||
|
t.countKeptTime(r.time, 1)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropKeptRoute drops the peer's route from the kept routes of o, if o keeps
|
||||||
|
// one, and says whether it did.
|
||||||
|
func (t *Table) dropKeptRoute(o *origin, peer uint) bool {
|
||||||
|
i := slices.IndexFunc(o.routes, func(r route) bool { return r.peer == peer })
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
t.countKeptTime(o.routes[i].time, -1)
|
||||||
|
o.routes = slices.Delete(o.routes, i, i+1)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordChange records that the pair of prefix and the AS of o changed, and
|
||||||
|
// brings the count of origins without a kept route up to date.
|
||||||
|
// wasWithoutKeptRoute says whether o was such an origin before the change. An
|
||||||
|
// origin that no peer carries any more is removed.
|
||||||
|
func (t *Table) recordChange(
|
||||||
|
prefix netip.Prefix, o *origin, wasWithoutKeptRoute bool,
|
||||||
|
) {
|
||||||
|
t.changed[pair{prefix: prefix, asn: o.asn}] = struct{}{}
|
||||||
|
|
||||||
|
if wasWithoutKeptRoute {
|
||||||
|
t.originsWithoutKeptRoute--
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.withoutKeptRoute() {
|
||||||
|
t.originsWithoutKeptRoute++
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.peers.None() {
|
||||||
|
t.removeOrigin(prefix, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countKeptTime adds delta to the number of kept routes with time at.
|
||||||
|
func (t *Table) countKeptTime(at time.Time, delta int) {
|
||||||
|
count, _ := t.keptTimes.Get(keptTime{time: at})
|
||||||
|
count.time = at
|
||||||
|
count.routes += delta
|
||||||
|
|
||||||
|
if count.routes == 0 {
|
||||||
|
t.keptTimes.Delete(count)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.keptTimes.ReplaceOrInsert(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentContent returns what the table holds for the pair now.
|
||||||
|
func (t *Table) currentContent(key pair) Change {
|
||||||
|
o := t.byOrigin[key.asn][key.prefix]
|
||||||
|
if o == nil {
|
||||||
|
return Change{Prefix: key.prefix, OriginASN: key.asn, Removed: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Change{
|
||||||
|
Prefix: key.prefix,
|
||||||
|
OriginASN: key.asn,
|
||||||
|
Peers: o.peers.Clone(),
|
||||||
|
Routes: t.exportRoutes(o.routes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportRoutes returns copies of routes that name each peer by its address, or
|
||||||
|
// nil when there are none.
|
||||||
|
func (t *Table) exportRoutes(routes []route) []Route {
|
||||||
|
if len(routes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := make([]Route, 0, len(routes))
|
||||||
|
for _, r := range routes {
|
||||||
|
exported = append(exported, Route{
|
||||||
|
Peer: t.peerAddresses[r.peer],
|
||||||
|
ASPath: slices.Clone(r.path),
|
||||||
|
Time: r.time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return exported
|
||||||
|
}
|
||||||
|
|
||||||
|
// family is the part of the table for one IP version.
|
||||||
|
type family struct {
|
||||||
|
// byLength[n] holds the prefixes of mask length n that some peer carries,
|
||||||
|
// each with its origins in AS number order.
|
||||||
|
byLength []map[netip.Prefix][]*origin
|
||||||
|
// minLookupLength is the shortest mask length the address lookup uses.
|
||||||
|
minLookupLength int
|
||||||
|
// routes counts the peer and prefix pairs: each peer that carries a
|
||||||
|
// prefix counts once for it.
|
||||||
|
routes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFamily returns an empty family for addresses of bits bits.
|
||||||
|
func newFamily(bits, minLookupLength int) family {
|
||||||
|
f := family{
|
||||||
|
byLength: make([]map[netip.Prefix][]*origin, bits+1),
|
||||||
|
minLookupLength: minLookupLength,
|
||||||
|
}
|
||||||
|
|
||||||
|
for n := range f.byLength {
|
||||||
|
f.byLength[n] = make(map[netip.Prefix][]*origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixesByLength returns how many prefixes of each mask length f holds.
|
||||||
|
func (f *family) prefixesByLength() []int {
|
||||||
|
counts := make([]int, len(f.byLength))
|
||||||
|
for n, prefixes := range f.byLength {
|
||||||
|
counts[n] = len(prefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
// origin is what the table holds for one prefix and origin AS.
|
||||||
|
type origin struct {
|
||||||
|
asn int
|
||||||
|
// peers holds the number of each peer whose current route to the prefix
|
||||||
|
// has this origin AS.
|
||||||
|
peers bitset.BitSet
|
||||||
|
// routes are the newest of those routes, newest first, at most
|
||||||
|
// keptRoutesPerOrigin.
|
||||||
|
routes []route
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutKeptRoute says whether peers carry o but o keeps no route.
|
||||||
|
func (o *origin) withoutKeptRoute() bool {
|
||||||
|
return o.peers.Any() && len(o.routes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// newestTime returns the time of the newest kept route of o, or nil when o
|
||||||
|
// keeps none.
|
||||||
|
func (o *origin) newestTime() *time.Time {
|
||||||
|
if len(o.routes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newest := o.routes[0].time
|
||||||
|
|
||||||
|
return &newest
|
||||||
|
}
|
||||||
|
|
||||||
|
// originWithPeer returns the origin whose peer set holds peer, or nil when
|
||||||
|
// none does. A peer carries a prefix with one origin AS at most.
|
||||||
|
func originWithPeer(origins []*origin, peer uint) *origin {
|
||||||
|
for _, o := range origins {
|
||||||
|
if o.peers.Test(peer) {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// route is a kept route. peer is the peer's number.
|
||||||
|
type route struct {
|
||||||
|
peer uint
|
||||||
|
path []int
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// equal says whether r and other are the same peer's route with the same path
|
||||||
|
// and time.
|
||||||
|
func (r route) equal(other route) bool {
|
||||||
|
return r.peer == other.peer && r.time.Equal(other.time) &&
|
||||||
|
slices.Equal(r.path, other.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pair names one prefix and origin AS.
|
||||||
|
type pair struct {
|
||||||
|
prefix netip.Prefix
|
||||||
|
asn int
|
||||||
|
}
|
||||||
|
|
||||||
|
// keptTime counts the kept routes with one time.
|
||||||
|
type keptTime struct {
|
||||||
|
time time.Time
|
||||||
|
routes int
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user