A peer can now send several routes for one prefix, told apart by a path identifier, and each is a route of its own in the table. Wire: - The ADD-PATH capability lists several address families. It was read as exactly one, so the OPEN of a router offering it for IPv4 and IPv6 was malformed and the session never came up. - Prefixes in UPDATEs are `Nlri`: a prefix and a path identifier. The identifier is on the wire, in the body and in MP_REACH/MP_UNREACH, for the families the session uses ADD-PATH for in that direction. Sending and receiving are settled separately, so a session has one codec context for each. Negotiation: - `add_path` on a peer in the configuration: off (the default), receive, send or both, offered for every family offered to the peer. - In use for a family and a direction when one side offered to send and the other to receive. Shown on /statusz and in /api/peers. Table: - A route is known by its peer and path identifier together. Identifiers are stored only in rows that have a non-zero one, so routes of peers that send one route each cost nothing more. - Equal routes from one peer are chosen between by the lower identifier. - /api/routes/received gives each route's path_id. Journal: - A second journal in each shard records every change to any route, for followers that want all the routes of a prefix and not only the best: RibTable::reader_of_all_routes and Event::Routes. This is what sending several routes to a peer will read. It costs nothing while nobody follows it. Sending: prefixes go out with a path identifier where that was agreed, but there is still one route for each prefix to send: only originated routes are announced. Tested with the table and journal model tests extended to path identifiers and to followers of all routes, the codec property tests and fuzz targets extended to ADD-PATH sessions, and end to end in both directions and for the case where only one side offers it. 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(())
|
|
}
|