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>
240 lines
8.2 KiB
Rust
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(())
|
|
}
|