A peer that restarts keeps its routes with us, and we keep ours with our peers while we restart, so that a restart withdraws nothing across the network and the machine goes on forwarding. On by default for every peer; `graceful_restart` sets the restart time we give, caps how long a peer's routes are kept, or turns it off. When a peer restarts: its routes are marked stale in the routing table, and chosen as before, for at most its restart time. When it is back, what it sends again is fresh, and the stale routes of a family go at its End-of-RIB marker, or after the cap; at once for a family whose forwarding it did not keep. Only a connection that failed counts as a restart, and, with RFC 8538 agreed, an expired hold timer or a NOTIFICATION other than a Hard Reset. The stale mark is a bit of the route's word that selection never looks at. When we restart: SIGUSR1 leaves the kernel's routes where they are, closes the sessions without a word where graceful restart is agreed, and exits with status 75, on which the systemd unit starts us again. The next start finds the routes in the kernel, says so in its OPENs (R, and F where forwarding was kept), and has them go once every peer has sent its End-of-RIB. Our own End-of-RIB markers for learned routes wait for that too. SIGTERM still stops for good, now with a Hard Reset where RFC 8538 is agreed, as are errors of ours. The status page and API show what was agreed with each peer, the routes kept while a peer restarts, and which routes are stale. Tested against FRR and BIRD in a new phase of the interoperability lab, and with routes kept in a real kernel over a restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
11 KiB
Rust
312 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(),
|
|
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(())
|
|
}
|