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>
289 lines
10 KiB
Rust
289 lines
10 KiB
Rust
//! End-to-end tests of ADD-PATH (RFC 7911): a peer sending several routes
|
|
//! for one prefix, told apart by path identifiers.
|
|
|
|
use std::net::{Ipv4Addr, SocketAddr};
|
|
use std::time::Duration;
|
|
|
|
use bgp_integration_tests::test_peer::TestPeer;
|
|
use bgp_integration_tests::test_server::TestServer;
|
|
use bgp_packet::message::{
|
|
AsPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute,
|
|
UpdateMessage,
|
|
};
|
|
use bgp_packet::open::{AddPathEntry, AddPathSendRecv, Capability, OpenMessage};
|
|
use bgp_primitives::constants::AfiSafi;
|
|
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
|
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
|
use bgp_server::config::PeerConfig;
|
|
use bgp_server::family::{FamilySet, SUPPORTED};
|
|
use eyre::Result;
|
|
use serde_json::Value;
|
|
|
|
const LOCAL_ASN: u32 = 65000;
|
|
const PEER_ASN: u32 = 65001;
|
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
fn prefix(s: &str) -> IpPrefix {
|
|
IpPrefix::try_from(s).unwrap()
|
|
}
|
|
|
|
/// A server on which connections from this machine are those of a
|
|
/// configured peer, with ADD-PATH offered to it as `add_path` says.
|
|
async fn server_offering(
|
|
add_path: Option<AddPathSendRecv>,
|
|
originate: &[&str],
|
|
) -> Result<TestServer> {
|
|
let originate: Vec<LocalRoute> = originate
|
|
.iter()
|
|
.map(|p| LocalRoute::new(prefix(p), LocalRouteAttrs::default()))
|
|
.collect();
|
|
TestServer::start_with(LOCAL_ASN, |c| {
|
|
c.peers = vec![PeerConfig {
|
|
name: "route-server-client".into(),
|
|
endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
|
|
remote_asn: PEER_ASN,
|
|
hold_time_secs: 90,
|
|
local_address: None,
|
|
families: FamilySet::supported(),
|
|
add_path,
|
|
}];
|
|
c.originate = originate;
|
|
// Keep the dialer out of the way.
|
|
c.connect_retry = Duration::from_secs(3600);
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// A session from a peer whose OPEN offers ADD-PATH as `offer` says, for
|
|
/// IPv4 unicast. Returns the peer and the server's OPEN.
|
|
async fn peer_offering(
|
|
server: &TestServer,
|
|
offer: Option<AddPathSendRecv>,
|
|
) -> Result<(TestPeer, OpenMessage)> {
|
|
let mut capabilities = vec![Capability::FourByteAsn { asn: PEER_ASN }];
|
|
capabilities.extend(SUPPORTED.into_iter().map(Capability::multiprotocol));
|
|
if let Some(send_recv) = offer {
|
|
capabilities.push(Capability::AddPath {
|
|
entries: vec![AddPathEntry {
|
|
afi: AfiSafi::IPV4_UNICAST.afi,
|
|
safi: AfiSafi::IPV4_UNICAST.safi,
|
|
send_recv,
|
|
}],
|
|
});
|
|
}
|
|
let mut peer = TestPeer::connect(server.addr).await?;
|
|
let open = peer
|
|
.handshake_with(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90, capabilities)
|
|
.await?;
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while server.registry.list().is_empty() {
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.await?;
|
|
Ok((peer, open))
|
|
}
|
|
|
|
fn add_path_offered(open: &OpenMessage) -> Vec<(AfiSafi, AddPathSendRecv)> {
|
|
open.capabilities()
|
|
.filter_map(|capability| match capability {
|
|
Capability::AddPath { entries } => Some(entries),
|
|
_ => None,
|
|
})
|
|
.flatten()
|
|
.map(|entry| (entry.family(), entry.send_recv))
|
|
.collect()
|
|
}
|
|
|
|
fn announcement(nlri: Nlri, 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: vec![nlri],
|
|
})
|
|
}
|
|
|
|
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 routes the server holds, as (path identifier, AS path, best).
|
|
fn held(server: &TestServer) -> Vec<(u32, Vec<u32>, bool)> {
|
|
let mut held: Vec<(u32, Vec<u32>, bool)> = server
|
|
.received_routes()
|
|
.into_iter()
|
|
.map(|route| {
|
|
let path = route
|
|
.attributes
|
|
.iter()
|
|
.find_map(|attribute| match attribute {
|
|
PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_default();
|
|
(route.path_id, path, route.best)
|
|
})
|
|
.collect();
|
|
held.sort();
|
|
held
|
|
}
|
|
|
|
/// A peer that sends several routes for one prefix: each is kept, the best
|
|
/// of them is chosen, and each is withdrawn on its own.
|
|
#[tokio::test]
|
|
async fn test_several_routes_for_a_prefix_from_one_peer() -> Result<()> {
|
|
let server = server_offering(Some(AddPathSendRecv::Receive), &[]).await?;
|
|
let (base, _http) = server.serve_http().await?;
|
|
let (mut peer, open) = peer_offering(&server, Some(AddPathSendRecv::Send)).await?;
|
|
|
|
// The server offered to receive, for every family it offers.
|
|
assert_eq!(
|
|
add_path_offered(&open),
|
|
SUPPORTED.map(|family| (family, AddPathSendRecv::Receive))
|
|
);
|
|
// In use for the one family the peer sends for, and in that direction.
|
|
let session = &server.registry.list()[0];
|
|
assert_eq!(
|
|
session.add_path_receive,
|
|
[AfiSafi::IPV4_UNICAST].into_iter().collect()
|
|
);
|
|
assert!(session.add_path_send.is_empty());
|
|
peer.send_add_path = vec![AfiSafi::IPV4_UNICAST];
|
|
|
|
let p = prefix("203.0.113.0/24");
|
|
let path = |path_id| Nlri { prefix: p, path_id };
|
|
peer.send(&announcement(path(1), &[PEER_ASN, 64999, 64998]))
|
|
.await?;
|
|
peer.send(&announcement(path(2), &[PEER_ASN, 64998]))
|
|
.await?;
|
|
wait_for_routes(&server, 2).await?;
|
|
assert_eq!(
|
|
held(&server),
|
|
[
|
|
(1, vec![PEER_ASN, 64999, 64998], false),
|
|
(2, vec![PEER_ASN, 64998], true),
|
|
]
|
|
);
|
|
assert_eq!(server.rib.prefix_count(), 1);
|
|
|
|
// The API tells them apart too.
|
|
let routes: Value = reqwest::get(format!("{base}/api/routes/received"))
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
let mut ids: Vec<u64> = routes
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|route| route["path_id"].as_u64().unwrap())
|
|
.collect();
|
|
ids.sort_unstable();
|
|
assert_eq!(ids, [1, 2]);
|
|
let peers: Value = reqwest::get(format!("{base}/api/peers"))
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(
|
|
peers[0]["add_path_receive"],
|
|
serde_json::json!(["IPv4 unicast"])
|
|
);
|
|
|
|
// Announcing a path again replaces that path only.
|
|
peer.send(&announcement(path(1), &[PEER_ASN])).await?;
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while held(&server)[0].1.len() != 1 {
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
held(&server),
|
|
[(1, vec![PEER_ASN], true), (2, vec![PEER_ASN, 64998], false),]
|
|
);
|
|
|
|
// And withdrawing one leaves the other.
|
|
peer.send(&Message::Update(UpdateMessage {
|
|
withdrawn_routes: vec![path(1)],
|
|
path_attributes: vec![],
|
|
announced_routes: vec![],
|
|
}))
|
|
.await?;
|
|
wait_for_routes(&server, 1).await?;
|
|
assert_eq!(held(&server), [(2, vec![PEER_ASN, 64998], true)]);
|
|
|
|
// When the session ends, all of its routes go.
|
|
peer.send(&announcement(path(3), &[PEER_ASN, 1, 2, 3]))
|
|
.await?;
|
|
wait_for_routes(&server, 2).await?;
|
|
drop(peer);
|
|
wait_for_routes(&server, 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// ADD-PATH changes how prefixes are written. It must be in use only when
|
|
/// both sides said so; a peer that offers it to a server that does not is
|
|
/// spoken to, and listened to, in plain BGP.
|
|
#[tokio::test]
|
|
async fn test_add_path_is_not_used_unless_both_sides_offer_it() -> Result<()> {
|
|
let server = server_offering(None, &["10.100.0.0/16"]).await?;
|
|
let (mut peer, open) = peer_offering(&server, Some(AddPathSendRecv::Both)).await?;
|
|
assert!(add_path_offered(&open).is_empty());
|
|
let session = &server.registry.list()[0];
|
|
assert!(session.add_path_receive.is_empty() && session.add_path_send.is_empty());
|
|
|
|
// Plain prefixes both ways.
|
|
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
|
|
assert_eq!(update.announced_routes, vec![prefix("10.100.0.0/16")]);
|
|
peer.send(&announcement(prefix("203.0.113.0/24").into(), &[PEER_ASN]))
|
|
.await?;
|
|
wait_for_routes(&server, 1).await?;
|
|
assert_eq!(held(&server), [(0, vec![PEER_ASN], true)]);
|
|
|
|
// And the other way round: offered by the server, not by the peer.
|
|
let server = server_offering(Some(AddPathSendRecv::Both), &["10.100.0.0/16"]).await?;
|
|
let (mut peer, open) = peer_offering(&server, None).await?;
|
|
assert_eq!(add_path_offered(&open).len(), SUPPORTED.len());
|
|
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
|
|
assert_eq!(update.announced_routes, vec![prefix("10.100.0.0/16")]);
|
|
Ok(())
|
|
}
|
|
|
|
/// Where the peer offered to receive and the server to send, the server's
|
|
/// prefixes carry a path identifier, and a peer that reads them so
|
|
/// understands them.
|
|
#[tokio::test]
|
|
async fn test_prefixes_are_sent_with_path_identifiers_when_agreed() -> Result<()> {
|
|
let server = server_offering(Some(AddPathSendRecv::Both), &["10.100.0.0/16"]).await?;
|
|
let (mut peer, _) = peer_offering(&server, Some(AddPathSendRecv::Receive)).await?;
|
|
let session = &server.registry.list()[0];
|
|
assert_eq!(
|
|
session.add_path_send,
|
|
[AfiSafi::IPV4_UNICAST].into_iter().collect()
|
|
);
|
|
assert!(session.add_path_receive.is_empty());
|
|
|
|
// Read as agreed: with identifiers.
|
|
peer.ctx.add_path = vec![AfiSafi::IPV4_UNICAST];
|
|
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
|
|
assert_eq!(update.announced_routes.len(), 1);
|
|
assert_eq!(update.announced_routes[0].prefix, prefix("10.100.0.0/16"));
|
|
|
|
// The peer did not offer to send, so the server reads its prefixes
|
|
// plain.
|
|
peer.send(&announcement(prefix("203.0.113.0/24").into(), &[PEER_ASN]))
|
|
.await?;
|
|
wait_for_routes(&server, 1).await?;
|
|
assert_eq!(held(&server), [(0, vec![PEER_ASN], true)]);
|
|
Ok(())
|
|
}
|