Remove policy scripts

Rhai made a decision about a route cost one to three microseconds,
some two hundred times what the same costs in Rust. That was bearable
where a function is asked once for an UPDATE, and is not where every
route is weighed against every peer, as it is when routes are passed
on. What is wanted of a policy (mark routes by where they came from,
mark what is announced, let communities say who is told what) needs no
language; it is to be configuration, done natively.

Kept from that work: route selection goes by a LOCAL_PREF that policy
set on a route from an external peer, and the lab's configuration can
be added to by a phase.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 22:47:57 +02:00
1 parent cbf0df1eef
commit 9367c38907
48 files changed
+21 -3435

No files matched your search

-5
View File
@@ -18,7 +18,6 @@ stats_alloc = { version = "0.1.10", optional = true }
[dev-dependencies]
bgp-integration-tests.workspace = true
bgp-policy.workspace = true
bgp-rib.workspace = true
bgp-server.workspace = true
tokio.workspace = true
@@ -39,9 +38,5 @@ harness = false
name = "real_table"
harness = false
[[bench]]
name = "policy"
harness = false
[lints]
workspace = true
-350
View File
@@ -1,350 +0,0 @@
//! 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()),
);
}
}
}
-1
View File
@@ -11,7 +11,6 @@ publish = false
[dependencies]
bgp-fib.workspace = true
bgp-packet.workspace = true
bgp-policy.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-rpki.workspace = true
@@ -67,7 +67,6 @@ impl TestServer {
http_endpoint: None,
rpki: None,
kernel: None,
policy: None,
};
customize(&mut config);
@@ -50,8 +50,6 @@ async fn server_offering(
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
c.originate = originate;
// Keep the dialer out of the way.
@@ -28,8 +28,6 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig {
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}
}
-2
View File
@@ -67,8 +67,6 @@ async fn server_with(
import,
role,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await?;
@@ -46,8 +46,6 @@ async fn server_dialing(listener: &TcpListener) -> Result<TestServer> {
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await
@@ -180,8 +180,6 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> {
},
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await?;
-8
View File
@@ -37,8 +37,6 @@ async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestS
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await
@@ -196,8 +194,6 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await?;
@@ -249,8 +245,6 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await?;
@@ -271,8 +265,6 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await?;
@@ -51,8 +51,6 @@ async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)>
import,
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await?;
@@ -193,8 +193,6 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> {
import: ImportPolicy::permissive(),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
// Keep the dialer out of the way.
c.connect_retry = Duration::from_secs(3600);
@@ -1,361 +0,0 @@
//! The operator's own rules, as a script: what they do to routes on their
//! way in, and to what a peer is told.
use std::collections::BTreeMap;
use std::net::Ipv4Addr;
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::message::{
AsPathAttribute, CommunitiesPathAttribute, LocalPrefPathAttribute, Message,
NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
};
use bgp_policy::Policy;
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::PeerRole;
use eyre::Result;
use tokio::net::TcpListener;
const LOCAL_ASN: u32 = 1299;
const PEER_ASN: u32 = 3333;
const TIMEOUT: Duration = Duration::from_secs(5);
const SCRIPT: &str = r#"
const BLACKHOLE = "65535:666";
const LEARNED_FROM = #{ customer: "1299:1000", peer: "1299:2000", provider: "1299:3000" };
fn from_peer(route, peer) {
if route.has_community(BLACKHOLE) { return false; }
// Nobody else's word for where we learned a route.
route.remove_community("1299:*");
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = if route.has_community("3333:80") { 80 } else { 200 };
true
}
fn by_prefix(route, peer) {
if route.within("194.0.0.0/8") { return false; }
if route.prefix == "193.0.4.0/24" { route.add_community("1299:4"); }
true
}
fn broken(route, peer) {
if route.prefix_len == 22 { return route.no_such_thing; }
true
}
fn to_peer(route, peer) {
if route.has_community("1299:9") { return false; } // not for anyone else
if route.within("193.0.0.0/21") { route.prepend(2); }
route.add_community("1299:" + peer.asn % 1000);
route.med = 50;
true
}
"#;
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
/// A server with one configured peer, whose routes are given to the
/// script's function `import`, and which is told what `export` lets by.
async fn server_with(
import: Option<&str>,
export: Option<&str>,
originate: Vec<LocalRoute>,
) -> Result<(TestServer, TcpListener)> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let endpoint = listener.local_addr()?;
let policy = Policy::compile(SCRIPT)?;
let import_hook = import.map(|name| policy.hook(name)).transpose()?;
let export_hook = export.map(|name| policy.hook(name)).transpose()?;
let server = TestServer::start_with(LOCAL_ASN, |c| {
c.accept_unconfigured_peers = false;
c.originate = originate;
c.policy = Some(policy);
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: Some(PeerRole::Peer),
require_role: false,
import_hook,
export_hook,
}];
})
.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)
}
async fn announce(
peer: &mut TestPeer,
prefixes: &[&str],
communities: &[(u16, u16)],
) -> Result<()> {
let mut path_attributes = vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![PEER_ASN]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
];
if !communities.is_empty() {
path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(
communities.to_vec(),
)));
}
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes,
announced_routes: prefixes.iter().map(|p| prefix(p).into()).collect(),
}))
.await
}
/// Waits until the server has taken `taken` routes of the session's and
/// refused `refused`.
async fn dealt_with(server: &TestServer, taken: u64, refused: u64) -> Result<()> {
let counters = server.registry.list()[0].counters.clone();
tokio::time::timeout(TIMEOUT, async {
while (counters.routes(), counters.rejected()) != (taken, refused) {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.map_err(|_| {
eyre::eyre!(
"the server took {} routes and refused {}; expected {taken} and {refused}",
counters.routes(),
counters.rejected()
)
})
}
/// A route's communities and its LOCAL_PREF.
type Marks = (Vec<(u16, u16)>, Option<u32>);
/// The communities and LOCAL_PREF each prefix is in the table with.
fn table(server: &TestServer) -> BTreeMap<String, Marks> {
server
.received_routes()
.iter()
.map(|route| {
let attributes = &route.attributes;
let communities = attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::Communities(communities) => Some(communities.0.clone()),
_ => None,
})
.unwrap_or_default();
let local_pref = attributes.iter().find_map(|attribute| match attribute {
PathAttribute::LocalPref(LocalPrefPathAttribute(value)) => Some(*value),
_ => None,
});
(route.prefix.to_string(), (communities, local_pref))
})
.collect()
}
fn refusals(server: &TestServer) -> Vec<(&'static str, u64)> {
server.refusals.peers()[0]
.1
.by_reason()
.into_iter()
.map(|(reason, count)| (reason.as_str(), count))
.collect()
}
/// Routes are marked with where they were learned and given a
/// preference, by what they carry and who sent them; one the function
/// refuses is not taken.
#[tokio::test]
async fn test_routes_are_marked_preferred_and_refused_on_their_way_in() -> Result<()> {
let (server, _listener) = server_with(Some("from_peer"), None, vec![]).await?;
let mut peer = established(&server).await?;
// One UPDATE of two routes; one of a route with marks of its own, one
// of them forged; and one the function refuses.
announce(&mut peer, &["193.0.0.0/21", "193.0.8.0/22"], &[]).await?;
announce(&mut peer, &["194.0.1.0/24"], &[(3333, 80), (1299, 1000)]).await?;
announce(&mut peer, &["195.0.1.0/24"], &[(65535, 666)]).await?;
dealt_with(&server, 3, 1).await?;
assert_eq!(
table(&server),
BTreeMap::from([
("193.0.0.0/21".to_string(), (vec![(1299, 2000)], Some(200))),
("193.0.8.0/22".to_string(), (vec![(1299, 2000)], Some(200))),
(
"194.0.1.0/24".to_string(),
(vec![(3333, 80), (1299, 2000)], Some(80))
),
])
);
assert_eq!(refusals(&server), [("refused by policy function", 1)]);
// A route the function took is replaced by one it refuses: gone.
announce(&mut peer, &["193.0.8.0/22"], &[(65535, 666)]).await?;
dealt_with(&server, 2, 2).await?;
assert!(!table(&server).contains_key("193.0.8.0/22"));
Ok(())
}
/// A function that asks for the prefix is asked about each route of an
/// UPDATE, and may answer differently for each.
#[tokio::test]
async fn test_a_function_may_treat_the_routes_of_one_update_differently() -> Result<()> {
let (server, _listener) = server_with(Some("by_prefix"), None, vec![]).await?;
let mut peer = established(&server).await?;
announce(
&mut peer,
&[
"193.0.0.0/21",
"194.0.1.0/24",
"193.0.4.0/24",
"194.0.2.0/24",
"195.0.0.0/24",
],
&[(3333, 1)],
)
.await?;
dealt_with(&server, 3, 2).await?;
assert_eq!(
table(&server),
BTreeMap::from([
("193.0.0.0/21".to_string(), (vec![(3333, 1)], None)),
(
"193.0.4.0/24".to_string(),
(vec![(3333, 1), (1299, 4)], None)
),
("195.0.0.0/24".to_string(), (vec![(3333, 1)], None)),
])
);
assert_eq!(
server.rib.attrs().len(),
2,
"two routes share their attributes"
);
Ok(())
}
/// A function that fails on a route has refused it, and no other.
#[tokio::test]
async fn test_a_function_that_fails_refuses_the_route() -> Result<()> {
let (server, _listener) = server_with(Some("broken"), None, vec![]).await?;
let mut peer = established(&server).await?;
announce(
&mut peer,
&["193.0.0.0/21", "193.0.8.0/22", "195.0.0.0/24"],
&[],
)
.await?;
dealt_with(&server, 2, 1).await?;
assert_eq!(refusals(&server), [("policy function failed", 1)]);
assert!(!table(&server).contains_key("193.0.8.0/22"));
Ok(())
}
/// What a peer is told is what the function for it lets by, as the
/// function left it.
#[tokio::test]
async fn test_what_a_peer_is_told_goes_through_its_function() -> Result<()> {
let route = |prefix_text: &str, communities: Vec<(u16, u16)>| {
LocalRoute::new(
prefix(prefix_text),
LocalRouteAttrs {
communities,
..LocalRouteAttrs::default()
},
)
};
let originate = vec![
route("193.0.0.0/21", vec![(1299, 1)]),
route("194.0.0.0/22", vec![]),
route("195.0.0.0/22", vec![(1299, 9)]),
];
let (server, _listener) = server_with(None, Some("to_peer"), originate).await?;
let mut peer = established(&server).await?;
let mut told = BTreeMap::new();
for _ in 0..2 {
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
let path = update
.path_attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()),
_ => None,
})
.unwrap();
let communities = update
.path_attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::Communities(communities) => Some(communities.0.clone()),
_ => None,
})
.unwrap_or_default();
let med = update
.path_attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::MultiExitDisc(med) => Some(med.0),
_ => None,
});
// In the order of their type codes, as any UPDATE of ours.
let codes: Vec<u8> = update
.path_attributes
.iter()
.map(PathAttribute::type_code)
.collect();
assert!(codes.is_sorted(), "{codes:?}");
// The peer is one: the route is still marked as only for customers.
assert!(codes.contains(&35), "{codes:?}");
told.insert(
update.announced_routes[0].prefix.to_string(),
(path, communities, med),
);
}
assert_eq!(
told,
BTreeMap::from([
(
"193.0.0.0/21".to_string(),
(vec![LOCAL_ASN; 3], vec![(1299, 1), (1299, 333)], Some(50))
),
(
"194.0.0.0/22".to_string(),
(vec![LOCAL_ASN], vec![(1299, 333)], Some(50))
),
])
);
// And nothing of the third.
assert!(
tokio::time::timeout(Duration::from_millis(300), peer.recv_update())
.await
.is_err()
);
Ok(())
}
-2
View File
@@ -58,8 +58,6 @@ async fn server_with(role: Option<PeerRole>, require_role: bool) -> Result<Setup
import: default_policy(),
role,
require_role,
import_hook: None,
export_hook: None,
}];
})
.await?;
-2
View File
@@ -77,8 +77,6 @@ async fn server_with(
import,
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}];
})
.await?;
-22
View File
@@ -1,22 +0,0 @@
[package]
name = "bgp-policy"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
version.workspace = true
[dependencies]
bgp-packet.workspace = true
bgp-primitives.workspace = true
foldhash.workspace = true
# The language policies are written in. Without what a policy has no use
# for: closures over outer variables, loading other files, clocks, floats.
# `sync` makes the engine something every session's thread can use.
rhai = { version = "1.26", default-features = false, features = ["std", "sync", "no_module", "no_time", "no_float", "no_custom_syntax"] }
thiserror.workspace = true
tracing.workspace = true
[lints]
workspace = true
-249
View File
@@ -1,249 +0,0 @@
//! Policies written by the operator, in a script.
//!
//! The checks every route gets (`bgp_server::policy`) say what no network
//! should take from another. What a network wants beyond that is its own
//! business: which routes it prefers, what it marks them with, what it
//! tells which neighbor. That is said in a script, in [Rhai], as functions
//! that are given a route and the peer it came from or is going to:
//!
//! ```text
//! const BLACKHOLE = "65535:666";
//!
//! fn from_transit(route, peer) {
//! if route.has_community(BLACKHOLE) { return false; }
//! route.add_community("64601:2000");
//! route.local_pref = 80;
//! true
//! }
//! ```
//!
//! A function returns whether the route is taken (or announced), and may
//! change the route before it does: its communities, LOCAL_PREF, MED, and
//! on the way out how often our AS is on its path. See [`route`] for all
//! of what a script is given.
//!
//! # What a script costs
//!
//! Rhai walks the script's syntax tree; a call that looks at a few
//! communities and changes one or two takes about a microsecond, two
//! hundred times what the same would in Rust. So a script is not run for
//! every route: routes that arrive in one UPDATE share their attributes,
//! and a function that does not ask for the prefix gives one answer for
//! all of them. [`Verdict::saw_prefix`] says whether it asked.
//!
//! # What a script cannot do
//!
//! Anything but compute: there are no files, no network, no clock. One
//! that runs for too long is stopped ([`MAX_OPERATIONS`]). A function that
//! fails (it is stopped, or asks for something that is not there) has
//! refused the route: failing must not be a way through.
//!
//! [Rhai]: https://rhai.rs
pub mod route;
use std::collections::HashMap;
use std::sync::Arc;
use bgp_packet::message::{NlriNextHop, PathAttribute};
use bgp_primitives::ip_prefix::IpPrefix;
use rhai::{AST, CallFnOptions, Dynamic, Engine, Scope};
pub use route::Peer;
use route::Route;
/// How many steps one call of a function may take. A function that looks
/// at every community of a route with a hundred of them takes a few
/// thousand.
pub const MAX_OPERATIONS: u64 = 200_000;
/// What is wrong with a script, or went wrong in one.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PolicyError {
#[error("the script does not parse: {0}")]
Parse(String),
#[error("the script failed when it was loaded: {0}")]
Load(String),
#[error("the script has no function {name:?}; it has: {known}")]
NoSuchFunction { name: String, known: String },
#[error(
"the function {name:?} takes {takes} arguments; a policy takes two, the route and the peer"
)]
Arguments { name: String, takes: usize },
#[error("{function}: {message}")]
Run { function: String, message: String },
#[error("{function} returned {returned}; a policy returns true or false")]
Returned { function: String, returned: String },
}
/// Whether routes are coming in or going out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// A route a peer announced, on its way into the table.
Import,
/// A route on its way to a peer.
Export,
}
/// A script, read and ready to be called.
pub struct Policy {
engine: Engine,
ast: AST,
}
impl std::fmt::Debug for Policy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Policy")
.field("functions", &self.functions())
.finish()
}
}
impl Policy {
/// Reads `source`. What it has outside of functions is run once, now:
/// that is where its constants are defined.
pub fn compile(source: &str) -> Result<Arc<Self>, PolicyError> {
let mut engine = Engine::new();
engine.set_max_operations(MAX_OPERATIONS);
engine.set_max_call_levels(32);
engine.set_max_expr_depths(64, 32);
engine.set_max_string_size(16 * 1024);
engine.set_max_array_size(16 * 1024);
engine.set_max_map_size(1024);
// What a script prints is for whoever is writing it.
engine.on_print(|text| tracing::info!(target: "bgp_policy::script", "{text}"));
engine.on_debug(|text, _, position| {
tracing::debug!(target: "bgp_policy::script", %position, "{text}");
});
route::register(&mut engine);
let ast = engine
.compile(source)
.map_err(|e| PolicyError::Parse(e.to_string()))?;
let mut globals = Scope::new();
engine
.run_ast_with_scope(&mut globals, &ast)
.map_err(|e| PolicyError::Load(e.to_string()))?;
// A Rhai function sees nothing outside itself. A policy's
// constants are for all its functions, so they are found for
// them: this is asked for every variable a function names, before
// the function's own are looked at.
let constants: HashMap<String, Dynamic, foldhash::fast::RandomState> = globals
.iter()
.map(|(name, _, value)| (name.to_string(), value))
.collect();
if !constants.is_empty() {
#[allow(deprecated, reason = "the API is marked volatile, not obsolete")]
engine.on_var(move |name, _, _| Ok(constants.get(name).cloned()));
}
Ok(Arc::new(Self { engine, ast }))
}
/// The names of the script's functions, in order.
pub fn functions(&self) -> Vec<String> {
let mut names: Vec<String> = self
.ast
.iter_functions()
.map(|function| function.name.to_string())
.collect();
names.sort();
names.dedup();
names
}
/// The function called `name`, to be run on routes.
pub fn hook(self: &Arc<Self>, name: &str) -> Result<Hook, PolicyError> {
let found: Vec<usize> = self
.ast
.iter_functions()
.filter(|function| function.name == name)
.map(|function| function.params.len())
.collect();
match found.as_slice() {
[] => Err(PolicyError::NoSuchFunction {
name: name.to_string(),
known: self.functions().join(", "),
}),
takes if takes.contains(&2) => Ok(Hook {
policy: Arc::clone(self),
name: name.to_string(),
}),
[takes, ..] => Err(PolicyError::Arguments {
name: name.to_string(),
takes: *takes,
}),
}
}
}
/// A route as a policy is asked about it.
#[derive(Debug, Clone, Copy)]
pub struct Subject<'a> {
pub prefix: &'a IpPrefix,
/// The route's attributes, but for its next hop if that came with the
/// prefixes (MP_REACH_NLRI).
pub attributes: &'a [PathAttribute],
pub next_hop: Option<NlriNextHop>,
pub direction: Direction,
}
/// What a policy made of a route.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
/// Whether the route is taken, or announced.
pub accept: bool,
/// The route's attributes as the policy left them, if it changed any.
pub attributes: Option<Vec<PathAttribute>>,
/// Whether the policy asked what the route's prefix is. If it did not,
/// its answer is the same for any other prefix with these attributes
/// from this peer, and need not be asked for again.
pub saw_prefix: bool,
}
/// One function of a policy. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Hook {
policy: Arc<Policy>,
name: String,
}
impl Hook {
pub fn name(&self) -> &str {
&self.name
}
/// Asks the function about a route from, or for, `peer`.
pub fn run(&self, peer: &Peer, subject: &Subject<'_>) -> Result<Verdict, PolicyError> {
let route = Route::new(subject, peer);
let returned = self
.policy
.engine
.call_fn_with_options::<Dynamic>(
// The script's top level was run when it was loaded.
CallFnOptions::new().eval_ast(false),
&mut Scope::new(),
&self.policy.ast,
&self.name,
(route.clone(), peer.clone()),
)
.map_err(|e| PolicyError::Run {
function: self.name.clone(),
message: e.to_string(),
})?;
// A function that ends without saying has no objection.
let accept = if returned.is_unit() {
true
} else {
returned.as_bool().map_err(|_| PolicyError::Returned {
function: self.name.clone(),
returned: returned.to_string(),
})?
};
let (attributes, saw_prefix) = route.finish(subject.attributes);
Ok(Verdict {
accept,
attributes: attributes.filter(|_| accept),
saw_prefix,
})
}
}
-629
View File
@@ -1,629 +0,0 @@
//! What a policy function is given: the route and the peer.
//!
//! # The route
//!
//! | In a script | |
//! |---|---|
//! | `route.prefix` | `"203.0.113.0/24"` |
//! | `route.prefix_len` | `24` |
//! | `route.within("203.0.112.0/22")` | whether the prefix is that one or inside it |
//! | `route.family` | `"ipv4"` or `"ipv6"` |
//! | `route.as_path` | `[64602, 3356, 15169]`, the neighbor's AS first |
//! | `route.as_path_len` | as route selection counts it: a set is one |
//! | `route.origin_as`, `route.neighbor_as` | the last and the first AS of the path; 0 if there is none |
//! | `route.path_has(3356)` | whether the AS is anywhere on the path |
//! | `route.origin` | `"igp"`, `"egp"` or `"incomplete"` |
//! | `route.next_hop` | `"192.0.2.1"` |
//! | `route.med`, `route.local_pref` | the number, or `()` if the route has none; both can be set, and unset with `()` |
//! | `route.communities` | `["3356:3", "3356:2012"]` |
//! | `route.has_community("3356:3")` | also `(3356, 3)`, and `"3356:*"` for any of that AS |
//! | `route.add_community("64601:100")` | also `(64601, 100)` |
//! | `route.remove_community("3356:*")` | all that match; `"*:*"` for all there are |
//! | `route.large_communities`, `route.has_large_community("64601:1:*")`, `route.add_large_community(..)`, `route.remove_large_community(..)` | the same for large communities (RFC 8092) |
//! | `route.prepend(2)` | on the way out: our AS that many more times on the path |
//!
//! # The peer
//!
//! `peer.name` (as configured), `peer.asn`, `peer.address`, `peer.role`
//! (`"customer"`, `"provider"`, `"peer"`, `"route_server"`,
//! `"route_server_client"`, or `""` if none is configured), `peer.ibgp`,
//! and `peer.local_asn`, which is our own.
use std::net::IpAddr;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use bgp_packet::community::{format_large, format_standard};
use bgp_packet::message::{
AsPathAttribute, AsPathSegment, CommunitiesPathAttribute, LargeCommunitiesPathAttribute,
LargeCommunity, LocalPrefPathAttribute, MultiExitDiscPathAttribute, NlriNextHop,
OriginPathAttribute, PathAttribute,
};
use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::IpPrefix;
use rhai::{Array, Dynamic, Engine, EvalAltResult, INT, ImmutableString};
use crate::{Direction, Subject};
type Fallible<T> = Result<T, Box<EvalAltResult>>;
/// A peer, as a script sees it. Made once for a session.
#[derive(Debug, Clone)]
pub struct Peer {
name: ImmutableString,
address: ImmutableString,
role: ImmutableString,
asn: u32,
local_asn: u32,
ibgp: bool,
}
impl Peer {
/// `role` is the peer's role by the name scripts know it under, or
/// empty.
pub fn new(name: &str, address: IpAddr, asn: u32, local_asn: u32, role: &str) -> Self {
Self {
name: name.into(),
address: address.to_string().into(),
role: role.into(),
asn,
local_asn,
ibgp: asn == local_asn,
}
}
}
/// The route a function is looking at, and what it has done to it.
#[derive(Debug)]
struct State {
prefix: IpPrefix,
direction: Direction,
local_asn: u32,
ibgp: bool,
/// The AS numbers of the path in order, sets and all.
as_path: Vec<u32>,
as_path_len: usize,
origin_as: u32,
neighbor_as: u32,
origin: OriginPathAttribute,
next_hop: Option<NlriNextHop>,
communities: Vec<(u16, u16)>,
large_communities: Vec<LargeCommunity>,
local_pref: Option<u32>,
med: Option<u32>,
/// How many more times our AS is to go on the path.
prepend: usize,
saw_prefix: bool,
/// Which of the above the function changed.
changed: Changed,
}
#[derive(Debug, Default, Clone, Copy)]
struct Changed {
communities: bool,
large_communities: bool,
local_pref: bool,
med: bool,
}
/// The handle a script holds. The function is given a copy and changes
/// what it points to; the caller reads that afterwards.
#[derive(Debug, Clone)]
pub(crate) struct Route(Arc<Mutex<State>>);
impl Route {
pub(crate) fn new(subject: &Subject<'_>, peer: &Peer) -> Self {
let mut state = State {
prefix: *subject.prefix,
direction: subject.direction,
local_asn: peer.local_asn,
ibgp: peer.ibgp,
as_path: Vec::new(),
as_path_len: 0,
origin_as: 0,
neighbor_as: 0,
origin: OriginPathAttribute::INCOMPLETE,
next_hop: subject.next_hop,
communities: Vec::new(),
large_communities: Vec::new(),
local_pref: None,
med: None,
prepend: 0,
saw_prefix: false,
changed: Changed::default(),
};
for attribute in subject.attributes {
match attribute {
PathAttribute::Origin(origin) => state.origin = *origin,
PathAttribute::AsPath(path) => {
state.as_path_len = path.hops();
let first = path.segments.first().filter(|segment| segment.ordered);
let last = path.segments.last().filter(|segment| segment.ordered);
state.neighbor_as = first.and_then(|s| s.path.first()).copied().unwrap_or(0);
state.origin_as = last.and_then(|s| s.path.last()).copied().unwrap_or(0);
state.as_path = path
.segments
.iter()
.flat_map(|segment| segment.path.iter().copied())
.collect();
}
PathAttribute::NextHop(next_hop) => {
state.next_hop = Some(NlriNextHop::Ipv4(next_hop.0));
}
PathAttribute::MultiExitDisc(med) => state.med = Some(med.0),
PathAttribute::LocalPref(local_pref) => state.local_pref = Some(local_pref.0),
PathAttribute::Communities(communities) => {
state.communities.clone_from(&communities.0);
}
PathAttribute::LargeCommunities(large) => {
state.large_communities.clone_from(&large.communities);
}
_ => {}
}
}
Self(Arc::new(Mutex::new(state)))
}
fn state(&self) -> MutexGuard<'_, State> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
/// What the function left: `attributes` as changed, if it changed
/// any, and whether it asked for the prefix.
pub(crate) fn finish(
&self,
attributes: &[PathAttribute],
) -> (Option<Vec<PathAttribute>>, bool) {
let state = self.state();
let changed = state.changed;
let prepend = state.prepend > 0 && !state.ibgp;
let any = changed.communities
|| changed.large_communities
|| changed.local_pref
|| changed.med
|| prepend;
if !any {
return (None, state.saw_prefix);
}
let mut attributes = attributes.to_vec();
if changed.communities {
let communities = (!state.communities.is_empty()).then(|| {
PathAttribute::Communities(CommunitiesPathAttribute(state.communities.clone()))
});
put(
&mut attributes,
bgp_packet::message::attr_type::COMMUNITIES,
communities,
);
}
if changed.large_communities {
let large = (!state.large_communities.is_empty()).then(|| {
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: state.large_communities.clone(),
})
});
put(
&mut attributes,
bgp_packet::message::attr_type::LARGE_COMMUNITIES,
large,
);
}
if changed.local_pref {
let local_pref = state
.local_pref
.map(|value| PathAttribute::LocalPref(LocalPrefPathAttribute(value)));
put(
&mut attributes,
bgp_packet::message::attr_type::LOCAL_PREF,
local_pref,
);
}
if changed.med {
let med = state
.med
.map(|value| PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(value)));
put(
&mut attributes,
bgp_packet::message::attr_type::MULTI_EXIT_DISC,
med,
);
}
if prepend {
let ours = std::iter::repeat_n(state.local_asn, state.prepend);
for attribute in &mut attributes {
if let PathAttribute::AsPath(path) = attribute {
prepend_to(path, ours);
break;
}
}
}
(Some(attributes), state.saw_prefix)
}
}
/// Puts `ours` at the front of `path`.
fn prepend_to(path: &mut AsPathAttribute, ours: impl Iterator<Item = u32>) {
match path.segments.first_mut() {
Some(first) if first.ordered => {
first.path.splice(0..0, ours);
}
_ => path.segments.insert(
0,
AsPathSegment {
ordered: true,
path: ours.collect(),
},
),
}
}
/// Makes the attribute of type `type_code` in `attributes` be `with`:
/// replaced, added in the order of the type codes, or taken out.
fn put(attributes: &mut Vec<PathAttribute>, type_code: u8, with: Option<PathAttribute>) {
let at = attributes
.iter()
.position(|attribute| attribute.type_code() == type_code);
match (at, with) {
(Some(at), Some(with)) => attributes[at] = with,
(Some(at), None) => {
attributes.remove(at);
}
(None, Some(with)) => {
let after = attributes
.iter()
.position(|attribute| attribute.type_code() > type_code)
.unwrap_or(attributes.len());
attributes.insert(after, with);
}
(None, None) => {}
}
}
fn wrong(what: impl Into<String>) -> Box<EvalAltResult> {
what.into().into()
}
/// A number a script gave, as one that fits where it is going.
fn number<T: TryFrom<INT>>(value: INT, what: &str) -> Fallible<T> {
T::try_from(value).map_err(|_| wrong(format!("{value} is not {what}")))
}
/// One part of a community as a script writes it: a number, or `*` for
/// any.
fn part<T: std::str::FromStr>(text: &str, whole: &str) -> Fallible<Option<T>> {
if text == "*" {
return Ok(None);
}
text.parse()
.map(Some)
.map_err(|_| wrong(format!("{whole:?} is not a community")))
}
type Pattern = (Option<u16>, Option<u16>);
type LargePattern = (Option<u32>, Option<u32>, Option<u32>);
fn pattern(text: &str) -> Fallible<Pattern> {
let (high, low) = text.split_once(':').ok_or_else(|| {
wrong(format!(
"{text:?} is not a community: it is written 64601:100"
))
})?;
Ok((part(high, text)?, part(low, text)?))
}
fn large_pattern(text: &str) -> Fallible<LargePattern> {
let mut parts = text.splitn(3, ':');
let mut next = || {
parts.next().ok_or_else(|| {
wrong(format!(
"{text:?} is not a large community: it is written 64601:1:2"
))
})
};
let (admin, first, second) = (next()?, next()?, next()?);
Ok((part(admin, text)?, part(first, text)?, part(second, text)?))
}
/// A pattern with nothing left open: a community to add.
fn exact((high, low): Pattern, text: &str) -> Fallible<(u16, u16)> {
high.zip(low).ok_or_else(|| {
wrong(format!(
"{text:?} cannot be added: it stands for more than one"
))
})
}
fn exact_large((admin, first, second): LargePattern, text: &str) -> Fallible<LargeCommunity> {
match (admin, first, second) {
(Some(global_admin), Some(data_1), Some(data_2)) => Ok(LargeCommunity {
global_admin,
data_1,
data_2,
}),
_ => Err(wrong(format!(
"{text:?} cannot be added: it stands for more than one"
))),
}
}
fn fits<T: PartialEq>(wanted: Option<T>, has: T) -> bool {
wanted.is_none_or(|wanted| wanted == has)
}
fn matches((high, low): Pattern, community: (u16, u16)) -> bool {
fits(high, community.0) && fits(low, community.1)
}
fn matches_large((admin, first, second): LargePattern, community: &LargeCommunity) -> bool {
fits(admin, community.global_admin)
&& fits(first, community.data_1)
&& fits(second, community.data_2)
}
impl State {
fn add_community(&mut self, community: (u16, u16)) {
if !self.communities.contains(&community) {
self.communities.push(community);
self.changed.communities = true;
}
}
fn remove_communities(&mut self, pattern: Pattern) {
let before = self.communities.len();
self.communities
.retain(|community| !matches(pattern, *community));
self.changed.communities |= self.communities.len() != before;
}
fn set_local_pref(&mut self, value: Option<u32>) {
self.changed.local_pref |= self.local_pref != value;
self.local_pref = value;
}
fn set_med(&mut self, value: Option<u32>) {
self.changed.med |= self.med != value;
self.med = value;
}
}
fn optional(value: Option<u32>) -> Dynamic {
value.map_or(Dynamic::UNIT, |value| Dynamic::from(INT::from(value)))
}
/// Tells `engine` what a route and a peer are, and what can be asked of
/// them.
pub(crate) fn register(engine: &mut Engine) {
engine.register_type_with_name::<Route>("Route");
engine.register_type_with_name::<Peer>("Peer");
// The peer.
engine.register_get("name", |peer: &mut Peer| peer.name.clone());
engine.register_get("address", |peer: &mut Peer| peer.address.clone());
engine.register_get("role", |peer: &mut Peer| peer.role.clone());
engine.register_get("asn", |peer: &mut Peer| INT::from(peer.asn));
engine.register_get("local_asn", |peer: &mut Peer| INT::from(peer.local_asn));
engine.register_get("ibgp", |peer: &mut Peer| peer.ibgp);
// The prefix. Asking for it is noted: the answer to a function that
// did not is good for every prefix.
engine.register_get("prefix", |route: &mut Route| {
let mut state = route.state();
state.saw_prefix = true;
state.prefix.to_string()
});
engine.register_get("prefix_len", |route: &mut Route| {
let mut state = route.state();
state.saw_prefix = true;
INT::from(state.prefix.length())
});
engine.register_fn(
"within",
|route: &mut Route, around: &str| -> Fallible<bool> {
let around = IpPrefix::try_from(around)
.map_err(|e| wrong(format!("{around:?} is not a prefix: {e}")))?;
let mut state = route.state();
state.saw_prefix = true;
Ok(around.contains(&state.prefix))
},
);
// Every route of an UPDATE's one batch is of the same family.
engine.register_get("family", |route: &mut Route| {
match route.state().prefix.address_family() {
IpVersion::Ipv4 => "ipv4",
IpVersion::Ipv6 => "ipv6",
}
});
// The path.
engine.register_get("as_path", |route: &mut Route| -> Array {
route
.state()
.as_path
.iter()
.map(|asn| Dynamic::from(INT::from(*asn)))
.collect()
});
engine.register_get("as_path_len", |route: &mut Route| {
route.state().as_path_len as INT
});
engine.register_get("origin_as", |route: &mut Route| {
INT::from(route.state().origin_as)
});
engine.register_get("neighbor_as", |route: &mut Route| {
INT::from(route.state().neighbor_as)
});
engine.register_fn("path_has", |route: &mut Route, asn: INT| {
u32::try_from(asn).is_ok_and(|asn| route.state().as_path.contains(&asn))
});
engine.register_fn("prepend", |route: &mut Route, times: INT| -> Fallible<()> {
let mut state = route.state();
if state.direction == Direction::Import {
return Err(wrong(
"prepend is for routes on their way out; to make a route less preferred on \
its way in, set its local_pref",
));
}
// More than this is a mistake, and makes the route no worse.
state.prepend = (0..=16)
.contains(&times)
.then_some(times as usize)
.ok_or_else(|| wrong(format!("prepend({times}): from 0 to 16 times")))?;
Ok(())
});
// The other attributes that are looked at.
engine.register_get("origin", |route: &mut Route| match route.state().origin {
OriginPathAttribute::IGP => "igp",
OriginPathAttribute::EGP => "egp",
OriginPathAttribute::INCOMPLETE => "incomplete",
});
engine.register_get("next_hop", |route: &mut Route| {
route
.state()
.next_hop
.map_or_else(String::new, |next_hop| match next_hop {
NlriNextHop::Ipv4(address) => address.to_string(),
NlriNextHop::Ipv6(address) => address.to_string(),
NlriNextHop::Ipv6WithLl { global, .. } => global.to_string(),
})
});
// Those that are also set.
engine.register_get("local_pref", |route: &mut Route| {
optional(route.state().local_pref)
});
engine.register_set(
"local_pref",
|route: &mut Route, value: INT| -> Fallible<()> {
let value = number(value, "a LOCAL_PREF")?;
route.state().set_local_pref(Some(value));
Ok(())
},
);
engine.register_set("local_pref", |route: &mut Route, (): ()| {
route.state().set_local_pref(None);
});
engine.register_get("med", |route: &mut Route| optional(route.state().med));
engine.register_set("med", |route: &mut Route, value: INT| -> Fallible<()> {
let value = number(value, "a MULTI_EXIT_DISC")?;
route.state().set_med(Some(value));
Ok(())
});
engine.register_set("med", |route: &mut Route, (): ()| {
route.state().set_med(None);
});
// Communities.
engine.register_get("communities", |route: &mut Route| -> Array {
route
.state()
.communities
.iter()
.map(|community| Dynamic::from(format_standard(*community)))
.collect()
});
engine.register_fn(
"has_community",
|route: &mut Route, text: &str| -> Fallible<bool> {
let wanted = pattern(text)?;
Ok(route
.state()
.communities
.iter()
.any(|community| matches(wanted, *community)))
},
);
engine.register_fn(
"has_community",
|route: &mut Route, high: INT, low: INT| -> Fallible<bool> {
let wanted = (
number(high, "half a community")?,
number(low, "half a community")?,
);
Ok(route.state().communities.contains(&wanted))
},
);
engine.register_fn(
"add_community",
|route: &mut Route, text: &str| -> Fallible<()> {
let community = exact(pattern(text)?, text)?;
route.state().add_community(community);
Ok(())
},
);
engine.register_fn(
"add_community",
|route: &mut Route, high: INT, low: INT| -> Fallible<()> {
let community = (
number(high, "half a community")?,
number(low, "half a community")?,
);
route.state().add_community(community);
Ok(())
},
);
engine.register_fn(
"remove_community",
|route: &mut Route, text: &str| -> Fallible<()> {
let unwanted = pattern(text)?;
route.state().remove_communities(unwanted);
Ok(())
},
);
engine.register_fn(
"remove_community",
|route: &mut Route, high: INT, low: INT| -> Fallible<()> {
let unwanted = (
Some(number(high, "half a community")?),
Some(number(low, "half a community")?),
);
route.state().remove_communities(unwanted);
Ok(())
},
);
// Large communities.
engine.register_get("large_communities", |route: &mut Route| -> Array {
route
.state()
.large_communities
.iter()
.map(|community| Dynamic::from(format_large(community)))
.collect()
});
engine.register_fn(
"has_large_community",
|route: &mut Route, text: &str| -> Fallible<bool> {
let wanted = large_pattern(text)?;
Ok(route
.state()
.large_communities
.iter()
.any(|community| matches_large(wanted, community)))
},
);
engine.register_fn(
"add_large_community",
|route: &mut Route, text: &str| -> Fallible<()> {
let community = exact_large(large_pattern(text)?, text)?;
let mut state = route.state();
if !state.large_communities.contains(&community) {
state.large_communities.push(community);
state.changed.large_communities = true;
}
Ok(())
},
);
engine.register_fn(
"remove_large_community",
|route: &mut Route, text: &str| -> Fallible<()> {
let unwanted = large_pattern(text)?;
let mut state = route.state();
let before = state.large_communities.len();
state
.large_communities
.retain(|community| !matches_large(unwanted, community));
state.changed.large_communities |= state.large_communities.len() != before;
Ok(())
},
);
}
-661
View File
@@ -1,661 +0,0 @@
//! Policies as an operator would write them, run on routes.
use std::net::Ipv4Addr;
use std::sync::Arc;
use bgp_packet::message::{
AsPathAttribute, AsPathSegment, CommunitiesPathAttribute, LargeCommunitiesPathAttribute,
LargeCommunity, LocalPrefPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute,
OriginPathAttribute, PathAttribute,
};
use bgp_policy::{Direction, Peer, Policy, PolicyError, Subject, Verdict};
use bgp_primitives::ip_prefix::IpPrefix;
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
fn peer(role: &str) -> Peer {
Peer::new(
"transit-a",
Ipv4Addr::new(192, 0, 2, 2).into(),
64602,
64601,
role,
)
}
/// A route by way of AS 64602 and 3356 from 15169, with communities of
/// 3356.
fn attributes() -> Vec<PathAttribute> {
vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![64602, 3356, 15169]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 2))),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(3356, 3), (3356, 2012)])),
]
}
fn run_on(
script: &str,
function: &str,
peer: &Peer,
route: &str,
attributes: &[PathAttribute],
direction: Direction,
) -> Result<Verdict, PolicyError> {
let policy = Policy::compile(script)?;
policy.hook(function)?.run(
peer,
&Subject {
prefix: &prefix(route),
attributes,
next_hop: None,
direction,
},
)
}
fn import(script: &str, route: &str, attributes: &[PathAttribute]) -> Verdict {
run_on(
script,
"policy",
&peer("provider"),
route,
attributes,
Direction::Import,
)
.unwrap()
}
fn communities(attributes: &[PathAttribute]) -> Vec<(u16, u16)> {
attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::Communities(communities) => Some(communities.0.clone()),
_ => None,
})
.unwrap_or_default()
}
/// The policy this was built for: mark routes by where they were learned,
/// and prefer some to others.
#[test]
fn routes_are_marked_and_preferred_by_who_sent_them() {
let script = r#"
const LEARNED_FROM = #{ customer: "64601:1000", peer: "64601:2000", provider: "64601:3000" };
const PREFERENCE = #{ customer: 300, peer: 200, provider: 100 };
fn policy(route, peer) {
// What others marked their routes with means nothing here.
route.remove_community("64601:*");
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = PREFERENCE[peer.role];
true
}
"#;
let mut given = attributes();
given[3] = PathAttribute::Communities(CommunitiesPathAttribute(vec![
(3356, 3),
(64601, 1000),
(64601, 7),
]));
for (role, mark, preference) in [
("customer", 1000, 300),
("peer", 2000, 200),
("provider", 3000, 100),
] {
let verdict = run_on(
script,
"policy",
&peer(role),
"203.0.113.0/24",
&given,
Direction::Import,
)
.unwrap();
assert!(verdict.accept);
assert!(!verdict.saw_prefix, "the answer is good for any prefix");
let changed = verdict.attributes.expect("the route was changed");
assert_eq!(communities(&changed), [(3356, 3), (64601, mark)], "{role}");
assert!(
changed.contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(
preference
))),
"{role}: {changed:?}"
);
// In the order of their type codes: LOCAL_PREF (5) before
// COMMUNITIES (8).
let codes: Vec<u8> = changed.iter().map(PathAttribute::type_code).collect();
assert_eq!(codes, [1, 2, 3, 5, 8]);
}
}
#[test]
fn a_policy_refuses_by_what_a_route_carries() {
let script = r#"
const BLACKHOLE = "65535:666";
fn policy(route, peer) {
if route.has_community(BLACKHOLE) { return false; }
if route.has_community("3356:*") && route.path_has(174) { return false; }
if route.as_path_len > 5 || route.origin_as == 64512 { return false; }
if route.origin != "igp" { return false; }
true
}
"#;
let verdict = import(script, "203.0.113.0/24", &attributes());
assert_eq!(
verdict,
Verdict {
accept: true,
attributes: None,
saw_prefix: false
},
"nothing to object to, and nothing changed"
);
let mut blackholed = attributes();
blackholed[3] = PathAttribute::Communities(CommunitiesPathAttribute(vec![(65535, 666)]));
assert!(!import(script, "203.0.113.0/24", &blackholed).accept);
let mut by_cogent = attributes();
by_cogent[1] = AsPathAttribute::from_asns(vec![64602, 174, 15169]);
assert!(!import(script, "203.0.113.0/24", &by_cogent).accept);
let mut long = attributes();
long[1] = AsPathAttribute::from_asns(vec![64602; 6]);
assert!(!import(script, "203.0.113.0/24", &long).accept);
let mut incomplete = attributes();
incomplete[0] = PathAttribute::Origin(OriginPathAttribute::INCOMPLETE);
assert!(!import(script, "203.0.113.0/24", &incomplete).accept);
}
/// A function that asks for the prefix is asked about every prefix; the
/// verdict says that it did.
#[test]
fn a_policy_that_looks_at_the_prefix_says_so() {
let script = r#"
fn policy(route, peer) {
if route.family == "ipv6" { return route.prefix_len <= 48; }
if route.within("203.0.112.0/22") { route.local_pref = 50; }
route.prefix != "198.51.100.0/24"
}
"#;
let inside = import(script, "203.0.113.0/24", &attributes());
assert!(inside.accept && inside.saw_prefix);
assert!(
inside
.attributes
.unwrap()
.contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(50)))
);
let outside = import(script, "192.0.2.0/24", &attributes());
assert_eq!(
outside,
Verdict {
accept: true,
attributes: None,
saw_prefix: true
}
);
assert!(!import(script, "198.51.100.0/24", &attributes()).accept);
assert!(import(script, "2001:db8::/48", &attributes()).accept);
assert!(!import(script, "2001:db8::/56", &attributes()).accept);
}
#[test]
fn everything_about_a_route_and_a_peer_can_be_read() {
let script = r#"
fn policy(route, peer) {
if route.as_path != [64602, 3356, 15169] { throw "as_path"; }
if route.as_path_len != 3 { throw "as_path_len"; }
if route.neighbor_as != 64602 || route.origin_as != 15169 { throw "ends"; }
if route.next_hop != "192.0.2.2" { throw "next_hop " + route.next_hop; }
if route.communities != ["3356:3", "3356:2012"] { throw "communities"; }
if !route.has_community(3356, 3) || route.has_community(3356, 4) { throw "has"; }
if route.large_communities != ["64601:1:2"] { throw "large"; }
if !route.has_large_community("64601:*:2") { throw "has large"; }
if route.med != 70 || route.local_pref != () { throw "numbers"; }
if peer.name != "transit-a" || peer.asn != 64602 || peer.local_asn != 64601 {
throw "peer";
}
if peer.address != "192.0.2.2" || peer.role != "provider" || peer.ibgp {
throw "peer";
}
}
"#;
let mut given = attributes();
given.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(70)));
given.push(PathAttribute::LargeCommunities(
LargeCommunitiesPathAttribute {
communities: vec![LargeCommunity {
global_admin: 64601,
data_1: 1,
data_2: 2,
}],
},
));
// It ends without returning anything: no objection.
let verdict = import(script, "203.0.113.0/24", &given);
assert!(verdict.accept && verdict.attributes.is_none());
}
#[test]
fn everything_that_can_be_changed_is_changed_only_if_it_differs() {
let script = r#"
fn same(route, peer) {
route.add_community("3356:3");
route.remove_community("1:1");
route.remove_large_community("1:*:*");
route.local_pref = ();
route.med = ();
}
fn other(route, peer) {
route.remove_community("*:*");
route.add_large_community("64601:1:2");
route.add_large_community("64601:1:2");
route.med = 5;
route.local_pref = 150;
route.local_pref = ();
}
"#;
let policy = Policy::compile(script).unwrap();
let ask = |function: &str| {
policy
.hook(function)
.unwrap()
.run(
&peer(""),
&Subject {
prefix: &prefix("203.0.113.0/24"),
attributes: &attributes(),
next_hop: None,
direction: Direction::Import,
},
)
.unwrap()
};
assert_eq!(ask("same").attributes, None);
let changed = ask("other").attributes.unwrap();
assert_eq!(
changed,
[
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![64602, 3356, 15169]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 2))),
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(5)),
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: vec![LargeCommunity {
global_admin: 64601,
data_1: 1,
data_2: 2
}]
}),
],
"no communities left, a MED and a large community added, LOCAL_PREF as it was"
);
}
/// On the way out a policy says what a peer is told, and how.
#[test]
fn a_route_is_announced_with_what_the_policy_gives_it() {
let script = r#"
fn to_peer(route, peer) {
if route.has_community("64601:3000") { return false; } // learned from a provider
if peer.name == "transit-a" { route.prepend(2); }
route.remove_community("64601:*");
true
}
"#;
let ours = |communities: Vec<(u16, u16)>| {
vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![64601]),
PathAttribute::Communities(CommunitiesPathAttribute(communities)),
]
};
let export = |attributes: &[PathAttribute]| {
run_on(
script,
"to_peer",
&peer("peer"),
"193.0.0.0/21",
attributes,
Direction::Export,
)
.unwrap()
};
assert!(!export(&ours(vec![(64601, 3000)])).accept);
let verdict = export(&ours(vec![(64601, 1000), (64601, 9)]));
assert_eq!(
verdict.attributes.unwrap(),
[
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![64601, 64601, 64601]),
]
);
// Prepending to a path that begins with a set, or is empty.
let set_first = vec![PathAttribute::AsPath(AsPathAttribute {
segments: vec![AsPathSegment {
ordered: false,
path: vec![1, 2],
}],
})];
let prepended = run_on(
"fn f(route, peer) { route.prepend(1); }",
"f",
&peer(""),
"193.0.0.0/21",
&set_first,
Direction::Export,
)
.unwrap();
assert_eq!(
prepended.attributes.unwrap(),
[PathAttribute::AsPath(AsPathAttribute {
segments: vec![
AsPathSegment {
ordered: true,
path: vec![64601]
},
AsPathSegment {
ordered: false,
path: vec![1, 2]
}
]
})]
);
}
/// A function that fails has refused the route: the caller is told what
/// went wrong, and has nothing to take.
#[test]
fn a_policy_that_fails_gives_no_verdict() {
let failure = |script: &str| {
run_on(
script,
"policy",
&peer("provider"),
"203.0.113.0/24",
&attributes(),
Direction::Import,
)
.unwrap_err()
.to_string()
};
let cases = [
(
"fn policy(route, peer) { route.add_community(\"3356\"); }",
"is not a community",
),
(
"fn policy(route, peer) { route.add_community(\"3356:*\"); }",
"stands for more than one",
),
(
"fn policy(route, peer) { route.add_community(70000, 1); }",
"70000 is not half a community",
),
(
"fn policy(route, peer) { route.local_pref = -1; }",
"-1 is not a LOCAL_PREF",
),
(
"fn policy(route, peer) { route.within(\"nowhere\") }",
"is not a prefix",
),
(
"fn policy(route, peer) { route.prepend(1); }",
"prepend is for routes on their way out",
),
("fn policy(route, peer) { route.nonsense }", "nonsense"),
("fn policy(route, peer) { throw \"no\"; }", "no"),
(
"fn policy(route, peer) { 7 }",
"returned 7; a policy returns true or false",
),
// One that never ends is stopped.
("fn policy(route, peer) { loop { } }", "operations"),
(
"fn policy(route, peer) { policy(route, peer) }",
"Stack overflow",
),
];
for (script, complaint) in cases {
let said = failure(script);
assert!(said.contains(complaint), "{script}: {said}");
}
}
#[test]
fn a_script_that_is_wrong_is_found_when_it_is_loaded() {
let load = |script: &str, function: &str| -> PolicyError {
match Policy::compile(script) {
Err(e) => e,
Ok(policy) => policy.hook(function).unwrap_err(),
}
};
assert!(matches!(
load("fn policy(route, peer) {", "policy"),
PolicyError::Parse(_)
));
assert!(matches!(
load("let x = 1 / 0;", "policy"),
PolicyError::Load(_)
));
assert_eq!(
load(
"fn from_a(r, p) { true } fn from_b(r, p) { true }",
"from_c"
)
.to_string(),
"the script has no function \"from_c\"; it has: from_a, from_b"
);
assert_eq!(
load("fn policy(route) { true }", "policy").to_string(),
"the function \"policy\" takes 1 arguments; a policy takes two, the route and the peer"
);
// Nothing of the machine is within reach of a script.
for out_of_reach in [
"import \"other\" as other;",
"fn policy(route, peer) { open_file(\"/etc/passwd\") }",
"fn policy(route, peer) { let t = timestamp(); true }",
] {
let policy = Policy::compile(out_of_reach).and_then(|policy| {
policy.hook("policy").and_then(|hook| {
hook.run(
&peer(""),
&Subject {
prefix: &prefix("203.0.113.0/24"),
attributes: &attributes(),
next_hop: None,
direction: Direction::Import,
},
)
})
});
assert!(policy.is_err(), "{out_of_reach}");
}
}
/// Constants are for every function of the script, also one that another
/// calls; and many sessions use one script at once.
#[test]
fn a_script_is_shared_by_every_session() {
let script = r#"
const TAGS = ["64601:1", "64601:2", "64601:3"];
fn tag(route, n) { route.add_community(TAGS[n]); }
fn policy(route, peer) { tag(route, peer.asn % 3); true }
"#;
let policy = Policy::compile(script).unwrap();
let hook = policy.hook("policy").unwrap();
assert_eq!(policy.functions(), ["policy", "tag"]);
let threads: Vec<_> = (0..8u32)
.map(|n| {
let hook = hook.clone();
std::thread::spawn(move || {
let peer = Peer::new(
"p",
Ipv4Addr::new(192, 0, 2, 9).into(),
64700 + n,
64601,
"",
);
for _ in 0..2000 {
let verdict = hook
.run(
&peer,
&Subject {
prefix: &prefix("203.0.113.0/24"),
attributes: &attributes(),
next_hop: None,
direction: Direction::Import,
},
)
.unwrap();
let expected = (64601, 1 + ((64700 + n) % 3) as u16);
assert_eq!(
communities(&verdict.attributes.unwrap()).last(),
Some(&expected)
);
}
})
})
.collect();
for thread in threads {
thread.join().unwrap();
}
assert_eq!(Arc::strong_count(&policy), 2);
}
/// The example operators start from does what its comments say.
#[test]
fn the_example_script_does_what_it_says() {
let script = include_str!("../../../testdata/policy.rhai");
let policy = Policy::compile(script).unwrap();
let from_peer = policy.hook("from_peer").unwrap();
let to_peer = policy.hook("to_peer").unwrap();
let them = |name: &str, role: &str| {
Peer::new(name, Ipv4Addr::new(192, 0, 2, 2).into(), 64602, 65000, role)
};
let with = |communities: Vec<(u16, u16)>| {
vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![64602]),
PathAttribute::Communities(CommunitiesPathAttribute(communities)),
]
};
let ask = |hook: &bgp_policy::Hook, peer: &Peer, attributes: &[PathAttribute], direction| {
hook.run(
peer,
&Subject {
prefix: &prefix("203.0.113.0/24"),
attributes,
next_hop: None,
direction,
},
)
.unwrap()
};
let local_pref = |attributes: &[PathAttribute]| {
attributes.iter().find_map(|attribute| match attribute {
PathAttribute::LocalPref(local_pref) => Some(local_pref.0),
_ => None,
})
};
// In: marked and ranked by who it is from, whatever it was marked with.
for (role, mark, preference) in [
("customer", 1000, 300),
("peer", 2000, 200),
("provider", 3000, 100),
] {
let verdict = ask(
&from_peer,
&them("r", role),
&with(vec![(65000, 1000), (3356, 3)]),
Direction::Import,
);
let changed = verdict.attributes.unwrap();
assert_eq!(communities(&changed), [(3356, 3), (65000, mark)], "{role}");
assert_eq!(local_pref(&changed), Some(preference), "{role}");
}
// A peer with no role is taken, with our marks removed and no more.
let verdict = ask(
&from_peer,
&them("r", ""),
&with(vec![(65000, 7)]),
Direction::Import,
);
assert!(verdict.accept);
assert_eq!(communities(&verdict.attributes.unwrap()), []);
// The peer's own "last resort" mark, and a blackhole request.
let verdict = ask(
&from_peer,
&them("r", "peer"),
&with(vec![(64602, 80)]),
Direction::Import,
);
assert_eq!(local_pref(&verdict.attributes.unwrap()), Some(80));
assert!(
!ask(
&from_peer,
&them("r", "peer"),
&with(vec![(65535, 666)]),
Direction::Import
)
.accept
);
// Out: what came from above goes to customers only, without our marks.
let from_provider = with(vec![(65000, 3000), (3356, 3)]);
assert!(
!ask(
&to_peer,
&them("r", "peer"),
&from_provider,
Direction::Export
)
.accept
);
assert!(
!ask(
&to_peer,
&them("r", "provider"),
&from_provider,
Direction::Export
)
.accept
);
let verdict = ask(
&to_peer,
&them("r", "customer"),
&from_provider,
Direction::Export,
);
assert_eq!(communities(&verdict.attributes.unwrap()), [(3356, 3)]);
assert!(
!ask(
&to_peer,
&them("r", "customer"),
&with(vec![(65535, 65281)]),
Direction::Export
)
.accept
);
// And the path is longer to the one it should be.
let verdict = ask(
&to_peer,
&them("backup-transit", "provider"),
&with(vec![]),
Direction::Export,
);
assert!(
verdict
.attributes
.unwrap()
.contains(&AsPathAttribute::from_asns(vec![65000, 65000, 64602]))
);
}
-1
View File
@@ -12,7 +12,6 @@ bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-fib.workspace = true
bgp-policy.workspace = true
bgp-rpki.workspace = true
bytes.workspace = true
futures.workspace = true
-66
View File
@@ -53,49 +53,6 @@ message BgpConfig {
// that the machine forwards packets by them. Left out, the daemon only
// speaks BGP and the machine's forwarding is not touched.
Kernel kernel = 11;
// The operator's own rules for routes, as a script. Left out, routes are
// judged by the checks of each peer's import and by nothing else.
PolicyScript policy = 12;
}
// A script with the operator's own rules: which routes are taken and
// announced beyond what the general checks say, and what they are marked
// with. It is written in Rhai (https://rhai.rs) as functions that are
// given a route and the peer it comes from or goes to, may change the
// route, and return whether it is taken:
//
// const BLACKHOLE = "65535:666";
//
// fn from_transit(route, peer) {
// if route.has_community(BLACKHOLE) { return false; }
// route.add_community("64601:3000"); // learned from a provider
// route.local_pref = 80;
// true
// }
//
// What a function can read and change is listed in bin/bgp/README.md. A
// peer says which function is for its routes (import.function and
// export.function); the two names here are for peers that say nothing.
//
// The script is read when the daemon starts, and a script that does not
// parse, or a function that is named and not there, stops it from
// starting; `bgp --check` finds both. A function that fails on a route
// (it asks for something that is not there, or does not end) has refused
// that route.
message PolicyScript {
// The file the script is in; if not absolute, beside the configuration
// file.
string path = 1;
// Or the script itself. One of the two.
string source = 2;
// The function for routes from peers that name none; none if empty.
string import_function = 3;
// The function for routes to peers that name none; none if empty.
string export_function = 4;
}
// Installing routes in the kernel. Linux only; the daemon needs the
@@ -254,23 +211,6 @@ message 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 is announced to the peer, and how.
ExportPolicy export = 11;
}
// What is announced to a peer.
//
// The daemon announces the routes it originates; it does not yet pass on
// what it learned from other peers. When it does, those routes will be
// given to the same function.
message ExportPolicy {
// The function of the policy script (see PolicyScript) that every route
// on its way to the peer is given. It may refuse the route, which is
// then not announced, or change its communities, its MED, and how often
// our AS is on its path. Empty for the script's export_function, or for
// none.
string function = 1;
}
// What a peer is to us.
@@ -356,12 +296,6 @@ message ImportPolicy {
// to. Not needed for a peer whose role is ROUTE_SERVER, whose routes
// have its clients as next hops.
bool accept_third_party_next_hop = 14;
// The function of the policy script (see PolicyScript) that every route
// from the peer is given, after it has passed all of the above. It may
// refuse the route, or change its communities, its LOCAL_PREF and its
// MED. Empty for the script's import_function, or for none.
string function = 15;
}
// A prefix, and optionally the more specific prefixes inside it.
+1 -79
View File
@@ -24,7 +24,6 @@ use bgp_packet::message::{
LocalPrefPathAttribute, Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute,
MultiExitDiscPathAttribute, NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage,
};
use bgp_policy::{Direction, Hook, PolicyError, Subject};
use bgp_primitives::constants::{AfiSafi, IpVersion};
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
use bgp_rib::table::DEFAULT_LOCAL_PREF;
@@ -35,9 +34,7 @@ use tokio::sync::broadcast::error::RecvError;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
use crate::metrics::POLICY_RUNS;
use crate::policy::PeerRole;
use crate::routes::policy_failed;
use crate::session::{PeerId, SessionClosed, SessionEvent, SessionHandle, SessionRegistry};
/// Starts the advertiser. It hears of every session that comes up after
@@ -238,7 +235,7 @@ impl Exporter {
role: session.role,
extended_next_hop: session.extended_next_hop,
};
let Some(mut update) = build_announce(route, &towards) else {
let Some(update) = build_announce(route, &towards) else {
debug!(
peer = %session.peer,
prefix = %route.prefix,
@@ -246,28 +243,6 @@ impl Exporter {
);
return Ok(());
};
// The operator's own rules for what this peer is told.
if let Some(export) = &session.export {
let (hook, peer) = &**export;
POLICY_RUNS.with_label_values(&["export"]).inc();
match apply_export(&mut update, hook, peer, &route.prefix, &towards) {
Ok(true) => {}
Ok(false) => {
debug!(
peer = %session.peer,
prefix = %route.prefix,
function = hook.name(),
"Not announcing route: the policy function refused it"
);
return Ok(());
}
// A function that fails has refused the route.
Err(e) => {
policy_failed(hook.name(), session.name.as_deref().unwrap_or(""), &e);
return Ok(());
}
}
}
debug!(peer = %session.peer, prefix = %route.prefix, "Announcing local route");
session.send(Message::Update(update)).await?;
self.announced.insert(route.prefix);
@@ -290,59 +265,6 @@ impl Exporter {
}
}
/// Asks the policy function `hook` about the route for `prefix` that
/// `update` announces, and makes `update` what the function left of it.
/// Returns whether the route is to be announced at all.
fn apply_export(
update: &mut UpdateMessage,
hook: &Hook,
peer: &bgp_policy::Peer,
prefix: &IpPrefix,
towards: &Towards,
) -> Result<bool, PolicyError> {
let next_hop = update
.path_attributes
.iter()
.find_map(|attribute| match attribute {
PathAttribute::MpReachNlri(mp) => Some(mp.next_hop),
_ => None,
});
let verdict = hook.run(
peer,
&Subject {
prefix,
attributes: &update.path_attributes,
next_hop,
direction: Direction::Export,
},
)?;
if !verdict.accept {
return Ok(false);
}
let Some(mut attributes) = verdict.attributes else {
return Ok(true);
};
// What the function may not decide. LOCAL_PREF does not leave the AS
// (RFC 4271 §5.1.5); and a path it made longer is still to reach a
// peer with two-octet AS numbers whole (RFC 6793 §4.2.2).
attributes.retain(|attribute| match attribute {
PathAttribute::LocalPref(_) => towards.ibgp,
PathAttribute::As4Path(_) => false,
_ => true,
});
if !towards.four_octet_asn {
let as4_path = attributes.iter().find_map(|attribute| match attribute {
PathAttribute::AsPath(path) => path.as4_path(),
_ => None,
});
attributes.extend(as4_path);
}
// In ascending order of type code, as they were.
attributes.sort_by_key(PathAttribute::type_code);
update.path_attributes = attributes;
Ok(true)
}
/// What about a session decides how a route is announced on it.
#[derive(Debug, Clone, Copy)]
pub struct Towards {
+5 -174
View File
@@ -1,5 +1,4 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
@@ -8,7 +7,6 @@ use bgp_fib::FibConfig;
use bgp_packet::community;
use bgp_packet::message::OriginPathAttribute;
use bgp_packet::open::AddPathSendRecv;
use bgp_policy::{Hook, Policy};
use bgp_primitives::constants::{AfiSafi, IpVersion};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
@@ -54,8 +52,6 @@ pub struct Config {
/// Where route origin validation gets its data; `None` for no
/// validation.
pub rpki: Option<RpkiConfig>,
/// The policy script, if there is one.
pub policy: Option<Arc<Policy>>,
}
#[derive(Debug, Clone)]
@@ -80,10 +76,6 @@ pub struct PeerConfig {
/// Refuse the peer if it does not say what it is itself (RFC 9234
/// §4.2, "strict mode").
pub require_role: bool,
/// The operator's own rules for the routes the peer sends, and for
/// those it is sent: functions of the policy script.
pub import_hook: Option<Hook>,
pub export_hook: Option<Hook>,
}
impl Config {
@@ -92,45 +84,7 @@ impl Config {
self.peers.iter().find(|peer| peer.endpoint.is_at(address))
}
/// Reads a configuration whose files, if it names any, are beside it
/// in the working directory.
pub fn from_proto(proto: BgpConfig) -> Result<Self, ConfigError> {
Self::from_proto_in(proto, Path::new("."))
}
/// Reads a configuration that came from a file in `directory`: other
/// files it names by a relative path are looked for there.
pub fn from_proto_in(proto: BgpConfig, directory: &Path) -> Result<Self, ConfigError> {
let policy = proto
.policy
.as_ref()
.map(|policy| load_policy(policy, directory))
.transpose()?;
let default_hooks = proto.policy.as_ref().map_or(("", ""), |policy| {
(
policy.import_function.as_str(),
policy.export_function.as_str(),
)
});
// The function `named`, or the one for peers that name none.
let hook = |named: &str, default: &str| -> Result<Option<Hook>, PeerProblem> {
let name = if named.is_empty() { default } else { named };
match (&policy, name) {
(_, "") => Ok(None),
(Some(policy), name) => Ok(Some(policy.hook(name)?)),
(None, name) => Err(PeerProblem::NoPolicyScript(name.to_string())),
}
};
// A function named for peers that name none has to be there
// whether or not there is such a peer.
if let Some(policy) = &policy {
for default in [default_hooks.0, default_hooks.1] {
if !default.is_empty() {
policy.hook(default)?;
}
}
}
// Peers refuse an identifier of 0.0.0.0, as we do theirs.
let router_id = Ipv4Addr::from_str(&proto.router_id)
.ok()
@@ -164,10 +118,7 @@ impl Config {
peer: name.clone(),
problem,
};
let functions = (peer.import.function.clone(), peer.export.function.clone());
let mut peer = parse_peer(peer).map_err(rejected)?;
peer.import_hook = hook(&functions.0, default_hooks.0).map_err(rejected)?;
peer.export_hook = hook(&functions.1, default_hooks.1).map_err(rejected)?;
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));
@@ -243,7 +194,6 @@ impl Config {
http_endpoint,
kernel: proto.kernel.as_ref().map(parse_kernel),
rpki,
policy,
})
}
}
@@ -349,29 +299,9 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
import,
role,
require_role: peer.require_role,
import_hook: None,
export_hook: None,
})
}
/// Reads the policy script, from the configuration itself or from the
/// file it names.
fn load_policy(policy: &proto::PolicyScript, directory: &Path) -> Result<Arc<Policy>, ConfigError> {
let source = match (policy.path.as_str(), policy.source.as_str()) {
("", "") => return Err(ConfigError::PolicySource),
("", source) => source.to_string(),
(path, "") => {
let path = directory.join(path);
std::fs::read_to_string(&path).map_err(|e| ConfigError::PolicyFile {
path: path.display().to_string(),
error: e.to_string(),
})?
}
(_, _) => return Err(ConfigError::PolicySource),
};
Ok(Policy::compile(&source)?)
}
fn parse_rpki(rpki: &proto::Rpki) -> Result<RpkiConfig, ConfigError> {
if rpki.caches.is_empty() {
return Err(ConfigError::RpkiNoCaches);
@@ -532,94 +462,6 @@ mod tests {
parse(text).err().expect(text)
}
/// Parses a configuration whose files are in `testdata`.
fn parse_in_testdata(text: &str) -> Result<Config, ConfigError> {
let proto = protobuf::text_format::parse_from_str::<BgpConfig>(text)
.expect("the test's textproto is well formed");
let testdata = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../testdata");
Config::from_proto_in(proto, &testdata)
}
/// The operator's rules are a script: in a file beside the
/// configuration or in the configuration itself, with a function for
/// each direction of each peer, or one for all that name none.
#[test]
fn the_policy_script_is_read_and_its_functions_found() {
const SCRIPT: &str = "fn a(route, peer) { true } fn b(route, peer) { false }";
let with = |policy: &str, peers: &str| {
parse(&format!(
"{BASE}policy {{ source: \"{SCRIPT}\" {policy} }}\n\
peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {peers} }}\n\
peers {{ name: \"r2\" endpoint {{ address: \"192.0.2.3\" }} remote_asn: 2 }}"
))
};
let names = |config: &Config| -> Vec<(Option<String>, Option<String>)> {
let name = |hook: &Option<Hook>| hook.as_ref().map(|hook| hook.name().to_string());
config
.peers
.iter()
.map(|peer| (name(&peer.import_hook), name(&peer.export_hook)))
.collect()
};
// Nobody names a function, and there is none for those: none run.
let config = with("", "").unwrap();
assert_eq!(config.policy.as_ref().unwrap().functions(), ["a", "b"]);
assert_eq!(names(&config), [(None, None), (None, None)]);
// One peer names its own; the other gets the one for the rest.
let config = with(
"import_function: \"a\"",
"import { function: \"b\" } export { function: \"a\" }",
)
.unwrap();
let (a, b) = (Some("a".to_string()), Some("b".to_string()));
assert_eq!(names(&config), [(b, a.clone()), (a, None)]);
// A function that is not there is found now, not when a route comes.
for (policy, peers) in [
("", "import { function: \"c\" }"),
("", "export { function: \"c\" }"),
("export_function: \"c\"", ""),
] {
let said = with(policy, peers).err().expect(peers).to_string();
assert!(
said.contains("the script has no function \"c\"; it has: a, b"),
"{said}"
);
}
// So is a function named without a script, and a script that is
// not one.
let said = rejected(&format!(
"{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 \
import {{ function: \"a\" }} }}"
))
.to_string();
assert_eq!(
said,
"peer \"r1\": names the policy function \"a\", and there is no policy script"
);
let said = rejected(&format!("{BASE}policy {{ source: \"fn a(route {{\" }}")).to_string();
assert!(
said.starts_with("policy: the script does not parse"),
"{said}"
);
for neither_or_both in ["policy {}", "policy { path: \"a\" source: \"b\" }"] {
assert_eq!(
rejected(&format!("{BASE}{neither_or_both}")),
ConfigError::PolicySource
);
}
// From a file beside the configuration.
let config =
parse_in_testdata(&format!("{BASE}policy {{ path: \"policy.rhai\" }}")).unwrap();
assert!(!config.policy.unwrap().functions().is_empty());
let said = parse_in_testdata(&format!("{BASE}policy {{ path: \"nowhere.rhai\" }}"))
.err()
.unwrap()
.to_string();
assert!(said.starts_with("policy: cannot read "), "{said}");
}
/// The example that the documentation points to, and that operators
/// start from: as it is, and with what it shows commented out put in.
#[test]
@@ -651,18 +493,7 @@ mod tests {
})
.collect();
assert_ne!(shown, example, "nothing was taken for configuration");
let config = parse_in_testdata(&shown).unwrap_or_else(|e| panic!("{e}\n{shown}"));
// The script it names is the example script, and the functions it
// names are in it.
assert!(config.policy.is_some(), "{shown}");
assert_eq!(
config.peers[0].import_hook.as_ref().map(Hook::name),
Some("from_peer")
);
assert_eq!(
config.peers[0].export_hook.as_ref().map(Hook::name),
Some("to_peer")
);
let config = parse(&shown).unwrap_or_else(|e| panic!("{e}\n{shown}"));
assert!(config.rpki.is_some(), "{shown}");
assert!(config.kernel.is_some(), "{shown}");
assert_eq!(config.peers[0].role, Some(PeerRole::Peer));
@@ -851,17 +682,17 @@ mod tests {
other => panic!("{other}"),
};
assert_eq!(
problem(peer("r1", "192.0.2.2") + peer("r1", "192.0.2.3").as_str()),
problem(peer("r1", "192.0.2.2") + &peer("r1", "192.0.2.3")),
PeerProblem::DuplicateName
);
assert_eq!(
problem(peer("r1", "192.0.2.2") + peer("r2", "192.0.2.2").as_str()),
problem(peer("r1", "192.0.2.2") + &peer("r2", "192.0.2.2")),
PeerProblem::DuplicateAddress
);
// A link-local address is one peer on each interface it is on.
assert_eq!(
problem(peer("r1", "fe80::1%eth0") + peer("r2", "fe80::1%eth0").as_str()),
problem(peer("r1", "fe80::1%eth0") + &peer("r2", "fe80::1%eth0")),
PeerProblem::DuplicateAddress
);
let config = parse(&format!(
-36
View File
@@ -21,7 +21,6 @@ use bgp_packet::message::{
};
use bgp_packet::open::AddPathSendRecv;
use bgp_packet::update_decode::{UpdateError, merge_as4_path};
use bgp_policy::Hook;
use bgp_primitives::ip_prefix::Nlri;
use bgp_primitives::parser::ParserContext;
use bgp_rib::table::{Outcome, PeerIndex, PeerInfo, RibTable};
@@ -108,10 +107,6 @@ pub struct PeerSettings {
pub role: Option<PeerRole>,
/// Refuse the peer if it does not say what it is (RFC 9234).
pub require_role: bool,
/// The functions of the policy script for the peer's routes and for
/// the routes it is sent.
pub import_hook: Option<Hook>,
pub export_hook: Option<Hook>,
}
impl PeerSettings {
@@ -128,8 +123,6 @@ impl PeerSettings {
import: Arc::new(ImportPolicy::permissive()),
role: None,
require_role: false,
import_hook: None,
export_hook: None,
}
}
}
@@ -145,8 +138,6 @@ impl From<&PeerConfig> for PeerSettings {
import: Arc::new(peer.import.clone()),
role: peer.role,
require_role: peer.require_role,
import_hook: peer.import_hook.clone(),
export_hook: peer.export_hook.clone(),
}
}
}
@@ -242,9 +233,6 @@ pub async fn run_session(
/// What exists only once a session is Established.
struct Established {
/// The policy function for the peer's routes, and the peer as it is
/// shown to it.
import_script: Option<(Hook, bgp_policy::Peer)>,
/// When it was.
since: Instant,
peer_id: PeerId,
@@ -290,10 +278,6 @@ impl Established {
.as_mut()
.map_or(Checks::NONE, |validation| validation.validator.checks()),
refusals,
script: self
.import_script
.as_ref()
.map(|(hook, peer)| routes::Script { hook, peer }),
};
(import, &mut self.held)
}
@@ -462,8 +446,6 @@ struct Session {
peer_name: Option<String>,
/// Which of the peer's routes are taken.
import: Arc<ImportPolicy>,
/// The policy script's functions for routes from and to the peer.
hooks: (Option<Hook>, Option<Hook>),
/// What the peer is to us, if that is known.
role: Option<PeerRole>,
/// Where what is refused is noted.
@@ -520,7 +502,6 @@ impl Session {
server,
peer_name: peer.name,
import: peer.import,
hooks: (peer.import_hook, peer.export_hook),
role: peer.role,
said: Said::new(),
};
@@ -825,23 +806,7 @@ impl Session {
changes: handle.validate(subject),
}
});
// The peer as the policy script sees it.
let script_peer = || {
bgp_policy::Peer::new(
self.refusals.label(),
self.peer_addr.ip(),
negotiated.peer_asn,
self.server.local_asn,
self.role.map_or("", PeerRole::in_scripts),
)
};
handle.export = self
.hooks
.1
.clone()
.map(|hook| Arc::new((hook, script_peer())));
self.established = Some(Established {
import_script: self.hooks.0.clone().map(|hook| (hook, script_peer())),
since: Instant::now(),
validation,
held: Held::default(),
@@ -1334,7 +1299,6 @@ mod tests {
std::net::IpAddr::from(Ipv4Addr::new(192, 0, 2, 1)).into(),
"test",
),
script: None,
};
routes::apply(&table, &import, &mut Held::default(), &update, None).unwrap();
assert_eq!(table.len(), 1);
-10
View File
@@ -176,12 +176,6 @@ pub enum ConfigError {
HttpPort,
#[error("rpki: no caches are listed")]
RpkiNoCaches,
#[error("policy: give the script's path or its source, and not both")]
PolicySource,
#[error("policy: cannot read {path}: {error}")]
PolicyFile { path: String, error: String },
#[error("policy: {0}")]
Policy(#[from] bgp_policy::PolicyError),
#[error("rpki: the port of cache {0:?} is required")]
RpkiCachePort(String),
#[error("peer {peer:?}: {problem}")]
@@ -198,10 +192,6 @@ pub enum ConfigError {
pub enum PeerProblem {
#[error("endpoint is missing")]
MissingEndpoint,
#[error("{0}")]
Policy(#[from] bgp_policy::PolicyError),
#[error("names the policy function {0:?}, and there is no policy script")]
NoPolicyScript(String),
#[error("endpoint is invalid: {0}")]
Endpoint(Box<ConfigError>),
#[error("remote_asn must be non-zero")]
-20
View File
@@ -164,24 +164,6 @@ pub static RIB_JOURNAL_ENTRIES: LazyLock<IntGauge> = LazyLock::new(|| {
.expect("metric registration")
});
pub static POLICY_RUNS: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!(
"bgp_policy_runs_total",
"Times a function of the policy script was asked about a route, by direction",
&["direction"]
)
.expect("metric registration")
});
pub static POLICY_FAILURES: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!(
"bgp_policy_failures_total",
"Times a function of the policy script failed, which refuses the route, by function",
&["function"]
)
.expect("metric registration")
});
/// Whether each configured peer's session is up: what to alert on when a
/// peer goes away, which a count of sessions cannot say.
pub static PEER_UP: LazyLock<IntGaugeVec> = LazyLock::new(|| {
@@ -225,8 +207,6 @@ pub fn init() {
LazyLock::force(&RIB_PREFIXES);
LazyLock::force(&RIB_ATTRIBUTE_SETS);
LazyLock::force(&RIB_JOURNAL_ENTRIES);
LazyLock::force(&POLICY_RUNS);
LazyLock::force(&POLICY_FAILURES);
LazyLock::force(&PEER_UP);
BUILD_INFO
.with_label_values(&[env!("CARGO_PKG_VERSION")])
+1 -21
View File
@@ -188,11 +188,6 @@ pub enum Rejection {
/// The next hop cannot be reached from this machine as its interfaces
/// are now, so the kernel could not be given the route.
NextHopUnreachable,
/// The operator's policy function for the peer refused the route.
Policy,
/// The operator's policy function failed on the route, which is as
/// good as refusing it.
PolicyFailed,
}
impl Rejection {
@@ -206,7 +201,7 @@ impl Rejection {
)
}
pub const ALL: [Self; 19] = [
pub const ALL: [Self; 17] = [
Self::BogonPrefix,
Self::TooShort,
Self::TooSpecific,
@@ -224,8 +219,6 @@ impl Rejection {
Self::NextHopNotPeer,
Self::OwnPrefix,
Self::NextHopUnreachable,
Self::Policy,
Self::PolicyFailed,
];
/// Where the reason is in [`Rejection::ALL`].
@@ -252,8 +245,6 @@ impl Rejection {
Self::NextHopNotPeer => "next hop is not the peer",
Self::OwnPrefix => "prefix is our own",
Self::NextHopUnreachable => "next hop unreachable",
Self::Policy => "refused by policy function",
Self::PolicyFailed => "policy function failed",
}
}
}
@@ -338,17 +329,6 @@ impl PeerRole {
Self::RouteServerClient => "route server client",
}
}
/// The role as a policy script has it in `peer.role`: one word.
pub fn in_scripts(self) -> &'static str {
match self {
Self::Provider => "provider",
Self::Customer => "customer",
Self::Peer => "peer",
Self::RouteServer => "route_server",
Self::RouteServerClient => "route_server_client",
}
}
}
/// Whose routes are being judged.
-1
View File
@@ -322,7 +322,6 @@ mod tests {
..Checks::NONE
},
refusals: &self.refusals.of_peer(address.into(), "test"),
script: None,
};
work(&self.table, &import, &mut peer.held)
}
+4 -145
View File
@@ -4,24 +4,18 @@
use std::borrow::Cow;
use std::cell::OnceCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::Arc;
use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage, only_to_customer_wire};
use bgp_policy::{Direction, Hook, PolicyError, Subject};
use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::Nlri;
use bgp_primitives::parser::EncodeError;
use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable};
use bgp_rpki::{PathValidity, Validity};
use tracing::warn;
use crate::metrics::{POLICY_FAILURES, POLICY_RUNS};
use crate::policy::{self, ImportPolicy, Rejection, RouteSource};
use crate::refusals::PeerRefusals;
use crate::rpki::Checks;
use crate::sync::IgnorePoison;
use crate::throttle::Throttle;
/// The most routes of one peer that are held aside as invalid. Far more
/// than a peer sending the whole Internet has; a bound all the same.
@@ -47,30 +41,6 @@ pub(crate) struct Import<'a> {
pub(crate) checks: Checks<'a>,
/// Where what is refused is noted.
pub(crate) refusals: &'a PeerRefusals,
/// The operator's own rules for the peer's routes, if there are any.
pub(crate) script: Option<Script<'a>>,
}
/// A function of the policy script, and the peer as it is shown to it.
#[derive(Clone, Copy)]
pub(crate) struct Script<'a> {
pub(crate) hook: &'a Hook,
pub(crate) peer: &'a bgp_policy::Peer,
}
/// Says that a policy function failed. Counted every time, said once in a
/// while: a function that fails on one route fails on the next.
pub(crate) fn policy_failed(function: &str, peer: &str, error: &PolicyError) {
static SAID: Mutex<Throttle> = Mutex::new(Throttle::every(Duration::from_secs(60)));
POLICY_FAILURES.with_label_values(&[function]).inc();
if let Some(unsaid) = SAID.locked().pass() {
warn!(
peer,
err = %error,
others_since_last_report = unsaid,
"A policy function failed; the route it was asked about is refused"
);
}
}
/// A peer's routes that are not in the routing table because the RPKI's
@@ -192,10 +162,6 @@ fn only_to_customer_mark(import: &Import<'_>, update: &UpdateMessage) -> Option<
})
}
/// Routes a policy function took, with their attributes as the function
/// left them if it changed any.
type Taken<'n> = (Cow<'n, [Nlri]>, Option<Vec<PathAttribute>>);
/// One UPDATE on its way into the table.
struct Applying<'a> {
table: &'a RibTable,
@@ -264,99 +230,6 @@ impl Applying<'_> {
.checks
.next_hops
.is_none_or(|next_hops| next_hops.next_hop_is_near(family, &used_next_hop));
// The operator's own rules come after the general ones, and before
// what may yet change: a route they refuse is refused for good.
for (routes, changed) in self.scripted(nlri, next_hop) {
self.admit(&routes, next_hop, origin, near, changed.as_deref())?;
}
Ok(())
}
/// Asks the peer's policy function, if it has one, about the routes of
/// `nlri`. Returns those it takes, with their attributes as the
/// function left them if it changed any; the others are refused here.
///
/// The function is asked once if it does not ask for the prefix: its
/// answer is then the same for all of them. Otherwise it is asked
/// about each, and routes it treats alike are returned together.
fn scripted<'n>(&mut self, nlri: &'n [Nlri], next_hop: Option<&NlriNextHop>) -> Vec<Taken<'n>> {
let Some(script) = self.import.script else {
return vec![(Cow::Borrowed(nlri), None)];
};
// Without the attributes that hold prefixes, which are not the
// route's and can be long.
let names_prefixes = |attribute: &PathAttribute| {
matches!(
attribute,
PathAttribute::MpReachNlri(_) | PathAttribute::MpUnreachNlri(_)
)
};
let attributes: Cow<'_, [PathAttribute]> =
if self.update.path_attributes.iter().any(names_prefixes) {
let mut plain = self.update.path_attributes.clone();
plain.retain(|attribute| !names_prefixes(attribute));
Cow::Owned(plain)
} else {
Cow::Borrowed(&self.update.path_attributes)
};
let mut taken: Vec<Taken<'n>> = Vec::new();
for (at, route) in nlri.iter().enumerate() {
POLICY_RUNS.with_label_values(&["import"]).inc();
let verdict = script.hook.run(
script.peer,
&Subject {
prefix: &route.prefix,
attributes: &attributes,
next_hop: next_hop.copied(),
direction: Direction::Import,
},
);
let verdict = match verdict {
Ok(verdict) => verdict,
Err(e) => {
policy_failed(script.hook.name(), self.import.refusals.label(), &e);
self.refuse(std::slice::from_ref(route), Rejection::PolicyFailed);
continue;
}
};
// The first answer is everyone's, unless it was about the
// prefix.
let (routes, all) = if at == 0 && !verdict.saw_prefix {
(nlri, true)
} else {
(std::slice::from_ref(route), false)
};
if !verdict.accept {
self.refuse(routes, Rejection::Policy);
} else if all {
taken.push((Cow::Borrowed(routes), verdict.attributes));
} else {
match taken
.iter_mut()
.find(|(_, changed)| *changed == verdict.attributes)
{
Some((alike, _)) => alike.to_mut().push(*route),
None => taken.push((Cow::Owned(vec![*route]), verdict.attributes)),
}
}
if all {
break;
}
}
taken
}
/// Puts into the table those of `nlri` that pass what is asked of
/// each route by itself. `changed` is their attributes, if the policy
/// function changed them.
fn admit(
&mut self,
nlri: &[Nlri],
next_hop: Option<&NlriNextHop>,
origin: Option<u32>,
near: bool,
changed: Option<&[PathAttribute]>,
) -> Result<(), EncodeError> {
// Nearly always every route is taken, and then nothing is copied.
let mut accepted = Cow::Borrowed(nlri);
let mut invalid = Vec::new();
@@ -381,7 +254,7 @@ impl Applying<'_> {
if accepted.is_empty() && invalid.is_empty() {
return Ok(());
}
let attrs = self.attributes(next_hop, changed)?;
let attrs = self.attributes(next_hop)?;
for route in invalid {
self.held.hold(route, &attrs);
}
@@ -459,23 +332,9 @@ impl Applying<'_> {
self.applied.outcome += self.table.withdraw(self.import.peer, refused);
}
/// The attribute set the UPDATE's routes go into the table with:
/// `changed`, if the policy function changed the attributes, or else
/// those the UPDATE came with.
fn attributes(
&self,
next_hop: Option<&NlriNextHop>,
changed: Option<&[PathAttribute]>,
) -> Result<Arc<AttrSet>, EncodeError> {
/// The attribute set the UPDATE's routes go into the table with.
fn attributes(&self, next_hop: Option<&NlriNextHop>) -> Result<Arc<AttrSet>, EncodeError> {
let store = self.table.attrs();
if let Some(changed) = changed {
let Some(marked_by) = self.mark else {
return store.intern(changed, next_hop);
};
let mut marked = changed.to_vec();
marked.push(PathAttribute::OnlyToCustomer(marked_by));
return store.intern(&marked, next_hop);
}
let Some(marked_by) = self.mark else {
if let Some(set) = self
.attributes_wire
-2
View File
@@ -121,8 +121,6 @@ impl BgpServer {
endpoint: p.endpoint.clone(),
remote_asn: p.remote_asn,
role: p.role,
import_function: p.import_hook.as_ref().map(|hook| hook.name().into()),
export_function: p.export_hook.as_ref().map(|hook| hook.name().into()),
})
.collect();
// The client starts now: with luck the data is there before the
-8
View File
@@ -65,10 +65,6 @@ pub struct ConfiguredPeer {
pub remote_asn: u32,
/// What the peer is to us, if that was said.
pub role: Option<PeerRole>,
/// The functions of the policy script for routes from and to the
/// peer, by name.
pub import_function: Option<String>,
pub export_function: Option<String>,
}
/// Counts kept by a session as it goes, for whoever wants to look.
@@ -149,9 +145,6 @@ pub struct SessionHandle {
pub rib_peer: PeerIndex,
/// How many routes the peer has, and how many were refused.
pub counters: Arc<SessionCounters>,
/// The policy function for routes on their way to the peer, with the
/// peer as it is shown to it.
pub export: Option<Arc<(bgp_policy::Hook, bgp_policy::Peer)>>,
outbound: mpsc::Sender<Message>,
/// Which of the RPKI's checks the peer's routes are subject to, if
/// any, and where the session is told to look at its routes again.
@@ -188,7 +181,6 @@ impl SessionHandle {
add_path_send: negotiated.add_path_send.clone(),
rib_peer,
counters: Arc::default(),
export: None,
outbound,
validation: None,
}
-10
View File
@@ -89,10 +89,6 @@ pub struct PeerView {
/// 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>,
/// The functions of the policy script that the peer's routes, and
/// the routes it is sent, are given to; absent where there is none.
pub import_function: Option<String>,
pub export_function: Option<String>,
/// Why the last connection attempt or session ended, while in Backoff.
pub last_error: Option<String>,
pub retry_at_unix: Option<u64>,
@@ -119,8 +115,6 @@ impl From<&SessionHandle> for PeerView {
held_routes: Some(s.counters.held()),
role: s.role.map(PeerRole::as_str),
role_confirmed: s.role.map(|_| s.role_confirmed),
import_function: None,
export_function: None,
last_error: None,
retry_at_unix: None,
}
@@ -155,8 +149,6 @@ impl PeerView {
held_routes: None,
role: peer.role.map(PeerRole::as_str),
role_confirmed: None,
import_function: peer.import_function.clone(),
export_function: peer.export_function.clone(),
last_error,
retry_at_unix,
}
@@ -180,8 +172,6 @@ pub fn peer_views(registry: &SessionRegistry) -> Vec<PeerView> {
claimed.insert(session.peer);
PeerView {
name: Some(peer.name.clone()),
import_function: peer.import_function.clone(),
export_function: peer.export_function.clone(),
..PeerView::from(session)
}
}
+1 -5
View File
@@ -123,10 +123,6 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, refused: [], rpki: null, kernel: null };
const DASH = '<span class="dim">-</span>';
// The functions of the policy script a peer's routes go through.
const policy = p => (p.import_function || p.export_function)
? ` <span class="dim" title="Functions of the policy script: for routes from the peer, and for routes to it">${[p.import_function && 'in: ' + esc(p.import_function), p.export_function && 'out: ' + esc(p.export_function)].filter(Boolean).join(', ')}</span>`
: '';
// ---- helpers -----------------------------------------------------------
const fmtDur = s => {
@@ -186,7 +182,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>'}${policy(p)}</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>${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>