Files
ferrix/crates/bench/benches/policy.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 cbf0df1eef Policy scripts: the operator's own rules for routes, in Rhai
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>
2026-10-02 21:09:39 +02:00

351 lines
12 KiB
Rust

//! What the operator's policy script costs, on a real table: the routes
//! one peer of a RIPE RIS route collector had announced to it, each with
//! the attributes it really has.
//!
//! Needs a dump in `crates/bench/data/` (see the README there) or at the
//! path in `BENCH_MRT`; without one it says so and does nothing.
//!
//! A function is asked about a route once for every UPDATE if it does not
//! look at the prefix, and once for every prefix if it does. How many
//! UPDATEs a table arrives in is the sender's business; the two ends are
//! one UPDATE for every distinct set of attributes, and one for every
//! route. Both are measured, with what the same work costs without a
//! script.
use std::collections::HashSet;
use std::hint::black_box;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::net::Ipv4Addr;
use bgp_bench::mrt::{Dump, parse_attributes};
use bgp_bench::{Prefixes, Shape, announcements, encoded};
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{NlriNextHop, PathAttribute};
use bgp_policy::{Direction, Hook, Peer, Policy, Subject};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_server::config::PeerConfig;
use bgp_server::family::FamilySet;
use bgp_server::policy::ImportPolicy;
use bytes::BytesMut;
const SCRIPT: &str = r#"
const BLACKHOLE = "65535:666";
const LEARNED_FROM = #{ customer: "64601:1000", peer: "64601:2000", provider: "64601:3000" };
const PREFERENCE = #{ customer: 300, peer: 200, provider: 100 };
// Nothing at all: what asking costs.
fn nothing(route, peer) { true }
// Looks, and changes nothing.
fn filter(route, peer) {
if route.has_community(BLACKHOLE) { return false; }
if route.as_path_len > 30 { return false; }
if route.path_has(64512) { return false; }
true
}
// What a network would have for its transit: refuse some, mark and
// rank the rest.
fn typical(route, peer) {
if route.has_community(BLACKHOLE) { return false; }
if route.as_path_len > 30 { return false; }
route.remove_community("64601:*");
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = PREFERENCE[peer.role];
true
}
// For any peer, whatever it is to us.
fn marks(route, peer) {
if route.has_community(BLACKHOLE) { return false; }
route.remove_community("64601:*");
route.add_community("64601:3000");
route.local_pref = 100;
true
}
fn marks_by_prefix(route, peer) {
if route.prefix_len > 24 && route.family == "ipv4" { return false; }
marks(route, peer)
}
// The same, and it looks at the prefix: asked for every route.
fn by_prefix(route, peer) {
if route.prefix_len > 24 && route.family == "ipv4" { return false; }
if route.has_community(BLACKHOLE) { return false; }
route.remove_community("64601:*");
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = PREFERENCE[peer.role];
true
}
"#;
fn dump_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("BENCH_MRT") {
return Some(path.into());
}
let data = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data");
let mut dumps: Vec<PathBuf> = std::fs::read_dir(data)
.ok()?
.filter_map(|entry| Some(entry.ok()?.path()))
.filter(|path| path.extension().is_some_and(|e| e == "gz"))
.collect();
dumps.sort();
dumps.pop()
}
type Attributes = (Vec<PathAttribute>, Option<NlriNextHop>);
struct Outcome {
elapsed: Duration,
asked: usize,
taken: usize,
changed: usize,
}
/// Asks `hook` about each of `routes`.
fn ask<'a>(
hook: &Hook,
peer: &Peer,
routes: impl Iterator<Item = (&'a IpPrefix, &'a Attributes)>,
) -> Outcome {
let started = Instant::now();
let (mut asked, mut taken, mut changed) = (0, 0, 0);
for (prefix, (attributes, next_hop)) in routes {
let verdict = hook
.run(
peer,
&Subject {
prefix,
attributes,
next_hop: *next_hop,
direction: Direction::Import,
},
)
.expect("the function runs");
asked += 1;
taken += usize::from(verdict.accept);
changed += usize::from(verdict.attributes.is_some());
black_box(&verdict);
}
Outcome {
elapsed: started.elapsed(),
asked,
taken,
changed,
}
}
fn row(what: &str, outcome: &Outcome) {
println!(
"{what:<52} {:>8.3} s {:>7.0} ns each {} asked, {} taken, {} changed",
outcome.elapsed.as_secs_f64(),
outcome.elapsed.as_nanos() as f64 / outcome.asked.max(1) as f64,
outcome.asked,
outcome.taken,
outcome.changed
);
}
/// One peer sends the whole (synthetic) table over TCP to a server that
/// gives its routes to `function`, or to none. Returns how long it took
/// for all of them to be in the table.
fn end_to_end(wire: &[BytesMut], routes: usize, function: Option<&str>) -> Duration {
const PEER_ASN: u32 = 64513;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("a runtime");
runtime.block_on(async {
// The configured peer is whoever connects from this machine; the
// listener is where the server's own attempts to reach it go.
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.expect("a port");
let endpoint = listener.local_addr().expect("its address");
let policy = Policy::compile(SCRIPT).expect("the script");
let import_hook = function.map(|name| policy.hook(name).expect("the function"));
let server = TestServer::start_with(4_200_000_000, |config| {
config.accept_unconfigured_peers = false;
config.peers = vec![PeerConfig {
name: "transit".into(),
endpoint: endpoint.into(),
remote_asn: PEER_ASN,
hold_time_secs: 90,
local_address: None,
families: FamilySet::supported(),
add_path: None,
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook,
export_hook: None,
}];
})
.await
.expect("a server");
let mut session = TestPeer::connect(server.addr).await.expect("a connection");
session
.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90)
.await
.expect("a session");
let stream: Vec<u8> = wire.iter().flat_map(|m| m.iter().copied()).collect();
let started = Instant::now();
let sending = tokio::spawn(async move {
session.send_raw(&stream).await.expect("the server reads");
session
});
while server.rib.len() < routes {
tokio::time::sleep(Duration::from_millis(2)).await;
}
let elapsed = started.elapsed();
drop(sending.await);
elapsed
})
}
fn main() {
// The whole way, first: a session, its state machine, the general
// checks, the function, the table. On a table that is made up, so
// that it runs anywhere.
let shape = Shape::INTERNET;
let prefixes = Prefixes::generate(shape);
let wire = encoded(&announcements(&prefixes, shape, 1));
let routes = shape.prefixes();
println!(
"== End to end: one peer sends {routes} routes in {} UPDATEs ({:.1} routes each) over TCP",
wire.len(),
routes as f64 / wire.len() as f64
);
let mut without = Duration::ZERO;
for (what, function) in [
("no policy function", None),
("a function that does nothing", Some("nothing")),
("marks and ranks every route", Some("marks")),
("the same, and looks at the prefix", Some("marks_by_prefix")),
] {
// The better of two: the first run of all also warms the machine.
let elapsed = end_to_end(&wire, routes, function).min(end_to_end(&wire, routes, function));
if function.is_none() {
without = elapsed;
}
println!(
"{what:<52} {:>8.3} s {:.2} M routes/s {:+.0}%",
elapsed.as_secs_f64(),
routes as f64 / elapsed.as_secs_f64() / 1e6,
100.0 * (elapsed.as_secs_f64() / without.as_secs_f64() - 1.0)
);
}
println!();
let Some(path) = dump_path() else {
println!("No table dump found; see crates/bench/data/README.md. Nothing more to do.");
return;
};
println!("== Reading {}", path.display());
let dump = Dump::read(&path).expect("a readable dump");
let peer_index = dump.largest_peers(1)[0];
let routes = &dump.routes[peer_index];
let parsed: Vec<Option<Attributes>> = dump
.attribute_sets
.iter()
.map(|wire| parse_attributes(wire))
.collect();
let routes: Vec<(&IpPrefix, &Attributes)> = routes
.iter()
.filter_map(|(prefix, set)| Some((prefix, parsed[*set as usize].as_ref()?)))
.collect();
let distinct: HashSet<*const Attributes> = routes
.iter()
.map(|(_, attributes)| std::ptr::from_ref(*attributes))
.collect();
let mut seen = HashSet::new();
let once_per_set: Vec<(&IpPrefix, &Attributes)> = routes
.iter()
.filter(|(_, attributes)| seen.insert(std::ptr::from_ref(*attributes)))
.copied()
.collect();
let communities: usize = routes
.iter()
.map(|(_, (attributes, _))| {
attributes
.iter()
.map(|attribute| match attribute {
PathAttribute::Communities(communities) => communities.0.len(),
_ => 0,
})
.sum::<usize>()
})
.sum();
println!(
"The peer with the most routes, AS{}: {} routes with {} distinct sets of attributes \
({:.1} routes a set), {:.1} communities a route\n",
dump.peers[peer_index].asn,
routes.len(),
distinct.len(),
routes.len() as f64 / distinct.len() as f64,
communities as f64 / routes.len() as f64
);
let policy = Policy::compile(SCRIPT).expect("the script");
let peer = Peer::new(
"transit",
dump.peers[peer_index].address,
dump.peers[peer_index].asn,
64601,
"provider",
);
// What it costs to go over the routes and do the work of the typical
// function in Rust.
let started = Instant::now();
let mut taken = 0usize;
for (_, (attributes, _)) in &routes {
let mut communities: Vec<(u16, u16)> = attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::Communities(communities) => Some(communities.0.clone()),
_ => None,
})
.unwrap_or_default();
if communities.contains(&(65535, 666)) {
continue;
}
communities.retain(|community| community.0 != 64601);
communities.push((64601, 3000));
let mut changed = attributes.clone();
changed.push(PathAttribute::Communities(
bgp_packet::message::CommunitiesPathAttribute(communities),
));
black_box(&changed);
taken += 1;
}
row(
"the typical one in Rust, for every route",
&Outcome {
elapsed: started.elapsed(),
asked: routes.len(),
taken,
changed: taken,
},
);
println!();
for function in ["nothing", "filter", "typical", "by_prefix"] {
let hook = policy.hook(function).expect("the function");
row(
&format!("{function}, asked for every route"),
&ask(&hook, &peer, routes.iter().copied()),
);
if function != "by_prefix" {
row(
&format!("{function}, asked once for each set of attributes"),
&ask(&hook, &peer, once_per_set.iter().copied()),
);
}
}
}