What other networks were told of rested on one thing: the Only to Customer mark that routes from providers, peers and route servers get as they come in. A route without it, from a session without a role or by a mistake anywhere along the way, went to every peer, from every router of ours, and nothing of ours stood in the way. export_limits are a second check, independent of the roles, the marks and the export policy. For peers above or beside us (PROVIDER, PEER, ROUTE_SERVER, and any in another AS without a role): - prefixes: only routes for these go out, learned or our own. With our and our customers' space listed, a leak of anyone else's routes goes nowhere above us. - max_prefixes: if more routes were to go out than this, none of them does. The session ends with a Cease / Administrative Shutdown whose Shutdown Communication says why (RFC 9003), and the peer is held down for its import.hold_down_seconds, so that it is told nothing rather than too much. The whole table is counted before any of it is sent. They are set at the top and hold for every such peer that has none of its own; unlike `export`, a peer's own export policy does not drop them. A peer's own export_limits take their place, and an empty one lifts them. Customers, route server clients and our own routers are not limited. A configuration in which learned routes are passed on and a peer in another AS has no role is now refused, as is accept_unconfigured_peers with learned routes: without a role, a peer's routes go to everyone and it is told of everything. This used to be a warning. At startup, a warning for each peer the limits apply to that has none, and for each originated route the limits leave out. A metric, bgp_export_limit_exceeded_total, counts the sessions ended for it. The interop lab has a phase in which a customer leaks: a route outside its space never reaches BIRD, and when more were to go than the limit, BIRD's session ends and its log has our reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
11 KiB
Rust
315 lines
11 KiB
Rust
//! Finding out what the server has and why: looking routes up, and seeing
|
|
//! what was refused.
|
|
|
|
use std::net::Ipv4Addr;
|
|
use std::time::Duration;
|
|
|
|
use bgp_integration_tests::default_policy;
|
|
use bgp_integration_tests::test_peer::TestPeer;
|
|
use bgp_integration_tests::test_server::TestServer;
|
|
use bgp_packet::message::{
|
|
AsPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute,
|
|
UpdateMessage,
|
|
};
|
|
use bgp_primitives::ip_prefix::IpPrefix;
|
|
use bgp_server::config::PeerConfig;
|
|
use bgp_server::family::FamilySet;
|
|
use bgp_server::policy::ImportPolicy;
|
|
use eyre::Result;
|
|
use serde_json::Value;
|
|
use tokio::net::TcpListener;
|
|
|
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
fn announcement(prefixes: &[&str], as_path: &[u32]) -> Message {
|
|
Message::Update(UpdateMessage {
|
|
withdrawn_routes: vec![],
|
|
path_attributes: vec![
|
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
|
AsPathAttribute::from_asns(as_path.to_vec()),
|
|
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
|
|
],
|
|
announced_routes: prefixes
|
|
.iter()
|
|
.map(|p| IpPrefix::try_from(*p).unwrap().into())
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
/// 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 = TestPeer::connect(server.addr).await?;
|
|
one.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
|
|
let mut two = TestPeer::connect(server.addr).await?;
|
|
two.handshake(65002, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
|
one.send(&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.send(&announcement(&["10.1.0.0/16"], &[65002, 65003]))
|
|
.await?;
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while server.rib.len() != 5 {
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.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 listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
|
let endpoint = listener.local_addr()?;
|
|
let server = TestServer::start_with(1299, |c| {
|
|
c.accept_unconfigured_peers = false;
|
|
c.peers = vec![PeerConfig {
|
|
graceful_restart: Default::default(),
|
|
groups: Vec::new(),
|
|
export_limits: None,
|
|
reflector_client: false,
|
|
name: "refused-demo".into(),
|
|
endpoint: endpoint.into(),
|
|
remote_asn: 3333,
|
|
hold_time_secs: 90,
|
|
local_address: None,
|
|
families: FamilySet::supported(),
|
|
add_path: None,
|
|
import: ImportPolicy {
|
|
max_prefixes: Some(100),
|
|
..default_policy()
|
|
},
|
|
role: None,
|
|
require_role: false,
|
|
export: Default::default(),
|
|
}];
|
|
})
|
|
.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 = TestPeer::connect(server.addr).await?;
|
|
peer.handshake(3333, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
|
peer.send(&announcement(&["193.0.0.0/21", "10.0.0.0/8"], &[3333]))
|
|
.await?;
|
|
peer.send(&announcement(
|
|
&["194.0.1.0/24", "194.0.2.0/24"],
|
|
&[3333, 64512],
|
|
))
|
|
.await?;
|
|
peer.send(&announcement(&["193.0.9.0/25"], &[3333, 2121]))
|
|
.await?;
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while server
|
|
.registry
|
|
.list()
|
|
.first()
|
|
.map(|s| s.counters.rejected())
|
|
!= Some(4)
|
|
{
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.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);
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while !server.registry.list().is_empty() {
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.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(())
|
|
}
|