Files
ferrix/crates/policy/tests/scripts.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 cbf0df1eef Policy scripts: the operator's own rules for routes, in Rhai
A new crate, bgp-policy, runs functions of a script on routes: on their
way in, after the general checks of a peer's import and before the
checks that can change (RPKI, next hop reachability); and on their way
out to a peer. A function is given the route and the peer, may refuse
the route, and may change its communities, large communities,
LOCAL_PREF and MED, and on the way out prepend our AS:

    fn from_peer(route, peer) {
        if route.has_community("65535:666") { return false; }
        route.add_community(LEARNED_FROM[peer.role]);
        route.local_pref = PREFERENCE[peer.role];
        true
    }

Configuration: policy { path | source, import_function, export_function }
and, for a peer, import { function } and export { function }. The script
is compiled at startup; `bgp --check` reads it and says which function
is whose. A function that fails on a route has refused it. Scripts have
no files, network or clock, and are stopped after 200,000 steps.

Route selection now goes by a LOCAL_PREF on a route from an external
peer: the decoder never lets a peer's own through, so one that is there
was set by policy.

What it costs was measured before it was built on (benches/policy.rs):
a call that tests a few communities and changes two takes 1 to 3
microseconds, some two hundred times what Rust would. So a function is
asked once for the routes of an UPDATE, which share their attributes,
unless it asks for the prefix. One peer sending 1.23 M routes over a
session: 0.37 s without a function, 0.80 s with one that marks and
ranks every route, 3.4 s if it also looks at the prefix.

Export applies to what is announced, which today is what the daemon
originates; learned routes are not passed on yet.

Tested by unit tests of the crate, sessions end to end, a fuzz target on
the example script (testdata/policy.rhai), and a lab phase in which a
script changes which of FRR's and BIRD's route is chosen and what each
of them is told.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 21:09:39 +02:00

662 lines
21 KiB
Rust

//! 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]))
);
}