Files
ferrix/crates/integration-tests/tests/best_route.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 630c175918 Rework the speaker: sans-I/O FSM, sharded table, RFC 7606, ADD-PATH
The work of the speaker-cleanup branch as one commit; the branch keeps
the step-by-step history.

Sessions
- The state machine is a pure function from events to actions, driven by
  one task per connection. Idle, Connect and Active belong to a dialer
  that keeps configured peers up with backoff.
- Capabilities are negotiated: address families, four-octet AS numbers
  with AS_TRANS and AS4_PATH, route refresh, ADD-PATH per family and
  direction.
- Collision detection per RFC 4271 section 6.8, by address and
  identifier.
- Connections are taken only from configured peers, a few per address.
- A panic in a session is contained and answered with a Cease; a core
  task that stops shuts the server down with a Cease to every peer.
- Writes that make no progress are given up (RFC 9687).

Codec
- Malformed UPDATEs are handled as RFC 7606 says: treat-as-withdraw,
  attribute discard, or session reset, with the NOTIFICATION to send.
- Address families are open types; any can be named and ignored.
- Prefixes are fixed-size values, normalised, with path identifiers.
- Lengths are filled in after writing; one definition of each type code.
- Property tests and fuzz targets for every decoder.

Routing table
- Adj-RIBs-In and Loc-RIB in one sharded table that sessions write to
  directly. Routes are sixteen bytes; attribute sets are interned in
  wire form. Best-path selection per RFC 4271 section 9.1.2.2 with
  deterministic MED.
- A journal per shard lets followers read changes at their own pace;
  one that falls behind is resynchronised.
- On a RIPE RIS dump of 148 peers and 39.3M routes: loaded in 2.0 s on
  12 cores, about 52 bytes a route.

Announcing
- Originated routes come from the configuration. Each session has its
  own exporter with an Adj-RIB-Out, so a slow peer delays only itself.
- Routes to a peer in our own AS have an empty path and a LOCAL_PREF.

Operations
- HTTP status page, JSON API, event stream and Prometheus metrics.
- Benchmarks on synthetic and real tables.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 12:35:54 +02:00

240 lines
8.2 KiB
Rust

