//! 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::announcement::announcement; use bgp_integration_tests::test_peer::TestPeer; use bgp_integration_tests::test_server::TestServer; use bgp_integration_tests::{TIMEOUT, get_json, prefix}; use bgp_packet::message::LargeCommunity; use bgp_rib::{LocalRoute, LocalRouteAttrs}; use eyre::Result; use serde_json::{Value, json}; const LOCAL_ASN: u32 = 65000; const PEER_ASN: u32 = 65001; async fn established_peer(server: &TestServer) -> Result { server .established(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1)) .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"]) ); // The session's TCP connection, by the address the peer list gives. // What the kernel says of it depends on the platform; that it was // asked, and why it had nothing to say if it had not, does not. let peer_addr = peers[0]["peer_addr"].as_str().unwrap(); let tcp = get_json(&client, &format!("{base}/api/tcp?peer={peer_addr}")).await?; let available = tcp["available"].as_bool().expect("available is a bool"); if available { assert!(tcp["connection"].is_object(), "{tcp}"); } else { assert!(tcp["reason"].is_string(), "{tcp}"); } // The address alone names the peer too, as it does elsewhere. let by_address = get_json(&client, &format!("{base}/api/tcp?peer=127.0.0.1")).await?; assert_eq!(by_address["available"], tcp["available"]); // A peer there is no session with is a bad request, not an answer. let nobody = client .get(format!("{base}/api/tcp?peer=nobody")) .send() .await?; assert_eq!(nobody.status(), 400); 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.announce( announcement(&["203.0.113.0/24"], &[PEER_ASN, 65002]) .with_next_hop(Ipv4Addr::new(10, 0, 0, 1)) .with_communities(&[(65001, 7)]), ) .await?; let received = tokio::time::timeout(TIMEOUT, async { loop { let v = get_json(&client, &format!("{base}/api/routes/received")).await?["routes"].take(); 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("Status for ferrix_bgp")); 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 { 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(()) }