A route learned from one router of our AS went to no other (RFC 4271 §9.2.1), so every router needed a session with every other. A router at the end of a single link, with one session to its neighbour inside the AS, heard only of what that neighbour learned itself, and nothing of what the neighbour had from the routers behind it. A peer in our own AS can now be made a route reflector client (`route_reflector_client`, RFC 4456). What a client sends goes to all our routers; what the others send goes to the clients. A reflected route gets ORIGINATOR_ID (where it started, if it does not say) and our cluster ID at the front of CLUSTER_LIST; it is not sent back to the router it started at, nor round to a cluster it has been through, and one that comes back anyway is refused as "route reflected back to us". The cluster ID is the router ID unless `cluster_id` says otherwise. The two attributes are now decoded as such rather than kept as bytes, and are never sent to other networks. Unlike a standard reflector, this one is the next hop of what it reflects, as it is of everything it announces: there is no resolving a next hop that is not a neighbour, so a client reaches what lies behind another router through the reflector. Only peers in our own AS can be clients; the configuration refuses the setting for any other. Nothing changes in what other networks are told: Only to Customer marks go along with reflected routes, and every receiving session keeps its own max_prefixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
356 lines
13 KiB
Rust
356 lines
13 KiB
Rust
//! AS path verification with ASPAs, end to end: a server that gets ASPAs
|
|
//! from a cache, a peer that announces routes, and the ASPAs changing
|
|
//! under both.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::net::Ipv4Addr;
|
|
use std::time::Duration;
|
|
|
|
use bgp_integration_tests::default_policy;
|
|
use bgp_integration_tests::test_cache::TestCache;
|
|
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_rpki::{CacheState, RpkiConfig};
|
|
use bgp_server::config::PeerConfig;
|
|
use bgp_server::family::FamilySet;
|
|
use bgp_server::policy::{ImportPolicy, PeerRole};
|
|
use eyre::Result;
|
|
use serde_json::Value;
|
|
use tokio::net::TcpListener;
|
|
|
|
const LOCAL_ASN: u32 = 1299;
|
|
const PEER_ASN: u32 = 3333;
|
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
fn prefix(s: &str) -> IpPrefix {
|
|
IpPrefix::try_from(s).unwrap()
|
|
}
|
|
|
|
/// What a test holds on to: the server, and the listener its dialling
|
|
/// goes to.
|
|
struct Setup {
|
|
server: TestServer,
|
|
_listener: TcpListener,
|
|
}
|
|
|
|
/// A server that validates against `cache`, with one configured peer at
|
|
/// the address the test connects from, which is `role` to it. Returns once
|
|
/// the server has the cache's data.
|
|
async fn server_with(
|
|
cache: &TestCache,
|
|
role: Option<PeerRole>,
|
|
import: ImportPolicy,
|
|
) -> Result<Setup> {
|
|
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
|
let endpoint = listener.local_addr()?;
|
|
let cache = cache.address();
|
|
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
|
c.accept_unconfigured_peers = false;
|
|
c.rpki = Some(RpkiConfig {
|
|
caches: vec![cache],
|
|
retry: Some(Duration::from_millis(100)),
|
|
..RpkiConfig::default()
|
|
});
|
|
c.peers = vec![PeerConfig {
|
|
graceful_restart: Default::default(),
|
|
groups: Vec::new(),
|
|
reflector_client: false,
|
|
name: "r1".into(),
|
|
endpoint: endpoint.into(),
|
|
remote_asn: PEER_ASN,
|
|
hold_time_secs: 90,
|
|
local_address: None,
|
|
families: FamilySet::supported(),
|
|
add_path: None,
|
|
import,
|
|
role,
|
|
require_role: false,
|
|
export: Default::default(),
|
|
}];
|
|
})
|
|
.await?;
|
|
let rpki = server.rpki.clone().expect("validation is configured");
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while rpki.status().state != CacheState::Synchronised {
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.await?;
|
|
Ok(Setup {
|
|
server,
|
|
_listener: listener,
|
|
})
|
|
}
|
|
|
|
async fn established(server: &TestServer) -> Result<TestPeer> {
|
|
let mut peer = TestPeer::connect(server.addr).await?;
|
|
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90)
|
|
.await?;
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while server.registry.list().is_empty() {
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.await?;
|
|
Ok(peer)
|
|
}
|
|
|
|
/// Announces `prefixes` with the AS path `path`.
|
|
async fn announce(peer: &mut TestPeer, prefixes: &[&str], path: &[u32]) -> Result<()> {
|
|
peer.send(&Message::Update(UpdateMessage {
|
|
withdrawn_routes: vec![],
|
|
path_attributes: vec![
|
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
|
AsPathAttribute::from_asns(path.to_vec()),
|
|
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
|
|
],
|
|
announced_routes: prefixes.iter().map(|p| prefix(p).into()).collect(),
|
|
}))
|
|
.await
|
|
}
|
|
|
|
/// Waits until the table holds exactly `expected` and the peer has `held`
|
|
/// routes held aside.
|
|
async fn table_becomes(server: &TestServer, expected: &[&str], held: u64) -> Result<()> {
|
|
let expected: BTreeSet<String> = expected.iter().map(|p| p.to_string()).collect();
|
|
let table = || -> BTreeSet<String> {
|
|
server
|
|
.received_routes()
|
|
.iter()
|
|
.map(|route| route.prefix.to_string())
|
|
.collect()
|
|
};
|
|
let held_now = || {
|
|
server
|
|
.registry
|
|
.list()
|
|
.first()
|
|
.map_or(0, |session| session.counters.held())
|
|
};
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while table() != expected || held_now() != held {
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| {
|
|
eyre::eyre!(
|
|
"the table has {:?} with {} held; expected {expected:?} with {held} held",
|
|
table(),
|
|
held_now()
|
|
)
|
|
})
|
|
}
|
|
|
|
/// AS 15169 buys from 3356 and says so; 3356 and the peer, 3333, have no
|
|
/// providers.
|
|
fn aspas() -> Vec<(u32, Vec<u32>)> {
|
|
vec![(15169, vec![3356]), (3356, vec![0]), (PEER_ASN, vec![0])]
|
|
}
|
|
|
|
/// A peer that hands us a route it got from one of its own peers is
|
|
/// leaking it. The route is refused; routes whose paths are attested, or
|
|
/// that nothing is known about, are taken.
|
|
#[tokio::test]
|
|
async fn test_a_leaked_route_is_refused_and_the_rest_are_taken() -> Result<()> {
|
|
let cache = TestCache::start_with(vec![], aspas()).await;
|
|
let Setup { server, .. } = server_with(&cache, Some(PeerRole::Peer), default_policy()).await?;
|
|
let (base, _http) = server.serve_http().await?;
|
|
let mut peer = established(&server).await?;
|
|
|
|
// The peer's own route; one of a customer of its that has no ASPA;
|
|
// and 15169's route by way of 3356, which is not for 3333 to pass on
|
|
// to a peer: 3356 is not its customer.
|
|
announce(&mut peer, &["193.0.0.0/21"], &[PEER_ASN]).await?;
|
|
announce(&mut peer, &["194.0.0.0/24"], &[PEER_ASN, 2121]).await?;
|
|
announce(&mut peer, &["8.8.8.0/24"], &[PEER_ASN, 3356, 15169]).await?;
|
|
table_becomes(&server, &["193.0.0.0/21", "194.0.0.0/24"], 1).await?;
|
|
|
|
let client = reqwest::Client::new();
|
|
let get = |path: &str| {
|
|
let request = client.get(format!("{base}{path}"));
|
|
async move { Ok::<Value, eyre::Report>(request.send().await?.error_for_status()?.json().await?) }
|
|
};
|
|
let routes = get("/api/routes/received").await?["routes"].take();
|
|
let validity: Vec<(&str, &str)> = routes
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|r| (r["prefix"].as_str().unwrap(), r["aspa"].as_str().unwrap()))
|
|
.collect();
|
|
assert_eq!(
|
|
validity,
|
|
[("193.0.0.0/21", "valid"), ("194.0.0.0/24", "unknown")]
|
|
);
|
|
let refused = get("/api/routes/rejected").await?;
|
|
assert_eq!(
|
|
refused[0]["by_reason"],
|
|
serde_json::json!({"ASPA invalid": 1})
|
|
);
|
|
let peers = get("/api/peers").await?;
|
|
assert_eq!(peers[0]["role"], "peer");
|
|
assert_eq!(peers[0]["held_routes"], 1);
|
|
let rpki = get("/api/rpki").await?;
|
|
assert_eq!(rpki["aspas"], 3);
|
|
assert_eq!(rpki["protocol_version"], 2);
|
|
Ok(())
|
|
}
|
|
|
|
/// What a path may look like depends on who it comes from. The same route
|
|
/// that a peer may not pass on is in order from a provider, which may hand
|
|
/// us anything it has. Where it is not said what the peer is, paths are
|
|
/// not verified.
|
|
#[tokio::test]
|
|
async fn test_a_route_that_has_crossed_a_peering_is_taken_from_a_provider_only() -> Result<()> {
|
|
for (role, taken) in [
|
|
(Some(PeerRole::Provider), true),
|
|
(Some(PeerRole::Customer), false),
|
|
(Some(PeerRole::Peer), false),
|
|
(Some(PeerRole::RouteServerClient), false),
|
|
(None, true),
|
|
] {
|
|
let cache = TestCache::start_with(vec![], aspas()).await;
|
|
let Setup { server, .. } = server_with(&cache, role, default_policy()).await?;
|
|
let mut peer = established(&server).await?;
|
|
announce(&mut peer, &["8.8.8.0/24"], &[PEER_ASN, 3356, 15169]).await?;
|
|
let expected = if taken {
|
|
table_becomes(&server, &["8.8.8.0/24"], 0).await
|
|
} else {
|
|
table_becomes(&server, &[], 1).await
|
|
};
|
|
expected.map_err(|e| eyre::eyre!("from a peer that is {role:?}: {e}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// An Internet exchange's route server does not put its AS on the path, so
|
|
/// the path need not begin with the route server's AS. A leak is refused
|
|
/// across it all the same.
|
|
#[tokio::test]
|
|
async fn test_paths_from_a_route_server_do_not_begin_with_its_as() -> Result<()> {
|
|
let cache = TestCache::start_with(vec![], aspas()).await;
|
|
let import = ImportPolicy {
|
|
accept_any_first_as: true,
|
|
..default_policy()
|
|
};
|
|
let Setup { server, .. } = server_with(&cache, Some(PeerRole::RouteServer), import).await?;
|
|
let mut peer = established(&server).await?;
|
|
|
|
// 3356's customer route, as 3356 gave it to the route server.
|
|
announce(&mut peer, &["8.8.8.0/24"], &[3356, 15169]).await?;
|
|
// The same route leaked to the route server by another member.
|
|
announce(&mut peer, &["8.8.4.0/24"], &[2121, 3356, 15169]).await?;
|
|
table_becomes(&server, &["8.8.8.0/24"], 1).await
|
|
}
|
|
|
|
/// Validity changes with the ASPAs, and the routes follow: one that was
|
|
/// taken goes when its path becomes invalid, and one that was refused is
|
|
/// taken when it no longer is. The peer sends nothing more meanwhile.
|
|
#[tokio::test]
|
|
async fn test_routes_follow_the_aspas() -> Result<()> {
|
|
let cache = TestCache::start_with(vec![], aspas()).await;
|
|
let Setup { server, .. } = server_with(&cache, Some(PeerRole::Peer), default_policy()).await?;
|
|
let mut peer = established(&server).await?;
|
|
|
|
announce(&mut peer, &["194.0.0.0/24"], &[PEER_ASN, 2121]).await?;
|
|
announce(&mut peer, &["8.8.8.0/24"], &[PEER_ASN, 3356, 15169]).await?;
|
|
table_becomes(&server, &["194.0.0.0/24"], 1).await?;
|
|
|
|
// 2121 says that its only provider is 1101: its route by way of 3333
|
|
// is now invalid, and goes.
|
|
let mut changed = aspas();
|
|
changed.push((2121, vec![1101]));
|
|
cache.set_aspas(changed);
|
|
table_becomes(&server, &[], 2).await?;
|
|
let refused = &server.refusals.peers()[0].1;
|
|
assert_eq!(refused.total(), 2);
|
|
assert_eq!(refused.recent()[0].prefix, prefix("194.0.0.0/24"));
|
|
assert_eq!(&refused.recent()[0].as_path[..], [PEER_ASN, 2121]);
|
|
|
|
// 3356 becomes a customer of 3333, and 2121 too: both routes come
|
|
// back, with the paths they were announced with.
|
|
cache.set_aspas(vec![
|
|
(15169, vec![3356]),
|
|
(3356, vec![PEER_ASN]),
|
|
(PEER_ASN, vec![0]),
|
|
(2121, vec![1101, PEER_ASN]),
|
|
]);
|
|
table_becomes(&server, &["194.0.0.0/24", "8.8.8.0/24"], 0).await?;
|
|
let paths: BTreeSet<Vec<u32>> = server
|
|
.received_routes()
|
|
.iter()
|
|
.flat_map(|route| &route.attributes)
|
|
.filter_map(|attribute| match attribute {
|
|
PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
paths,
|
|
[vec![PEER_ASN, 2121], vec![PEER_ASN, 3356, 15169]].into()
|
|
);
|
|
|
|
// The validator loses its ASPAs altogether: nothing is known about
|
|
// any path, and nothing is refused.
|
|
cache.set_aspas(vec![
|
|
(15169, vec![3356]),
|
|
(3356, vec![0]),
|
|
(PEER_ASN, vec![0]),
|
|
]);
|
|
table_becomes(&server, &["194.0.0.0/24"], 1).await?;
|
|
cache.set_aspas(vec![]);
|
|
table_becomes(&server, &["194.0.0.0/24", "8.8.8.0/24"], 0).await
|
|
}
|
|
|
|
/// A peer can be exempt: its routes with invalid paths are taken, and
|
|
/// shown for what they are.
|
|
#[tokio::test]
|
|
async fn test_a_peer_can_be_exempt_from_path_verification() -> Result<()> {
|
|
let cache = TestCache::start_with(vec![], aspas()).await;
|
|
let import = ImportPolicy {
|
|
accept_aspa_invalid: true,
|
|
..default_policy()
|
|
};
|
|
let Setup { server, .. } = server_with(&cache, Some(PeerRole::Peer), import).await?;
|
|
let (base, _http) = server.serve_http().await?;
|
|
let mut peer = established(&server).await?;
|
|
|
|
announce(&mut peer, &["8.8.8.0/24"], &[PEER_ASN, 3356, 15169]).await?;
|
|
table_becomes(&server, &["8.8.8.0/24"], 0).await?;
|
|
let routes: Value = reqwest::get(format!("{base}/api/routes/received"))
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(routes["routes"][0]["aspa"], "invalid");
|
|
assert!(server.refusals.peers().is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
/// A route can fail both checks. It is taken only when it passes both.
|
|
#[tokio::test]
|
|
async fn test_a_route_is_held_until_origin_and_path_are_both_in_order() -> Result<()> {
|
|
let google = |asn| bgp_rpki::Vrp {
|
|
prefix: prefix("8.8.8.0/24"),
|
|
max_length: 24,
|
|
asn,
|
|
};
|
|
let cache = TestCache::start_with(vec![google(13335)], aspas()).await;
|
|
let Setup { server, .. } = server_with(&cache, Some(PeerRole::Peer), default_policy()).await?;
|
|
let mut peer = established(&server).await?;
|
|
announce(&mut peer, &["8.8.8.0/24"], &[PEER_ASN, 3356, 15169]).await?;
|
|
table_becomes(&server, &[], 1).await?;
|
|
|
|
// The origin is put right; the path is still a leak.
|
|
cache.set(vec![google(15169)]);
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
table_becomes(&server, &[], 1).await?;
|
|
|
|
cache.set_aspas(vec![(15169, vec![3356]), (3356, vec![PEER_ASN])]);
|
|
table_becomes(&server, &["8.8.8.0/24"], 0).await
|
|
}
|