Files
ferrix/crates/integration-tests/tests/interop.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 41090966e4 Stop treating a dead RIB as an empty one
The RIB handles swallowed every failure of their actor: an UPDATE applied
to a stopped Adj-RIB-In vanished, and a snapshot of it came back empty.
Sessions stayed up, peers' routes went nowhere, and the status page showed
a healthy router with no routes. An actor stops only by panicking, so this
is the state the server would be in after a bug.

- Every operation on AdjRibIn and LocalRib returns Result<_, RibStopped>.
  LocalRibError is replaced by it.
- The server waits on both actors, the advertiser and the dialers. If one
  stops it sends every peer Cease / Administrative Shutdown and returns an
  error, so the process exits and can be restarted. Before, the tasks'
  handles were held and never looked at.
- A session that cannot store an UPDATE ends with the same Cease.
- The HTTP API answers 503 with the reason for anything that needs a table
  it cannot read, including /metricz: a scrape that fails is noticed, last
  known sizes are not.

Also:
- The routes of a session are now removed even if its task is torn down
  without running its cleanup (a panic), instead of staying in the table
  as routes of a peer that is gone.
- Table sizes for the status page and metrics are asked for as a count
  instead of copying the whole table to take its length.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 23:18:59 +02:00

141 lines
5.3 KiB
Rust

//! Interoperability with what real routers put on the wire.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{Message, PathAttribute};
use bgp_packet::open::{Capability, OpenOption};
use bgp_primitives::constants::{Afi, Safi};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use eyre::Result;
const TIMEOUT: Duration = Duration::from_secs(5);
/// The OPEN a BIRD 2 router sends for an IPv6-only session from AS210036:
/// one optional parameter carrying MP(IPv6 unicast), route refresh,
/// extended message, graceful restart (aware only), 4-octet AS, enhanced
/// route refresh and long-lived graceful restart (aware only).
#[rustfmt::skip]
const BIRD_OPEN: &[u8] = &[
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0x00, 0x37, 0x01, // length 55, type OPEN
0x04, // version
0x5b, 0xa0, // AS_TRANS
0x00, 0xf0, // hold time 240
0x0a, 0xff, 0x00, 0x01, // router id 10.255.0.1
0x1a, // optional parameters length 26
0x02, 0x18, // capabilities, 24 bytes
0x01, 0x04, 0x00, 0x02, 0x00, 0x01, // MP: IPv6 unicast
0x02, 0x00, // route refresh
0x06, 0x00, // extended message
0x40, 0x02, 0x00, 0x00, // graceful restart, no address families
0x41, 0x04, 0x00, 0x03, 0x34, 0x74, // 4-octet AS 210036
0x46, 0x00, // enhanced route refresh
0x47, 0x00, // long-lived graceful restart, none
];
/// IPv6 End-of-RIB: an UPDATE whose only attribute is an empty
/// MP_UNREACH_NLRI for IPv6 unicast (RFC 4724 §2).
#[rustfmt::skip]
const IPV6_END_OF_RIB: &[u8] = &[
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0x00, 0x1e, 0x02, // length 30, type UPDATE
0x00, 0x00, // no withdrawn routes
0x00, 0x07, // 7 bytes of path attributes
0x90, 0x0f, 0x00, 0x03, // optional + extended length, MP_UNREACH_NLRI, len 3
0x00, 0x02, 0x01, // IPv6 unicast, no prefixes
];
#[tokio::test]
async fn test_server_advertises_ipv4_and_ipv6_unicast() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peer = TestPeer::connect(server.addr).await?;
let open = peer.recv_open().await?;
let caps: Vec<&Capability> = open
.options
.iter()
.flat_map(|o| match o {
OpenOption::Capabilities(c) => c.iter(),
})
.collect();
for afi in [Afi::IPV4, Afi::IPV6] {
assert!(
caps.iter().any(|c| matches!(c, Capability::MultiProtocol { afi: a, safi: Safi::UNICAST } if *a == afi)),
"missing MP capability for {afi:?}: {caps:?}"
);
}
Ok(())
}
#[tokio::test]
async fn test_bird_open_and_end_of_rib_are_accepted() -> Result<()> {
let server = TestServer::start(4200000001).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.send_raw(BIRD_OPEN).await?;
peer.recv_open().await?;
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
Message::KeepAlive => {}
other => eyre::bail!("server did not accept BIRD's OPEN: {other:?}"),
}
peer.send(&Message::KeepAlive).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
let session = server.registry.list().remove(0);
assert_eq!(
session.peer.asn, 210036,
"4-octet ASN must come from the capability, not AS_TRANS"
);
assert!(session.four_octet_asn);
// End-of-RIB must neither break the session nor create a route.
peer.send_raw(IPV6_END_OF_RIB).await?;
peer.send(&Message::KeepAlive).await?;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
server.registry.list().len(),
1,
"session dropped after End-of-RIB"
);
assert!(server.adj_rib_in.snapshot().await.unwrap().is_empty());
Ok(())
}
/// Over an IPv4 test transport an IPv6 route cannot be sent (no IPv6 next
/// hop), so this only checks the builder the advertiser uses: the UPDATE for
/// an IPv6 prefix carries MP_REACH_NLRI with an IPv6 next hop and no NEXT_HOP.
#[tokio::test]
async fn test_ipv6_prefix_update_shape() -> Result<()> {
let route = LocalRoute::new(
IpPrefix::try_from("2001:db8:1::/48")?,
LocalRouteAttrs::default(),
);
let nh: std::net::Ipv6Addr = "fd00:179::2".parse()?;
let update = bgp_server::advertise::build_announce(&route, 4200000001, nh.into(), true)
.expect("ipv6 over ipv6");
assert!(update.announced_routes.is_empty());
assert!(
!update
.path_attributes
.iter()
.any(|a| matches!(a, PathAttribute::NextHop(_)))
);
assert!(
update
.path_attributes
.iter()
.any(|a| matches!(a, PathAttribute::MpReachNlri(m)
if m.afi == Afi::IPV6 && m.prefixes[0].to_string() == "2001:db8:1::/48"))
);
let _ = Ipv4Addr::LOCALHOST;
Ok(())
}