Files
ferrix/crates/integration-tests/tests/http_api.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 d49bab58a5 Add ADD-PATH (RFC 7911): several routes for a prefix from one peer
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>
2026-10-02 00:57:15 +02:00

304 lines
10 KiB
Rust

//! Drives the HTTP API against a real server with a real BGP peer attached.
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::{
AsPathAttribute, CommunitiesPathAttribute, LargeCommunity, Message, NextHopPathAttribute,
OriginPathAttribute, PathAttribute, UpdateMessage,
};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use eyre::Result;
use serde_json::{Value, json};
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()
}
async fn established_peer(server: &TestServer) -> Result<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90)
.await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(peer)
}
async fn get_json(client: &reqwest::Client, url: &str) -> Result<Value> {
Ok(client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
#[tokio::test]
async fn test_status_and_peers() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let config = get_json(&client, &format!("{base}/api/config")).await?;
assert_eq!(config["path"], "test.textpb");
assert_eq!(config["text"], format!("local_asn: {LOCAL_ASN}\n"));
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["local_asn"], LOCAL_ASN);
assert_eq!(status["router_id"], "127.0.0.1");
assert_eq!(status["sessions"], 0);
assert!(status["started_at_unix"].as_u64().unwrap() > 1_700_000_000);
assert!(!status["hostname"].as_str().unwrap().is_empty());
let _peer = established_peer(&server).await?;
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["sessions"], 1);
let peers = get_json(&client, &format!("{base}/api/peers")).await?;
assert_eq!(peers.as_array().unwrap().len(), 1);
assert_eq!(peers[0]["peer_asn"], PEER_ASN);
assert_eq!(peers[0]["local_addr"], server.addr.to_string());
assert_eq!(peers[0]["four_octet_asn"], true);
assert_eq!(
peers[0]["address_families"],
serde_json::json!(["IPv4 unicast", "IPv6 unicast"])
);
Ok(())
}
/// Originated routes are listed with their attributes as text.
#[tokio::test]
async fn test_originated_routes_are_listed() -> Result<()> {
let attrs = LocalRouteAttrs {
prepend: 1,
med: Some(10),
communities: vec![(65000, 100), (65535, 65281)],
large_communities: vec![LargeCommunity {
global_admin: 65000,
data_1: 1,
data_2: 2,
}],
..Default::default()
};
let server = TestServer::start_with(LOCAL_ASN, |c| {
c.originate = vec![LocalRoute::new(prefix("10.100.1.0/24"), attrs)];
})
.await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let local = get_json(&client, &format!("{base}/api/routes/local")).await?;
assert_eq!(local.as_array().unwrap().len(), 1);
assert_eq!(local[0]["prefix"], "10.100.1.0/24");
assert_eq!(local[0]["origin"], "IGP");
assert_eq!(local[0]["prepend"], 1);
assert_eq!(local[0]["med"], 10);
assert_eq!(local[0]["communities"], json!(["65000:100", "65535:65281"]));
assert_eq!(local[0]["large_communities"], json!(["65000:1:2"]));
assert_eq!(
get_json(&client, &format!("{base}/api/status")).await?["local_routes"],
1
);
Ok(())
}
/// The API only reports. Nothing reachable over HTTP changes what the
/// router announces.
#[tokio::test]
async fn test_api_is_read_only() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let body = json!({ "prefix": "10.100.1.0/24" });
for path in [
"/api/routes/local",
"/api/routes/received",
"/api/peers",
"/api/status",
] {
let post = client
.post(format!("{base}{path}"))
.json(&body)
.send()
.await?;
assert_eq!(post.status(), 405, "POST {path}");
let delete = client
.delete(format!("{base}{path}"))
.json(&body)
.send()
.await?;
assert_eq!(delete.status(), 405, "DELETE {path}");
}
assert!(server.local_rib.snapshot().await.unwrap().is_empty());
Ok(())
}
/// Routes the peer sends us show up in the received table with a summary.
#[tokio::test]
async fn test_received_routes_summary() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut peer = established_peer(&server).await?;
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![PEER_ASN, 65002]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65001, 7)])),
],
announced_routes: vec![prefix("203.0.113.0/24").into()],
}))
.await?;
let received = tokio::time::timeout(TIMEOUT, async {
loop {
let v = get_json(&client, &format!("{base}/api/routes/received")).await?;
if !v.as_array().unwrap().is_empty() {
return Ok::<_, eyre::Error>(v);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await??;
assert_eq!(received[0]["prefix"], "203.0.113.0/24");
assert_eq!(received[0]["peer_asn"], PEER_ASN);
assert_eq!(received[0]["summary"]["as_path"], json!([PEER_ASN, 65002]));
assert_eq!(received[0]["summary"]["next_hop"], "10.0.0.1");
assert_eq!(received[0]["summary"]["communities"], json!(["65001:7"]));
Ok(())
}
/// The SSE stream reports session and route changes as they happen.
#[tokio::test]
async fn test_event_stream() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut resp = client
.get(format!("{base}/api/events"))
.send()
.await?
.error_for_status()?;
assert!(
resp.headers()["content-type"]
.to_str()?
.starts_with("text/event-stream")
);
let _peer = established_peer(&server).await?;
server
.local_rib
.announce(LocalRoute::new(prefix("10.100.1.0/24"), Default::default()))
.await?;
// Collect SSE frames until we have seen both events.
let mut buf = String::new();
let mut seen_up = false;
let mut seen_announce = false;
tokio::time::timeout(TIMEOUT, async {
while !(seen_up && seen_announce) {
let chunk = resp
.chunk()
.await?
.ok_or_else(|| eyre::eyre!("stream ended"))?;
buf.push_str(std::str::from_utf8(&chunk)?);
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
let ev: Value = serde_json::from_str(line.trim())?;
match ev["type"].as_str() {
Some("session_up") if ev["peer"]["peer_asn"] == PEER_ASN => seen_up = true,
Some("local_announced") if ev["route"]["prefix"] == "10.100.1.0/24" => {
seen_announce = true
}
_ => {}
}
}
}
Ok::<_, eyre::Error>(())
})
.await??;
Ok(())
}
#[tokio::test]
async fn test_statusz_page_is_served() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let resp = client.get(format!("{base}/statusz")).send().await?;
assert_eq!(resp.status(), 200);
assert!(
resp.headers()["content-type"]
.to_str()?
.starts_with("text/html")
);
let body = resp.text().await?;
assert!(body.contains("<title>Status for ferrix_bgp</title>"));
assert!(body.contains("/api/events"));
// The root redirects to the page.
let no_redirect = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let resp = no_redirect.get(format!("{base}/")).send().await?;
assert_eq!(resp.status(), 307);
assert_eq!(resp.headers()["location"], "/statusz");
Ok(())
}
#[tokio::test]
async fn test_metricz() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let _peer = established_peer(&server).await?;
let text = client
.get(format!("{base}/metricz"))
.send()
.await?
.error_for_status()?
.text()
.await?;
// Metrics are process-global, so other tests in this binary contribute
// too; assert on presence and a lower bound rather than exact values.
let metric = |name: &str| -> Option<i64> {
text.lines()
.find(|l| l.starts_with(name) && l[name.len()..].starts_with(' '))
.and_then(|l| l.rsplit(' ').next()?.parse().ok())
};
assert!(
metric("bgp_sessions_established").unwrap_or(0) >= 1,
"{text}"
);
assert!(
metric("bgp_messages_sent_total{type=\"open\"}").unwrap_or(0) >= 1,
"{text}"
);
assert!(
metric("bgp_messages_received_total{type=\"keepalive\"}").unwrap_or(0) >= 1,
"{text}"
);
assert!(metric("bgp_local_routes").is_some(), "{text}");
assert!(metric("bgp_rib_routes").is_some(), "{text}");
Ok(())
}