diff --git a/README.md b/README.md index 9a1a64f..e0cf0ef 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # RouteWatch 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 -service, maintains a live routing table in SQLite, and provides HTTP APIs for -querying routing information. +table in real time. It works like this: + +- 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 @@ -233,6 +235,7 @@ routewatch/ │ ├── routewatch/ # Core application logic │ ├── server/ # HTTP server and handlers │ ├── database/ # SQLite storage layer +│ ├── routetable/ # The whole routing table in memory │ ├── streamer/ # RIPE RIS Live client │ ├── ristypes/ # BGP message data structures │ ├── logger/ # Structured logging wrapper diff --git a/TODO.md b/TODO.md index 35ecbbd..2151c36 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,10 @@ tested. # 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 address is on, in `blocklists`. The lists are DShield, Feodo Tracker, two FireHOL lists, Spamhaus DROP and the Tor exit list. RouteWatch downloads each @@ -244,4 +248,6 @@ tested. # 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 diff --git a/go.mod b/go.mod index 95b8520..b10f623 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module git.eeqj.de/sneak/routewatch go 1.27.1 require ( + github.com/bits-and-blooms/bitset v1.26.0 github.com/dustin/go-humanize v1.0.1 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/mattn/go-sqlite3 v1.14.29 github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 diff --git a/go.sum b/go.sum index 20e604d..244a36b 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= diff --git a/internal/routetable/benchmark_test.go b/internal/routetable/benchmark_test.go new file mode 100644 index 0000000..002d391 --- /dev/null +++ b/internal/routetable/benchmark_test.go @@ -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 +} diff --git a/internal/routetable/reads.go b/internal/routetable/reads.go new file mode 100644 index 0000000..fb925d7 --- /dev/null +++ b/internal/routetable/reads.go @@ -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 +} diff --git a/internal/routetable/reads_test.go b/internal/routetable/reads_test.go new file mode 100644 index 0000000..f4e20a4 --- /dev/null +++ b/internal/routetable/reads_test.go @@ -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) + } +} diff --git a/internal/routetable/routetable.go b/internal/routetable/routetable.go new file mode 100644 index 0000000..57f2215 --- /dev/null +++ b/internal/routetable/routetable.go @@ -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 +} diff --git a/internal/routetable/routetable_test.go b/internal/routetable/routetable_test.go new file mode 100644 index 0000000..1d7f333 --- /dev/null +++ b/internal/routetable/routetable_test.go @@ -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) + } +}