Before this change, a session on which ADD-PATH (RFC 7911) was
negotiated in the sending direction was sent path identifiers, but only
the best route for each prefix. A route collector such as bgp.tools
wants every route a router has accepted, from every neighbor, and from
one session: without this the collector saw one path per prefix, nearly
always the same neighbor's.
This change adds `all_paths` to the export policy. On a session with it,
and ADD-PATH send negotiated for a family, every accepted route for a
prefix of that family is advertised, each with a path identifier of its
own; for a family without ADD-PATH send the best route goes as before.
`all_paths` needs `learned_routes` (a configuration error otherwise) and
the peer's `add_path` to offer sending. The export policy, the role
rules, the well-known communities and the export limits apply to every
route alike, and the export limits count every route.
Implementation, in the advertiser:
* An exporter with `all_paths` follows the table's journal of all routes
(`RibTable::reader_of_all_routes`, until now unused) instead of the
best-route journal, and is given all the routes of a prefix whenever
one of them changes (`News::Routes`).
* What the peer holds is `Announced`: the set of prefixes for a
best-route session, as before; or for an all-paths session, for each
prefix the routes announced, keyed by the peer they came from and that
peer's own identifier, with the identifier they went out with and the
form they went out in. A route keeps its identifier for as long as it
is announced, goes out again only if it changed, and is withdrawn by
its identifier; a new route gets the lowest identifier not in use for
the prefix, from 1.
* Verdicts carry either kind (`Verdict::Best`, `Verdict::All`), and a
reset of the table puts what was announced in doubt until heard of
again, as before, with the same identifiers kept across the reset.
* The record for an all-paths session is about 50 bytes a route, some
tens of megabytes for a full table from several neighbors; the
best-route bookkeeping is unchanged in size.
Also, so that a collector session can be watched: the number of routes
advertised to each peer and not withdrawn since, every route counted
where several are sent for a prefix. The exporter keeps it in the
session's counters after every change; it is `advertised_routes` in
`/api/peers`, the "sent" column of the status page's neighbor table,
and the gauge `bgp_peer_advertised_routes{peer}`.
Documentation: a "Feeding a Route Collector" section in the guide to
passing routes on, with the configuration for a collector; `all_paths`
in the configuration reference; the ADD-PATH sections of the neighbor
guide and the standards reference; the limitation that was removed;
the new field, column and metric in the HTTP API, monitoring and
metrics references.
Testing: an integration test with two feeders and a collector that
negotiated ADD-PATH receive, which checks the two routes with their
identifiers, the withdrawal of one by its identifier, a changed route
keeping its identifier and a new route taking the free one; a
configuration test for `all_paths` without `learned_routes`; a new
`collector` phase of the interoperability lab in which FRR, as a route
collector, is told both of BIRD's routes for a prefix from BIRD's two
sessions and holds them apart, and loses one when its session is
disabled, with the advertised count checked at each step. The workspace tests, the kernel tests and every lab phase pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
392 lines
14 KiB
Rust
392 lines
14 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::collections::BTreeMap;
|
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use bgp_integration_tests::announcement::{Announcement, withdrawal};
|
|
use bgp_integration_tests::test_peer::TestPeer;
|
|
use bgp_integration_tests::test_server::TestServer;
|
|
use bgp_integration_tests::{peer_config, prefix, wait_until};
|
|
use bgp_packet::message::PathAttribute;
|
|
use bgp_packet::open::{AddPathEntry, Capability, OpenMessage, SendReceive};
|
|
use bgp_primitives::constants::AfiSafi;
|
|
use bgp_primitives::ip_prefix::Nlri;
|
|
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
|
use bgp_server::config::PeerConfig;
|
|
use bgp_server::family::SUPPORTED;
|
|
use bgp_server::policy::{ExportPolicy, ImportPolicy};
|
|
use eyre::Result;
|
|
use serde_json::Value;
|
|
|
|
const LOCAL_ASN: u32 = 65000;
|
|
const PEER_ASN: u32 = 65001;
|
|
|
|
/// 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<SendReceive>, 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| {
|
|
let at = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1);
|
|
c.peers = vec![PeerConfig {
|
|
add_path,
|
|
import: ImportPolicy::permissive(),
|
|
..peer_config("route-server-client", at, PEER_ASN)
|
|
}];
|
|
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<SendReceive>,
|
|
) -> 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?;
|
|
server.wait_for_session().await?;
|
|
Ok((peer, open))
|
|
}
|
|
|
|
fn add_path_offered(open: &OpenMessage) -> Vec<(AfiSafi, SendReceive)> {
|
|
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]) -> Announcement {
|
|
Announcement::of([nlri], as_path)
|
|
}
|
|
|
|
/// 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(SendReceive::Receive), &[]).await?;
|
|
let (base, _http) = server.serve_http().await?;
|
|
let (mut peer, open) = peer_offering(&server, Some(SendReceive::Send)).await?;
|
|
|
|
// The server offered to receive, for every family it offers.
|
|
assert_eq!(
|
|
add_path_offered(&open),
|
|
SUPPORTED.map(|family| (family, SendReceive::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.announce(announcement(path(1), &[PEER_ASN, 64999, 64998]))
|
|
.await?;
|
|
peer.announce(announcement(path(2), &[PEER_ASN, 64998]))
|
|
.await?;
|
|
server.wait_for_routes(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["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.announce(announcement(path(1), &[PEER_ASN])).await?;
|
|
wait_until("the path is replaced", || held(&server)[0].1.len() == 1).await?;
|
|
assert_eq!(
|
|
held(&server),
|
|
[(1, vec![PEER_ASN], true), (2, vec![PEER_ASN, 64998], false),]
|
|
);
|
|
|
|
// And withdrawing one leaves the other.
|
|
peer.announce(withdrawal([path(1)])).await?;
|
|
server.wait_for_routes(1).await?;
|
|
assert_eq!(held(&server), [(2, vec![PEER_ASN, 64998], true)]);
|
|
|
|
// When the session ends, all of its routes go.
|
|
peer.announce(announcement(path(3), &[PEER_ASN, 1, 2, 3]))
|
|
.await?;
|
|
server.wait_for_routes(2).await?;
|
|
drop(peer);
|
|
server.wait_for_routes(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(SendReceive::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 = peer.recv_update().await?;
|
|
assert_eq!(update.announced_routes, vec![prefix("10.100.0.0/16")]);
|
|
peer.announce(announcement(prefix("203.0.113.0/24").into(), &[PEER_ASN]))
|
|
.await?;
|
|
server.wait_for_routes(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(SendReceive::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 = 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(SendReceive::Both), &["10.100.0.0/16"]).await?;
|
|
let (mut peer, _) = peer_offering(&server, Some(SendReceive::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 = 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.announce(announcement(prefix("203.0.113.0/24").into(), &[PEER_ASN]))
|
|
.await?;
|
|
server.wait_for_routes(1).await?;
|
|
assert_eq!(held(&server), [(0, vec![PEER_ASN], true)]);
|
|
Ok(())
|
|
}
|
|
|
|
const COLLECTOR_ASN: u32 = 65100;
|
|
|
|
/// What a collector holds: for each prefix and path identifier, the
|
|
/// route's AS path.
|
|
type Held = BTreeMap<(String, u32), Vec<u32>>;
|
|
|
|
/// Reads what the collector is told, into `held`, until `done` says so.
|
|
async fn told(
|
|
collector: &mut TestPeer,
|
|
held: &mut Held,
|
|
done: impl Fn(&Held) -> bool,
|
|
) -> Result<()> {
|
|
while !done(held) {
|
|
let update = collector.recv_update().await?;
|
|
let path = update
|
|
.path_attributes
|
|
.iter()
|
|
.find_map(|attribute| match attribute {
|
|
PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_default();
|
|
for nlri in &update.withdrawn_routes {
|
|
held.remove(&(nlri.prefix.to_string(), nlri.path_id));
|
|
}
|
|
for nlri in &update.announced_routes {
|
|
held.insert((nlri.prefix.to_string(), nlri.path_id), path.clone());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The routes held for 203.0.113.0/24, by path identifier.
|
|
fn paths(held: &Held) -> Vec<(u32, Vec<u32>)> {
|
|
held.iter()
|
|
.map(|((prefix, id), path)| {
|
|
assert_eq!(prefix, "203.0.113.0/24");
|
|
(*id, path.clone())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A collector that agreed to be sent several routes for a prefix, and
|
|
/// whose export policy says to send it every route, is told of every
|
|
/// peer's route for a prefix, each with a path identifier of its own that
|
|
/// stays with the route; a route that goes is withdrawn by its identifier.
|
|
#[tokio::test]
|
|
async fn test_every_route_goes_to_a_collector_told_apart_by_path_identifiers() -> Result<()> {
|
|
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
|
// The collector is the configured peer at the IPv4 loopback
|
|
// address; the feeders come over IPv6 loopback as peers of no
|
|
// account.
|
|
c.listen_endpoints
|
|
.push(SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 0));
|
|
c.peers = vec![PeerConfig {
|
|
add_path: Some(SendReceive::Send),
|
|
import: ImportPolicy::permissive(),
|
|
export: Arc::new(ExportPolicy {
|
|
learned_routes: true,
|
|
all_paths: true,
|
|
..ExportPolicy::default()
|
|
}),
|
|
..peer_config(
|
|
"collector",
|
|
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
|
|
COLLECTOR_ASN,
|
|
)
|
|
}];
|
|
c.connect_retry = Duration::from_secs(3600);
|
|
})
|
|
.await?;
|
|
let route = |path: &[u32]| Announcement::of([prefix("203.0.113.0/24").into()], path);
|
|
let feeder = async |n: u32| -> Result<TestPeer> {
|
|
let mut peer = TestPeer::connect(server.addrs[1]).await?;
|
|
peer.handshake(PEER_ASN + n, Ipv4Addr::new(10, 0, 0, n as u8 + 1), 90)
|
|
.await?;
|
|
Ok(peer)
|
|
};
|
|
let mut a = feeder(0).await?;
|
|
let mut b = feeder(1).await?;
|
|
server.wait_for_sessions(2).await?;
|
|
a.announce(route(&[PEER_ASN, 64510])).await?;
|
|
b.announce(route(&[PEER_ASN + 1, 64510])).await?;
|
|
server.wait_for_routes(2).await?;
|
|
|
|
let mut capabilities = vec![Capability::FourByteAsn { asn: COLLECTOR_ASN }];
|
|
capabilities.extend(SUPPORTED.into_iter().map(Capability::multiprotocol));
|
|
capabilities.push(Capability::AddPath {
|
|
entries: vec![AddPathEntry {
|
|
afi: AfiSafi::IPV4_UNICAST.afi,
|
|
safi: AfiSafi::IPV4_UNICAST.safi,
|
|
send_recv: SendReceive::Receive,
|
|
}],
|
|
});
|
|
let mut collector = TestPeer::connect(server.addr).await?;
|
|
collector
|
|
.handshake_with(
|
|
COLLECTOR_ASN,
|
|
Ipv4Addr::new(10, 0, 0, 100),
|
|
90,
|
|
capabilities,
|
|
)
|
|
.await?;
|
|
collector.ctx.add_path = vec![AfiSafi::IPV4_UNICAST];
|
|
|
|
// Both routes, told apart.
|
|
let mut held = Held::new();
|
|
told(&mut collector, &mut held, |held| held.len() == 2).await?;
|
|
assert_eq!(
|
|
paths(&held),
|
|
[
|
|
(1, vec![LOCAL_ASN, PEER_ASN, 64510]),
|
|
(2, vec![LOCAL_ASN, PEER_ASN + 1, 64510]),
|
|
]
|
|
);
|
|
|
|
// The second peer's route goes: withdrawn by its identifier, the
|
|
// first's untouched.
|
|
b.announce(withdrawal([prefix("203.0.113.0/24").into()]))
|
|
.await?;
|
|
told(&mut collector, &mut held, |held| held.len() == 1).await?;
|
|
assert_eq!(paths(&held), [(1, vec![LOCAL_ASN, PEER_ASN, 64510])]);
|
|
|
|
// The first peer's route changes: it goes out again with the same
|
|
// identifier. A third peer's route gets the lowest free one.
|
|
a.announce(route(&[PEER_ASN, 64511, 64510])).await?;
|
|
let mut c = feeder(2).await?;
|
|
server.wait_for_sessions(4).await?;
|
|
c.announce(route(&[PEER_ASN + 2, 64510])).await?;
|
|
let changed = |held: &Held| {
|
|
held.len() == 2
|
|
&& held.get(&("203.0.113.0/24".to_string(), 1))
|
|
== Some(&vec![LOCAL_ASN, PEER_ASN, 64511, 64510])
|
|
};
|
|
told(&mut collector, &mut held, changed).await?;
|
|
assert_eq!(
|
|
paths(&held),
|
|
[
|
|
(1, vec![LOCAL_ASN, PEER_ASN, 64511, 64510]),
|
|
(2, vec![LOCAL_ASN, PEER_ASN + 2, 64510]),
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|