A peer's endpoint may be a link-local IPv6 address with the interface it is on. Such a peer is the address and the interface together, wherever peers are told apart: admission, collisions, hold-downs, the refusal log, the status page. The interface is looked up by name each time it is needed, so one that appears later or is made anew is found. Our routes go out with our link-local address in both places of MP_REACH_NLRI's next hop, as FRR sends its own. A peer's link-local next hop is taken alone, twice, or after "::" (BIRD), and resolved in the kernel on the interface of the session rather than by looking fe80::/64 up, which every interface has. The lab has a new phase, linklocal: sessions with FRR (by interface, with radvd beside the daemon) and BIRD over a LAN with link-local addresses only, both families, pings across, the interface down and up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
295 lines
10 KiB
Rust
295 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::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),
|
|
..ImportPolicy::default()
|
|
},
|
|
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(())
|
|
}
|