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 - -
-