From 35ce2e2e5758eea5aa03ac220fde0c1b554887f4 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Wed, 7 Oct 2026 22:09:07 +0200 Subject: [PATCH] Status page: routes in order of selection, lookup by origin AS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Received routes are listed from the best down: the RIB gains ranked_routes, which runs selection again without the winners, so the second route is the one that would be installed were the best withdrawn. The API gives each route its rank, and the page shows it as a numbered badge after the star. The lookup box moves to the received-routes table, which is what it filters, and takes an AS number: a new `origin` query parameter finds every route whose path ends in that AS, with an empty path counting as the local AS. Long cells are clamped to a few lines with a control to open them, and an AS repeated along a path (prepending) is shown once with a ×N badge. Peer names are bold and dark, not underlined links, so a row can be found by its name among the addresses and numbers. Co-Authored-By: Claude Fable 5.1 --- crates/integration-tests/tests/best_route.rs | 13 ++ crates/rib/src/table/mod.rs | 17 ++ crates/rib/src/table/select.rs | 36 +++- crates/rib/src/table/tests.rs | 56 ++++++ crates/web/src/api.rs | 183 +++++++++++++++---- crates/web/src/events.rs | 2 +- crates/web/static/statusz.html | 105 ++++++++--- docs/content/docs/operations/monitoring.md | 19 +- docs/content/docs/reference/http-api.md | 5 +- 9 files changed, 370 insertions(+), 66 deletions(-) diff --git a/crates/integration-tests/tests/best_route.rs b/crates/integration-tests/tests/best_route.rs index f80986e..026d951 100644 --- a/crates/integration-tests/tests/best_route.rs +++ b/crates/integration-tests/tests/best_route.rs @@ -81,6 +81,19 @@ async fn test_the_best_route_is_chosen_and_replaced_when_it_goes() -> Result<()> assert_eq!(routes.as_array().unwrap().len(), 2); assert_eq!(best.len(), 1); assert_eq!(best[0]["peer_asn"], 65002); + // Listed from the best down, each with its place in selection. + let ranks: Vec<(u64, u64)> = routes + .as_array() + .unwrap() + .iter() + .map(|route| { + ( + route["rank"].as_u64().unwrap(), + route["peer_asn"].as_u64().unwrap(), + ) + }) + .collect(); + assert_eq!(ranks, vec![(1, 65002), (2, 65001)]); // The session with the best route ends: the other route takes over. drop(near); diff --git a/crates/rib/src/table/mod.rs b/crates/rib/src/table/mod.rs index 26def64..6f74c8b 100644 --- a/crates/rib/src/table/mod.rs +++ b/crates/rib/src/table/mod.rs @@ -689,6 +689,23 @@ impl RibTable { .unwrap_or_default() } + /// Every route for `prefix`, from the best down: after the best, the + /// route that would be chosen were the ones before it gone, and so on. + pub fn ranked_routes(&self, prefix: &IpPrefix) -> Vec { + let hash = self.hasher.hash_one(prefix); + let shard = read(&self.shards[self.shard_of(hash)]); + shard + .rows + .find(hash, |row| row.prefix == *prefix) + .map(|row| { + select::ranked(row, &shard.peers) + .into_iter() + .map(|index| row.route(index).to_owned()) + .collect() + }) + .unwrap_or_default() + } + fn with_row(&self, prefix: &IpPrefix, read_row: impl FnOnce(&Row) -> T) -> Option { let hash = self.hasher.hash_one(prefix); let shard = read(&self.shards[self.shard_of(hash)]); diff --git a/crates/rib/src/table/select.rs b/crates/rib/src/table/select.rs index 926668c..a2de4d4 100644 --- a/crates/rib/src/table/select.rs +++ b/crates/rib/src/table/select.rs @@ -27,7 +27,7 @@ //! of all its routes. use super::attrs::SelectionKey; -use super::{DEFAULT_LOCAL_PREF, PeerInfo, Row}; +use super::{DEFAULT_LOCAL_PREF, Path, PeerInfo, Row}; /// Steps 1 to 3 as one number of 48 bits: the larger, the better. /// @@ -187,6 +187,40 @@ fn choose(row: &mut Row, peers: &[Option], scratch: &mut Scratch) -> b row.best != before } +/// The routes of `row` from the best down, as indices into its routes: +/// after the best, the one that would be chosen were the best gone, and +/// so on. What an operator asks when looking at a prefix: which route +/// would take over. +pub(super) fn ranked(row: &Row, peers: &[Option]) -> Vec { + // A copy to take the winners out of one by one, and where each of its + // routes is in the row itself. + let mut rest = Row { + prefix: row.prefix, + best: 0, + seqs: row.seqs, + paths: row + .paths + .iter() + .map(|path| Path { + word: path.word, + attrs: std::sync::Arc::clone(&path.attrs), + }) + .collect(), + path_ids: row.path_ids.clone(), + }; + let mut places: Vec = (0..row.paths.len()).collect(); + let mut scratch = Scratch::default(); + let mut order = Vec::with_capacity(row.paths.len()); + while !rest.paths.is_empty() { + choose(&mut rest, peers, &mut scratch); + let at = rest.best(); + order.push(places[at]); + rest.swap_remove(at); + places.swap_remove(at); + } + order +} + /// For a route just added at `added` that ties with the best in steps 1 to /// 3: whether it is the new best, if that can be said from the two routes /// alone. `None` if the whole row has to be looked at. diff --git a/crates/rib/src/table/tests.rs b/crates/rib/src/table/tests.rs index 3e1002a..f856d02 100644 --- a/crates/rib/src/table/tests.rs +++ b/crates/rib/src/table/tests.rs @@ -261,6 +261,62 @@ fn each_step_of_selection_decides_when_the_ones_before_tie() { assert_eq!(fixture.best(&p), Some(1)); } +/// The ranking of a prefix's routes is selection run again without the +/// winners: each route in it is what would be chosen were the ones before +/// it gone. +#[test] +fn routes_are_ranked_as_they_would_be_chosen_one_after_another() { + let p = prefix("10.0.0.0/8"); + // Peer 0 would win on identifier alone; peer 2 has the highest + // LOCAL_PREF, peer 1 the shortest path of the rest, peer 3 a MED + // that loses to peer 1's within their shared neighbouring AS. + let peers = [external(1), external(2), internal(3), external(4)]; + let fixture = Fixture::new(&peers); + fixture.announce(0, &p, &Route::via(&[7, 1, 2])); + fixture.announce(1, &p, &Route::via(&[7, 3])); + fixture.announce( + 2, + &p, + &Route { + local_pref: Some(200), + ..Route::via(&[8, 1, 2, 3]) + }, + ); + fixture.announce( + 3, + &p, + &Route { + med: Some(5), + ..Route::via(&[7, 4]) + }, + ); + let ranked: Vec = fixture + .table + .ranked_routes(&p) + .iter() + .map(|route| { + fixture + .peers + .iter() + .position(|peer| *peer == route.peer) + .unwrap() + }) + .collect(); + assert_eq!(ranked, vec![2, 1, 3, 0]); + assert_eq!(fixture.best(&p), Some(2)); + // Taking the best away makes the second the best, and so on down. + fixture.withdraw(2, &p); + assert_eq!(fixture.best(&p), Some(1)); + fixture.withdraw(1, &p); + assert_eq!(fixture.best(&p), Some(3)); + assert!( + fixture + .table + .ranked_routes(&prefix("10.1.0.0/16")) + .is_empty() + ); +} + /// MEDs are compared only within a neighbouring AS. Taken pair by pair /// that is not an order: A beats B, B beats C, C beats A. Whatever the /// order of arrival, the answer must be the same. diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index f37fff3..bef659a 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -15,7 +15,7 @@ use bgp_packet::message::{ }; use bgp_primitives::constants::IpVersion; use bgp_primitives::ip_prefix::IpPrefix; -use bgp_rib::table::{PeerIndex, RibTable, Route, RouteRef}; +use bgp_rib::table::{AttrSet, PeerIndex, RibTable, Route, RouteRef, SelectionKey}; use bgp_rib::{LocalRoute, RibStopped}; use bgp_rpki::{CacheState, Update}; use bgp_server::address::{PeerAddress, socket_text}; @@ -467,6 +467,9 @@ pub struct ReceivedRouteView { pub path_id: u32, /// Whether this is the route chosen for its prefix. pub best: bool, + /// The route's place among the prefix's routes: 1 for the best, 2 for + /// the route that would be chosen were the best gone, and so on. + pub rank: u32, /// Whether it is kept from a session of the peer's that ended, and not /// sent again since (graceful restart). pub stale: bool, @@ -534,7 +537,7 @@ impl PeerNames { /// A view of one route. `None` if the route's session has just ended: /// the route is on its way out of the table. - pub(crate) fn view(&self, route: &Route) -> Option { + pub(crate) fn view(&self, route: &Route, rank: u32) -> Option { let (peer, name, check) = self.peers.get(&route.peer)?; let origin = route.attrs.key().origin(); // What a session stored was accepted by the decoder, and parses. @@ -546,6 +549,7 @@ impl PeerNames { prefix: route.prefix.to_string(), path_id: route.path_id, best: route.best, + rank, stale: route.stale, rpki: self .rpki @@ -648,7 +652,8 @@ const DEFAULT_ROUTE_LIMIT: usize = 1000; const MAX_ROUTE_LIMIT: usize = 100_000; /// Which routes to return. At most one of `prefix`, `address` and `within` -/// says where to look; `peer` and `best` narrow what is found there. +/// says where to look; `peer`, `origin` and `best` narrow what is found +/// there. #[derive(Debug, Default, Deserialize)] pub struct RouteQuery { /// About this many routes to a page. A page holds whole prefixes: it @@ -670,11 +675,68 @@ pub struct RouteQuery { within: Option, /// Only routes from this peer: its configured name, or its address. peer: Option, + /// Only routes originated by this AS: the last of the AS path, as a + /// number, with or without `AS` in front. Routes with an empty path + /// are this router's own AS's. + origin: Option, /// Only the best route of each prefix. #[serde(default)] best: bool, } +/// What narrows the routes found where a query looks. +struct Narrowing { + /// The peers whose routes are wanted, if not every peer's. + peers: Option>, + /// The AS whose routes are wanted, if not every AS's. + origin: Option, + /// This router's AS: what an empty path originates from. + local_asn: u32, + best: bool, +} + +impl Narrowing { + fn of(query: &RouteQuery, names: &PeerNames, local_asn: u32) -> Result { + let origin = match &query.origin { + None => None, + Some(text) => Some(parse_asn(text)?), + }; + Ok(Self { + peers: query.peer.as_deref().map(|peer| names.matching(peer)), + origin, + local_asn, + best: query.best, + }) + } + + fn wants(&self, peer: PeerIndex, best: bool, key: &SelectionKey) -> bool { + (!self.best || best) + && self + .peers + .as_ref() + .is_none_or(|peers| peers.contains(&peer)) + && self.origin.is_none_or(|origin| { + let originated_by = if key.as_path_len == 0 { + Some(self.local_asn) + } else { + key.origin() + }; + originated_by == Some(origin) + }) + } +} + +/// An AS number given in a query: `64496` or `AS64496`. +fn parse_asn(text: &str) -> Result { + let digits = text + .strip_prefix("AS") + .or_else(|| text.strip_prefix("as")) + .unwrap_or(text); + digits + .parse() + .map_err(|_| QueryError::Bad(format!("{text:?} is not an AS number"))) +} + /// Why a query was not answered. pub enum QueryError { /// The query makes no sense; the text says how. @@ -736,7 +798,7 @@ impl Scope { pub struct RoutesPage { /// The routes, prefix by prefix in address order (IPv4 first, a /// shorter prefix before the longer ones at the same address), the - /// best route of each prefix first. A lookup by address has the most + /// routes of each prefix from the best down. A lookup by address has the most /// specific prefix first instead. pub routes: Vec, /// If there are routes after these: what to pass as `after` to get @@ -881,37 +943,34 @@ fn find_routes( names: &PeerNames, scope: &Scope, place: Place, - query: &RouteQuery, + narrowing: &Narrowing, limit: usize, ) -> RoutesPage { - let peers = query.peer.as_deref().map(|peer| names.matching(peer)); - let wanted = |peer: PeerIndex, best: bool| { - (!query.best || best) && peers.as_ref().is_none_or(|peers| peers.contains(&peer)) + let wanted = + |peer: PeerIndex, best: bool, attrs: &AttrSet| narrowing.wants(peer, best, attrs.key()); + // The routes of one prefix from the best down, each with its rank + // among all of them: that stays what it is whatever the query keeps. + let ranked = |prefix: &IpPrefix| -> Vec<(u32, Route)> { + (1..).zip(table.ranked_routes(prefix)).collect() }; - let views = |mut routes: Vec| -> Vec { - routes.retain(|route| wanted(route.peer, route.best)); + let views = |routes: Vec<(u32, Route)>| -> Vec { routes .iter() - .filter_map(|route| names.view(route)) + .filter(|(_, route)| wanted(route.peer, route.best, &route.attrs)) + .filter_map(|(rank, route)| names.view(route, *rank)) .collect() }; - // The routes of each prefix come with the best one first. - let best_first = |routes: &mut Vec| routes.sort_by_key(|route| !route.best); // A listing: a page of it. let listed = |inside: Option<&IpPrefix>| { let page = page_of_prefixes(table, place, limit, |route| { inside.is_none_or(|outer| outer.contains(&route.prefix)) - && wanted(route.peer, route.best) + && wanted(route.peer, route.best, route.attrs) }); let routes = page .prefixes .iter() - .flat_map(|prefix| { - let mut routes = table.routes(prefix); - best_first(&mut routes); - views(routes) - }) + .flat_map(|prefix| views(ranked(prefix))) .collect(); let edge = |more: bool, prefix: Option<&IpPrefix>| { prefix.filter(|_| more).map(IpPrefix::to_string) @@ -923,29 +982,21 @@ fn find_routes( } }; // A lookup: all there is, which is little. - let looked_up = |routes: Vec| RoutesPage { - routes: views(routes), + let looked_up = |routes: Vec| RoutesPage { + routes, next_after: None, prev_before: None, }; match scope { Scope::All => listed(None), Scope::Within(outer) => listed(Some(outer)), - Scope::Exactly(prefix) => { - let mut routes = table.routes(prefix); - best_first(&mut routes); - looked_up(routes) - } + Scope::Exactly(prefix) => looked_up(views(ranked(prefix))), // Place the longest prefix that could cover the address to the // shortest: one lookup for each length. Scope::Covering(host) => looked_up( (0..=host.length()) .rev() - .flat_map(|length| { - let mut routes = table.routes(&host.shortened(length)); - best_first(&mut routes); - routes - }) + .flat_map(|length| views(ranked(&host.shortened(length)))) .collect(), ), } @@ -976,10 +1027,11 @@ pub async fn received_routes( let scope = Scope::of(&query)?; let place = Place::of(&query)?; let names = PeerNames::of(&state); + let narrowing = Narrowing::of(&query, &names, state.info.local_asn)?; // Looking through the table, copying, parsing and sorting many routes // takes long enough to keep off the threads that sessions run on. let page = tokio::task::spawn_blocking(move || { - find_routes(&state.rib, &names, &scope, place, &query, limit) + find_routes(&state.rib, &names, &scope, place, &narrowing, limit) }) .await // Only if making the views panicked. @@ -1351,6 +1403,73 @@ mod tests { /// The place a page begins is a prefix, not a count: what comes and /// goes elsewhere in the table between two pages moves nothing. + #[test] + fn a_lookup_by_origin_finds_the_routes_whose_path_ends_in_that_as() { + let prefixes = some_prefixes(); + let (table, _) = table(&prefixes); + let narrowed = |origin: &str| Narrowing { + peers: None, + origin: Some(parse_asn(origin).ok().unwrap()), + local_asn: 65000, + best: false, + }; + let found = |narrowing: &Narrowing| { + walk(&table, 1000, false, |route| { + narrowing.wants(route.peer, route.best, route.attrs.key()) + }) + .concat() + .len() + }; + // The first peer's paths end in 65010, the second's in 65002. + assert_eq!(found(&narrowed("AS65010")), prefixes.len()); + assert_eq!(found(&narrowed("65002")), prefixes.len().div_ceil(3)); + // 65001 is on every path, but originates nothing. + assert_eq!(found(&narrowed("65001")), 0); + assert!(parse_asn("AS-65001").is_err()); + } + + #[test] + fn a_route_with_an_empty_path_is_originated_by_this_router_s_as() { + let table = RibTable::new(1); + let peer = table + .add_peer(PeerInfo { + asn: 65000, + router_id: Ipv4Addr::new(10, 0, 0, 9), + address: Ipv4Addr::new(192, 0, 2, 9).into(), + interface: 0, + ibgp: true, + reflector_client: false, + }) + .unwrap(); + let attrs = table + .attrs() + .intern( + &[ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 9))), + ], + None, + ) + .unwrap(); + table.announce(peer, &attrs, &[prefix("193.0.0.0/21")]); + let ours = |origin: u32| Narrowing { + peers: None, + origin: Some(origin), + local_asn: 65000, + best: false, + }; + let found = |narrowing: &Narrowing| { + walk(&table, 10, false, |route| { + narrowing.wants(route.peer, route.best, route.attrs.key()) + }) + .concat() + .len() + }; + assert_eq!(found(&ours(65000)), 1); + assert_eq!(found(&ours(65001)), 0); + } + #[test] fn a_table_that_changes_between_pages_does_not_shift_them() { let prefixes = some_prefixes(); diff --git a/crates/web/src/events.rs b/crates/web/src/events.rs index 700a859..878078d 100644 --- a/crates/web/src/events.rs +++ b/crates/web/src/events.rs @@ -147,7 +147,7 @@ fn best_routes(state: &AppState) -> impl Stream + use<> { let names = PeerNames::of(&state_for_views); for change in changes { let event = match change { - Change::Best(route) => match names.view(&route) { + Change::Best(route) => match names.view(&route, 1) { Some(route) => WireEvent::BestChanged { route }, None => continue, }, diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index 35cc1dd..4390989 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -63,7 +63,22 @@ table.data tr.dim td { color: #666; } .comms { min-width: 26em; } table.kv th { background: #eef; font-weight: normal; position: static; } table.data td.path { min-width: 12em; } +/* An AS repeated along the path (prepended): once, with how many times. */ +.g.x { font-family: monospace; background: #eef4ff; border-color: #88a; margin-left: 1px; cursor: help; } +/* A cell with more in it than a few lines: clamped until opened. */ +.clamp { max-height: 4.4em; overflow: hidden; } +.clamp.tall:not(.open) { -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); } +.clamp.open { max-height: none; } +.clamp-more { cursor: pointer; display: none; margin-top: 2px; } +.tall + .clamp-more { display: inline-block; } table.data td.name { white-space: nowrap; } +/* Peer names are what a row is found by: bold, dark, and not underlined + until pointed at, so that they stand out from the addresses and numbers + around them rather than joining the blue of every other link. */ +table.data td.name a.peer-link { font-weight: bold; color: #112; text-decoration: none; font-size: 13.5px; } +table.data td.name a.peer-link:hover { text-decoration: underline; color: #00c; } +/* Where the route is in path selection: the best, or the Nth to take over. */ +.g.rank { font-family: sans-serif; color: #446; background: #f3f3f8; min-width: 1em; text-align: center; } /* The strip: what needs attention, if anything. */ #strip { position: sticky; top: 0; z-index: 3; background: #fff; border: 1px solid #99a; padding: 4px 8px; margin: 6px 0; } #strip .item { display: inline-block; margin-right: 1.5em; white-space: nowrap; } @@ -93,6 +108,7 @@ form.lookup input[type=text] { font-family: monospace; }
...: router ID ..., AS ..., Ferrix ...
Started ..., up ...
+On this page: peers · RPKI · originated · kernel · received, and looking routes up · rejected · events

@@ -102,19 +118,6 @@ JSON: status, peers, ...
-
-Look up - -from - - -show all - -
-