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

Route origin validation (RPKI)

+

Route origin and path validation (RPKI)

...

Originated routes (...)

@@ -86,7 +86,7 @@ from - +
prefixfrom peerAS pathnext hoporiginMEDlocal prefcommunitieslarge communitiesRPKIbest
prefixfrom peerAS pathnext hoporiginMEDlocal prefcommunitieslarge communitiesRPKIASPAbest
@@ -175,13 +175,13 @@ from const up = p.state === 'Established'; const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : ''; return ` - ${p.name ? esc(p.name) : '(inbound)'}${esc(p.peer_addr)}${p.peer_asn} + ${p.name ? esc(p.name) : '(inbound)'}${esc(p.peer_addr)}${p.peer_asn}${p.role ? ` ${esc(p.role)}` : ''} ${esc(p.state)}${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH} ${up ? esc(p.local_addr) : DASH}${up ? fmtTime(p.established_at_unix) : DASH} ${up ? age(p.established_at_unix) : DASH}${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH} ${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH} ${up ? p.routes : DASH} - ${up ? (p.rejected_routes ? `${p.rejected_routes}` : 0) + (p.held_routes ? ` (${p.held_routes} held)` : '') : DASH}${detail}`; + ${up ? (p.rejected_routes ? `${p.rejected_routes}` : 0) + (p.held_routes ? ` (${p.held_routes} held)` : '') : DASH}${detail}`; }).join('')); renderPeerChoices(); } @@ -250,27 +250,29 @@ from ? `showing ${state.received.length} of ${st.received_routes}, for ${st.received_prefixes} prefixes` : `${state.received.length}${st ? `, for ${st.received_prefixes} prefixes` : ''}`; if (!state.received.length) { - setHTML(b, `${looking ? 'No route matches.' : 'No routes received from peers.'}`); return; + setHTML(b, `${looking ? 'No route matches.' : 'No routes received from peers.'}`); return; } setHTML(b, state.received.map(r => { const s = r.summary; return ` ${esc(r.prefix)}${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} AS${r.peer_asn}${r.path_id ? ` path ${r.path_id}` : ''}${asPath(s.as_path)} ${s.next_hop ? esc(s.next_hop) : DASH}${s.origin ? esc(s.origin) : DASH}${s.med ?? DASH}${s.local_pref ?? DASH} ${commList(s.communities, true)}${commList(s.large_communities)} - ${r.rpki ? `${esc(r.rpki)}` : DASH}${r.best ? 'best' : ''}`; }).join('')); + ${r.rpki ? `${esc(r.rpki)}` : DASH} + ${r.aspa ? `${esc(r.aspa)}` : DASH}${r.best ? 'best' : ''}`; }).join('')); } - const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim' }; + const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim', unknown: 'dim' }; const RPKI_STATE_CLASS = { synchronised: 'ok', synchronising: 'warn', connecting: 'bad' }; function renderRpki() { const r = state.rpki, b = $('rpki-body'); if (!r) return; if (!r.enabled) { setHTML(b, 'Not configured: routes are not validated.'); return; } const data = r.updated_at_unix - ? `${r.vrps_ipv4} IPv4 and ${r.vrps_ipv6} IPv6 VRPs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` + + ? `${r.vrps_ipv4} IPv4 and ${r.vrps_ipv6} IPv6 VRPs and ${r.aspas} ASPAs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` + (r.expires_at_unix ? `, dropped in ${until(r.expires_at_unix)} unless confirmed again` : '') : 'no data: every route is "not found" and none is refused as invalid'; setHTML(b, `Cache ${esc(r.cache || '?')}: ${esc(r.state)}` + ` (RTR version ${r.protocol_version}). ${data}.` + + (r.updated_at_unix && r.protocol_version < 2 ? '
This cache speaks an RTR version without ASPAs: AS paths are not verified.' : '') + (r.last_error ? `
Last error: ${esc(r.last_error)}` : '')); } diff --git a/docs/RFCs.md b/docs/RFCs.md index 459f33a..1695643 100644 --- a/docs/RFCs.md +++ b/docs/RFCs.md @@ -187,14 +187,35 @@ https://datatracker.ietf.org/doc/html/rfc6811 | Validation of routes from internal peers and of originated routes | ❌ | | Validity as a step of route selection (RFC 8097 communities) | ❌ | -### RFC 8210 — The RPKI to Router Protocol, Version 1 (and RFC 6810, version 0) +### draft-ietf-sidrops-aspa-verification — BGP AS_PATH Verification Based on ASPA +https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/ + +Implemented from version 24 of the draft. + +| Feature | Status | +|---------|--------| +| Upstream verification (routes from customers, peers, route servers and route server clients) | ✅ | +| Downstream verification (routes from providers) | ✅ | +| Per-peer role (`role`); paths of peers without one are not verified | ✅ | +| Invalid paths refused, per peer (`accept_aspa_invalid` to take them) | ✅ | +| Routes revalidated when the ASPAs change, in both directions | ✅ | +| Paths with an AS_SET invalid | ✅ | +| Verification on export | ❌ (learned routes are not exported yet) | +| Roles negotiated in OPEN, Only-to-Customer attribute (RFC 9234) | ❌ | + +### RFC 8210 — The RPKI to Router Protocol, Version 1 (RFC 6810, version 0; draft-ietf-sidrops-8210bis, version 2) https://datatracker.ietf.org/doc/html/rfc8210 +Version 2 is implemented from version 23 of the draft. + | Feature | Status | |---------|--------| | Reset and serial queries, notifies, cache reset, incremental updates | ✅ | | Refresh, retry and expire intervals; data dropped when it expires | ✅ | -| Fallback to version 0; several caches with failover | ✅ | +| Version 2, with fallback to 1 and 0; several caches with failover | ✅ | +| ASPA PDU (version 2) | ✅ | +| Error Reports sent to the cache | ✅ | +| Version 2's ordering of PDUs enforced on what a cache sends | ❌ (any order is taken) | | Transport over plain TCP | ✅ | | Transport over SSH, TLS, TCP-MD5 or TCP-AO | ❌ | | Router keys (BGPsec) | 🟡 read and ignored | diff --git a/fuzz/seeds/rtr/aspa_answer b/fuzz/seeds/rtr/aspa_answer new file mode 100644 index 0000000..264f711 Binary files /dev/null and b/fuzz/seeds/rtr/aspa_answer differ diff --git a/testdata/server.textpb b/testdata/server.textpb index c7bbbb4..61ecf24 100644 --- a/testdata/server.textpb +++ b/testdata/server.textpb @@ -22,6 +22,10 @@ peers { # Take several routes for a prefix from this peer, if it offers to send # them (ADD-PATH). Also ADD_PATH_SEND and ADD_PATH_BOTH. Off by default. add_path: ADD_PATH_RECEIVE + # What the peer is to us: PROVIDER, CUSTOMER, PEER, ROUTE_SERVER or + # ROUTE_SERVER_CLIENT. With it, and a validator that has ASPAs, the AS + # paths of the peer's routes are verified, and leaked routes refused. + # role: PEER # Which of the peer's routes are taken. Without this block the general # checks still apply: no private or reserved prefixes or AS numbers, # nothing longer than /24 or /48, no default route, and a path that @@ -53,8 +57,9 @@ originate { large_communities: "65000:1:2" } -# Route origin validation: refuse routes whose origin AS is not the one the -# holder of the addresses authorised. Point it at a validator's RTR server. +# Validation with the RPKI: refuse routes whose origin AS is not the one the +# holder of the addresses authorised, and, from peers with a role, routes +# whose AS path is a leak. Point it at a validator's RTR server. # rpki { caches { address: "127.0.0.1" port: 3323 } } # Read-only status page, JSON API and metrics. It has no authentication: