A new crate, bgp-policy, runs functions of a script on routes: on their
way in, after the general checks of a peer's import and before the
checks that can change (RPKI, next hop reachability); and on their way
out to a peer. A function is given the route and the peer, may refuse
the route, and may change its communities, large communities,
LOCAL_PREF and MED, and on the way out prepend our AS:
fn from_peer(route, peer) {
if route.has_community("65535:666") { return false; }
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = PREFERENCE[peer.role];
true
}
Configuration: policy { path | source, import_function, export_function }
and, for a peer, import { function } and export { function }. The script
is compiled at startup; `bgp --check` reads it and says which function
is whose. A function that fails on a route has refused it. Scripts have
no files, network or clock, and are stopped after 200,000 steps.
Route selection now goes by a LOCAL_PREF on a route from an external
peer: the decoder never lets a peer's own through, so one that is there
was set by policy.
What it costs was measured before it was built on (benches/policy.rs):
a call that tests a few communities and changes two takes 1 to 3
microseconds, some two hundred times what Rust would. So a function is
asked once for the routes of an UPDATE, which share their attributes,
unless it asks for the prefix. One peer sending 1.23 M routes over a
session: 0.37 s without a function, 0.80 s with one that marks and
ranks every route, 3.4 s if it also looks at the prefix.
Export applies to what is announced, which today is what the daemon
originates; learned routes are not passed on yet.
Tested by unit tests of the crate, sessions end to end, a fuzz target on
the example script (testdata/policy.rhai), and a lab phase in which a
script changes which of FRR's and BIRD's route is chosen and what each
of them is told.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
411 lines
15 KiB
Rust
411 lines
15 KiB
Rust
//! 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::default_policy;
|
|
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::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: endpoint.into(),
|
|
remote_asn: PEER_ASN,
|
|
hold_time_secs: 90,
|
|
local_address: None,
|
|
families: FamilySet::supported(),
|
|
add_path: None,
|
|
import: default_policy(),
|
|
role,
|
|
require_role,
|
|
import_hook: None,
|
|
export_hook: None,
|
|
}];
|
|
})
|
|
.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(())
|
|
}
|