From b16227c51f1354e62ddcef2c74e26d43f7d93215 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Mon, 5 Oct 2026 18:10:35 +0200 Subject: [PATCH] Route reflection, with ourselves as next hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route learned from one router of our AS went to no other (RFC 4271 §9.2.1), so every router needed a session with every other. A router at the end of a single link, with one session to its neighbour inside the AS, heard only of what that neighbour learned itself, and nothing of what the neighbour had from the routers behind it. A peer in our own AS can now be made a route reflector client (`route_reflector_client`, RFC 4456). What a client sends goes to all our routers; what the others send goes to the clients. A reflected route gets ORIGINATOR_ID (where it started, if it does not say) and our cluster ID at the front of CLUSTER_LIST; it is not sent back to the router it started at, nor round to a cluster it has been through, and one that comes back anyway is refused as "route reflected back to us". The cluster ID is the router ID unless `cluster_id` says otherwise. The two attributes are now decoded as such rather than kept as bytes, and are never sent to other networks. Unlike a standard reflector, this one is the next hop of what it reflects, as it is of everything it announces: there is no resolving a next hop that is not a neighbour, so a client reaches what lies behind another router through the reflector. Only peers in our own AS can be clients; the configuration refuses the setting for any other. Nothing changes in what other networks are told: Only to Customer marks go along with reflected routes, and every receiving session keeps its own max_prefixes. Co-Authored-By: Claude Opus 5.5 --- crates/bench/benches/full_table.rs | 1 + crates/bench/benches/real_table.rs | 1 + crates/fib/tests/kernel.rs | 2 + crates/integration-tests/src/test_server.rs | 1 + crates/integration-tests/tests/add_path.rs | 1 + crates/integration-tests/tests/admission.rs | 1 + crates/integration-tests/tests/aspa.rs | 1 + crates/integration-tests/tests/collision.rs | 1 + crates/integration-tests/tests/debugging.rs | 1 + crates/integration-tests/tests/dialer.rs | 4 + .../integration-tests/tests/import_policy.rs | 1 + crates/integration-tests/tests/negotiation.rs | 1 + crates/integration-tests/tests/roles.rs | 1 + crates/integration-tests/tests/rpki.rs | 1 + crates/packet/src/attributes.rs | 40 ++++ crates/packet/src/update_decode.rs | 17 +- crates/packet/tests/properties.rs | 18 ++ crates/rib/src/table/mod.rs | 4 + crates/rib/src/table/tests.rs | 2 + crates/server/proto/config.proto | 18 ++ crates/server/src/advertise.rs | 139 +++++++++++- crates/server/src/config.rs | 70 +++++- crates/server/src/connection.rs | 11 + crates/server/src/error.rs | 4 + crates/server/src/export.rs | 205 +++++++++++++++++- crates/server/src/policy.rs | 75 ++++++- crates/server/src/reach.rs | 1 + crates/server/src/server.rs | 1 + crates/server/src/session.rs | 8 +- crates/web/src/api.rs | 1 + .../docs/getting-started/introduction.md | 4 +- docs/content/docs/guides/passing-routes-on.md | 67 +++++- docs/content/docs/reference/configuration.md | 2 + docs/content/docs/reference/rfcs.md | 14 +- 34 files changed, 693 insertions(+), 26 deletions(-) diff --git a/crates/bench/benches/full_table.rs b/crates/bench/benches/full_table.rs index 50af1f3..c327dc6 100644 --- a/crates/bench/benches/full_table.rs +++ b/crates/bench/benches/full_table.rs @@ -164,6 +164,7 @@ fn into_table(wire: &[Vec], routes: usize, threads: usize) { address: Ipv4Addr::new(192, 0, 2, peer as u8 + 1).into(), interface: 0, ibgp: false, + reflector_client: false, }) .expect("room for the peer") }) diff --git a/crates/bench/benches/real_table.rs b/crates/bench/benches/real_table.rs index 24977ea..93451dd 100644 --- a/crates/bench/benches/real_table.rs +++ b/crates/bench/benches/real_table.rs @@ -283,6 +283,7 @@ fn add_peers(table: &RibTable, dump: &Dump, peers: &[usize]) -> Vec { address: dump.peers[peer].address, interface: 0, ibgp: false, + reflector_client: false, }) .expect("room for the peer") }) diff --git a/crates/fib/tests/kernel.rs b/crates/fib/tests/kernel.rs index b76f201..2b7b3f4 100644 --- a/crates/fib/tests/kernel.rs +++ b/crates/fib/tests/kernel.rs @@ -122,6 +122,7 @@ impl Machine { address: self.neighbor(host).into(), interface: 0, ibgp: false, + reflector_client: false, }) .unwrap() } @@ -721,6 +722,7 @@ async fn an_ipv4_route_goes_by_way_of_an_ipv6_neighbor() { address: neighbor, interface: 0, ibgp: false, + reflector_client: false, }) .unwrap(); // Both families by way of the same neighbor. diff --git a/crates/integration-tests/src/test_server.rs b/crates/integration-tests/src/test_server.rs index 364e7cd..93eda31 100644 --- a/crates/integration-tests/src/test_server.rs +++ b/crates/integration-tests/src/test_server.rs @@ -57,6 +57,7 @@ impl TestServer { pub async fn start_with(local_asn: u32, customize: impl FnOnce(&mut Config)) -> Result { let mut config = Config { router_id: Ipv4Addr::new(127, 0, 0, 1), + cluster_id: Ipv4Addr::new(127, 0, 0, 1), local_asn, hold_time_secs: 90, listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)], diff --git a/crates/integration-tests/tests/add_path.rs b/crates/integration-tests/tests/add_path.rs index 93fc54c..b7f599d 100644 --- a/crates/integration-tests/tests/add_path.rs +++ b/crates/integration-tests/tests/add_path.rs @@ -42,6 +42,7 @@ async fn server_offering( c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "route-server-client".into(), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1).into(), remote_asn: PEER_ASN, diff --git a/crates/integration-tests/tests/admission.rs b/crates/integration-tests/tests/admission.rs index 49cb139..8b74d4b 100644 --- a/crates/integration-tests/tests/admission.rs +++ b/crates/integration-tests/tests/admission.rs @@ -20,6 +20,7 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig { PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), remote_asn: PEER_ASN, diff --git a/crates/integration-tests/tests/aspa.rs b/crates/integration-tests/tests/aspa.rs index 14ccafc..9817baa 100644 --- a/crates/integration-tests/tests/aspa.rs +++ b/crates/integration-tests/tests/aspa.rs @@ -59,6 +59,7 @@ async fn server_with( c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), remote_asn: PEER_ASN, diff --git a/crates/integration-tests/tests/collision.rs b/crates/integration-tests/tests/collision.rs index e178173..c1639c0 100644 --- a/crates/integration-tests/tests/collision.rs +++ b/crates/integration-tests/tests/collision.rs @@ -38,6 +38,7 @@ async fn server_dialing(listener: &TcpListener) -> Result { c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), remote_asn: PEER_ASN, diff --git a/crates/integration-tests/tests/debugging.rs b/crates/integration-tests/tests/debugging.rs index f0f03e5..3de9d83 100644 --- a/crates/integration-tests/tests/debugging.rs +++ b/crates/integration-tests/tests/debugging.rs @@ -169,6 +169,7 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> { c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "refused-demo".into(), endpoint: endpoint.into(), remote_asn: 3333, diff --git a/crates/integration-tests/tests/dialer.rs b/crates/integration-tests/tests/dialer.rs index 3af18af..1c3f79e 100644 --- a/crates/integration-tests/tests/dialer.rs +++ b/crates/integration-tests/tests/dialer.rs @@ -29,6 +29,7 @@ async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result Result<()> { c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "r1".into(), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1).into(), remote_asn: PEER_ASN, @@ -243,6 +245,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> { c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), remote_asn: PEER_ASN, @@ -266,6 +269,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> { c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), remote_asn: PEER_ASN, diff --git a/crates/integration-tests/tests/import_policy.rs b/crates/integration-tests/tests/import_policy.rs index 5a7be02..4d8fd27 100644 --- a/crates/integration-tests/tests/import_policy.rs +++ b/crates/integration-tests/tests/import_policy.rs @@ -44,6 +44,7 @@ async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)> c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "r1".into(), endpoint: endpoint.into(), remote_asn: PEER_ASN, diff --git a/crates/integration-tests/tests/negotiation.rs b/crates/integration-tests/tests/negotiation.rs index 4c2cc08..b3128d8 100644 --- a/crates/integration-tests/tests/negotiation.rs +++ b/crates/integration-tests/tests/negotiation.rs @@ -185,6 +185,7 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> { c.peers = vec![PeerConfig { graceful_restart: Default::default(), groups: Vec::new(), + reflector_client: false, name: "v6-only".into(), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1).into(), remote_asn: PEER_ASN, diff --git a/crates/integration-tests/tests/roles.rs b/crates/integration-tests/tests/roles.rs index 6661729..6a3c981 100644 --- a/crates/integration-tests/tests/roles.rs +++ b/crates/integration-tests/tests/roles.rs @@ -50,6 +50,7 @@ async fn server_with(role: Option, require_role: bool) -> Result), /// An attribute this implementation does not interpret, kept as it /// came so that it can be passed on. UnknownPathAttribute { @@ -198,6 +205,8 @@ impl PathAttribute { Self::As4Path(_) => AS4_PATH, Self::LargeCommunities(_) => LARGE_COMMUNITIES, Self::OnlyToCustomer(_) => ONLY_TO_CUSTOMER, + Self::OriginatorId(_) => ORIGINATOR_ID, + Self::ClusterList(_) => CLUSTER_LIST, Self::UnknownPathAttribute { type_code, .. } => *type_code, } } @@ -275,6 +284,31 @@ impl PathAttribute { })?; Self::OnlyToCustomer(u32::from_be_bytes(asn)) } + ORIGINATOR_ID => { + let id = <[u8; 4]>::try_from(value).map_err(|_| { + malformed(format!( + "ORIGINATOR_ID is {} octets long, not four", + value.len() + )) + })?; + Self::OriginatorId(Ipv4Addr::from(id)) + } + CLUSTER_LIST => { + if value.is_empty() || !value.len().is_multiple_of(4) { + return Err(malformed(format!( + "CLUSTER_LIST is {} octets long, not a multiple of four", + value.len() + ))); + } + Self::ClusterList( + value + .as_chunks::<4>() + .0 + .iter() + .map(|id| Ipv4Addr::from(*id)) + .collect(), + ) + } _ => Self::UnknownPathAttribute { flags, type_code, @@ -332,6 +366,12 @@ impl PathAttribute { Self::As4Path(a) => a.to_wire_with_width(true, out)?, Self::LargeCommunities(a) => a.to_wire(out), Self::OnlyToCustomer(asn) => out.put_u32(*asn), + Self::OriginatorId(id) => out.put_slice(&id.octets()), + Self::ClusterList(ids) => { + for id in ids { + out.put_slice(&id.octets()); + } + } Self::UnknownPathAttribute { payload, .. } => out.put_slice(payload), } Ok(()) diff --git a/crates/packet/src/update_decode.rs b/crates/packet/src/update_decode.rs index 4ccf50f..317f55b 100644 --- a/crates/packet/src/update_decode.rs +++ b/crates/packet/src/update_decode.rs @@ -406,8 +406,6 @@ impl<'a> AttributeReader<'a> { // Known by their definition but not modelled: kept as bytes, once // their length has been checked. let length_is_valid = match raw.type_code { - ORIGINATOR_ID => Some(value.len() == 4), - CLUSTER_LIST => Some(!value.is_empty() && value.len().is_multiple_of(4)), AS4_AGGREGATOR => Some(value.len() == 8), _ => None, }; @@ -899,6 +897,21 @@ mod tests { let reflected = UpdateMessage::decode(&ibgp(), &body(&[], &attributes, &NLRI)).unwrap(); assert!(reflected.errors.is_empty()); assert_eq!(reflected.update.path_attributes.len(), 6); + assert!( + reflected + .update + .path_attributes + .contains(&PathAttribute::OriginatorId(Ipv4Addr::new(10, 0, 0, 1))) + ); + assert!( + reflected + .update + .path_attributes + .contains(&PathAttribute::ClusterList(vec![ + Ipv4Addr::new(10, 0, 0, 2), + Ipv4Addr::new(10, 0, 0, 3) + ])) + ); let mut attributes = mandatory(); attributes.push(local_pref()); diff --git a/crates/packet/tests/properties.rs b/crates/packet/tests/properties.rs index 89c0b4f..9fd3d72 100644 --- a/crates/packet/tests/properties.rs +++ b/crates/packet/tests/properties.rs @@ -292,6 +292,22 @@ fn path_attributes(session: Session) -> impl Strategy }) })), proptest::option::of(any::().prop_map(PathAttribute::OnlyToCustomer)), + // Route reflection's, which only an internal peer sends. + proptest::option::of((any::<[u8; 4]>(), vec(any::<[u8; 4]>(), 1..4))).prop_map( + move |reflected| { + reflected + .filter(|_| session.ibgp) + .map(|(originator, clusters)| { + vec![ + PathAttribute::OriginatorId(Ipv4Addr::from(originator)), + PathAttribute::ClusterList( + clusters.into_iter().map(Ipv4Addr::from).collect(), + ), + ] + }) + .unwrap_or_default() + }, + ), unknown_attributes(), ); (mandatory, optional).prop_map(|(mut attributes, optional)| { @@ -306,6 +322,7 @@ fn path_attributes(session: Session) -> impl Strategy as4, large, otc, + reflected, unknown, ) = optional; attributes.extend( @@ -324,6 +341,7 @@ fn path_attributes(session: Session) -> impl Strategy .into_iter() .flatten(), ); + attributes.extend(reflected); attributes.extend(unknown); attributes }) diff --git a/crates/rib/src/table/mod.rs b/crates/rib/src/table/mod.rs index 544e9c5..83d289d 100644 --- a/crates/rib/src/table/mod.rs +++ b/crates/rib/src/table/mod.rs @@ -58,6 +58,10 @@ pub struct PeerInfo { pub interface: u32, /// Whether the peer is in our own AS. pub ibgp: bool, + /// Whether routes learned from our other routers are passed on to the + /// peer, and its routes to them: whether it is a route reflector + /// client of ours (RFC 4456). + pub reflector_client: bool, } /// A peer's number in a table, given out by [`RibTable::add_peer`]. Small, diff --git a/crates/rib/src/table/tests.rs b/crates/rib/src/table/tests.rs index acf959f..3e1002a 100644 --- a/crates/rib/src/table/tests.rs +++ b/crates/rib/src/table/tests.rs @@ -63,6 +63,7 @@ pub(super) fn external(number: u8) -> PeerInfo { address: IpAddr::from(Ipv4Addr::new(192, 0, 2, number)), interface: 0, ibgp: false, + reflector_client: false, } } @@ -70,6 +71,7 @@ pub(super) fn internal(number: u8) -> PeerInfo { PeerInfo { asn: 64999, ibgp: true, + reflector_client: false, ..external(number) } } diff --git a/crates/server/proto/config.proto b/crates/server/proto/config.proto index 204632e..dab6e8f 100644 --- a/crates/server/proto/config.proto +++ b/crates/server/proto/config.proto @@ -100,6 +100,13 @@ message BgpConfig { // Graceful restart, for every peer that has no `graceful_restart` of its // own. Left out, it is on, as the defaults below have it. GracefulRestart graceful_restart = 14; + + // Where this router passes on routes from one router of our AS to + // another (Peer.route_reflector_client), the cluster it does so for, as + // a dotted-quad: what it puts on such routes, and what it does not take + // back (RFC 4456). Give routers that serve the same clients the same + // cluster ID. Defaults to router_id. + string cluster_id = 15; } // Graceful restart (RFC 4724): BGP restarting without the routes going. @@ -304,6 +311,17 @@ message Peer { // the same numbers on all routers of the AS: a route marked on one is // acted on by another. From 1. repeated uint32 groups = 13; + + // Pass on to this peer, a router of our own AS, the routes learned from + // our other routers, and pass on the routes it sends to all of them: we + // are its route reflector (RFC 4456). Without it, as BGP has it, a route + // learned from one router of our AS goes to no other, and every router + // needs a session with every other. Routes go with this router as next + // hop, so that the peer can reach what lies behind another router + // through this one. Only for a peer in our own AS: other networks are + // told of learned routes by `export` and the roles alone. The peer's + // `export` still has to pass on learned routes. + bool route_reflector_client = 14; } // What is announced to a peer. diff --git a/crates/server/src/advertise.rs b/crates/server/src/advertise.rs index 2bff933..840fb67 100644 --- a/crates/server/src/advertise.rs +++ b/crates/server/src/advertise.rs @@ -23,7 +23,7 @@ //! routes over a restart of ours drops those it has not had again then. use std::collections::{HashMap, HashSet}; -use std::net::{IpAddr, Ipv6Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::sync::Arc; use std::time::Duration; @@ -44,7 +44,7 @@ use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, watch}; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; -use crate::export::{self, Egress, Outgoing, Source}; +use crate::export::{self, Egress, Outgoing, Reflection, Source}; use crate::family::FamilySet; use crate::marks::Control; use crate::metrics::ROUTES_EXPORTED; @@ -59,6 +59,7 @@ pub fn spawn_advertiser( registry: SessionRegistry, local_asn: u32, control: Option, + cluster_id: Ipv4Addr, converged: watch::Receiver, ) -> JoinHandle<()> { let advertiser = Advertiser { @@ -69,6 +70,7 @@ pub fn spawn_advertiser( registry, local_asn, control, + cluster_id, resyncs: Arc::new(Semaphore::new(RESYNCS_AT_ONCE)), exporters: HashMap::new(), }; @@ -96,6 +98,8 @@ struct Advertiser { registry: SessionRegistry, local_asn: u32, control: Option, + /// The cluster this router reflects routes for (RFC 4456). + cluster_id: Ipv4Addr, /// Leave for a session to gather the whole table before it sends it; /// see `Learned::gathered`. resyncs: Arc, @@ -151,6 +155,7 @@ impl Advertiser { rib: Arc::clone(&self.rib), resyncs: Arc::clone(&self.resyncs), control: self.control, + cluster_id: self.cluster_id, session: session.clone(), local_asn: self.local_asn, resend: Arc::clone(&resend), @@ -196,6 +201,7 @@ struct Exporter { rib: Arc, resyncs: Arc, control: Option, + cluster_id: Ipv4Addr, learned: Option, local_rib: LocalRib, changes: Receiver, @@ -498,6 +504,11 @@ impl Exporter { policy: &session.export, groups: &session.groups, control: self.control, + reflection: Reflection { + cluster_id: self.cluster_id, + client: session.reflector_client, + peer_router_id: session.peer_router_id, + }, }; // What the peer is to end up with for each prefix heard of: the // last word on it counts. While the whole table is being given out @@ -507,7 +518,7 @@ impl Exporter { let mut verdicts = learned.gathered.take().unwrap_or_default(); let mut synced = false; // Who the routes are from, asked of the table once for each. - let mut sources: HashMap> = HashMap::new(); + let mut sources: HashMap> = HashMap::new(); for item in news { match item { News::Reset => { @@ -535,17 +546,21 @@ impl Exporter { peer, attrs, } => { - let ibgp = *sources - .entry(peer) - .or_insert_with(|| self.rib.peer(peer).map(|peer| peer.ibgp)); - let dressed = match ibgp { + let source = *sources.entry(peer).or_insert_with(|| { + self.rib.peer(peer).map(|info| Source { + peer, + ibgp: info.ibgp, + reflector_client: info.reflector_client, + router_id: info.router_id, + }) + }); + let dressed = match source { // What we originate ourselves is announced as // that, and not as someone else's route for it. - Some(ibgp) + Some(source) if session.families.carries(&prefix) && !self.announced.contains(&prefix) => { - let source = Source { peer, ibgp }; learned.dressed(&attrs, source, prefix.address_family(), &egress) } // The peer has gone, and its routes with it. @@ -676,6 +691,11 @@ impl Exporter { policy: &session.export, groups: &session.groups, control: self.control, + reflection: Reflection { + cluster_id: self.cluster_id, + client: session.reflector_client, + peer_router_id: session.peer_router_id, + }, }; if !egress.dress(&mut update.path_attributes) { debug!( @@ -1294,6 +1314,7 @@ mod tests { registry.clone(), 65000, None, + Ipv4Addr::new(192, 0, 2, 1), watch::channel(true).1, ); Self { @@ -1329,6 +1350,7 @@ mod tests { address, interface: 0, ibgp: false, + reflector_client: false, }) .unwrap(); let (tx, outbound) = mpsc::channel(capacity); @@ -1398,6 +1420,105 @@ mod tests { } } + /// A route from a router of our AS reaches a route reflector client + /// of ours through the advertiser, marked with where it started and our + /// cluster, with its preference, and with us as next hop. + #[tokio::test] + async fn a_route_from_one_of_our_routers_is_reflected_to_a_client() { + let registry = SessionRegistry::new(); + let rib = Arc::new(RibTable::new(4)); + let cluster = Ipv4Addr::new(10, 0, 0, 100); + spawn_advertiser( + LocalRib::spawn(), + Arc::clone(&rib), + registry.clone(), + 65000, + None, + cluster, + watch::channel(true).1, + ); + let ours = |n: u8, client| PeerInfo { + asn: 65000, + router_id: Ipv4Addr::new(10, 0, 0, n), + address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, n)), + interface: 0, + ibgp: true, + reflector_client: client, + }; + // Another router of ours, not a client, with a route in the table. + let other = rib.add_peer(ours(1, false)).unwrap(); + let came = [ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![64496]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + PathAttribute::LocalPref(LocalPrefPathAttribute(200)), + ]; + let attrs = rib.attrs().intern(&came, None).unwrap(); + rib.announce(other, &attrs, &[Nlri::from(nth_prefix(1))]); + + // A client that is told of learned routes. + let negotiated = Negotiated { + peer_asn: 65000, + ibgp: true, + peer_identifier: Ipv4Addr::new(10, 0, 0, 2), + hold_time_secs: 90, + four_octet_asn: true, + route_refresh: false, + families: FamilySet::supported(), + add_path_receive: FamilySet::default(), + add_path_send: FamilySet::default(), + role_confirmed: false, + extended_next_hop: false, + graceful_restart: None, + }; + let rib_peer = rib.add_peer(ours(2, true)).unwrap(); + let (tx, mut outbound) = mpsc::channel(16); + let mut handle = SessionHandle::new( + PeerId { + addr: (Ipv4Addr::new(192, 0, 2, 2), 179).into(), + asn: 65000, + }, + None, + Direction::Inbound, + (Ipv4Addr::new(192, 0, 2, 100), 179).into(), + &negotiated, + rib_peer, + tx, + ); + handle.export = Arc::new(crate::policy::ExportPolicy { + learned_routes: true, + ..crate::policy::ExportPolicy::default() + }); + handle.reflector_client = true; + let _registration = registry.register(handle); + + let update = tokio::time::timeout(Duration::from_secs(10), async { + loop { + match outbound.recv().await { + Some(Message::Update(update)) if !update.announced_routes.is_empty() => { + return update; + } + Some(_) => {} + None => panic!("the session's queue closed"), + } + } + }) + .await + .expect("the client is told of the route"); + assert_eq!(update.announced_routes, [Nlri::from(nth_prefix(1))]); + assert_eq!( + update.path_attributes, + [ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![64496]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 100))), + PathAttribute::LocalPref(LocalPrefPathAttribute(200)), + PathAttribute::OriginatorId(Ipv4Addr::new(10, 0, 0, 1)), + PathAttribute::ClusterList(vec![cluster]), + ] + ); + } + /// One peer that takes nothing must not keep the others from being /// sent their routes. There used to be one task announcing to every /// session in turn, and it waited for whichever queue was full. diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 05906e0..8b50ed0 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -16,7 +16,7 @@ use crate::address::{Endpoint, PeerAddress}; use crate::error::{ConfigError, ExportProblem, ImportProblem, OriginateProblem, PeerProblem}; use crate::family::FamilySet; use crate::marks::{Control, Marks, Patterns, Scrub}; -use crate::policy::{ExportPolicy, ImportPolicy, ImportRule, PeerRole, PrefixSet}; +use crate::policy::{ExportPolicy, ImportPolicy, ImportRule, PeerRole, PrefixSet, Reflection}; use crate::proto::config::{self as proto, BgpConfig, OriginatedRoute, Peer}; use crate::restart::GracefulRestartConfig; @@ -28,6 +28,9 @@ const MAX_AS_PATH_PREPEND: u32 = 16; /// Validated, Rust-native server configuration derived from the proto config. pub struct Config { pub router_id: Ipv4Addr, + /// The cluster this router reflects routes for (RFC 4456), where it + /// has route reflector clients. + pub cluster_id: Ipv4Addr, pub local_asn: u32, /// The hold time offered to a peer that is not in the configuration /// (see `accept_unconfigured_peers`). Configured peers have their own. @@ -94,6 +97,9 @@ pub struct PeerConfig { /// The groups the session is in, for control communities to name it /// by. pub groups: Vec, + /// Pass on to this peer, in our own AS, the routes learned from our + /// other routers, and its routes to them (RFC 4456). + pub reflector_client: bool, } impl Config { @@ -112,6 +118,14 @@ impl Config { if proto.local_asn == 0 { return Err(ConfigError::LocalAsn); } + let cluster_id = if proto.cluster_id.is_empty() { + router_id + } else { + Ipv4Addr::from_str(&proto.cluster_id) + .ok() + .filter(|id| !id.is_unspecified()) + .ok_or_else(|| ConfigError::ClusterId(proto.cluster_id.clone()))? + }; let listen_endpoints = if proto.listen_endpoints.is_empty() { vec![ @@ -149,6 +163,9 @@ impl Config { if peer.role.is_some() && peer.remote_asn == proto.local_asn { return Err(rejected(PeerProblem::RoleInOwnAs)); } + if peer.reflector_client && peer.remote_asn != proto.local_asn { + return Err(rejected(PeerProblem::ReflectorClientOutsideAs)); + } // A peer is told from the others by its name in what the // dialer reports, and by its address when it connects to us. if peers.iter().any(|earlier| earlier.name == peer.name) { @@ -190,6 +207,10 @@ impl Config { for peer in &mut peers { if peer.remote_asn == proto.local_asn { peer.import.originated = Arc::clone(&originated); + peer.import.reflection = Some(Reflection { + router_id, + cluster_id, + }); } else { peer.import.own_prefixes = Arc::clone(&own_prefixes); // What is ours to say is not taken from other networks, @@ -223,6 +244,7 @@ impl Config { Ok(Self { router_id, + cluster_id, local_asn: proto.local_asn, hold_time_secs: DEFAULT_HOLD_TIME_SECS, listen_endpoints, @@ -375,6 +397,7 @@ fn parse_peer( None => graceful_restart, }, groups: peer.groups, + reflector_client: peer.route_reflector_client, }) } @@ -464,6 +487,7 @@ fn parse_import(import: &proto::ImportPolicy) -> Result defaults.max_as_path_length, length => u16::try_from(length).map_err(|_| ImportProblem::PathLength(length))?, @@ -671,6 +695,50 @@ mod tests { assert!(wrong.to_string().contains("rule 2: "), "{wrong}"); } + /// A route reflector's clients are routers of our own AS; the cluster + /// is the router's own unless given, and what comes from our routers is + /// checked against both. + #[test] + fn route_reflection_is_for_our_own_routers() { + let config = parse(&format!( + "{BASE}peers {{ name: \"client\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 65000 + route_reflector_client: true }} + peers {{ name: \"external\" endpoint {{ address: \"192.0.2.3\" }} remote_asn: 64496 }}" + )) + .unwrap(); + assert_eq!(config.cluster_id, config.router_id); + assert!(config.peers[0].reflector_client); + assert_eq!( + config.peers[0].import.reflection, + Some(Reflection { + router_id: config.router_id, + cluster_id: config.router_id + }) + ); + assert_eq!(config.peers[1].import.reflection, None); + + let given = parse(&format!("{BASE}cluster_id: \"10.0.0.9\"")).unwrap(); + assert_eq!(given.cluster_id, Ipv4Addr::new(10, 0, 0, 9)); + assert_eq!( + rejected(&format!("{BASE}cluster_id: \"0.0.0.0\"")), + ConfigError::ClusterId("0.0.0.0".into()) + ); + let outside = rejected(&format!( + "{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 64496 + route_reflector_client: true }}" + )); + assert!( + matches!( + &outside, + ConfigError::Peer { + problem: PeerProblem::ReflectorClientOutsideAs, + .. + } + ), + "{outside:?}" + ); + } + #[test] fn sessions_are_put_in_groups() { let config = parse(&format!( diff --git a/crates/server/src/connection.rs b/crates/server/src/connection.rs index 263d103..004dbf1 100644 --- a/crates/server/src/connection.rs +++ b/crates/server/src/connection.rs @@ -116,6 +116,8 @@ pub struct PeerSettings { pub export: Arc, /// The groups the session is in, for control communities. pub groups: Arc<[u32]>, + /// Whether the peer is a route reflector client of ours (RFC 4456). + pub reflector_client: bool, /// Graceful restart with the peer. pub graceful_restart: GracefulRestartConfig, } @@ -140,6 +142,7 @@ impl PeerSettings { require_role: false, export, groups: Arc::default(), + reflector_client: false, graceful_restart, } } @@ -158,6 +161,7 @@ impl From<&PeerConfig> for PeerSettings { require_role: peer.require_role, export: Arc::clone(&peer.export), groups: peer.groups.as_slice().into(), + reflector_client: peer.reflector_client, graceful_restart: peer.graceful_restart, } } @@ -573,6 +577,8 @@ struct Session { export: Arc, /// The groups the session is in, for control communities. groups: Arc<[u32]>, + /// Whether the peer is a route reflector client of ours (RFC 4456). + reflector_client: bool, /// Where what is refused is noted. refusals: Arc, /// Graceful restart with the peer, as configured. @@ -636,6 +642,7 @@ impl Session { role: peer.role, export: peer.export, groups: peer.groups, + reflector_client: peer.reflector_client, said: Said::new(), graceful_restart: peer.graceful_restart, leave, @@ -907,6 +914,8 @@ impl Session { address: self.peer_addr.ip(), interface: PeerAddress::from(self.peer_addr).interface, ibgp: negotiated.ibgp, + // The configuration allows it only inside our AS. + reflector_client: self.reflector_client && negotiated.ibgp, }; // A peer back from a restart takes over the routes kept of it. let kept = self @@ -938,6 +947,7 @@ impl Session { handle.role_confirmed = negotiated.role_confirmed; handle.export = Arc::clone(&self.export); handle.groups = Arc::clone(&self.groups); + handle.reflector_client = self.reflector_client && negotiated.ibgp; // 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. @@ -1603,6 +1613,7 @@ mod tests { address: Ipv4Addr::new(192, 0, 2, 1).into(), interface: 0, ibgp: false, + reflector_client: false, }) .unwrap(); let update = UpdateMessage { diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 4c1d218..a4b7684 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -164,6 +164,8 @@ pub enum ServerError { pub enum ConfigError { #[error("invalid router_id {0:?}: must be a dotted-quad IPv4 address other than 0.0.0.0")] RouterId(String), + #[error("invalid cluster_id {0:?}: must be a dotted-quad IPv4 address other than 0.0.0.0")] + ClusterId(String), #[error("local_asn must be non-zero")] LocalAsn, #[error("invalid IP address {0:?}")] @@ -222,6 +224,8 @@ pub enum PeerProblem { Role, #[error("groups has a 0 in it; groups are numbered from 1")] Group, + #[error("route_reflector_client is set for a peer in another AS; it is for routers of our own")] + ReflectorClientOutsideAs, #[error("import: {0}")] Import(#[from] ImportProblem), #[error("export: {0}")] diff --git a/crates/server/src/export.rs b/crates/server/src/export.rs index 0f65a3d..7722f09 100644 --- a/crates/server/src/export.rs +++ b/crates/server/src/export.rs @@ -10,8 +10,9 @@ //! //! - not back to the peer it came from, and not to a peer whose AS is on //! its path already (it would refuse it as a loop); -//! - not from one peer in our own AS to another (RFC 4271 §9.2.1: there is -//! no route reflection here); +//! - not from one peer in our own AS to another (RFC 4271 §9.2.1), unless +//! one of the two is a route reflector client of ours (RFC 4456), and +//! then not back to the router the route started at; //! - not up or across if it came down or across: a route marked "only to //! customer" goes to customers only (RFC 9234 §5, egress, 2); //! - not where its communities say it is not to go: NO_EXPORT and @@ -21,10 +22,15 @@ //! //! What goes is changed as BGP has it: our AS on the path and ourselves as //! next hop towards another network, the MED it came with dropped; the -//! path as it was and a LOCAL_PREF towards a peer in our own AS. To a -//! route server's client the route goes as it came (RFC 7947 §2.2). +//! path as it was and a LOCAL_PREF towards a peer in our own AS. A route +//! passed from one router of our AS to another is marked with where it +//! started and with our cluster (ORIGINATOR_ID, CLUSTER_LIST), and goes, +//! like every route, with ourselves as next hop: there is no resolving a +//! next hop that is not a neighbor, so the peer reaches what lies behind +//! the other router through us. To a route server's client the route goes +//! as it came (RFC 7947 §2.2). -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr}; use bgp_packet::message::{ AsPathAttribute, AsPathSegment, LocalPrefPathAttribute, MpReachNlriPathAttribute, @@ -54,6 +60,20 @@ pub struct Egress<'a> { /// The communities by which routes say who is told of them, if they /// are honoured. pub control: Option, + /// What route reflection needs to know of the session. + pub reflection: Reflection, +} + +/// Route reflection (RFC 4456), as far as one session goes. +#[derive(Debug, Clone, Copy)] +pub struct Reflection { + /// The cluster this router reflects routes for. + pub cluster_id: Ipv4Addr, + /// Whether the peer is a route reflector client of ours. + pub client: bool, + /// The peer's BGP Identifier: a route that started at the peer is not + /// passed back to it. + pub peer_router_id: Ipv4Addr, } /// Where a learned route came from. @@ -62,6 +82,11 @@ pub struct Source { pub peer: PeerIndex, /// Whether that peer is in our own AS. pub ibgp: bool, + /// Whether that peer is a route reflector client of ours. + pub reflector_client: bool, + /// Its BGP Identifier: where the route started inside our AS, if it + /// says nothing else. + pub router_id: Ipv4Addr, } /// A route ready to be announced on a session: its attributes, but for @@ -140,7 +165,12 @@ impl Egress<'_> { if !self.policy.learned_routes || source.peer == self.rib_peer { return None; } - if source.ibgp && towards.ibgp { + // From one router of our AS to another only by way of a route + // reflector, which we are only for our clients: what a client sends + // goes to all our routers, and what the others send goes to the + // clients (RFC 4456 §6). + let reflected = source.ibgp && towards.ibgp; + if reflected && !source.reflector_client && !self.reflection.client { return None; } let mut came_with = next_hop; @@ -192,6 +222,9 @@ impl Egress<'_> { // (RFC 4271 §5.1.4, §5.1.5). PathAttribute::MultiExitDisc(_) => towards.ibgp || transparent, PathAttribute::LocalPref(_) => towards.ibgp, + // Only for routers of our AS, and only for a route that is + // reflected from one to another; put on afresh below. + PathAttribute::OriginatorId(_) | PathAttribute::ClusterList(_) => reflected, // What we do not understand goes on if it says it may, marked // as having passed a router that did not look at it; and no // further if not (RFC 4271 §5). @@ -204,6 +237,9 @@ impl Egress<'_> { } _ => true, }); + if reflected && !self.reflect(&mut attributes, source) { + return None; + } if towards.ibgp { if !attributes .iter() @@ -252,6 +288,39 @@ impl Egress<'_> { } } +impl Egress<'_> { + /// Marks a route reflected from one router of our AS to another with + /// where it started and with our cluster (RFC 4456 §8). Returns whether + /// it goes: not back to the router it started at, nor round a circle + /// of reflectors that has been through our cluster already. + fn reflect(&self, attributes: &mut Vec, source: Source) -> bool { + let originator = attributes + .iter() + .find_map(|attribute| match attribute { + PathAttribute::OriginatorId(originator) => Some(*originator), + _ => None, + }) + .unwrap_or(source.router_id); + if originator == self.reflection.peer_router_id { + return false; + } + let mut clusters = attributes + .iter() + .find_map(|attribute| match attribute { + PathAttribute::ClusterList(clusters) => Some(clusters.clone()), + _ => None, + }) + .unwrap_or_default(); + if clusters.contains(&self.reflection.cluster_id) { + return false; + } + clusters.insert(0, self.reflection.cluster_id); + marks::put(attributes, PathAttribute::OriginatorId(originator)); + marks::put(attributes, PathAttribute::ClusterList(clusters)); + true + } +} + /// Puts `ours` at the front of `path`. fn prepend(path: &mut AsPathAttribute, ours: impl Iterator) { match path.segments.first_mut() { @@ -399,6 +468,7 @@ mod tests { address: Ipv4Addr::new(192, 0, 2, n).into(), interface: 0, ibgp: false, + reflector_client: false, }) .unwrap() }; @@ -445,6 +515,22 @@ mod tests { from_ibgp: bool, groups: &'a [u32], control: Option, + to_client: bool, + from_client: bool, + } + + /// Our cluster, and the BGP Identifiers of the peers a route comes + /// from and goes to. + const CLUSTER: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 9); + const FROM_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); + const TO_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); + + fn reflection(client: bool) -> Reflection { + Reflection { + cluster_id: CLUSTER, + client, + peer_router_id: TO_ID, + } } impl Case<'_> { @@ -457,10 +543,13 @@ mod tests { policy: self.policy, groups: self.groups, control: self.control, + reflection: reflection(self.to_client), }; let source = Source { peer: from, ibgp: self.from_ibgp, + reflector_client: self.from_client, + router_id: FROM_ID, }; egress.outgoing(attributes, None, source, IpVersion::Ipv4) } @@ -474,6 +563,8 @@ mod tests { from_ibgp: false, groups: &[], control: None, + to_client: false, + from_client: false, } } @@ -537,10 +628,13 @@ mod tests { policy: &policy, groups: &[], control: None, + reflection: reflection(false), }; let source = Source { peer: from, ibgp: false, + reflector_client: false, + router_id: FROM_ID, }; assert!( egress @@ -610,6 +704,105 @@ mod tests { assert!(from_internal.out(route(vec![])).is_some()); } + /// Route reflection (RFC 4456): between routers of our AS only by way + /// of a client of ours, marked with where the route started and with + /// our cluster, and never back to where it started or round a circle. + #[test] + fn a_route_is_reflected_to_and_from_clients_only() { + let policy = all_routes(); + let between = |from_client, to_client| Case { + to_ibgp: true, + from_ibgp: true, + from_client, + to_client, + ..plain(&policy) + }; + let preferred = || route(vec![PathAttribute::LocalPref(LocalPrefPathAttribute(200))]); + // Neither a client: as BGP has it, nothing. + assert!(between(false, false).out(preferred()).is_none()); + + // To a client, and from one: marked, the preference as it was, and + // ourselves as next hop. + for (from_client, to_client) in [(false, true), (true, false), (true, true)] { + let outgoing = between(from_client, to_client) + .out(preferred()) + .unwrap_or_else(|| panic!("from {from_client} to {to_client}")); + assert_eq!( + outgoing.attributes, + [ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![3356, 64700]), + PathAttribute::LocalPref(LocalPrefPathAttribute(200)), + PathAttribute::OriginatorId(FROM_ID), + PathAttribute::ClusterList(vec![CLUSTER]), + ] + ); + assert_eq!( + outgoing.next_hop, + NlriNextHop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)) + ); + } + + // Reflected before: where it started stays, our cluster goes first. + let elsewhere = Ipv4Addr::new(10, 0, 0, 5); + let other_cluster = Ipv4Addr::new(10, 0, 0, 7); + let reflected_before = |originator, clusters: Vec| { + route(vec![ + PathAttribute::LocalPref(LocalPrefPathAttribute(200)), + PathAttribute::OriginatorId(originator), + PathAttribute::ClusterList(clusters), + ]) + }; + let outgoing = between(false, true) + .out(reflected_before(elsewhere, vec![other_cluster])) + .unwrap(); + assert!( + outgoing + .attributes + .contains(&PathAttribute::OriginatorId(elsewhere)) + ); + assert!( + outgoing + .attributes + .contains(&PathAttribute::ClusterList(vec![CLUSTER, other_cluster])) + ); + // Not back to the router it started at, nor round to our cluster. + assert!( + between(false, true) + .out(reflected_before(TO_ID, vec![other_cluster])) + .is_none() + ); + assert!( + between(false, true) + .out(reflected_before(elsewhere, vec![other_cluster, CLUSTER])) + .is_none() + ); + + // To another network they are not sent; a route from another + // network to a client of ours gets none. + let from_inside = Case { + from_ibgp: true, + ..plain(&policy) + }; + let outgoing = from_inside + .out(reflected_before(elsewhere, vec![other_cluster])) + .unwrap(); + assert!(!outgoing.attributes.iter().any(|attribute| matches!( + attribute, + PathAttribute::OriginatorId(_) | PathAttribute::ClusterList(_) + ))); + let to_client = Case { + to_ibgp: true, + to_client: true, + ..plain(&policy) + }; + let outgoing = to_client.out(route(vec![])).unwrap(); + assert!(!outgoing.attributes.iter().any(|attribute| matches!( + attribute, + PathAttribute::OriginatorId(_) | PathAttribute::ClusterList(_) + ))); + } + /// RFC 9234 §5, egress: what came down or across goes on down only, /// and what goes down or across is marked. #[test] diff --git a/crates/server/src/policy.rs b/crates/server/src/policy.rs index 98ffbac..c3d2342 100644 --- a/crates/server/src/policy.rs +++ b/crates/server/src/policy.rs @@ -122,6 +122,20 @@ pub struct ImportPolicy { /// What is done to some of the routes that are taken, beyond the /// above: each route by the first rule that holds for it. pub rules: Vec, + /// For a peer in our own AS, what a route passed on inside the AS + /// must not say it has already been through (RFC 4456 §8); `None` for + /// a peer in another AS, whose routes carry no such thing. + pub reflection: Option, +} + +/// Who we are to routes passed on from one router of our AS to another: +/// a route whose ORIGINATOR_ID is our BGP Identifier started here, and one +/// whose CLUSTER_LIST holds our cluster ID has been passed on by us +/// already. Either has gone round in a circle, and is not taken. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Reflection { + pub router_id: Ipv4Addr, + pub cluster_id: Ipv4Addr, } /// What is done to the routes with some communities, or for some @@ -191,6 +205,7 @@ impl Default for ImportPolicy { marks: Marks::default(), local_pref: None, rules: Vec::new(), + reflection: None, } } } @@ -308,6 +323,9 @@ pub enum Rejection { /// inside one: the machine reaches it directly, and a route by way of /// a peer would compete with that. OwnNetwork, + /// The route was passed on inside our AS and has come back: it says it + /// started at this router, or was passed on by it (RFC 4456 §8). + ReflectionLoop, } impl Rejection { @@ -321,7 +339,7 @@ impl Rejection { ) } - pub const ALL: [Self; 19] = [ + pub const ALL: [Self; 20] = [ Self::BogonPrefix, Self::TooShort, Self::TooSpecific, @@ -341,6 +359,7 @@ impl Rejection { Self::OwnPrefix, Self::NextHopUnreachable, Self::OwnNetwork, + Self::ReflectionLoop, ]; /// Where the reason is in [`Rejection::ALL`]. @@ -369,6 +388,7 @@ impl Rejection { Self::OwnPrefix => "prefix is our own", Self::NextHopUnreachable => "next hop unreachable", Self::OwnNetwork => "prefix is a network of this router", + Self::ReflectionLoop => "route reflected back to us", } } } @@ -500,6 +520,16 @@ impl ImportPolicy { .is_some_and(|segment| segment.ordered); let origin = facts.last.filter(|_| ends_in_sequence); if from.ibgp { + if let Some(us) = self.reflection { + let came_back = attributes.iter().any(|attribute| match attribute { + PathAttribute::OriginatorId(originator) => *originator == us.router_id, + PathAttribute::ClusterList(clusters) => clusters.contains(&us.cluster_id), + _ => false, + }); + if came_back { + return Err(Rejection::ReflectionLoop); + } + } return Ok(origin); } @@ -1100,6 +1130,49 @@ mod tests { ); } + /// A route passed on inside our AS that says it started here, or that + /// it was passed on by our cluster already, has gone round in a circle + /// (RFC 4456 §8). + #[test] + fn a_reflected_route_that_comes_back_is_not_taken() { + let us = Ipv4Addr::new(10, 0, 0, 1); + let cluster = Ipv4Addr::new(10, 0, 0, 9); + let policy = ImportPolicy { + reflection: Some(Reflection { + router_id: us, + cluster_id: cluster, + }), + ..ImportPolicy::default() + }; + let internal = RouteSource { + peer_asn: 1299, + ibgp: true, + ..EXTERNAL + }; + let reflected = |originator, clusters: &[Ipv4Addr]| { + let mut attributes = path(&[]); + attributes.push(PathAttribute::OriginatorId(originator)); + attributes.push(PathAttribute::ClusterList(clusters.to_vec())); + attributes + }; + let elsewhere = Ipv4Addr::new(10, 0, 0, 5); + let other_cluster = Ipv4Addr::new(10, 0, 0, 7); + assert_eq!( + policy + .check_path(&reflected(elsewhere, &[other_cluster]), &internal) + .map(drop), + Ok(()) + ); + assert_eq!( + policy.check_path(&reflected(us, &[other_cluster]), &internal), + Err(Rejection::ReflectionLoop) + ); + assert_eq!( + policy.check_path(&reflected(elsewhere, &[other_cluster, cluster]), &internal), + Err(Rejection::ReflectionLoop) + ); + } + #[test] fn the_path_must_begin_with_the_peers_as_unless_it_is_a_route_server() { let policy = ImportPolicy::default(); diff --git a/crates/server/src/reach.rs b/crates/server/src/reach.rs index 89286a1..88436db 100644 --- a/crates/server/src/reach.rs +++ b/crates/server/src/reach.rs @@ -284,6 +284,7 @@ mod tests { address: address.into(), interface: 0, ibgp: false, + reflector_client: false, }) .unwrap(), address, diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs index c6a6b6e..9a85495 100644 --- a/crates/server/src/server.rs +++ b/crates/server/src/server.rs @@ -249,6 +249,7 @@ impl BgpServer { self.registry.clone(), self.config.local_asn, self.config.control, + self.config.cluster_id, self.restarts.convergence().watch(), ), )); diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index 42b8045..e8b413f 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -6,7 +6,7 @@ //! the registry to enumerate live sessions and queue outbound messages. use std::collections::HashMap; -use std::net::SocketAddr; +use std::net::{Ipv4Addr, SocketAddr}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime}; @@ -167,6 +167,10 @@ pub struct SessionHandle { pub export: Arc, /// The groups the session is in, for control communities. pub groups: Arc<[u32]>, + /// Whether the peer is a route reflector client of ours (RFC 4456). + pub reflector_client: bool, + /// The peer's BGP Identifier. + pub peer_router_id: Ipv4Addr, outbound: mpsc::Sender, /// 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. @@ -206,6 +210,8 @@ impl SessionHandle { counters: Arc::default(), export: Arc::default(), groups: Arc::default(), + reflector_client: false, + peer_router_id: negotiated.peer_identifier, outbound, validation: None, } diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index 49b08c7..0cc43e0 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -1096,6 +1096,7 @@ mod tests { address: Ipv4Addr::new(192, 0, 2, n).into(), interface: 0, ibgp: false, + reflector_client: false, }) .unwrap() }); diff --git a/docs/content/docs/getting-started/introduction.md b/docs/content/docs/getting-started/introduction.md index 7366164..b8b4684 100644 --- a/docs/content/docs/getting-started/introduction.md +++ b/docs/content/docs/getting-started/introduction.md @@ -61,8 +61,8 @@ Ferrix provides the following capabilities: Ferrix 0.2.2 does not support the following features: -- Route reflection, per-client best path selection for route servers, and - ADD-PATH for advertised routes. +- Per-client best path selection for route servers, and ADD-PATH for + advertised routes. - TCP Authentication Option (TCP-AO), TCP MD5 signatures, and the Generalized TTL Security Mechanism (GTSM). - Long-Lived Graceful Restart, Bidirectional Forwarding Detection (BFD), diff --git a/docs/content/docs/guides/passing-routes-on.md b/docs/content/docs/guides/passing-routes-on.md index bf885d2..620fe57 100644 --- a/docs/content/docs/guides/passing-routes-on.md +++ b/docs/content/docs/guides/passing-routes-on.md @@ -49,8 +49,8 @@ rules cannot be disabled: - To the neighbor from which the route was received. - To a neighbor whose AS number is in the AS path of the route. -- From one IBGP neighbor to another IBGP neighbor. Route reflection is not - supported. +- From one IBGP neighbor to another IBGP neighbor, unless one of them is a + route reflector client. See [Route Reflection](#route-reflection). - To an EBGP neighbor, if the route has the `NO_EXPORT` community; to any neighbor, if the route has the `NO_ADVERTISE` community. @@ -63,6 +63,7 @@ each type of neighbor. |---|---|---|---|---| | EBGP | Local AS prepended | Local address of the session | Removed | Not sent | | IBGP | Unchanged | Local address of the session | Unchanged | Sent; 100 if the route has none | +| IBGP, reflected | Unchanged | Local address of the session | Unchanged | Sent unchanged | | `ROUTE_SERVER_CLIENT` (RFC 7947) | Unchanged | Unchanged, if the client can use it | Unchanged | Not sent | Ferrix advertises unknown optional transitive attributes with the Partial @@ -256,6 +257,65 @@ routes and Ferrix advertises them to a second neighbor, the second neighbor receives all routes after 2.1 seconds, with an average of four routes in each UPDATE message. +## Route Reflection + +By default, Ferrix does not advertise a route learned from an IBGP +neighbor to another IBGP neighbor (RFC 4271). Every router of the local AS +then requires an IBGP session with every other router. Route reflection +(RFC 4456) removes that requirement: a router that is a route reflector +advertises routes between its IBGP neighbors. + +To make an IBGP neighbor a route reflector client, configure the following +statements: + +```textproto +peers { + name: "router-3" + remote_asn: 64500 + route_reflector_client: true + export { learned_routes: true } +} +``` + +Ferrix then advertises routes as follows: + +- Routes from a client go to all IBGP neighbors, clients and others. +- Routes from an IBGP neighbor that is not a client go to the clients + only. +- Routes from EBGP neighbors go to all IBGP neighbors, as without route + reflection. + +The export policy of the receiving neighbor must enable `learned_routes`. +`route_reflector_client` is permitted only for IBGP neighbors; Ferrix +rejects the configuration otherwise. Advertisement to EBGP neighbors is +not affected. + +### Next Hop of Reflected Routes + +Ferrix advertises a reflected route with the local address of the session +as next hop, as it does every route. A standard route reflector does not +change the next hop. Ferrix does, because it does not resolve next hops +that are not directly connected: a client that is not connected to the +router that learned the route can then still use it, and sends the +traffic through the route reflector. + +### Loop Prevention + +When Ferrix reflects a route, it adds the ORIGINATOR_ID attribute, which +contains the BGP identifier of the router that learned the route, if the +route does not already have it. It also prepends its cluster ID to the +CLUSTER_LIST attribute. Ferrix does not reflect a route back to the router +in its ORIGINATOR_ID, and does not reflect a route whose CLUSTER_LIST +contains its cluster ID. Ferrix rejects a route from an IBGP neighbor +whose ORIGINATOR_ID is the local router ID, or whose CLUSTER_LIST contains +the local cluster ID, with the reason `route reflected back to us`. + +The cluster ID is the router ID by default. Configure the top-level +`cluster_id` statement to give two route reflectors that serve the same +clients the same cluster ID. + +Ferrix does not send ORIGINATOR_ID and CLUSTER_LIST to EBGP neighbors. + ## Verifying Route Advertisement The metric `bgp_routes_exported_total{change}` counts learned routes that @@ -269,6 +329,7 @@ display them, use the neighbor's own tools. client receives the same best path. - ADD-PATH is not supported for advertisement; only the best path is advertised. -- Route reflection is not supported. +- Best path selection does not use ORIGINATOR_ID and CLUSTER_LIST as tie + breakers (RFC 4456 Section 9). - UPDATE messages are generated separately for each session, even when several sessions receive identical routes. diff --git a/docs/content/docs/reference/configuration.md b/docs/content/docs/reference/configuration.md index f4fc454..db185f0 100644 --- a/docs/content/docs/reference/configuration.md +++ b/docs/content/docs/reference/configuration.md @@ -55,6 +55,7 @@ Top-level statements. | `kernel` [Kernel](#kernel) | Disabled | Enables kernel route installation (Linux only). | | `export` [ExportPolicy](#exportpolicy) | Originated routes only | Specifies the export policy for neighbors that have no `export` statement. | | `control_communities` bool | `false` | Enables control communities, which restrict the neighbors to which a route is advertised and prepend the local AS number on its way to them. See [Control Communities](@/docs/guides/passing-routes-on.md#control-communities). | +| `cluster_id` string | `router_id` | Specifies the cluster ID that the router uses as a route reflector, as a dotted-quad IPv4 address. See [Route Reflection](@/docs/guides/passing-routes-on.md#route-reflection). | | `graceful_restart` [GracefulRestart](#gracefulrestart) | Enabled | Specifies graceful restart for neighbors that have no `graceful_restart` statement. Its `restart_time_seconds` also limits how long Ferrix waits at startup for neighbors to send their routes. | ## Peer @@ -75,6 +76,7 @@ Statements for a BGP neighbor. | `require_role` bool | `false` | Rejects the session if the neighbor does not advertise a BGP role. Requires `role`. | | `export` [ExportPolicy](#exportpolicy) | Top-level `export` | Specifies the export policy for the neighbor. Replaces the top-level `export` statement. | | `graceful_restart` [GracefulRestart](#gracefulrestart) | Top-level `graceful_restart` | Specifies graceful restart for the neighbor. Each statement that is set replaces the corresponding top-level statement. | +| `route_reflector_client` bool | `false` | Makes the IBGP neighbor a route reflector client: Ferrix advertises to it the routes learned from other IBGP neighbors, and advertises its routes to them. Permitted only for IBGP neighbors. See [Route Reflection](@/docs/guides/passing-routes-on.md#route-reflection). | | `groups` uint32, repeated | None | Specifies the groups of the session, by number, for control communities that name sessions by group. Use the same numbers on all routers in the local AS. Range: 1 through 4,294,967,295. See [Session Groups](@/docs/guides/passing-routes-on.md#session-groups). | ## TcpEndpoint diff --git a/docs/content/docs/reference/rfcs.md b/docs/content/docs/reference/rfcs.md index 9cf230f..3e66219 100644 --- a/docs/content/docs/reference/rfcs.md +++ b/docs/content/docs/reference/rfcs.md @@ -44,7 +44,7 @@ The base protocol specification. | §9 | Adj-RIB-In (per-peer table of received routes) | ✅ | | §9 | Locally originated routes, advertised to all established peers | ✅ | | §9 | Advertisement of the best path for each prefix to neighbors (`export.learned_routes`), with the local AS prepended and the local address as next hop; MED and LOCAL_PREF not sent to EBGP neighbors; LOCAL_PREF sent to IBGP neighbors (§5.1.4, §5.1.5, §9.1.3) | ✅ | -| §9.2.1 | Routes learned from an IBGP neighbor not advertised to other IBGP neighbors (no route reflection) | ✅ | +| §9.2.1 | Routes learned from an IBGP neighbor not advertised to other IBGP neighbors, except by route reflection (RFC 4456) | ✅ | | §9.1.3 | Routes not advertised to a neighbor whose AS is in the AS path | ✅ | | §5 | Unknown optional transitive attributes advertised with the Partial bit set; unknown non-transitive attributes not advertised | ✅ | | §9.2 | Hold timer reset on any received message (not only KEEPALIVE) | ✅ | @@ -107,6 +107,18 @@ The base protocol specification. | End-of-RIB marker sent and received | ✅ | | Route selection deferred until neighbors' End-of-RIB (for the removal of kept kernel routes and End-of-RIB markers for learned routes) | ✅ | +### RFC 4456 — BGP Route Reflection + + +| Feature | Status | +|---|---| +| Route reflector: routes from clients to all IBGP neighbors, from other IBGP neighbors to clients | ✅ | +| ORIGINATOR_ID and CLUSTER_LIST added on reflection, not sent to EBGP neighbors | ✅ | +| Routes with the local router ID as ORIGINATOR_ID, or the local cluster ID in CLUSTER_LIST, rejected | ✅ | +| Configurable cluster ID | ✅ | +| Next hop unchanged on reflection | ❌ The route reflector is the next hop: next hops that are not directly connected are not resolved | +| ORIGINATOR_ID and CLUSTER_LIST length as best path tie breakers (§9) | ❌ | + ### RFC 8538 — Notification Message Support for BGP Graceful Restart