Check roles in OPEN and prevent route leaks with Only to Customer (RFC 9234)

A peer's configured role now does three more things:

- Our OPEN carries the BGP Role capability with what we are to the peer.
  A peer whose own says it is something that does not pair with that, or
  that sends two different roles, is refused with OPEN Message Error /
  Role Mismatch. A peer that says nothing is accepted unless require_role
  is set. A role on a peer in our own AS is a configuration error.
- Routes from a provider, a peer or a route server are marked Only to
  Customer on the way in if they are not already, and our routes are
  marked on the way out to a customer, a peer or a route server client.
- A marked route from a customer or route server client, or from a peer
  with someone else's mark, is a leak and is refused. No policy setting
  turns that off.

The packet crate gains the Only to Customer attribute (malformed:
treat-as-withdraw), and the Role Mismatch subcode is corrected from 8 to
11.

The status page shows whether the peer confirmed its role.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 14:27:36 +02:00
1 parent 8086aa994f
commit f9ef781d51
30 files changed
+1128 -25

No files matched your search

@@ -49,6 +49,7 @@ async fn server_offering(
add_path,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}];
c.originate = originate;
// Keep the dialer out of the way.
@@ -27,6 +27,7 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig {
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}
}
+1
View File
@@ -65,6 +65,7 @@ async fn server_with(
add_path: None,
import,
role,
require_role: false,
}];
})
.await?;
@@ -45,6 +45,7 @@ async fn server_dialing(listener: &TcpListener) -> Result<TestServer> {
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}];
})
.await
@@ -176,6 +176,7 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> {
..ImportPolicy::default()
},
role: None,
require_role: false,
}];
})
.await?;
+4
View File
@@ -36,6 +36,7 @@ async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestS
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}];
})
.await
@@ -192,6 +193,7 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}];
})
.await?;
@@ -242,6 +244,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}];
})
.await?;
@@ -261,6 +264,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}];
})
.await?;
@@ -49,6 +49,7 @@ async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)>
add_path: None,
import,
role: None,
require_role: false,
}];
})
.await?;
@@ -126,6 +126,7 @@ async fn test_ipv6_prefix_update_shape() -> Result<()> {
next_hop: nh.into(),
four_octet_asn: true,
ibgp: false,
role: None,
},
)
.expect("ipv6 over ipv6");
@@ -192,6 +192,7 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> {
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
}];
// Keep the dialer out of the way.
c.connect_retry = Duration::from_secs(3600);
+407
View File
@@ -0,0 +1,407 @@
//! Roles and route leak prevention (RFC 9234), end to end: what the two
//! ends say they are to each other, and what becomes of routes that are
//! marked as only to go to customers.
use std::net::{Ipv4Addr, Ipv6Addr};
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::errors::{BgpError, OpenMsgSubcode};
use bgp_packet::message::{
AsPathAttribute, Message, MpReachNlriPathAttribute, NextHopPathAttribute, NlriNextHop,
OriginPathAttribute, PathAttribute, UpdateMessage,
};
use bgp_packet::open::{BgpRole, Capability};
use bgp_primitives::constants::AfiSafi;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::LocalRoute;
use bgp_server::config::PeerConfig;
use bgp_server::family::FamilySet;
use bgp_server::policy::{ImportPolicy, PeerRole};
use eyre::{Result, bail};
use serde_json::Value;
use tokio::net::TcpListener;
const LOCAL_ASN: u32 = 1299;
const PEER_ASN: u32 = 3333;
const TIMEOUT: Duration = Duration::from_secs(5);
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
/// What a test holds on to: the server, and the listener its dialling
/// goes to.
struct Setup {
server: TestServer,
_listener: TcpListener,
}
/// A server with one configured peer at the address the test connects
/// from, which is `role` to it, and which originates 193.0.0.0/21.
async fn server_with(role: Option<PeerRole>, require_role: bool) -> Result<Setup> {
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.originate = vec![LocalRoute::new(prefix("193.0.0.0/21"), Default::default())];
c.peers = vec![PeerConfig {
name: "r1".into(),
endpoint,
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: None,
families: FamilySet::supported(),
add_path: None,
import: ImportPolicy::default(),
role,
require_role,
}];
})
.await?;
Ok(Setup {
server,
_listener: listener,
})
}
/// The capabilities of an ordinary peer, and with `role` what it says it
/// is.
fn capabilities(role: Option<BgpRole>) -> Vec<Capability> {
let mut capabilities = vec![
Capability::FourByteAsn { asn: PEER_ASN },
Capability::multiprotocol(AfiSafi::IPV4_UNICAST),
Capability::multiprotocol(AfiSafi::IPV6_UNICAST),
Capability::RouteRefresh,
];
capabilities.extend(role.map(|role| Capability::BgpRole { role }));
capabilities
}
/// A session with the server, on which the peer says it is `says`.
async fn established(server: &TestServer, says: Option<BgpRole>) -> Result<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake_with(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90, capabilities(says))
.await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await?;
Ok(peer)
}
/// Announces `prefixes` with the AS path `path` and, with `marked_by`, an
/// Only to Customer attribute with that AS number.
async fn announce(
peer: &mut TestPeer,
prefixes: &[&str],
path: &[u32],
marked_by: Option<u32>,
) -> Result<()> {
let mut path_attributes = vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(path.to_vec()),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
];
path_attributes.extend(marked_by.map(PathAttribute::OnlyToCustomer));
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes,
announced_routes: prefixes.iter().map(|p| prefix(p).into()).collect(),
}))
.await
}
/// Waits until the table holds `count` routes, and returns the mark each
/// has, by prefix.
async fn marks(server: &TestServer, count: usize) -> Result<Vec<(String, Option<u32>)>> {
tokio::time::timeout(TIMEOUT, async {
while server.received_routes().len() != count {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.map_err(|_| {
eyre::eyre!(
"expected {count} routes, the table has {:?}",
server.received_routes()
)
})?;
let mut marks: Vec<(String, Option<u32>)> = server
.received_routes()
.iter()
.map(|route| {
let mark = route
.attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::OnlyToCustomer(asn) => Some(*asn),
_ => None,
});
(route.prefix.to_string(), mark)
})
.collect();
marks.sort();
Ok(marks)
}
/// The role the server's OPEN announces.
fn announced_role(open: &bgp_packet::open::OpenMessage) -> Option<BgpRole> {
open.capabilities().find_map(|capability| match capability {
Capability::BgpRole { role } => Some(*role),
_ => None,
})
}
/// Both ends say what they are, and it fits: the session comes up, and is
/// shown as one whose peer confirmed its role.
#[tokio::test]
async fn test_roles_that_fit_together_are_confirmed() -> Result<()> {
let Setup { server, .. } = server_with(Some(PeerRole::Customer), false).await?;
let (base, _http) = server.serve_http().await?;
let mut peer = TestPeer::connect(server.addr).await?;
let open = peer
.handshake_with(
PEER_ASN,
Ipv4Addr::new(10, 0, 0, 2),
90,
capabilities(Some(BgpRole::Customer)),
)
.await?;
// The peer is our customer: we are its provider.
assert_eq!(announced_role(&open), Some(BgpRole::Provider));
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await?;
let peers: Value = reqwest::get(format!("{base}/api/peers"))
.await?
.json()
.await?;
assert_eq!(peers[0]["role"], "customer");
assert_eq!(peers[0]["role_confirmed"], true);
Ok(())
}
/// The commonest way to a leak is the two ends not agreeing on what they
/// are to each other. A peer that says it is something else than it is
/// configured as is refused, with the error RFC 9234 gives for it.
#[tokio::test]
async fn test_a_peer_that_says_it_is_something_else_is_refused() -> Result<()> {
let Setup { server, .. } = server_with(Some(PeerRole::Customer), false).await?;
let mut peer = TestPeer::connect(server.addr).await?;
// It thinks it is our peer; we have it down as a customer.
peer.send_open_with(
PEER_ASN,
Ipv4Addr::new(10, 0, 0, 2),
90,
capabilities(Some(BgpRole::Peer)),
)
.await?;
peer.recv_open().await?;
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
Message::Notification(n) => {
assert_eq!(n.error, BgpError::OpenMsg(OpenMsgSubcode::RoleMismatch));
// Subcode 11 on the wire.
assert_eq!(n.error.code_subcode(), (2, 11));
}
other => bail!("expected NOTIFICATION, got {other:?}"),
}
assert!(server.registry.list().is_empty());
Ok(())
}
/// A peer that says nothing does not implement RFC 9234. It is taken at
/// our word for what it is, unless the configuration requires it to say.
#[tokio::test]
async fn test_a_peer_that_does_not_say_is_refused_only_where_required() -> Result<()> {
let Setup { server, .. } = server_with(Some(PeerRole::Customer), false).await?;
let (base, _http) = server.serve_http().await?;
let _peer = established(&server, None).await?;
let peers: Value = reqwest::get(format!("{base}/api/peers"))
.await?
.json()
.await?;
assert_eq!(peers[0]["role"], "customer");
assert_eq!(peers[0]["role_confirmed"], false);
let Setup { server, .. } = server_with(Some(PeerRole::Customer), true).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.send_open_with(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90, capabilities(None))
.await?;
peer.recv_open().await?;
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
Message::Notification(n) => {
assert_eq!(n.error, BgpError::OpenMsg(OpenMsgSubcode::RoleMismatch));
}
other => bail!("expected NOTIFICATION, got {other:?}"),
}
Ok(())
}
/// A route marked as only to go to customers has been handed down or
/// across once. Coming up from a customer it is a leak, and is refused
/// whatever else the peer's policy takes.
#[tokio::test]
async fn test_a_marked_route_from_a_customer_is_a_leak() -> Result<()> {
let Setup { server, .. } = server_with(Some(PeerRole::Customer), false).await?;
let mut peer = established(&server, Some(BgpRole::Customer)).await?;
// The customer's own route, and one its other provider gave it.
announce(&mut peer, &["194.0.0.0/24"], &[PEER_ASN], None).await?;
announce(
&mut peer,
&["8.8.8.0/24"],
&[PEER_ASN, 3356, 15169],
Some(3356),
)
.await?;
// A customer's routes may go anywhere: they are not marked on the way
// in.
assert_eq!(
marks(&server, 1).await?,
[("194.0.0.0/24".to_string(), None)]
);
tokio::time::timeout(TIMEOUT, async {
while server.refusals.peers().is_empty() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await?;
let refused = &server.refusals.peers()[0].1;
assert_eq!(refused.total(), 1);
assert_eq!(refused.recent()[0].prefix, prefix("8.8.8.0/24"));
assert_eq!(
refused.recent()[0].reason.as_str(),
"route leak (Only to Customer)"
);
Ok(())
}
/// From a peer a marked route is in order only if the peer marked it
/// itself, on its way to us. A mark of someone else's means the peer got
/// the route from above or from another peer.
#[tokio::test]
async fn test_a_route_marked_by_someone_else_is_a_leak_from_a_peer() -> Result<()> {
let Setup { server, .. } = server_with(Some(PeerRole::Peer), false).await?;
let mut peer = established(&server, Some(BgpRole::Peer)).await?;
announce(&mut peer, &["194.0.0.0/24"], &[PEER_ASN], Some(PEER_ASN)).await?;
announce(
&mut peer,
&["8.8.8.0/24"],
&[PEER_ASN, 3356, 15169],
Some(3356),
)
.await?;
// A peer that does not mark its routes has them marked for it.
announce(&mut peer, &["195.0.0.0/24"], &[PEER_ASN, 2121], None).await?;
assert_eq!(
marks(&server, 2).await?,
[
("194.0.0.0/24".to_string(), Some(PEER_ASN)),
("195.0.0.0/24".to_string(), Some(PEER_ASN)),
]
);
assert_eq!(server.refusals.peers()[0].1.total(), 1);
Ok(())
}
/// A route from a provider is marked on the way in, if it is not already:
/// from here it may only go down. That holds for both address families,
/// and whether or not the peer's attributes could be stored as they came.
#[tokio::test]
async fn test_routes_from_a_provider_are_marked_on_the_way_in() -> Result<()> {
for four_octet in [true, false] {
let Setup { server, .. } = server_with(Some(PeerRole::Provider), false).await?;
let mut peer = TestPeer::connect(server.addr).await?;
let mut offered = capabilities(Some(BgpRole::Provider));
if !four_octet {
offered.retain(|capability| !matches!(capability, Capability::FourByteAsn { .. }));
}
peer.handshake_with(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90, offered)
.await?;
announce(&mut peer, &["194.0.0.0/24"], &[PEER_ASN, 2121], None).await?;
// One that someone further up has marked already keeps that mark.
announce(
&mut peer,
&["8.8.8.0/24"],
&[PEER_ASN, 3356, 15169],
Some(3356),
)
.await?;
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![PEER_ASN, 2121]),
PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
family: AfiSafi::IPV6_UNICAST,
next_hop: NlriNextHop::Ipv6(Ipv6Addr::new(0x2001, 0x7f8, 0, 0, 0, 0, 0, 1)),
prefixes: vec![prefix("2a00:1450::/32").into()],
}),
],
announced_routes: vec![],
}))
.await?;
assert_eq!(
marks(&server, 3).await?,
[
("194.0.0.0/24".to_string(), Some(PEER_ASN)),
("2a00:1450::/32".to_string(), Some(PEER_ASN)),
("8.8.8.0/24".to_string(), Some(3356)),
],
"four-octet AS numbers: {four_octet}"
);
assert!(server.refusals.peers().is_empty());
}
Ok(())
}
/// Our own routes are marked on their way down to a customer or across to
/// a peer, and not on their way up to a provider.
#[tokio::test]
async fn test_our_routes_are_marked_on_the_way_down_or_across() -> Result<()> {
for (peer_is, says, marked) in [
(PeerRole::Customer, BgpRole::Customer, true),
(PeerRole::Peer, BgpRole::Peer, true),
(
PeerRole::RouteServerClient,
BgpRole::RouteServerClient,
true,
),
(PeerRole::Provider, BgpRole::Provider, false),
(PeerRole::RouteServer, BgpRole::RouteServer, false),
] {
let Setup { server, .. } = server_with(Some(peer_is), false).await?;
let mut peer = established(&server, Some(says)).await?;
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(update.announced_routes, [prefix("193.0.0.0/21")]);
let mark = update
.path_attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::OnlyToCustomer(asn) => Some(*asn),
_ => None,
});
assert_eq!(mark, marked.then_some(LOCAL_ASN), "to our {peer_is:?}");
}
// Nothing is said to a peer whose role is not known.
let Setup { server, .. } = server_with(None, false).await?;
let mut peer = established(&server, None).await?;
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert!(
!update
.path_attributes
.iter()
.any(|attribute| matches!(attribute, PathAttribute::OnlyToCustomer(_)))
);
Ok(())
}
+1
View File
@@ -74,6 +74,7 @@ async fn server_with(
add_path: None,
import,
role: None,
require_role: false,
}];
})
.await?;
+2 -1
View File
@@ -128,8 +128,9 @@ pub enum OpenMsgSubcode {
#[error("Unsupported capability")]
UnsupportedCapability = 7,
/// RFC 9234 §4.2.
#[error("Role mismatch")]
RoleMismatch = 8,
RoleMismatch = 11,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, FromRepr)]
+33 -1
View File
@@ -405,6 +405,7 @@ pub mod attr_type {
pub const AS4_PATH: u8 = 17;
pub const AS4_AGGREGATOR: u8 = 18;
pub const LARGE_COMMUNITIES: u8 = 32;
pub const ONLY_TO_CUSTOMER: u8 = 35;
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@@ -424,6 +425,10 @@ pub enum PathAttribute {
/// speakers that only handle two-octet ones (RFC 6793 §3).
As4Path(AsPathAttribute),
LargeCommunities(LargeCommunitiesPathAttribute),
/// Only to Customer (RFC 9234 §5): the route has been handed to a
/// customer, a peer or a route server client by the AS with this
/// number, and from there on may only go down to customers.
OnlyToCustomer(u32),
/// An attribute this implementation does not interpret, kept as it
/// came so that it can be passed on.
UnknownPathAttribute {
@@ -447,7 +452,7 @@ pub(crate) fn defined_flags(type_code: u8) -> Option<(bool, bool)> {
OPTIONAL_NON_TRANSITIVE
}
AGGREGATOR | COMMUNITIES | EXTENDED_COMMUNITIES | AS4_PATH | AS4_AGGREGATOR
| LARGE_COMMUNITIES => OPTIONAL_TRANSITIVE,
| LARGE_COMMUNITIES | ONLY_TO_CUSTOMER => OPTIONAL_TRANSITIVE,
_ => return None,
})
}
@@ -547,6 +552,7 @@ impl PathAttribute {
Self::ExtendedCommunities(_) => EXTENDED_COMMUNITIES,
Self::As4Path(_) => AS4_PATH,
Self::LargeCommunities(_) => LARGE_COMMUNITIES,
Self::OnlyToCustomer(_) => ONLY_TO_CUSTOMER,
Self::UnknownPathAttribute { type_code, .. } => *type_code,
}
}
@@ -615,6 +621,15 @@ impl PathAttribute {
LARGE_COMMUNITIES => {
Self::LargeCommunities(whole(LargeCommunitiesPathAttribute::from_wire(value)?)?)
}
ONLY_TO_CUSTOMER => {
let asn = <[u8; 4]>::try_from(value).map_err(|_| {
malformed(format!(
"Only to Customer is {} octets long, not four",
value.len()
))
})?;
Self::OnlyToCustomer(u32::from_be_bytes(asn))
}
_ => Self::UnknownPathAttribute {
flags,
type_code,
@@ -671,12 +686,29 @@ impl PathAttribute {
Self::ExtendedCommunities(a) => a.to_wire(out),
Self::As4Path(a) => a.to_wire_with_width(true, out)?,
Self::LargeCommunities(a) => a.to_wire(out),
Self::OnlyToCustomer(asn) => out.put_u32(*asn),
Self::UnknownPathAttribute { payload, .. } => out.put_slice(payload),
}
Ok(())
}
}
/// An Only to Customer attribute with this AS number, as it is on the
/// wire: for adding to attributes that are kept as bytes.
pub fn only_to_customer_wire(asn: u32) -> [u8; 7] {
const OPTIONAL_TRANSITIVE: u8 = 0xc0;
let [a, b, c, d] = asn.to_be_bytes();
[
OPTIONAL_TRANSITIVE,
attr_type::ONLY_TO_CUSTOMER,
4,
a,
b,
c,
d,
]
}
type Parsed<'a, T> = IResult<&'a [u8], T, BgpParserError<&'a [u8]>>;
fn malformed<'a>(detail: String) -> nom::Err<BgpParserError<&'a [u8]>> {
+37
View File
@@ -478,6 +478,7 @@ mod tests {
const COMMUNITIES: u8 = 8;
const EXTENDED_COMMUNITIES: u8 = 16;
const LARGE_COMMUNITY: u8 = 32;
const ONLY_TO_CUSTOMER: u8 = 35;
const WELL_KNOWN: u8 = 0x40;
const OPTIONAL: u8 = 0x80;
@@ -626,6 +627,31 @@ mod tests {
assert_eq!(decoded.attributes_wire, None);
}
#[test]
fn only_to_customer_is_read_and_kept_as_it_came() {
let wire = crate::message::only_to_customer_wire(3333);
assert_eq!(
wire.to_vec(),
attr(OPTIONAL_TRANSITIVE, ONLY_TO_CUSTOMER, &[0, 0, 0x0d, 0x05])
);
let decoded = announce_with(&ebgp(), wire.to_vec());
assert!(decoded.errors.is_empty());
assert_eq!(
decoded.update.path_attributes.last(),
Some(&PathAttribute::OnlyToCustomer(3333))
);
assert_eq!(
decoded.attributes_wire,
Some([mandatory().concat(), wire.to_vec()].concat())
);
// And is written as it is read.
let mut out = bytes::BytesMut::new();
PathAttribute::OnlyToCustomer(3333)
.to_wire(&ebgp(), &mut out)
.unwrap();
assert_eq!(out[..], wire);
}
#[test]
fn an_update_that_announces_nothing_needs_no_attributes() {
// End-of-RIB, and a plain withdrawal.
@@ -664,6 +690,17 @@ mod tests {
MED,
attr(OPTIONAL_TRANSITIVE, MED, &[0, 0, 0, 50]),
),
// RFC 9234 §5.
(
"Only to Customer of two octets",
ONLY_TO_CUSTOMER,
attr(OPTIONAL_TRANSITIVE, ONLY_TO_CUSTOMER, &[0xfd, 0xe8]),
),
(
"Only to Customer of eight octets",
ONLY_TO_CUSTOMER,
attr(OPTIONAL_TRANSITIVE, ONLY_TO_CUSTOMER, &[0; 8]),
),
];
for (case, type_code, extra) in cases {
let decoded = announce_with(&ebgp(), extra);
+16 -3
View File
@@ -189,7 +189,7 @@ fn mp_unreach(session: Session) -> impl Strategy<Value = MpUnreachNlriPathAttrib
/// Type codes whose definition the decoder knows; an unknown attribute has
/// none of these.
const DEFINED_ATTRIBUTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 16, 17, 18, 32];
const DEFINED_ATTRIBUTES: [u8; 17] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 16, 17, 18, 32, 35];
/// Unknown attributes, each with a type code of its own: a repeated
/// attribute is dropped by the decoder.
@@ -291,11 +291,23 @@ fn path_attributes(session: Session) -> impl Strategy<Value = Vec<PathAttribute>
.collect(),
})
})),
proptest::option::of(any::<u32>().prop_map(PathAttribute::OnlyToCustomer)),
unknown_attributes(),
);
(mandatory, optional).prop_map(|(mut attributes, optional)| {
let (med, atomic, aggregator, communities, reach, unreach, extended, as4, large, unknown) =
optional;
let (
med,
atomic,
aggregator,
communities,
reach,
unreach,
extended,
as4,
large,
otc,
unknown,
) = optional;
attributes.extend(
[
med,
@@ -307,6 +319,7 @@ fn path_attributes(session: Session) -> impl Strategy<Value = Vec<PathAttribute>
extended,
as4,
large,
otc,
]
.into_iter()
.flatten(),
+21 -6
View File
@@ -358,13 +358,28 @@ impl AttrStore {
wire: &[u8],
next_hop: Option<&NlriNextHop>,
) -> Option<Arc<AttrSet>> {
let Some(next_hop) = next_hop else {
self.intern_received_with(wire, &[], next_hop)
}
/// As [`AttrStore::intern_received`], with attributes of our own after
/// the peer's: `added` is one or more whole attributes in the same
/// form, of types that `wire` does not have.
pub fn intern_received_with(
&self,
wire: &[u8],
added: &[u8],
next_hop: Option<&NlriNextHop>,
) -> Option<Arc<AttrSet>> {
if added.is_empty() && next_hop.is_none() {
return self.intern_wire(wire);
};
let mut with_next_hop = BytesMut::with_capacity(wire.len() + 36);
with_next_hop.put_slice(wire);
encode_next_hop(next_hop, &mut with_next_hop);
self.intern_wire(&with_next_hop)
}
let mut whole = BytesMut::with_capacity(wire.len() + added.len() + 36);
whole.put_slice(wire);
whole.put_slice(added);
if let Some(next_hop) = next_hop {
encode_next_hop(next_hop, &mut whole);
}
self.intern_wire(&whole)
}
/// How many distinct sets are stored.
+22 -5
View File
@@ -146,12 +146,29 @@ message Peer {
// apply and there is no limit and no list.
ImportPolicy import = 8;
// What the peer is to us. It decides how the AS paths of its routes are
// verified against the ASPAs, where rpki is configured: a route from a
// customer, a peer or a route server must have come up through
// providers all the way; one from a provider may have come up and then
// down. Left out, the paths of the peer's routes are not verified.
// What the peer is to us. Not for a peer in our own AS. With it set:
//
// - Our OPEN says what we are to the peer, and a peer that says it is
// something else than configured here is refused (RFC 9234). One side
// having the other down as a customer and the other as a peer is how
// leaks come about.
// - Routes are marked "only to customer" as they come in from a provider,
// a peer or a route server, and as they go out to a customer, a peer
// or a route server client. A route with that mark that comes back up
// from a customer, or across from a second peer, is a leak and is
// refused. This cannot be turned off.
// - Where rpki is configured, the AS paths of the peer's routes are
// verified against the ASPAs: a route from a customer, a peer or a
// route server must have come up through providers all the way; one
// from a provider may have come up and then down.
//
// Left out, none of this is done for the peer.
Role role = 9;
// Refuse the peer unless its OPEN says what it is. By default a peer
// that says nothing (one that does not implement RFC 9234) is taken at
// this configuration's word for it. Needs role.
bool require_role = 10;
}
// What a peer is to us.
+56
View File
@@ -34,6 +34,7 @@ use tokio::sync::broadcast::error::RecvError;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
use crate::policy::PeerRole;
use crate::session::{PeerId, SessionClosed, SessionEvent, SessionHandle, SessionRegistry};
/// Starts the advertiser. It hears of every session that comes up after
@@ -231,6 +232,7 @@ impl Exporter {
next_hop: session.local_addr.ip(),
four_octet_asn: session.four_octet_asn,
ibgp: session.ibgp,
role: session.role,
};
let Some(update) = build_announce(route, &towards) else {
debug!(
@@ -272,6 +274,8 @@ pub struct Towards {
pub four_octet_asn: bool,
/// Whether the peer is in our own AS.
pub ibgp: bool,
/// What the peer is to us, if that is known.
pub role: Option<PeerRole>,
}
/// Builds the UPDATE announcing `route` on a session.
@@ -352,6 +356,20 @@ pub fn build_announce(route: &LocalRoute, towards: &Towards) -> Option<UpdateMes
},
));
}
// A route that goes down to a customer or across to a peer may not
// come up again from there: it is marked, for every network after us
// to see (RFC 9234 §5, egress, 1). Routes we originate carry no such
// mark of anyone else's. When routes learned from peers are passed
// on, one that carries the mark must not go to a provider, a peer or
// a route server at all (egress, 2;
// `PeerRole::may_be_sent_only_to_customer_routes`).
if let Some(marked_by) = towards
.role
.filter(|_| !towards.ibgp)
.and_then(|role| role.only_to_customer_on_export(towards.local_asn))
{
path_attributes.push(PathAttribute::OnlyToCustomer(marked_by));
}
Some(UpdateMessage {
withdrawn_routes: vec![],
@@ -394,6 +412,42 @@ mod tests {
IpPrefix::try_from(s).unwrap()
}
/// RFC 9234 §5, the egress procedure for routes we originate.
#[test]
fn a_route_is_marked_only_to_customer_on_its_way_down_or_across() {
let route = LocalRoute::new(
IpPrefix::try_from("10.1.0.0/16").unwrap(),
LocalRouteAttrs::default(),
);
let to = |role| Towards {
role,
..towards(65000, IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), true)
};
let mark = |role| {
build_announce(&route, &to(role))
.unwrap()
.path_attributes
.into_iter()
.find(|attribute| matches!(attribute, PathAttribute::OnlyToCustomer(_)))
};
for role in [
PeerRole::Customer,
PeerRole::Peer,
PeerRole::RouteServerClient,
] {
assert_eq!(
mark(Some(role)),
Some(PathAttribute::OnlyToCustomer(65000)),
"{role:?}"
);
}
// On its way up it may still go anywhere; and where it is not
// known what the peer is, nothing is said.
for role in [Some(PeerRole::Provider), Some(PeerRole::RouteServer), None] {
assert_eq!(mark(role), None, "{role:?}");
}
}
/// An external peer.
fn towards(local_asn: u32, next_hop: IpAddr, four_octet_asn: bool) -> Towards {
Towards {
@@ -401,6 +455,7 @@ mod tests {
next_hop,
four_octet_asn,
ibgp: false,
role: None,
}
}
@@ -675,6 +730,7 @@ mod tests {
families: FamilySet::supported(),
add_path_receive: FamilySet::default(),
add_path_send: FamilySet::default(),
role_confirmed: false,
};
let rib_peer = self
.table
+31
View File
@@ -66,6 +66,9 @@ pub struct PeerConfig {
pub import: ImportPolicy,
/// What the peer is to us, if that was said.
pub role: Option<PeerRole>,
/// Refuse the peer if it does not say what it is itself (RFC 9234
/// §4.2, "strict mode").
pub require_role: bool,
}
impl Config {
@@ -109,6 +112,10 @@ impl Config {
problem,
};
let peer = parse_peer(peer).map_err(rejected)?;
// RFC 9234 is about what networks are to each other.
if peer.role.is_some() && peer.remote_asn == proto.local_asn {
return Err(rejected(PeerProblem::RoleInOwnAs));
}
// A peer is told from the others by its name in what the
// dialer reports, and by its address when it connects to us.
if peers.iter().any(|earlier| earlier.name == peer.name) {
@@ -236,6 +243,9 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
Ok(proto::Role::ROUTE_SERVER_CLIENT) => Some(PeerRole::RouteServerClient),
Err(_) => return Err(PeerProblem::Role),
};
if peer.require_role && role.is_none() {
return Err(PeerProblem::RequireRoleWithoutRole);
}
Ok(PeerConfig {
// A peer without a name goes by its address.
@@ -252,6 +262,7 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
add_path,
import,
role,
require_role: peer.require_role,
})
}
@@ -764,6 +775,26 @@ mod tests {
role_of("role: ROUTE_SERVER").unwrap(),
Some(PeerRole::RouteServer)
);
// Roles are between networks, and a peer can be required to say
// its own only where we know what to expect.
let problem = |body: &str| match role_of(body).err().unwrap() {
ConfigError::Peer { problem, .. } => problem,
other => panic!("{other:?}"),
};
assert_eq!(
problem("role: PEER remote_asn: 65000"),
PeerProblem::RoleInOwnAs
);
assert_eq!(
problem("require_role: true"),
PeerProblem::RequireRoleWithoutRole
);
let config = parse(&format!(
"{BASE}peers {{ endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 \
role: PEER require_role: true }}"
))
.unwrap();
assert!(config.peers[0].require_role);
assert!(!import_of("").unwrap().accept_aspa_invalid);
assert!(
import_of("import { accept_aspa_invalid: true }")
+9
View File
@@ -99,6 +99,8 @@ pub struct PeerSettings {
pub import: Arc<ImportPolicy>,
/// What the peer is to us, if that is known.
pub role: Option<PeerRole>,
/// Refuse the peer if it does not say what it is (RFC 9234).
pub require_role: bool,
}
impl PeerSettings {
@@ -114,6 +116,7 @@ impl PeerSettings {
add_path: None,
import: Arc::new(ImportPolicy::permissive()),
role: None,
require_role: false,
}
}
}
@@ -128,6 +131,7 @@ impl From<&PeerConfig> for PeerSettings {
add_path: peer.add_path,
import: Arc::new(peer.import.clone()),
role: peer.role,
require_role: peer.require_role,
}
}
}
@@ -395,6 +399,8 @@ impl Session {
expected_asn: peer.expected_asn,
families: peer.families,
add_path: peer.add_path,
role: peer.role,
require_role: peer.require_role,
});
// The peer goes by its configured name, or by its address.
let label = peer
@@ -675,6 +681,7 @@ impl Session {
outbound_tx,
);
handle.role = self.role;
handle.role_confirmed = negotiated.role_confirmed;
// Routes from other networks are validated, where there is data
// to validate against and the peer's policy does not say to take
// invalid ones anyway.
@@ -708,6 +715,7 @@ impl Session {
local_asn: self.server.local_asn,
ibgp: negotiated.ibgp,
four_octet_asn: negotiated.four_octet_asn,
role: self.role,
},
counters: Arc::clone(&handle.counters),
routes_metric: PEER_ROUTES.with_label_values(&[self.refusals.label()]),
@@ -1188,6 +1196,7 @@ mod tests {
local_asn: 65000,
ibgp: false,
four_octet_asn: true,
role: None,
},
policy: &ImportPolicy::permissive(),
checks: Checks::NONE,
+28
View File
@@ -6,6 +6,7 @@ use std::time::Duration;
use bgp_packet::errors::{BgpError, CeaseSubcode, DecodeError, FsmSubcode, OpenMsgSubcode};
use bgp_packet::message::NotificationMessage;
use bgp_packet::open::BgpRole;
use bgp_primitives::parser::EncodeError;
use bgp_rib::RibStopped;
use bgp_rib::table::TooManyPeers;
@@ -13,6 +14,21 @@ use thiserror::Error;
use crate::family::FamilySet;
use crate::fsm::State;
use crate::policy::PeerRole;
/// Says what is wrong between what a peer is configured as and what it
/// says it is.
fn role_mismatch(configured: Option<PeerRole>, theirs: Option<BgpRole>) -> String {
let configured = configured.map_or("no role is configured".to_string(), |role| {
format!("the peer is configured as our {}", role.as_str())
});
match theirs {
Some(theirs) => format!("role mismatch: {configured} and says it is {theirs:?}"),
None => format!(
"role mismatch: {configured} and does not say what it is, or says more than one thing"
),
}
}
/// Why a session ended abnormally.
#[derive(Debug, Error)]
@@ -40,6 +56,13 @@ pub enum SessionError {
OpenRefused(BgpError),
#[error("peer announced AS{actual} but AS{expected} was configured")]
WrongPeerAs { expected: u32, actual: u32 },
/// RFC 9234 §4.2. `theirs` is `None` if the peer did not say, or said
/// more than one thing.
#[error("{}", role_mismatch(*.configured, *.theirs))]
RoleMismatch {
configured: Option<PeerRole>,
theirs: Option<BgpRole>,
},
#[error("closed by connection collision resolution")]
Collision,
/// The routing table has no room for another peer.
@@ -88,6 +111,7 @@ impl SessionError {
}),
Self::OpenRefused(error) => error.clone(),
Self::WrongPeerAs { .. } => BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs),
Self::RoleMismatch { .. } => BgpError::OpenMsg(OpenMsgSubcode::RoleMismatch),
Self::Collision => BgpError::Cease(CeaseSubcode::ConnCollisionResolution),
Self::TableFull(_) => BgpError::Cease(CeaseSubcode::OutOfResources),
Self::PrefixLimitExceeded { .. } => BgpError::Cease(CeaseSubcode::MaxPrefixes),
@@ -179,6 +203,10 @@ pub enum PeerProblem {
AddressFamily,
#[error("add_path has a value that is not one of its settings")]
AddPath,
#[error("role is set for a peer in our own AS; roles are between networks")]
RoleInOwnAs,
#[error("require_role is set without a role")]
RequireRoleWithoutRole,
#[error("role has a value that is not one of its settings")]
Role,
#[error("import: {0}")]
+196
View File
@@ -21,6 +21,7 @@ use bgp_packet::open::{AddPathEntry, AddPathSendRecv, Capability, OpenMessage, O
use crate::error::SessionError;
use crate::family::FamilySet;
use crate::metrics::message_type_name;
use crate::policy::PeerRole;
/// How long to wait for the peer's OPEN, and for its first KEEPALIVE when
/// the negotiated hold time is zero. RFC 4271 §8.2.2 suggests four minutes.
@@ -65,6 +66,13 @@ pub struct FsmConfig {
/// The directions to offer ADD-PATH (RFC 7911) for, on each of
/// `families`; `None` for neither.
pub add_path: Option<AddPathSendRecv>,
/// What the peer is to us, if that is configured. Our OPEN then says
/// what we are to it, and the peer's must not say otherwise (RFC 9234).
/// Not for a peer in our own AS.
pub role: Option<PeerRole>,
/// Refuse a peer that does not say what it is (RFC 9234 §4.2, "strict
/// mode"). Means nothing without `role`.
pub require_role: bool,
}
/// What the two OPENs settled.
@@ -90,6 +98,9 @@ pub struct Negotiated {
/// The families for which we send the peer path identifiers: we
/// offered to send and the peer to receive.
pub add_path_send: FamilySet,
/// Whether the peer said what it is to us, and it is what we have it
/// configured as (RFC 9234).
pub role_confirmed: bool,
}
/// A message from the peer, with what the decoder found out besides.
@@ -309,6 +320,36 @@ impl Fsm {
});
}
// RFC 9234 §4.2: the two ends have to agree on what they are to
// each other.
let mut their_roles = open
.capabilities()
.filter_map(|capability| match capability {
Capability::BgpRole { role } => Some(*role),
_ => None,
});
let their_role = their_roles.next();
let role_mismatch = |theirs| SessionError::RoleMismatch {
configured: self.config.role,
theirs,
};
// Saying it twice is saying it once; saying two things is saying
// nothing that can be relied on.
if their_roles.any(|role| Some(role) != their_role) {
return self.fail(role_mismatch(None));
}
let role_confirmed = match (self.config.role, their_role) {
(Some(configured), Some(theirs)) if theirs == configured.theirs() => true,
(Some(_), Some(theirs)) => return self.fail(role_mismatch(Some(theirs))),
(Some(_), None) if self.config.require_role => {
return self.fail(role_mismatch(None));
}
// A peer that does not say is taken at our word for it; and
// what a peer says of itself means nothing where nothing is
// configured to hold it against.
(Some(_), None) | (None, _) => false,
};
let ours = self.config.families.clone();
let theirs = FamilySet::offered_by(&open);
let families = ours.intersection(&theirs);
@@ -348,6 +389,7 @@ impl Fsm {
families,
add_path_receive,
add_path_send,
role_confirmed,
};
// With a hold time of zero the timer will not run once Established,
// but a peer that never confirms must still be given up on.
@@ -419,6 +461,9 @@ fn build_open(config: &FsmConfig) -> Message {
// not accept routes of a family that was not offered (RFC 4760 §8).
capabilities.extend(config.families.iter().map(Capability::multiprotocol));
capabilities.push(Capability::RouteRefresh);
if let Some(role) = config.role {
capabilities.push(Capability::BgpRole { role: role.ours() });
}
if let Some(send_recv) = config.add_path {
capabilities.push(Capability::AddPath {
entries: config
@@ -468,6 +513,8 @@ mod tests {
use bgp_rib::table::TooManyPeers;
use bgp_packet::open::BgpRole;
use super::*;
use crate::family::SUPPORTED;
@@ -482,6 +529,8 @@ mod tests {
expected_asn: None,
families: FamilySet::supported(),
add_path: None,
role: None,
require_role: false,
}
}
@@ -582,6 +631,7 @@ mod tests {
families: only(AfiSafi::IPV4_UNICAST),
add_path_receive: FamilySet::default(),
add_path_send: FamilySet::default(),
role_confirmed: false,
};
assert!(matches!(
&actions[..],
@@ -812,6 +862,152 @@ mod tests {
));
}
/// What becomes of a session with a peer configured as `role`, whose
/// OPEN carries the Role capabilities `theirs`: whether the peer
/// confirmed its role, or the error the session ended with.
fn roles_meet(
role: Option<PeerRole>,
require_role: bool,
theirs: &[BgpRole],
) -> Result<bool, BgpError> {
let (mut fsm, _) = Fsm::new(FsmConfig {
role,
require_role,
..config()
});
let open = OpenMessage {
options: vec![OpenOption::Capabilities(
theirs
.iter()
.map(|role| Capability::BgpRole { role: *role })
.collect(),
)],
..peer_open(90)
};
let actions = fsm.handle(received(Message::Open(open)));
match &actions[..] {
[Action::Admit(negotiated), ..] => Ok(negotiated.role_confirmed),
_ => Err(ending(&fsm, actions).0.expect("a NOTIFICATION")),
}
}
/// RFC 9234 §4.1: our OPEN says what we are to the peer, which is the
/// other half of what the peer is to us.
#[test]
fn our_open_says_what_we_are_to_the_peer() {
for (peer_is, we_are) in [
(PeerRole::Provider, BgpRole::Customer),
(PeerRole::Customer, BgpRole::Provider),
(PeerRole::Peer, BgpRole::Peer),
(PeerRole::RouteServer, BgpRole::RouteServerClient),
(PeerRole::RouteServerClient, BgpRole::RouteServer),
] {
let (_, actions) = Fsm::new(FsmConfig {
role: Some(peer_is),
..config()
});
let Action::Send(open) = &actions[0] else {
panic!("expected an OPEN first, got {actions:?}");
};
let roles: Vec<_> = capabilities(open)
.into_iter()
.filter(|capability| matches!(capability, Capability::BgpRole { .. }))
.collect();
assert_eq!(
roles,
[&Capability::BgpRole { role: we_are }],
"{peer_is:?}"
);
}
// Nothing is said where nothing is configured.
let (_, actions) = Fsm::new(config());
let Action::Send(open) = &actions[0] else {
panic!("expected an OPEN first, got {actions:?}");
};
assert!(
!capabilities(open)
.iter()
.any(|capability| matches!(capability, Capability::BgpRole { .. }))
);
}
/// RFC 9234 §4.2.
#[test]
fn a_peer_that_says_it_is_something_else_than_configured_is_refused() {
let mismatch = Err(BgpError::OpenMsg(OpenMsgSubcode::RoleMismatch));
let all = [
BgpRole::Provider,
BgpRole::RouteServer,
BgpRole::RouteServerClient,
BgpRole::Customer,
BgpRole::Peer,
];
for configured in [
PeerRole::Provider,
PeerRole::Customer,
PeerRole::Peer,
PeerRole::RouteServer,
PeerRole::RouteServerClient,
] {
for theirs in all {
let expected = if theirs == configured.theirs() {
Ok(true)
} else {
mismatch.clone()
};
assert_eq!(
roles_meet(Some(configured), false, &[theirs]),
expected,
"configured as {configured:?}, says {theirs:?}"
);
}
// A role nobody has defined is not the one configured.
assert_eq!(
roles_meet(Some(configured), false, &[BgpRole::Unknown(9)]),
mismatch
);
}
}
#[test]
fn a_peer_that_does_not_say_what_it_is_is_refused_only_if_it_must() {
let mismatch = Err(BgpError::OpenMsg(OpenMsgSubcode::RoleMismatch));
// A peer that knows nothing of roles is taken at our word for it,
assert_eq!(roles_meet(Some(PeerRole::Customer), false, &[]), Ok(false));
// unless it is required to say.
assert_eq!(roles_meet(Some(PeerRole::Customer), true, &[]), mismatch);
assert_eq!(
roles_meet(Some(PeerRole::Customer), true, &[BgpRole::Customer]),
Ok(true)
);
// What a peer says where nothing is configured confirms nothing.
assert_eq!(roles_meet(None, false, &[BgpRole::Provider]), Ok(false));
}
#[test]
fn a_peer_that_says_two_things_is_refused() {
let mismatch = Err(BgpError::OpenMsg(OpenMsgSubcode::RoleMismatch));
// The same thing twice is the same thing.
assert_eq!(
roles_meet(Some(PeerRole::Peer), false, &[BgpRole::Peer, BgpRole::Peer]),
Ok(true)
);
// Two things, one of them right, is not.
assert_eq!(
roles_meet(
Some(PeerRole::Peer),
false,
&[BgpRole::Peer, BgpRole::Customer]
),
mismatch
);
// Not even where we have nothing configured to compare with.
assert_eq!(
roles_meet(None, false, &[BgpRole::Peer, BgpRole::Customer]),
mismatch
);
}
#[test]
fn an_unacceptable_open_is_refused_with_the_matching_subcode() {
let refused = |open: OpenMessage, config: FsmConfig| {
+174 -1
View File
@@ -30,6 +30,7 @@ use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use bgp_packet::message::{AS_TRANS, AsPathAttribute, PathAttribute};
use bgp_packet::open::BgpRole;
use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::IpPrefix;
@@ -150,10 +151,14 @@ pub enum Rejection {
/// A network on the path has said who its providers are, and the path
/// does not go by them: a route leak, or a path that was made up.
AspaInvalid,
/// The route is marked as one that may only be passed down to
/// customers, and it did not come from above: someone has leaked it
/// (RFC 9234).
Leak,
}
impl Rejection {
pub const ALL: [Self; 11] = [
pub const ALL: [Self; 12] = [
Self::BogonPrefix,
Self::TooShort,
Self::TooSpecific,
@@ -165,6 +170,7 @@ impl Rejection {
Self::OriginNotAllowed,
Self::RpkiInvalid,
Self::AspaInvalid,
Self::Leak,
];
/// Where the reason is in [`Rejection::ALL`].
@@ -185,6 +191,7 @@ impl Rejection {
Self::OriginNotAllowed => "origin AS not allowed",
Self::RpkiInvalid => "RPKI invalid",
Self::AspaInvalid => "ASPA invalid",
Self::Leak => "route leak (Only to Customer)",
}
}
}
@@ -209,6 +216,57 @@ pub enum PeerRole {
}
impl PeerRole {
/// The role the peer has to announce in its OPEN: its own (RFC 9234
/// §4.1).
pub fn theirs(self) -> BgpRole {
match self {
Self::Provider => BgpRole::Provider,
Self::Customer => BgpRole::Customer,
Self::Peer => BgpRole::Peer,
Self::RouteServer => BgpRole::RouteServer,
Self::RouteServerClient => BgpRole::RouteServerClient,
}
}
/// The role we announce in ours: what we are to the peer, which is
/// the other half of the pair (RFC 9234 §4.2).
pub fn ours(self) -> BgpRole {
match self {
Self::Provider => BgpRole::Customer,
Self::Customer => BgpRole::Provider,
Self::Peer => BgpRole::Peer,
Self::RouteServer => BgpRole::RouteServerClient,
Self::RouteServerClient => BgpRole::RouteServer,
}
}
/// Whether the peer is above or beside us, and not below: a provider,
/// a peer, or a route server. A route that came from there may only
/// go on to customers.
fn is_above_or_beside(self) -> bool {
matches!(self, Self::Provider | Self::Peer | Self::RouteServer)
}
/// The Only to Customer attribute to add to a route received from the
/// peer, whose AS number is `peer_asn`, if the route does not have
/// one (RFC 9234 §5, ingress, 3).
pub fn only_to_customer_on_import(self, peer_asn: u32) -> Option<u32> {
self.is_above_or_beside().then_some(peer_asn)
}
/// The Only to Customer attribute to add to a route sent to the peer
/// if the route does not have one (RFC 9234 §5, egress, 1): the route
/// is going down or across, and may not come up again.
pub fn only_to_customer_on_export(self, local_asn: u32) -> Option<u32> {
matches!(self, Self::Customer | Self::Peer | Self::RouteServerClient).then_some(local_asn)
}
/// Whether a route with an Only to Customer attribute may be sent to
/// the peer: not upwards or across (RFC 9234 §5, egress, 2).
pub fn may_be_sent_only_to_customer_routes(self) -> bool {
!self.is_above_or_beside()
}
pub fn as_str(self) -> &'static str {
match self {
Self::Provider => "provider",
@@ -231,6 +289,8 @@ pub struct From {
/// 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,
/// What the peer is to us, if that is known.
pub role: Option<PeerRole>,
}
impl ImportPolicy {
@@ -265,6 +325,24 @@ impl ImportPolicy {
if facts.has_local_asn {
return Err(Rejection::OwnAs);
}
// RFC 9234 §5, ingress, 1 and 2. A route that is only to go to
// customers has been down or across once already. From a customer
// it is coming up again; from a peer it is coming across again,
// unless it was the peer itself that marked it, on its way to us.
let only_to_customer = attributes.iter().find_map(|attribute| match attribute {
PathAttribute::OnlyToCustomer(asn) => Some(*asn),
_ => None,
});
if let (Some(marked_by), Some(role)) = (only_to_customer, from.role) {
let leak = match role {
PeerRole::Customer | PeerRole::RouteServerClient => true,
PeerRole::Peer => marked_by != from.peer_asn,
PeerRole::Provider | PeerRole::RouteServer => false,
};
if leak {
return Err(Rejection::Leak);
}
}
if !self.accept_any_first_as {
// The first AS of the first segment, which has to be a
// sequence: a set has no first.
@@ -572,6 +650,7 @@ mod tests {
local_asn: 1299,
ibgp: false,
four_octet_asn: true,
role: None,
};
fn path(asns: &[u32]) -> Vec<PathAttribute> {
@@ -809,6 +888,100 @@ mod tests {
);
}
/// RFC 9234 §5, the ingress procedure.
#[test]
fn a_route_only_for_customers_is_a_leak_from_below_or_from_a_second_peer() {
let policy = ImportPolicy::default();
let marked_by = |asn: u32| {
let mut attributes = path(&[3333, 2121]);
attributes.push(PathAttribute::OnlyToCustomer(asn));
attributes
};
let from = |role| From {
role: Some(role),
..EXTERNAL
};
// From below it is always a leak.
for role in [PeerRole::Customer, PeerRole::RouteServerClient] {
for asn in [3333, 2121] {
assert_eq!(
policy.check_path(&marked_by(asn), &from(role)),
Err(Rejection::Leak),
"{role:?}"
);
}
}
// From a peer, unless the peer marked it itself.
assert_eq!(
policy.check_path(&marked_by(2121), &from(PeerRole::Peer)),
Err(Rejection::Leak)
);
assert_eq!(
policy.check_path(&marked_by(3333), &from(PeerRole::Peer)),
Ok(Some(2121))
);
// From above it is where it should be.
for role in [PeerRole::Provider, PeerRole::RouteServer] {
assert_eq!(
policy.check_path(&marked_by(2121), &from(role)),
Ok(Some(2121)),
"{role:?}"
);
}
// Without the mark nothing is known, whoever it is from; and
// without knowing what the peer is, the mark says nothing.
for role in [PeerRole::Customer, PeerRole::Peer, PeerRole::Provider] {
assert_eq!(
policy.check_path(&path(&[3333, 2121]), &from(role)),
Ok(Some(2121))
);
}
assert_eq!(
policy.check_path(&marked_by(2121), &EXTERNAL),
Ok(Some(2121))
);
// Not even a policy that takes everything else takes a leak.
assert_eq!(
ImportPolicy::permissive().check_path(&marked_by(2121), &from(PeerRole::Customer)),
Err(Rejection::Leak)
);
}
#[test]
fn roles_pair_up_and_decide_where_the_mark_is_put() {
use PeerRole::*;
for role in [Provider, Customer, Peer, RouteServer, RouteServerClient] {
// What we are to the peer is what the peer is to us, seen
// from the other side.
let mirrored = [Provider, Customer, Peer, RouteServer, RouteServerClient]
.into_iter()
.find(|other| other.theirs() == role.ours())
.unwrap();
assert_eq!(mirrored.ours(), role.theirs(), "{role:?}");
}
assert_eq!(Provider.ours(), BgpRole::Customer);
assert_eq!(RouteServer.ours(), BgpRole::RouteServerClient);
assert_eq!(Peer.ours(), BgpRole::Peer);
// Marked on the way in from above or beside; on the way out
// downwards or across.
assert_eq!(Provider.only_to_customer_on_import(3333), Some(3333));
assert_eq!(Peer.only_to_customer_on_import(3333), Some(3333));
assert_eq!(RouteServer.only_to_customer_on_import(3333), Some(3333));
assert_eq!(Customer.only_to_customer_on_import(3333), None);
assert_eq!(RouteServerClient.only_to_customer_on_import(3333), None);
assert_eq!(Customer.only_to_customer_on_export(1299), Some(1299));
assert_eq!(Peer.only_to_customer_on_export(1299), Some(1299));
assert_eq!(
RouteServerClient.only_to_customer_on_export(1299),
Some(1299)
);
assert_eq!(Provider.only_to_customer_on_export(1299), None);
assert_eq!(RouteServer.only_to_customer_on_export(1299), None);
assert!(Customer.may_be_sent_only_to_customer_routes());
assert!(!Peer.may_be_sent_only_to_customer_routes());
}
#[test]
fn the_path_is_read_with_its_as4_path_and_its_sets() {
let mut out = vec![99];
+30 -4
View File
@@ -6,7 +6,7 @@ use std::cell::OnceCell;
use std::collections::HashMap;
use std::sync::Arc;
use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage};
use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage, only_to_customer_wire};
use bgp_primitives::ip_prefix::Nlri;
use bgp_primitives::parser::EncodeError;
use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable};
@@ -138,13 +138,39 @@ pub(crate) fn apply(
// as far as that mattering.
let path_invalid = OnceCell::new();
// A route from above or beside us may from here on only go down to
// customers. If nobody has marked it so yet, we do (RFC 9234 §5,
// ingress, 3): the mark is part of the route as it is kept, and goes
// wherever the route goes.
let mark = import
.from
.role
.filter(|_| !import.from.ibgp)
.and_then(|role| role.only_to_customer_on_import(import.from.peer_asn))
.filter(|_| {
!update
.path_attributes
.iter()
.any(|attribute| matches!(attribute, PathAttribute::OnlyToCustomer(_)))
});
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))
let Some(marked_by) = mark else {
if let Some(set) =
attributes_wire.and_then(|wire| table.attrs().intern_received(wire, next_hop))
{
return Ok(set);
}
return table.attrs().intern(&update.path_attributes, next_hop);
};
let added = only_to_customer_wire(marked_by);
if let Some(set) = attributes_wire
.and_then(|wire| table.attrs().intern_received_with(wire, &added, next_hop))
{
return Ok(set);
}
table.attrs().intern(&update.path_attributes, next_hop)
let mut marked = update.path_attributes.clone();
marked.push(PathAttribute::OnlyToCustomer(marked_by));
table.attrs().intern(&marked, next_hop)
};
let mut announce = |nlri: &[Nlri], next_hop| -> Result<(), EncodeError> {
if nlri.is_empty() {
+3
View File
@@ -123,6 +123,8 @@ pub struct SessionHandle {
pub ibgp: bool,
/// What the peer is to us, if that is known.
pub role: Option<PeerRole>,
/// Whether the peer said so itself in its OPEN (RFC 9234).
pub role_confirmed: bool,
/// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
/// AS_PATH is encoded on the wire for this session.
pub four_octet_asn: bool,
@@ -167,6 +169,7 @@ impl SessionHandle {
established_at: SystemTime::now(),
ibgp: negotiated.ibgp,
role: None,
role_confirmed: false,
four_octet_asn: negotiated.four_octet_asn,
families: negotiated.families.clone(),
add_path_receive: negotiated.add_path_receive.clone(),
+5
View File
@@ -82,6 +82,9 @@ pub struct PeerView {
/// What the peer is to us, if configured: `provider`, `customer`,
/// `peer`, `route server` or `route server client`.
pub role: Option<&'static str>,
/// Whether the peer's OPEN said the same (RFC 9234). `false` for a
/// peer that does not say; one that says otherwise gets no session.
pub role_confirmed: Option<bool>,
/// Why the last connection attempt or session ended, while in Backoff.
pub last_error: Option<String>,
pub retry_at_unix: Option<u64>,
@@ -106,6 +109,7 @@ impl From<&SessionHandle> for PeerView {
rejected_routes: Some(s.counters.rejected()),
held_routes: Some(s.counters.held()),
role: s.role.map(PeerRole::as_str),
role_confirmed: s.role.map(|_| s.role_confirmed),
last_error: None,
retry_at_unix: None,
}
@@ -138,6 +142,7 @@ impl PeerView {
rejected_routes: None,
held_routes: None,
role: peer.role.map(PeerRole::as_str),
role_confirmed: None,
last_error,
retry_at_unix,
}
+1 -1
View File
@@ -175,7 +175,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
return `<tr>
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}${p.role ? ` <span class="dim">${esc(p.role)}</span>` : ''}</td>
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}${p.role ? ` <span class="dim" title="${p.role_confirmed ? 'The peer says so too (RFC 9234)' : 'By our configuration; the peer has not said what it is'}">${esc(p.role)}${p.role_confirmed ? ' &check;' : ''}</span>` : ''}</td>
<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>