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>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 21:09:39 +02:00
1 parent f1d80c66df
commit cbf0df1eef
52 files changed
+3467 -43

No files matched your search

+174 -5
View File
@@ -1,4 +1,5 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
@@ -7,6 +8,7 @@ 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};
@@ -52,6 +54,8 @@ 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)]
@@ -76,6 +80,10 @@ 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 {
@@ -84,7 +92,45 @@ 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()
@@ -118,7 +164,10 @@ impl Config {
peer: name.clone(),
problem,
};
let peer = parse_peer(peer).map_err(rejected)?;
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)?;
// 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));
@@ -194,6 +243,7 @@ impl Config {
http_endpoint,
kernel: proto.kernel.as_ref().map(parse_kernel),
rpki,
policy,
})
}
}
@@ -299,9 +349,29 @@ 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);
@@ -462,6 +532,94 @@ 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]
@@ -493,7 +651,18 @@ mod tests {
})
.collect();
assert_ne!(shown, example, "nothing was taken for configuration");
let config = parse(&shown).unwrap_or_else(|e| panic!("{e}\n{shown}"));
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")
);
assert!(config.rpki.is_some(), "{shown}");
assert!(config.kernel.is_some(), "{shown}");
assert_eq!(config.peers[0].role, Some(PeerRole::Peer));
@@ -682,17 +851,17 @@ mod tests {
other => panic!("{other}"),
};
assert_eq!(
problem(peer("r1", "192.0.2.2") + &peer("r1", "192.0.2.3")),
problem(peer("r1", "192.0.2.2") + peer("r1", "192.0.2.3").as_str()),
PeerProblem::DuplicateName
);
assert_eq!(
problem(peer("r1", "192.0.2.2") + &peer("r2", "192.0.2.2")),
problem(peer("r1", "192.0.2.2") + peer("r2", "192.0.2.2").as_str()),
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")),
problem(peer("r1", "fe80::1%eth0") + peer("r2", "fe80::1%eth0").as_str()),
PeerProblem::DuplicateAddress
);
let config = parse(&format!(