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