diff --git a/bin/bgp/README.md b/bin/bgp/README.md index 7e6a707..f2fe027 100644 --- a/bin/bgp/README.md +++ b/bin/bgp/README.md @@ -44,13 +44,33 @@ counted in `bgp_connections_refused_total`. `accept_unconfigured_peers: true` lets any host open a session as any AS; it exists for tests on a closed network. +## What is taken from a peer + +Every route a peer announces is judged before it goes into the routing +table (`import` in a peer's configuration; see `config.proto`): + +- Refused for any peer unless turned off: prefixes that are not routed on + the Internet (private, documentation, multicast, reserved), prefixes longer + than /24 or /48, a default route, paths with a private or reserved AS + number, and paths that do not begin with the peer's own AS. +- Always refused: a path with our own AS number in it. +- If configured: a list of the prefixes the peer may announce and of the AS + numbers its routes may originate from. +- `max_prefixes`: one route more ends the session with Cease / Maximum + Number of Prefixes Reached and keeps the peer down for ten minutes. + +A refused route is dropped, along with the route it would have replaced. +`bgp_routes_rejected_total` counts them by reason, and the status page shows +how many routes each peer has and how many were refused. + ## Trying it without a router `fake_peer` is a minimal BGP speaker that connects to the daemon, announces a prefix, prints every UPDATE it receives and, with `--echo`, re-announces each received prefix back with its own ASN prepended (dropping NO_EXPORT routes). -That makes originated prefixes show up in the received table as if they had -travelled through a neighbouring AS: +The prefix it announces shows up in the received table. The ones it echoes +have been through our AS already, so they are refused as loops and counted +in the peer's refused routes: ``` cargo run -- testdata/server.textpb diff --git a/crates/bench/benches/full_table.rs b/crates/bench/benches/full_table.rs index 4c55887..16e3cf8 100644 --- a/crates/bench/benches/full_table.rs +++ b/crates/bench/benches/full_table.rs @@ -105,7 +105,9 @@ fn main() { .build() .expect("a runtime"); runtime.block_on(async { - let server = TestServer::start(65000).await.expect("a server"); + // An AS number that is in none of the generated paths: a route + // with the server's own AS in its path is refused as a loop. + let server = TestServer::start(4_200_000_000).await.expect("a server"); let mut senders = Vec::new(); for (index, messages) in wire.iter().enumerate() { let peer = index as u32 + 1; diff --git a/crates/integration-tests/tests/add_path.rs b/crates/integration-tests/tests/add_path.rs index 55d175f..33d9104 100644 --- a/crates/integration-tests/tests/add_path.rs +++ b/crates/integration-tests/tests/add_path.rs @@ -16,6 +16,7 @@ use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; use bgp_rib::{LocalRoute, LocalRouteAttrs}; use bgp_server::config::PeerConfig; use bgp_server::family::{FamilySet, SUPPORTED}; +use bgp_server::policy::ImportPolicy; use eyre::Result; use serde_json::Value; @@ -46,6 +47,7 @@ async fn server_offering( local_address: None, families: FamilySet::supported(), add_path, + import: ImportPolicy::permissive(), }]; c.originate = originate; // Keep the dialer out of the way. diff --git a/crates/integration-tests/tests/admission.rs b/crates/integration-tests/tests/admission.rs index 7ce6fda..c57dcd6 100644 --- a/crates/integration-tests/tests/admission.rs +++ b/crates/integration-tests/tests/admission.rs @@ -8,6 +8,7 @@ use bgp_integration_tests::test_peer::TestPeer; use bgp_integration_tests::test_server::TestServer; use bgp_server::config::PeerConfig; use bgp_server::family::FamilySet; +use bgp_server::policy::ImportPolicy; use eyre::Result; use tokio::net::TcpListener; @@ -24,15 +25,13 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig { local_address: None, families: FamilySet::supported(), add_path: None, + import: ImportPolicy::permissive(), } } /// Whether the server closed the connection without sending anything. async fn closed_unanswered(peer: &mut TestPeer) -> bool { - matches!( - tokio::time::timeout(TIMEOUT, peer.recv()).await, - Ok(Err(_)) - ) + matches!(tokio::time::timeout(TIMEOUT, peer.recv()).await, Ok(Err(_))) } /// A host that is not a configured peer gets nothing: no OPEN to answer, diff --git a/crates/integration-tests/tests/collision.rs b/crates/integration-tests/tests/collision.rs index 192d37a..7b1e082 100644 --- a/crates/integration-tests/tests/collision.rs +++ b/crates/integration-tests/tests/collision.rs @@ -18,6 +18,7 @@ use bgp_rib::{LocalRoute, LocalRouteAttrs}; use bgp_server::collision::Direction; use bgp_server::config::PeerConfig; use bgp_server::family::FamilySet; +use bgp_server::policy::ImportPolicy; use eyre::Result; use tokio::net::TcpListener; @@ -42,6 +43,7 @@ async fn server_dialing(listener: &TcpListener) -> Result { local_address: None, families: FamilySet::supported(), add_path: None, + import: ImportPolicy::permissive(), }]; }) .await diff --git a/crates/integration-tests/tests/dialer.rs b/crates/integration-tests/tests/dialer.rs index d4e751e..7a831c2 100644 --- a/crates/integration-tests/tests/dialer.rs +++ b/crates/integration-tests/tests/dialer.rs @@ -12,6 +12,7 @@ use bgp_primitives::ip_prefix::IpPrefix; use bgp_rib::{LocalRoute, LocalRouteAttrs}; use bgp_server::config::PeerConfig; use bgp_server::family::FamilySet; +use bgp_server::policy::ImportPolicy; use bgp_server::session::DialState; use eyre::Result; use tokio::net::TcpListener; @@ -33,6 +34,7 @@ async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result Result<()> { local_address: None, families: FamilySet::supported(), add_path: None, + import: ImportPolicy::permissive(), }]; }) .await?; @@ -235,6 +238,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> { local_address: Some(Ipv4Addr::LOCALHOST.into()), families: FamilySet::supported(), add_path: None, + import: ImportPolicy::permissive(), }]; }) .await?; @@ -252,6 +256,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> { local_address: Some(Ipv4Addr::new(192, 0, 2, 1).into()), families: FamilySet::supported(), add_path: None, + import: ImportPolicy::permissive(), }]; }) .await?; diff --git a/crates/integration-tests/tests/import_policy.rs b/crates/integration-tests/tests/import_policy.rs new file mode 100644 index 0000000..3a2ea85 --- /dev/null +++ b/crates/integration-tests/tests/import_policy.rs @@ -0,0 +1,280 @@ +//! What is taken from a peer: the checks every route goes through, and the +//! limit on how many a peer may have. + +use std::collections::BTreeSet; +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::errors::{BgpError, CeaseSubcode}; +use bgp_packet::message::{ + AsPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute, + UpdateMessage, +}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_server::config::PeerConfig; +use bgp_server::family::FamilySet; +use bgp_server::policy::{ImportPolicy, PrefixSet}; +use bgp_server::session::DialState; +use eyre::Result; +use tokio::net::TcpListener; + +/// AS numbers as they are on the Internet: the checks refuse the private +/// ones the other tests use. +const LOCAL_ASN: u32 = 1299; +const PEER_ASN: u32 = 3333; +const TIMEOUT: Duration = Duration::from_secs(5); + +fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() +} + +/// A server with one configured peer, at the address the test connects +/// from, whose routes are judged by `import`. Returns the listener that +/// keeps the server's own dialling occupied. +async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let endpoint = listener.local_addr()?; + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.accept_unconfigured_peers = false; + c.connect_retry = Duration::from_millis(50); + c.peers = vec![PeerConfig { + name: "r1".into(), + endpoint, + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: None, + families: FamilySet::supported(), + add_path: None, + import, + }]; + }) + .await?; + Ok((server, listener)) +} + +async fn established(server: &TestServer) -> Result { + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + Ok(peer) +} + +/// Announces IPv4 `prefixes` with the AS path `path`. +async fn announce(peer: &mut TestPeer, prefixes: &[&str], path: &[u32]) -> Result<()> { + peer.send(&Message::Update(UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(path.to_vec()), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + ], + announced_routes: prefixes.iter().map(|p| prefix(p).into()).collect(), + })) + .await +} + +/// Waits until the server has dealt with `count` announced routes of the +/// session's, taken or refused, and returns the prefixes in its table. +async fn table_after(server: &TestServer, taken: u64, refused: u64) -> Result> { + let counters = server.registry.list()[0].counters.clone(); + tokio::time::timeout(TIMEOUT, async { + while (counters.routes(), counters.rejected()) != (taken, refused) { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .map_err(|_| { + eyre::eyre!( + "the server took {} routes and refused {}; expected {taken} and {refused}", + counters.routes(), + counters.rejected() + ) + })?; + Ok(server + .received_routes() + .iter() + .map(|route| route.prefix.to_string()) + .collect()) +} + +fn set(prefixes: &[&str]) -> BTreeSet { + prefixes.iter().map(|p| p.to_string()).collect() +} + +/// With nothing configured for a peer but its address and AS, its routes +/// still go through the checks that hold for anyone: only space that is +/// routed on the Internet, nothing too specific, a path that begins with +/// the peer and has no reserved AS in it, and never one that has been +/// through us. Everything a peer sent used to be taken. +#[tokio::test] +async fn test_routes_that_no_peer_should_announce_are_refused() -> Result<()> { + let (server, _listener) = server_with(ImportPolicy::default()).await?; + let mut peer = established(&server).await?; + + // One UPDATE with good and bad prefixes: the good ones are taken. + announce( + &mut peer, + &[ + "193.0.0.0/21", + "10.0.0.0/8", // private + "193.0.10.0/25", // more specific than is routed + "0.0.0.0/0", // default + "193.0.16.0/24", + ], + &[PEER_ASN], + ) + .await?; + assert_eq!( + table_after(&server, 2, 3).await?, + set(&["193.0.0.0/21", "193.0.16.0/24"]) + ); + + // Paths that are refused whatever the prefix: not the peer's own, + // with a private AS in it, and one that has been through us. + announce(&mut peer, &["194.0.1.0/24"], &[174, PEER_ASN]).await?; + announce(&mut peer, &["194.0.2.0/24"], &[PEER_ASN, 64512]).await?; + announce(&mut peer, &["194.0.3.0/24"], &[PEER_ASN, LOCAL_ASN, 174]).await?; + assert_eq!( + table_after(&server, 2, 6).await?, + set(&["193.0.0.0/21", "193.0.16.0/24"]) + ); + Ok(()) +} + +/// A route that is refused takes the place of the one the peer had +/// announced before, which goes: the peer no longer has the old route, +/// and the new one is not acceptable. +#[tokio::test] +async fn test_a_refused_route_replaces_the_one_that_was_taken() -> Result<()> { + let (server, _listener) = server_with(ImportPolicy::default()).await?; + let mut peer = established(&server).await?; + + announce(&mut peer, &["193.0.0.0/21", "193.0.16.0/24"], &[PEER_ASN]).await?; + assert_eq!(table_after(&server, 2, 0).await?.len(), 2); + + announce(&mut peer, &["193.0.0.0/21"], &[PEER_ASN, 64512]).await?; + assert_eq!(table_after(&server, 1, 1).await?, set(&["193.0.16.0/24"])); + Ok(()) +} + +/// A peer with a list of what it may announce is held to it: its own +/// prefixes, originated by the AS numbers given. +#[tokio::test] +async fn test_a_peer_is_held_to_its_prefix_list_and_origins() -> Result<()> { + let mut prefixes = PrefixSet::default(); + prefixes.allow(prefix("193.0.0.0/21"), 24); + let (server, _listener) = server_with(ImportPolicy { + prefixes: Some(prefixes), + origin_asns: Some([PEER_ASN, 2121].into()), + ..ImportPolicy::default() + }) + .await?; + let mut peer = established(&server).await?; + + // Its own, and a more specific that the list allows. + announce(&mut peer, &["193.0.0.0/21", "193.0.4.0/24"], &[PEER_ASN]).await?; + // A customer of its that is listed as an origin. + announce(&mut peer, &["193.0.6.0/24"], &[PEER_ASN, 2121]).await?; + // Someone else's prefix, as a hijack or a leak would be. + announce(&mut peer, &["8.8.8.0/24"], &[PEER_ASN]).await?; + // Its own prefix, but passed on from an origin that is not listed. + announce(&mut peer, &["193.0.7.0/24"], &[PEER_ASN, 15169]).await?; + + assert_eq!( + table_after(&server, 3, 2).await?, + set(&["193.0.0.0/21", "193.0.4.0/24", "193.0.6.0/24"]) + ); + Ok(()) +} + +/// A peer that sends more routes than it is allowed is cut off and told +/// why, and kept down for a while: neither its connections are taken nor +/// is it dialled. A session that came straight back would only fill up +/// again. +#[tokio::test] +async fn test_a_peer_over_its_limit_is_cut_off_and_kept_down() -> Result<()> { + let (server, listener) = server_with(ImportPolicy { + max_prefixes: Some(3), + hold_down: Duration::from_millis(1500), + ..ImportPolicy::default() + }) + .await?; + // The server dials the peer as well; that connection is left unanswered + // until the end. + let dialled = tokio::time::timeout(TIMEOUT, listener.accept()).await??; + + let mut peer = established(&server).await?; + // Refused routes do not count towards the limit, and replacing a + // route does not either. + announce( + &mut peer, + &["193.0.0.0/24", "193.0.1.0/24", "10.0.0.0/8"], + &[PEER_ASN], + ) + .await?; + announce(&mut peer, &["193.0.1.0/24", "193.0.2.0/24"], &[PEER_ASN]).await?; + assert_eq!(table_after(&server, 3, 1).await?.len(), 3); + + announce(&mut peer, &["193.0.3.0/24"], &[PEER_ASN]).await?; + let notification = tokio::time::timeout(TIMEOUT, async { + loop { + if let Message::Notification(notification) = peer.recv().await? { + return Ok::<_, eyre::Report>(notification); + } + } + }) + .await??; + assert_eq!( + notification.error, + BgpError::Cease(CeaseSubcode::MaxPrefixes) + ); + drop(peer); + tokio::time::timeout(TIMEOUT, async { + while !server.received_routes().is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + + // Held down: a new connection gets nothing. + let mut early = TestPeer::connect(server.addr).await?; + assert!( + matches!( + tokio::time::timeout(TIMEOUT, early.recv()).await, + Ok(Err(_)) + ), + "a peer that is held down is not answered" + ); + // Nor is it dialled: once the connection the server had open to it is + // gone, the dialer waits out the hold-down and says so. + drop(dialled); + let reason = tokio::time::timeout(TIMEOUT, async { + loop { + if let Some((_, DialState::Backoff { last_error, .. })) = + server.registry.configured_peers().into_iter().next() + && last_error.contains("held down") + { + return last_error; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + assert!(reason.contains("limit of routes"), "{reason}"); + + // When the time is up the peer is dialled again, and taken back. + tokio::time::timeout(TIMEOUT, listener.accept()).await??; + let mut later = TestPeer::connect(server.addr).await?; + later + .handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + Ok(()) +} diff --git a/crates/integration-tests/tests/negotiation.rs b/crates/integration-tests/tests/negotiation.rs index debcead..e024f50 100644 --- a/crates/integration-tests/tests/negotiation.rs +++ b/crates/integration-tests/tests/negotiation.rs @@ -17,6 +17,7 @@ use bgp_primitives::ip_prefix::IpPrefix; use bgp_rib::{LocalRoute, LocalRouteAttrs}; use bgp_server::config::PeerConfig; use bgp_server::family::{FamilySet, SUPPORTED}; +use bgp_server::policy::ImportPolicy; use eyre::{Result, bail}; const LOCAL_ASN: u32 = 65000; @@ -189,6 +190,7 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> { local_address: None, families: only(AfiSafi::IPV6_UNICAST), add_path: None, + import: ImportPolicy::permissive(), }]; // Keep the dialer out of the way. c.connect_retry = Duration::from_secs(3600); diff --git a/crates/primitives/src/ip_prefix.rs b/crates/primitives/src/ip_prefix.rs index 8080a1f..e332446 100644 --- a/crates/primitives/src/ip_prefix.rs +++ b/crates/primitives/src/ip_prefix.rs @@ -88,6 +88,25 @@ impl IpPrefix { &self.address[..usize::from(self.length.div_ceil(8))] } + /// The prefix of `length` bits that covers this one: itself, if it is + /// no longer than that. + pub fn shortened(&self, length: u8) -> Self { + if length >= self.length { + return *self; + } + let mut address = [0u8; 16]; + let whole = usize::from(length / 8); + address[..whole].copy_from_slice(&self.address[..whole]); + if !length.is_multiple_of(8) { + address[whole] = self.address[whole] & (u8::MAX << (8 - length % 8)); + } + Self { + address_family: self.address_family, + length, + address, + } + } + /// Returns true if `other` is equal to or more specific than `self`, i.e. /// `self` covers `other`. Prefixes of different address families never /// contain each other. @@ -344,6 +363,21 @@ mod tests { } } + /// A shortened prefix is the same as one made from the same + /// address with the shorter length, and covers the original. + #[test] + fn shortening_keeps_the_leading_bits( + version: bool, bytes: [u8; 16], length in 0u8..=128, shorter in 0u8..=128, + ) { + let version = if version { IpVersion::Ipv6 } else { IpVersion::Ipv4 }; + let length = length.min(version.bits()); + let prefix = IpPrefix::new(version, bytes, length).unwrap(); + let shortened = prefix.shortened(shorter); + let expected = IpPrefix::new(version, bytes, shorter.min(length)).unwrap(); + prop_assert_eq!(shortened, expected); + prop_assert!(shortened.contains(&prefix)); + } + /// `contains` agrees with comparing the leading bits by hand. #[test] fn contains_is_a_comparison_of_leading_bits( diff --git a/crates/rib/src/table/tests.rs b/crates/rib/src/table/tests.rs index ceef3af..32266f4 100644 --- a/crates/rib/src/table/tests.rs +++ b/crates/rib/src/table/tests.rs @@ -715,7 +715,13 @@ fn keys_of_one_shard_differ_in_the_bits_their_table_uses() { "shard {shard} has {} of 64000 keys", counts[shard] ); - assert_eq!(tags[shard].len(), 128, "shard {shard}"); + // Nearly all of the 128 there are; taken from the bits the shard + // is picked by, there would be two. + assert!( + tags[shard].len() > 100, + "shard {shard} has {} tags", + tags[shard].len() + ); } assert_eq!(spread(u64::MAX, 3), 2); assert_eq!(spread(0, 3), 0); diff --git a/crates/server/proto/config.proto b/crates/server/proto/config.proto index 8164668..d927bbf 100644 --- a/crates/server/proto/config.proto +++ b/crates/server/proto/config.proto @@ -110,6 +110,68 @@ message Peer { // direction and address family, only if the peer agrees. Defaults to // off: one route for each prefix, as in plain BGP. AddPath add_path = 7; + + // Which of the peer's routes are taken. Left out, the general checks + // apply and there is no limit and no list. + ImportPolicy import = 8; +} + +// What is taken from a peer. Every route is judged before it goes into +// the routing table. One that is refused is dropped, and so is the route +// it would have replaced. +// +// A route whose AS path holds our own AS number is always refused. +message ImportPolicy { + // The most routes the peer may have at a time. One more ends the session + // with Cease / Maximum Number of Prefixes Reached, and the peer is kept + // down for hold_down_seconds. Set it somewhat above what the peer is + // expected to send. Zero is no limit. + uint32 max_prefixes = 1; + + // How long the peer is kept down after going over max_prefixes. Neither + // side's connections are taken meanwhile. Defaults to 600. + uint32 hold_down_seconds = 2; + + // By default, prefixes that are not routed on the Internet are refused + // (private, loopback, link-local, documentation, multicast and reserved + // space), and so are routes with a reserved, private or documentation AS + // number anywhere in their path. True takes them. + bool accept_bogons = 3; + + // By default a default route (0.0.0.0/0 or ::/0) is refused. True takes + // one, as from an upstream that is asked to send it. + bool accept_default_route = 4; + + // The longest prefixes taken. Default to 24 and 48, the longest that are + // routed between networks. + uint32 max_ipv4_prefix_length = 5; + uint32 max_ipv6_prefix_length = 6; + + // By default the AS path of a route has to begin with the peer's own AS + // number. True takes any, as is needed for an Internet exchange's route + // server, which does not put its AS on the path. + bool accept_any_first_as = 7; + + // The longest AS path taken, counted in AS numbers. Defaults to 64. + uint32 max_as_path_length = 8; + + // The prefixes the peer may announce. Empty allows any. This is the + // list generated for a peer from the routing registries (with bgpq4, + // say). + repeated PrefixRange prefixes = 9; + + // The AS numbers the peer's routes may originate from: the last AS of + // the path. Empty allows any. + repeated uint32 origin_asns = 10; +} + +// A prefix, and optionally the more specific prefixes inside it. +message PrefixRange { + // In CIDR notation, e.g. "193.0.0.0/21". + string prefix = 1; + // The longest prefix inside it that is also allowed. Left out, only the + // prefix itself is. + uint32 max_length = 2; } // The directions ADD-PATH can be offered for. diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index e564cae..1399a5c 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -5,12 +5,13 @@ use std::time::Duration; use bgp_packet::community; use bgp_packet::message::OriginPathAttribute; use bgp_packet::open::AddPathSendRecv; -use bgp_primitives::constants::AfiSafi; +use bgp_primitives::constants::{AfiSafi, IpVersion}; use bgp_primitives::ip_prefix::IpPrefix; use bgp_rib::{LocalRoute, LocalRouteAttrs}; -use crate::error::{ConfigError, OriginateProblem, PeerProblem}; +use crate::error::{ConfigError, ImportProblem, OriginateProblem, PeerProblem}; use crate::family::FamilySet; +use crate::policy::{ImportPolicy, PrefixSet}; use crate::proto::config::{self as proto, BgpConfig, OriginatedRoute, Peer}; const DEFAULT_BGP_PORT: u16 = 179; @@ -57,6 +58,8 @@ pub struct PeerConfig { /// The directions ADD-PATH (RFC 7911) is offered to this peer for, on /// every one of `families`. `None` offers it for neither. pub add_path: Option, + /// Which of the peer's routes are taken. + pub import: ImportPolicy, } impl Config { @@ -207,6 +210,11 @@ fn parse_peer(peer: Peer) -> Result { Err(_) => return Err(PeerProblem::AddPath), }; + let import = match peer.import.as_ref() { + Some(import) => parse_import(import)?, + None => ImportPolicy::default(), + }; + Ok(PeerConfig { // A peer without a name goes by its address. name: if peer.name.is_empty() { @@ -220,6 +228,75 @@ fn parse_peer(peer: Peer) -> Result { local_address, families, add_path, + import, + }) +} + +fn parse_import(import: &proto::ImportPolicy) -> Result { + let defaults = ImportPolicy::default(); + // A length of zero is the field left out. + let longest = |field, value: u32, version: IpVersion, default: u8| match value { + 0 => Ok(default), + value => u8::try_from(value) + .ok() + .filter(|length| *length <= version.bits()) + .ok_or(ImportProblem::PrefixLength { + field, + value, + longest: version.bits(), + }), + }; + + let mut prefixes = PrefixSet::default(); + for range in &import.prefixes { + let prefix = + IpPrefix::try_from(range.prefix.as_str()).map_err(|problem| ImportProblem::Prefix { + prefix: range.prefix.clone(), + problem, + })?; + let longest = prefix.address_family().bits(); + let max_length = match range.max_length { + 0 => prefix.length(), + max_length => u8::try_from(max_length) + .ok() + .filter(|max| (prefix.length()..=longest).contains(max)) + .ok_or(ImportProblem::MaxLength { + prefix: range.prefix.clone(), + max_length, + longest, + })?, + }; + prefixes.allow(prefix, max_length); + } + + Ok(ImportPolicy { + max_prefixes: Some(import.max_prefixes).filter(|limit| *limit > 0), + hold_down: match import.hold_down_seconds { + 0 => defaults.hold_down, + seconds => Duration::from_secs(seconds.into()), + }, + accept_bogons: import.accept_bogons, + accept_default_route: import.accept_default_route, + max_ipv4_length: longest( + "max_ipv4_prefix_length", + import.max_ipv4_prefix_length, + IpVersion::Ipv4, + defaults.max_ipv4_length, + )?, + max_ipv6_length: longest( + "max_ipv6_prefix_length", + import.max_ipv6_prefix_length, + IpVersion::Ipv6, + defaults.max_ipv6_length, + )?, + accept_any_first_as: import.accept_any_first_as, + max_as_path_length: match import.max_as_path_length { + 0 => defaults.max_as_path_length, + length => u16::try_from(length).map_err(|_| ImportProblem::PathLength(length))?, + }, + prefixes: Some(prefixes).filter(|prefixes| !prefixes.is_empty()), + origin_asns: Some(import.origin_asns.iter().copied().collect()) + .filter(|asns: &std::collections::HashSet| !asns.is_empty()), }) } @@ -499,4 +576,94 @@ mod tests { ConfigError::RouterId("0.0.0.0".into()) ); } + + fn import_of(body: &str) -> Result { + parse(&format!( + "{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}" + )) + .map(|config| config.peers[0].import.clone()) + } + + /// A peer with nothing said about its routes still gets the general + /// checks; what is said overrides them one by one. + #[test] + fn an_import_policy_starts_from_the_general_checks() { + assert_eq!(import_of("").unwrap(), ImportPolicy::default()); + assert_eq!(import_of("import {}").unwrap(), ImportPolicy::default()); + + let policy = import_of( + "import { + max_prefixes: 1000 hold_down_seconds: 60 + accept_default_route: true accept_any_first_as: true + max_ipv4_prefix_length: 32 max_as_path_length: 20 + prefixes { prefix: \"193.0.0.0/21\" max_length: 24 } + prefixes { prefix: \"2001:67c:2e8::/48\" } + origin_asns: 3333 origin_asns: 2121 + }", + ) + .unwrap(); + assert_eq!(policy.max_prefixes, Some(1000)); + assert_eq!(policy.hold_down, Duration::from_secs(60)); + assert!(policy.accept_default_route && policy.accept_any_first_as); + assert!(!policy.accept_bogons); + assert_eq!((policy.max_ipv4_length, policy.max_ipv6_length), (32, 48)); + assert_eq!(policy.max_as_path_length, 20); + assert_eq!(policy.origin_asns, Some([3333, 2121].into())); + + let prefixes = policy.prefixes.unwrap(); + let p = |s: &str| IpPrefix::try_from(s).unwrap(); + assert!(prefixes.allows(&p("193.0.4.0/24"))); + assert!(!prefixes.allows(&p("193.0.4.0/25"))); + assert!(prefixes.allows(&p("2001:67c:2e8::/48"))); + assert!(!prefixes.allows(&p("2001:67c:2e8:1::/64"))); + } + + #[test] + fn a_bad_import_policy_is_rejected_with_the_reason() { + let problem = |body: &str| match import_of(&format!("import {{ {body} }}")) { + Err(ConfigError::Peer { + problem: PeerProblem::Import(problem), + .. + }) => problem, + other => panic!("{body}: {:?}", other.map(|_| ())), + }; + assert!(matches!( + problem("prefixes { prefix: \"193.0.0.0\" }"), + ImportProblem::Prefix { .. } + )); + // Shorter than the prefix itself, and longer than an address. + assert_eq!( + problem("prefixes { prefix: \"193.0.0.0/21\" max_length: 20 }"), + ImportProblem::MaxLength { + prefix: "193.0.0.0/21".into(), + max_length: 20, + longest: 32 + } + ); + assert!(matches!( + problem("prefixes { prefix: \"193.0.0.0/21\" max_length: 33 }"), + ImportProblem::MaxLength { .. } + )); + assert_eq!( + problem("max_ipv4_prefix_length: 33"), + ImportProblem::PrefixLength { + field: "max_ipv4_prefix_length", + value: 33, + longest: 32 + } + ); + assert_eq!( + problem("max_as_path_length: 70000"), + ImportProblem::PathLength(70000) + ); + // The message says which peer and which entry. + assert_eq!( + import_of("import { prefixes { prefix: \"193.0.0.0/21\" max_length: 20 } }") + .err() + .unwrap() + .to_string(), + "peer \"r1\": import: prefix \"193.0.0.0/21\": max_length 20 is not between \ + its own length and 32" + ); + } } diff --git a/crates/server/src/connection.rs b/crates/server/src/connection.rs index 78f1c96..a6b30e1 100644 --- a/crates/server/src/connection.rs +++ b/crates/server/src/connection.rs @@ -38,8 +38,9 @@ use crate::metrics::{ MESSAGES_RECEIVED, MESSAGES_SENT, SESSIONS_ESTABLISHED_TOTAL, UPDATE_ERRORS, message_type_name, }; use crate::panic::contained; +use crate::policy::{self, HoldDowns, ImportPolicy}; use crate::routes; -use crate::session::{PeerId, SessionGuard, SessionHandle, SessionRegistry}; +use crate::session::{PeerId, SessionCounters, SessionGuard, SessionHandle, SessionRegistry}; /// Number of outbound messages that may be queued for a session before /// senders are back-pressured. @@ -66,6 +67,8 @@ pub struct ServerContext { pub registry: SessionRegistry, /// Used to resolve connection collisions (RFC 4271 §6.8). pub collisions: CollisionTable, + /// Peers that are being kept down for going over their limits. + pub hold_downs: HoldDowns, } /// What is configured for the peer at the other end of a connection. @@ -81,10 +84,14 @@ pub struct PeerSettings { pub families: FamilySet, /// The directions to offer the peer ADD-PATH for (RFC 7911). pub add_path: Option, + /// Which of the peer's routes are taken. + pub import: Arc, } impl PeerSettings { /// For a peer that connected to us and is not in the configuration. + /// Such peers are taken only where that was asked for, on a closed + /// test network, and there everything they announce is taken. pub fn unconfigured(hold_time_secs: u16) -> Self { Self { name: None, @@ -92,6 +99,7 @@ impl PeerSettings { hold_time_secs, families: FamilySet::supported(), add_path: None, + import: Arc::new(ImportPolicy::permissive()), } } } @@ -104,6 +112,7 @@ impl From<&PeerConfig> for PeerSettings { hold_time_secs: peer.hold_time_secs, families: peer.families.clone(), add_path: peer.add_path, + import: Arc::new(peer.import.clone()), } } } @@ -143,6 +152,14 @@ pub async fn run_session( Err(panic) => Err(session.close_after_panic(panic).await), }; let established = session.established.is_some(); + if matches!(result, Err(SessionError::PrefixLimitExceeded { .. })) { + // A peer that sends far more than it should will do so again on + // the next session; it is left alone for a while. + session + .server + .hold_downs + .hold(peer_addr.ip(), session.import.hold_down); + } // Whatever ended the session, routes learned over it are gone // (RFC 4271 §8.2.2). Do this before the registration drops so that the @@ -178,6 +195,10 @@ struct Established { outbound: mpsc::Receiver, /// The routes learned from the peer, which go when the session does. routes: PeerRoutes, + /// Whose routes the import policy is judging. + from: policy::From, + /// How many routes the peer has, and how many were refused. + counters: Arc, /// Keeps the session listed in the registry. _registration: SessionGuard, } @@ -279,6 +300,8 @@ struct Session { server: Arc, /// Configured name of the peer, if it is a configured one. peer_name: Option, + /// Which of the peer's routes are taken. + import: Arc, } impl Session { @@ -319,6 +342,7 @@ impl Session { direction, server, peer_name: peer.name, + import: peer.import, }; Ok((session, first_actions)) } @@ -472,7 +496,19 @@ impl Session { attributes_wire, } => { if let Err(e) = self.store(&update, attributes_wire.as_deref()) { - error!(peer_addr = %self.peer_addr, err = %e, "Cannot store the peer's routes"); + match &e { + SessionError::PrefixLimitExceeded { .. } => warn!( + peer_addr = %self.peer_addr, + err = %e, + hold_down = ?self.import.hold_down, + "Ending the session and keeping the peer down" + ), + _ => error!( + peer_addr = %self.peer_addr, + err = %e, + "Cannot store the peer's routes" + ), + } queue = self.fsm.handle(Event::LocalFailure(e)).into(); } } @@ -547,7 +583,7 @@ impl Session { collision.set_established(); } let (outbound_tx, outbound) = mpsc::channel(OUTBOUND_QUEUE_CAPACITY); - let registration = self.server.registry.register(SessionHandle::new( + let handle = SessionHandle::new( peer_id, self.peer_name.clone(), self.direction, @@ -555,13 +591,20 @@ impl Session { negotiated, rib_peer, outbound_tx, - )); + ); self.established = Some(Established { peer_id, rib_peer, outbound, routes: PeerRoutes::new(Arc::clone(&self.server.rib), rib_peer), - _registration: registration, + from: policy::From { + peer_asn: negotiated.peer_asn, + local_asn: self.server.local_asn, + ibgp: negotiated.ibgp, + four_octet_asn: negotiated.four_octet_asn, + }, + counters: Arc::clone(&handle.counters), + _registration: self.server.registry.register(handle), }); Ok(()) } @@ -575,12 +618,13 @@ impl Session { let Some(established) = &self.established else { return Ok(()); }; - let outcome = routes::apply( - &self.server.rib, - established.rib_peer, - update, - attributes_wire, - )?; + let import = routes::Import { + peer: established.rib_peer, + from: established.from, + policy: &self.import, + }; + let routes::Applied { outcome, rejected } = + routes::apply(&self.server.rib, &import, update, attributes_wire)?; // One line for every UPDATE is too much to log by default: a full // table is a million of them. debug!( @@ -588,10 +632,19 @@ impl Session { added = outcome.added, replaced = outcome.replaced, removed = outcome.removed, + rejected, best_changed = outcome.best_changed, "Received UPDATE" ); - Ok(()) + let held = established + .counters + .note(outcome.added, outcome.removed, rejected); + match self.import.max_prefixes { + Some(limit) if held > u64::from(limit) => { + Err(SessionError::PrefixLimitExceeded { limit }) + } + _ => Ok(()), + } } async fn send(&mut self, msg: &Message) -> Result<(), SessionError> { @@ -853,6 +906,7 @@ mod tests { rib: Arc::new(RibTable::new(4)), registry: SessionRegistry::new(), collisions: CollisionTable::new(false), + hold_downs: HoldDowns::default(), }); let (mut session, _) = Session::open( stream, @@ -898,6 +952,7 @@ mod tests { rib: Arc::new(RibTable::new(4)), registry: SessionRegistry::new(), collisions: CollisionTable::new(false), + hold_downs: HoldDowns::default(), }); let (mut session, _) = Session::open( stream, @@ -952,7 +1007,17 @@ mod tests { .into(), ], }; - routes::apply(&table, peer, &update, None).unwrap(); + let import = routes::Import { + peer, + from: policy::From { + peer_asn: 65001, + local_asn: 65000, + ibgp: false, + four_octet_asn: true, + }, + policy: &ImportPolicy::permissive(), + }; + routes::apply(&table, &import, &update, None).unwrap(); assert_eq!(table.len(), 1); assert_eq!(table.attrs().len(), 1); diff --git a/crates/server/src/dialer.rs b/crates/server/src/dialer.rs index c35a37b..889399a 100644 --- a/crates/server/src/dialer.rs +++ b/crates/server/src/dialer.rs @@ -68,6 +68,20 @@ async fn dial_loop(peer: PeerConfig, server: Arc, connect_retry: continue; } + if let Some(remaining) = server.hold_downs.remaining(peer.endpoint.ip()) { + registry.set_dial_state( + &peer.name, + DialState::Backoff { + until: SystemTime::now() + remaining, + last_error: "held down after going over its limit of routes".into(), + }, + ); + tokio::time::sleep(remaining).await; + // The wait starts over: the peer has had time to be put right. + backoff = connect_retry; + continue; + } + registry.set_dial_state(&peer.name, DialState::Connecting); info!(peer = %peer.name, endpoint = %peer.endpoint, "Connecting to peer"); diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index e20a3cd..63fdf1e 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -53,6 +53,10 @@ pub enum SessionError { /// the session could carry nothing. #[error("no address family in common: we offer {ours}, the peer offers {theirs}")] NoCommonAddressFamily { ours: FamilySet, theirs: FamilySet }, + /// The peer has more routes than it is allowed (RFC 4486). It is kept + /// down for a while after. + #[error("the peer went over its limit of {limit} routes")] + PrefixLimitExceeded { limit: u32 }, /// The peer took nothing we sent for this long (RFC 9687): it has /// stopped reading, and there is no telling it so. #[error("the peer accepted no data for {0:?}")] @@ -86,6 +90,7 @@ impl SessionError { Self::WrongPeerAs { .. } => BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs), Self::Collision => BgpError::Cease(CeaseSubcode::ConnCollisionResolution), Self::TableFull(_) => BgpError::Cease(CeaseSubcode::OutOfResources), + Self::PrefixLimitExceeded { .. } => BgpError::Cease(CeaseSubcode::MaxPrefixes), // The session will be set up again; nothing is wrong at the // peer's end. Self::Panicked(_) => BgpError::Cease(CeaseSubcode::AdminReset), @@ -170,6 +175,34 @@ pub enum PeerProblem { AddressFamily, #[error("add_path has a value that is not one of its settings")] AddPath, + #[error("import: {0}")] + Import(#[from] ImportProblem), +} + +/// What is wrong with a peer's `import` policy. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ImportProblem { + #[error("prefix {prefix:?}: {problem}")] + Prefix { + prefix: String, + problem: bgp_primitives::ip_prefix::PrefixError, + }, + #[error( + "prefix {prefix:?}: max_length {max_length} is not between its own length and {longest}" + )] + MaxLength { + prefix: String, + max_length: u32, + longest: u8, + }, + #[error("{field} of {value} is longer than an address of {longest} bits")] + PrefixLength { + field: &'static str, + value: u32, + longest: u8, + }, + #[error("max_as_path_length of {0} is more than a path can hold")] + PathLength(u32), } /// What is wrong with one `originate` entry. diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index ca003cc..d3edefb 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -8,6 +8,7 @@ pub mod family; pub mod fsm; pub mod metrics; mod panic; +pub mod policy; mod routes; pub mod server; pub mod session; diff --git a/crates/server/src/metrics.rs b/crates/server/src/metrics.rs index d5566f9..1f873e8 100644 --- a/crates/server/src/metrics.rs +++ b/crates/server/src/metrics.rs @@ -51,6 +51,15 @@ pub static SESSIONS_ESTABLISHED: LazyLock = LazyLock::new(|| { .expect("metric registration") }); +pub static ROUTES_REJECTED: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "bgp_routes_rejected_total", + "Routes announced by peers and refused by their import policy, by why", + &["reason"] + ) + .expect("metric registration") +}); + pub static CONNECTIONS_REFUSED: LazyLock = LazyLock::new(|| { register_int_counter_vec!( "bgp_connections_refused_total", diff --git a/crates/server/src/policy.rs b/crates/server/src/policy.rs new file mode 100644 index 0000000..6125a6a --- /dev/null +++ b/crates/server/src/policy.rs @@ -0,0 +1,857 @@ +//! Import policy: which of a peer's routes are taken. +//! +//! A peer can announce anything. What it announces by mistake or on purpose +//! that is not its to announce becomes, if we take it, a route we use and +//! pass on. So every route is judged before it goes into the routing table, +//! by rules that are part of each peer's configuration: +//! +//! - **Checks that hold for any peer**, on unless turned off: no prefixes +//! that are not routed on the Internet, none more specific than is routed +//! between networks, no reserved AS numbers in the path, a path that +//! begins with the peer's own AS and is not absurdly long. +//! - **What this peer in particular may announce**, if configured: a list +//! of prefixes, and the AS numbers routes may originate from. This is +//! what is generated from the routing registries for each customer or +//! exchange member. +//! - **How many routes** the peer may have at a time. One more ends the +//! session, and it is kept down for a while: a peer that suddenly sends +//! a great many more routes than it should is leaking someone else's. +//! +//! A route with our own AS number in its path is never taken, whatever is +//! configured: it has been through this AS already (RFC 4271 §9.1.2). +//! +//! A route that is refused is not kept. If the peer had announced the same +//! route before in a form that was taken, that one goes: the peer has +//! replaced it, and the replacement is not acceptable. + +use std::collections::{HashMap, HashSet}; +use std::net::IpAddr; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use bgp_packet::message::{AS_TRANS, AsPathAttribute, PathAttribute}; +use bgp_primitives::constants::IpVersion; +use bgp_primitives::ip_prefix::IpPrefix; + +use crate::sync::IgnorePoison; + +/// The longest prefixes routed between networks: anything more specific is +/// filtered nearly everywhere, and is usually a leak of internal routes. +const LONGEST_IPV4: u8 = 24; +const LONGEST_IPV6: u8 = 48; + +/// The shortest prefixes anyone announces, short of a default route. +const SHORTEST_IPV4: u8 = 8; +const SHORTEST_IPV6: u8 = 16; + +/// A path longer than this is not one traffic would be sent along; it is a +/// mistake or an attempt to exhaust something. +const DEFAULT_MAX_AS_PATH_LENGTH: u16 = 64; + +/// How long a peer that went over its limit is kept down, unless +/// configured otherwise. +const DEFAULT_HOLD_DOWN: Duration = Duration::from_secs(600); + +/// What is taken from one peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportPolicy { + /// The most routes the peer may have at a time; `None` for no limit. + pub max_prefixes: Option, + /// How long the peer is kept down after going over the limit. + pub hold_down: Duration, + /// Take prefixes and AS numbers that are reserved, private or for + /// documentation. + pub accept_bogons: bool, + pub accept_default_route: bool, + /// The longest IPv4 and IPv6 prefixes taken. + pub max_ipv4_length: u8, + pub max_ipv6_length: u8, + /// Take routes whose path does not begin with the peer's AS. A route + /// server does not put its own AS on the path (RFC 7947 §2.2.2.1). + pub accept_any_first_as: bool, + /// The longest AS path taken, counted in AS numbers. + pub max_as_path_length: u16, + /// The prefixes the peer may announce; `None` for any. + pub prefixes: Option, + /// The AS numbers the peer's routes may originate from; `None` for + /// any. + pub origin_asns: Option>, +} + +impl Default for ImportPolicy { + /// What holds for a peer with nothing configured: every general check, + /// no list of its own, no limit. + fn default() -> Self { + Self { + max_prefixes: None, + hold_down: DEFAULT_HOLD_DOWN, + accept_bogons: false, + accept_default_route: false, + max_ipv4_length: LONGEST_IPV4, + max_ipv6_length: LONGEST_IPV6, + accept_any_first_as: false, + max_as_path_length: DEFAULT_MAX_AS_PATH_LENGTH, + prefixes: None, + origin_asns: None, + } + } +} + +impl ImportPolicy { + /// Takes everything but routes that have been through our own AS. For + /// a closed test network, where the prefixes and AS numbers are the + /// private and documentation ones a real policy refuses. + pub fn permissive() -> Self { + Self { + accept_bogons: true, + accept_default_route: true, + max_ipv4_length: IpVersion::Ipv4.bits(), + max_ipv6_length: IpVersion::Ipv6.bits(), + accept_any_first_as: true, + max_as_path_length: u16::MAX, + ..Self::default() + } + } +} + +/// Why a route was not taken. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Rejection { + /// The prefix is reserved, private or for documentation. + BogonPrefix, + /// A default route, or a prefix too short to be anyone's. + TooShort, + /// The prefix is more specific than is routed between networks. + TooSpecific, + /// The prefix is not one the peer is allowed to announce. + PrefixNotAllowed, + /// The path holds our own AS number: the route has been here before. + OwnAs, + /// The path holds an AS number that is reserved, private or for + /// documentation. + BogonAs, + /// The path does not begin with the peer's AS number. + FirstAs, + PathTooLong, + /// The route does not originate from an AS the peer may announce + /// routes of. + OriginNotAllowed, +} + +impl Rejection { + pub const ALL: [Self; 9] = [ + Self::BogonPrefix, + Self::TooShort, + Self::TooSpecific, + Self::PrefixNotAllowed, + Self::OwnAs, + Self::BogonAs, + Self::FirstAs, + Self::PathTooLong, + Self::OriginNotAllowed, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::BogonPrefix => "bogon prefix", + Self::TooShort => "prefix too short", + Self::TooSpecific => "prefix too specific", + Self::PrefixNotAllowed => "prefix not allowed", + Self::OwnAs => "own AS in path", + Self::BogonAs => "bogon AS in path", + Self::FirstAs => "path does not begin with the peer's AS", + Self::PathTooLong => "path too long", + Self::OriginNotAllowed => "origin AS not allowed", + } + } +} + +/// Whose routes are being judged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct From { + pub peer_asn: u32, + pub local_asn: u32, + /// Whether the peer is in our own AS. The checks on the path are about + /// routes coming in from other networks, and are not made then. + pub ibgp: bool, + /// Whether AS numbers are four octets wide on the session. If not, a + /// path may hold AS_TRANS where a wider number belongs. + pub four_octet_asn: bool, +} + +impl ImportPolicy { + /// Judges what the routes of one UPDATE have in common: their path. + pub fn check_path(&self, attributes: &[PathAttribute], from: &From) -> Result<(), Rejection> { + if from.ibgp { + return Ok(()); + } + let path = attributes.iter().find_map(|attribute| match attribute { + PathAttribute::AsPath(path) => Some(path), + _ => None, + }); + let as4_path = attributes.iter().find_map(|attribute| match attribute { + PathAttribute::As4Path(path) => Some(path), + _ => None, + }); + let facts = PathFacts::of(path, as4_path, from); + + if facts.has_local_asn { + return Err(Rejection::OwnAs); + } + if !self.accept_any_first_as { + // The first AS of the first segment, which has to be a + // sequence: a set has no first. + let first = path + .and_then(|path| path.segments.first()) + .filter(|segment| segment.ordered) + .and_then(|segment| segment.path.first()); + if first != Some(&from.peer_asn) { + return Err(Rejection::FirstAs); + } + } + if facts.length > usize::from(self.max_as_path_length) { + return Err(Rejection::PathTooLong); + } + if !self.accept_bogons && facts.has_bogon_asn { + return Err(Rejection::BogonAs); + } + if let Some(allowed) = &self.origin_asns { + // The origin is the last AS of the path. If the path ends in a + // set, there is no telling which of its members that is. + let ends_in_sequence = as4_path + .or(path) + .and_then(|path| path.segments.last()) + .is_some_and(|segment| segment.ordered); + let origin = facts.last.filter(|_| ends_in_sequence); + if !origin.is_some_and(|origin| allowed.contains(&origin)) { + return Err(Rejection::OriginNotAllowed); + } + } + Ok(()) + } + + /// Judges one prefix. + pub fn check_prefix(&self, prefix: &IpPrefix) -> Result<(), Rejection> { + let (shortest, longest) = match prefix.address_family() { + IpVersion::Ipv4 => (SHORTEST_IPV4, self.max_ipv4_length), + IpVersion::Ipv6 => (SHORTEST_IPV6, self.max_ipv6_length), + }; + if prefix.length() > longest { + return Err(Rejection::TooSpecific); + } + if prefix.length() == 0 { + if !self.accept_default_route { + return Err(Rejection::TooShort); + } + } else if !self.accept_bogons { + if prefix.length() < shortest { + return Err(Rejection::TooShort); + } + if is_bogon_prefix(prefix) { + return Err(Rejection::BogonPrefix); + } + } + if let Some(allowed) = &self.prefixes + && !allowed.allows(prefix) + { + return Err(Rejection::PrefixNotAllowed); + } + Ok(()) + } +} + +/// What the checks need to know of a path's AS numbers, found in one pass +/// over them and without copying the path: this is done for every UPDATE. +struct PathFacts { + /// How many AS numbers the path holds, sets and all. + length: usize, + has_local_asn: bool, + has_bogon_asn: bool, + last: Option, +} + +impl PathFacts { + /// Reads the path as it really is. On a session with two-octet AS + /// numbers the path's tail is in AS4_PATH with the numbers that did + /// not fit (RFC 6793 §4.2.3), and that is what counts. + fn of(path: Option<&AsPathAttribute>, as4_path: Option<&AsPathAttribute>, from: &From) -> Self { + let length = asns(path).count(); + let wide = asns(as4_path).count(); + // A longer AS4_PATH than AS_PATH is not to be used (RFC 6793 + // §4.2.3). + let (kept, tail) = if wide <= length { + (length - wide, as4_path) + } else { + (length, None) + }; + let mut facts = Self { + length, + has_local_asn: false, + has_bogon_asn: false, + last: None, + }; + for asn in asns(path).take(kept).chain(asns(tail)) { + facts.has_local_asn |= asn == from.local_asn; + facts.has_bogon_asn |= is_bogon_asn(asn, from.four_octet_asn); + facts.last = Some(asn); + } + facts + } +} + +/// The AS numbers of a path in order, sets and all. +fn asns(path: Option<&AsPathAttribute>) -> impl Iterator + '_ { + path.into_iter() + .flat_map(|path| &path.segments) + .flat_map(|segment| segment.path.iter().copied()) +} + +/// AS numbers that are not anyone's on the Internet: reserved, private use +/// (RFC 6996), for documentation (RFC 5398), or AS_TRANS where a real +/// number should be. +fn is_bogon_asn(asn: u32, four_octet_asn: bool) -> bool { + // On a two-octet session AS_TRANS stands for a number that did not + // fit, and is in the path by rights. + if asn == u32::from(AS_TRANS) { + return four_octet_asn; + } + matches!( + asn, + 0 | 64496..=131071 | 4_200_000_000..=u32::MAX + ) +} + +/// Address space that is not routed on the Internet. A prefix inside any of +/// these is a bogon. +static BOGONS: LazyLock> = LazyLock::new(|| { + [ + // IPv4 (RFC 6890 and its successors). + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + // IPv6 inside the global unicast space. + "2001::/32", + "2001:db8::/32", + "2002::/16", + "3ffe::/16", + "3fff::/20", + ] + .into_iter() + .map(|prefix| IpPrefix::try_from(prefix).expect("the bogon list is written correctly")) + .collect() +}); + +/// The only IPv6 space that is routed: global unicast. +static GLOBAL_UNICAST_V6: LazyLock = + LazyLock::new(|| IpPrefix::try_from("2000::/3").expect("a prefix")); + +fn is_bogon_prefix(prefix: &IpPrefix) -> bool { + // Nearly every prefix is nowhere near a bogon, and this is asked for + // every route: the address's first octets say whether the list needs + // looking at. A prefix too short to have them is looked up in full. + let may_be_listed = match (prefix.address_family(), prefix.bytes()) { + (IpVersion::Ipv4, [first, ..]) => { + matches!( + first, + 0 | 10 | 100 | 127 | 169 | 172 | 192 | 198 | 203 | 224..=255 + ) + } + (IpVersion::Ipv6, [first, second, ..]) => { + if first & 0xe0 != 0x20 { + // Outside 2000::/3. + return true; + } + matches!((first, second), (0x20, 0x01 | 0x02) | (0x3f, 0xfe | 0xff)) + } + _ => true, + }; + may_be_listed && is_listed_bogon(prefix) +} + +/// Whether `prefix` is in space that is not routed, by looking through the +/// whole list. +fn is_listed_bogon(prefix: &IpPrefix) -> bool { + if prefix.address_family() == IpVersion::Ipv6 && !GLOBAL_UNICAST_V6.contains(prefix) { + return true; + } + BOGONS.iter().any(|bogon| bogon.contains(prefix)) +} + +/// A list of prefixes a peer may announce. Each entry allows the prefix +/// itself and, if it says so, the more specific prefixes inside it up to a +/// length. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PrefixSet { + /// For each prefix listed, the longest prefix inside it that is + /// allowed. + entries: HashMap, + /// The lengths of the prefixes listed, by IP version: the only lengths + /// at which a covering entry can be. + lengths: [Vec; 2], +} + +impl PrefixSet { + /// Allows `prefix` and every prefix inside it of at most `max_length` + /// bits. + pub fn allow(&mut self, prefix: IpPrefix, max_length: u8) { + let longest = self.entries.entry(prefix).or_insert(max_length); + *longest = (*longest).max(max_length); + let lengths = &mut self.lengths[Self::version_index(&prefix)]; + if let Err(at) = lengths.binary_search(&prefix.length()) { + lengths.insert(at, prefix.length()); + } + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Whether `prefix` is allowed by some entry. + pub fn allows(&self, prefix: &IpPrefix) -> bool { + // An entry that covers it is the prefix cut to the entry's length, + // so there is one place to look for each length there is. + self.lengths[Self::version_index(prefix)] + .iter() + .take_while(|length| **length <= prefix.length()) + .any(|length| { + self.entries + .get(&prefix.shortened(*length)) + .is_some_and(|longest| prefix.length() <= *longest) + }) + } + + fn version_index(prefix: &IpPrefix) -> usize { + match prefix.address_family() { + IpVersion::Ipv4 => 0, + IpVersion::Ipv6 => 1, + } + } +} + +/// Peers that are being kept down, and until when: those that went over +/// their limit. Shared by the sessions, which put peers here, and by the +/// dialer and the listeners, which leave them alone meanwhile. +#[derive(Debug, Default)] +pub struct HoldDowns(Mutex>); + +impl HoldDowns { + /// Keeps the peer at `address` down for `duration` from now. + pub fn hold(&self, address: IpAddr, duration: Duration) { + self.0.locked().insert(address, Instant::now() + duration); + } + + /// How much longer the peer at `address` is kept down, if it is. + pub fn remaining(&self, address: IpAddr) -> Option { + let mut held = self.0.locked(); + let until = *held.get(&address)?; + let remaining = until.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + held.remove(&address); + return None; + } + Some(remaining) + } +} + +#[cfg(test)] +mod tests { + use bgp_packet::message::AsPathSegment; + use proptest::prelude::*; + + use super::*; + + fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() + } + + const EXTERNAL: From = From { + peer_asn: 3333, + local_asn: 1299, + ibgp: false, + four_octet_asn: true, + }; + + fn path(asns: &[u32]) -> Vec { + vec![AsPathAttribute::from_asns(asns.to_vec())] + } + + #[test] + fn prefixes_that_are_not_routed_on_the_internet_are_refused() { + let policy = ImportPolicy::default(); + for bogon in [ + "10.1.0.0/16", + "192.168.1.0/24", + "100.64.0.0/10", + "192.0.2.0/24", + "224.0.0.0/8", + "2001:db8::/32", + "2001:db8:1::/48", + "fc00::/16", + "fe80::/16", + "2002::/16", + ] { + assert_eq!( + policy.check_prefix(&prefix(bogon)), + Err(Rejection::BogonPrefix), + "{bogon}" + ); + } + for routed in [ + "193.0.0.0/21", + "8.8.8.0/24", + "2001:67c:2e8::/48", + "2a00::/16", + ] { + assert_eq!(policy.check_prefix(&prefix(routed)), Ok(()), "{routed}"); + } + // The space around a bogon is not one. + assert_eq!(policy.check_prefix(&prefix("192.0.3.0/24")), Ok(())); + assert_eq!(policy.check_prefix(&prefix("11.0.0.0/8")), Ok(())); + } + + #[test] + fn prefixes_too_long_or_too_short_are_refused() { + let policy = ImportPolicy::default(); + assert_eq!( + policy.check_prefix(&prefix("193.0.0.0/25")), + Err(Rejection::TooSpecific) + ); + assert_eq!( + policy.check_prefix(&prefix("2001:67c:2e8::/49")), + Err(Rejection::TooSpecific) + ); + assert_eq!( + policy.check_prefix(&prefix("0.0.0.0/0")), + Err(Rejection::TooShort) + ); + assert_eq!( + policy.check_prefix(&prefix("::/0")), + Err(Rejection::TooShort) + ); + assert_eq!( + policy.check_prefix(&prefix("128.0.0.0/1")), + Err(Rejection::TooShort) + ); + + // Each can be allowed for a peer it is expected from. + let upstream = ImportPolicy { + accept_default_route: true, + max_ipv4_length: 32, + ..ImportPolicy::default() + }; + assert_eq!(upstream.check_prefix(&prefix("0.0.0.0/0")), Ok(())); + assert_eq!(upstream.check_prefix(&prefix("193.0.0.1/32")), Ok(())); + // And nothing else is let through with them. + assert_eq!( + upstream.check_prefix(&prefix("10.0.0.1/32")), + Err(Rejection::BogonPrefix) + ); + } + + #[test] + fn a_peer_with_a_prefix_list_may_announce_only_what_is_on_it() { + let mut allowed = PrefixSet::default(); + allowed.allow(prefix("193.0.0.0/21"), 21); + allowed.allow(prefix("2001:67c::/32"), 48); + let policy = ImportPolicy { + prefixes: Some(allowed), + ..ImportPolicy::default() + }; + assert_eq!(policy.check_prefix(&prefix("193.0.0.0/21")), Ok(())); + // More specific than the entry allows. + assert_eq!( + policy.check_prefix(&prefix("193.0.0.0/24")), + Err(Rejection::PrefixNotAllowed) + ); + // Less specific than the entry. + assert_eq!( + policy.check_prefix(&prefix("193.0.0.0/20")), + Err(Rejection::PrefixNotAllowed) + ); + assert_eq!(policy.check_prefix(&prefix("2001:67c:2e8::/48")), Ok(())); + assert_eq!(policy.check_prefix(&prefix("2001:67c::/32")), Ok(())); + // Someone else's. + assert_eq!( + policy.check_prefix(&prefix("8.8.8.0/24")), + Err(Rejection::PrefixNotAllowed) + ); + // The general checks still hold for what is on the list. + assert_eq!( + policy.check_prefix(&prefix("2001:67c:2e8:1::/64")), + Err(Rejection::TooSpecific) + ); + } + + #[test] + fn a_route_that_has_been_through_our_as_is_never_taken() { + // Not even by a policy that takes everything else. + for policy in [ImportPolicy::default(), ImportPolicy::permissive()] { + assert_eq!( + policy.check_path(&path(&[3333, 1299, 2914]), &EXTERNAL), + Err(Rejection::OwnAs) + ); + } + // From a peer in our own AS the path says nothing of the kind. + let internal = From { + peer_asn: 1299, + ibgp: true, + ..EXTERNAL + }; + assert_eq!( + ImportPolicy::default().check_path(&path(&[]), &internal), + Ok(()) + ); + } + + #[test] + fn the_path_must_begin_with_the_peers_as_unless_it_is_a_route_server() { + let policy = ImportPolicy::default(); + assert_eq!(policy.check_path(&path(&[3333, 2914]), &EXTERNAL), Ok(())); + assert_eq!( + policy.check_path(&path(&[2914, 3333]), &EXTERNAL), + Err(Rejection::FirstAs) + ); + assert_eq!( + policy.check_path(&path(&[]), &EXTERNAL), + Err(Rejection::FirstAs) + ); + // A set has no first AS. + let set = vec![PathAttribute::AsPath(AsPathAttribute { + segments: vec![AsPathSegment { + ordered: false, + path: vec![3333], + }], + })]; + assert_eq!(policy.check_path(&set, &EXTERNAL), Err(Rejection::FirstAs)); + + let route_server = ImportPolicy { + accept_any_first_as: true, + ..ImportPolicy::default() + }; + assert_eq!( + route_server.check_path(&path(&[2914, 174]), &EXTERNAL), + Ok(()) + ); + } + + #[test] + fn reserved_and_private_as_numbers_in_the_path_are_refused() { + let policy = ImportPolicy::default(); + for bogon in [ + 0, + 23456, + 64496, + 64512, + 65000, + 65535, + 65536, + 131071, + 4_200_000_000, + u32::MAX, + ] { + assert_eq!( + policy.check_path(&path(&[3333, bogon]), &EXTERNAL), + Err(Rejection::BogonAs), + "AS{bogon}" + ); + } + for real in [174, 64495, 131072, 210036, 4_199_999_999] { + assert_eq!( + policy.check_path(&path(&[3333, real]), &EXTERNAL), + Ok(()), + "AS{real}" + ); + } + } + + /// On a session with two-octet AS numbers a wider number is AS_TRANS in + /// the path and itself in AS4_PATH. The path is judged as it really is. + #[test] + fn a_path_from_a_two_octet_peer_is_judged_with_its_as4_path() { + let two_octet = From { + four_octet_asn: false, + ..EXTERNAL + }; + let policy = ImportPolicy { + origin_asns: Some([210036].into()), + ..ImportPolicy::default() + }; + let mut attributes = path(&[3333, 23456]); + attributes.push(PathAttribute::As4Path(AsPathAttribute { + segments: vec![AsPathSegment { + ordered: true, + path: vec![210036], + }], + })); + assert_eq!(policy.check_path(&attributes, &two_octet), Ok(())); + + // What AS4_PATH reveals is judged too. + let mut attributes = path(&[3333, 23456]); + attributes.push(PathAttribute::As4Path(AsPathAttribute { + segments: vec![AsPathSegment { + ordered: true, + path: vec![4_200_000_001], + }], + })); + assert_eq!( + ImportPolicy::default().check_path(&attributes, &two_octet), + Err(Rejection::BogonAs) + ); + } + + #[test] + fn an_absurdly_long_path_is_refused() { + let policy = ImportPolicy::default(); + assert_eq!(policy.check_path(&path(&[3333; 64]), &EXTERNAL), Ok(())); + assert_eq!( + policy.check_path(&path(&[3333; 65]), &EXTERNAL), + Err(Rejection::PathTooLong) + ); + } + + #[test] + fn a_peer_may_announce_only_routes_of_the_origins_listed_for_it() { + let policy = ImportPolicy { + origin_asns: Some([3333, 2121].into()), + ..ImportPolicy::default() + }; + assert_eq!(policy.check_path(&path(&[3333]), &EXTERNAL), Ok(())); + assert_eq!(policy.check_path(&path(&[3333, 2121]), &EXTERNAL), Ok(())); + assert_eq!( + policy.check_path(&path(&[3333, 15169]), &EXTERNAL), + Err(Rejection::OriginNotAllowed) + ); + // A path that ends in a set has no one origin. + let ends_in_set = vec![PathAttribute::AsPath(AsPathAttribute { + segments: vec![ + AsPathSegment { + ordered: true, + path: vec![3333], + }, + AsPathSegment { + ordered: false, + path: vec![2121], + }, + ], + })]; + assert_eq!( + policy.check_path(&ends_in_set, &EXTERNAL), + Err(Rejection::OriginNotAllowed) + ); + } + + #[test] + fn a_peer_is_held_down_for_as_long_as_was_said() { + let held = HoldDowns::default(); + let address: IpAddr = "192.0.2.1".parse().unwrap(); + assert_eq!(held.remaining(address), None); + + held.hold(address, Duration::from_secs(600)); + let remaining = held.remaining(address).expect("held"); + assert!(remaining > Duration::from_secs(590), "{remaining:?}"); + assert_eq!(held.remaining("192.0.2.2".parse().unwrap()), None); + + held.hold(address, Duration::ZERO); + assert_eq!(held.remaining(address), None); + assert!( + held.0.locked().is_empty(), + "a hold that is over is forgotten" + ); + } + + /// The quick look at a prefix's first octets never lets through what + /// the list has. + fn assert_quick_check_agrees(prefix: IpPrefix) { + assert_eq!( + is_bogon_prefix(&prefix), + is_listed_bogon(&prefix), + "{prefix}" + ); + } + + #[test] + fn the_quick_bogon_check_agrees_with_the_list_around_every_entry() { + for bogon in BOGONS.iter().chain([&*GLOBAL_UNICAST_V6]) { + let version = bogon.address_family(); + let mut address = [0u8; 16]; + address[..bogon.bytes().len()].copy_from_slice(bogon.bytes()); + // The entry, what covers it, and what is inside it, at every + // length; and the same just past its end. + for flip in [None, Some(bogon.length().saturating_sub(1))] { + let mut address = address; + if let Some(bit) = flip { + address[usize::from(bit / 8)] ^= 0x80 >> (bit % 8); + } + for length in 0..=version.bits() { + assert_quick_check_agrees(IpPrefix::new(version, address, length).unwrap()); + } + } + } + } + + proptest! { + #[test] + fn the_quick_bogon_check_agrees_with_the_list( + v6: bool, bytes: [u8; 16], length in 0u8..=128, + ) { + let version = if v6 { IpVersion::Ipv6 } else { IpVersion::Ipv4 }; + let prefix = IpPrefix::new(version, bytes, length.min(version.bits())).unwrap(); + assert_quick_check_agrees(prefix); + } + + /// The prefix set agrees with looking at every entry in turn. + #[test] + fn a_prefix_set_allows_what_some_entry_covers( + entries in proptest::collection::vec((any::<[u8; 4]>(), 0u8..=32, 0u8..=32), 0..12), + probes in proptest::collection::vec((any::<[u8; 4]>(), 0u8..=32), 1..40), + pick in any::(), + ) { + let entries: Vec<(IpPrefix, u8)> = entries + .into_iter() + .map(|(bytes, length, extra)| { + let prefix = IpPrefix::new(IpVersion::Ipv4, bytes, length).unwrap(); + (prefix, length.max(extra)) + }) + .collect(); + let mut set = PrefixSet::default(); + for (entry, max_length) in &entries { + set.allow(*entry, *max_length); + } + // Random prefixes rarely fall inside a random entry, so some + // probes are made from the entries themselves. + let mut probes: Vec = probes + .into_iter() + .map(|(bytes, length)| IpPrefix::new(IpVersion::Ipv4, bytes, length).unwrap()) + .collect(); + if !entries.is_empty() { + let (entry, _) = entries[pick.index(entries.len())]; + for length in entry.length()..=32 { + let mut bytes = [0u8; 4]; + bytes[..entry.bytes().len()].copy_from_slice(entry.bytes()); + probes.push(IpPrefix::new(IpVersion::Ipv4, bytes, length).unwrap()); + } + } + for probe in probes { + let expected = entries.iter().any(|(entry, max_length)| { + entry.contains(&probe) && probe.length() <= *max_length + }); + prop_assert_eq!(set.allows(&probe), expected, "{}", probe); + } + } + } +} diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs index 606894e..0210644 100644 --- a/crates/server/src/routes.rs +++ b/crates/server/src/routes.rs @@ -1,5 +1,7 @@ -//! Putting the routes of a received UPDATE into the routing table. +//! Putting the routes of a received UPDATE into the routing table, as far +//! as the peer's import policy lets them in. +use std::borrow::Cow; use std::sync::Arc; use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage}; @@ -7,9 +9,28 @@ use bgp_primitives::ip_prefix::Nlri; use bgp_primitives::parser::EncodeError; use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable}; -/// Applies `update`, received from `peer`, to `table`: its withdrawals -/// first and then its announcements, so that a prefix named in both ends up -/// announced (RFC 4271 §4.3). +use crate::metrics::ROUTES_REJECTED; +use crate::policy::{From, ImportPolicy, Rejection}; + +/// What applying an UPDATE did. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct Applied { + /// What changed in the table. + pub(crate) outcome: Outcome, + /// How many routes the UPDATE announced that the policy refused. + pub(crate) rejected: usize, +} + +/// Whose UPDATE is being applied, and by what rules. +pub(crate) struct Import<'a> { + pub(crate) peer: PeerIndex, + pub(crate) from: From, + pub(crate) policy: &'a ImportPolicy, +} + +/// Applies `update` to `table`: its withdrawals first and then its +/// announcements, so that a prefix named in both ends up announced +/// (RFC 4271 §4.3). /// /// An UPDATE names prefixes in four places: withdrawn routes and NLRI in the /// message body, which are IPv4, and the MP_UNREACH_NLRI and MP_REACH_NLRI @@ -17,26 +38,35 @@ use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable}; /// several routes for a prefix (ADD-PATH, RFC 7911), each is named by its /// prefix and path identifier together, and is a route of its own. /// +/// A route the policy refuses is withdrawn instead: whatever the peer had +/// announced for it before is replaced by this, and this is not taken. +/// /// `attributes_wire` is the attributes as the decoder kept them, if it did /// (see `DecodedUpdate::attributes_wire`). With it the table finds the /// attribute set from those bytes, without writing the attributes out again. pub(crate) fn apply( table: &RibTable, - peer: PeerIndex, + import: &Import<'_>, update: &UpdateMessage, attributes_wire: Option<&[u8]>, -) -> Result { - let mut outcome = Outcome::default(); +) -> Result { + let peer = import.peer; + let mut applied = Applied::default(); if !update.withdrawn_routes.is_empty() { - outcome += table.withdraw(peer, &update.withdrawn_routes); + applied.outcome += table.withdraw(peer, &update.withdrawn_routes); } for attribute in &update.path_attributes { if let PathAttribute::MpUnreachNlri(mp) = attribute { - outcome += table.withdraw(peer, &mp.prefixes); + applied.outcome += table.withdraw(peer, &mp.prefixes); } } + // What the routes have in common is judged once. + let path_verdict = import + .policy + .check_path(&update.path_attributes, &import.from); + let attributes = |next_hop: Option<&NlriNextHop>| -> Result, EncodeError> { if let Some(set) = attributes_wire.and_then(|wire| table.attrs().intern_received(wire, next_hop)) @@ -46,8 +76,23 @@ pub(crate) fn apply( table.attrs().intern(&update.path_attributes, next_hop) }; let mut announce = |nlri: &[Nlri], next_hop| -> Result<(), EncodeError> { - if !nlri.is_empty() { - outcome += table.announce(peer, &attributes(next_hop)?, nlri); + if nlri.is_empty() { + return Ok(()); + } + let mut refuse = |refused: &[Nlri], rejection: Rejection| { + ROUTES_REJECTED + .with_label_values(&[rejection.as_str()]) + .inc_by(refused.len() as u64); + applied.rejected += refused.len(); + applied.outcome += table.withdraw(peer, refused); + }; + if let Err(rejection) = path_verdict { + refuse(nlri, rejection); + return Ok(()); + } + let accepted = sift(import.policy, nlri, &mut refuse); + if !accepted.is_empty() { + applied.outcome += table.announce(peer, &attributes(next_hop)?, &accepted); } Ok(()) }; @@ -60,5 +105,27 @@ pub(crate) fn apply( announce(&mp.prefixes, Some(&mp.next_hop))?; } } - Ok(outcome) + Ok(applied) +} + +/// The routes of `nlri` whose prefixes the policy takes; the others are +/// given to `refuse`. Nearly always that is all of them, and nothing is +/// copied. +fn sift<'a>( + policy: &ImportPolicy, + nlri: &'a [Nlri], + refuse: &mut impl FnMut(&[Nlri], Rejection), +) -> Cow<'a, [Nlri]> { + let verdict = |route: &Nlri| policy.check_prefix(&route.prefix); + let Some(first_refused) = nlri.iter().position(|route| verdict(route).is_err()) else { + return Cow::Borrowed(nlri); + }; + let mut accepted = nlri[..first_refused].to_vec(); + for route in &nlri[first_refused..] { + match verdict(route) { + Ok(()) => accepted.push(*route), + Err(rejection) => refuse(std::slice::from_ref(route), rejection), + } + } + Cow::Owned(accepted) } diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs index 58b5284..73bf06e 100644 --- a/crates/server/src/server.rs +++ b/crates/server/src/server.rs @@ -24,6 +24,7 @@ use crate::dialer::spawn_dialers; use crate::error::ServerError; use crate::metrics::CONNECTIONS_REFUSED; use crate::panic; +use crate::policy::HoldDowns; use crate::session::{ConfiguredPeer, SessionRegistry}; use crate::sync::IgnorePoison; @@ -125,12 +126,25 @@ impl BgpServer { "BGP server started" ); + for peer in &self.config.peers { + if peer.import.max_prefixes.is_none() { + warn!( + peer = %peer.name, + "No limit on the routes taken from this peer (import.max_prefixes)" + ); + } + } + if self.config.accept_unconfigured_peers { + warn!("Taking sessions and routes from any host (accept_unconfigured_peers)"); + } + let context = Arc::new(ServerContext { router_id: self.config.router_id, local_asn: self.config.local_asn, rib: Arc::clone(&self.rib), registry: self.registry.clone(), collisions: CollisionTable::new(self.config.collision_detect_established_state), + hold_downs: HoldDowns::default(), }); // The tasks the server cannot do without. None of them ends while @@ -169,7 +183,7 @@ impl BgpServer { }; match result { Ok((stream, peer_addr)) => { - match admit(&self.config, peer_addr.ip(), &connections) { + match admit(&self.config, &context, peer_addr.ip(), &connections) { Ok((peer, slot)) => { let context = Arc::clone(&context); tokio::spawn(serve(stream, peer_addr, context, peer, slot)); @@ -223,9 +237,13 @@ fn listen(addr: SocketAddr) -> io::Result { /// settings. fn admit( config: &Config, + context: &ServerContext, address: IpAddr, connections: &ConnectionCounts, ) -> Result<(PeerSettings, ConnectionSlot), Refusal> { + if context.hold_downs.remaining(address).is_some() { + return Err(Refusal::HeldDown); + } // A connection from a configured peer is run with what is configured // for that peer. let peer = match config.peer_at(address) { @@ -261,6 +279,8 @@ enum Refusal { NotAPeer, /// Its address has as many connections open as one may. TooMany, + /// The peer went over its limit of routes and is being kept down. + HeldDown, } impl Refusal { @@ -268,6 +288,7 @@ impl Refusal { match self { Self::NotAPeer => "not a configured peer", Self::TooMany => "too many connections from the address", + Self::HeldDown => "peer is held down", } } } diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index 61ef7b5..0900574 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime}; @@ -61,6 +61,36 @@ pub struct ConfiguredPeer { pub remote_asn: u32, } +/// Counts kept by a session as it goes, for whoever wants to look. +#[derive(Debug, Default)] +pub struct SessionCounters { + /// Routes of the peer's that are in the routing table now. + routes: AtomicU64, + /// Routes the peer announced that its import policy refused, since the + /// session came up. + rejected: AtomicU64, +} + +impl SessionCounters { + pub fn routes(&self) -> u64 { + self.routes.load(Ordering::Relaxed) + } + + pub fn rejected(&self) -> u64 { + self.rejected.load(Ordering::Relaxed) + } + + /// Notes what one UPDATE did, and returns how many routes the peer has + /// in the table after it. + pub(crate) fn note(&self, added: usize, removed: usize, rejected: usize) -> u64 { + self.rejected.fetch_add(rejected as u64, Ordering::Relaxed); + // Only the session writes this, so the two steps are not a race. + let routes = (self.routes() + added as u64).saturating_sub(removed as u64); + self.routes.store(routes, Ordering::Relaxed); + routes + } +} + /// Handle to one established session. Cloning is cheap. #[derive(Debug, Clone)] pub struct SessionHandle { @@ -89,6 +119,8 @@ pub struct SessionHandle { /// The peer's number in the routing table: what its routes there are /// marked with. pub rib_peer: PeerIndex, + /// How many routes the peer has, and how many were refused. + pub counters: Arc, outbound: mpsc::Sender, } @@ -118,6 +150,7 @@ impl SessionHandle { add_path_receive: negotiated.add_path_receive.clone(), add_path_send: negotiated.add_path_send.clone(), rib_peer, + counters: Arc::default(), outbound, } } diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index 5ca02dc..c3ad194 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -65,6 +65,11 @@ pub struct PeerView { /// identifiers. pub add_path_receive: Option>, pub add_path_send: Option>, + /// How many of the peer's routes are in the routing table. + pub routes: Option, + /// How many routes the peer announced on this session that its import + /// policy refused. + pub rejected_routes: Option, /// Why the last connection attempt or session ended, while in Backoff. pub last_error: Option, pub retry_at_unix: Option, @@ -85,6 +90,8 @@ impl From<&SessionHandle> for PeerView { address_families: Some(s.families.iter().map(|f| f.to_string()).collect()), add_path_receive: Some(s.add_path_receive.iter().map(|f| f.to_string()).collect()), add_path_send: Some(s.add_path_send.iter().map(|f| f.to_string()).collect()), + routes: Some(s.counters.routes()), + rejected_routes: Some(s.counters.rejected()), last_error: None, retry_at_unix: None, } @@ -113,6 +120,8 @@ impl PeerView { address_families: None, add_path_receive: None, add_path_send: None, + routes: None, + rejected_routes: None, last_error, retry_at_unix, } diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index 4d1f3f8..eaa5724 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -57,7 +57,7 @@ View: variables, status json, <

