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