Judge every route a peer announces: import policy, prefix limit, hold-down

A configured peer could announce any prefix with any path, in any
number, and all of it was stored and could be chosen as best. Each
peer now has an import policy, and every route goes through it before
it reaches the routing table.

Checks that hold for any peer unless turned off for it:
- no prefixes that are not routed on the Internet (private, loopback,
  link-local, documentation, multicast, reserved; for IPv6, anything
  outside global unicast);
- nothing longer than /24 or /48, no default route, nothing shorter
  than /8 or /16;
- no reserved, private or documentation AS number in the path;
- a path that begins with the peer's own AS (turned off for a route
  server), and of at most 64 AS numbers.

Always: a route with our own AS number in its path is refused. RFC 4271
requires this loop check and it was missing.

Per peer, if configured: the prefixes it may announce, each with an
optional longest more-specific, and the AS numbers its routes may
originate from. This is the list generated from the routing registries.

max_prefixes limits how many routes a peer may have. One more ends the
session with Cease / Maximum Number of Prefixes Reached, and the peer
is kept down for hold_down_seconds (ten minutes unless set): its
connections are not taken and it is not dialled. Refused routes do not
count towards the limit.

A refused route is withdrawn rather than ignored, so that it replaces
whatever the peer had announced for it before.

On a session with two-octet AS numbers the path is judged with its
AS4_PATH merged in, so that the real AS numbers are what is checked.

Peers that are not configured (accept_unconfigured_peers, for test
networks) get a policy that takes everything but loops.

Refusals are counted in bgp_routes_rejected_total by reason. The status
page and /api/peers show, for each session, how many routes the peer
has and how many were refused. The server warns at startup about peers
with no limit.

The prefix check costs about 4 ns a route with the default policy; the
end-to-end benchmark is unchanged.

Also: the benchmark's server AS is one that is in none of its generated
paths, and a table test that depended on the hasher's seed is fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 12:59:37 +02:00
1 parent 630c175918
commit e2b1979e38
24 files changed
+1745 -39

No files matched your search

+3 -1
View File
@@ -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;
@@ -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.
+3 -4
View File
@@ -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,
@@ -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<TestServer> {
local_address: None,
families: FamilySet::supported(),
add_path: None,
import: ImportPolicy::permissive(),
}];
})
.await
+5
View File
@@ -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<TestS
local_address: None,
families: FamilySet::supported(),
add_path: None,
import: ImportPolicy::permissive(),
}];
})
.await
@@ -187,6 +189,7 @@ async fn test_inbound_from_configured_address_uses_config() -> 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?;
@@ -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<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90)
.await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await?;
Ok(peer)
}
/// Announces 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<BTreeSet<String>> {
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<String> {
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(())
}
@@ -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);
+34
View File
@@ -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(
+7 -1
View File
@@ -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);
+62
View File
@@ -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.
+169 -2
View File
@@ -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<AddPathSendRecv>,
/// Which of the peer's routes are taken.
pub import: ImportPolicy,
}
impl Config {
@@ -207,6 +210,11 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
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<PeerConfig, PeerProblem> {
local_address,
families,
add_path,
import,
})
}
fn parse_import(import: &proto::ImportPolicy) -> Result<ImportPolicy, ImportProblem> {
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<u32>| !asns.is_empty()),
})
}
@@ -499,4 +576,94 @@ mod tests {
ConfigError::RouterId("0.0.0.0".into())
);
}
fn import_of(body: &str) -> Result<ImportPolicy, ConfigError> {
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"
);
}
}
+78 -13
View File
@@ -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<AddPathSendRecv>,
/// Which of the peer's routes are taken.
pub import: Arc<ImportPolicy>,
}
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<Message>,
/// 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<SessionCounters>,
/// Keeps the session listed in the registry.
_registration: SessionGuard,
}
@@ -279,6 +300,8 @@ struct Session {
server: Arc<ServerContext>,
/// Configured name of the peer, if it is a configured one.
peer_name: Option<String>,
/// Which of the peer's routes are taken.
import: Arc<ImportPolicy>,
}
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);
+14
View File
@@ -68,6 +68,20 @@ async fn dial_loop(peer: PeerConfig, server: Arc<ServerContext>, 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");
+33
View File
@@ -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.
+1
View File
@@ -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;
+9
View File
@@ -51,6 +51,15 @@ pub static SESSIONS_ESTABLISHED: LazyLock<IntGauge> = LazyLock::new(|| {
.expect("metric registration")
});
pub static ROUTES_REJECTED: LazyLock<IntCounterVec> = 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<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!(
"bgp_connections_refused_total",
+857
View File
@@ -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<u32>,
/// 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<PrefixSet>,
/// The AS numbers the peer's routes may originate from; `None` for
/// any.
pub origin_asns: Option<HashSet<u32>>,
}
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<u32>,
}
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<Item = u32> + '_ {
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<Vec<IpPrefix>> = 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<IpPrefix> =
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<IpPrefix, u8>,
/// The lengths of the prefixes listed, by IP version: the only lengths
/// at which a covering entry can be.
lengths: [Vec<u8>; 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<HashMap<IpAddr, Instant>>);
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<Duration> {
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<PathAttribute> {
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::<prop::sample::Index>(),
) {
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<IpPrefix> = 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);
}
}
}
}
+79 -12
View File
@@ -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<Outcome, EncodeError> {
let mut outcome = Outcome::default();
) -> Result<Applied, EncodeError> {
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<Arc<AttrSet>, 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)
}
+22 -1
View File
@@ -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<TcpListener> {
/// 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",
}
}
}
+34 -1
View File
@@ -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<SessionCounters>,
outbound: mpsc::Sender<Message>,
}
@@ -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,
}
}
+9
View File
@@ -65,6 +65,11 @@ pub struct PeerView {
/// identifiers.
pub add_path_receive: Option<Vec<String>>,
pub add_path_send: Option<Vec<String>>,
/// How many of the peer's routes are in the routing table.
pub routes: Option<u64>,
/// How many routes the peer announced on this session that its import
/// policy refused.
pub rejected_routes: Option<u64>,
/// Why the last connection attempt or session ended, while in Backoff.
pub last_error: Option<String>,
pub retry_at_unix: Option<u64>,
@@ -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,
}
+3 -2
View File
@@ -57,7 +57,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
<table class="data">
<thead><tr><th>name</th><th>peer</th><th>AS</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte AS</th><th>address families</th><th>detail</th></tr></thead>
<thead><tr><th>name</th><th>peer</th><th>AS</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte AS</th><th>address families</th><th>routes</th><th>refused</th><th>detail</th></tr></thead>
<tbody id="peers-body"></tbody>
</table>
@@ -155,7 +155,8 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td>
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}</td><td>${detail}</td></tr>`;
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}</td>
<td>${up ? p.routes : DASH}</td><td>${up ? p.rejected_routes : DASH}</td><td>${detail}</td></tr>`;
}).join(''));
}