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
+2475 -350

No files matched your search

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