The test files had grown their own copies of the same helpers: prefix() twelve times, established() seven, an UPDATE builder six, a seventeen field PeerConfig literal in fifteen places, a timeout constant in twenty one. Each is in crates/integration-tests/src once now: peer_config() and TestServer::with_peer for a configured peer, Announcement for building UPDATEs, established(), the waits (wait_until, wait_for, wait_for_routes, wait_for_sessions), get_json, and TIMEOUT. A new field on PeerConfig is one line to add, not fifteen. TestPeer::recv has a deadline, so a test that gets nothing fails instead of hanging; is_closed, which read one byte and took a KEEPALIVE for an open connection, is closed_within and closed_unanswered. Net 788 lines fewer; every test checks what it did. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
262 lines
8.9 KiB
Rust
262 lines
8.9 KiB
Rust
//! Finding out what the server has and why: looking routes up, and seeing
|
|
//! what was refused.
|
|
|
|
use std::net::Ipv4Addr;
|
|
|
|
use bgp_integration_tests::announcement::announcement;
|
|
use bgp_integration_tests::test_server::{ServerWithPeer, TestServer};
|
|
use bgp_integration_tests::{default_policy, wait_until};
|
|
use bgp_server::policy::ImportPolicy;
|
|
use eyre::Result;
|
|
use serde_json::Value;
|
|
|
|
/// The routes a query returns, as "prefix from AS, best or not".
|
|
async fn routes(client: &reqwest::Client, base: &str, query: &str) -> Result<Vec<String>> {
|
|
let reply = client
|
|
.get(format!("{base}/api/routes/received?{query}"))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
let page: Value = reply.json().await?;
|
|
Ok(page["routes"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|r| {
|
|
format!(
|
|
"{} AS{}{}",
|
|
r["prefix"].as_str().unwrap(),
|
|
r["peer_asn"],
|
|
if r["best"] == true { " best" } else { "" }
|
|
)
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// The questions asked when something is wrong with a prefix: who sends it
|
|
/// and which route won, what covers an address, what is inside a block.
|
|
#[tokio::test]
|
|
async fn test_routes_can_be_looked_up() -> Result<()> {
|
|
let server = TestServer::start(65000).await?;
|
|
let (base, _http) = server.serve_http().await?;
|
|
let client = reqwest::Client::new();
|
|
|
|
let mut one = server
|
|
.established(65001, Ipv4Addr::new(10, 0, 0, 1))
|
|
.await?;
|
|
let mut two = server
|
|
.established(65002, Ipv4Addr::new(10, 0, 0, 2))
|
|
.await?;
|
|
one.announce(announcement(
|
|
&["10.0.0.0/8", "10.1.0.0/16", "10.1.2.0/24", "192.0.2.0/24"],
|
|
&[65001],
|
|
))
|
|
.await?;
|
|
// A longer path for one of the same prefixes: it loses.
|
|
two.announce(announcement(&["10.1.0.0/16"], &[65002, 65003]))
|
|
.await?;
|
|
server.wait_for_routes(5).await?;
|
|
|
|
// One prefix: every route for it, the best first.
|
|
assert_eq!(
|
|
routes(&client, &base, "prefix=10.1.0.0/16").await?,
|
|
["10.1.0.0/16 AS65001 best", "10.1.0.0/16 AS65002"]
|
|
);
|
|
assert!(
|
|
routes(&client, &base, "prefix=10.2.0.0/16")
|
|
.await?
|
|
.is_empty()
|
|
);
|
|
|
|
// An address: every prefix that covers it, the most specific first.
|
|
assert_eq!(
|
|
routes(&client, &base, "address=10.1.2.3").await?,
|
|
[
|
|
"10.1.2.0/24 AS65001 best",
|
|
"10.1.0.0/16 AS65001 best",
|
|
"10.1.0.0/16 AS65002",
|
|
"10.0.0.0/8 AS65001 best"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
routes(&client, &base, "address=10.200.0.1").await?,
|
|
["10.0.0.0/8 AS65001 best"]
|
|
);
|
|
|
|
// A block: itself and everything inside it.
|
|
assert_eq!(
|
|
routes(&client, &base, "within=10.1.0.0/16").await?,
|
|
[
|
|
"10.1.0.0/16 AS65001 best",
|
|
"10.1.0.0/16 AS65002",
|
|
"10.1.2.0/24 AS65001 best"
|
|
]
|
|
);
|
|
|
|
// Only what was chosen; and everything, in address order.
|
|
assert_eq!(
|
|
routes(&client, &base, "within=10.0.0.0/8&best=true").await?,
|
|
[
|
|
"10.0.0.0/8 AS65001 best",
|
|
"10.1.0.0/16 AS65001 best",
|
|
"10.1.2.0/24 AS65001 best"
|
|
]
|
|
);
|
|
assert_eq!(routes(&client, &base, "").await?.len(), 5);
|
|
// A page holds whole prefixes: at most the limit, unless one prefix
|
|
// alone has more.
|
|
assert!((1..=2).contains(&routes(&client, &base, "limit=2").await?.len()));
|
|
|
|
// One peer's routes, by its address; a peer that is not there has none.
|
|
assert_eq!(routes(&client, &base, "peer=127.0.0.1").await?.len(), 5);
|
|
assert!(routes(&client, &base, "peer=192.0.2.9").await?.is_empty());
|
|
|
|
// A query that makes no sense says so.
|
|
for (query, complaint) in [
|
|
("prefix=10.1.0.0", "is not a prefix"),
|
|
("address=example", "is not an IP address"),
|
|
("prefix=10.0.0.0/8&address=10.0.0.1", "at most one of"),
|
|
] {
|
|
let reply = client
|
|
.get(format!("{base}/api/routes/received?{query}"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(reply.status(), 400, "{query}");
|
|
let text = reply.text().await?;
|
|
assert!(text.contains(complaint), "{query}: {text}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A refused route is not in the table, so it has to be findable somewhere
|
|
/// else: which peer, which prefix, with what path, and why. And there is
|
|
/// something to alert on.
|
|
#[tokio::test]
|
|
async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> {
|
|
let ServerWithPeer {
|
|
server,
|
|
listener: _listener,
|
|
} = TestServer::with_peer(1299, 3333, |_, peer| {
|
|
peer.name = "refused-demo".into();
|
|
peer.import = ImportPolicy {
|
|
max_prefixes: Some(100),
|
|
..default_policy()
|
|
};
|
|
})
|
|
.await?;
|
|
let (base, _http) = server.serve_http().await?;
|
|
let client = reqwest::Client::new();
|
|
let get = |path: &str| {
|
|
let request = client.get(format!("{base}{path}"));
|
|
async move { Ok::<_, eyre::Report>(request.send().await?.error_for_status()?) }
|
|
};
|
|
|
|
let refused: Value = get("/api/routes/rejected").await?.json().await?;
|
|
assert_eq!(refused, serde_json::json!([]), "nothing refused yet");
|
|
|
|
let mut peer = server.established(3333, Ipv4Addr::new(10, 0, 0, 2)).await?;
|
|
peer.announce(announcement(&["193.0.0.0/21", "10.0.0.0/8"], &[3333]))
|
|
.await?;
|
|
peer.announce(announcement(
|
|
&["194.0.1.0/24", "194.0.2.0/24"],
|
|
&[3333, 64512],
|
|
))
|
|
.await?;
|
|
peer.announce(announcement(&["193.0.9.0/25"], &[3333, 2121]))
|
|
.await?;
|
|
wait_until("four routes are refused", || {
|
|
server
|
|
.registry
|
|
.list()
|
|
.first()
|
|
.map(|s| s.counters.rejected())
|
|
== Some(4)
|
|
})
|
|
.await?;
|
|
|
|
let refused: Value = get("/api/routes/rejected").await?.json().await?;
|
|
let record = &refused[0];
|
|
assert_eq!(record["peer"], "refused-demo");
|
|
assert_eq!(record["peer_addr"], "127.0.0.1");
|
|
assert_eq!(record["total"], 4);
|
|
assert_eq!(
|
|
record["by_reason"],
|
|
serde_json::json!({"bogon AS in path": 2, "bogon prefix": 1, "prefix too specific": 1})
|
|
);
|
|
// The latest first, each with the path it came with.
|
|
let recent: Vec<(String, String, String)> = record["recent"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|r| {
|
|
(
|
|
r["prefix"].as_str().unwrap().to_string(),
|
|
r["reason"].as_str().unwrap().to_string(),
|
|
r["as_path"].to_string(),
|
|
)
|
|
})
|
|
.collect();
|
|
let row = |p: &str, why: &str, path: &str| (p.to_string(), why.to_string(), path.to_string());
|
|
assert_eq!(
|
|
recent,
|
|
[
|
|
row("193.0.9.0/25", "prefix too specific", "[3333,2121]"),
|
|
row("194.0.2.0/24", "bogon AS in path", "[3333,64512]"),
|
|
row("194.0.1.0/24", "bogon AS in path", "[3333,64512]"),
|
|
row("10.0.0.0/8", "bogon prefix", "[3333]"),
|
|
]
|
|
);
|
|
// And with all its attributes, as for a route that was taken, to see
|
|
// what came along with it.
|
|
let latest = &record["recent"][0];
|
|
assert_eq!(latest["summary"]["next_hop"], "192.0.2.1");
|
|
assert_eq!(latest["summary"]["origin"], "IGP");
|
|
assert_eq!(
|
|
latest["summary"]["as_path"],
|
|
serde_json::json!([3333, 2121])
|
|
);
|
|
assert_eq!(
|
|
latest["attributes"].as_array().map(Vec::len),
|
|
Some(3),
|
|
"ORIGIN, AS_PATH and NEXT_HOP: {latest}"
|
|
);
|
|
// Asked for by peer, by name or by address.
|
|
for peer in ["refused-demo", "127.0.0.1"] {
|
|
let one: Value = get(&format!("/api/routes/rejected?peer={peer}"))
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(one.as_array().unwrap().len(), 1, "{peer}");
|
|
}
|
|
let none: Value = get("/api/routes/rejected?peer=someone-else")
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(none, serde_json::json!([]));
|
|
|
|
// The peer's line in the list of peers has the numbers too.
|
|
let peers: Value = get("/api/peers").await?.json().await?;
|
|
assert_eq!(peers[0]["routes"], 1);
|
|
assert_eq!(peers[0]["rejected_routes"], 4);
|
|
|
|
// What an alert would be made from.
|
|
let metrics = get("/metricz").await?.text().await?;
|
|
for line in [
|
|
r#"bgp_routes_rejected_total{peer="refused-demo",reason="bogon AS in path"} 2"#,
|
|
r#"bgp_routes_rejected_total{peer="refused-demo",reason="bogon prefix"} 1"#,
|
|
r#"bgp_peer_routes{peer="refused-demo"} 1"#,
|
|
r#"bgp_peer_max_prefixes{peer="refused-demo"} 100"#,
|
|
] {
|
|
assert!(metrics.contains(line), "{line} is not in:\n{metrics}");
|
|
}
|
|
|
|
// The record outlives the session; the count of routes does not.
|
|
drop(peer);
|
|
server.wait_for_sessions(0).await?;
|
|
let refused: Value = get("/api/routes/rejected").await?.json().await?;
|
|
assert_eq!(refused[0]["total"], 4);
|
|
let metrics = get("/metricz").await?.text().await?;
|
|
assert!(!metrics.contains(r#"bgp_peer_routes{peer="refused-demo"}"#));
|
|
Ok(())
|
|
}
|