Status page: routes in order of selection, lookup by origin AS
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ce7eef27e
commit
35ce2e2e57
9 files changed
+370
-66
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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<Route> {
|
||||
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<T>(&self, prefix: &IpPrefix, read_row: impl FnOnce(&Row) -> T) -> Option<T> {
|
||||
let hash = self.hasher.hash_one(prefix);
|
||||
let shard = read(&self.shards[self.shard_of(hash)]);
|
||||
|
||||
@@ -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<PeerInfo>], 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<PeerInfo>]) -> Vec<usize> {
|
||||
// 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<usize> = (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.
|
||||
|
||||
@@ -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<usize> = 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.
|
||||
|
||||
+151
-32
@@ -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<ReceivedRouteView> {
|
||||
pub(crate) fn view(&self, route: &Route, rank: u32) -> Option<ReceivedRouteView> {
|
||||
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<String>,
|
||||
/// Only routes from this peer: its configured name, or its address.
|
||||
peer: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<Vec<PeerIndex>>,
|
||||
/// The AS whose routes are wanted, if not every AS's.
|
||||
origin: Option<u32>,
|
||||
/// 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<Self, QueryError> {
|
||||
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<u32, QueryError> {
|
||||
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<ReceivedRouteView>,
|
||||
/// 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<Route>| -> Vec<ReceivedRouteView> {
|
||||
routes.retain(|route| wanted(route.peer, route.best));
|
||||
let views = |routes: Vec<(u32, Route)>| -> Vec<ReceivedRouteView> {
|
||||
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<Route>| 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<Route>| RoutesPage {
|
||||
routes: views(routes),
|
||||
let looked_up = |routes: Vec<ReceivedRouteView>| 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();
|
||||
|
||||
@@ -147,7 +147,7 @@ fn best_routes(state: &AppState) -> impl Stream<Item = WireEvent> + 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,
|
||||
},
|
||||
|
||||
@@ -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; }
|
||||
<div class="lefthand">
|
||||
<b id="s-hostname">...</b>: router ID <b id="s-router-id">...</b>, AS <b id="s-asn">...</b>, Ferrix <span id="s-version">...</span><br>
|
||||
Started <span id="s-started">...</span>, up <span id="s-uptime">...</span><br>
|
||||
<span class="dim">On this page: <a href="#peers">peers</a> · <a href="#rpki">RPKI</a> · <a href="#local">originated</a> · <a href="#kernel">kernel</a> · <a href="#received">received, and looking routes up</a> · <a href="#rejected">rejected</a> · <a href="#events">events</a></span><br>
|
||||
</div>
|
||||
<div class="righthand">
|
||||
<span id="refresh-ctl"></span><br>
|
||||
@@ -102,19 +118,6 @@ JSON: <a href="/api/status">status</a>, <a href="/api/peers">peers</a>, <a href=
|
||||
|
||||
<div id="strip" style="clear: both">...</div>
|
||||
|
||||
<form class="lookup" id="lookup">
|
||||
Look up <input type="text" id="lookup-q" size="30" placeholder="prefix, address, or peer" title="A prefix, an address to find every prefix that covers it, or a peer's name">
|
||||
<select id="lookup-mode" title="What to find for a prefix">
|
||||
<option value="exact">routes for this prefix or address</option>
|
||||
<option value="within">this prefix and everything inside it</option>
|
||||
</select>
|
||||
from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
<label><input type="checkbox" id="lookup-best"> best only</label>
|
||||
<button type="submit">look up</button>
|
||||
<a href="#" id="lookup-clear">show all</a>
|
||||
<span id="lookup-note"></span>
|
||||
</form>
|
||||
|
||||
<details id="config-zippy" hidden>
|
||||
<summary>Configuration <span class="dim" id="config-path"></span></summary>
|
||||
<pre id="config-text"></pre>
|
||||
@@ -143,6 +146,18 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
<div id="kernel-body">...</div>
|
||||
|
||||
<h1 id="received">Received routes (<span id="received-count">...</span>)</h1>
|
||||
<form class="lookup" id="lookup">
|
||||
Look up <input type="text" id="lookup-q" size="30" placeholder="prefix, address, AS number, or peer" title="A prefix; an address, to find every prefix that covers it; an AS number, to find every route it originates; or a peer's name">
|
||||
<select id="lookup-mode" title="What to find for a prefix">
|
||||
<option value="exact">routes for this prefix or address</option>
|
||||
<option value="within">this prefix and everything inside it</option>
|
||||
</select>
|
||||
from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
<label><input type="checkbox" id="lookup-best"> best only</label>
|
||||
<button type="submit">look up</button>
|
||||
<a href="#" id="lookup-clear">show all</a>
|
||||
<span id="lookup-note"></span>
|
||||
</form>
|
||||
<div class="pager" id="received-pager"></div>
|
||||
<table class="data">
|
||||
<thead><tr><th></th><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th class="num">MED</th><th class="num">local pref</th><th>communities</th><th>large communities</th><th>attrs</th><th title="Route origin validation (RPKI) and AS path verification (ASPA)">RPKI · ASPA</th></tr></thead>
|
||||
@@ -297,7 +312,30 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
|
||||
// An AS number, whose holder's name is shown when it is pointed at.
|
||||
const asn = n => `<span class="asn" data-asn="${esc(n)}">${esc(n)}</span>`;
|
||||
const asPath = p => p.length ? p.map(asn).join(' ') : '<span class="dim">(empty)</span>';
|
||||
// A path, with an AS that repeats (prepending) once, and how many times.
|
||||
function asPath(p) {
|
||||
if (!p.length) return '<span class="dim">(empty)</span>';
|
||||
const runs = [];
|
||||
for (const n of p) {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.asn === n) last.times++; else runs.push({ asn: n, times: 1 });
|
||||
}
|
||||
return runs.map(r => r.times === 1 ? asn(r.asn)
|
||||
: `<span class="nowrap">${asn(r.asn)}${glyph(`×${r.times}`, `AS${r.asn} ${r.times} times in a row: prepended ${r.times - 1} times`, 'x')}</span>`).join(' ');
|
||||
}
|
||||
// Wraps what can run to many lines: shown clamped, with a way to open
|
||||
// it, if it does. See markTall.
|
||||
const clamp = html => `<div class="clamp">${html}</div><span class="g dim clamp-more">▾ more</span>`;
|
||||
// After a render: finds the clamped cells with more than fits.
|
||||
function markTall(root) {
|
||||
for (const c of root.querySelectorAll('.clamp')) c.classList.toggle('tall', c.scrollHeight > c.clientHeight + 1);
|
||||
}
|
||||
function toggleClamp(cell) {
|
||||
const c = cell.querySelector('.clamp.tall'), more = cell.querySelector('.clamp-more');
|
||||
if (!c) return;
|
||||
const open = c.classList.toggle('open');
|
||||
more.textContent = open ? '▴ less' : '▾ more';
|
||||
}
|
||||
|
||||
async function getJSON(url) {
|
||||
const r = await fetch(url);
|
||||
@@ -448,7 +486,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
// ---- looking routes up -------------------------------------------------
|
||||
// The query lives in the page's address, so that it survives a reload and
|
||||
// auto-refresh, and can be passed on.
|
||||
const QUERY_KEYS = ['q', 'mode', 'peer', 'best'];
|
||||
const QUERY_KEYS = ['q', 'mode', 'peer', 'origin', 'best'];
|
||||
// Which page of the listing is shown lives there too: the prefix the
|
||||
// page comes after, or the one it comes before.
|
||||
const PAGE_KEYS = ['after', 'before'];
|
||||
@@ -456,7 +494,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
const PAGE_ROUTES = 100;
|
||||
function lookup() {
|
||||
const here = new URLSearchParams(location.search);
|
||||
return { q: (here.get('q') || '').trim(), mode: here.get('mode') || 'exact', peer: here.get('peer') || '', best: here.get('best') === '1' };
|
||||
return { q: (here.get('q') || '').trim(), mode: here.get('mode') || 'exact', peer: here.get('peer') || '', origin: here.get('origin') || '', best: here.get('best') === '1' };
|
||||
}
|
||||
function pagePlace() {
|
||||
const here = new URLSearchParams(location.search);
|
||||
@@ -472,12 +510,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
renderRefreshControl();
|
||||
loadReceived().catch(console.error);
|
||||
}
|
||||
function isLookup() { const l = lookup(); return !!(l.q || l.peer || l.best); }
|
||||
function isLookup() { const l = lookup(); return !!(l.q || l.peer || l.origin || l.best); }
|
||||
// What to ask the API for: an address finds every prefix that covers it.
|
||||
function receivedUrl() {
|
||||
const l = lookup(), ask = new URLSearchParams();
|
||||
if (l.q) ask.set(l.mode === 'within' ? 'within' : l.q.includes('/') ? 'prefix' : 'address', l.q);
|
||||
if (l.peer) ask.set('peer', l.peer);
|
||||
if (l.origin) ask.set('origin', l.origin);
|
||||
if (l.best) ask.set('best', 'true');
|
||||
const place = pagePlace();
|
||||
if (place.after) ask.set('after', place.after); else if (place.before) ask.set('before', place.before);
|
||||
@@ -494,7 +533,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
}
|
||||
function showLookup() {
|
||||
const l = lookup();
|
||||
$('lookup-q').value = l.q; $('lookup-mode').value = l.mode; $('lookup-best').checked = l.best;
|
||||
$('lookup-q').value = l.origin ? `AS${l.origin}` : l.q; $('lookup-mode').value = l.mode; $('lookup-best').checked = l.best;
|
||||
renderPeerChoices();
|
||||
}
|
||||
function setLookup(l) {
|
||||
@@ -505,6 +544,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
if (l.q) url.searchParams.set('q', l.q);
|
||||
if (l.q && l.mode === 'within') url.searchParams.set('mode', 'within');
|
||||
if (l.peer) url.searchParams.set('peer', l.peer);
|
||||
if (l.origin) url.searchParams.set('origin', l.origin);
|
||||
if (l.best) url.searchParams.set('best', '1');
|
||||
}
|
||||
history.replaceState(null, '', url.pathname + url.search + '#received');
|
||||
@@ -513,11 +553,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
renderRejected();
|
||||
}
|
||||
// What was typed is a peer's name if a peer has it: then it is the peer
|
||||
// to look at, not a prefix.
|
||||
// to look at, not a prefix. A number, with or without AS in front, is
|
||||
// an AS: the routes it originates.
|
||||
function submitLookup() {
|
||||
const typed = $('lookup-q').value.trim();
|
||||
const isPeer = state.peers.some(p => p.name === typed);
|
||||
setLookup({ q: isPeer ? '' : typed, mode: $('lookup-mode').value, peer: isPeer ? typed : $('lookup-peer').value, best: $('lookup-best').checked });
|
||||
const asn = /^(?:AS)?(\d+)$/i.exec(typed);
|
||||
setLookup({ q: isPeer || asn ? '' : typed, mode: $('lookup-mode').value, peer: isPeer ? typed : $('lookup-peer').value,
|
||||
origin: asn ? asn[1] : '', best: $('lookup-best').checked });
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
@@ -539,6 +582,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
const [mark, cls] = VALIDITY[value] || [value, ''];
|
||||
return `<span class="${cls}" title="${esc(what)}: ${esc(value)}">${mark}</span>`;
|
||||
}
|
||||
// Where a route stands in path selection: the best, or the one that
|
||||
// would take over were those ranked before it gone.
|
||||
const ORDINAL = n => n + (n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th');
|
||||
function rankGlyph(r) {
|
||||
if (r.best) return glyph('★', 'The best route for this prefix: the one chosen, installed and passed on', 'green');
|
||||
if (!r.rank) return '';
|
||||
return glyph(String(r.rank), `${ORDINAL(r.rank)} in path selection: the route that would be chosen were the ${r.rank - 1} before it gone`, 'rank');
|
||||
}
|
||||
function renderReceived() {
|
||||
const b = $('received-body'), st = state.status, looking = isLookup();
|
||||
const page = state.receivedPage, paged = !!(page.next_after || page.prev_before);
|
||||
@@ -553,14 +604,15 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
setHTML(b, `<tr><td colspan="12" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
|
||||
}
|
||||
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${r.best ? 'best' : ''}">
|
||||
<td>${r.best ? glyph('★', 'The best route for this prefix: the one chosen, installed and passed on', 'green') : ''}${r.stale ? glyph('stale', 'Kept from a session of the peer’s that ended (graceful restart), and not sent again since', 'amber') : ''}</td>
|
||||
<td class="nowrap">${rankGlyph(r)}${r.stale ? glyph('stale', 'Kept from a session of the peer’s that ended (graceful restart), and not sent again since', 'amber') : ''}</td>
|
||||
<td class="mono">${esc(r.prefix)}</td>
|
||||
<td class="mono">${r.peer_name ? `<a href="#received" class="peer-link" data-peer="${esc(r.peer_name)}">${esc(r.peer_name)}</a> ` : ''}<span class="dim">${esc(r.peer_addr)} AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}</span></td>
|
||||
<td class="mono path">${asPath(s.as_path)}</td>
|
||||
<td class="mono path">${clamp(asPath(s.as_path))}</td>
|
||||
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td>
|
||||
<td class="num">${s.med ?? DASH}</td><td class="num">${s.local_pref ?? DASH}</td>
|
||||
<td>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</td>
|
||||
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td>
|
||||
<td class="nowrap">${validity('RPKI', r.rpki)} ${validity('ASPA', r.aspa)}</td></tr>`; }).join(''));
|
||||
markTall(b);
|
||||
}
|
||||
|
||||
const RPKI_STATE_CLASS = { synchronised: 'green', synchronising: 'amber', connecting: 'red' };
|
||||
@@ -648,12 +700,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
return `<tr class="group"><td colspan="11"><a href="#received" class="peer-link" data-peer="${esc(p.peer)}">${esc(p.peer)}</a> <span class="dim" style="font-weight: normal">${esc(p.peer_addr)}</span>: <span style="font-weight: normal">${announcements(p.total)} rejected</span> ${reasonBar(p.by_reason, p.total)}${more}</td></tr>` +
|
||||
lines.map(r => { const s = r.summary; return `<tr><td></td>
|
||||
<td class="mono">${esc(r.prefix)}${r.path_id ? ` <span class="dim">path ${r.path_id}</span>` : ''}</td>
|
||||
<td class="mono path">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td>
|
||||
<td class="mono path">${clamp(asPath(r.as_path))}</td><td>${esc(r.reason)}</td>
|
||||
<td class="num">${r.times > 1 ? `×${r.times}` : ''}</td>
|
||||
<td class="nowrap"><span title="${esc(fmtWhen(r.at_unix))}${r.times > 1 ? `; the first of these ${esc(fmtWhen(r.first))}` : ''}">${age(r.at_unix)} ago</span></td>
|
||||
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td>
|
||||
<td>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</td></tr>`; }).join('');
|
||||
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td></tr>`; }).join('');
|
||||
}).join(''));
|
||||
markTall(b);
|
||||
}
|
||||
|
||||
// ---- data loading ------------------------------------------------------
|
||||
@@ -790,6 +843,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
$('lookup').addEventListener('submit', e => { e.preventDefault(); submitLookup(); });
|
||||
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
|
||||
$('peers-problems').addEventListener('change', renderPeers);
|
||||
document.addEventListener('click', e => { if (e.target.closest('.clamp-more')) toggleClamp(e.target.closest('td')); });
|
||||
document.addEventListener('dblclick', e => { const td = e.target.closest('td'); if (td && td.querySelector('.clamp')) { toggleClamp(td); getSelection().removeAllRanges(); } });
|
||||
// A peer's name anywhere on the page leads to its routes.
|
||||
document.addEventListener('click', e => {
|
||||
const link = e.target.closest('a.peer-link');
|
||||
|
||||
@@ -35,7 +35,6 @@ the RPKI state.
|
||||
|
||||
Below the status line, the page displays the following information:
|
||||
|
||||
- A lookup of received routes, by prefix, address, or neighbor name.
|
||||
- The configuration that was read at startup.
|
||||
- Each neighbor, with its session state, time established, next hop,
|
||||
session properties, number of accepted routes, rejected announcements,
|
||||
@@ -47,7 +46,9 @@ Below the status line, the page displays the following information:
|
||||
- Kernel route installation: the number of installed routes, the number of
|
||||
routes not installed and the reason, the last error reported by the
|
||||
kernel, and the interfaces.
|
||||
- The received routes that match the lookup, best path first.
|
||||
- The received routes, in order of path selection, with a lookup by prefix, address,
|
||||
origin AS, or neighbor name. The lookup is above the table that it
|
||||
filters.
|
||||
- Rejected routes, by neighbor, with the number of rejections for each
|
||||
reason, and the most recent rejected routes. Rejections of the same
|
||||
prefix for the same reason by the same AS path are displayed as one row
|
||||
@@ -72,10 +73,15 @@ them. Point to a glyph to display its meaning.
|
||||
| `AP↓`, `AP↑` | ADD-PATH: several paths per prefix are received, or sent. |
|
||||
| `2B` | The neighbor supports two-octet AS numbers only. |
|
||||
| `↑`, `↓`, `↔`, `RS`, `RSC`, `iBGP` | The role of the neighbor: provider, customer, peer, route server, route server client, or a router of the local AS. |
|
||||
| `★` | The best path for the prefix. |
|
||||
| `★`, `2`, `3`, … | The position of the route in path selection: the best path, the path that would be selected if the best path were withdrawn, and so on. |
|
||||
| `×N` | After an AS number in an AS path: the AS appears N times in a row (prepending). |
|
||||
| `OTC`, `AA`, `AGG`, `AS4`, `OID`, `CL`, `EXT` | Path attributes without a column of their own: Only to Customer, ATOMIC_AGGREGATE, AGGREGATOR, AS4_PATH, ORIGINATOR_ID, CLUSTER_LIST, and extended communities. |
|
||||
| `✓`, `✗`, `–` | RPKI or ASPA validation state: valid, invalid, or not found. |
|
||||
|
||||
An AS path or a list of communities that runs to more than a few lines is
|
||||
displayed shortened, with a **more** control. Click the control, or
|
||||
double-click the cell, to display the whole value.
|
||||
|
||||
Communities for which `community_names` specifies a name are displayed as
|
||||
a badge with that name; the community itself is displayed when you point
|
||||
to the badge. Communities of the local AS that are control communities
|
||||
@@ -95,10 +101,11 @@ parameters.
|
||||
|
||||
| Parameter | Result |
|
||||
|---|---|
|
||||
| `prefix=193.0.0.0/21` | All routes for the prefix, best path first |
|
||||
| `prefix=193.0.0.0/21` | All routes for the prefix, in order of path selection |
|
||||
| `address=193.0.0.1` | All prefixes that contain the address, most specific first |
|
||||
| `within=193.0.0.0/16` | The prefix and all more specific prefixes |
|
||||
| `peer=NAME` | Only routes from the neighbor, identified by name or address |
|
||||
| `origin=AS64496` | Only routes originated by the AS, the last AS of the AS path. Routes with an empty AS path are originated by the local AS. |
|
||||
| `best=true` | Only the best path for each prefix |
|
||||
| `limit=N` | At most N routes. Default: 1000. Maximum: 100000. |
|
||||
|
||||
@@ -110,8 +117,8 @@ curl -s '127.0.0.1:8080/api/routes/received?address=193.0.0.1&best=true' | jq
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `within`, `peer`, and `best` parameters, when used without `prefix`
|
||||
> or `address`, scan the entire routing table. With a full routing table,
|
||||
> The `within`, `peer`, `origin`, and `best` parameters, when used without
|
||||
> `prefix` or `address`, scan the entire routing table. With a full routing table,
|
||||
> such a query takes noticeably longer.
|
||||
|
||||
## Rejected Routes
|
||||
|
||||
@@ -97,10 +97,11 @@ lists the query parameters.
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `prefix` | Returns all routes for the prefix, best path first. Example: `prefix=193.0.0.0/21`. |
|
||||
| `prefix` | Returns all routes for the prefix, in order of path selection. Example: `prefix=193.0.0.0/21`. |
|
||||
| `address` | Returns all routes for the prefixes that contain the address, most specific first. Example: `address=193.0.0.1`. |
|
||||
| `within` | Returns the routes for the prefix and all more specific prefixes. Example: `within=193.0.0.0/16`. |
|
||||
| `peer` | Returns only routes from the neighbor, identified by name or address. |
|
||||
| `origin` | Returns only routes originated by the AS: the last AS of the AS path. The value is a number, with or without the prefix `AS`. Routes with an empty AS path are originated by the local AS. Example: `origin=AS64496`. |
|
||||
| `best` | If `true`, returns only the best path for each prefix. |
|
||||
| `limit` | Specifies the approximate number of routes in a page. Default: 1000. Maximum: 100000. |
|
||||
| `after` | Returns the page after the specified prefix. Use the `next_after` value of the previous page. |
|
||||
@@ -119,6 +120,7 @@ Prefixes are sorted by address, with IPv4 prefixes first.
|
||||
"prefix": "203.0.113.0/24",
|
||||
"path_id": 0,
|
||||
"best": true,
|
||||
"rank": 1,
|
||||
"stale": false,
|
||||
"rpki": null,
|
||||
"aspa": null,
|
||||
@@ -143,6 +145,7 @@ Prefixes are sorted by address, with IPv4 prefixes first.
|
||||
|---|---|
|
||||
| `path_id` | ADD-PATH path identifier, or 0. |
|
||||
| `best` | Whether the route is the best path for the prefix. |
|
||||
| `rank` | The position of the route in path selection for the prefix: 1 for the best path, 2 for the path that would be selected if the best path were withdrawn, and so on. |
|
||||
| `stale` | Whether the route is retained from a previous session of the neighbor and has not been advertised again (graceful restart). |
|
||||
| `rpki`, `aspa` | Route origin validation and ASPA verification state, if RPKI validation is configured. |
|
||||
| `summary` | The path attributes as text. `other` lists, in received order, the attributes that have no field of their own: atomic aggregate, aggregator, extended communities, AS4_PATH, only to customer, originator ID, cluster list, and unrecognized attributes. |
|
||||
|
||||
Reference in new issue
Block a user