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

+61
View File
@@ -0,0 +1,61 @@
// An example policy script; see "Policy scripts" in bin/bgp/README.md for
// everything a function can read and change.
//
// A function is given a route and the peer it comes from (or goes to). It
// may change the route, and returns whether the route is taken (or
// announced). Which function is for which peer is said in the
// configuration: import { function: "from_peer" }.
//
// Constants defined here are for every function below.
// Where a route was learned: what the rules for announcing it go by.
const LEARNED_FROM = #{
customer: "65000:1000",
peer: "65000:2000",
provider: "65000:3000",
route_server: "65000:2000",
};
// A customer's route is preferred to a peer's, and that to a provider's:
// the first earns money, the last costs it.
const PREFERENCE = #{ customer: 300, peer: 200, route_server: 200, provider: 100 };
const BLACKHOLE = "65535:666";
const NO_EXPORT = "65535:65281";
// Routes on their way in.
fn from_peer(route, peer) {
// Not ours to act on, from just anyone.
if route.has_community(BLACKHOLE) { return false; }
// Marks in our own range mean something here: nobody else's word for
// them is taken.
route.remove_community("65000:*");
if peer.role in LEARNED_FROM {
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = PREFERENCE[peer.role];
}
// A peer can ask to be a last resort: 65000:80 on a route of its own.
if route.has_community(peer.asn % 65536, 80) {
route.local_pref = 80;
}
true
}
// Routes on their way out.
fn to_peer(route, peer) {
if route.has_community(NO_EXPORT) { return false; }
// Only customers are told what we learned from peers and providers.
let from_above = route.has_community("65000:2000") || route.has_community("65000:3000");
if from_above && peer.role != "customer" { return false; }
// Our marks are our own business.
route.remove_community("65000:*");
// Traffic from this one is wanted by another way: the path looks
// longer to it.
if peer.name == "backup-transit" { route.prepend(2); }
true
}
+11
View File
@@ -48,7 +48,13 @@ peers {
# as generated from the routing registries.
# prefixes { prefix: "193.0.0.0/21" max_length: 24 }
# origin_asns: 3333
# And then the operator's own rules: a function of the policy
# script (see `policy` below), which may refuse the route or mark
# and rank it.
# function: "from_peer"
}
# What the peer is told also goes through a function of the script.
# export { function: "to_peer" }
}
connect_retry_seconds: 5
@@ -65,6 +71,11 @@ originate {
large_communities: "65000:1:2"
}
# The operator's own rules for routes, as a script beside this file; see
# policy.rhai here for an example, and "Policy scripts" in
# bin/bgp/README.md. `bgp --check` reads it and says what is wrong.
# policy { path: "policy.rhai" }
# Validation with the RPKI: refuse routes whose origin AS is not the one the
# holder of the addresses authorised, and, from peers with a role, routes
# whose AS path is a leak. Point it at a validator's RTR server.