Peers (...)

- +
namepeerASstateopened bylocal address (next hop)establisheduptime4-byte ASaddress familiesdetail
namepeerASstateopened bylocal address (next hop)establisheduptime4-byte ASaddress familiesroutesrefuseddetail
@@ -155,7 +155,8 @@ View: variables, status json, < ${esc(p.state)}${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH} ${up ? esc(p.local_addr) : DASH}${up ? fmtTime(p.established_at_unix) : DASH} ${up ? age(p.established_at_unix) : DASH}${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH} - ${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}${detail}`; + ${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH} + ${up ? p.routes : DASH}${up ? p.rejected_routes : DASH}${detail}`; }).join('')); } diff --git a/testdata/server.textpb b/testdata/server.textpb index de239b0..8b61c18 100644 --- a/testdata/server.textpb +++ b/testdata/server.textpb @@ -22,6 +22,21 @@ peers { # Take several routes for a prefix from this peer, if it offers to send # them (ADD-PATH). Also ADD_PATH_SEND and ADD_PATH_BOTH. Off by default. add_path: ADD_PATH_RECEIVE + # Which of the peer's routes are taken. Without this block the general + # checks still apply: no private or reserved prefixes or AS numbers, + # nothing longer than /24 or /48, no default route, and a path that + # begins with the peer's AS. This example is a test network that uses + # exactly such prefixes and AS numbers, so it takes them. + import { + # More routes than this ends the session and keeps the peer down + # for hold_down_seconds (600 unless set). + max_prefixes: 1000 + accept_bogons: true + # For a real peer: the prefixes and origin ASes it may announce, + # as generated from the routing registries. + # prefixes { prefix: "193.0.0.0/21" max_length: 24 } + # origin_asns: 3333 + } } connect_retry_seconds: 5