Verify AS paths with ASPAs, over RTR version 2
Origin validation says who may originate a prefix and nothing about the
rest of the path: it does not catch a route leak, nor a hijack that keeps
the right origin at the end of a made-up path. ASPAs, in which a network
lists its providers, do.
bgp-rpki:
- aspa: the ASPAs and the upstream and downstream verification procedures
of draft-ietf-sidrops-aspa-verification-24, tested against the draft's
wording as written.
- rtr: protocol version 2 (draft-ietf-sidrops-8210bis-23) with the ASPA
PDU and the new error codes.
- client: asks for version 2 and falls back to whatever older version the
cache speaks; keeps ASPAs with the VRPs, applied whole or not at all;
publishes which customers' ASPAs changed; tells the cache why it is
being left (Error Report); does not ask one version's serial of another.
Server:
- A peer has a role (provider, customer, peer, route server, route server
client), which decides how its paths are verified. Without one they are
not.
- A route whose path is invalid is refused ("ASPA invalid") and held, like
one with an invalid origin; accept_aspa_invalid exempts a peer.
- When ASPAs change, routes in the table whose path has a changed customer
on it are verified again, and held routes are released when they pass
both checks.
- The revalidator finds a route's peer by index instead of searching a
list for every route.
The status page and API show each peer's role, each route's path validity,
the number of ASPAs and the RTR version in use; bgp_rpki_aspas is on
/metricz.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
bdaf226a16
commit
8086aa994f
35 files changed
+2484
-359
No files matched your search
@@ -1,11 +1,11 @@
|
||||
//! An RPKI cache for tests: an RTR server that hands out whatever VRPs the
|
||||
//! test says there are.
|
||||
//! An RPKI cache for tests: an RTR server that hands out whatever VRPs and
|
||||
//! ASPAs the test says there are.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bgp_rpki::Vrp;
|
||||
use bgp_rpki::rtr::{Pdu, Timers};
|
||||
use bgp_rpki::rtr::{Pdu, Timers, VERSION_2};
|
||||
use bytes::BytesMut;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -14,11 +14,19 @@ use tokio::task::JoinHandle;
|
||||
|
||||
const SESSION: u16 = 0x1234;
|
||||
|
||||
/// What the cache has to give.
|
||||
#[derive(Clone, Default)]
|
||||
struct Data {
|
||||
vrps: Vec<Vrp>,
|
||||
/// Customer ASes and their providers.
|
||||
aspas: Vec<(u32, Vec<u32>)>,
|
||||
serial: u32,
|
||||
}
|
||||
|
||||
/// The cache. Stops, closing its connections, when dropped.
|
||||
pub struct TestCache {
|
||||
address: SocketAddr,
|
||||
/// The VRPs there are, and their serial.
|
||||
data: Arc<Mutex<(Vec<Vrp>, u32)>>,
|
||||
data: Arc<Mutex<Data>>,
|
||||
/// The serial, for telling connected routers there is something new.
|
||||
serial: watch::Sender<u32>,
|
||||
task: JoinHandle<()>,
|
||||
@@ -26,9 +34,18 @@ pub struct TestCache {
|
||||
|
||||
impl TestCache {
|
||||
pub async fn start(vrps: Vec<Vrp>) -> Self {
|
||||
Self::start_with(vrps, vec![]).await
|
||||
}
|
||||
|
||||
/// A cache with ASPAs too: customer ASes and their providers.
|
||||
pub async fn start_with(vrps: Vec<Vrp>, aspas: Vec<(u32, Vec<u32>)>) -> Self {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let data = Arc::new(Mutex::new((vrps, 1)));
|
||||
let data = Arc::new(Mutex::new(Data {
|
||||
vrps,
|
||||
aspas,
|
||||
serial: 1,
|
||||
}));
|
||||
let (serial, _) = watch::channel(1);
|
||||
let task = tokio::spawn({
|
||||
let (data, serial) = (Arc::clone(&data), serial.clone());
|
||||
@@ -57,11 +74,20 @@ impl TestCache {
|
||||
|
||||
/// Replaces the VRPs and tells connected routers.
|
||||
pub fn set(&self, vrps: Vec<Vrp>) {
|
||||
self.change(|data| data.vrps = vrps);
|
||||
}
|
||||
|
||||
/// Replaces the ASPAs and tells connected routers.
|
||||
pub fn set_aspas(&self, aspas: Vec<(u32, Vec<u32>)>) {
|
||||
self.change(|data| data.aspas = aspas);
|
||||
}
|
||||
|
||||
fn change(&self, change: impl FnOnce(&mut Data)) {
|
||||
let serial = {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.0 = vrps;
|
||||
data.1 += 1;
|
||||
data.1
|
||||
change(&mut data);
|
||||
data.serial += 1;
|
||||
data.serial
|
||||
};
|
||||
self.serial.send_replace(serial);
|
||||
}
|
||||
@@ -75,12 +101,10 @@ impl Drop for TestCache {
|
||||
|
||||
/// One router's connection. Asked for changes, this cache always says to
|
||||
/// ask for everything: the router works out what changed.
|
||||
async fn serve(
|
||||
mut stream: TcpStream,
|
||||
data: Arc<Mutex<(Vec<Vrp>, u32)>>,
|
||||
mut serial: watch::Receiver<u32>,
|
||||
) {
|
||||
async fn serve(mut stream: TcpStream, data: Arc<Mutex<Data>>, mut serial: watch::Receiver<u32>) {
|
||||
let mut buf = BytesMut::new();
|
||||
// The version the router speaks, once it has said something.
|
||||
let mut spoken = VERSION_2;
|
||||
loop {
|
||||
let mut out = BytesMut::new();
|
||||
tokio::select! {
|
||||
@@ -90,17 +114,25 @@ async fn serve(
|
||||
}
|
||||
while let Ok(Some((version, pdu, used))) = Pdu::decode(&buf) {
|
||||
let _ = buf.split_to(used);
|
||||
spoken = version;
|
||||
match pdu {
|
||||
Pdu::SerialQuery { .. } => Pdu::CacheReset.encode(version, &mut out),
|
||||
Pdu::ResetQuery => {
|
||||
let (vrps, serial) = data.lock().unwrap().clone();
|
||||
let data = data.lock().unwrap().clone();
|
||||
Pdu::CacheResponse { session_id: SESSION }.encode(version, &mut out);
|
||||
for vrp in vrps {
|
||||
for vrp in data.vrps {
|
||||
Pdu::Prefix { announce: true, vrp }.encode(version, &mut out);
|
||||
}
|
||||
// Only version 2 has ASPAs.
|
||||
for (customer, providers) in data.aspas {
|
||||
if version >= VERSION_2 {
|
||||
Pdu::Aspa { announce: true, customer, providers }
|
||||
.encode(version, &mut out);
|
||||
}
|
||||
}
|
||||
Pdu::EndOfData {
|
||||
session_id: SESSION,
|
||||
serial,
|
||||
serial: data.serial,
|
||||
timers: Some(Timers::default()),
|
||||
}
|
||||
.encode(version, &mut out);
|
||||
@@ -114,7 +146,7 @@ async fn serve(
|
||||
return;
|
||||
}
|
||||
let serial = *serial.borrow_and_update();
|
||||
Pdu::SerialNotify { session_id: SESSION, serial }.encode(1, &mut out);
|
||||
Pdu::SerialNotify { session_id: SESSION, serial }.encode(spoken, &mut out);
|
||||
}
|
||||
}
|
||||
if stream.write_all(&out).await.is_err() {
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn server_offering(
|
||||
families: FamilySet::supported(),
|
||||
add_path,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}];
|
||||
c.originate = originate;
|
||||
// Keep the dialer out of the way.
|
||||
|
||||
@@ -26,6 +26,7 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig {
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! 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::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 {
|
||||
name: "r1".into(),
|
||||
endpoint,
|
||||
remote_asn: PEER_ASN,
|
||||
hold_time_secs: 90,
|
||||
local_address: None,
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import,
|
||||
role,
|
||||
}];
|
||||
})
|
||||
.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), ImportPolicy::default()).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?;
|
||||
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, ImportPolicy::default()).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,
|
||||
..ImportPolicy::default()
|
||||
};
|
||||
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), ImportPolicy::default()).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,
|
||||
..ImportPolicy::default()
|
||||
};
|
||||
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[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), ImportPolicy::default()).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
|
||||
}
|
||||
@@ -44,6 +44,7 @@ async fn server_dialing(listener: &TcpListener) -> Result<TestServer> {
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -175,6 +175,7 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> {
|
||||
max_prefixes: Some(100),
|
||||
..ImportPolicy::default()
|
||||
},
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -35,6 +35,7 @@ async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestS
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await
|
||||
@@ -190,6 +191,7 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
@@ -239,6 +241,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
@@ -257,6 +260,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)>
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import,
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -191,6 +191,7 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> {
|
||||
families: only(AfiSafi::IPV6_UNICAST),
|
||||
add_path: None,
|
||||
import: ImportPolicy::permissive(),
|
||||
role: None,
|
||||
}];
|
||||
// Keep the dialer out of the way.
|
||||
c.connect_retry = Duration::from_secs(3600);
|
||||
|
||||
@@ -73,6 +73,7 @@ async fn server_with(
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import,
|
||||
role: None,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
@@ -335,6 +336,6 @@ async fn test_held_routes_are_taken_when_the_validation_data_expires() -> Result
|
||||
|
||||
drop(cache);
|
||||
table_becomes(&server, &["193.0.4.0/24"], 0).await?;
|
||||
assert!(server.rpki.as_ref().unwrap().current().table.is_empty());
|
||||
assert!(server.rpki.as_ref().unwrap().current().vrps.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
@@ -130,6 +130,24 @@ fn scan_as_path(mut segments: &[u8]) -> Option<PathScan> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends the AS numbers of an AS_PATH or AS4_PATH value with four-octet
|
||||
/// AS numbers to `out`, and returns whether any segment is not a sequence.
|
||||
/// Stops at what is not a segment.
|
||||
fn read_as_path(mut segments: &[u8], out: &mut Vec<u32>) -> bool {
|
||||
const AS_SEQUENCE: u8 = 2;
|
||||
let mut has_set = false;
|
||||
while let [kind, count, rest @ ..] = segments {
|
||||
let Some((asns, rest)) = rest.split_at_checked(usize::from(*count) * 4) else {
|
||||
break;
|
||||
};
|
||||
has_set |= *kind != AS_SEQUENCE;
|
||||
let (asns, _) = asns.as_chunks::<4>();
|
||||
out.extend(asns.iter().map(|asn| u32::from_be_bytes(*asn)));
|
||||
segments = rest;
|
||||
}
|
||||
has_set
|
||||
}
|
||||
|
||||
/// One distinct list of path attributes, with the next hop that goes with
|
||||
/// it. Made by an [`AttrStore`]; two routes have the same attributes
|
||||
/// exactly when they point to the same `AttrSet`.
|
||||
@@ -153,6 +171,42 @@ impl AttrSet {
|
||||
&self.wire
|
||||
}
|
||||
|
||||
/// Puts the AS numbers of the route's path in `out`, in place of what
|
||||
/// was there: in the order of the path, the neighbor's first and the
|
||||
/// origin's last, sets and all. Returns whether the path has a set in
|
||||
/// it.
|
||||
///
|
||||
/// This is the path as it really is: where AS4_PATH has the numbers
|
||||
/// that a two-octet session could not carry, they are in their places
|
||||
/// (RFC 6793 §4.2.3). It reads the stored bytes and builds nothing
|
||||
/// else, for those who ask it of every route.
|
||||
pub fn as_path(&self, out: &mut Vec<u32>) -> bool {
|
||||
out.clear();
|
||||
let (mut path, mut wide): (&[u8], &[u8]) = (&[], &[]);
|
||||
let mut wire = &*self.wire;
|
||||
while let Ok((attribute, rest)) = split_attribute(wire) {
|
||||
match attribute.type_code {
|
||||
attr_type::AS_PATH => path = attribute.value,
|
||||
attr_type::AS4_PATH => wide = attribute.value,
|
||||
_ => {}
|
||||
}
|
||||
wire = rest;
|
||||
}
|
||||
let mut has_set = read_as_path(path, out);
|
||||
let narrow = out.len();
|
||||
has_set |= read_as_path(wide, out);
|
||||
let wide = out.len() - narrow;
|
||||
if wide <= narrow {
|
||||
// AS4_PATH is the path's tail; what comes before it is
|
||||
// AS_PATH's.
|
||||
out.drain(narrow - wide..narrow);
|
||||
} else {
|
||||
// An AS4_PATH longer than the path is not used.
|
||||
out.truncate(narrow);
|
||||
}
|
||||
has_set
|
||||
}
|
||||
|
||||
/// The attributes, parsed, and the next hop if it came in
|
||||
/// MP_REACH_NLRI.
|
||||
///
|
||||
@@ -442,6 +496,61 @@ mod tests {
|
||||
assert_eq!(store.intern(&attrs, None).unwrap().key().med, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_is_read_off_a_set_as_it_really_is() {
|
||||
use bgp_packet::message::{AsPathAttribute, AsPathSegment};
|
||||
let store = AttrStore::new();
|
||||
let mut path = Vec::new();
|
||||
|
||||
let set = store
|
||||
.intern(&attributes(vec![3333, 3333, 1299]), None)
|
||||
.unwrap();
|
||||
assert!(!set.as_path(&mut path));
|
||||
assert_eq!(path, [3333, 3333, 1299]);
|
||||
|
||||
// A two-octet path whose tail AS4_PATH has in full.
|
||||
let sequence = |path: Vec<u32>| AsPathSegment {
|
||||
ordered: true,
|
||||
path,
|
||||
};
|
||||
let mut narrow = attributes(vec![3333, 23456, 23456]);
|
||||
narrow.push(PathAttribute::As4Path(AsPathAttribute {
|
||||
segments: vec![sequence(vec![210036, 4_200_000_001])],
|
||||
}));
|
||||
let set = store.intern(&narrow, None).unwrap();
|
||||
assert!(!set.as_path(&mut path));
|
||||
assert_eq!(path, [3333, 210036, 4_200_000_001]);
|
||||
|
||||
// An AS4_PATH longer than the path is not used.
|
||||
let mut too_wide = attributes(vec![3333]);
|
||||
too_wide.push(PathAttribute::As4Path(AsPathAttribute {
|
||||
segments: vec![sequence(vec![1, 2])],
|
||||
}));
|
||||
let set = store.intern(&too_wide, None).unwrap();
|
||||
assert!(!set.as_path(&mut path));
|
||||
assert_eq!(path, [3333]);
|
||||
|
||||
// A set is said to be there.
|
||||
let mut with_set = attributes(vec![]);
|
||||
with_set[1] = PathAttribute::AsPath(AsPathAttribute {
|
||||
segments: vec![
|
||||
sequence(vec![3333]),
|
||||
AsPathSegment {
|
||||
ordered: false,
|
||||
path: vec![64500, 64501],
|
||||
},
|
||||
],
|
||||
});
|
||||
let set = store.intern(&with_set, None).unwrap();
|
||||
assert!(set.as_path(&mut path));
|
||||
assert_eq!(path, [3333, 64500, 64501]);
|
||||
|
||||
// No path at all.
|
||||
let set = store.intern(&attributes(vec![]), None).unwrap();
|
||||
assert!(!set.as_path(&mut path));
|
||||
assert!(path.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributes_on_the_wire_are_interned_without_parsing_them() {
|
||||
let store = AttrStore::new();
|
||||
|
||||
@@ -10,6 +10,7 @@ version.workspace = true
|
||||
[dependencies]
|
||||
bgp-primitives.workspace = true
|
||||
bytes.workspace = true
|
||||
foldhash.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Autonomous System Provider Authorisations, and what they say about a
|
||||
//! route's AS path (draft-ietf-sidrops-aspa-verification).
|
||||
//!
|
||||
//! An ASPA is a signed statement by an AS of who its providers are: the
|
||||
//! networks it buys transit from. With those statements a router can tell
|
||||
//! a path that goes the way traffic is paid to go (up from the origin
|
||||
//! through providers, across at most one peering, and down through
|
||||
//! customers) from one that does not. A path that climbs again after it
|
||||
//! has come down, or crosses a second peering, is a route leak or a forged
|
||||
//! path, and this is the check that finds them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What the ASPAs say about a path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PathValidity {
|
||||
/// Some AS on the path has no ASPA, and nothing on the path is known
|
||||
/// to be wrong. Most paths are in this state while few networks have
|
||||
/// an ASPA, and are used.
|
||||
Unknown,
|
||||
/// Every hop that has to be from a customer to its provider is
|
||||
/// attested to be one.
|
||||
Valid,
|
||||
/// Some AS on the path has said who its providers are, and the path
|
||||
/// went from it to an AS that is not one of them where only a provider
|
||||
/// could come next.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl PathValidity {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Valid => "valid",
|
||||
Self::Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the two procedures verifies a path. That depends on what the
|
||||
/// neighbor it came from is to us.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Algorithm {
|
||||
/// For a route from a customer, a peer, or across a route server: all
|
||||
/// of its path must lead upwards, from each AS to a provider of it
|
||||
/// (§6.1 of the draft).
|
||||
Upstream,
|
||||
/// For a route from a provider: its path may lead up from the origin
|
||||
/// and then down to us, and nothing else (§6.2).
|
||||
Downstream,
|
||||
}
|
||||
|
||||
/// Whether one AS is a provider of another, as far as the ASPAs say.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Hop {
|
||||
/// The customer has no ASPA.
|
||||
NoAttestation,
|
||||
/// The customer's ASPA lists the other AS.
|
||||
Provider,
|
||||
/// The customer has an ASPA, and the other AS is not on it.
|
||||
NotProvider,
|
||||
}
|
||||
|
||||
/// The ASPAs there are: for each customer AS that has one, its providers.
|
||||
///
|
||||
/// Cloning copies the list of customers and shares the lists of providers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AspaTable {
|
||||
providers: HashMap<u32, Arc<[u32]>, foldhash::fast::FixedState>,
|
||||
}
|
||||
|
||||
impl AspaTable {
|
||||
/// How many customer ASes have an ASPA.
|
||||
pub fn len(&self) -> usize {
|
||||
self.providers.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.providers.is_empty()
|
||||
}
|
||||
|
||||
/// Records that `customer`'s providers are `providers`, in place of
|
||||
/// what was recorded for it before, which is returned.
|
||||
pub fn set(
|
||||
&mut self,
|
||||
customer: u32,
|
||||
providers: impl IntoIterator<Item = u32>,
|
||||
) -> Option<Arc<[u32]>> {
|
||||
let mut providers: Vec<u32> = providers.into_iter().collect();
|
||||
providers.sort_unstable();
|
||||
providers.dedup();
|
||||
self.providers.insert(customer, providers.into())
|
||||
}
|
||||
|
||||
/// Forgets `customer`'s ASPA, and returns the providers it listed.
|
||||
pub fn remove(&mut self, customer: u32) -> Option<Arc<[u32]>> {
|
||||
self.providers.remove(&customer)
|
||||
}
|
||||
|
||||
/// Puts back what [`AspaTable::set`] or [`AspaTable::remove`] returned.
|
||||
pub(crate) fn restore(&mut self, customer: u32, providers: Option<Arc<[u32]>>) {
|
||||
match providers {
|
||||
Some(providers) => self.providers.insert(customer, providers),
|
||||
None => self.providers.remove(&customer),
|
||||
};
|
||||
}
|
||||
|
||||
/// The providers `customer` lists, in increasing order, if it has an
|
||||
/// ASPA.
|
||||
pub fn providers(&self, customer: u32) -> Option<&[u32]> {
|
||||
self.providers.get(&customer).map(|providers| &**providers)
|
||||
}
|
||||
|
||||
/// The customers and their providers, in no particular order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u32, &[u32])> {
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|(customer, providers)| (*customer, &**providers))
|
||||
}
|
||||
|
||||
fn hop(&self, customer: u32, provider: u32) -> Hop {
|
||||
match self.providers.get(&customer) {
|
||||
None => Hop::NoAttestation,
|
||||
Some(providers) if providers.binary_search(&provider).is_ok() => Hop::Provider,
|
||||
Some(_) => Hop::NotProvider,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies `path`: the AS numbers of an AS_PATH in the order they are
|
||||
/// in it, the neighbor's first and the origin's last, with whatever
|
||||
/// repeats it has.
|
||||
///
|
||||
/// This is the part of the draft's procedures that the ASPAs decide.
|
||||
/// What comes before it there is the caller's to check: that the path
|
||||
/// has no AS_SET and begins with the neighbor's AS. An empty path is
|
||||
/// invalid.
|
||||
pub fn verify(&self, path: &[u32], algorithm: Algorithm) -> PathValidity {
|
||||
// The draft numbers the path's distinct ASes from the origin,
|
||||
// A(1), to the neighbor, A(N). Here they are `hops[0]` to
|
||||
// `hops[n - 1]`.
|
||||
let mut hops: Vec<u32> = Vec::with_capacity(path.len());
|
||||
for asn in path.iter().rev() {
|
||||
if hops.last() != Some(asn) {
|
||||
hops.push(*asn);
|
||||
}
|
||||
}
|
||||
let n = hops.len();
|
||||
if n == 0 {
|
||||
return PathValidity::Invalid;
|
||||
}
|
||||
|
||||
// How far up from the origin the path can be a chain of customers
|
||||
// and their providers at most (as far as nothing says otherwise),
|
||||
// and at least (as far as it is attested).
|
||||
let (mut max_up, mut min_up) = (n, n);
|
||||
for (at, pair) in hops.windows(2).enumerate() {
|
||||
match self.hop(pair[0], pair[1]) {
|
||||
Hop::Provider => continue,
|
||||
Hop::NoAttestation => min_up = min_up.min(at + 1),
|
||||
Hop::NotProvider => {
|
||||
min_up = min_up.min(at + 1);
|
||||
max_up = at + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if algorithm == Algorithm::Upstream {
|
||||
return match (max_up < n, min_up < n) {
|
||||
(true, _) => PathValidity::Invalid,
|
||||
(false, true) => PathValidity::Unknown,
|
||||
(false, false) => PathValidity::Valid,
|
||||
};
|
||||
}
|
||||
|
||||
// The same from the neighbor's end, looking down: each AS is to be
|
||||
// the customer of the one after it.
|
||||
let (mut max_down, mut min_down) = (n, n);
|
||||
for (at, pair) in hops.windows(2).rev().enumerate() {
|
||||
match self.hop(pair[1], pair[0]) {
|
||||
Hop::Provider => continue,
|
||||
Hop::NoAttestation => min_down = min_down.min(at + 1),
|
||||
Hop::NotProvider => {
|
||||
min_down = min_down.min(at + 1);
|
||||
max_down = at + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The way up and the way down have to meet, or overlap.
|
||||
if max_up + max_down < n {
|
||||
PathValidity::Invalid
|
||||
} else if min_up + min_down < n {
|
||||
PathValidity::Unknown
|
||||
} else {
|
||||
PathValidity::Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(u32, Vec<u32>)> for AspaTable {
|
||||
fn from_iter<T: IntoIterator<Item = (u32, Vec<u32>)>>(aspas: T) -> Self {
|
||||
let mut table = Self::default();
|
||||
for (customer, providers) in aspas {
|
||||
table.set(customer, providers);
|
||||
}
|
||||
table
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::Algorithm::{Downstream, Upstream};
|
||||
use super::PathValidity::{Invalid, Unknown, Valid};
|
||||
use super::*;
|
||||
|
||||
fn table(aspas: &[(u32, &[u32])]) -> AspaTable {
|
||||
aspas
|
||||
.iter()
|
||||
.map(|(customer, providers)| (*customer, providers.to_vec()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A small Internet. 10 and 20 are the large networks, which peer and
|
||||
/// have no providers (an ASPA that lists AS 0). 100 buys from 10, 200
|
||||
/// from 20, 300 from both. 1000 buys from 100; 2000 from 200 and has
|
||||
/// no ASPA.
|
||||
fn internet() -> AspaTable {
|
||||
table(&[
|
||||
(10, &[0]),
|
||||
(20, &[0]),
|
||||
(100, &[10]),
|
||||
(200, &[20]),
|
||||
(300, &[10, 20]),
|
||||
(1000, &[100]),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_only_climbs_is_valid_from_a_customer_or_peer() {
|
||||
let aspas = internet();
|
||||
// 1000 -> 100 -> 10, as 10 hears it from its customer 100.
|
||||
assert_eq!(aspas.verify(&[100, 1000], Upstream), Valid);
|
||||
// As 20 hears it from its peer 10.
|
||||
assert_eq!(aspas.verify(&[10, 100, 1000], Upstream), Valid);
|
||||
// A route the neighbor originates is as far up as it has got.
|
||||
assert_eq!(aspas.verify(&[1000], Upstream), Valid);
|
||||
// Prepending changes nothing.
|
||||
assert_eq!(
|
||||
aspas.verify(&[10, 10, 100, 100, 100, 1000, 1000], Upstream),
|
||||
Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leak_is_invalid_from_a_customer_or_peer() {
|
||||
let aspas = internet();
|
||||
// 300 buys from 10 and 20, and passes 10's routes on to 20: the
|
||||
// classic leak. 20 hears the path 300 10 100 1000 from its
|
||||
// customer; 10 says it has no providers, so 300 is not one.
|
||||
assert_eq!(aspas.verify(&[300, 10, 100, 1000], Upstream), Invalid);
|
||||
// 20 passes a route of its peer 10's on to another peer: 20 is not
|
||||
// a provider of 10.
|
||||
assert_eq!(aspas.verify(&[20, 10, 100, 1000], Upstream), Invalid);
|
||||
// An origin that is not where the path says: 1000 lists only 100.
|
||||
assert_eq!(aspas.verify(&[200, 1000], Upstream), Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hop_nobody_has_attested_leaves_the_path_unknown() {
|
||||
let aspas = internet();
|
||||
// 2000 has no ASPA.
|
||||
assert_eq!(aspas.verify(&[200, 2000], Upstream), Unknown);
|
||||
assert_eq!(aspas.verify(&[20, 200, 2000], Upstream), Unknown);
|
||||
// But what is known to be wrong is invalid whatever else is not
|
||||
// known.
|
||||
assert_eq!(aspas.verify(&[300, 20, 200, 2000], Upstream), Invalid);
|
||||
// With no ASPAs at all, nothing is known.
|
||||
assert_eq!(AspaTable::default().verify(&[1, 2, 3], Upstream), Unknown);
|
||||
assert_eq!(AspaTable::default().verify(&[1, 2, 3], Downstream), Unknown);
|
||||
// Except of the path that is no path.
|
||||
assert_eq!(AspaTable::default().verify(&[], Upstream), Invalid);
|
||||
assert_eq!(AspaTable::default().verify(&[], Downstream), Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_a_provider_a_path_may_climb_and_then_descend() {
|
||||
let aspas = internet();
|
||||
// 1000's route, as 200 hears it from its provider 20: up through
|
||||
// 100 to 10, across to 20.
|
||||
assert_eq!(aspas.verify(&[20, 10, 100, 1000], Downstream), Valid);
|
||||
// As 300 hears it from 10: only up.
|
||||
assert_eq!(aspas.verify(&[10, 100, 1000], Downstream), Valid);
|
||||
// As 1000 would hear 300's route from 100: up to 10, down to 100.
|
||||
assert_eq!(aspas.verify(&[100, 10, 300], Downstream), Valid);
|
||||
// Short paths have no room to be wrong in.
|
||||
assert_eq!(aspas.verify(&[20, 1000], Downstream), Valid);
|
||||
assert_eq!(aspas.verify(&[20], Downstream), Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_a_provider_a_path_that_descends_and_climbs_again_is_invalid() {
|
||||
let aspas = internet();
|
||||
// 300 leaks 10's routes to 20, and 20 passes them down to 200,
|
||||
// which hears the path 20 300 10 100 1000. The way up from 1000
|
||||
// ends at 10, three ASes in: 300 is not a provider of 10. The way
|
||||
// down to 200 begins no earlier than 20, one AS: 20 has no
|
||||
// providers, so it did not get the route from one. Three and one
|
||||
// do not make five.
|
||||
assert_eq!(aspas.verify(&[20, 300, 10, 100, 1000], Downstream), Invalid);
|
||||
// Two peerings in a row: 30 is another large network, which hands
|
||||
// its customer a route it got from its peer 20, which got it from
|
||||
// its peer 10.
|
||||
let mut aspas = internet();
|
||||
aspas.set(30, [0]);
|
||||
assert_eq!(aspas.verify(&[30, 20, 10, 100, 1000], Downstream), Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_a_provider_what_is_not_attested_leaves_the_path_unknown() {
|
||||
let aspas = internet();
|
||||
// 2000 (no ASPA) up to 200, to 20, across to 10, down to 100: the
|
||||
// first hop is not attested either way.
|
||||
assert_eq!(aspas.verify(&[10, 20, 200, 2000], Downstream), Unknown);
|
||||
// With 2000's ASPA it is valid.
|
||||
let mut with = aspas.clone();
|
||||
with.set(2000, [200]);
|
||||
assert_eq!(with.verify(&[10, 20, 200, 2000], Downstream), Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_aspa_is_replaced_and_removed_whole() {
|
||||
let mut aspas = internet();
|
||||
assert_eq!(aspas.providers(300), Some(&[10, 20][..]));
|
||||
assert_eq!(
|
||||
aspas.set(300, [20, 10, 20, 5]).as_deref(),
|
||||
Some(&[10, 20][..])
|
||||
);
|
||||
assert_eq!(aspas.providers(300), Some(&[5, 10, 20][..]));
|
||||
assert_eq!(aspas.remove(300).as_deref(), Some(&[5, 10, 20][..]));
|
||||
assert_eq!(aspas.providers(300), None);
|
||||
assert_eq!(aspas.remove(300), None);
|
||||
assert_eq!(aspas.len(), 5);
|
||||
}
|
||||
|
||||
/// The procedures as the draft words them, with its numbering: A(1) is
|
||||
/// the origin, A(N) the neighbor.
|
||||
fn by_the_draft(aspas: &AspaTable, path: &[u32], algorithm: Algorithm) -> PathValidity {
|
||||
let mut a: Vec<u32> = path.iter().rev().copied().collect();
|
||||
a.dedup();
|
||||
let n = a.len();
|
||||
if n == 0 {
|
||||
return Invalid;
|
||||
}
|
||||
// One-based, as written.
|
||||
let hop = |i: usize, j: usize| aspas.hop(a[i - 1], a[j - 1]);
|
||||
let first_up = |wanted: &[Hop]| {
|
||||
(1..n)
|
||||
.find(|i| wanted.contains(&hop(*i, *i + 1)))
|
||||
.unwrap_or(n)
|
||||
};
|
||||
let last_down = |wanted: &[Hop]| {
|
||||
(2..=n)
|
||||
.rev()
|
||||
.find(|j| wanted.contains(&hop(*j, *j - 1)))
|
||||
.map_or(n, |j| n - j + 1)
|
||||
};
|
||||
let not = [Hop::NotProvider];
|
||||
let not_attested = [Hop::NoAttestation, Hop::NotProvider];
|
||||
let (max_up, min_up) = (first_up(¬), first_up(¬_attested));
|
||||
match algorithm {
|
||||
Upstream if max_up < n => Invalid,
|
||||
Upstream if min_up < n => Unknown,
|
||||
Upstream => Valid,
|
||||
Downstream => {
|
||||
let (max_down, min_down) = (last_down(¬), last_down(¬_attested));
|
||||
if max_up + max_down < n {
|
||||
Invalid
|
||||
} else if min_up + min_down < n {
|
||||
Unknown
|
||||
} else {
|
||||
Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Over a handful of AS numbers, so that paths and ASPAs meet.
|
||||
#[test]
|
||||
fn verification_agrees_with_the_draft_as_written(
|
||||
aspas in proptest::collection::vec(
|
||||
(0u32..8, proptest::collection::vec(0u32..8, 1..4)), 0..8),
|
||||
path in proptest::collection::vec(0u32..8, 0..10),
|
||||
downstream: bool,
|
||||
) {
|
||||
let aspas: AspaTable = aspas.into_iter().collect();
|
||||
let algorithm = if downstream { Downstream } else { Upstream };
|
||||
prop_assert_eq!(
|
||||
aspas.verify(&path, algorithm),
|
||||
by_the_draft(&aspas, &path, algorithm)
|
||||
);
|
||||
}
|
||||
|
||||
/// What is invalid from a provider is invalid from anyone, and
|
||||
/// what is valid from a customer is valid from anyone: the
|
||||
/// procedure for providers allows more.
|
||||
#[test]
|
||||
fn the_procedure_for_providers_is_the_more_lenient(
|
||||
aspas in proptest::collection::vec(
|
||||
(0u32..8, proptest::collection::vec(0u32..8, 1..4)), 0..8),
|
||||
path in proptest::collection::vec(0u32..8, 1..10),
|
||||
) {
|
||||
let aspas: AspaTable = aspas.into_iter().collect();
|
||||
let (up, down) = (aspas.verify(&path, Upstream), aspas.verify(&path, Downstream));
|
||||
if down == Invalid {
|
||||
prop_assert_eq!(up, Invalid);
|
||||
}
|
||||
if up == Valid {
|
||||
prop_assert_eq!(down, Valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+339
-120
@@ -1,11 +1,14 @@
|
||||
//! Keeps the list of VRPs in step with a validator, over the RPKI-to-Router
|
||||
//! protocol (RFC 8210).
|
||||
//! Keeps the VRPs and ASPAs in step with a validator, over the
|
||||
//! RPKI-to-Router protocol (RFC 8210 and draft-ietf-sidrops-8210bis).
|
||||
//!
|
||||
//! The client connects to a cache (the validator's RTR server), asks for
|
||||
//! everything once, and from then on for what has changed: when the cache
|
||||
//! says there is something new, and otherwise every so often. Each time the
|
||||
//! data changes a new [`VrpTable`] is published for whoever validates
|
||||
//! routes.
|
||||
//! data changes an [`Update`] is published for whoever validates routes.
|
||||
//!
|
||||
//! The newest version of the protocol is asked for first, and an older one
|
||||
//! if that is what the cache speaks. Only version 2 carries ASPAs: with an
|
||||
//! older cache there are none, and paths are not verified.
|
||||
//!
|
||||
//! A cache that cannot be reached is tried again, and the other caches in
|
||||
//! turn. Meanwhile the data last received is used, but not for ever: a list
|
||||
@@ -30,7 +33,8 @@ use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, sleep_until, timeout};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::rtr::{Pdu, RtrError, Timers, VERSION_0, VERSION_1, error_code};
|
||||
use crate::aspa::AspaTable;
|
||||
use crate::rtr::{Pdu, RtrError, Timers, VERSION_0, VERSION_2, error_code};
|
||||
use crate::vrp::{Vrp, VrpTable};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
@@ -41,6 +45,9 @@ const RESPONSE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// How long to wait before trying the next cache after one has failed.
|
||||
const NEXT_CACHE_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// How long a cache that is being left is given to take the reason.
|
||||
const ERROR_REPORT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Where the VRPs come from, and how patiently.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RpkiConfig {
|
||||
@@ -54,16 +61,28 @@ pub struct RpkiConfig {
|
||||
pub expire: Option<Duration>,
|
||||
}
|
||||
|
||||
/// The VRPs as they are after a change.
|
||||
/// The VRPs and ASPAs as they are after a change.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Update {
|
||||
/// Goes up by one with every update.
|
||||
pub version: u64,
|
||||
pub table: Arc<VrpTable>,
|
||||
/// The VRPs that were added or removed to get here from the version
|
||||
/// before: only routes that these cover can have changed validity.
|
||||
/// `None` if any route may have.
|
||||
pub changed: Option<Arc<VrpTable>>,
|
||||
pub vrps: Arc<VrpTable>,
|
||||
pub aspas: Arc<AspaTable>,
|
||||
/// What was added or removed to get here from the version before.
|
||||
/// `None` if any route's validity may have changed.
|
||||
pub changed: Option<Changed>,
|
||||
}
|
||||
|
||||
/// What differs between one [`Update`] and the next.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Changed {
|
||||
/// The VRPs that came or went: only routes that these cover can have
|
||||
/// another origin validity than before.
|
||||
pub vrps: Arc<VrpTable>,
|
||||
/// The customer ASes whose ASPA came, went or lists other providers:
|
||||
/// only paths with one of these on them can verify otherwise than
|
||||
/// before.
|
||||
pub aspa_customers: Arc<HashSet<u32>>,
|
||||
}
|
||||
|
||||
/// How things stand with the caches.
|
||||
@@ -89,6 +108,9 @@ pub struct Status {
|
||||
pub protocol_version: u8,
|
||||
pub vrps_ipv4: usize,
|
||||
pub vrps_ipv6: usize,
|
||||
/// How many ASes have an ASPA. None do if the cache speaks a version
|
||||
/// of the protocol before 2.
|
||||
pub aspas: usize,
|
||||
/// When the data was last confirmed by a cache.
|
||||
pub updated_at: Option<SystemTime>,
|
||||
/// When it will be dropped unless confirmed again.
|
||||
@@ -112,12 +134,15 @@ pub struct Rpki {
|
||||
|
||||
impl Rpki {
|
||||
/// Starts a client for `config` and returns the handle to its data,
|
||||
/// with the task that runs it. Until the first cache has answered the
|
||||
/// table is empty, and every route is [`crate::Validity::NotFound`].
|
||||
/// with the task that runs it. Until the first cache has answered
|
||||
/// there are no VRPs and no ASPAs: every route's origin is
|
||||
/// [`crate::Validity::NotFound`] and every path
|
||||
/// [`crate::PathValidity::Unknown`].
|
||||
pub fn spawn(config: RpkiConfig) -> (Self, JoinHandle<()>) {
|
||||
let (updates, _) = watch::channel(Update {
|
||||
version: 0,
|
||||
table: Arc::default(),
|
||||
vrps: Arc::default(),
|
||||
aspas: Arc::default(),
|
||||
changed: None,
|
||||
});
|
||||
let rpki = Self {
|
||||
@@ -129,9 +154,10 @@ impl Rpki {
|
||||
state: CacheState::Connecting,
|
||||
session_id: None,
|
||||
serial: None,
|
||||
protocol_version: VERSION_1,
|
||||
protocol_version: VERSION_2,
|
||||
vrps_ipv4: 0,
|
||||
vrps_ipv6: 0,
|
||||
aspas: 0,
|
||||
updated_at: None,
|
||||
expires_at: None,
|
||||
last_error: None,
|
||||
@@ -141,16 +167,16 @@ impl Rpki {
|
||||
let client = Client {
|
||||
shared: Arc::clone(&rpki.shared),
|
||||
config,
|
||||
vrps: HashSet::new(),
|
||||
data: Data::default(),
|
||||
place: None,
|
||||
protocol_version: VERSION_1,
|
||||
protocol_version: VERSION_2,
|
||||
timers: Timers::default(),
|
||||
expires: None,
|
||||
};
|
||||
(rpki, tokio::spawn(client.run()))
|
||||
}
|
||||
|
||||
/// The VRPs as they are now.
|
||||
/// The VRPs and ASPAs as they are now.
|
||||
pub fn current(&self) -> Update {
|
||||
self.shared.updates.borrow().clone()
|
||||
}
|
||||
@@ -192,33 +218,114 @@ enum SessionError {
|
||||
#[error("the cache sent {0} where it has no place")]
|
||||
Unexpected(&'static str),
|
||||
#[error("the cache {0}")]
|
||||
Inconsistent(&'static str),
|
||||
/// Not a failure of the cache: it speaks the older version, and is to
|
||||
Inconsistent(Inconsistency),
|
||||
/// Not a failure of the cache: it speaks an older version, and is to
|
||||
/// be asked again in that.
|
||||
#[error("the cache speaks protocol version 0")]
|
||||
Downgrade,
|
||||
#[error("the cache speaks protocol version {0}")]
|
||||
Downgrade(u8),
|
||||
}
|
||||
|
||||
impl SessionError {
|
||||
/// The error code that tells the cache why it is being left, if that
|
||||
/// is something it did and can be told (RFC 8210 §10). An Error Report
|
||||
/// is never answered with another.
|
||||
fn report_code(&self) -> Option<u16> {
|
||||
match self {
|
||||
Self::Malformed(e) => Some(e.code()),
|
||||
Self::Unexpected(_) => Some(error_code::CORRUPT_DATA),
|
||||
Self::Inconsistent(problem) => Some(problem.code),
|
||||
Self::Io(_) | Self::Closed | Self::Timeout | Self::Reported { .. } => None,
|
||||
Self::Downgrade(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Something a cache said that does not fit with what it said before.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Inconsistency {
|
||||
what: &'static str,
|
||||
/// The error code it is reported to the cache with.
|
||||
code: u16,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Inconsistency {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.what)
|
||||
}
|
||||
}
|
||||
|
||||
const fn inconsistent(what: &'static str, code: u16) -> Inconsistency {
|
||||
Inconsistency { what, code }
|
||||
}
|
||||
|
||||
const DUPLICATE_VRP: Inconsistency =
|
||||
inconsistent("announced a VRP twice", error_code::DUPLICATE_ANNOUNCEMENT);
|
||||
const UNKNOWN_VRP: Inconsistency = inconsistent(
|
||||
"withdrew a VRP it had not announced",
|
||||
error_code::WITHDRAWAL_OF_UNKNOWN_RECORD,
|
||||
);
|
||||
const UNKNOWN_ASPA: Inconsistency = inconsistent(
|
||||
"withdrew an ASPA it had not announced",
|
||||
error_code::WITHDRAWAL_OF_UNKNOWN_RECORD,
|
||||
);
|
||||
const OTHER_SESSION: Inconsistency = inconsistent(
|
||||
"ended an answer in another session than it began it",
|
||||
error_code::CORRUPT_DATA,
|
||||
);
|
||||
const OTHER_VERSION: Inconsistency = inconsistent(
|
||||
"changed protocol version in the middle of a session",
|
||||
error_code::UNEXPECTED_VERSION,
|
||||
);
|
||||
const INTERNAL: Inconsistency = inconsistent(
|
||||
"could not be followed (a bug here)",
|
||||
error_code::INTERNAL_ERROR,
|
||||
);
|
||||
|
||||
/// Where the data is at: which of the cache's sessions, and which serial.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Place {
|
||||
cache: SocketAddr,
|
||||
/// The version of the protocol the data came in. What another version
|
||||
/// carries is not the same data.
|
||||
protocol_version: u8,
|
||||
session_id: u16,
|
||||
serial: u32,
|
||||
}
|
||||
|
||||
/// What a cache has given: the data validation rests on.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct Data {
|
||||
vrps: HashSet<Vrp>,
|
||||
aspas: AspaTable,
|
||||
}
|
||||
|
||||
/// One thing an answer says: a VRP or an ASPA, come or gone.
|
||||
#[derive(Debug, Clone)]
|
||||
enum Record {
|
||||
Vrp {
|
||||
announce: bool,
|
||||
vrp: Vrp,
|
||||
},
|
||||
/// With providers, the ASPA that is now `customer`'s; without, that
|
||||
/// it has none any more.
|
||||
Aspa {
|
||||
customer: u32,
|
||||
providers: Option<Vec<u32>>,
|
||||
},
|
||||
}
|
||||
|
||||
struct Client {
|
||||
shared: Arc<Shared>,
|
||||
config: RpkiConfig,
|
||||
/// The VRPs as last received.
|
||||
vrps: HashSet<Vrp>,
|
||||
/// Where `vrps` is at, if it came from a cache that can be asked for
|
||||
/// The data as last received.
|
||||
data: Data,
|
||||
/// Where `data` is at, if it came from a cache that can be asked for
|
||||
/// what has changed since.
|
||||
place: Option<Place>,
|
||||
protocol_version: u8,
|
||||
/// As the cache last gave them, or the defaults.
|
||||
timers: Timers,
|
||||
/// When `vrps` is to be dropped unless a cache confirms it first.
|
||||
/// When `data` is to be dropped unless a cache confirms it first.
|
||||
expires: Option<Instant>,
|
||||
}
|
||||
|
||||
@@ -291,9 +398,9 @@ impl Client {
|
||||
status.state = CacheState::Connecting;
|
||||
});
|
||||
let ended = self.session(cache).await;
|
||||
if matches!(ended, SessionError::Downgrade) {
|
||||
info!(%cache, "RPKI cache speaks RTR version 0; asking again in that");
|
||||
self.protocol_version = VERSION_0;
|
||||
if let SessionError::Downgrade(version) = ended {
|
||||
info!(%cache, version, "RPKI cache speaks an older RTR version; asking again in that");
|
||||
self.protocol_version = version;
|
||||
continue;
|
||||
}
|
||||
warn!(%cache, err = %ended, "RPKI cache session ended");
|
||||
@@ -304,8 +411,9 @@ impl Client {
|
||||
|
||||
// The next cache at once; when all have been tried, a rest.
|
||||
attempt += 1;
|
||||
// What is new may be at a newer version.
|
||||
self.protocol_version = VERSION_1;
|
||||
// The next cache, or this one when it is back, may speak a
|
||||
// newer version.
|
||||
self.protocol_version = VERSION_2;
|
||||
let wait = if attempt.is_multiple_of(self.config.caches.len()) {
|
||||
self.retry()
|
||||
} else {
|
||||
@@ -331,10 +439,11 @@ impl Client {
|
||||
/// The data has gone unconfirmed for too long to be used.
|
||||
fn expired(&mut self) {
|
||||
warn!(
|
||||
vrps = self.vrps.len(),
|
||||
vrps = self.data.vrps.len(),
|
||||
aspas = self.data.aspas.len(),
|
||||
"RPKI data has expired without a cache to confirm it; dropping it"
|
||||
);
|
||||
self.vrps.clear();
|
||||
self.data = Data::default();
|
||||
self.place = None;
|
||||
self.expires = None;
|
||||
self.publish(Arc::default(), None);
|
||||
@@ -365,7 +474,7 @@ impl Client {
|
||||
|
||||
loop {
|
||||
if let Err(e) = self.synchronise(cache, &mut connection).await {
|
||||
return e;
|
||||
return self.leave(&mut connection, e).await;
|
||||
}
|
||||
// Until the cache says there is something new, or it is time
|
||||
// to ask anyway. If the data runs out first, it is the cache
|
||||
@@ -373,14 +482,20 @@ impl Client {
|
||||
let ask = Instant::now() + self.refresh();
|
||||
let expires = self.expires.unwrap_or(ask);
|
||||
tokio::select! {
|
||||
pdu = connection.next() => match pdu {
|
||||
Ok((_, Pdu::SerialNotify { .. })) => {}
|
||||
Ok((_, Pdu::ErrorReport { code, text })) => {
|
||||
return SessionError::Reported { code, text };
|
||||
}
|
||||
Ok(_) => return SessionError::Unexpected("an answer nobody asked for"),
|
||||
Err(e) => return e,
|
||||
},
|
||||
pdu = connection.next() => {
|
||||
let problem = match pdu {
|
||||
Ok((_, Pdu::ErrorReport { code, text })) => {
|
||||
SessionError::Reported { code, text }
|
||||
}
|
||||
Ok((version, _)) if version != self.protocol_version => {
|
||||
SessionError::Inconsistent(OTHER_VERSION)
|
||||
}
|
||||
Ok((_, Pdu::SerialNotify { .. })) => continue,
|
||||
Ok(_) => SessionError::Unexpected("an answer nobody asked for"),
|
||||
Err(e) => e,
|
||||
};
|
||||
return self.leave(&mut connection, problem).await;
|
||||
}
|
||||
() = sleep_until(ask) => {}
|
||||
() = sleep_until(expires), if expires < ask => {
|
||||
self.expired();
|
||||
@@ -390,6 +505,24 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the cache why the session is ending, if it is for something
|
||||
/// the cache did, and gives the reason back.
|
||||
async fn leave(&self, connection: &mut Connection, why: SessionError) -> SessionError {
|
||||
if let Some(code) = why.report_code() {
|
||||
let report = Pdu::ErrorReport {
|
||||
code,
|
||||
text: why.to_string(),
|
||||
};
|
||||
// If it cannot be told, it cannot.
|
||||
let _ = timeout(
|
||||
ERROR_REPORT_TIMEOUT,
|
||||
connection.send(self.protocol_version, &report),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
why
|
||||
}
|
||||
|
||||
/// Asks the cache for what has changed, or for everything, and takes
|
||||
/// the answer.
|
||||
async fn synchronise(
|
||||
@@ -397,8 +530,11 @@ impl Client {
|
||||
cache: SocketAddr,
|
||||
connection: &mut Connection,
|
||||
) -> Result<(), SessionError> {
|
||||
// Changes can be asked for only of the cache the data came from.
|
||||
let mut since = self.place.filter(|place| place.cache == cache);
|
||||
// Changes can be asked for only of the cache the data came from,
|
||||
// in the version of the protocol it came in.
|
||||
let mut since = self.place.filter(|place| {
|
||||
place.cache == cache && place.protocol_version == self.protocol_version
|
||||
});
|
||||
loop {
|
||||
let query = match since {
|
||||
Some(place) => Pdu::SerialQuery {
|
||||
@@ -433,20 +569,26 @@ impl Client {
|
||||
let session_id = loop {
|
||||
let (version, pdu) = next().await?;
|
||||
if version < self.protocol_version {
|
||||
// An older cache answers in its own version; the query has
|
||||
// to be put again in that (RFC 8210 §7).
|
||||
return Err(SessionError::Downgrade);
|
||||
// An older cache answers in its own version, with an error
|
||||
// or as if it had been asked in that; the query has to be
|
||||
// put again in that version (RFC 8210 §7).
|
||||
return Err(SessionError::Downgrade(version));
|
||||
}
|
||||
if version > self.protocol_version {
|
||||
return Err(SessionError::Inconsistent(OTHER_VERSION));
|
||||
}
|
||||
match pdu {
|
||||
Pdu::CacheResponse { session_id } => break session_id,
|
||||
Pdu::CacheReset if since.is_some() => return Ok(Answer::Reset),
|
||||
// News of what is being asked for anyway.
|
||||
Pdu::SerialNotify { .. } => {}
|
||||
// A cache that refuses the version in the version it
|
||||
// refuses: the next one down, then.
|
||||
Pdu::ErrorReport { code, .. }
|
||||
if code == error_code::UNSUPPORTED_VERSION
|
||||
&& self.protocol_version != VERSION_0 =>
|
||||
{
|
||||
return Err(SessionError::Downgrade);
|
||||
return Err(SessionError::Downgrade(self.protocol_version - 1));
|
||||
}
|
||||
Pdu::ErrorReport { code, text } => {
|
||||
return Err(SessionError::Reported { code, text });
|
||||
@@ -460,13 +602,25 @@ impl Client {
|
||||
return Ok(Answer::Reset);
|
||||
}
|
||||
|
||||
// Then the VRPs, and End of Data. Nothing takes effect until the
|
||||
// answer is complete (RFC 8210 §8.1), so what it says is kept
|
||||
// aside in the order it came.
|
||||
let mut records: Vec<(Vrp, bool)> = Vec::new();
|
||||
// Then the VRPs and ASPAs, and End of Data. Nothing takes effect
|
||||
// until the answer is complete (RFC 8210 §8.1), so what it says is
|
||||
// kept aside in the order it came.
|
||||
let mut records: Vec<Record> = Vec::new();
|
||||
let (serial, timers) = loop {
|
||||
match next().await?.1 {
|
||||
Pdu::Prefix { announce, vrp } => records.push((vrp, announce)),
|
||||
let (version, pdu) = next().await?;
|
||||
if version != self.protocol_version && !matches!(pdu, Pdu::ErrorReport { .. }) {
|
||||
return Err(SessionError::Inconsistent(OTHER_VERSION));
|
||||
}
|
||||
match pdu {
|
||||
Pdu::Prefix { announce, vrp } => records.push(Record::Vrp { announce, vrp }),
|
||||
Pdu::Aspa {
|
||||
announce,
|
||||
customer,
|
||||
providers,
|
||||
} => records.push(Record::Aspa {
|
||||
customer,
|
||||
providers: announce.then_some(providers),
|
||||
}),
|
||||
Pdu::RouterKey => {}
|
||||
Pdu::EndOfData {
|
||||
session_id: ended,
|
||||
@@ -474,9 +628,7 @@ impl Client {
|
||||
timers,
|
||||
} => {
|
||||
if ended != session_id {
|
||||
return Err(SessionError::Inconsistent(
|
||||
"ended an answer in another session than it began it",
|
||||
));
|
||||
return Err(SessionError::Inconsistent(OTHER_SESSION));
|
||||
}
|
||||
break (serial, timers);
|
||||
}
|
||||
@@ -492,47 +644,47 @@ impl Client {
|
||||
};
|
||||
|
||||
// The first data there is can change any route's validity; after
|
||||
// that, only where a VRP came or went.
|
||||
// that, only where something came or went.
|
||||
let first = self.place.is_none() && self.expires.is_none();
|
||||
let before = match since {
|
||||
let (before, replaced) = match since {
|
||||
// Changes, to what there is.
|
||||
Some(_) => std::mem::take(&mut self.vrps),
|
||||
Some(_) => (std::mem::take(&mut self.data), None),
|
||||
// Everything anew.
|
||||
None => HashSet::new(),
|
||||
};
|
||||
let replaced = match since {
|
||||
Some(_) => None,
|
||||
None => Some(std::mem::take(&mut self.vrps)),
|
||||
None => (Data::default(), Some(std::mem::take(&mut self.data))),
|
||||
};
|
||||
let vrps = Arc::clone(&self.shared.updates.borrow().vrps);
|
||||
// Building the tables is work for a thread that may take its time.
|
||||
let built = tokio::task::spawn_blocking(move || apply(before, replaced, &records, first))
|
||||
let built = tokio::task::spawn_blocking(move || apply(before, replaced, &records, vrps))
|
||||
.await
|
||||
.map_err(|_| SessionError::Inconsistent("could not be followed (a bug here)"))?;
|
||||
.map_err(|_| SessionError::Inconsistent(INTERNAL))?;
|
||||
let built = match built {
|
||||
Ok(built) => built,
|
||||
Err((vrps, problem)) => {
|
||||
Err((data, problem)) => {
|
||||
// What there was stays as it was.
|
||||
self.vrps = vrps;
|
||||
self.data = data;
|
||||
return Err(SessionError::Inconsistent(problem));
|
||||
}
|
||||
};
|
||||
|
||||
self.vrps = built.vrps;
|
||||
self.data = built.data;
|
||||
self.place = Some(Place {
|
||||
cache,
|
||||
protocol_version: self.protocol_version,
|
||||
session_id,
|
||||
serial,
|
||||
});
|
||||
self.timers = timers.unwrap_or_default();
|
||||
self.expires = Some(Instant::now() + self.expire());
|
||||
if first || built.changes > 0 {
|
||||
self.publish(built.table, built.changed);
|
||||
let changes = built.changed.vrps.len() + built.changed.aspa_customers.len();
|
||||
if first || changes > 0 {
|
||||
self.publish(built.vrps, (!first).then_some(built.changed));
|
||||
}
|
||||
info!(
|
||||
%cache,
|
||||
serial,
|
||||
vrps = self.vrps.len(),
|
||||
changed = built.changes,
|
||||
vrps = self.data.vrps.len(),
|
||||
aspas = self.data.aspas.len(),
|
||||
changes,
|
||||
"RPKI data is up to date"
|
||||
);
|
||||
let expires_at = SystemTime::now() + self.expire();
|
||||
@@ -547,11 +699,11 @@ impl Client {
|
||||
Ok(Answer::Complete)
|
||||
}
|
||||
|
||||
/// Makes `table`, which holds the VRPs as they now are, the one in
|
||||
/// use.
|
||||
fn publish(&self, table: Arc<VrpTable>, changed: Option<Arc<VrpTable>>) {
|
||||
/// Publishes the data as it now is. `vrps` holds its VRPs.
|
||||
fn publish(&self, vrps: Arc<VrpTable>, changed: Option<Changed>) {
|
||||
let count = |version| {
|
||||
self.vrps
|
||||
self.data
|
||||
.vrps
|
||||
.iter()
|
||||
.filter(|vrp| vrp.prefix.address_family() == version)
|
||||
.count()
|
||||
@@ -560,84 +712,151 @@ impl Client {
|
||||
self.status(|status| {
|
||||
status.vrps_ipv4 = ipv4;
|
||||
status.vrps_ipv6 = ipv6;
|
||||
status.aspas = self.data.aspas.len();
|
||||
});
|
||||
let aspas = Arc::new(self.data.aspas.clone());
|
||||
self.shared.updates.send_modify(|update| {
|
||||
update.version += 1;
|
||||
update.table = table;
|
||||
update.vrps = vrps;
|
||||
update.aspas = aspas;
|
||||
update.changed = changed;
|
||||
self.shared.version.store(update.version, Ordering::Release);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The VRPs after an answer, ready to be used.
|
||||
/// The data after an answer, ready to be used.
|
||||
struct Built {
|
||||
vrps: HashSet<Vrp>,
|
||||
table: Arc<VrpTable>,
|
||||
/// See [`Update::changed`].
|
||||
changed: Option<Arc<VrpTable>>,
|
||||
/// How many VRPs came or went.
|
||||
changes: usize,
|
||||
data: Data,
|
||||
/// The VRPs of `data`, arranged for validating.
|
||||
vrps: Arc<VrpTable>,
|
||||
changed: Changed,
|
||||
}
|
||||
|
||||
/// Applies the `records` of a complete answer, in order, to `vrps`: each
|
||||
/// a VRP and whether it is announced or withdrawn. `replaced` is the set
|
||||
/// that an answer to a Reset Query takes the place of.
|
||||
/// Applies the `records` of a complete answer, in order, to `data`.
|
||||
/// `replaced` is the data that an answer to a Reset Query takes the place
|
||||
/// of, and `vrps` the table of the VRPs as they were before the answer,
|
||||
/// which is kept if the answer changes none.
|
||||
///
|
||||
/// If the answer contradicts itself or what there was, what there was is
|
||||
/// given back with the reason.
|
||||
fn apply(
|
||||
mut vrps: HashSet<Vrp>,
|
||||
replaced: Option<HashSet<Vrp>>,
|
||||
records: &[(Vrp, bool)],
|
||||
first: bool,
|
||||
) -> Result<Built, (HashSet<Vrp>, &'static str)> {
|
||||
mut data: Data,
|
||||
replaced: Option<Data>,
|
||||
records: &[Record],
|
||||
vrps: Arc<VrpTable>,
|
||||
) -> Result<Built, (Data, Inconsistency)> {
|
||||
// What each record displaced, to put back if a later one cannot be
|
||||
// applied.
|
||||
let mut displaced: Vec<Option<Arc<[u32]>>> = Vec::new();
|
||||
let mut problem = None;
|
||||
let mut applied = 0;
|
||||
for (vrp, announce) in records {
|
||||
let ok = match announce {
|
||||
true => vrps.insert(*vrp),
|
||||
false => vrps.remove(vrp),
|
||||
for record in records {
|
||||
let ok = match record {
|
||||
Record::Vrp {
|
||||
announce: true,
|
||||
vrp,
|
||||
} => data.vrps.insert(*vrp).then_some(None).ok_or(DUPLICATE_VRP),
|
||||
Record::Vrp {
|
||||
announce: false,
|
||||
vrp,
|
||||
} => data.vrps.remove(vrp).then_some(None).ok_or(UNKNOWN_VRP),
|
||||
// An ASPA that is announced takes the place of the one there
|
||||
// was (draft-ietf-sidrops-8210bis §5.12).
|
||||
Record::Aspa {
|
||||
customer,
|
||||
providers: Some(providers),
|
||||
} => Ok(data.aspas.set(*customer, providers.iter().copied())),
|
||||
Record::Aspa {
|
||||
customer,
|
||||
providers: None,
|
||||
} => data.aspas.remove(*customer).map(Some).ok_or(UNKNOWN_ASPA),
|
||||
};
|
||||
if !ok {
|
||||
problem = Some(match announce {
|
||||
true => "announced a VRP twice",
|
||||
false => "withdrew a VRP it had not announced",
|
||||
});
|
||||
break;
|
||||
match ok {
|
||||
Ok(was) => displaced.push(was),
|
||||
Err(e) => {
|
||||
problem = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
if let Some(problem) = problem {
|
||||
return Err(match replaced {
|
||||
Some(replaced) => (replaced, problem),
|
||||
None => {
|
||||
// Undo, last first.
|
||||
for (vrp, announce) in records[..applied].iter().rev() {
|
||||
match announce {
|
||||
true => vrps.remove(vrp),
|
||||
false => vrps.insert(*vrp),
|
||||
};
|
||||
for (record, was) in records.iter().zip(displaced).rev() {
|
||||
match record {
|
||||
Record::Vrp {
|
||||
announce: true,
|
||||
vrp,
|
||||
} => {
|
||||
data.vrps.remove(vrp);
|
||||
}
|
||||
Record::Vrp {
|
||||
announce: false,
|
||||
vrp,
|
||||
} => {
|
||||
data.vrps.insert(*vrp);
|
||||
}
|
||||
Record::Aspa { customer, .. } => data.aspas.restore(*customer, was),
|
||||
}
|
||||
}
|
||||
(vrps, problem)
|
||||
(data, problem)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// What came or went: against the set replaced, or else whatever the
|
||||
// What came or went: against the data replaced, or else whatever the
|
||||
// answer touched.
|
||||
let changed: Vec<Vrp> = match &replaced {
|
||||
Some(replaced) => vrps.symmetric_difference(replaced).copied().collect(),
|
||||
None => records.iter().map(|(vrp, _)| *vrp).collect(),
|
||||
let (changed_vrps, aspa_customers): (Vec<Vrp>, HashSet<u32>) = match &replaced {
|
||||
Some(replaced) => (
|
||||
data.vrps
|
||||
.symmetric_difference(&replaced.vrps)
|
||||
.copied()
|
||||
.collect(),
|
||||
differing_customers(&data.aspas, &replaced.aspas),
|
||||
),
|
||||
None => (
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|record| match record {
|
||||
Record::Vrp { vrp, .. } => Some(*vrp),
|
||||
Record::Aspa { .. } => None,
|
||||
})
|
||||
.collect(),
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|record| match record {
|
||||
Record::Aspa { customer, .. } => Some(*customer),
|
||||
Record::Vrp { .. } => None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
Ok(Built {
|
||||
table: Arc::new(VrpTable::new(vrps.iter().copied())),
|
||||
changed: (!first).then(|| Arc::new(VrpTable::new(changed.iter().copied()))),
|
||||
changes: changed.len(),
|
||||
vrps,
|
||||
vrps: match changed_vrps.is_empty() {
|
||||
true => vrps,
|
||||
false => Arc::new(VrpTable::new(data.vrps.iter().copied())),
|
||||
},
|
||||
changed: Changed {
|
||||
vrps: Arc::new(VrpTable::new(changed_vrps)),
|
||||
aspa_customers: Arc::new(aspa_customers),
|
||||
},
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/// The customers whose ASPA is not the same in `a` as in `b`.
|
||||
fn differing_customers(a: &AspaTable, b: &AspaTable) -> HashSet<u32> {
|
||||
let differs = |(customer, providers): (u32, &[u32]), other: &AspaTable| {
|
||||
(other.providers(customer) != Some(providers)).then_some(customer)
|
||||
};
|
||||
a.iter()
|
||||
.filter_map(|aspa| differs(aspa, b))
|
||||
.chain(b.iter().filter_map(|aspa| differs(aspa, a)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How a query was answered.
|
||||
enum Answer {
|
||||
/// With data, which is now in use.
|
||||
|
||||
+15
-4
@@ -1,4 +1,5 @@
|
||||
//! Route origin validation with the RPKI (RFC 6811).
|
||||
//! Route origin validation (RFC 6811) and AS path verification
|
||||
//! (draft-ietf-sidrops-aspa-verification) with the RPKI.
|
||||
//!
|
||||
//! The holder of an address block can sign a statement of which AS may
|
||||
//! originate routes for it. Validators collect and check those statements
|
||||
@@ -7,13 +8,23 @@
|
||||
//! tell a route whose origin is authorised from one whose origin is not:
|
||||
//! the commonest kind of hijack and of mistake.
|
||||
//!
|
||||
//! - [`vrp`] holds the list and answers whether a route is valid.
|
||||
//!
|
||||
//! The same way, a network can sign a statement of who its providers are
|
||||
//! (an ASPA). With those a router can tell a path that could have come
|
||||
//! about by networks passing routes on as they are paid to from one that
|
||||
//! could not: a route leak, or a path made up to get past origin
|
||||
//! validation.
|
||||
//!
|
||||
//! - [`vrp`] holds the VRPs and answers whether a route's origin is valid.
|
||||
//! - [`aspa`] holds the ASPAs and answers whether a path is.
|
||||
//! - [`rtr`] is the protocol's messages.
|
||||
//! - [`client`] keeps the list in step with a validator.
|
||||
//! - [`client`] keeps both in step with a validator.
|
||||
|
||||
pub mod aspa;
|
||||
pub mod client;
|
||||
pub mod rtr;
|
||||
pub mod vrp;
|
||||
|
||||
pub use client::{CacheState, Rpki, RpkiConfig, Status, Update};
|
||||
pub use aspa::{Algorithm, AspaTable, PathValidity};
|
||||
pub use client::{CacheState, Changed, Rpki, RpkiConfig, Status, Update};
|
||||
pub use vrp::{Validity, Vrp, VrpTable};
|
||||
+138
-17
@@ -1,5 +1,6 @@
|
||||
//! The messages of the RPKI-to-Router protocol (RFC 8210, and RFC 6810 for
|
||||
//! version 0), as bytes and back. No I/O here.
|
||||
//! The messages of the RPKI-to-Router protocol, as bytes and back: version
|
||||
//! 0 (RFC 6810), version 1 (RFC 8210) and version 2
|
||||
//! (draft-ietf-sidrops-8210bis). No I/O here.
|
||||
//!
|
||||
//! Every message (a PDU) begins with the same eight octets: the protocol
|
||||
//! version, the type, a field whose meaning depends on the type, and the
|
||||
@@ -11,17 +12,20 @@ use bytes::{BufMut, BytesMut};
|
||||
|
||||
use crate::vrp::Vrp;
|
||||
|
||||
/// The protocol version this client asks for. Version 1 added the timers in
|
||||
/// End of Data and router keys.
|
||||
/// The newest protocol version, which this client asks for first. Version
|
||||
/// 2 added ASPAs.
|
||||
pub const VERSION_2: u8 = 2;
|
||||
/// Version 1 added the timers in End of Data and router keys.
|
||||
pub const VERSION_1: u8 = 1;
|
||||
/// What an older validator speaks.
|
||||
/// What the oldest validators speak.
|
||||
pub const VERSION_0: u8 = 0;
|
||||
|
||||
const HEADER_LEN: usize = 8;
|
||||
|
||||
/// The longest PDU this client reads. Every PDU but Error Report and Router
|
||||
/// Key has a fixed, small length, and those two have no need to be large.
|
||||
pub const MAX_PDU_LEN: usize = 16 * 1024;
|
||||
/// The longest PDU this client reads. Every PDU but Error Report, Router
|
||||
/// Key and ASPA has a fixed, small length. An ASPA of this length lists
|
||||
/// sixteen thousand providers, more than validators let an AS have.
|
||||
pub const MAX_PDU_LEN: usize = 64 * 1024;
|
||||
|
||||
mod pdu_type {
|
||||
pub const SERIAL_NOTIFY: u8 = 0;
|
||||
@@ -34,9 +38,10 @@ mod pdu_type {
|
||||
pub const CACHE_RESET: u8 = 8;
|
||||
pub const ROUTER_KEY: u8 = 9;
|
||||
pub const ERROR_REPORT: u8 = 10;
|
||||
pub const ASPA: u8 = 11;
|
||||
}
|
||||
|
||||
/// Error codes of an Error Report (RFC 8210 §12).
|
||||
/// Error codes of an Error Report (RFC 8210 §12; from 9 on, version 2).
|
||||
pub mod error_code {
|
||||
pub const CORRUPT_DATA: u16 = 0;
|
||||
pub const INTERNAL_ERROR: u16 = 1;
|
||||
@@ -47,6 +52,9 @@ pub mod error_code {
|
||||
pub const WITHDRAWAL_OF_UNKNOWN_RECORD: u16 = 6;
|
||||
pub const DUPLICATE_ANNOUNCEMENT: u16 = 7;
|
||||
pub const UNEXPECTED_VERSION: u16 = 8;
|
||||
pub const ASPA_PROVIDER_LIST: u16 = 9;
|
||||
pub const TRANSPORT_FAILURE: u16 = 10;
|
||||
pub const ORDERING: u16 = 11;
|
||||
}
|
||||
|
||||
/// The timers a validator tells its routers to use (RFC 8210 §6), in
|
||||
@@ -97,6 +105,14 @@ pub enum Pdu {
|
||||
CacheReset,
|
||||
/// A BGPsec router key. Not used here; read so as to be skipped.
|
||||
RouterKey,
|
||||
/// The ASPA of `customer`, whole (version 2). Announced, it takes the
|
||||
/// place of any the cache gave for that customer before, and lists at
|
||||
/// least one provider; withdrawn, it lists none.
|
||||
Aspa {
|
||||
announce: bool,
|
||||
customer: u32,
|
||||
providers: Vec<u32>,
|
||||
},
|
||||
/// Something went wrong at the sender's end, or with what we sent.
|
||||
ErrorReport { code: u16, text: String },
|
||||
}
|
||||
@@ -112,6 +128,8 @@ pub enum RtrError {
|
||||
Version(u8),
|
||||
#[error("a prefix PDU with prefix length {length} and maximum length {max_length}")]
|
||||
Prefix { length: u8, max_length: u8 },
|
||||
#[error("an ASPA of AS{customer} is announced with no providers")]
|
||||
AspaWithoutProviders { customer: u32 },
|
||||
}
|
||||
|
||||
impl RtrError {
|
||||
@@ -121,6 +139,7 @@ impl RtrError {
|
||||
Self::Length { .. } | Self::Prefix { .. } => error_code::CORRUPT_DATA,
|
||||
Self::UnknownType(_) => error_code::UNSUPPORTED_PDU_TYPE,
|
||||
Self::Version(_) => error_code::UNSUPPORTED_VERSION,
|
||||
Self::AspaWithoutProviders { .. } => error_code::ASPA_PROVIDER_LIST,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,12 +160,12 @@ impl Pdu {
|
||||
let Some(header) = buf.first_chunk::<HEADER_LEN>() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (version, pdu_type) = (header[0], header[1]);
|
||||
let (version, pdu_type, flags) = (header[0], header[1], header[2]);
|
||||
let field = be_u16(&header[2..]);
|
||||
let length = be_u32(&header[4..]);
|
||||
let bad_length = || RtrError::Length { pdu_type, length };
|
||||
|
||||
if version > VERSION_1 {
|
||||
if version > VERSION_2 {
|
||||
return Err(RtrError::Version(version));
|
||||
}
|
||||
let total = usize::try_from(length).map_err(|_| bad_length())?;
|
||||
@@ -162,6 +181,15 @@ impl Pdu {
|
||||
pdu_type::IPV6_PREFIX => Some(32),
|
||||
pdu_type::END_OF_DATA => Some(if version == VERSION_0 { 12 } else { 24 }),
|
||||
pdu_type::ROUTER_KEY | pdu_type::ERROR_REPORT => None,
|
||||
pdu_type::ASPA if version >= VERSION_2 => {
|
||||
// The customer, and whole AS numbers after it; none of
|
||||
// them in a withdrawal.
|
||||
let announce = flags & 1 == 1;
|
||||
if total < 12 || !total.is_multiple_of(4) || (!announce && total != 12) {
|
||||
return Err(bad_length());
|
||||
}
|
||||
None
|
||||
}
|
||||
other => return Err(RtrError::UnknownType(other)),
|
||||
};
|
||||
if fixed.is_some_and(|fixed| fixed != total) {
|
||||
@@ -215,6 +243,24 @@ impl Pdu {
|
||||
},
|
||||
pdu_type::CACHE_RESET => Self::CacheReset,
|
||||
pdu_type::ROUTER_KEY => Self::RouterKey,
|
||||
pdu_type::ASPA => {
|
||||
let announce = flags & 1 == 1;
|
||||
let (customer, providers) = body.split_at(4);
|
||||
let customer = be_u32(customer);
|
||||
if announce && providers.is_empty() {
|
||||
return Err(RtrError::AspaWithoutProviders { customer });
|
||||
}
|
||||
Self::Aspa {
|
||||
announce,
|
||||
customer,
|
||||
providers: providers
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|asn| u32::from_be_bytes(*asn))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pdu_type::ERROR_REPORT => {
|
||||
// The length of the PDU that was at fault, that PDU, the
|
||||
// length of the text, the text.
|
||||
@@ -234,7 +280,8 @@ impl Pdu {
|
||||
}
|
||||
|
||||
/// Writes the PDU, for a session that speaks protocol version
|
||||
/// `version`, to the end of `out`.
|
||||
/// `version`, to the end of `out`. Whether that version has such a PDU
|
||||
/// is the caller's to know.
|
||||
pub fn encode(&self, version: u8, out: &mut BytesMut) {
|
||||
let start = out.len();
|
||||
let header = |out: &mut BytesMut, pdu_type: u8, field: u16| {
|
||||
@@ -288,6 +335,17 @@ impl Pdu {
|
||||
}
|
||||
Self::CacheReset => header(out, pdu_type::CACHE_RESET, 0),
|
||||
Self::RouterKey => header(out, pdu_type::ROUTER_KEY, 0),
|
||||
Self::Aspa {
|
||||
announce,
|
||||
customer,
|
||||
providers,
|
||||
} => {
|
||||
header(out, pdu_type::ASPA, u16::from(*announce) << 8);
|
||||
out.put_u32(*customer);
|
||||
for provider in providers {
|
||||
out.put_u32(*provider);
|
||||
}
|
||||
}
|
||||
Self::ErrorReport { code, text } => {
|
||||
header(out, pdu_type::ERROR_REPORT, *code);
|
||||
// No copy of the PDU at fault.
|
||||
@@ -387,6 +445,35 @@ mod tests {
|
||||
}
|
||||
)
|
||||
);
|
||||
// Version 2's ASPA, as draft-ietf-sidrops-8210bis §5.12 draws it.
|
||||
#[rustfmt::skip]
|
||||
let aspa = [
|
||||
2, 11, 1, 0, 0, 0, 0, 20,
|
||||
0, 0, 0x0d, 0x05,
|
||||
0, 0, 0x04, 0x4d, 0, 0, 0x0c, 0xb9,
|
||||
];
|
||||
assert_eq!(
|
||||
decode_one(&aspa),
|
||||
(
|
||||
2,
|
||||
Pdu::Aspa {
|
||||
announce: true,
|
||||
customer: 3333,
|
||||
providers: vec![1101, 3257],
|
||||
}
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_one(&[2, 11, 0, 0, 0, 0, 0, 12, 0, 0, 0x0d, 0x05]),
|
||||
(
|
||||
2,
|
||||
Pdu::Aspa {
|
||||
announce: false,
|
||||
customer: 3333,
|
||||
providers: vec![],
|
||||
}
|
||||
)
|
||||
);
|
||||
#[rustfmt::skip]
|
||||
let error = [
|
||||
1, 10, 0, 2, 0, 0, 0, 30,
|
||||
@@ -423,8 +510,8 @@ mod tests {
|
||||
fn what_is_not_a_pdu_is_refused_from_its_header() {
|
||||
// A version from the future.
|
||||
assert_eq!(
|
||||
Pdu::decode(&[2, 2, 0, 0, 0, 0, 0, 8]),
|
||||
Err(RtrError::Version(2))
|
||||
Pdu::decode(&[3, 2, 0, 0, 0, 0, 0, 8]),
|
||||
Err(RtrError::Version(3))
|
||||
);
|
||||
assert_eq!(
|
||||
Pdu::decode(&[1, 99, 0, 0, 0, 0, 0, 8]),
|
||||
@@ -456,6 +543,29 @@ mod tests {
|
||||
Err(RtrError::Prefix { length, max_length })
|
||||
);
|
||||
}
|
||||
// An ASPA before version 2 had them.
|
||||
assert_eq!(
|
||||
Pdu::decode(&[1, 11, 0, 0, 0, 0, 0, 12, 0, 0, 0, 1]),
|
||||
Err(RtrError::UnknownType(11))
|
||||
);
|
||||
// ASPAs with half an AS number, without a customer, withdrawn
|
||||
// with providers: refused from the header.
|
||||
for (flags, length) in [(1u8, 18u32), (1, 8), (0, 16)] {
|
||||
let mut header = vec![2, 11, flags, 0];
|
||||
header.extend_from_slice(&length.to_be_bytes());
|
||||
assert_eq!(
|
||||
Pdu::decode(&header),
|
||||
Err(RtrError::Length {
|
||||
pdu_type: 11,
|
||||
length
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Announced with no providers.
|
||||
assert_eq!(
|
||||
Pdu::decode(&[2, 11, 1, 0, 0, 0, 0, 12, 0, 0, 0xfd, 0xe8]),
|
||||
Err(RtrError::AspaWithoutProviders { customer: 65000 })
|
||||
);
|
||||
// An Error Report whose inner lengths do not add up.
|
||||
#[rustfmt::skip]
|
||||
let error = [1, 10, 0, 2, 0, 0, 0, 16, 0, 0, 0, 9, 0, 0, 0, 0];
|
||||
@@ -508,7 +618,7 @@ mod tests {
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn a_pdu_that_is_written_reads_back_equal(pdu in any_pdu(), version in 0u8..=1) {
|
||||
fn a_pdu_that_is_written_reads_back_equal(pdu in any_pdu(), version in 0u8..=2) {
|
||||
let mut wire = BytesMut::new();
|
||||
pdu.encode(version, &mut wire);
|
||||
// Version 0 does not carry the timers.
|
||||
@@ -523,6 +633,17 @@ mod tests {
|
||||
prop_assert_eq!(Pdu::decode(&wire), Ok(Some((version, expected, wire.len()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_aspa_that_is_written_reads_back_equal(
|
||||
customer: u32,
|
||||
providers in proptest::collection::vec(any::<u32>(), 0..40),
|
||||
) {
|
||||
let pdu = Pdu::Aspa { announce: !providers.is_empty(), customer, providers };
|
||||
let mut wire = BytesMut::new();
|
||||
pdu.encode(VERSION_2, &mut wire);
|
||||
prop_assert_eq!(Pdu::decode(&wire), Ok(Some((VERSION_2, pdu, wire.len()))));
|
||||
}
|
||||
|
||||
/// Whatever arrives, decoding returns: a PDU, a wait for more, or
|
||||
/// an error. What it reads is within what arrived.
|
||||
#[test]
|
||||
@@ -535,8 +656,8 @@ mod tests {
|
||||
/// Noise behind a header that could be a PDU's, to get past it.
|
||||
#[test]
|
||||
fn noise_behind_a_plausible_header_is_never_more_than_an_error(
|
||||
version in 0u8..=1,
|
||||
pdu_type in 0u8..12,
|
||||
version in 0u8..=2,
|
||||
pdu_type in 0u8..13,
|
||||
field: u16,
|
||||
length in 8u32..64,
|
||||
body in proptest::collection::vec(any::<u8>(), 0..64),
|
||||
|
||||
+265
-39
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rpki::rtr::{Pdu, Timers, error_code};
|
||||
use bgp_rpki::{CacheState, Rpki, RpkiConfig, Update, Validity, Vrp};
|
||||
use bgp_rpki::{Algorithm, CacheState, PathValidity, Rpki, RpkiConfig, Update, Validity, Vrp};
|
||||
use bytes::BytesMut;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -15,6 +15,8 @@ use tokio::sync::watch;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SESSION: u16 = 0x1234;
|
||||
/// The version of the protocol the client asks for first.
|
||||
const V: u8 = 2;
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
@@ -120,7 +122,7 @@ impl Connection {
|
||||
}];
|
||||
pdus.extend(vrps.iter().copied().map(announce));
|
||||
pdus.push(end_of_data(serial));
|
||||
self.send(1, &pdus).await;
|
||||
self.send(V, &pdus).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +151,10 @@ async fn the_client_takes_everything_once_and_then_the_changes() {
|
||||
let (rpki, mut updates) = client_of(&[&cache]);
|
||||
// Nothing is known until a cache has answered.
|
||||
assert_eq!(rpki.version(), 0);
|
||||
assert!(rpki.current().table.is_empty());
|
||||
assert!(rpki.current().vrps.is_empty());
|
||||
|
||||
let mut connection = cache.accept().await;
|
||||
assert_eq!(connection.received().await, (1, Pdu::ResetQuery));
|
||||
assert_eq!(connection.received().await, (V, Pdu::ResetQuery));
|
||||
let ripe = vrp("193.0.0.0/21", 24, 3333);
|
||||
let other = vrp("2001:67c:2e8::/48", 48, 3333);
|
||||
connection.send_all(&[ripe, other], 7).await;
|
||||
@@ -164,13 +166,13 @@ async fn the_client_takes_everything_once_and_then_the_changes() {
|
||||
update.changed.is_none(),
|
||||
"the first data can change anything"
|
||||
);
|
||||
assert_eq!(update.table.len(), 2);
|
||||
assert_eq!(update.vrps.len(), 2);
|
||||
assert_eq!(
|
||||
update.table.validate(&prefix("193.0.4.0/24"), Some(3333)),
|
||||
update.vrps.validate(&prefix("193.0.4.0/24"), Some(3333)),
|
||||
Validity::Valid
|
||||
);
|
||||
assert_eq!(
|
||||
update.table.validate(&prefix("193.0.4.0/24"), Some(15169)),
|
||||
update.vrps.validate(&prefix("193.0.4.0/24"), Some(15169)),
|
||||
Validity::Invalid
|
||||
);
|
||||
let status = rpki.status();
|
||||
@@ -182,7 +184,7 @@ async fn the_client_takes_everything_once_and_then_the_changes() {
|
||||
// The cache has news: the client asks for what changed since serial 7.
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
V,
|
||||
&[Pdu::SerialNotify {
|
||||
session_id: SESSION,
|
||||
serial: 8,
|
||||
@@ -192,7 +194,7 @@ async fn the_client_takes_everything_once_and_then_the_changes() {
|
||||
assert_eq!(
|
||||
connection.received().await,
|
||||
(
|
||||
1,
|
||||
V,
|
||||
Pdu::SerialQuery {
|
||||
session_id: SESSION,
|
||||
serial: 7
|
||||
@@ -202,7 +204,7 @@ async fn the_client_takes_everything_once_and_then_the_changes() {
|
||||
let new = vrp("8.8.8.0/24", 24, 15169);
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
V,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
@@ -216,23 +218,23 @@ async fn the_client_takes_everything_once_and_then_the_changes() {
|
||||
|
||||
let update = next_update(&mut updates).await;
|
||||
assert_eq!(update.version, 2);
|
||||
assert_eq!(update.table.len(), 2);
|
||||
assert_eq!(update.vrps.len(), 2);
|
||||
assert_eq!(
|
||||
update.table.validate(&prefix("8.8.8.0/24"), Some(15169)),
|
||||
update.vrps.validate(&prefix("8.8.8.0/24"), Some(15169)),
|
||||
Validity::Valid
|
||||
);
|
||||
assert_eq!(
|
||||
update
|
||||
.table
|
||||
.vrps
|
||||
.validate(&prefix("2001:67c:2e8::/48"), Some(3333)),
|
||||
Validity::NotFound,
|
||||
"withdrawn"
|
||||
);
|
||||
// Only routes that what came and went covers can have changed.
|
||||
let changed = update.changed.expect("a change to what there was");
|
||||
assert!(changed.covers(&prefix("8.8.8.0/24")));
|
||||
assert!(changed.covers(&prefix("2001:67c:2e8::/48")));
|
||||
assert!(!changed.covers(&prefix("193.0.0.0/21")));
|
||||
assert!(changed.vrps.covers(&prefix("8.8.8.0/24")));
|
||||
assert!(changed.vrps.covers(&prefix("2001:67c:2e8::/48")));
|
||||
assert!(!changed.vrps.covers(&prefix("193.0.0.0/21")));
|
||||
assert_eq!(rpki.status().serial, Some(8));
|
||||
}
|
||||
|
||||
@@ -251,7 +253,7 @@ async fn a_cache_reset_makes_the_client_ask_for_everything() {
|
||||
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
V,
|
||||
&[Pdu::SerialNotify {
|
||||
session_id: SESSION,
|
||||
serial: 20,
|
||||
@@ -262,17 +264,17 @@ async fn a_cache_reset_makes_the_client_ask_for_everything() {
|
||||
connection.received().await.1,
|
||||
Pdu::SerialQuery { serial: 7, .. }
|
||||
));
|
||||
connection.send(1, &[Pdu::CacheReset]).await;
|
||||
connection.send(V, &[Pdu::CacheReset]).await;
|
||||
assert_eq!(connection.received().await.1, Pdu::ResetQuery);
|
||||
let added = vrp("1.1.1.0/24", 24, 13335);
|
||||
connection.send_all(&[kept, added], 20).await;
|
||||
|
||||
let update = next_update(&mut updates).await;
|
||||
assert_eq!(update.table.len(), 2);
|
||||
assert_eq!(update.vrps.len(), 2);
|
||||
let changed = update.changed.expect("known what changed");
|
||||
assert!(changed.covers(&prefix("8.8.8.0/24")));
|
||||
assert!(changed.covers(&prefix("1.1.1.0/24")));
|
||||
assert!(!changed.covers(&prefix("193.0.0.0/21")), "it stayed");
|
||||
assert!(changed.vrps.covers(&prefix("8.8.8.0/24")));
|
||||
assert!(changed.vrps.covers(&prefix("1.1.1.0/24")));
|
||||
assert!(!changed.vrps.covers(&prefix("193.0.0.0/21")), "it stayed");
|
||||
assert_eq!(rpki.status().serial, Some(20));
|
||||
}
|
||||
|
||||
@@ -290,7 +292,7 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
|
||||
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
V,
|
||||
&[Pdu::SerialNotify {
|
||||
session_id: SESSION,
|
||||
serial: 8,
|
||||
@@ -301,7 +303,7 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
|
||||
// A good change, then the withdrawal of something never announced.
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
V,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
@@ -313,8 +315,19 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
|
||||
)
|
||||
.await;
|
||||
|
||||
// The client gives the session up and comes back; what it has is
|
||||
// what it had, and it asks from where it was.
|
||||
// The client says what was wrong,
|
||||
assert!(matches!(
|
||||
connection.received().await,
|
||||
(
|
||||
V,
|
||||
Pdu::ErrorReport {
|
||||
code: error_code::WITHDRAWAL_OF_UNKNOWN_RECORD,
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
// gives the session up and comes back; what it has is what it had,
|
||||
// and it asks from where it was.
|
||||
let mut again = cache.accept().await;
|
||||
assert_eq!(
|
||||
again.received().await.1,
|
||||
@@ -325,7 +338,7 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
|
||||
);
|
||||
let current = rpki.current();
|
||||
assert_eq!(current.version, 1);
|
||||
assert_eq!(current.table.len(), 1);
|
||||
assert_eq!(current.vrps.len(), 1);
|
||||
assert!(
|
||||
rpki.status()
|
||||
.last_error
|
||||
@@ -333,6 +346,219 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
fn aspa(customer: u32, providers: &[u32]) -> Pdu {
|
||||
Pdu::Aspa {
|
||||
announce: true,
|
||||
customer,
|
||||
providers: providers.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ASPAs come with the VRPs, in version 2. One that is announced again
|
||||
/// takes the place of the one before.
|
||||
#[tokio::test]
|
||||
async fn aspas_are_taken_replaced_and_withdrawn() {
|
||||
let cache = Cache::start().await;
|
||||
let (rpki, mut updates) = client_of(&[&cache]);
|
||||
let mut connection = cache.accept().await;
|
||||
assert_eq!(connection.received().await, (V, Pdu::ResetQuery));
|
||||
connection
|
||||
.send(
|
||||
V,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
},
|
||||
announce(vrp("193.0.0.0/21", 24, 3333)),
|
||||
aspa(3333, &[1101, 3257]),
|
||||
aspa(1101, &[0]),
|
||||
end_of_data(7),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let update = next_update(&mut updates).await;
|
||||
assert_eq!(update.vrps.len(), 1);
|
||||
assert_eq!(update.aspas.len(), 2);
|
||||
assert_eq!(update.aspas.providers(3333), Some(&[1101, 3257][..]));
|
||||
assert_eq!(rpki.status().aspas, 2);
|
||||
assert_eq!(rpki.status().protocol_version, 2);
|
||||
assert_eq!(
|
||||
update.aspas.verify(&[174, 3333], Algorithm::Upstream),
|
||||
PathValidity::Invalid
|
||||
);
|
||||
|
||||
connection
|
||||
.send(
|
||||
V,
|
||||
&[Pdu::SerialNotify {
|
||||
session_id: SESSION,
|
||||
serial: 8,
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
connection.received().await;
|
||||
connection
|
||||
.send(
|
||||
V,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
},
|
||||
// Another list for 3333, a new ASPA, and one gone.
|
||||
aspa(3333, &[174, 1101]),
|
||||
aspa(2121, &[3333]),
|
||||
Pdu::Aspa {
|
||||
announce: false,
|
||||
customer: 1101,
|
||||
providers: vec![],
|
||||
},
|
||||
end_of_data(8),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let update = next_update(&mut updates).await;
|
||||
assert_eq!(update.aspas.len(), 2);
|
||||
assert_eq!(update.aspas.providers(3333), Some(&[174, 1101][..]));
|
||||
assert_eq!(update.aspas.providers(1101), None);
|
||||
assert_eq!(
|
||||
update.aspas.verify(&[174, 3333], Algorithm::Upstream),
|
||||
PathValidity::Valid
|
||||
);
|
||||
let changed = update.changed.expect("a change to what there was");
|
||||
assert_eq!(*changed.aspa_customers, [3333, 2121, 1101].into());
|
||||
assert!(changed.vrps.is_empty(), "no VRP came or went");
|
||||
assert_eq!(update.vrps.len(), 1);
|
||||
|
||||
// The withdrawal of an ASPA there is not is a contradiction: nothing
|
||||
// of the answer is used.
|
||||
connection
|
||||
.send(
|
||||
V,
|
||||
&[Pdu::SerialNotify {
|
||||
session_id: SESSION,
|
||||
serial: 9,
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
connection.received().await;
|
||||
connection
|
||||
.send(
|
||||
V,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
},
|
||||
aspa(3333, &[6939]),
|
||||
Pdu::Aspa {
|
||||
announce: false,
|
||||
customer: 1101,
|
||||
providers: vec![],
|
||||
},
|
||||
end_of_data(9),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
connection.received().await.1,
|
||||
Pdu::ErrorReport {
|
||||
code: error_code::WITHDRAWAL_OF_UNKNOWN_RECORD,
|
||||
..
|
||||
}
|
||||
));
|
||||
let mut again = cache.accept().await;
|
||||
assert!(matches!(
|
||||
again.received().await.1,
|
||||
Pdu::SerialQuery { serial: 8, .. }
|
||||
));
|
||||
let current = rpki.current();
|
||||
assert_eq!(current.version, 2);
|
||||
assert_eq!(current.aspas.providers(3333), Some(&[174, 1101][..]));
|
||||
}
|
||||
|
||||
/// A cache that speaks version 1 is spoken to in it. It has no ASPAs to
|
||||
/// give: those that came from a newer cache go when its data takes their
|
||||
/// place.
|
||||
#[tokio::test]
|
||||
async fn a_cache_that_speaks_version_one_has_no_aspas() {
|
||||
let cache = Cache::start().await;
|
||||
let (rpki, mut updates) = client_of(&[&cache]);
|
||||
let mut connection = cache.accept().await;
|
||||
connection.received().await;
|
||||
let ripe = vrp("193.0.0.0/21", 24, 3333);
|
||||
connection
|
||||
.send(
|
||||
V,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
},
|
||||
announce(ripe),
|
||||
aspa(3333, &[1101]),
|
||||
end_of_data(7),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(next_update(&mut updates).await.aspas.len(), 1);
|
||||
|
||||
// The validator is replaced by an older one at the same address.
|
||||
drop(connection);
|
||||
let mut connection = cache.accept().await;
|
||||
assert!(matches!(
|
||||
connection.received().await,
|
||||
(V, Pdu::SerialQuery { serial: 7, .. })
|
||||
));
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
&[Pdu::ErrorReport {
|
||||
code: error_code::UNSUPPORTED_VERSION,
|
||||
text: "version 1 at most".into(),
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
// Asked again in version 1, and for everything: the serial was of
|
||||
// another version's data.
|
||||
let mut connection = cache.accept().await;
|
||||
assert_eq!(connection.received().await, (1, Pdu::ResetQuery));
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
},
|
||||
announce(ripe),
|
||||
end_of_data(3),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let update = next_update(&mut updates).await;
|
||||
assert!(update.aspas.is_empty());
|
||||
assert_eq!(update.vrps.len(), 1);
|
||||
let changed = update.changed.expect("the difference to what there was");
|
||||
assert_eq!(*changed.aspa_customers, [3333].into());
|
||||
assert!(changed.vrps.is_empty());
|
||||
let status = rpki.status();
|
||||
assert_eq!((status.protocol_version, status.aspas), (1, 0));
|
||||
|
||||
// An ASPA in a version that has none is not RTR.
|
||||
connection
|
||||
.stream
|
||||
.write_all(&[1, 11, 1, 0, 0, 0, 0, 16, 0, 0, 0, 1, 0, 0, 0, 2])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
connection.received().await,
|
||||
(
|
||||
1,
|
||||
Pdu::ErrorReport {
|
||||
code: error_code::UNSUPPORTED_PDU_TYPE,
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/// A validator that only speaks version 0 says so, by an error or by
|
||||
/// answering in version 0. The client asks again in version 0.
|
||||
#[tokio::test]
|
||||
@@ -341,7 +567,7 @@ async fn a_cache_that_speaks_version_zero_is_spoken_to_in_it() {
|
||||
let cache = Cache::start().await;
|
||||
let (rpki, mut updates) = client_of(&[&cache]);
|
||||
let mut connection = cache.accept().await;
|
||||
assert_eq!(connection.received().await, (1, Pdu::ResetQuery));
|
||||
assert_eq!(connection.received().await, (V, Pdu::ResetQuery));
|
||||
if refuses_with_an_error {
|
||||
connection
|
||||
.send(
|
||||
@@ -381,7 +607,7 @@ async fn a_cache_that_speaks_version_zero_is_spoken_to_in_it() {
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(next_update(&mut updates).await.table.len(), 1);
|
||||
assert_eq!(next_update(&mut updates).await.vrps.len(), 1);
|
||||
assert_eq!(rpki.status().protocol_version, 0);
|
||||
}
|
||||
}
|
||||
@@ -407,7 +633,7 @@ async fn a_lost_cache_is_replaced_by_the_next_and_the_data_is_kept() {
|
||||
let mut connection = second.accept().await;
|
||||
assert_eq!(connection.received().await.1, Pdu::ResetQuery);
|
||||
// Still in use meanwhile.
|
||||
assert_eq!(rpki.current().table.len(), 1);
|
||||
assert_eq!(rpki.current().vrps.len(), 1);
|
||||
|
||||
connection
|
||||
.send_all(
|
||||
@@ -416,10 +642,10 @@ async fn a_lost_cache_is_replaced_by_the_next_and_the_data_is_kept() {
|
||||
)
|
||||
.await;
|
||||
let update = next_update(&mut updates).await;
|
||||
assert_eq!(update.table.len(), 2);
|
||||
assert_eq!(update.vrps.len(), 2);
|
||||
let changed = update.changed.expect("the difference to what there was");
|
||||
assert!(changed.covers(&prefix("1.1.1.0/24")));
|
||||
assert!(!changed.covers(&prefix("193.0.0.0/21")));
|
||||
assert!(changed.vrps.covers(&prefix("1.1.1.0/24")));
|
||||
assert!(!changed.vrps.covers(&prefix("193.0.0.0/21")));
|
||||
assert_eq!(rpki.status().cache, Some(second.address()));
|
||||
}
|
||||
|
||||
@@ -440,16 +666,16 @@ async fn data_that_is_not_confirmed_in_time_is_dropped() {
|
||||
connection
|
||||
.send_all(&[vrp("193.0.0.0/21", 24, 3333)], 7)
|
||||
.await;
|
||||
assert_eq!(next_update(&mut updates).await.table.len(), 1);
|
||||
assert_eq!(next_update(&mut updates).await.vrps.len(), 1);
|
||||
|
||||
// The cache goes and does not come back.
|
||||
drop(connection);
|
||||
drop(cache);
|
||||
let update = next_update(&mut updates).await;
|
||||
assert!(update.table.is_empty());
|
||||
assert!(update.vrps.is_empty());
|
||||
assert!(update.changed.is_none(), "anything may have changed");
|
||||
assert_eq!(
|
||||
update.table.validate(&prefix("193.0.0.0/21"), Some(15169)),
|
||||
update.vrps.validate(&prefix("193.0.0.0/21"), Some(15169)),
|
||||
Validity::NotFound
|
||||
);
|
||||
let status = rpki.status();
|
||||
@@ -468,7 +694,7 @@ async fn a_cache_that_misbehaves_is_given_up_on() {
|
||||
connection.received().await;
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
V,
|
||||
&[Pdu::ErrorReport {
|
||||
code: error_code::NO_DATA_AVAILABLE,
|
||||
text: "still validating".into(),
|
||||
@@ -486,7 +712,7 @@ async fn a_cache_that_misbehaves_is_given_up_on() {
|
||||
// Half an answer, then noise.
|
||||
connection
|
||||
.send(
|
||||
1,
|
||||
V,
|
||||
&[
|
||||
Pdu::CacheResponse {
|
||||
session_id: SESSION,
|
||||
@@ -502,6 +728,6 @@ async fn a_cache_that_misbehaves_is_given_up_on() {
|
||||
.unwrap();
|
||||
let mut connection = cache.accept().await;
|
||||
assert_eq!(connection.received().await.1, Pdu::ResetQuery);
|
||||
assert!(rpki.current().table.is_empty());
|
||||
assert!(rpki.current().vrps.is_empty());
|
||||
assert_eq!(rpki.version(), 0);
|
||||
}
|
||||
@@ -45,8 +45,8 @@ message BgpConfig {
|
||||
// testing on a closed network and nothing else.
|
||||
bool accept_unconfigured_peers = 9;
|
||||
|
||||
// Route origin validation with the RPKI (RFC 6811). Left out, routes
|
||||
// are not validated.
|
||||
// Route origin validation (RFC 6811) and AS path verification with the
|
||||
// RPKI. Left out, routes are not validated.
|
||||
Rpki rpki = 10;
|
||||
}
|
||||
|
||||
@@ -55,11 +55,17 @@ message BgpConfig {
|
||||
// for its prefix ("invalid") is refused, from every peer that is not in
|
||||
// our own AS and does not have accept_rpki_invalid set. Routes that
|
||||
// nothing is known about are taken as before.
|
||||
//
|
||||
// A validator that speaks version 2 of the protocol also gives the ASPAs,
|
||||
// in which networks say who their providers are. With those, a route
|
||||
// whose AS path cannot have come about by networks passing routes on as
|
||||
// they should (a route leak, or a made-up path) is refused, from every
|
||||
// peer that has a role set and does not have accept_aspa_invalid set.
|
||||
message Rpki {
|
||||
// The RPKI-to-Router (RFC 8210) servers of one or more validators, such
|
||||
// as Routinator, rpki-client with StayRTR, or FORT, in order of
|
||||
// preference. The port is required. One is used at a time, over plain
|
||||
// TCP: keep it on a network you trust.
|
||||
// The RPKI-to-Router servers (RFC 8210, draft-ietf-sidrops-8210bis) of
|
||||
// one or more validators, such as Routinator, rpki-client with StayRTR,
|
||||
// or FORT, in order of preference. The port is required. One is used at
|
||||
// a time, over plain TCP: keep it on a network you trust.
|
||||
repeated TcpEndpoint caches = 1;
|
||||
|
||||
// How often to ask the cache for changes unprompted, how long to wait
|
||||
@@ -139,6 +145,28 @@ message Peer {
|
||||
// Which of the peer's routes are taken. Left out, the general checks
|
||||
// apply and there is no limit and no list.
|
||||
ImportPolicy import = 8;
|
||||
|
||||
// What the peer is to us. It decides how the AS paths of its routes are
|
||||
// verified against the ASPAs, where rpki is configured: a route from a
|
||||
// customer, a peer or a route server must have come up through
|
||||
// providers all the way; one from a provider may have come up and then
|
||||
// down. Left out, the paths of the peer's routes are not verified.
|
||||
Role role = 9;
|
||||
}
|
||||
|
||||
// What a peer is to us.
|
||||
enum Role {
|
||||
ROLE_UNSPECIFIED = 0;
|
||||
// We buy transit from the peer.
|
||||
PROVIDER = 1;
|
||||
// The peer buys transit from us.
|
||||
CUSTOMER = 2;
|
||||
// The peer and we exchange our own and our customers' routes.
|
||||
PEER = 3;
|
||||
// The peer is an Internet exchange's route server.
|
||||
ROUTE_SERVER = 4;
|
||||
// We are a route server and the peer is one of our clients.
|
||||
ROUTE_SERVER_CLIENT = 5;
|
||||
}
|
||||
|
||||
// What is taken from a peer. Every route is judged before it goes into
|
||||
@@ -192,6 +220,11 @@ message ImportPolicy {
|
||||
// By default, where route origin validation is configured (rpki), a
|
||||
// route that is invalid is refused. True takes it all the same.
|
||||
bool accept_rpki_invalid = 11;
|
||||
|
||||
// By default, where the validator gives ASPAs and the peer has a role,
|
||||
// a route whose AS path they find invalid is refused. True takes it all
|
||||
// the same.
|
||||
bool accept_aspa_invalid = 12;
|
||||
}
|
||||
|
||||
// A prefix, and optionally the more specific prefixes inside it.
|
||||
|
||||
@@ -12,7 +12,7 @@ use bgp_rpki::RpkiConfig;
|
||||
|
||||
use crate::error::{ConfigError, ImportProblem, OriginateProblem, PeerProblem};
|
||||
use crate::family::FamilySet;
|
||||
use crate::policy::{ImportPolicy, PrefixSet};
|
||||
use crate::policy::{ImportPolicy, PeerRole, PrefixSet};
|
||||
use crate::proto::config::{self as proto, BgpConfig, OriginatedRoute, Peer};
|
||||
|
||||
const DEFAULT_BGP_PORT: u16 = 179;
|
||||
@@ -64,6 +64,8 @@ pub struct PeerConfig {
|
||||
pub add_path: Option<AddPathSendRecv>,
|
||||
/// Which of the peer's routes are taken.
|
||||
pub import: ImportPolicy,
|
||||
/// What the peer is to us, if that was said.
|
||||
pub role: Option<PeerRole>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -225,6 +227,16 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
|
||||
None => ImportPolicy::default(),
|
||||
};
|
||||
|
||||
let role = match peer.role.enum_value() {
|
||||
Ok(proto::Role::ROLE_UNSPECIFIED) => None,
|
||||
Ok(proto::Role::PROVIDER) => Some(PeerRole::Provider),
|
||||
Ok(proto::Role::CUSTOMER) => Some(PeerRole::Customer),
|
||||
Ok(proto::Role::PEER) => Some(PeerRole::Peer),
|
||||
Ok(proto::Role::ROUTE_SERVER) => Some(PeerRole::RouteServer),
|
||||
Ok(proto::Role::ROUTE_SERVER_CLIENT) => Some(PeerRole::RouteServerClient),
|
||||
Err(_) => return Err(PeerProblem::Role),
|
||||
};
|
||||
|
||||
Ok(PeerConfig {
|
||||
// A peer without a name goes by its address.
|
||||
name: if peer.name.is_empty() {
|
||||
@@ -239,6 +251,7 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
|
||||
families,
|
||||
add_path,
|
||||
import,
|
||||
role,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -323,6 +336,7 @@ fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProb
|
||||
)?,
|
||||
accept_any_first_as: import.accept_any_first_as,
|
||||
accept_rpki_invalid: import.accept_rpki_invalid,
|
||||
accept_aspa_invalid: import.accept_aspa_invalid,
|
||||
max_as_path_length: match import.max_as_path_length {
|
||||
0 => defaults.max_as_path_length,
|
||||
length => u16::try_from(length).map_err(|_| ImportProblem::PathLength(length))?,
|
||||
@@ -737,6 +751,25 @@ mod tests {
|
||||
);
|
||||
|
||||
// A peer can be exempt.
|
||||
// A peer's paths are verified only if it is said what the peer is.
|
||||
let role_of = |body: &str| {
|
||||
parse(&format!(
|
||||
"{BASE}peers {{ endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}"
|
||||
))
|
||||
.map(|config| config.peers[0].role)
|
||||
};
|
||||
assert_eq!(role_of("").unwrap(), None);
|
||||
assert_eq!(role_of("role: CUSTOMER").unwrap(), Some(PeerRole::Customer));
|
||||
assert_eq!(
|
||||
role_of("role: ROUTE_SERVER").unwrap(),
|
||||
Some(PeerRole::RouteServer)
|
||||
);
|
||||
assert!(!import_of("").unwrap().accept_aspa_invalid);
|
||||
assert!(
|
||||
import_of("import { accept_aspa_invalid: true }")
|
||||
.unwrap()
|
||||
.accept_aspa_invalid
|
||||
);
|
||||
assert!(!import_of("").unwrap().accept_rpki_invalid);
|
||||
assert!(
|
||||
import_of("import { accept_rpki_invalid: true }")
|
||||
|
||||
@@ -43,10 +43,10 @@ use crate::metrics::{
|
||||
SESSIONS_ESTABLISHED_TOTAL, UPDATE_ERRORS, message_type_name,
|
||||
};
|
||||
use crate::panic::contained;
|
||||
use crate::policy::{self, HoldDowns, ImportPolicy};
|
||||
use crate::policy::{self, HoldDowns, ImportPolicy, PeerRole};
|
||||
use crate::refusals::{PeerRefusals, RefusalLog};
|
||||
use crate::routes::{self, Held};
|
||||
use crate::rpki::Validator;
|
||||
use crate::rpki::{Checks, Subject, Validator};
|
||||
use crate::session::{PeerId, SessionCounters, SessionGuard, SessionHandle, SessionRegistry};
|
||||
|
||||
/// Number of outbound messages that may be queued for a session before
|
||||
@@ -97,6 +97,8 @@ pub struct PeerSettings {
|
||||
pub add_path: Option<AddPathSendRecv>,
|
||||
/// Which of the peer's routes are taken.
|
||||
pub import: Arc<ImportPolicy>,
|
||||
/// What the peer is to us, if that is known.
|
||||
pub role: Option<PeerRole>,
|
||||
}
|
||||
|
||||
impl PeerSettings {
|
||||
@@ -111,6 +113,7 @@ impl PeerSettings {
|
||||
families: FamilySet::supported(),
|
||||
add_path: None,
|
||||
import: Arc::new(ImportPolicy::permissive()),
|
||||
role: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +127,7 @@ impl From<&PeerConfig> for PeerSettings {
|
||||
families: peer.families.clone(),
|
||||
add_path: peer.add_path,
|
||||
import: Arc::new(peer.import.clone()),
|
||||
role: peer.role,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +221,7 @@ struct Established {
|
||||
counters: Arc<SessionCounters>,
|
||||
/// The same number of routes, where metrics are read from.
|
||||
routes_metric: IntGauge,
|
||||
/// Route origin validation for the peer's routes, if they are subject
|
||||
/// Validation of the peer's routes with the RPKI, if they are subject
|
||||
/// to it: the data to validate against, and where the session is told
|
||||
/// that it has changed.
|
||||
validation: Option<Validation>,
|
||||
@@ -366,6 +370,8 @@ struct Session {
|
||||
peer_name: Option<String>,
|
||||
/// Which of the peer's routes are taken.
|
||||
import: Arc<ImportPolicy>,
|
||||
/// What the peer is to us, if that is known.
|
||||
role: Option<PeerRole>,
|
||||
/// Where what is refused is noted.
|
||||
refusals: Arc<PeerRefusals>,
|
||||
}
|
||||
@@ -415,6 +421,7 @@ impl Session {
|
||||
server,
|
||||
peer_name: peer.name,
|
||||
import: peer.import,
|
||||
role: peer.role,
|
||||
};
|
||||
Ok((session, first_actions))
|
||||
}
|
||||
@@ -667,17 +674,24 @@ impl Session {
|
||||
rib_peer,
|
||||
outbound_tx,
|
||||
);
|
||||
handle.role = self.role;
|
||||
// Routes from other networks are validated, where there is data
|
||||
// to validate against and the peer's policy does not say to take
|
||||
// invalid ones anyway.
|
||||
let subject = Subject::of(
|
||||
&self.import,
|
||||
self.role,
|
||||
negotiated.peer_asn,
|
||||
negotiated.ibgp,
|
||||
);
|
||||
let validation = self
|
||||
.server
|
||||
.rpki
|
||||
.clone()
|
||||
.filter(|_| !negotiated.ibgp && !self.import.accept_rpki_invalid)
|
||||
.map(|rpki| Validation {
|
||||
validator: Validator::new(rpki),
|
||||
changes: handle.validated(),
|
||||
.zip(subject)
|
||||
.map(|(rpki, subject)| Validation {
|
||||
validator: Validator::new(rpki, subject),
|
||||
changes: handle.validate(subject),
|
||||
});
|
||||
self.established = Some(Established {
|
||||
validation,
|
||||
@@ -715,10 +729,10 @@ impl Session {
|
||||
peer: established.rib_peer,
|
||||
from: established.from,
|
||||
policy: &self.import,
|
||||
vrps: established
|
||||
checks: established
|
||||
.validation
|
||||
.as_mut()
|
||||
.map(|validation| validation.validator.table()),
|
||||
.map_or(Checks::NONE, |validation| validation.validator.checks()),
|
||||
refusals: &self.refusals,
|
||||
};
|
||||
let routes::Applied { outcome, rejected } = routes::apply(
|
||||
@@ -753,10 +767,10 @@ impl Session {
|
||||
peer: established.rib_peer,
|
||||
from: established.from,
|
||||
policy: &self.import,
|
||||
vrps: established
|
||||
checks: established
|
||||
.validation
|
||||
.as_mut()
|
||||
.map(|validation| validation.validator.table()),
|
||||
.map_or(Checks::NONE, |validation| validation.validator.checks()),
|
||||
refusals: &self.refusals,
|
||||
};
|
||||
let routes::Applied { outcome, rejected } =
|
||||
@@ -1176,7 +1190,7 @@ mod tests {
|
||||
four_octet_asn: true,
|
||||
},
|
||||
policy: &ImportPolicy::permissive(),
|
||||
vrps: None,
|
||||
checks: Checks::NONE,
|
||||
refusals: &RefusalLog::default().of_peer(Ipv4Addr::new(192, 0, 2, 1).into(), "test"),
|
||||
};
|
||||
routes::apply(&table, &import, &mut Held::default(), &update, None).unwrap();
|
||||
|
||||
@@ -179,6 +179,8 @@ pub enum PeerProblem {
|
||||
AddressFamily,
|
||||
#[error("add_path has a value that is not one of its settings")]
|
||||
AddPath,
|
||||
#[error("role has a value that is not one of its settings")]
|
||||
Role,
|
||||
#[error("import: {0}")]
|
||||
Import(#[from] ImportProblem),
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ mod panic;
|
||||
pub mod policy;
|
||||
pub mod refusals;
|
||||
mod routes;
|
||||
mod rpki;
|
||||
pub mod rpki;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
mod sync;
|
||||
|
||||
@@ -96,14 +96,19 @@ pub static RPKI_VRPS: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RPKI_UPDATES: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"bgp_rpki_updates_total",
|
||||
"Times the validated ROA payloads changed"
|
||||
pub static RPKI_ASPAS: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!(
|
||||
"bgp_rpki_aspas",
|
||||
"Customer ASes with an ASPA in use for AS path verification"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static RPKI_UPDATES: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!("bgp_rpki_updates_total", "Times the VRPs or ASPAs changed")
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static CONNECTIONS_REFUSED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_connections_refused_total",
|
||||
|
||||
+110
-1
@@ -78,6 +78,8 @@ pub struct ImportPolicy {
|
||||
pub origin_asns: Option<HashSet<u32>>,
|
||||
/// Take routes that route origin validation finds invalid (RFC 6811).
|
||||
pub accept_rpki_invalid: bool,
|
||||
/// Take routes whose path the ASPAs find invalid.
|
||||
pub accept_aspa_invalid: bool,
|
||||
}
|
||||
|
||||
impl Default for ImportPolicy {
|
||||
@@ -96,6 +98,7 @@ impl Default for ImportPolicy {
|
||||
prefixes: None,
|
||||
origin_asns: None,
|
||||
accept_rpki_invalid: false,
|
||||
accept_aspa_invalid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,7 @@ impl ImportPolicy {
|
||||
accept_any_first_as: true,
|
||||
max_as_path_length: u16::MAX,
|
||||
accept_rpki_invalid: true,
|
||||
accept_aspa_invalid: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
@@ -143,10 +147,13 @@ pub enum Rejection {
|
||||
/// The holder of the addresses has said who may originate routes for
|
||||
/// them, and it is not this route's origin (RFC 6811).
|
||||
RpkiInvalid,
|
||||
/// A network on the path has said who its providers are, and the path
|
||||
/// does not go by them: a route leak, or a path that was made up.
|
||||
AspaInvalid,
|
||||
}
|
||||
|
||||
impl Rejection {
|
||||
pub const ALL: [Self; 10] = [
|
||||
pub const ALL: [Self; 11] = [
|
||||
Self::BogonPrefix,
|
||||
Self::TooShort,
|
||||
Self::TooSpecific,
|
||||
@@ -157,6 +164,7 @@ impl Rejection {
|
||||
Self::PathTooLong,
|
||||
Self::OriginNotAllowed,
|
||||
Self::RpkiInvalid,
|
||||
Self::AspaInvalid,
|
||||
];
|
||||
|
||||
/// Where the reason is in [`Rejection::ALL`].
|
||||
@@ -176,6 +184,38 @@ impl Rejection {
|
||||
Self::PathTooLong => "path too long",
|
||||
Self::OriginNotAllowed => "origin AS not allowed",
|
||||
Self::RpkiInvalid => "RPKI invalid",
|
||||
Self::AspaInvalid => "ASPA invalid",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a peer is to us, in the business between networks. It decides
|
||||
/// what its routes may look like: a customer's routes have come up to us
|
||||
/// through its own customers only, while a provider's may have been
|
||||
/// anywhere.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PeerRole {
|
||||
/// We buy transit from the peer.
|
||||
Provider,
|
||||
/// The peer buys transit from us.
|
||||
Customer,
|
||||
/// The peer and we exchange our own and our customers' routes.
|
||||
Peer,
|
||||
/// The peer is an Internet exchange's route server, and we are one of
|
||||
/// its clients.
|
||||
RouteServer,
|
||||
/// We are a route server, and the peer is one of our clients.
|
||||
RouteServerClient,
|
||||
}
|
||||
|
||||
impl PeerRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Provider => "provider",
|
||||
Self::Customer => "customer",
|
||||
Self::Peer => "peer",
|
||||
Self::RouteServer => "route server",
|
||||
Self::RouteServerClient => "route server client",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +359,34 @@ impl PathFacts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the AS numbers of the path that `attributes` describe in `out`,
|
||||
/// in place of what was there, as [`PathFacts`] reads it: the neighbor's
|
||||
/// first, the origin's last, sets and all. Returns whether the path has a
|
||||
/// set in it.
|
||||
pub(crate) fn effective_path(attributes: &[PathAttribute], out: &mut Vec<u32>) -> bool {
|
||||
let find = |wide: bool| {
|
||||
attributes.iter().find_map(|attribute| match attribute {
|
||||
PathAttribute::AsPath(path) if !wide => Some(path),
|
||||
PathAttribute::As4Path(path) if wide => Some(path),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (path, as4_path) = (find(false), find(true));
|
||||
let length = asns(path).count();
|
||||
let wide = asns(as4_path).count();
|
||||
let (kept, tail) = if wide <= length {
|
||||
(length - wide, as4_path)
|
||||
} else {
|
||||
(length, None)
|
||||
};
|
||||
out.clear();
|
||||
out.extend(asns(path).take(kept).chain(asns(tail)));
|
||||
path.into_iter()
|
||||
.chain(tail)
|
||||
.flat_map(|path| &path.segments)
|
||||
.any(|segment| !segment.ordered)
|
||||
}
|
||||
|
||||
/// The AS numbers of a path in order, sets and all.
|
||||
pub(crate) fn asns(path: Option<&AsPathAttribute>) -> impl Iterator<Item = u32> + '_ {
|
||||
path.into_iter()
|
||||
@@ -741,6 +809,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_is_read_with_its_as4_path_and_its_sets() {
|
||||
let mut out = vec![99];
|
||||
assert!(!effective_path(&path(&[3333, 1299]), &mut out));
|
||||
assert_eq!(out, [3333, 1299]);
|
||||
|
||||
let sequence = |path: Vec<u32>| AsPathSegment {
|
||||
ordered: true,
|
||||
path,
|
||||
};
|
||||
let mut attributes = path(&[3333, 23456, 23456]);
|
||||
attributes.push(PathAttribute::As4Path(AsPathAttribute {
|
||||
segments: vec![sequence(vec![210036, 4_200_000_001])],
|
||||
}));
|
||||
assert!(!effective_path(&attributes, &mut out));
|
||||
assert_eq!(out, [3333, 210036, 4_200_000_001]);
|
||||
|
||||
// An AS4_PATH longer than the path is not used.
|
||||
let mut attributes = path(&[3333]);
|
||||
attributes.push(PathAttribute::As4Path(AsPathAttribute {
|
||||
segments: vec![sequence(vec![1, 2])],
|
||||
}));
|
||||
assert!(!effective_path(&attributes, &mut out));
|
||||
assert_eq!(out, [3333]);
|
||||
|
||||
let with_set = vec![PathAttribute::AsPath(AsPathAttribute {
|
||||
segments: vec![
|
||||
sequence(vec![3333]),
|
||||
AsPathSegment {
|
||||
ordered: false,
|
||||
path: vec![64500, 64501],
|
||||
},
|
||||
],
|
||||
})];
|
||||
assert!(effective_path(&with_set, &mut out));
|
||||
assert_eq!(out, [3333, 64500, 64501]);
|
||||
|
||||
assert!(!effective_path(&[], &mut out));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurdly_long_path_is_refused() {
|
||||
let policy = ImportPolicy::default();
|
||||
|
||||
+50
-35
@@ -10,10 +10,11 @@ use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage};
|
||||
use bgp_primitives::ip_prefix::Nlri;
|
||||
use bgp_primitives::parser::EncodeError;
|
||||
use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable};
|
||||
use bgp_rpki::{Validity, VrpTable};
|
||||
use bgp_rpki::{PathValidity, Validity};
|
||||
|
||||
use crate::policy::{self, From, ImportPolicy, Rejection};
|
||||
use crate::refusals::PeerRefusals;
|
||||
use crate::rpki::Checks;
|
||||
|
||||
/// The most routes of one peer that are held aside as invalid. Far more
|
||||
/// than a peer sending the whole Internet has; a bound all the same.
|
||||
@@ -34,15 +35,15 @@ pub(crate) struct Import<'a> {
|
||||
pub(crate) peer: PeerIndex,
|
||||
pub(crate) from: From,
|
||||
pub(crate) policy: &'a ImportPolicy,
|
||||
/// What the RPKI says about who may originate what, if this peer's
|
||||
/// routes are validated against it.
|
||||
pub(crate) vrps: Option<&'a VrpTable>,
|
||||
/// What the peer's routes are validated against, with the data of the
|
||||
/// RPKI.
|
||||
pub(crate) checks: Checks<'a>,
|
||||
/// Where what is refused is noted.
|
||||
pub(crate) refusals: &'a PeerRefusals,
|
||||
}
|
||||
|
||||
/// A peer's routes that are not in the routing table because route origin
|
||||
/// validation found them invalid.
|
||||
/// A peer's routes that are not in the routing table because the RPKI's
|
||||
/// data found them invalid: their origin (RFC 6811) or their path (ASPA).
|
||||
///
|
||||
/// Every other reason for refusing a route is a matter of the route and of
|
||||
/// our configuration, and stays true for as long as the session lasts.
|
||||
@@ -100,7 +101,7 @@ impl Held {
|
||||
///
|
||||
/// A route the policy refuses is withdrawn instead: whatever the peer had
|
||||
/// announced for it before is replaced by this, and this is not taken. One
|
||||
/// refused as invalid by route origin validation is kept in `held`.
|
||||
/// refused as invalid by the RPKI's data is kept in `held`.
|
||||
///
|
||||
/// `attributes_wire` is the attributes as the decoder kept them, if it did
|
||||
/// (see `DecodedUpdate::attributes_wire`). With it the table finds the
|
||||
@@ -133,6 +134,9 @@ pub(crate) fn apply(
|
||||
let path_verdict = import
|
||||
.policy
|
||||
.check_path(&update.path_attributes, &import.from);
|
||||
// Whether the ASPAs find the path invalid: asked only if a route gets
|
||||
// as far as that mattering.
|
||||
let path_invalid = OnceCell::new();
|
||||
|
||||
let attributes = |next_hop: Option<&NlriNextHop>| -> Result<Arc<AttrSet>, EncodeError> {
|
||||
if let Some(set) =
|
||||
@@ -168,13 +172,19 @@ pub(crate) fn apply(
|
||||
nlri,
|
||||
|route| {
|
||||
import.policy.check_prefix(&route.prefix)?;
|
||||
match import.vrps.map(|vrps| vrps.validate(&route.prefix, origin)) {
|
||||
Some(Validity::Invalid) => {
|
||||
invalid.push(*route);
|
||||
Err(Rejection::RpkiInvalid)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
let origin_invalid = import
|
||||
.checks
|
||||
.vrps
|
||||
.is_some_and(|vrps| vrps.validate(&route.prefix, origin) == Validity::Invalid);
|
||||
let rejection = if origin_invalid {
|
||||
Rejection::RpkiInvalid
|
||||
} else if *path_invalid.get_or_init(|| path_is_invalid(import, update)) {
|
||||
Rejection::AspaInvalid
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
invalid.push(*route);
|
||||
Err(rejection)
|
||||
},
|
||||
&mut refuse,
|
||||
);
|
||||
@@ -202,6 +212,22 @@ pub(crate) fn apply(
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
/// Whether the ASPAs find the path of `update`'s routes invalid.
|
||||
fn path_is_invalid(import: &Import<'_>, update: &UpdateMessage) -> bool {
|
||||
let Some((aspas, check)) = import.checks.aspas else {
|
||||
return false;
|
||||
};
|
||||
// Without ASPAs nothing is known about any path, and there are none
|
||||
// until a validator that has them is found: not worth reading the
|
||||
// path for.
|
||||
if aspas.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut path = Vec::new();
|
||||
let has_set = policy::effective_path(&update.path_attributes, &mut path);
|
||||
check.verify(aspas, &path, has_set) == PathValidity::Invalid
|
||||
}
|
||||
|
||||
/// The AS numbers of the UPDATE's AS_PATH.
|
||||
fn path_of(update: &UpdateMessage) -> Arc<[u32]> {
|
||||
let path = update
|
||||
@@ -255,13 +281,11 @@ pub(crate) fn revalidate(
|
||||
suspects: &[Nlri],
|
||||
) -> Applied {
|
||||
let mut applied = Applied::default();
|
||||
let Some(vrps) = import.vrps else {
|
||||
let checks = &import.checks;
|
||||
if checks.vrps.is_none() && checks.aspas.is_none() {
|
||||
return applied;
|
||||
};
|
||||
let validity = |route: &Nlri, attrs: &AttrSet| {
|
||||
let origin = Some(attrs.key().origin_as).filter(|origin| *origin != 0);
|
||||
vrps.validate(&route.prefix, origin)
|
||||
};
|
||||
}
|
||||
let mut path = Vec::new();
|
||||
|
||||
for suspect in suspects {
|
||||
// As it is in the table now.
|
||||
@@ -272,25 +296,16 @@ pub(crate) fn revalidate(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if validity(suspect, &route.attrs) != Validity::Invalid {
|
||||
let Some(rejection) = checks.rejection(&suspect.prefix, &route.attrs, &mut path) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
applied.outcome += table.withdraw(import.peer, std::slice::from_ref(suspect));
|
||||
applied.rejected += 1;
|
||||
let as_path: Arc<[u32]> = match route.attrs.attributes() {
|
||||
Some((attributes, _)) => {
|
||||
let path = attributes.iter().find_map(|attribute| match attribute {
|
||||
PathAttribute::AsPath(path) => Some(path),
|
||||
_ => None,
|
||||
});
|
||||
policy::asns(path).collect()
|
||||
}
|
||||
None => Arc::default(),
|
||||
};
|
||||
route.attrs.as_path(&mut path);
|
||||
import.refusals.note(
|
||||
std::slice::from_ref(suspect),
|
||||
Rejection::RpkiInvalid,
|
||||
&as_path,
|
||||
rejection,
|
||||
&Arc::from(&path[..]),
|
||||
);
|
||||
held.hold(*suspect, &route.attrs);
|
||||
}
|
||||
@@ -298,7 +313,7 @@ pub(crate) fn revalidate(
|
||||
let released: Vec<Nlri> = held
|
||||
.routes
|
||||
.iter()
|
||||
.filter(|(route, attrs)| validity(route, attrs) != Validity::Invalid)
|
||||
.filter(|(route, attrs)| checks.rejection(&route.prefix, attrs, &mut path).is_none())
|
||||
.map(|(route, _)| *route)
|
||||
.collect();
|
||||
for route in released {
|
||||
|
||||
+350
-59
@@ -1,26 +1,34 @@
|
||||
//! Route origin validation in the server: where a session gets the data it
|
||||
//! validates against, and what happens to routes already taken when that
|
||||
//! data changes.
|
||||
//! Validation with the RPKI in the server: where a session gets the data
|
||||
//! it validates against, and what happens to routes already taken when
|
||||
//! that data changes.
|
||||
//!
|
||||
//! Two things are asked of a route. Whether its origin may announce its
|
||||
//! prefix (RFC 6811), which the validated ROA payloads say; and whether
|
||||
//! its path could have come about by networks passing routes on as they
|
||||
//! should (draft-ietf-sidrops-aspa-verification), which the ASPAs say. A
|
||||
//! route that fails either is not taken.
|
||||
//!
|
||||
//! A route is validated when it arrives (see [`crate::routes`]). But
|
||||
//! whether a route is valid is not settled once: the validated ROA
|
||||
//! payloads change every few minutes, and each change can make routes that
|
||||
//! are in the table invalid, and routes that were refused as invalid
|
||||
//! acceptable. The [revalidator](spawn_revalidator) deals with the first
|
||||
//! by looking through the table; each session deals with the second, from
|
||||
//! the routes it has [held](crate::routes::Held).
|
||||
//! whether a route is valid is not settled once: the data changes every
|
||||
//! few minutes, and each change can make routes that are in the table
|
||||
//! invalid, and routes that were refused as invalid acceptable. The
|
||||
//! [revalidator](spawn_revalidator) deals with the first by looking
|
||||
//! through the table; each session deals with the second, from the routes
|
||||
//! it has [held](crate::routes::Held).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_primitives::ip_prefix::Nlri;
|
||||
use bgp_rib::table::{PeerIndex, RibTable};
|
||||
use bgp_rpki::{Rpki, Update, Validity, VrpTable};
|
||||
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
||||
use bgp_rib::table::{AttrSet, PeerIndex, RibTable};
|
||||
use bgp_rpki::{Algorithm, AspaTable, Changed, PathValidity, Rpki, Update, Validity, VrpTable};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::metrics::{RPKI_UPDATES, RPKI_VRPS};
|
||||
use crate::metrics::{RPKI_ASPAS, RPKI_UPDATES, RPKI_VRPS};
|
||||
use crate::policy::{ImportPolicy, PeerRole, Rejection};
|
||||
use crate::session::{SessionHandle, SessionRegistry};
|
||||
|
||||
/// How long after new data is published the table is looked through. A
|
||||
@@ -29,33 +37,153 @@ use crate::session::{SessionHandle, SessionRegistry};
|
||||
/// through the table sees what it put there.
|
||||
const SETTLE: Duration = Duration::from_millis(200);
|
||||
|
||||
/// A session's view of the validation data: the table in use, replaced
|
||||
/// when a newer one is published. Asking whether there is a newer one
|
||||
/// costs the load of one number, which is done for every UPDATE.
|
||||
pub(crate) struct Validator {
|
||||
rpki: Rpki,
|
||||
table: Arc<VrpTable>,
|
||||
version: u64,
|
||||
/// How the AS paths of a peer's routes are verified against the ASPAs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PathCheck {
|
||||
algorithm: Algorithm,
|
||||
/// The AS a path has to begin with: the peer's, unless the peer is a
|
||||
/// route server, which does not put its own on the path.
|
||||
first_as: Option<u32>,
|
||||
}
|
||||
|
||||
impl Validator {
|
||||
pub(crate) fn new(rpki: Rpki) -> Self {
|
||||
let Update { version, table, .. } = rpki.current();
|
||||
impl PathCheck {
|
||||
/// For routes from the peer with AS number `peer_asn`, which is
|
||||
/// `role` to us (§6 of the draft).
|
||||
pub fn new(role: PeerRole, peer_asn: u32) -> Self {
|
||||
let (algorithm, first_as) = match role {
|
||||
PeerRole::Customer | PeerRole::Peer | PeerRole::RouteServerClient => {
|
||||
(Algorithm::Upstream, Some(peer_asn))
|
||||
}
|
||||
PeerRole::RouteServer => (Algorithm::Upstream, None),
|
||||
PeerRole::Provider => (Algorithm::Downstream, Some(peer_asn)),
|
||||
};
|
||||
Self {
|
||||
rpki,
|
||||
table,
|
||||
version,
|
||||
algorithm,
|
||||
first_as,
|
||||
}
|
||||
}
|
||||
|
||||
/// The table to validate against now.
|
||||
pub(crate) fn table(&mut self) -> &VrpTable {
|
||||
if self.rpki.version() != self.version {
|
||||
let Update { version, table, .. } = self.rpki.current();
|
||||
self.table = table;
|
||||
self.version = version;
|
||||
/// What `aspas` say about `path`: its AS numbers in order, the
|
||||
/// neighbor's first, and whether there is a set among them.
|
||||
pub fn verify(&self, aspas: &AspaTable, path: &[u32], has_set: bool) -> PathValidity {
|
||||
// Without ASPAs nothing is known about any path.
|
||||
if aspas.is_empty() {
|
||||
return PathValidity::Unknown;
|
||||
}
|
||||
&self.table
|
||||
// What a set stands for cannot be verified, and sets are no
|
||||
// longer to be used (RFC 9774).
|
||||
if has_set || path.is_empty() {
|
||||
return PathValidity::Invalid;
|
||||
}
|
||||
if self.first_as.is_some_and(|first| path[0] != first) {
|
||||
return PathValidity::Invalid;
|
||||
}
|
||||
aspas.verify(path, self.algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the RPKI's checks a peer's routes are subject to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Subject {
|
||||
/// Whether invalid origins are refused.
|
||||
pub origin: bool,
|
||||
/// Whether invalid paths are refused, and how paths are verified.
|
||||
pub path: Option<PathCheck>,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
/// For a peer with the given policy and role; `None` if its routes
|
||||
/// are subject to neither check. Routes from our own AS are not: the
|
||||
/// checks are for where routes come into it.
|
||||
pub(crate) fn of(
|
||||
policy: &ImportPolicy,
|
||||
role: Option<PeerRole>,
|
||||
peer_asn: u32,
|
||||
ibgp: bool,
|
||||
) -> Option<Self> {
|
||||
let subject = Self {
|
||||
origin: !policy.accept_rpki_invalid,
|
||||
path: role
|
||||
.filter(|_| !policy.accept_aspa_invalid)
|
||||
.map(|role| PathCheck::new(role, peer_asn)),
|
||||
};
|
||||
(!ibgp && (subject.origin || subject.path.is_some())).then_some(subject)
|
||||
}
|
||||
}
|
||||
|
||||
/// The data to validate one peer's routes against: each part is there if
|
||||
/// the peer's routes are subject to that check.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Checks<'a> {
|
||||
pub(crate) vrps: Option<&'a VrpTable>,
|
||||
pub(crate) aspas: Option<(&'a AspaTable, PathCheck)>,
|
||||
}
|
||||
|
||||
impl<'a> Checks<'a> {
|
||||
/// No checks: everything passes.
|
||||
pub(crate) const NONE: Self = Self {
|
||||
vrps: None,
|
||||
aspas: None,
|
||||
};
|
||||
|
||||
fn new(subject: Subject, data: &'a Update) -> Self {
|
||||
Self {
|
||||
vrps: subject.origin.then_some(&*data.vrps),
|
||||
aspas: subject.path.map(|check| (&*data.aspas, check)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a route for `prefix` with the attributes `attrs` is invalid,
|
||||
/// if it is. `path` is room to read the AS path into.
|
||||
pub(crate) fn rejection(
|
||||
&self,
|
||||
prefix: &IpPrefix,
|
||||
attrs: &AttrSet,
|
||||
path: &mut Vec<u32>,
|
||||
) -> Option<Rejection> {
|
||||
if let Some(vrps) = self.vrps {
|
||||
let origin = Some(attrs.key().origin_as).filter(|origin| *origin != 0);
|
||||
if vrps.validate(prefix, origin) == Validity::Invalid {
|
||||
return Some(Rejection::RpkiInvalid);
|
||||
}
|
||||
}
|
||||
if let Some((aspas, check)) = self.aspas
|
||||
&& !aspas.is_empty()
|
||||
{
|
||||
let has_set = attrs.as_path(path);
|
||||
if check.verify(aspas, path, has_set) == PathValidity::Invalid {
|
||||
return Some(Rejection::AspaInvalid);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A session's view of the validation data: the data in use, replaced when
|
||||
/// newer is published. Asking whether there is newer costs the load of one
|
||||
/// number, which is done for every UPDATE.
|
||||
pub(crate) struct Validator {
|
||||
rpki: Rpki,
|
||||
subject: Subject,
|
||||
data: Update,
|
||||
}
|
||||
|
||||
impl Validator {
|
||||
pub(crate) fn new(rpki: Rpki, subject: Subject) -> Self {
|
||||
let data = rpki.current();
|
||||
Self {
|
||||
rpki,
|
||||
subject,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// What to validate against now.
|
||||
pub(crate) fn checks(&mut self) -> Checks<'_> {
|
||||
if self.rpki.version() != self.data.version {
|
||||
self.data = self.rpki.current();
|
||||
}
|
||||
Checks::new(self.subject, &self.data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +201,11 @@ pub(crate) fn spawn_revalidator(
|
||||
// Ends only if the client that publishes the data has gone, which
|
||||
// the server notices for itself.
|
||||
while updates.changed().await.is_ok() {
|
||||
let update = updates.borrow_and_update().clone();
|
||||
let mut update = updates.borrow_and_update().clone();
|
||||
// What changed is known only from one version to the next.
|
||||
let changed = match update.changed {
|
||||
Some(changed) if update.version == seen + 1 => Some(changed),
|
||||
_ => None,
|
||||
};
|
||||
if update.version != seen + 1 {
|
||||
update.changed = None;
|
||||
}
|
||||
seen = update.version;
|
||||
|
||||
let status = rpki.status();
|
||||
@@ -88,23 +215,33 @@ pub(crate) fn spawn_revalidator(
|
||||
RPKI_VRPS
|
||||
.with_label_values(&["ipv6"])
|
||||
.set(status.vrps_ipv6 as i64);
|
||||
RPKI_ASPAS.set(status.aspas as i64);
|
||||
RPKI_UPDATES.inc();
|
||||
|
||||
tokio::time::sleep(SETTLE).await;
|
||||
let sessions: Vec<SessionHandle> = registry
|
||||
let sessions: Vec<(SessionHandle, Subject)> = registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter(SessionHandle::is_validated)
|
||||
.filter_map(|session| {
|
||||
let subject = session.validated()?;
|
||||
Some((session, subject))
|
||||
})
|
||||
.collect();
|
||||
let peers: Vec<PeerIndex> = sessions.iter().map(|session| session.rib_peer).collect();
|
||||
let table = Arc::clone(&update.table);
|
||||
// By peer, for looking up as often as there are routes.
|
||||
let mut subjects: Vec<Option<Subject>> = Vec::new();
|
||||
for (session, subject) in &sessions {
|
||||
let at = session.rib_peer.as_usize();
|
||||
if subjects.len() <= at {
|
||||
subjects.resize(at + 1, None);
|
||||
}
|
||||
subjects[at] = Some(*subject);
|
||||
}
|
||||
let (vrps, aspas) = (update.vrps.len(), update.aspas.len());
|
||||
let rib = Arc::clone(&rib);
|
||||
// Looks at every route, or at every prefix at least: not for a
|
||||
// thread that sessions run on.
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
newly_invalid(&rib, &table, changed.as_deref(), &peers)
|
||||
})
|
||||
.await;
|
||||
let found =
|
||||
tokio::task::spawn_blocking(move || newly_invalid(&rib, &update, &subjects)).await;
|
||||
let mut suspects = match found {
|
||||
Ok(suspects) => suspects,
|
||||
Err(e) => {
|
||||
@@ -113,36 +250,65 @@ pub(crate) fn spawn_revalidator(
|
||||
}
|
||||
};
|
||||
info!(
|
||||
vrps = update.table.len(),
|
||||
vrps,
|
||||
aspas,
|
||||
now_invalid = suspects.values().map(Vec::len).sum::<usize>(),
|
||||
sessions = sessions.len(),
|
||||
"RPKI data changed; revalidating routes"
|
||||
);
|
||||
// Every session, also those with nothing found: each has its
|
||||
// held routes to look at again.
|
||||
for session in sessions {
|
||||
for (session, _) in sessions {
|
||||
session.revalidate(suspects.remove(&session.rib_peer).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The routes of `peers` in the table that `vrps` finds invalid, by peer.
|
||||
/// With `changed`, only routes that the VRPs in it cover are looked at: no
|
||||
/// others can have become invalid.
|
||||
/// The routes in the table that `data` finds invalid, by peer. `subjects`
|
||||
/// says, for each peer by its number in the table, which checks its routes
|
||||
/// are subject to.
|
||||
///
|
||||
/// Where it is known what changed in the data, only routes that the change
|
||||
/// can have made invalid are looked at: those for prefixes that a VRP that
|
||||
/// came or went covers, and those with an AS on their path whose ASPA
|
||||
/// changed.
|
||||
fn newly_invalid(
|
||||
rib: &RibTable,
|
||||
vrps: &VrpTable,
|
||||
changed: Option<&VrpTable>,
|
||||
peers: &[PeerIndex],
|
||||
data: &Update,
|
||||
subjects: &[Option<Subject>],
|
||||
) -> HashMap<PeerIndex, Vec<Nlri>> {
|
||||
let changed = data.changed.as_ref();
|
||||
let vrps_may_matter = changed.is_none_or(|changed| !changed.vrps.is_empty());
|
||||
let aspas_may_matter =
|
||||
!data.aspas.is_empty() && changed.is_none_or(|changed| !changed.aspa_customers.is_empty());
|
||||
let on_changed_path = |changed: &Changed, path: &[u32]| {
|
||||
path.iter().any(|asn| changed.aspa_customers.contains(asn))
|
||||
};
|
||||
let path = RefCell::new(Vec::new());
|
||||
|
||||
let routes = rib.routes_where(usize::MAX, |route| {
|
||||
changed.is_none_or(|changed| changed.covers(&route.prefix))
|
||||
&& peers.contains(&route.peer)
|
||||
&& {
|
||||
let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0);
|
||||
vrps.validate(&route.prefix, origin) == Validity::Invalid
|
||||
let Some(Some(subject)) = subjects.get(route.peer.as_usize()) else {
|
||||
return false;
|
||||
};
|
||||
if subject.origin
|
||||
&& vrps_may_matter
|
||||
&& changed.is_none_or(|changed| changed.vrps.covers(&route.prefix))
|
||||
{
|
||||
let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0);
|
||||
if data.vrps.validate(&route.prefix, origin) == Validity::Invalid {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(check) = subject.path
|
||||
&& aspas_may_matter
|
||||
{
|
||||
let path = &mut *path.borrow_mut();
|
||||
let has_set = route.attrs.as_path(path);
|
||||
return changed.is_none_or(|changed| on_changed_path(changed, path))
|
||||
&& check.verify(&data.aspas, path, has_set) == PathValidity::Invalid;
|
||||
}
|
||||
false
|
||||
});
|
||||
let mut by_peer: HashMap<PeerIndex, Vec<Nlri>> = HashMap::new();
|
||||
for route in routes {
|
||||
@@ -153,3 +319,128 @@ fn newly_invalid(
|
||||
}
|
||||
by_peer
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn aspas() -> AspaTable {
|
||||
[(3333, vec![1101]), (1101, vec![0]), (1299, vec![0])]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_peer_is_decides_how_its_paths_are_verified() {
|
||||
let aspas = aspas();
|
||||
// 3333's route by way of its provider 1101, passed on by 1101's
|
||||
// peer 1299: a path that goes up and then across.
|
||||
let across = [1299, 1101, 3333];
|
||||
// From a provider it may have been anywhere above us.
|
||||
let provider = PathCheck::new(PeerRole::Provider, 1299);
|
||||
assert_eq!(provider.verify(&aspas, &across, false), PathValidity::Valid);
|
||||
// A customer or a peer may not hand us what it got from a peer.
|
||||
for role in [
|
||||
PeerRole::Customer,
|
||||
PeerRole::Peer,
|
||||
PeerRole::RouteServerClient,
|
||||
] {
|
||||
let check = PathCheck::new(role, 1299);
|
||||
assert_eq!(
|
||||
check.verify(&aspas, &across, false),
|
||||
PathValidity::Invalid,
|
||||
"{role:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
check.verify(&aspas, &[1299], false),
|
||||
PathValidity::Valid,
|
||||
"{role:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_must_begin_with_the_peer_unless_it_is_a_route_server() {
|
||||
let aspas = aspas();
|
||||
let up = [1101, 3333];
|
||||
for role in [PeerRole::Customer, PeerRole::Peer, PeerRole::Provider] {
|
||||
assert_eq!(
|
||||
PathCheck::new(role, 1101).verify(&aspas, &up, false),
|
||||
PathValidity::Valid
|
||||
);
|
||||
assert_eq!(
|
||||
PathCheck::new(role, 6939).verify(&aspas, &up, false),
|
||||
PathValidity::Invalid,
|
||||
"{role:?}: the path does not begin with the peer's AS"
|
||||
);
|
||||
}
|
||||
// The route server's own AS, 6695 say, is not on the path.
|
||||
let route_server = PathCheck::new(PeerRole::RouteServer, 6695);
|
||||
assert_eq!(route_server.verify(&aspas, &up, false), PathValidity::Valid);
|
||||
assert_eq!(
|
||||
route_server.verify(&aspas, &[1299, 1101, 3333], false),
|
||||
PathValidity::Invalid,
|
||||
"a leak is a leak across a route server too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_a_set_or_with_nothing_in_it_is_invalid() {
|
||||
let aspas = aspas();
|
||||
for role in [
|
||||
PeerRole::Customer,
|
||||
PeerRole::Provider,
|
||||
PeerRole::RouteServer,
|
||||
] {
|
||||
let check = PathCheck::new(role, 1101);
|
||||
assert_eq!(
|
||||
check.verify(&aspas, &[1101, 3333], true),
|
||||
PathValidity::Invalid
|
||||
);
|
||||
assert_eq!(check.verify(&aspas, &[], false), PathValidity::Invalid);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn which_checks_a_peer_is_subject_to_follows_from_its_configuration() {
|
||||
let strict = ImportPolicy::default();
|
||||
// Origins are validated for any external peer; paths only where
|
||||
// it is known what the peer is.
|
||||
assert_eq!(
|
||||
Subject::of(&strict, None, 3333, false),
|
||||
Some(Subject {
|
||||
origin: true,
|
||||
path: None
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
Subject::of(&strict, Some(PeerRole::Customer), 3333, false),
|
||||
Some(Subject {
|
||||
origin: true,
|
||||
path: Some(PathCheck::new(PeerRole::Customer, 3333))
|
||||
})
|
||||
);
|
||||
assert_eq!(Subject::of(&strict, Some(PeerRole::Peer), 3333, true), None);
|
||||
|
||||
let lenient = ImportPolicy {
|
||||
accept_aspa_invalid: true,
|
||||
accept_rpki_invalid: true,
|
||||
..ImportPolicy::default()
|
||||
};
|
||||
assert_eq!(
|
||||
Subject::of(&lenient, Some(PeerRole::Peer), 3333, false),
|
||||
None
|
||||
);
|
||||
let origins_only = ImportPolicy {
|
||||
accept_aspa_invalid: true,
|
||||
..ImportPolicy::default()
|
||||
};
|
||||
assert_eq!(
|
||||
Subject::of(&origins_only, Some(PeerRole::Peer), 3333, false),
|
||||
Some(Subject {
|
||||
origin: true,
|
||||
path: None
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ impl BgpServer {
|
||||
name: p.name.clone(),
|
||||
endpoint: p.endpoint,
|
||||
remote_asn: p.remote_asn,
|
||||
role: p.role,
|
||||
})
|
||||
.collect();
|
||||
// The client starts now: with luck the data is there before the
|
||||
|
||||
@@ -23,6 +23,8 @@ use crate::collision::Direction;
|
||||
use crate::family::FamilySet;
|
||||
use crate::fsm::Negotiated;
|
||||
use crate::metrics::SESSIONS_ESTABLISHED;
|
||||
use crate::policy::PeerRole;
|
||||
use crate::rpki::Subject;
|
||||
use crate::sync::IgnorePoisonRw;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
@@ -60,6 +62,8 @@ pub struct ConfiguredPeer {
|
||||
pub name: String,
|
||||
pub endpoint: SocketAddr,
|
||||
pub remote_asn: u32,
|
||||
/// What the peer is to us, if that was said.
|
||||
pub role: Option<PeerRole>,
|
||||
}
|
||||
|
||||
/// Counts kept by a session as it goes, for whoever wants to look.
|
||||
@@ -117,6 +121,8 @@ pub struct SessionHandle {
|
||||
pub established_at: SystemTime,
|
||||
/// Whether the peer is in our own AS.
|
||||
pub ibgp: bool,
|
||||
/// What the peer is to us, if that is known.
|
||||
pub role: Option<PeerRole>,
|
||||
/// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
|
||||
/// AS_PATH is encoded on the wire for this session.
|
||||
pub four_octet_asn: bool,
|
||||
@@ -134,9 +140,9 @@ pub struct SessionHandle {
|
||||
/// How many routes the peer has, and how many were refused.
|
||||
pub counters: Arc<SessionCounters>,
|
||||
outbound: mpsc::Sender<Message>,
|
||||
/// Where the session is told to look at its routes again, if they are
|
||||
/// subject to route origin validation.
|
||||
revalidate: Option<mpsc::UnboundedSender<Vec<Nlri>>>,
|
||||
/// Which of the RPKI's checks the peer's routes are subject to, if
|
||||
/// any, and where the session is told to look at its routes again.
|
||||
validation: Option<(Subject, mpsc::UnboundedSender<Vec<Nlri>>)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -160,6 +166,7 @@ impl SessionHandle {
|
||||
local_addr,
|
||||
established_at: SystemTime::now(),
|
||||
ibgp: negotiated.ibgp,
|
||||
role: None,
|
||||
four_octet_asn: negotiated.four_octet_asn,
|
||||
families: negotiated.families.clone(),
|
||||
add_path_receive: negotiated.add_path_receive.clone(),
|
||||
@@ -167,29 +174,29 @@ impl SessionHandle {
|
||||
rib_peer,
|
||||
counters: Arc::default(),
|
||||
outbound,
|
||||
revalidate: None,
|
||||
validation: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the session's routes as subject to route origin validation.
|
||||
/// What is sent with [`SessionHandle::revalidate`] arrives at the
|
||||
/// receiver returned.
|
||||
pub(crate) fn validated(&mut self) -> mpsc::UnboundedReceiver<Vec<Nlri>> {
|
||||
/// Marks the session's routes as subject to validation with the
|
||||
/// RPKI's data. What is sent with [`SessionHandle::revalidate`]
|
||||
/// arrives at the receiver returned.
|
||||
pub(crate) fn validate(&mut self, subject: Subject) -> mpsc::UnboundedReceiver<Vec<Nlri>> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
self.revalidate = Some(tx);
|
||||
self.validation = Some((subject, tx));
|
||||
rx
|
||||
}
|
||||
|
||||
/// Whether the session's routes are subject to route origin
|
||||
/// validation.
|
||||
pub fn is_validated(&self) -> bool {
|
||||
self.revalidate.is_some()
|
||||
/// Which of the RPKI's checks the session's routes are subject to, if
|
||||
/// any.
|
||||
pub fn validated(&self) -> Option<Subject> {
|
||||
self.validation.as_ref().map(|(subject, _)| *subject)
|
||||
}
|
||||
|
||||
/// Tells the session that the validation data has changed. `suspects`
|
||||
/// are routes of its peer's in the table that are now invalid.
|
||||
pub(crate) fn revalidate(&self, suspects: Vec<Nlri>) {
|
||||
if let Some(revalidate) = &self.revalidate {
|
||||
if let Some((_, revalidate)) = &self.validation {
|
||||
// Fails only if the session has just ended.
|
||||
let _ = revalidate.send(suspects);
|
||||
}
|
||||
|
||||
+42
-12
@@ -3,7 +3,6 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::Json;
|
||||
@@ -17,7 +16,9 @@ use bgp_primitives::constants::IpVersion;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::table::{PeerIndex, RibTable, Route};
|
||||
use bgp_rib::{LocalRoute, RibStopped};
|
||||
use bgp_rpki::{CacheState, VrpTable};
|
||||
use bgp_rpki::{CacheState, Update};
|
||||
use bgp_server::policy::PeerRole;
|
||||
use bgp_server::rpki::PathCheck;
|
||||
use bgp_server::session::{ConfiguredPeer, DialState, PeerId, SessionHandle, SessionRegistry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -76,8 +77,11 @@ pub struct PeerView {
|
||||
/// policy refused.
|
||||
pub rejected_routes: Option<u64>,
|
||||
/// How many of the peer's routes are held out of the table as invalid
|
||||
/// by route origin validation, to be taken if that changes.
|
||||
/// by the RPKI's data, to be taken if that changes.
|
||||
pub held_routes: Option<u64>,
|
||||
/// What the peer is to us, if configured: `provider`, `customer`,
|
||||
/// `peer`, `route server` or `route server client`.
|
||||
pub role: Option<&'static str>,
|
||||
/// Why the last connection attempt or session ended, while in Backoff.
|
||||
pub last_error: Option<String>,
|
||||
pub retry_at_unix: Option<u64>,
|
||||
@@ -101,6 +105,7 @@ impl From<&SessionHandle> for PeerView {
|
||||
routes: Some(s.counters.routes()),
|
||||
rejected_routes: Some(s.counters.rejected()),
|
||||
held_routes: Some(s.counters.held()),
|
||||
role: s.role.map(PeerRole::as_str),
|
||||
last_error: None,
|
||||
retry_at_unix: None,
|
||||
}
|
||||
@@ -132,6 +137,7 @@ impl PeerView {
|
||||
routes: None,
|
||||
rejected_routes: None,
|
||||
held_routes: None,
|
||||
role: peer.role.map(PeerRole::as_str),
|
||||
last_error,
|
||||
retry_at_unix,
|
||||
}
|
||||
@@ -274,6 +280,11 @@ pub struct ReceivedRouteView {
|
||||
/// peer's are taken regardless; then `invalid`.) Absent where
|
||||
/// validation is not configured.
|
||||
pub rpki: Option<&'static str>,
|
||||
/// What the ASPAs say of the route's path: `valid` or `unknown`
|
||||
/// (`invalid` only if the peer's are taken regardless). Absent where
|
||||
/// validation is not configured, for a peer whose role is not, and
|
||||
/// for one in our own AS.
|
||||
pub aspa: Option<&'static str>,
|
||||
pub summary: AttrSummary,
|
||||
/// The decoded attribute list. MP_REACH_NLRI is not in it: its next hop
|
||||
/// is in the summary and its prefixes are routes of their own.
|
||||
@@ -283,9 +294,9 @@ pub struct ReceivedRouteView {
|
||||
/// Who the routes in the table are from: the table knows peers by number,
|
||||
/// the sessions know which number is whose.
|
||||
pub(crate) struct PeerNames {
|
||||
peers: HashMap<PeerIndex, (PeerId, Option<String>)>,
|
||||
peers: HashMap<PeerIndex, (PeerId, Option<String>, Option<PathCheck>)>,
|
||||
/// What routes are validated against, where they are.
|
||||
vrps: Option<Arc<VrpTable>>,
|
||||
rpki: Option<Update>,
|
||||
}
|
||||
|
||||
impl PeerNames {
|
||||
@@ -295,9 +306,17 @@ impl PeerNames {
|
||||
.registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|session| (session.rib_peer, (session.peer, session.name)))
|
||||
.map(|session| {
|
||||
// How the peer's paths are verified, or would be if
|
||||
// its invalid routes were not taken regardless.
|
||||
let check = session
|
||||
.role
|
||||
.filter(|_| !session.ibgp)
|
||||
.map(|role| PathCheck::new(role, session.peer.asn));
|
||||
(session.rib_peer, (session.peer, session.name, check))
|
||||
})
|
||||
.collect(),
|
||||
vrps: state.rpki.as_ref().map(|rpki| rpki.current().table),
|
||||
rpki: state.rpki.as_ref().map(|rpki| rpki.current()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +325,7 @@ impl PeerNames {
|
||||
let address: Option<IpAddr> = wanted.parse().ok();
|
||||
self.peers
|
||||
.iter()
|
||||
.filter(|(_, (peer, name))| {
|
||||
.filter(|(_, (peer, name, _))| {
|
||||
name.as_deref() == Some(wanted) || address == Some(peer.addr.ip())
|
||||
})
|
||||
.map(|(index, _)| *index)
|
||||
@@ -316,7 +335,7 @@ impl PeerNames {
|
||||
/// A view of one route. `None` if the route's session has just ended:
|
||||
/// the route is on its way out of the table.
|
||||
pub(crate) fn view(&self, route: &Route) -> Option<ReceivedRouteView> {
|
||||
let (peer, name) = self.peers.get(&route.peer)?;
|
||||
let (peer, name, check) = self.peers.get(&route.peer)?;
|
||||
let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0);
|
||||
// What a session stored was accepted by the decoder, and parses.
|
||||
let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default();
|
||||
@@ -328,9 +347,14 @@ impl PeerNames {
|
||||
path_id: route.path_id,
|
||||
best: route.best,
|
||||
rpki: self
|
||||
.vrps
|
||||
.rpki
|
||||
.as_ref()
|
||||
.map(|vrps| vrps.validate(&route.prefix, origin).as_str()),
|
||||
.map(|rpki| rpki.vrps.validate(&route.prefix, origin).as_str()),
|
||||
aspa: self.rpki.as_ref().zip(*check).map(|(rpki, check)| {
|
||||
let mut path = Vec::new();
|
||||
let has_set = route.attrs.as_path(&mut path);
|
||||
check.verify(&rpki.aspas, &path, has_set).as_str()
|
||||
}),
|
||||
summary: summarize(&attributes, next_hop.as_ref()),
|
||||
attributes,
|
||||
})
|
||||
@@ -568,7 +592,8 @@ pub async fn received_routes(
|
||||
Ok(Json(routes))
|
||||
}
|
||||
|
||||
/// Route origin validation: where the data comes from and how fresh it is.
|
||||
/// Validation with the RPKI: where the data comes from and how fresh it
|
||||
/// is.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RpkiView {
|
||||
/// Whether validation is configured at all.
|
||||
@@ -582,6 +607,9 @@ pub struct RpkiView {
|
||||
pub protocol_version: Option<u8>,
|
||||
pub vrps_ipv4: usize,
|
||||
pub vrps_ipv6: usize,
|
||||
/// How many ASes have an ASPA. A cache that speaks a version of the
|
||||
/// protocol before 2 has none to give.
|
||||
pub aspas: usize,
|
||||
pub updated_at_unix: Option<u64>,
|
||||
/// When the data is dropped unless a cache confirms it first.
|
||||
pub expires_at_unix: Option<u64>,
|
||||
@@ -599,6 +627,7 @@ pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
|
||||
protocol_version: None,
|
||||
vrps_ipv4: 0,
|
||||
vrps_ipv6: 0,
|
||||
aspas: 0,
|
||||
updated_at_unix: None,
|
||||
expires_at_unix: None,
|
||||
last_error: None,
|
||||
@@ -617,6 +646,7 @@ pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
|
||||
protocol_version: Some(status.protocol_version),
|
||||
vrps_ipv4: status.vrps_ipv4,
|
||||
vrps_ipv6: status.vrps_ipv6,
|
||||
aspas: status.aspas,
|
||||
updated_at_unix: status.updated_at.map(unix_secs),
|
||||
expires_at_unix: status.expires_at.map(unix_secs),
|
||||
last_error: status.last_error,
|
||||
|
||||
@@ -63,7 +63,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
<tbody id="peers-body"></tbody>
|
||||
</table>
|
||||
|
||||
<h1 id="rpki">Route origin validation (RPKI)</h1>
|
||||
<h1 id="rpki">Route origin and path validation (RPKI)</h1>
|
||||
<div id="rpki-body">...</div>
|
||||
|
||||
<h1 id="local">Originated routes (<span id="local-count">...</span>)</h1>
|
||||
@@ -86,7 +86,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
<span id="lookup-note"></span>
|
||||
</form>
|
||||
<table class="data">
|
||||
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>RPKI</th><th>best</th></tr></thead>
|
||||
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>RPKI</th><th>ASPA</th><th>best</th></tr></thead>
|
||||
<tbody id="received-body"></tbody>
|
||||
</table>
|
||||
|
||||
@@ -175,13 +175,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
const up = p.state === 'Established';
|
||||
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
|
||||
return `<tr>
|
||||
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
|
||||
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}${p.role ? ` <span class="dim">${esc(p.role)}</span>` : ''}</td>
|
||||
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
|
||||
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
|
||||
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td>
|
||||
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}</td>
|
||||
<td>${up ? p.routes : DASH}</td>
|
||||
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
|
||||
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI or ASPA invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
|
||||
}).join(''));
|
||||
renderPeerChoices();
|
||||
}
|
||||
@@ -250,27 +250,29 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
? `showing ${state.received.length} of ${st.received_routes}, for ${st.received_prefixes} prefixes`
|
||||
: `${state.received.length}${st ? `, for ${st.received_prefixes} prefixes` : ''}`;
|
||||
if (!state.received.length) {
|
||||
setHTML(b, `<tr><td colspan="11" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
|
||||
setHTML(b, `<tr><td colspan="12" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
|
||||
}
|
||||
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr>
|
||||
<td class="mono">${esc(r.prefix)}</td><td class="mono">${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}${r.path_id ? ` path ${r.path_id}` : ''}</span></td><td class="mono">${asPath(s.as_path)}</td>
|
||||
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td><td>${s.med ?? DASH}</td><td>${s.local_pref ?? DASH}</td>
|
||||
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td>
|
||||
<td>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td><td>${r.best ? '<span class="ok">best</span>' : ''}</td></tr>`; }).join(''));
|
||||
<td>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td>
|
||||
<td>${r.aspa ? `<span class="${RPKI_CLASS[r.aspa] || ''}">${esc(r.aspa)}</span>` : DASH}</td><td>${r.best ? '<span class="ok">best</span>' : ''}</td></tr>`; }).join(''));
|
||||
}
|
||||
|
||||
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim' };
|
||||
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim', unknown: 'dim' };
|
||||
const RPKI_STATE_CLASS = { synchronised: 'ok', synchronising: 'warn', connecting: 'bad' };
|
||||
function renderRpki() {
|
||||
const r = state.rpki, b = $('rpki-body');
|
||||
if (!r) return;
|
||||
if (!r.enabled) { setHTML(b, '<span class="dim">Not configured: routes are not validated.</span>'); return; }
|
||||
const data = r.updated_at_unix
|
||||
? `${r.vrps_ipv4} IPv4 and ${r.vrps_ipv6} IPv6 VRPs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` +
|
||||
? `${r.vrps_ipv4} IPv4 and ${r.vrps_ipv6} IPv6 VRPs and ${r.aspas} ASPAs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` +
|
||||
(r.expires_at_unix ? `, dropped in ${until(r.expires_at_unix)} unless confirmed again` : '')
|
||||
: '<span class="warn">no data: every route is "not found" and none is refused as invalid</span>';
|
||||
setHTML(b, `Cache <span class="mono">${esc(r.cache || '?')}</span>: <span class="${RPKI_STATE_CLASS[r.state] || ''}">${esc(r.state)}</span>` +
|
||||
` <span class="dim">(RTR version ${r.protocol_version})</span>. ${data}.` +
|
||||
(r.updated_at_unix && r.protocol_version < 2 ? '<br><span class="warn">This cache speaks an RTR version without ASPAs: AS paths are not verified.</span>' : '') +
|
||||
(r.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user