//! End-to-end tests of route selection: routes for one prefix from several
//! peers, which of them the server chooses, and how that is shown.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::{RouteChange, TestServer};
use bgp_packet::message::{
AsPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute,
UpdateMessage,
};
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
use eyre::Result;
use serde_json::Value;
const LOCAL_ASN: u32 = 65000;
const TIMEOUT: Duration = Duration::from_secs(5);
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
/// A session from the peer numbered `number`, in AS 65000 + `number`.
async fn peer(server: &TestServer, number: u8) -> Result<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(
LOCAL_ASN + u32::from(number),
Ipv4Addr::new(10, 0, 0, number),
90,
)
.await?;
Ok(peer)
}
fn announcement(prefixes: &[IpPrefix], as_path: &[u32]) -> Message {
Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(as_path.to_vec()),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
],
announced_routes: Nlri::plain(prefixes.iter().copied()),
})
}
fn withdrawal(prefixes: &[IpPrefix]) -> Message {
Message::Update(UpdateMessage {
withdrawn_routes: Nlri::plain(prefixes.iter().copied()),
path_attributes: vec![],
announced_routes: vec![],
})
}
/// Waits until the table holds `routes` routes.
async fn wait_for_routes(server: &TestServer, routes: usize) -> Result<()> {
tokio::time::timeout(TIMEOUT, async {
while server.rib.len() != routes {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await?;
Ok(())
}
/// The AS path of the route the server holds as best for `prefix`.
fn best_path(server: &TestServer, prefix: &IpPrefix) -> Option<Vec<u32>> {
let best = server
.received_routes()
.into_iter()
.find(|route| route.prefix == *prefix && route.best)?;
best.attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()),
_ => None,
})
}
async fn get_json(client: &reqwest::Client, url: &str) -> Result<Value> {
Ok(client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
/// Two peers offer the same prefix. The route with the shorter AS path is
/// chosen; when it goes, the other is.
#[tokio::test]
async fn test_the_best_route_is_chosen_and_replaced_when_it_goes() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut changes = server.route_changes();
let p = prefix("203.0.113.0/24");
let mut far = peer(&server, 1).await?;
far.send(&announcement(&[p], &[65001, 64999, 64998]))
.await?;
wait_for_routes(&server, 1).await?;
assert_eq!(best_path(&server, &p), Some(vec![65001, 64999, 64998]));
let mut near = peer(&server, 2).await?;
near.send(&announcement(&[p], &[65002, 64998])).await?;
wait_for_routes(&server, 2).await?;
assert_eq!(best_path(&server, &p), Some(vec![65002, 64998]));
// Both routes are kept and shown; one is marked.
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["received_routes"], 2);
assert_eq!(status["received_prefixes"], 1);
let routes = get_json(&client, &format!("{base}/api/routes/received")).await?;
let best: Vec<&Value> = routes
.as_array()
.unwrap()
.iter()
.filter(|route| route["best"] == true)
.collect();
assert_eq!(routes.as_array().unwrap().len(), 2);
assert_eq!(best.len(), 1);
assert_eq!(best[0]["peer_asn"], 65002);
// The session with the best route ends: the other route takes over.
drop(near);
wait_for_routes(&server, 1).await?;
assert_eq!(best_path(&server, &p), Some(vec![65001, 64999, 64998]));
far.send(&withdrawal(&[p])).await?;
wait_for_routes(&server, 0).await?;
assert_eq!(best_path(&server, &p), None);
// A follower of the best routes is told of each change: the first
// route, the better one, back to the first, none.
let mut seen = Vec::new();
while seen.last() != Some(&RouteChange::Withdrawn { prefix: p }) {
seen.push(tokio::time::timeout(TIMEOUT, changes.recv()).await??);
}
assert!(
seen.iter()
.all(|change| matches!(change, RouteChange::Announced { prefix, .. } | RouteChange::Withdrawn { prefix } if *prefix == p)),
"{seen:?}"
);
Ok(())
}
/// The event stream reports changes of best route, and only those.
#[tokio::test]
async fn test_changes_of_best_route_are_on_the_event_stream() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let p = prefix("203.0.113.0/24");
// A route that is there before the client connects is not an event.
let mut first = peer(&server, 1).await?;
first
.send(&announcement(&[prefix("198.51.100.0/24")], &[65001]))
.await?;
wait_for_routes(&server, 1).await?;
let mut stream = reqwest::Client::new()
.get(format!("{base}/api/events"))
.send()
.await?
.error_for_status()?;
first.send(&announcement(&[p], &[65001, 64998])).await?;
wait_for_routes(&server, 2).await?;
first.send(&withdrawal(&[p])).await?;
wait_for_routes(&server, 1).await?;
let mut text = String::new();
let mut events: Vec<Value> = Vec::new();
tokio::time::timeout(TIMEOUT, async {
while !events.iter().any(|event| event["type"] == "best_withdrawn") {
let chunk = stream
.chunk()
.await?
.ok_or_else(|| eyre::eyre!("stream ended"))?;
text.push_str(std::str::from_utf8(&chunk)?);
events = text
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.filter_map(|data| serde_json::from_str(data.trim()).ok())
.collect();
}
Ok::<_, eyre::Error>(())
})
.await??;
let about_routes: Vec<&Value> = events
.iter()
.filter(|event| {
event["type"]
.as_str()
.is_some_and(|t| t.starts_with("best_"))
})
.collect();
// The announcement and the withdrawal may have been read as one change
// or as two; either way nothing is said of the route that was there
// from the start, and the last word is that the prefix is gone.
assert!(about_routes.len() <= 2, "{about_routes:?}");
for event in &about_routes {
let named = event["prefix"]
.as_str()
.or(event["route"]["prefix"].as_str());
assert_eq!(named, Some("203.0.113.0/24"), "{event}");
}
assert_eq!(about_routes.last().unwrap()["type"], "best_withdrawn");
Ok(())
}
/// The table can be far too large to send whole: the API gives a part of
/// it, and says how much there is.
#[tokio::test]
async fn test_received_routes_are_returned_up_to_a_limit() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let prefixes: Vec<IpPrefix> = (0..40).map(|n| prefix(&format!("10.{n}.0.0/16"))).collect();
let mut peer = peer(&server, 1).await?;
peer.send(&announcement(&prefixes, &[65001])).await?;
wait_for_routes(&server, 40).await?;
let count = |routes: Value| routes.as_array().unwrap().len();
let url = format!("{base}/api/routes/received");
assert_eq!(count(get_json(&client, &url).await?), 40);
assert_eq!(
count(get_json(&client, &format!("{url}?limit=7")).await?),
7
);
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["received_routes"], 40);
Ok(())
}