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:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 14:12:59 +02:00
1 parent bdaf226a16
commit 8086aa994f
35 files changed
+2484 -359

No files matched your search

Generated
+1
View File
@@ -255,6 +255,7 @@ version = "0.0.2"
dependencies = [ dependencies = [
"bgp-primitives", "bgp-primitives",
"bytes", "bytes",
"foldhash",
"proptest", "proptest",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
+51 -3
View File
@@ -23,7 +23,7 @@ When `http_endpoint` is set the binary also serves:
| `GET /api/routes/local` | Originated routes | | `GET /api/routes/local` | Originated routes |
| `GET /api/routes/received` | Routes learned from peers; see "Finding a route" | | `GET /api/routes/received` | Routes learned from peers; see "Finding a route" |
| `GET /api/routes/rejected` | Routes peers announced that were refused, by peer | | `GET /api/routes/rejected` | Routes peers announced that were refused, by peer |
| `GET /api/rpki` | Route origin validation: the cache, the data and its age | | `GET /api/rpki` | Validation with the RPKI: the cache, the data and its age |
| `GET /api/events` | Server-sent events for session and route changes | | `GET /api/events` | Server-sent events for session and route changes |
| `GET /metricz` | Prometheus metrics | | `GET /metricz` | Prometheus metrics |
@@ -87,12 +87,60 @@ nothing is invalid any more.
The status page shows the cache, how fresh the data is, each route's The status page shows the cache, how fresh the data is, each route's
validity, and how many of each peer's routes are held. `/api/rpki` has the validity, and how many of each peer's routes are held. `/api/rpki` has the
same as JSON; `bgp_rpki_vrps{family}` and `bgp_rpki_updates_total` are on same as JSON; `bgp_rpki_vrps{family}`, `bgp_rpki_aspas` and
`/metricz`. Refused routes are counted under the reason `RPKI invalid`. `bgp_rpki_updates_total` are on `/metricz`. Refused routes are counted under
the reasons `RPKI invalid` and `ASPA invalid`.
The connection to the cache is plain TCP, without authentication: run the The connection to the cache is plain TCP, without authentication: run the
validator on the same host or on a network you trust. validator on the same host or on a network you trust.
## AS path verification (ASPA)
Origin validation says who may originate a prefix. It says nothing about the
rest of the path, so it does not catch a route leak (a network passing its
provider's or peer's routes on to another provider or peer), nor a hijack
that keeps the right origin at the end of a made-up path. ASPAs do: in one,
a network lists its providers, and a path that goes from that network to
anyone else, where only a provider could come next, is invalid
(draft-ietf-sidrops-aspa-verification).
Two things are needed for paths to be verified:
- a validator that hands out ASPAs, which takes version 2 of the
RPKI-to-Router protocol (draft-ietf-sidrops-8210bis). The daemon asks for
version 2 and falls back to 1 or 0 if that is what the validator speaks;
the status page says which is in use, and warns when it is one without
ASPAs;
- `role` on the peer, saying what the peer is to us, because what a path may
look like depends on who hands it over:
| `role` | The peer | Its paths |
|--------|----------|-----------|
| `CUSTOMER` | buys transit from us | must lead up through providers all the way |
| `PEER` | exchanges its own and its customers' routes with us | the same |
| `ROUTE_SERVER` | is an exchange's route server | the same; the path need not begin with the route server's AS |
| `ROUTE_SERVER_CLIENT` | is a client of ours, where we are the route server | the same |
| `PROVIDER` | sells us transit | may lead up, across one peering, and down |
A peer without a role has its origins validated and its paths not.
A path is **invalid** (refused, unless the peer has
`accept_aspa_invalid: true`), **valid**, or **unknown**, which is what most
paths are while few networks have an ASPA; unknown ones are taken. A path
with an AS_SET in it is invalid, and so is one that does not begin with the
peer's AS (other than from a route server). As with origins, routes follow
the data: held while invalid, taken when they no longer are.
```
rpki { caches { address: "127.0.0.1" port: 3323 } }
peers {
name: "transit"
endpoint { address: "192.0.2.1" }
remote_asn: 64496
role: PROVIDER
}
```
## Finding a route ## Finding a route
The status page has a look-up box over the received routes, and The status page has a look-up box over the received routes, and
+50 -18
View File
@@ -1,11 +1,11 @@
//! An RPKI cache for tests: an RTR server that hands out whatever VRPs the //! An RPKI cache for tests: an RTR server that hands out whatever VRPs and
//! test says there are. //! ASPAs the test says there are.
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use bgp_rpki::Vrp; use bgp_rpki::Vrp;
use bgp_rpki::rtr::{Pdu, Timers}; use bgp_rpki::rtr::{Pdu, Timers, VERSION_2};
use bytes::BytesMut; use bytes::BytesMut;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
@@ -14,11 +14,19 @@ use tokio::task::JoinHandle;
const SESSION: u16 = 0x1234; 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. /// The cache. Stops, closing its connections, when dropped.
pub struct TestCache { pub struct TestCache {
address: SocketAddr, address: SocketAddr,
/// The VRPs there are, and their serial. data: Arc<Mutex<Data>>,
data: Arc<Mutex<(Vec<Vrp>, u32)>>,
/// The serial, for telling connected routers there is something new. /// The serial, for telling connected routers there is something new.
serial: watch::Sender<u32>, serial: watch::Sender<u32>,
task: JoinHandle<()>, task: JoinHandle<()>,
@@ -26,9 +34,18 @@ pub struct TestCache {
impl TestCache { impl TestCache {
pub async fn start(vrps: Vec<Vrp>) -> Self { 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 listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
let address = listener.local_addr().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 (serial, _) = watch::channel(1);
let task = tokio::spawn({ let task = tokio::spawn({
let (data, serial) = (Arc::clone(&data), serial.clone()); let (data, serial) = (Arc::clone(&data), serial.clone());
@@ -57,11 +74,20 @@ impl TestCache {
/// Replaces the VRPs and tells connected routers. /// Replaces the VRPs and tells connected routers.
pub fn set(&self, vrps: Vec<Vrp>) { 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 serial = {
let mut data = self.data.lock().unwrap(); let mut data = self.data.lock().unwrap();
data.0 = vrps; change(&mut data);
data.1 += 1; data.serial += 1;
data.1 data.serial
}; };
self.serial.send_replace(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 /// One router's connection. Asked for changes, this cache always says to
/// ask for everything: the router works out what changed. /// ask for everything: the router works out what changed.
async fn serve( async fn serve(mut stream: TcpStream, data: Arc<Mutex<Data>>, mut serial: watch::Receiver<u32>) {
mut stream: TcpStream,
data: Arc<Mutex<(Vec<Vrp>, u32)>>,
mut serial: watch::Receiver<u32>,
) {
let mut buf = BytesMut::new(); let mut buf = BytesMut::new();
// The version the router speaks, once it has said something.
let mut spoken = VERSION_2;
loop { loop {
let mut out = BytesMut::new(); let mut out = BytesMut::new();
tokio::select! { tokio::select! {
@@ -90,17 +114,25 @@ async fn serve(
} }
while let Ok(Some((version, pdu, used))) = Pdu::decode(&buf) { while let Ok(Some((version, pdu, used))) = Pdu::decode(&buf) {
let _ = buf.split_to(used); let _ = buf.split_to(used);
spoken = version;
match pdu { match pdu {
Pdu::SerialQuery { .. } => Pdu::CacheReset.encode(version, &mut out), Pdu::SerialQuery { .. } => Pdu::CacheReset.encode(version, &mut out),
Pdu::ResetQuery => { Pdu::ResetQuery => {
let (vrps, serial) = data.lock().unwrap().clone(); let data = data.lock().unwrap().clone();
Pdu::CacheResponse { session_id: SESSION }.encode(version, &mut out); 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); 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 { Pdu::EndOfData {
session_id: SESSION, session_id: SESSION,
serial, serial: data.serial,
timers: Some(Timers::default()), timers: Some(Timers::default()),
} }
.encode(version, &mut out); .encode(version, &mut out);
@@ -114,7 +146,7 @@ async fn serve(
return; return;
} }
let serial = *serial.borrow_and_update(); 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() { if stream.write_all(&out).await.is_err() {
@@ -48,6 +48,7 @@ async fn server_offering(
families: FamilySet::supported(), families: FamilySet::supported(),
add_path, add_path,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
}]; }];
c.originate = originate; c.originate = originate;
// Keep the dialer out of the way. // Keep the dialer out of the way.
@@ -26,6 +26,7 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig {
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
} }
} }
+352
View File
@@ -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(), families: FamilySet::supported(),
add_path: None, add_path: None,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
}]; }];
}) })
.await .await
@@ -175,6 +175,7 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> {
max_prefixes: Some(100), max_prefixes: Some(100),
..ImportPolicy::default() ..ImportPolicy::default()
}, },
role: None,
}]; }];
}) })
.await?; .await?;
+4
View File
@@ -35,6 +35,7 @@ async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestS
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
}]; }];
}) })
.await .await
@@ -190,6 +191,7 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
}]; }];
}) })
.await?; .await?;
@@ -239,6 +241,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
}]; }];
}) })
.await?; .await?;
@@ -257,6 +260,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
}]; }];
}) })
.await?; .await?;
@@ -48,6 +48,7 @@ async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)>
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import, import,
role: None,
}]; }];
}) })
.await?; .await?;
@@ -191,6 +191,7 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> {
families: only(AfiSafi::IPV6_UNICAST), families: only(AfiSafi::IPV6_UNICAST),
add_path: None, add_path: None,
import: ImportPolicy::permissive(), import: ImportPolicy::permissive(),
role: None,
}]; }];
// Keep the dialer out of the way. // Keep the dialer out of the way.
c.connect_retry = Duration::from_secs(3600); c.connect_retry = Duration::from_secs(3600);
+2 -1
View File
@@ -73,6 +73,7 @@ async fn server_with(
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import, import,
role: None,
}]; }];
}) })
.await?; .await?;
@@ -335,6 +336,6 @@ async fn test_held_routes_are_taken_when_the_validation_data_expires() -> Result
drop(cache); drop(cache);
table_becomes(&server, &["193.0.4.0/24"], 0).await?; 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(()) Ok(())
} }
+109
View File
@@ -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 /// One distinct list of path attributes, with the next hop that goes with
/// it. Made by an [`AttrStore`]; two routes have the same attributes /// it. Made by an [`AttrStore`]; two routes have the same attributes
/// exactly when they point to the same `AttrSet`. /// exactly when they point to the same `AttrSet`.
@@ -153,6 +171,42 @@ impl AttrSet {
&self.wire &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 /// The attributes, parsed, and the next hop if it came in
/// MP_REACH_NLRI. /// MP_REACH_NLRI.
/// ///
@@ -442,6 +496,61 @@ mod tests {
assert_eq!(store.intern(&attrs, None).unwrap().key().med, Some(7)); 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] #[test]
fn attributes_on_the_wire_are_interned_without_parsing_them() { fn attributes_on_the_wire_are_interned_without_parsing_them() {
let store = AttrStore::new(); let store = AttrStore::new();
+1
View File
@@ -10,6 +10,7 @@ version.workspace = true
[dependencies] [dependencies]
bgp-primitives.workspace = true bgp-primitives.workspace = true
bytes.workspace = true bytes.workspace = true
foldhash.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tracing.workspace = true tracing.workspace = true
+426
View File
@@ -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(&not), first_up(&not_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(&not), last_down(&not_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
View File
@@ -1,11 +1,14 @@
//! Keeps the list of VRPs in step with a validator, over the RPKI-to-Router //! Keeps the VRPs and ASPAs in step with a validator, over the
//! protocol (RFC 8210). //! RPKI-to-Router protocol (RFC 8210 and draft-ietf-sidrops-8210bis).
//! //!
//! The client connects to a cache (the validator's RTR server), asks for //! 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 //! 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 //! says there is something new, and otherwise every so often. Each time the
//! data changes a new [`VrpTable`] is published for whoever validates //! data changes an [`Update`] is published for whoever validates routes.
//! 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 //! 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 //! 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 tokio::time::{Instant, sleep_until, timeout};
use tracing::{info, warn}; 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}; use crate::vrp::{Vrp, VrpTable};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); 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. /// How long to wait before trying the next cache after one has failed.
const NEXT_CACHE_DELAY: Duration = Duration::from_secs(1); 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. /// Where the VRPs come from, and how patiently.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct RpkiConfig { pub struct RpkiConfig {
@@ -54,16 +61,28 @@ pub struct RpkiConfig {
pub expire: Option<Duration>, 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)] #[derive(Debug, Clone)]
pub struct Update { pub struct Update {
/// Goes up by one with every update. /// Goes up by one with every update.
pub version: u64, pub version: u64,
pub table: Arc<VrpTable>, pub vrps: Arc<VrpTable>,
/// The VRPs that were added or removed to get here from the version pub aspas: Arc<AspaTable>,
/// before: only routes that these cover can have changed validity. /// What was added or removed to get here from the version before.
/// `None` if any route may have. /// `None` if any route's validity may have changed.
pub changed: Option<Arc<VrpTable>>, 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. /// How things stand with the caches.
@@ -89,6 +108,9 @@ pub struct Status {
pub protocol_version: u8, pub protocol_version: u8,
pub vrps_ipv4: usize, pub vrps_ipv4: usize,
pub vrps_ipv6: 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. /// When the data was last confirmed by a cache.
pub updated_at: Option<SystemTime>, pub updated_at: Option<SystemTime>,
/// When it will be dropped unless confirmed again. /// When it will be dropped unless confirmed again.
@@ -112,12 +134,15 @@ pub struct Rpki {
impl Rpki { impl Rpki {
/// Starts a client for `config` and returns the handle to its data, /// 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 /// with the task that runs it. Until the first cache has answered
/// table is empty, and every route is [`crate::Validity::NotFound`]. /// 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<()>) { pub fn spawn(config: RpkiConfig) -> (Self, JoinHandle<()>) {
let (updates, _) = watch::channel(Update { let (updates, _) = watch::channel(Update {
version: 0, version: 0,
table: Arc::default(), vrps: Arc::default(),
aspas: Arc::default(),
changed: None, changed: None,
}); });
let rpki = Self { let rpki = Self {
@@ -129,9 +154,10 @@ impl Rpki {
state: CacheState::Connecting, state: CacheState::Connecting,
session_id: None, session_id: None,
serial: None, serial: None,
protocol_version: VERSION_1, protocol_version: VERSION_2,
vrps_ipv4: 0, vrps_ipv4: 0,
vrps_ipv6: 0, vrps_ipv6: 0,
aspas: 0,
updated_at: None, updated_at: None,
expires_at: None, expires_at: None,
last_error: None, last_error: None,
@@ -141,16 +167,16 @@ impl Rpki {
let client = Client { let client = Client {
shared: Arc::clone(&rpki.shared), shared: Arc::clone(&rpki.shared),
config, config,
vrps: HashSet::new(), data: Data::default(),
place: None, place: None,
protocol_version: VERSION_1, protocol_version: VERSION_2,
timers: Timers::default(), timers: Timers::default(),
expires: None, expires: None,
}; };
(rpki, tokio::spawn(client.run())) (rpki, tokio::spawn(client.run()))
} }
/// The VRPs as they are now. /// The VRPs and ASPAs as they are now.
pub fn current(&self) -> Update { pub fn current(&self) -> Update {
self.shared.updates.borrow().clone() self.shared.updates.borrow().clone()
} }
@@ -192,33 +218,114 @@ enum SessionError {
#[error("the cache sent {0} where it has no place")] #[error("the cache sent {0} where it has no place")]
Unexpected(&'static str), Unexpected(&'static str),
#[error("the cache {0}")] #[error("the cache {0}")]
Inconsistent(&'static str), Inconsistent(Inconsistency),
/// Not a failure of the cache: it speaks the older version, and is to /// Not a failure of the cache: it speaks an older version, and is to
/// be asked again in that. /// be asked again in that.
#[error("the cache speaks protocol version 0")] #[error("the cache speaks protocol version {0}")]
Downgrade, 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. /// Where the data is at: which of the cache's sessions, and which serial.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Place { struct Place {
cache: SocketAddr, 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, session_id: u16,
serial: u32, 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 { struct Client {
shared: Arc<Shared>, shared: Arc<Shared>,
config: RpkiConfig, config: RpkiConfig,
/// The VRPs as last received. /// The data as last received.
vrps: HashSet<Vrp>, data: Data,
/// Where `vrps` is at, if it came from a cache that can be asked for /// Where `data` is at, if it came from a cache that can be asked for
/// what has changed since. /// what has changed since.
place: Option<Place>, place: Option<Place>,
protocol_version: u8, protocol_version: u8,
/// As the cache last gave them, or the defaults. /// As the cache last gave them, or the defaults.
timers: Timers, 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>, expires: Option<Instant>,
} }
@@ -291,9 +398,9 @@ impl Client {
status.state = CacheState::Connecting; status.state = CacheState::Connecting;
}); });
let ended = self.session(cache).await; let ended = self.session(cache).await;
if matches!(ended, SessionError::Downgrade) { if let SessionError::Downgrade(version) = ended {
info!(%cache, "RPKI cache speaks RTR version 0; asking again in that"); info!(%cache, version, "RPKI cache speaks an older RTR version; asking again in that");
self.protocol_version = VERSION_0; self.protocol_version = version;
continue; continue;
} }
warn!(%cache, err = %ended, "RPKI cache session ended"); 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. // The next cache at once; when all have been tried, a rest.
attempt += 1; attempt += 1;
// What is new may be at a newer version. // The next cache, or this one when it is back, may speak a
self.protocol_version = VERSION_1; // newer version.
self.protocol_version = VERSION_2;
let wait = if attempt.is_multiple_of(self.config.caches.len()) { let wait = if attempt.is_multiple_of(self.config.caches.len()) {
self.retry() self.retry()
} else { } else {
@@ -331,10 +439,11 @@ impl Client {
/// The data has gone unconfirmed for too long to be used. /// The data has gone unconfirmed for too long to be used.
fn expired(&mut self) { fn expired(&mut self) {
warn!( 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" "RPKI data has expired without a cache to confirm it; dropping it"
); );
self.vrps.clear(); self.data = Data::default();
self.place = None; self.place = None;
self.expires = None; self.expires = None;
self.publish(Arc::default(), None); self.publish(Arc::default(), None);
@@ -365,7 +474,7 @@ impl Client {
loop { loop {
if let Err(e) = self.synchronise(cache, &mut connection).await { 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 // 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 // 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 ask = Instant::now() + self.refresh();
let expires = self.expires.unwrap_or(ask); let expires = self.expires.unwrap_or(ask);
tokio::select! { tokio::select! {
pdu = connection.next() => match pdu { pdu = connection.next() => {
Ok((_, Pdu::SerialNotify { .. })) => {} let problem = match pdu {
Ok((_, Pdu::ErrorReport { code, text })) => { Ok((_, Pdu::ErrorReport { code, text })) => {
return SessionError::Reported { code, text }; SessionError::Reported { code, text }
} }
Ok(_) => return SessionError::Unexpected("an answer nobody asked for"), Ok((version, _)) if version != self.protocol_version => {
Err(e) => return e, 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(ask) => {}
() = sleep_until(expires), if expires < ask => { () = sleep_until(expires), if expires < ask => {
self.expired(); 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 /// Asks the cache for what has changed, or for everything, and takes
/// the answer. /// the answer.
async fn synchronise( async fn synchronise(
@@ -397,8 +530,11 @@ impl Client {
cache: SocketAddr, cache: SocketAddr,
connection: &mut Connection, connection: &mut Connection,
) -> Result<(), SessionError> { ) -> Result<(), SessionError> {
// Changes can be asked for only of the cache the data came from. // Changes can be asked for only of the cache the data came from,
let mut since = self.place.filter(|place| place.cache == cache); // 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 { loop {
let query = match since { let query = match since {
Some(place) => Pdu::SerialQuery { Some(place) => Pdu::SerialQuery {
@@ -433,20 +569,26 @@ impl Client {
let session_id = loop { let session_id = loop {
let (version, pdu) = next().await?; let (version, pdu) = next().await?;
if version < self.protocol_version { if version < self.protocol_version {
// An older cache answers in its own version; the query has // An older cache answers in its own version, with an error
// to be put again in that (RFC 8210 §7). // or as if it had been asked in that; the query has to be
return Err(SessionError::Downgrade); // 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 { match pdu {
Pdu::CacheResponse { session_id } => break session_id, Pdu::CacheResponse { session_id } => break session_id,
Pdu::CacheReset if since.is_some() => return Ok(Answer::Reset), Pdu::CacheReset if since.is_some() => return Ok(Answer::Reset),
// News of what is being asked for anyway. // News of what is being asked for anyway.
Pdu::SerialNotify { .. } => {} Pdu::SerialNotify { .. } => {}
// A cache that refuses the version in the version it
// refuses: the next one down, then.
Pdu::ErrorReport { code, .. } Pdu::ErrorReport { code, .. }
if code == error_code::UNSUPPORTED_VERSION if code == error_code::UNSUPPORTED_VERSION
&& self.protocol_version != VERSION_0 => && self.protocol_version != VERSION_0 =>
{ {
return Err(SessionError::Downgrade); return Err(SessionError::Downgrade(self.protocol_version - 1));
} }
Pdu::ErrorReport { code, text } => { Pdu::ErrorReport { code, text } => {
return Err(SessionError::Reported { code, text }); return Err(SessionError::Reported { code, text });
@@ -460,13 +602,25 @@ impl Client {
return Ok(Answer::Reset); return Ok(Answer::Reset);
} }
// Then the VRPs, and End of Data. Nothing takes effect until the // Then the VRPs and ASPAs, and End of Data. Nothing takes effect
// answer is complete (RFC 8210 §8.1), so what it says is kept // until the answer is complete (RFC 8210 §8.1), so what it says is
// aside in the order it came. // kept aside in the order it came.
let mut records: Vec<(Vrp, bool)> = Vec::new(); let mut records: Vec<Record> = Vec::new();
let (serial, timers) = loop { let (serial, timers) = loop {
match next().await?.1 { let (version, pdu) = next().await?;
Pdu::Prefix { announce, vrp } => records.push((vrp, announce)), 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::RouterKey => {}
Pdu::EndOfData { Pdu::EndOfData {
session_id: ended, session_id: ended,
@@ -474,9 +628,7 @@ impl Client {
timers, timers,
} => { } => {
if ended != session_id { if ended != session_id {
return Err(SessionError::Inconsistent( return Err(SessionError::Inconsistent(OTHER_SESSION));
"ended an answer in another session than it began it",
));
} }
break (serial, timers); break (serial, timers);
} }
@@ -492,47 +644,47 @@ impl Client {
}; };
// The first data there is can change any route's validity; after // 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 first = self.place.is_none() && self.expires.is_none();
let before = match since { let (before, replaced) = match since {
// Changes, to what there is. // Changes, to what there is.
Some(_) => std::mem::take(&mut self.vrps), Some(_) => (std::mem::take(&mut self.data), None),
// Everything anew. // Everything anew.
None => HashSet::new(), None => (Data::default(), Some(std::mem::take(&mut self.data))),
};
let replaced = match since {
Some(_) => None,
None => Some(std::mem::take(&mut self.vrps)),
}; };
let vrps = Arc::clone(&self.shared.updates.borrow().vrps);
// Building the tables is work for a thread that may take its time. // 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 .await
.map_err(|_| SessionError::Inconsistent("could not be followed (a bug here)"))?; .map_err(|_| SessionError::Inconsistent(INTERNAL))?;
let built = match built { let built = match built {
Ok(built) => built, Ok(built) => built,
Err((vrps, problem)) => { Err((data, problem)) => {
// What there was stays as it was. // What there was stays as it was.
self.vrps = vrps; self.data = data;
return Err(SessionError::Inconsistent(problem)); return Err(SessionError::Inconsistent(problem));
} }
}; };
self.vrps = built.vrps; self.data = built.data;
self.place = Some(Place { self.place = Some(Place {
cache, cache,
protocol_version: self.protocol_version,
session_id, session_id,
serial, serial,
}); });
self.timers = timers.unwrap_or_default(); self.timers = timers.unwrap_or_default();
self.expires = Some(Instant::now() + self.expire()); self.expires = Some(Instant::now() + self.expire());
if first || built.changes > 0 { let changes = built.changed.vrps.len() + built.changed.aspa_customers.len();
self.publish(built.table, built.changed); if first || changes > 0 {
self.publish(built.vrps, (!first).then_some(built.changed));
} }
info!( info!(
%cache, %cache,
serial, serial,
vrps = self.vrps.len(), vrps = self.data.vrps.len(),
changed = built.changes, aspas = self.data.aspas.len(),
changes,
"RPKI data is up to date" "RPKI data is up to date"
); );
let expires_at = SystemTime::now() + self.expire(); let expires_at = SystemTime::now() + self.expire();
@@ -547,11 +699,11 @@ impl Client {
Ok(Answer::Complete) Ok(Answer::Complete)
} }
/// Makes `table`, which holds the VRPs as they now are, the one in /// Publishes the data as it now is. `vrps` holds its VRPs.
/// use. fn publish(&self, vrps: Arc<VrpTable>, changed: Option<Changed>) {
fn publish(&self, table: Arc<VrpTable>, changed: Option<Arc<VrpTable>>) {
let count = |version| { let count = |version| {
self.vrps self.data
.vrps
.iter() .iter()
.filter(|vrp| vrp.prefix.address_family() == version) .filter(|vrp| vrp.prefix.address_family() == version)
.count() .count()
@@ -560,84 +712,151 @@ impl Client {
self.status(|status| { self.status(|status| {
status.vrps_ipv4 = ipv4; status.vrps_ipv4 = ipv4;
status.vrps_ipv6 = ipv6; status.vrps_ipv6 = ipv6;
status.aspas = self.data.aspas.len();
}); });
let aspas = Arc::new(self.data.aspas.clone());
self.shared.updates.send_modify(|update| { self.shared.updates.send_modify(|update| {
update.version += 1; update.version += 1;
update.table = table; update.vrps = vrps;
update.aspas = aspas;
update.changed = changed; update.changed = changed;
self.shared.version.store(update.version, Ordering::Release); 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 { struct Built {
vrps: HashSet<Vrp>, data: Data,
table: Arc<VrpTable>, /// The VRPs of `data`, arranged for validating.
/// See [`Update::changed`]. vrps: Arc<VrpTable>,
changed: Option<Arc<VrpTable>>, changed: Changed,
/// How many VRPs came or went.
changes: usize,
} }
/// Applies the `records` of a complete answer, in order, to `vrps`: each /// Applies the `records` of a complete answer, in order, to `data`.
/// a VRP and whether it is announced or withdrawn. `replaced` is the set /// `replaced` is the data that an answer to a Reset Query takes the place
/// that an answer to a Reset Query takes the place of. /// 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 /// If the answer contradicts itself or what there was, what there was is
/// given back with the reason. /// given back with the reason.
fn apply( fn apply(
mut vrps: HashSet<Vrp>, mut data: Data,
replaced: Option<HashSet<Vrp>>, replaced: Option<Data>,
records: &[(Vrp, bool)], records: &[Record],
first: bool, vrps: Arc<VrpTable>,
) -> Result<Built, (HashSet<Vrp>, &'static str)> { ) -> 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 problem = None;
let mut applied = 0; for record in records {
for (vrp, announce) in records { let ok = match record {
let ok = match announce { Record::Vrp {
true => vrps.insert(*vrp), announce: true,
false => vrps.remove(vrp), 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 { match ok {
problem = Some(match announce { Ok(was) => displaced.push(was),
true => "announced a VRP twice", Err(e) => {
false => "withdrew a VRP it had not announced", problem = Some(e);
}); break;
break; }
} }
applied += 1;
} }
if let Some(problem) = problem { if let Some(problem) = problem {
return Err(match replaced { return Err(match replaced {
Some(replaced) => (replaced, problem), Some(replaced) => (replaced, problem),
None => { None => {
// Undo, last first. // Undo, last first.
for (vrp, announce) in records[..applied].iter().rev() { for (record, was) in records.iter().zip(displaced).rev() {
match announce { match record {
true => vrps.remove(vrp), Record::Vrp {
false => vrps.insert(*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. // answer touched.
let changed: Vec<Vrp> = match &replaced { let (changed_vrps, aspa_customers): (Vec<Vrp>, HashSet<u32>) = match &replaced {
Some(replaced) => vrps.symmetric_difference(replaced).copied().collect(), Some(replaced) => (
None => records.iter().map(|(vrp, _)| *vrp).collect(), 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 { Ok(Built {
table: Arc::new(VrpTable::new(vrps.iter().copied())), vrps: match changed_vrps.is_empty() {
changed: (!first).then(|| Arc::new(VrpTable::new(changed.iter().copied()))), true => vrps,
changes: changed.len(), false => Arc::new(VrpTable::new(data.vrps.iter().copied())),
vrps, },
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. /// How a query was answered.
enum Answer { enum Answer {
/// With data, which is now in use. /// With data, which is now in use.
+15 -4
View File
@@ -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 //! The holder of an address block can sign a statement of which AS may
//! originate routes for it. Validators collect and check those statements //! 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: //! tell a route whose origin is authorised from one whose origin is not:
//! the commonest kind of hijack and of mistake. //! 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. //! - [`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 client;
pub mod rtr; pub mod rtr;
pub mod vrp; 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}; pub use vrp::{Validity, Vrp, VrpTable};
+138 -17
View File
@@ -1,5 +1,6 @@
//! The messages of the RPKI-to-Router protocol (RFC 8210, and RFC 6810 for //! The messages of the RPKI-to-Router protocol, as bytes and back: version
//! version 0), as bytes and back. No I/O here. //! 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 //! 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 //! 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; use crate::vrp::Vrp;
/// The protocol version this client asks for. Version 1 added the timers in /// The newest protocol version, which this client asks for first. Version
/// End of Data and router keys. /// 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; pub const VERSION_1: u8 = 1;
/// What an older validator speaks. /// What the oldest validators speak.
pub const VERSION_0: u8 = 0; pub const VERSION_0: u8 = 0;
const HEADER_LEN: usize = 8; const HEADER_LEN: usize = 8;
/// The longest PDU this client reads. Every PDU but Error Report and Router /// The longest PDU this client reads. Every PDU but Error Report, Router
/// Key has a fixed, small length, and those two have no need to be large. /// Key and ASPA has a fixed, small length. An ASPA of this length lists
pub const MAX_PDU_LEN: usize = 16 * 1024; /// sixteen thousand providers, more than validators let an AS have.
pub const MAX_PDU_LEN: usize = 64 * 1024;
mod pdu_type { mod pdu_type {
pub const SERIAL_NOTIFY: u8 = 0; pub const SERIAL_NOTIFY: u8 = 0;
@@ -34,9 +38,10 @@ mod pdu_type {
pub const CACHE_RESET: u8 = 8; pub const CACHE_RESET: u8 = 8;
pub const ROUTER_KEY: u8 = 9; pub const ROUTER_KEY: u8 = 9;
pub const ERROR_REPORT: u8 = 10; 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 mod error_code {
pub const CORRUPT_DATA: u16 = 0; pub const CORRUPT_DATA: u16 = 0;
pub const INTERNAL_ERROR: u16 = 1; pub const INTERNAL_ERROR: u16 = 1;
@@ -47,6 +52,9 @@ pub mod error_code {
pub const WITHDRAWAL_OF_UNKNOWN_RECORD: u16 = 6; pub const WITHDRAWAL_OF_UNKNOWN_RECORD: u16 = 6;
pub const DUPLICATE_ANNOUNCEMENT: u16 = 7; pub const DUPLICATE_ANNOUNCEMENT: u16 = 7;
pub const UNEXPECTED_VERSION: u16 = 8; 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 /// The timers a validator tells its routers to use (RFC 8210 §6), in
@@ -97,6 +105,14 @@ pub enum Pdu {
CacheReset, CacheReset,
/// A BGPsec router key. Not used here; read so as to be skipped. /// A BGPsec router key. Not used here; read so as to be skipped.
RouterKey, 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. /// Something went wrong at the sender's end, or with what we sent.
ErrorReport { code: u16, text: String }, ErrorReport { code: u16, text: String },
} }
@@ -112,6 +128,8 @@ pub enum RtrError {
Version(u8), Version(u8),
#[error("a prefix PDU with prefix length {length} and maximum length {max_length}")] #[error("a prefix PDU with prefix length {length} and maximum length {max_length}")]
Prefix { length: u8, max_length: u8 }, Prefix { length: u8, max_length: u8 },
#[error("an ASPA of AS{customer} is announced with no providers")]
AspaWithoutProviders { customer: u32 },
} }
impl RtrError { impl RtrError {
@@ -121,6 +139,7 @@ impl RtrError {
Self::Length { .. } | Self::Prefix { .. } => error_code::CORRUPT_DATA, Self::Length { .. } | Self::Prefix { .. } => error_code::CORRUPT_DATA,
Self::UnknownType(_) => error_code::UNSUPPORTED_PDU_TYPE, Self::UnknownType(_) => error_code::UNSUPPORTED_PDU_TYPE,
Self::Version(_) => error_code::UNSUPPORTED_VERSION, 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 { let Some(header) = buf.first_chunk::<HEADER_LEN>() else {
return Ok(None); 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 field = be_u16(&header[2..]);
let length = be_u32(&header[4..]); let length = be_u32(&header[4..]);
let bad_length = || RtrError::Length { pdu_type, length }; let bad_length = || RtrError::Length { pdu_type, length };
if version > VERSION_1 { if version > VERSION_2 {
return Err(RtrError::Version(version)); return Err(RtrError::Version(version));
} }
let total = usize::try_from(length).map_err(|_| bad_length())?; let total = usize::try_from(length).map_err(|_| bad_length())?;
@@ -162,6 +181,15 @@ impl Pdu {
pdu_type::IPV6_PREFIX => Some(32), pdu_type::IPV6_PREFIX => Some(32),
pdu_type::END_OF_DATA => Some(if version == VERSION_0 { 12 } else { 24 }), pdu_type::END_OF_DATA => Some(if version == VERSION_0 { 12 } else { 24 }),
pdu_type::ROUTER_KEY | pdu_type::ERROR_REPORT => None, 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)), other => return Err(RtrError::UnknownType(other)),
}; };
if fixed.is_some_and(|fixed| fixed != total) { if fixed.is_some_and(|fixed| fixed != total) {
@@ -215,6 +243,24 @@ impl Pdu {
}, },
pdu_type::CACHE_RESET => Self::CacheReset, pdu_type::CACHE_RESET => Self::CacheReset,
pdu_type::ROUTER_KEY => Self::RouterKey, 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 => { pdu_type::ERROR_REPORT => {
// The length of the PDU that was at fault, that PDU, the // The length of the PDU that was at fault, that PDU, the
// length of the text, the text. // length of the text, the text.
@@ -234,7 +280,8 @@ impl Pdu {
} }
/// Writes the PDU, for a session that speaks protocol version /// 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) { pub fn encode(&self, version: u8, out: &mut BytesMut) {
let start = out.len(); let start = out.len();
let header = |out: &mut BytesMut, pdu_type: u8, field: u16| { 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::CacheReset => header(out, pdu_type::CACHE_RESET, 0),
Self::RouterKey => header(out, pdu_type::ROUTER_KEY, 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 } => { Self::ErrorReport { code, text } => {
header(out, pdu_type::ERROR_REPORT, *code); header(out, pdu_type::ERROR_REPORT, *code);
// No copy of the PDU at fault. // 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] #[rustfmt::skip]
let error = [ let error = [
1, 10, 0, 2, 0, 0, 0, 30, 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() { fn what_is_not_a_pdu_is_refused_from_its_header() {
// A version from the future. // A version from the future.
assert_eq!( assert_eq!(
Pdu::decode(&[2, 2, 0, 0, 0, 0, 0, 8]), Pdu::decode(&[3, 2, 0, 0, 0, 0, 0, 8]),
Err(RtrError::Version(2)) Err(RtrError::Version(3))
); );
assert_eq!( assert_eq!(
Pdu::decode(&[1, 99, 0, 0, 0, 0, 0, 8]), Pdu::decode(&[1, 99, 0, 0, 0, 0, 0, 8]),
@@ -456,6 +543,29 @@ mod tests {
Err(RtrError::Prefix { length, max_length }) 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. // An Error Report whose inner lengths do not add up.
#[rustfmt::skip] #[rustfmt::skip]
let error = [1, 10, 0, 2, 0, 0, 0, 16, 0, 0, 0, 9, 0, 0, 0, 0]; 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! { proptest! {
#[test] #[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(); let mut wire = BytesMut::new();
pdu.encode(version, &mut wire); pdu.encode(version, &mut wire);
// Version 0 does not carry the timers. // 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())))); 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 /// Whatever arrives, decoding returns: a PDU, a wait for more, or
/// an error. What it reads is within what arrived. /// an error. What it reads is within what arrived.
#[test] #[test]
@@ -535,8 +656,8 @@ mod tests {
/// Noise behind a header that could be a PDU's, to get past it. /// Noise behind a header that could be a PDU's, to get past it.
#[test] #[test]
fn noise_behind_a_plausible_header_is_never_more_than_an_error( fn noise_behind_a_plausible_header_is_never_more_than_an_error(
version in 0u8..=1, version in 0u8..=2,
pdu_type in 0u8..12, pdu_type in 0u8..13,
field: u16, field: u16,
length in 8u32..64, length in 8u32..64,
body in proptest::collection::vec(any::<u8>(), 0..64), body in proptest::collection::vec(any::<u8>(), 0..64),
+265 -39
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use bgp_primitives::ip_prefix::IpPrefix; use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rpki::rtr::{Pdu, Timers, error_code}; 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 bytes::BytesMut;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
@@ -15,6 +15,8 @@ use tokio::sync::watch;
const TIMEOUT: Duration = Duration::from_secs(5); const TIMEOUT: Duration = Duration::from_secs(5);
const SESSION: u16 = 0x1234; const SESSION: u16 = 0x1234;
/// The version of the protocol the client asks for first.
const V: u8 = 2;
fn prefix(s: &str) -> IpPrefix { fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap() IpPrefix::try_from(s).unwrap()
@@ -120,7 +122,7 @@ impl Connection {
}]; }];
pdus.extend(vrps.iter().copied().map(announce)); pdus.extend(vrps.iter().copied().map(announce));
pdus.push(end_of_data(serial)); 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]); let (rpki, mut updates) = client_of(&[&cache]);
// Nothing is known until a cache has answered. // Nothing is known until a cache has answered.
assert_eq!(rpki.version(), 0); assert_eq!(rpki.version(), 0);
assert!(rpki.current().table.is_empty()); assert!(rpki.current().vrps.is_empty());
let mut connection = cache.accept().await; 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 ripe = vrp("193.0.0.0/21", 24, 3333);
let other = vrp("2001:67c:2e8::/48", 48, 3333); let other = vrp("2001:67c:2e8::/48", 48, 3333);
connection.send_all(&[ripe, other], 7).await; 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(), update.changed.is_none(),
"the first data can change anything" "the first data can change anything"
); );
assert_eq!(update.table.len(), 2); assert_eq!(update.vrps.len(), 2);
assert_eq!( 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 Validity::Valid
); );
assert_eq!( 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 Validity::Invalid
); );
let status = rpki.status(); 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. // The cache has news: the client asks for what changed since serial 7.
connection connection
.send( .send(
1, V,
&[Pdu::SerialNotify { &[Pdu::SerialNotify {
session_id: SESSION, session_id: SESSION,
serial: 8, serial: 8,
@@ -192,7 +194,7 @@ async fn the_client_takes_everything_once_and_then_the_changes() {
assert_eq!( assert_eq!(
connection.received().await, connection.received().await,
( (
1, V,
Pdu::SerialQuery { Pdu::SerialQuery {
session_id: SESSION, session_id: SESSION,
serial: 7 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); let new = vrp("8.8.8.0/24", 24, 15169);
connection connection
.send( .send(
1, V,
&[ &[
Pdu::CacheResponse { Pdu::CacheResponse {
session_id: SESSION, 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; let update = next_update(&mut updates).await;
assert_eq!(update.version, 2); assert_eq!(update.version, 2);
assert_eq!(update.table.len(), 2); assert_eq!(update.vrps.len(), 2);
assert_eq!( 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 Validity::Valid
); );
assert_eq!( assert_eq!(
update update
.table .vrps
.validate(&prefix("2001:67c:2e8::/48"), Some(3333)), .validate(&prefix("2001:67c:2e8::/48"), Some(3333)),
Validity::NotFound, Validity::NotFound,
"withdrawn" "withdrawn"
); );
// Only routes that what came and went covers can have changed. // Only routes that what came and went covers can have changed.
let changed = update.changed.expect("a change to what there was"); let changed = update.changed.expect("a change to what there was");
assert!(changed.covers(&prefix("8.8.8.0/24"))); assert!(changed.vrps.covers(&prefix("8.8.8.0/24")));
assert!(changed.covers(&prefix("2001:67c:2e8::/48"))); assert!(changed.vrps.covers(&prefix("2001:67c:2e8::/48")));
assert!(!changed.covers(&prefix("193.0.0.0/21"))); assert!(!changed.vrps.covers(&prefix("193.0.0.0/21")));
assert_eq!(rpki.status().serial, Some(8)); assert_eq!(rpki.status().serial, Some(8));
} }
@@ -251,7 +253,7 @@ async fn a_cache_reset_makes_the_client_ask_for_everything() {
connection connection
.send( .send(
1, V,
&[Pdu::SerialNotify { &[Pdu::SerialNotify {
session_id: SESSION, session_id: SESSION,
serial: 20, serial: 20,
@@ -262,17 +264,17 @@ async fn a_cache_reset_makes_the_client_ask_for_everything() {
connection.received().await.1, connection.received().await.1,
Pdu::SerialQuery { serial: 7, .. } Pdu::SerialQuery { serial: 7, .. }
)); ));
connection.send(1, &[Pdu::CacheReset]).await; connection.send(V, &[Pdu::CacheReset]).await;
assert_eq!(connection.received().await.1, Pdu::ResetQuery); assert_eq!(connection.received().await.1, Pdu::ResetQuery);
let added = vrp("1.1.1.0/24", 24, 13335); let added = vrp("1.1.1.0/24", 24, 13335);
connection.send_all(&[kept, added], 20).await; connection.send_all(&[kept, added], 20).await;
let update = next_update(&mut updates).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"); let changed = update.changed.expect("known what changed");
assert!(changed.covers(&prefix("8.8.8.0/24"))); assert!(changed.vrps.covers(&prefix("8.8.8.0/24")));
assert!(changed.covers(&prefix("1.1.1.0/24"))); assert!(changed.vrps.covers(&prefix("1.1.1.0/24")));
assert!(!changed.covers(&prefix("193.0.0.0/21")), "it stayed"); assert!(!changed.vrps.covers(&prefix("193.0.0.0/21")), "it stayed");
assert_eq!(rpki.status().serial, Some(20)); assert_eq!(rpki.status().serial, Some(20));
} }
@@ -290,7 +292,7 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
connection connection
.send( .send(
1, V,
&[Pdu::SerialNotify { &[Pdu::SerialNotify {
session_id: SESSION, session_id: SESSION,
serial: 8, 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. // A good change, then the withdrawal of something never announced.
connection connection
.send( .send(
1, V,
&[ &[
Pdu::CacheResponse { Pdu::CacheResponse {
session_id: SESSION, session_id: SESSION,
@@ -313,8 +315,19 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
) )
.await; .await;
// The client gives the session up and comes back; what it has is // The client says what was wrong,
// what it had, and it asks from where it was. 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; let mut again = cache.accept().await;
assert_eq!( assert_eq!(
again.received().await.1, again.received().await.1,
@@ -325,7 +338,7 @@ async fn an_answer_that_contradicts_itself_changes_nothing() {
); );
let current = rpki.current(); let current = rpki.current();
assert_eq!(current.version, 1); assert_eq!(current.version, 1);
assert_eq!(current.table.len(), 1); assert_eq!(current.vrps.len(), 1);
assert!( assert!(
rpki.status() rpki.status()
.last_error .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 /// 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. /// answering in version 0. The client asks again in version 0.
#[tokio::test] #[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 cache = Cache::start().await;
let (rpki, mut updates) = client_of(&[&cache]); let (rpki, mut updates) = client_of(&[&cache]);
let mut connection = cache.accept().await; 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 { if refuses_with_an_error {
connection connection
.send( .send(
@@ -381,7 +607,7 @@ async fn a_cache_that_speaks_version_zero_is_spoken_to_in_it() {
], ],
) )
.await; .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); 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; let mut connection = second.accept().await;
assert_eq!(connection.received().await.1, Pdu::ResetQuery); assert_eq!(connection.received().await.1, Pdu::ResetQuery);
// Still in use meanwhile. // Still in use meanwhile.
assert_eq!(rpki.current().table.len(), 1); assert_eq!(rpki.current().vrps.len(), 1);
connection connection
.send_all( .send_all(
@@ -416,10 +642,10 @@ async fn a_lost_cache_is_replaced_by_the_next_and_the_data_is_kept() {
) )
.await; .await;
let update = next_update(&mut updates).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"); let changed = update.changed.expect("the difference to what there was");
assert!(changed.covers(&prefix("1.1.1.0/24"))); assert!(changed.vrps.covers(&prefix("1.1.1.0/24")));
assert!(!changed.covers(&prefix("193.0.0.0/21"))); assert!(!changed.vrps.covers(&prefix("193.0.0.0/21")));
assert_eq!(rpki.status().cache, Some(second.address())); assert_eq!(rpki.status().cache, Some(second.address()));
} }
@@ -440,16 +666,16 @@ async fn data_that_is_not_confirmed_in_time_is_dropped() {
connection connection
.send_all(&[vrp("193.0.0.0/21", 24, 3333)], 7) .send_all(&[vrp("193.0.0.0/21", 24, 3333)], 7)
.await; .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. // The cache goes and does not come back.
drop(connection); drop(connection);
drop(cache); drop(cache);
let update = next_update(&mut updates).await; 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!(update.changed.is_none(), "anything may have changed");
assert_eq!( 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 Validity::NotFound
); );
let status = rpki.status(); let status = rpki.status();
@@ -468,7 +694,7 @@ async fn a_cache_that_misbehaves_is_given_up_on() {
connection.received().await; connection.received().await;
connection connection
.send( .send(
1, V,
&[Pdu::ErrorReport { &[Pdu::ErrorReport {
code: error_code::NO_DATA_AVAILABLE, code: error_code::NO_DATA_AVAILABLE,
text: "still validating".into(), text: "still validating".into(),
@@ -486,7 +712,7 @@ async fn a_cache_that_misbehaves_is_given_up_on() {
// Half an answer, then noise. // Half an answer, then noise.
connection connection
.send( .send(
1, V,
&[ &[
Pdu::CacheResponse { Pdu::CacheResponse {
session_id: SESSION, session_id: SESSION,
@@ -502,6 +728,6 @@ async fn a_cache_that_misbehaves_is_given_up_on() {
.unwrap(); .unwrap();
let mut connection = cache.accept().await; let mut connection = cache.accept().await;
assert_eq!(connection.received().await.1, Pdu::ResetQuery); 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); assert_eq!(rpki.version(), 0);
} }
+39 -6
View File
@@ -45,8 +45,8 @@ message BgpConfig {
// testing on a closed network and nothing else. // testing on a closed network and nothing else.
bool accept_unconfigured_peers = 9; bool accept_unconfigured_peers = 9;
// Route origin validation with the RPKI (RFC 6811). Left out, routes // Route origin validation (RFC 6811) and AS path verification with the
// are not validated. // RPKI. Left out, routes are not validated.
Rpki rpki = 10; Rpki rpki = 10;
} }
@@ -55,11 +55,17 @@ message BgpConfig {
// for its prefix ("invalid") is refused, from every peer that is not in // 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 // our own AS and does not have accept_rpki_invalid set. Routes that
// nothing is known about are taken as before. // 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 { message Rpki {
// The RPKI-to-Router (RFC 8210) servers of one or more validators, such // The RPKI-to-Router servers (RFC 8210, draft-ietf-sidrops-8210bis) of
// as Routinator, rpki-client with StayRTR, or FORT, in order of // one or more validators, such as Routinator, rpki-client with StayRTR,
// preference. The port is required. One is used at a time, over plain // or FORT, in order of preference. The port is required. One is used at
// TCP: keep it on a network you trust. // a time, over plain TCP: keep it on a network you trust.
repeated TcpEndpoint caches = 1; repeated TcpEndpoint caches = 1;
// How often to ask the cache for changes unprompted, how long to wait // 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 // Which of the peer's routes are taken. Left out, the general checks
// apply and there is no limit and no list. // apply and there is no limit and no list.
ImportPolicy import = 8; 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 // 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 // By default, where route origin validation is configured (rpki), a
// route that is invalid is refused. True takes it all the same. // route that is invalid is refused. True takes it all the same.
bool accept_rpki_invalid = 11; 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. // A prefix, and optionally the more specific prefixes inside it.
+34 -1
View File
@@ -12,7 +12,7 @@ use bgp_rpki::RpkiConfig;
use crate::error::{ConfigError, ImportProblem, OriginateProblem, PeerProblem}; use crate::error::{ConfigError, ImportProblem, OriginateProblem, PeerProblem};
use crate::family::FamilySet; 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}; use crate::proto::config::{self as proto, BgpConfig, OriginatedRoute, Peer};
const DEFAULT_BGP_PORT: u16 = 179; const DEFAULT_BGP_PORT: u16 = 179;
@@ -64,6 +64,8 @@ pub struct PeerConfig {
pub add_path: Option<AddPathSendRecv>, pub add_path: Option<AddPathSendRecv>,
/// Which of the peer's routes are taken. /// Which of the peer's routes are taken.
pub import: ImportPolicy, pub import: ImportPolicy,
/// What the peer is to us, if that was said.
pub role: Option<PeerRole>,
} }
impl Config { impl Config {
@@ -225,6 +227,16 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
None => ImportPolicy::default(), 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 { Ok(PeerConfig {
// A peer without a name goes by its address. // A peer without a name goes by its address.
name: if peer.name.is_empty() { name: if peer.name.is_empty() {
@@ -239,6 +251,7 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
families, families,
add_path, add_path,
import, 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_any_first_as: import.accept_any_first_as,
accept_rpki_invalid: import.accept_rpki_invalid, accept_rpki_invalid: import.accept_rpki_invalid,
accept_aspa_invalid: import.accept_aspa_invalid,
max_as_path_length: match import.max_as_path_length { max_as_path_length: match import.max_as_path_length {
0 => defaults.max_as_path_length, 0 => defaults.max_as_path_length,
length => u16::try_from(length).map_err(|_| ImportProblem::PathLength(length))?, length => u16::try_from(length).map_err(|_| ImportProblem::PathLength(length))?,
@@ -737,6 +751,25 @@ mod tests {
); );
// A peer can be exempt. // 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("").unwrap().accept_rpki_invalid);
assert!( assert!(
import_of("import { accept_rpki_invalid: true }") import_of("import { accept_rpki_invalid: true }")
+26 -12
View File
@@ -43,10 +43,10 @@ use crate::metrics::{
SESSIONS_ESTABLISHED_TOTAL, UPDATE_ERRORS, message_type_name, SESSIONS_ESTABLISHED_TOTAL, UPDATE_ERRORS, message_type_name,
}; };
use crate::panic::contained; use crate::panic::contained;
use crate::policy::{self, HoldDowns, ImportPolicy}; use crate::policy::{self, HoldDowns, ImportPolicy, PeerRole};
use crate::refusals::{PeerRefusals, RefusalLog}; use crate::refusals::{PeerRefusals, RefusalLog};
use crate::routes::{self, Held}; use crate::routes::{self, Held};
use crate::rpki::Validator; use crate::rpki::{Checks, Subject, Validator};
use crate::session::{PeerId, SessionCounters, SessionGuard, SessionHandle, SessionRegistry}; use crate::session::{PeerId, SessionCounters, SessionGuard, SessionHandle, SessionRegistry};
/// Number of outbound messages that may be queued for a session before /// Number of outbound messages that may be queued for a session before
@@ -97,6 +97,8 @@ pub struct PeerSettings {
pub add_path: Option<AddPathSendRecv>, pub add_path: Option<AddPathSendRecv>,
/// Which of the peer's routes are taken. /// Which of the peer's routes are taken.
pub import: Arc<ImportPolicy>, pub import: Arc<ImportPolicy>,
/// What the peer is to us, if that is known.
pub role: Option<PeerRole>,
} }
impl PeerSettings { impl PeerSettings {
@@ -111,6 +113,7 @@ impl PeerSettings {
families: FamilySet::supported(), families: FamilySet::supported(),
add_path: None, add_path: None,
import: Arc::new(ImportPolicy::permissive()), import: Arc::new(ImportPolicy::permissive()),
role: None,
} }
} }
} }
@@ -124,6 +127,7 @@ impl From<&PeerConfig> for PeerSettings {
families: peer.families.clone(), families: peer.families.clone(),
add_path: peer.add_path, add_path: peer.add_path,
import: Arc::new(peer.import.clone()), import: Arc::new(peer.import.clone()),
role: peer.role,
} }
} }
} }
@@ -217,7 +221,7 @@ struct Established {
counters: Arc<SessionCounters>, counters: Arc<SessionCounters>,
/// The same number of routes, where metrics are read from. /// The same number of routes, where metrics are read from.
routes_metric: IntGauge, 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 /// to it: the data to validate against, and where the session is told
/// that it has changed. /// that it has changed.
validation: Option<Validation>, validation: Option<Validation>,
@@ -366,6 +370,8 @@ struct Session {
peer_name: Option<String>, peer_name: Option<String>,
/// Which of the peer's routes are taken. /// Which of the peer's routes are taken.
import: Arc<ImportPolicy>, import: Arc<ImportPolicy>,
/// What the peer is to us, if that is known.
role: Option<PeerRole>,
/// Where what is refused is noted. /// Where what is refused is noted.
refusals: Arc<PeerRefusals>, refusals: Arc<PeerRefusals>,
} }
@@ -415,6 +421,7 @@ impl Session {
server, server,
peer_name: peer.name, peer_name: peer.name,
import: peer.import, import: peer.import,
role: peer.role,
}; };
Ok((session, first_actions)) Ok((session, first_actions))
} }
@@ -667,17 +674,24 @@ impl Session {
rib_peer, rib_peer,
outbound_tx, outbound_tx,
); );
handle.role = self.role;
// Routes from other networks are validated, where there is data // Routes from other networks are validated, where there is data
// to validate against and the peer's policy does not say to take // to validate against and the peer's policy does not say to take
// invalid ones anyway. // invalid ones anyway.
let subject = Subject::of(
&self.import,
self.role,
negotiated.peer_asn,
negotiated.ibgp,
);
let validation = self let validation = self
.server .server
.rpki .rpki
.clone() .clone()
.filter(|_| !negotiated.ibgp && !self.import.accept_rpki_invalid) .zip(subject)
.map(|rpki| Validation { .map(|(rpki, subject)| Validation {
validator: Validator::new(rpki), validator: Validator::new(rpki, subject),
changes: handle.validated(), changes: handle.validate(subject),
}); });
self.established = Some(Established { self.established = Some(Established {
validation, validation,
@@ -715,10 +729,10 @@ impl Session {
peer: established.rib_peer, peer: established.rib_peer,
from: established.from, from: established.from,
policy: &self.import, policy: &self.import,
vrps: established checks: established
.validation .validation
.as_mut() .as_mut()
.map(|validation| validation.validator.table()), .map_or(Checks::NONE, |validation| validation.validator.checks()),
refusals: &self.refusals, refusals: &self.refusals,
}; };
let routes::Applied { outcome, rejected } = routes::apply( let routes::Applied { outcome, rejected } = routes::apply(
@@ -753,10 +767,10 @@ impl Session {
peer: established.rib_peer, peer: established.rib_peer,
from: established.from, from: established.from,
policy: &self.import, policy: &self.import,
vrps: established checks: established
.validation .validation
.as_mut() .as_mut()
.map(|validation| validation.validator.table()), .map_or(Checks::NONE, |validation| validation.validator.checks()),
refusals: &self.refusals, refusals: &self.refusals,
}; };
let routes::Applied { outcome, rejected } = let routes::Applied { outcome, rejected } =
@@ -1176,7 +1190,7 @@ mod tests {
four_octet_asn: true, four_octet_asn: true,
}, },
policy: &ImportPolicy::permissive(), policy: &ImportPolicy::permissive(),
vrps: None, checks: Checks::NONE,
refusals: &RefusalLog::default().of_peer(Ipv4Addr::new(192, 0, 2, 1).into(), "test"), refusals: &RefusalLog::default().of_peer(Ipv4Addr::new(192, 0, 2, 1).into(), "test"),
}; };
routes::apply(&table, &import, &mut Held::default(), &update, None).unwrap(); routes::apply(&table, &import, &mut Held::default(), &update, None).unwrap();
+2
View File
@@ -179,6 +179,8 @@ pub enum PeerProblem {
AddressFamily, AddressFamily,
#[error("add_path has a value that is not one of its settings")] #[error("add_path has a value that is not one of its settings")]
AddPath, AddPath,
#[error("role has a value that is not one of its settings")]
Role,
#[error("import: {0}")] #[error("import: {0}")]
Import(#[from] ImportProblem), Import(#[from] ImportProblem),
} }
+1 -1
View File
@@ -11,7 +11,7 @@ mod panic;
pub mod policy; pub mod policy;
pub mod refusals; pub mod refusals;
mod routes; mod routes;
mod rpki; pub mod rpki;
pub mod server; pub mod server;
pub mod session; pub mod session;
mod sync; mod sync;
+9 -4
View File
@@ -96,14 +96,19 @@ pub static RPKI_VRPS: LazyLock<IntGaugeVec> = LazyLock::new(|| {
.expect("metric registration") .expect("metric registration")
}); });
pub static RPKI_UPDATES: LazyLock<IntCounter> = LazyLock::new(|| { pub static RPKI_ASPAS: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_counter!( register_int_gauge!(
"bgp_rpki_updates_total", "bgp_rpki_aspas",
"Times the validated ROA payloads changed" "Customer ASes with an ASPA in use for AS path verification"
) )
.expect("metric registration") .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(|| { pub static CONNECTIONS_REFUSED: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!( register_int_counter_vec!(
"bgp_connections_refused_total", "bgp_connections_refused_total",
+110 -1
View File
@@ -78,6 +78,8 @@ pub struct ImportPolicy {
pub origin_asns: Option<HashSet<u32>>, pub origin_asns: Option<HashSet<u32>>,
/// Take routes that route origin validation finds invalid (RFC 6811). /// Take routes that route origin validation finds invalid (RFC 6811).
pub accept_rpki_invalid: bool, pub accept_rpki_invalid: bool,
/// Take routes whose path the ASPAs find invalid.
pub accept_aspa_invalid: bool,
} }
impl Default for ImportPolicy { impl Default for ImportPolicy {
@@ -96,6 +98,7 @@ impl Default for ImportPolicy {
prefixes: None, prefixes: None,
origin_asns: None, origin_asns: None,
accept_rpki_invalid: false, accept_rpki_invalid: false,
accept_aspa_invalid: false,
} }
} }
} }
@@ -113,6 +116,7 @@ impl ImportPolicy {
accept_any_first_as: true, accept_any_first_as: true,
max_as_path_length: u16::MAX, max_as_path_length: u16::MAX,
accept_rpki_invalid: true, accept_rpki_invalid: true,
accept_aspa_invalid: true,
..Self::default() ..Self::default()
} }
} }
@@ -143,10 +147,13 @@ pub enum Rejection {
/// The holder of the addresses has said who may originate routes for /// The holder of the addresses has said who may originate routes for
/// them, and it is not this route's origin (RFC 6811). /// them, and it is not this route's origin (RFC 6811).
RpkiInvalid, 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 { impl Rejection {
pub const ALL: [Self; 10] = [ pub const ALL: [Self; 11] = [
Self::BogonPrefix, Self::BogonPrefix,
Self::TooShort, Self::TooShort,
Self::TooSpecific, Self::TooSpecific,
@@ -157,6 +164,7 @@ impl Rejection {
Self::PathTooLong, Self::PathTooLong,
Self::OriginNotAllowed, Self::OriginNotAllowed,
Self::RpkiInvalid, Self::RpkiInvalid,
Self::AspaInvalid,
]; ];
/// Where the reason is in [`Rejection::ALL`]. /// Where the reason is in [`Rejection::ALL`].
@@ -176,6 +184,38 @@ impl Rejection {
Self::PathTooLong => "path too long", Self::PathTooLong => "path too long",
Self::OriginNotAllowed => "origin AS not allowed", Self::OriginNotAllowed => "origin AS not allowed",
Self::RpkiInvalid => "RPKI invalid", 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. /// The AS numbers of a path in order, sets and all.
pub(crate) fn asns(path: Option<&AsPathAttribute>) -> impl Iterator<Item = u32> + '_ { pub(crate) fn asns(path: Option<&AsPathAttribute>) -> impl Iterator<Item = u32> + '_ {
path.into_iter() 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] #[test]
fn an_absurdly_long_path_is_refused() { fn an_absurdly_long_path_is_refused() {
let policy = ImportPolicy::default(); let policy = ImportPolicy::default();
+50 -35
View File
@@ -10,10 +10,11 @@ use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage};
use bgp_primitives::ip_prefix::Nlri; use bgp_primitives::ip_prefix::Nlri;
use bgp_primitives::parser::EncodeError; use bgp_primitives::parser::EncodeError;
use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable}; 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::policy::{self, From, ImportPolicy, Rejection};
use crate::refusals::PeerRefusals; use crate::refusals::PeerRefusals;
use crate::rpki::Checks;
/// The most routes of one peer that are held aside as invalid. Far more /// 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. /// 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) peer: PeerIndex,
pub(crate) from: From, pub(crate) from: From,
pub(crate) policy: &'a ImportPolicy, pub(crate) policy: &'a ImportPolicy,
/// What the RPKI says about who may originate what, if this peer's /// What the peer's routes are validated against, with the data of the
/// routes are validated against it. /// RPKI.
pub(crate) vrps: Option<&'a VrpTable>, pub(crate) checks: Checks<'a>,
/// Where what is refused is noted. /// Where what is refused is noted.
pub(crate) refusals: &'a PeerRefusals, pub(crate) refusals: &'a PeerRefusals,
} }
/// A peer's routes that are not in the routing table because route origin /// A peer's routes that are not in the routing table because the RPKI's
/// validation found them invalid. /// 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 /// 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. /// 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 /// 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 /// 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 /// `attributes_wire` is the attributes as the decoder kept them, if it did
/// (see `DecodedUpdate::attributes_wire`). With it the table finds the /// (see `DecodedUpdate::attributes_wire`). With it the table finds the
@@ -133,6 +134,9 @@ pub(crate) fn apply(
let path_verdict = import let path_verdict = import
.policy .policy
.check_path(&update.path_attributes, &import.from); .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> { let attributes = |next_hop: Option<&NlriNextHop>| -> Result<Arc<AttrSet>, EncodeError> {
if let Some(set) = if let Some(set) =
@@ -168,13 +172,19 @@ pub(crate) fn apply(
nlri, nlri,
|route| { |route| {
import.policy.check_prefix(&route.prefix)?; import.policy.check_prefix(&route.prefix)?;
match import.vrps.map(|vrps| vrps.validate(&route.prefix, origin)) { let origin_invalid = import
Some(Validity::Invalid) => { .checks
invalid.push(*route); .vrps
Err(Rejection::RpkiInvalid) .is_some_and(|vrps| vrps.validate(&route.prefix, origin) == Validity::Invalid);
} let rejection = if origin_invalid {
_ => Ok(()), 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, &mut refuse,
); );
@@ -202,6 +212,22 @@ pub(crate) fn apply(
Ok(applied) 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. /// The AS numbers of the UPDATE's AS_PATH.
fn path_of(update: &UpdateMessage) -> Arc<[u32]> { fn path_of(update: &UpdateMessage) -> Arc<[u32]> {
let path = update let path = update
@@ -255,13 +281,11 @@ pub(crate) fn revalidate(
suspects: &[Nlri], suspects: &[Nlri],
) -> Applied { ) -> Applied {
let mut applied = Applied::default(); 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; return applied;
}; }
let validity = |route: &Nlri, attrs: &AttrSet| { let mut path = Vec::new();
let origin = Some(attrs.key().origin_as).filter(|origin| *origin != 0);
vrps.validate(&route.prefix, origin)
};
for suspect in suspects { for suspect in suspects {
// As it is in the table now. // As it is in the table now.
@@ -272,25 +296,16 @@ pub(crate) fn revalidate(
else { else {
continue; continue;
}; };
if validity(suspect, &route.attrs) != Validity::Invalid { let Some(rejection) = checks.rejection(&suspect.prefix, &route.attrs, &mut path) else {
continue; continue;
} };
applied.outcome += table.withdraw(import.peer, std::slice::from_ref(suspect)); applied.outcome += table.withdraw(import.peer, std::slice::from_ref(suspect));
applied.rejected += 1; applied.rejected += 1;
let as_path: Arc<[u32]> = match route.attrs.attributes() { route.attrs.as_path(&mut path);
Some((attributes, _)) => {
let path = attributes.iter().find_map(|attribute| match attribute {
PathAttribute::AsPath(path) => Some(path),
_ => None,
});
policy::asns(path).collect()
}
None => Arc::default(),
};
import.refusals.note( import.refusals.note(
std::slice::from_ref(suspect), std::slice::from_ref(suspect),
Rejection::RpkiInvalid, rejection,
&as_path, &Arc::from(&path[..]),
); );
held.hold(*suspect, &route.attrs); held.hold(*suspect, &route.attrs);
} }
@@ -298,7 +313,7 @@ pub(crate) fn revalidate(
let released: Vec<Nlri> = held let released: Vec<Nlri> = held
.routes .routes
.iter() .iter()
.filter(|(route, attrs)| validity(route, attrs) != Validity::Invalid) .filter(|(route, attrs)| checks.rejection(&route.prefix, attrs, &mut path).is_none())
.map(|(route, _)| *route) .map(|(route, _)| *route)
.collect(); .collect();
for route in released { for route in released {
+350 -59
View File
@@ -1,26 +1,34 @@
//! Route origin validation in the server: where a session gets the data it //! Validation with the RPKI in the server: where a session gets the data
//! validates against, and what happens to routes already taken when that //! it validates against, and what happens to routes already taken when
//! data changes. //! 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 //! A route is validated when it arrives (see [`crate::routes`]). But
//! whether a route is valid is not settled once: the validated ROA //! whether a route is valid is not settled once: the data changes every
//! payloads change every few minutes, and each change can make routes that //! few minutes, and each change can make routes that are in the table
//! are in the table invalid, and routes that were refused as invalid //! invalid, and routes that were refused as invalid acceptable. The
//! acceptable. The [revalidator](spawn_revalidator) deals with the first //! [revalidator](spawn_revalidator) deals with the first by looking
//! by looking through the table; each session deals with the second, from //! through the table; each session deals with the second, from the routes
//! the routes it has [held](crate::routes::Held). //! it has [held](crate::routes::Held).
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use bgp_primitives::ip_prefix::Nlri; use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
use bgp_rib::table::{PeerIndex, RibTable}; use bgp_rib::table::{AttrSet, PeerIndex, RibTable};
use bgp_rpki::{Rpki, Update, Validity, VrpTable}; use bgp_rpki::{Algorithm, AspaTable, Changed, PathValidity, Rpki, Update, Validity, VrpTable};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::{info, warn}; 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}; use crate::session::{SessionHandle, SessionRegistry};
/// How long after new data is published the table is looked through. A /// 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. /// through the table sees what it put there.
const SETTLE: Duration = Duration::from_millis(200); const SETTLE: Duration = Duration::from_millis(200);
/// A session's view of the validation data: the table in use, replaced /// How the AS paths of a peer's routes are verified against the ASPAs.
/// when a newer one is published. Asking whether there is a newer one #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// costs the load of one number, which is done for every UPDATE. pub struct PathCheck {
pub(crate) struct Validator { algorithm: Algorithm,
rpki: Rpki, /// The AS a path has to begin with: the peer's, unless the peer is a
table: Arc<VrpTable>, /// route server, which does not put its own on the path.
version: u64, first_as: Option<u32>,
} }
impl Validator { impl PathCheck {
pub(crate) fn new(rpki: Rpki) -> Self { /// For routes from the peer with AS number `peer_asn`, which is
let Update { version, table, .. } = rpki.current(); /// `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 { Self {
rpki, algorithm,
table, first_as,
version,
} }
} }
/// The table to validate against now. /// What `aspas` say about `path`: its AS numbers in order, the
pub(crate) fn table(&mut self) -> &VrpTable { /// neighbor's first, and whether there is a set among them.
if self.rpki.version() != self.version { pub fn verify(&self, aspas: &AspaTable, path: &[u32], has_set: bool) -> PathValidity {
let Update { version, table, .. } = self.rpki.current(); // Without ASPAs nothing is known about any path.
self.table = table; if aspas.is_empty() {
self.version = version; 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 // Ends only if the client that publishes the data has gone, which
// the server notices for itself. // the server notices for itself.
while updates.changed().await.is_ok() { 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. // What changed is known only from one version to the next.
let changed = match update.changed { if update.version != seen + 1 {
Some(changed) if update.version == seen + 1 => Some(changed), update.changed = None;
_ => None, }
};
seen = update.version; seen = update.version;
let status = rpki.status(); let status = rpki.status();
@@ -88,23 +215,33 @@ pub(crate) fn spawn_revalidator(
RPKI_VRPS RPKI_VRPS
.with_label_values(&["ipv6"]) .with_label_values(&["ipv6"])
.set(status.vrps_ipv6 as i64); .set(status.vrps_ipv6 as i64);
RPKI_ASPAS.set(status.aspas as i64);
RPKI_UPDATES.inc(); RPKI_UPDATES.inc();
tokio::time::sleep(SETTLE).await; tokio::time::sleep(SETTLE).await;
let sessions: Vec<SessionHandle> = registry let sessions: Vec<(SessionHandle, Subject)> = registry
.list() .list()
.into_iter() .into_iter()
.filter(SessionHandle::is_validated) .filter_map(|session| {
let subject = session.validated()?;
Some((session, subject))
})
.collect(); .collect();
let peers: Vec<PeerIndex> = sessions.iter().map(|session| session.rib_peer).collect(); // By peer, for looking up as often as there are routes.
let table = Arc::clone(&update.table); 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); let rib = Arc::clone(&rib);
// Looks at every route, or at every prefix at least: not for a // Looks at every route, or at every prefix at least: not for a
// thread that sessions run on. // thread that sessions run on.
let found = tokio::task::spawn_blocking(move || { let found =
newly_invalid(&rib, &table, changed.as_deref(), &peers) tokio::task::spawn_blocking(move || newly_invalid(&rib, &update, &subjects)).await;
})
.await;
let mut suspects = match found { let mut suspects = match found {
Ok(suspects) => suspects, Ok(suspects) => suspects,
Err(e) => { Err(e) => {
@@ -113,36 +250,65 @@ pub(crate) fn spawn_revalidator(
} }
}; };
info!( info!(
vrps = update.table.len(), vrps,
aspas,
now_invalid = suspects.values().map(Vec::len).sum::<usize>(), now_invalid = suspects.values().map(Vec::len).sum::<usize>(),
sessions = sessions.len(), sessions = sessions.len(),
"RPKI data changed; revalidating routes" "RPKI data changed; revalidating routes"
); );
// Every session, also those with nothing found: each has its // Every session, also those with nothing found: each has its
// held routes to look at again. // held routes to look at again.
for session in sessions { for (session, _) in sessions {
session.revalidate(suspects.remove(&session.rib_peer).unwrap_or_default()); session.revalidate(suspects.remove(&session.rib_peer).unwrap_or_default());
} }
} }
}) })
} }
/// The routes of `peers` in the table that `vrps` finds invalid, by peer. /// The routes in the table that `data` finds invalid, by peer. `subjects`
/// With `changed`, only routes that the VRPs in it cover are looked at: no /// says, for each peer by its number in the table, which checks its routes
/// others can have become invalid. /// 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( fn newly_invalid(
rib: &RibTable, rib: &RibTable,
vrps: &VrpTable, data: &Update,
changed: Option<&VrpTable>, subjects: &[Option<Subject>],
peers: &[PeerIndex],
) -> HashMap<PeerIndex, Vec<Nlri>> { ) -> 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| { let routes = rib.routes_where(usize::MAX, |route| {
changed.is_none_or(|changed| changed.covers(&route.prefix)) let Some(Some(subject)) = subjects.get(route.peer.as_usize()) else {
&& peers.contains(&route.peer) return false;
&& { };
let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0); if subject.origin
vrps.validate(&route.prefix, origin) == Validity::Invalid && 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(); let mut by_peer: HashMap<PeerIndex, Vec<Nlri>> = HashMap::new();
for route in routes { for route in routes {
@@ -153,3 +319,128 @@ fn newly_invalid(
} }
by_peer 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
})
);
}
}
+1
View File
@@ -104,6 +104,7 @@ impl BgpServer {
name: p.name.clone(), name: p.name.clone(),
endpoint: p.endpoint, endpoint: p.endpoint,
remote_asn: p.remote_asn, remote_asn: p.remote_asn,
role: p.role,
}) })
.collect(); .collect();
// The client starts now: with luck the data is there before the // The client starts now: with luck the data is there before the
+21 -14
View File
@@ -23,6 +23,8 @@ use crate::collision::Direction;
use crate::family::FamilySet; use crate::family::FamilySet;
use crate::fsm::Negotiated; use crate::fsm::Negotiated;
use crate::metrics::SESSIONS_ESTABLISHED; use crate::metrics::SESSIONS_ESTABLISHED;
use crate::policy::PeerRole;
use crate::rpki::Subject;
use crate::sync::IgnorePoisonRw; use crate::sync::IgnorePoisonRw;
const EVENT_CHANNEL_CAPACITY: usize = 256; const EVENT_CHANNEL_CAPACITY: usize = 256;
@@ -60,6 +62,8 @@ pub struct ConfiguredPeer {
pub name: String, pub name: String,
pub endpoint: SocketAddr, pub endpoint: SocketAddr,
pub remote_asn: u32, 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. /// Counts kept by a session as it goes, for whoever wants to look.
@@ -117,6 +121,8 @@ pub struct SessionHandle {
pub established_at: SystemTime, pub established_at: SystemTime,
/// Whether the peer is in our own AS. /// Whether the peer is in our own AS.
pub ibgp: bool, 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 /// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
/// AS_PATH is encoded on the wire for this session. /// AS_PATH is encoded on the wire for this session.
pub four_octet_asn: bool, pub four_octet_asn: bool,
@@ -134,9 +140,9 @@ pub struct SessionHandle {
/// How many routes the peer has, and how many were refused. /// How many routes the peer has, and how many were refused.
pub counters: Arc<SessionCounters>, pub counters: Arc<SessionCounters>,
outbound: mpsc::Sender<Message>, outbound: mpsc::Sender<Message>,
/// Where the session is told to look at its routes again, if they are /// Which of the RPKI's checks the peer's routes are subject to, if
/// subject to route origin validation. /// any, and where the session is told to look at its routes again.
revalidate: Option<mpsc::UnboundedSender<Vec<Nlri>>>, validation: Option<(Subject, mpsc::UnboundedSender<Vec<Nlri>>)>,
} }
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@@ -160,6 +166,7 @@ impl SessionHandle {
local_addr, local_addr,
established_at: SystemTime::now(), established_at: SystemTime::now(),
ibgp: negotiated.ibgp, ibgp: negotiated.ibgp,
role: None,
four_octet_asn: negotiated.four_octet_asn, four_octet_asn: negotiated.four_octet_asn,
families: negotiated.families.clone(), families: negotiated.families.clone(),
add_path_receive: negotiated.add_path_receive.clone(), add_path_receive: negotiated.add_path_receive.clone(),
@@ -167,29 +174,29 @@ impl SessionHandle {
rib_peer, rib_peer,
counters: Arc::default(), counters: Arc::default(),
outbound, outbound,
revalidate: None, validation: None,
} }
} }
/// Marks the session's routes as subject to route origin validation. /// Marks the session's routes as subject to validation with the
/// What is sent with [`SessionHandle::revalidate`] arrives at the /// RPKI's data. What is sent with [`SessionHandle::revalidate`]
/// receiver returned. /// arrives at the receiver returned.
pub(crate) fn validated(&mut self) -> mpsc::UnboundedReceiver<Vec<Nlri>> { pub(crate) fn validate(&mut self, subject: Subject) -> mpsc::UnboundedReceiver<Vec<Nlri>> {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
self.revalidate = Some(tx); self.validation = Some((subject, tx));
rx rx
} }
/// Whether the session's routes are subject to route origin /// Which of the RPKI's checks the session's routes are subject to, if
/// validation. /// any.
pub fn is_validated(&self) -> bool { pub fn validated(&self) -> Option<Subject> {
self.revalidate.is_some() self.validation.as_ref().map(|(subject, _)| *subject)
} }
/// Tells the session that the validation data has changed. `suspects` /// Tells the session that the validation data has changed. `suspects`
/// are routes of its peer's in the table that are now invalid. /// are routes of its peer's in the table that are now invalid.
pub(crate) fn revalidate(&self, suspects: Vec<Nlri>) { 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. // Fails only if the session has just ended.
let _ = revalidate.send(suspects); let _ = revalidate.send(suspects);
} }
+42 -12
View File
@@ -3,7 +3,6 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use axum::Json; use axum::Json;
@@ -17,7 +16,9 @@ use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::IpPrefix; use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::table::{PeerIndex, RibTable, Route}; use bgp_rib::table::{PeerIndex, RibTable, Route};
use bgp_rib::{LocalRoute, RibStopped}; 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 bgp_server::session::{ConfiguredPeer, DialState, PeerId, SessionHandle, SessionRegistry};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -76,8 +77,11 @@ pub struct PeerView {
/// policy refused. /// policy refused.
pub rejected_routes: Option<u64>, pub rejected_routes: Option<u64>,
/// How many of the peer's routes are held out of the table as invalid /// 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>, 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. /// Why the last connection attempt or session ended, while in Backoff.
pub last_error: Option<String>, pub last_error: Option<String>,
pub retry_at_unix: Option<u64>, pub retry_at_unix: Option<u64>,
@@ -101,6 +105,7 @@ impl From<&SessionHandle> for PeerView {
routes: Some(s.counters.routes()), routes: Some(s.counters.routes()),
rejected_routes: Some(s.counters.rejected()), rejected_routes: Some(s.counters.rejected()),
held_routes: Some(s.counters.held()), held_routes: Some(s.counters.held()),
role: s.role.map(PeerRole::as_str),
last_error: None, last_error: None,
retry_at_unix: None, retry_at_unix: None,
} }
@@ -132,6 +137,7 @@ impl PeerView {
routes: None, routes: None,
rejected_routes: None, rejected_routes: None,
held_routes: None, held_routes: None,
role: peer.role.map(PeerRole::as_str),
last_error, last_error,
retry_at_unix, retry_at_unix,
} }
@@ -274,6 +280,11 @@ pub struct ReceivedRouteView {
/// peer's are taken regardless; then `invalid`.) Absent where /// peer's are taken regardless; then `invalid`.) Absent where
/// validation is not configured. /// validation is not configured.
pub rpki: Option<&'static str>, 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, pub summary: AttrSummary,
/// The decoded attribute list. MP_REACH_NLRI is not in it: its next hop /// 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. /// 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, /// Who the routes in the table are from: the table knows peers by number,
/// the sessions know which number is whose. /// the sessions know which number is whose.
pub(crate) struct PeerNames { 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. /// What routes are validated against, where they are.
vrps: Option<Arc<VrpTable>>, rpki: Option<Update>,
} }
impl PeerNames { impl PeerNames {
@@ -295,9 +306,17 @@ impl PeerNames {
.registry .registry
.list() .list()
.into_iter() .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(), .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(); let address: Option<IpAddr> = wanted.parse().ok();
self.peers self.peers
.iter() .iter()
.filter(|(_, (peer, name))| { .filter(|(_, (peer, name, _))| {
name.as_deref() == Some(wanted) || address == Some(peer.addr.ip()) name.as_deref() == Some(wanted) || address == Some(peer.addr.ip())
}) })
.map(|(index, _)| *index) .map(|(index, _)| *index)
@@ -316,7 +335,7 @@ impl PeerNames {
/// A view of one route. `None` if the route's session has just ended: /// A view of one route. `None` if the route's session has just ended:
/// the route is on its way out of the table. /// the route is on its way out of the table.
pub(crate) fn view(&self, route: &Route) -> Option<ReceivedRouteView> { 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); let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0);
// What a session stored was accepted by the decoder, and parses. // What a session stored was accepted by the decoder, and parses.
let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default(); let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default();
@@ -328,9 +347,14 @@ impl PeerNames {
path_id: route.path_id, path_id: route.path_id,
best: route.best, best: route.best,
rpki: self rpki: self
.vrps .rpki
.as_ref() .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()), summary: summarize(&attributes, next_hop.as_ref()),
attributes, attributes,
}) })
@@ -568,7 +592,8 @@ pub async fn received_routes(
Ok(Json(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)] #[derive(Debug, Clone, Serialize)]
pub struct RpkiView { pub struct RpkiView {
/// Whether validation is configured at all. /// Whether validation is configured at all.
@@ -582,6 +607,9 @@ pub struct RpkiView {
pub protocol_version: Option<u8>, pub protocol_version: Option<u8>,
pub vrps_ipv4: usize, pub vrps_ipv4: usize,
pub vrps_ipv6: 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>, pub updated_at_unix: Option<u64>,
/// When the data is dropped unless a cache confirms it first. /// When the data is dropped unless a cache confirms it first.
pub expires_at_unix: Option<u64>, pub expires_at_unix: Option<u64>,
@@ -599,6 +627,7 @@ pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
protocol_version: None, protocol_version: None,
vrps_ipv4: 0, vrps_ipv4: 0,
vrps_ipv6: 0, vrps_ipv6: 0,
aspas: 0,
updated_at_unix: None, updated_at_unix: None,
expires_at_unix: None, expires_at_unix: None,
last_error: None, last_error: None,
@@ -617,6 +646,7 @@ pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
protocol_version: Some(status.protocol_version), protocol_version: Some(status.protocol_version),
vrps_ipv4: status.vrps_ipv4, vrps_ipv4: status.vrps_ipv4,
vrps_ipv6: status.vrps_ipv6, vrps_ipv6: status.vrps_ipv6,
aspas: status.aspas,
updated_at_unix: status.updated_at.map(unix_secs), updated_at_unix: status.updated_at.map(unix_secs),
expires_at_unix: status.expires_at.map(unix_secs), expires_at_unix: status.expires_at.map(unix_secs),
last_error: status.last_error, last_error: status.last_error,
+10 -8
View File
@@ -63,7 +63,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<tbody id="peers-body"></tbody> <tbody id="peers-body"></tbody>
</table> </table>
<h1 id="rpki">Route origin validation (RPKI)</h1> <h1 id="rpki">Route origin and path validation (RPKI)</h1>
<div id="rpki-body">...</div> <div id="rpki-body">...</div>
<h1 id="local">Originated routes (<span id="local-count">...</span>)</h1> <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> <span id="lookup-note"></span>
</form> </form>
<table class="data"> <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> <tbody id="received-body"></tbody>
</table> </table>
@@ -175,13 +175,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const up = p.state === 'Established'; const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : ''; const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
return `<tr> 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><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 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 ? 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 ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}</td>
<td>${up ? p.routes : 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('')); }).join(''));
renderPeerChoices(); 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` ? `showing ${state.received.length} of ${st.received_routes}, for ${st.received_prefixes} prefixes`
: `${state.received.length}${st ? `, for ${st.received_prefixes} prefixes` : ''}`; : `${state.received.length}${st ? `, for ${st.received_prefixes} prefixes` : ''}`;
if (!state.received.length) { 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> 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">${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">${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 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' }; const RPKI_STATE_CLASS = { synchronised: 'ok', synchronising: 'warn', connecting: 'bad' };
function renderRpki() { function renderRpki() {
const r = state.rpki, b = $('rpki-body'); const r = state.rpki, b = $('rpki-body');
if (!r) return; if (!r) return;
if (!r.enabled) { setHTML(b, '<span class="dim">Not configured: routes are not validated.</span>'); return; } if (!r.enabled) { setHTML(b, '<span class="dim">Not configured: routes are not validated.</span>'); return; }
const data = r.updated_at_unix 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` : '') (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>'; : '<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>` + 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}.` + ` <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)}` : '')); (r.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
} }
+23 -2
View File
@@ -187,14 +187,35 @@ https://datatracker.ietf.org/doc/html/rfc6811
| Validation of routes from internal peers and of originated routes | ❌ | | Validation of routes from internal peers and of originated routes | ❌ |
| Validity as a step of route selection (RFC 8097 communities) | ❌ | | Validity as a step of route selection (RFC 8097 communities) | ❌ |
### RFC 8210 — The RPKI to Router Protocol, Version 1 (and RFC 6810, version 0) ### draft-ietf-sidrops-aspa-verification — BGP AS_PATH Verification Based on ASPA
https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/
Implemented from version 24 of the draft.
| Feature | Status |
|---------|--------|
| Upstream verification (routes from customers, peers, route servers and route server clients) | ✅ |
| Downstream verification (routes from providers) | ✅ |
| Per-peer role (`role`); paths of peers without one are not verified | ✅ |
| Invalid paths refused, per peer (`accept_aspa_invalid` to take them) | ✅ |
| Routes revalidated when the ASPAs change, in both directions | ✅ |
| Paths with an AS_SET invalid | ✅ |
| Verification on export | ❌ (learned routes are not exported yet) |
| Roles negotiated in OPEN, Only-to-Customer attribute (RFC 9234) | ❌ |
### RFC 8210 — The RPKI to Router Protocol, Version 1 (RFC 6810, version 0; draft-ietf-sidrops-8210bis, version 2)
https://datatracker.ietf.org/doc/html/rfc8210 https://datatracker.ietf.org/doc/html/rfc8210
Version 2 is implemented from version 23 of the draft.
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Reset and serial queries, notifies, cache reset, incremental updates | ✅ | | Reset and serial queries, notifies, cache reset, incremental updates | ✅ |
| Refresh, retry and expire intervals; data dropped when it expires | ✅ | | Refresh, retry and expire intervals; data dropped when it expires | ✅ |
| Fallback to version 0; several caches with failover | ✅ | | Version 2, with fallback to 1 and 0; several caches with failover | ✅ |
| ASPA PDU (version 2) | ✅ |
| Error Reports sent to the cache | ✅ |
| Version 2's ordering of PDUs enforced on what a cache sends | ❌ (any order is taken) |
| Transport over plain TCP | ✅ | | Transport over plain TCP | ✅ |
| Transport over SSH, TLS, TCP-MD5 or TCP-AO | ❌ | | Transport over SSH, TLS, TCP-MD5 or TCP-AO | ❌ |
| Router keys (BGPsec) | 🟡 read and ignored | | Router keys (BGPsec) | 🟡 read and ignored |
Binary file not shown.
+7 -2
View File
@@ -22,6 +22,10 @@ peers {
# Take several routes for a prefix from this peer, if it offers to send # Take several routes for a prefix from this peer, if it offers to send
# them (ADD-PATH). Also ADD_PATH_SEND and ADD_PATH_BOTH. Off by default. # them (ADD-PATH). Also ADD_PATH_SEND and ADD_PATH_BOTH. Off by default.
add_path: ADD_PATH_RECEIVE add_path: ADD_PATH_RECEIVE
# What the peer is to us: PROVIDER, CUSTOMER, PEER, ROUTE_SERVER or
# ROUTE_SERVER_CLIENT. With it, and a validator that has ASPAs, the AS
# paths of the peer's routes are verified, and leaked routes refused.
# role: PEER
# Which of the peer's routes are taken. Without this block the general # Which of the peer's routes are taken. Without this block the general
# checks still apply: no private or reserved prefixes or AS numbers, # checks still apply: no private or reserved prefixes or AS numbers,
# nothing longer than /24 or /48, no default route, and a path that # nothing longer than /24 or /48, no default route, and a path that
@@ -53,8 +57,9 @@ originate {
large_communities: "65000:1:2" large_communities: "65000:1:2"
} }
# Route origin validation: refuse routes whose origin AS is not the one the # Validation with the RPKI: refuse routes whose origin AS is not the one the
# holder of the addresses authorised. Point it at a validator's RTR server. # holder of the addresses authorised, and, from peers with a role, routes
# whose AS path is a leak. Point it at a validator's RTR server.
# rpki { caches { address: "127.0.0.1" port: 3323 } } # rpki { caches { address: "127.0.0.1" port: 3323 } }
# Read-only status page, JSON API and metrics. It has no authentication: # Read-only status page, JSON API and metrics. It has no authentication: