Files
ferrix/crates/integration-tests/tests/debugging.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 9ca86ede2f Harden import, the kernel task and the RTR client after review
Three independent reviews (hijack resistance, reliability, code health)
of the whole tree. This is what they found that could let a bad route in
or take the daemon down.

Hijacks:
- A route had two paths on a two-octet session, AS_PATH and AS4_PATH,
  read in two places by two rules. A peer could make the origin checked
  on import differ from the one checked at revalidation, and get an
  RPKI-invalid route into the table. AS4_PATH is now merged into AS_PATH
  once, on receipt (RFC 6793 §4.2.3), and nothing else knows of it.
- The next hop is judged on import: it must be usable, agreed on
  (RFC 8950), and by default the peer itself.
- Our own originated prefixes are not taken from other networks.
- A route for a network the machine is on is never put in the kernel.
- Paths with an AS_SET are refused (RFC 9774).
- A configured peer gets one session, not one per BGP Identifier.
- More IPv6 special-purpose prefixes are bogons.

Reliability:
- A batch of requests the kernel refused could overflow the netlink
  socket and end the daemon. The socket has room now, a lost answer or a
  silent kernel means asking what is there, and neither is fatal.
- Working the routes out again within the grace period after a restart
  no longer drops the routes being kept.
- That work can no longer be put off for ever by interface changes.
- Peers whose sessions keep failing are slowed down.
- Unused attribute sets are swept when they pile up, not only each
  minute.
- Log lines a peer can cause are said once in a while.
- An RTR answer is bounded in size and time; data cannot expire between
  two refreshes.
- The shard of a key is taken from a stirred hash: neighbouring bits of
  the hash proved alike for prefixes in a row. The test that showed it,
  failing one run in sixteen, is deterministic now.

The lab has a phase, hijacks, in which FRR tries the first three.
route::apply is a struct with methods instead of nested closures, and
the kernel task's loop is shorter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 19:13:40 +02:00

296 lines
10 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 {
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,
}];
})
.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]"),
]
);
// 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(())
}