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

+77 -1
View File
@@ -29,7 +29,7 @@ cd "$(dirname "$0")/../.."
bgp="${CARGO_TARGET_DIR:-target}/release/bgp"
work=/tmp/ferrix-lab
pathspace=ferrixlab
all_phases="basics roles rpki refresh flaps restart scale limits hijacks nexthops v6only linklocal"
all_phases="basics roles rpki refresh flaps restart scale limits hijacks nexthops policy v6only linklocal"
phases="${PHASES:-$all_phases}"
scale_routes="${SCALE_ROUTES:-200000}"
@@ -130,8 +130,10 @@ END
# --- the daemon under test ------------------------------------------------------
# dut_config [frr-role] [frr-max-prefixes] [more of frr4's import policy]
# [more of frr4] [more of bird4] [more of the configuration]
dut_config() {
local frr_role=${1:-PEER} frr_limit=${2:-2000000} frr_import=${3:-}
local frr_more=${4:-} bird_more=${5:-} more=${6:-}
cat > "$work/dut.textpb" <<END
router_id: "10.9.0.1"
local_asn: 64601
@@ -143,6 +145,7 @@ peers {
address_families: IPV4_UNICAST
role: $frr_role
import { accept_bogons: true max_prefixes: $frr_limit hold_down_seconds: 5 $frr_import }
$frr_more
}
peers {
name: "frr6" endpoint { address: "2001:db8:9::2" } remote_asn: 64602
@@ -156,6 +159,7 @@ peers {
role: PROVIDER require_role: true
add_path: ADD_PATH_RECEIVE
import { accept_bogons: true max_prefixes: 2000000 }
$bird_more
}
peers {
name: "bird6" endpoint { address: "2001:db8:9::3" } remote_asn: 4200000003
@@ -168,6 +172,7 @@ originate { prefix: "2001:db8:1::/48" large_communities: "64601:1:2" }
kernel { stale_seconds: 3 }
rpki { caches { address: "127.0.0.1" port: 3323 } retry_seconds: 1 }
http_endpoint { address: "127.0.0.1" port: 8080 }
$more
END
}
@@ -823,6 +828,77 @@ phase_nexthops() {
ok "back to normal"
}
# The operator's own rules, as a script: routes are marked with where
# they were learned and ranked by it, which changes which of two routes
# is chosen; and what each neighbor is told is decided for each.
phase_policy() {
cat > "$work/policy.rhai" <<'END'
const LEARNED_FROM = #{ peer: "64601:2000", provider: "64601:3000" };
// Every route on its way in. Here the provider's routes are the ones to
// prefer, which is the other way about from what selection would do.
fn incoming(route, peer) {
route.remove_community("64601:*");
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = if peer.role == "provider" { 300 } else { 100 };
true
}
fn to_frr(route, peer) {
route.add_community("64601:777");
route.prepend(2);
true
}
// BIRD is not told our IPv4 prefix.
fn to_bird(route, peer) { !route.within("10.1.0.0/24") }
END
dut_down TERM
dut_config PEER 2000000 "" \
'export { function: "to_frr" }' 'export { function: "to_bird" }' \
'policy { path: "policy.rhai" import_function: "incoming" }'
"$bgp" --check "$work/dut.textpb" | grep -q "frr4: import incoming, export to_frr" \
|| fail "--check does not say which functions are whose: $("$bgp" --check "$work/dut.textpb" 2>&1)"
dut_up
# A prefix nobody has said anything about in the RPKI, from both; of
# the two, FRR's would be chosen.
echo "route 10.77.0.0/24 blackhole;" > "$work/bird-both.conf"
bird_config "$work/bird-both.conf"
bird_ctl configure >/dev/null
frr_configure "router bgp 64602" "address-family ipv4 unicast" "network 10.77.0.0/24"
wait_for "four sessions" established 4
wait_for "FRR's route for it" dut_has 10.77.0.0/24 frr4
wait_for "BIRD's route for it" dut_has 10.77.0.0/24 bird4
# In: marked by where they were learned, and ranked.
local marks
marks=$(api "/api/routes/received?prefix=10.77.0.0/24" | jq -c '[.routes[] | {peer: .peer_name, marks: (tostring | [scan("64601:[0-9]+")] | unique)}] | sort_by(.peer)')
[ "$marks" = '[{"peer":"bird4","marks":["64601:3000"]},{"peer":"frr4","marks":["64601:2000"]}]' ] \
|| fail "the routes are marked $marks"
wait_for "BIRD's route to be the one chosen" best_is 10.77.0.0/24 bird4
wait_for "it in the kernel" dut_kernel -4 "10.77.0.0/24 via 10.9.0.3 dev ix0 metric 20"
peer_is frr4 '.import_function == "incoming" and .export_function == "to_frr"' >/dev/null \
|| fail "the status does not say which functions are the peer's"
ok "routes are marked with where they were learned, and the provider's is chosen and installed"
# Out: FRR is told our route with a longer path and a mark; BIRD is not
# told it at all, and is told the IPv6 one.
wait_for "our route at FRR" bash -c "sudo ip netns exec lab-frr vtysh -N $pathspace -c 'show bgp ipv4 unicast 10.1.0.0/24 json' 2>/dev/null | jq -e '.paths[0] | .aspath.string == \"64601 64601 64601\" and (.community.string | contains(\"64601:777\")) and (.community.string | contains(\"64601:100\"))'"
wait_for "our IPv6 route at BIRD" bird_has 2001:db8:1::/48
sleep 1
bird_has 10.1.0.0/24 && fail "BIRD was told the route its function refuses"
ok "FRR is told our route with a longer path and a mark; BIRD is not told it"
frr_configure "router bgp 64602" "address-family ipv4 unicast" "no network 10.77.0.0/24"
bird_config
bird_ctl configure >/dev/null
dut_down TERM
dut_config
dut_up
converged
ok "back to normal"
}
# The exchange LAN loses its IPv4 addresses. One session over IPv6 with
# each router carries both families, and IPv4 routes have IPv6 next hops
# (RFC 8950), in BGP and in the kernel. Run after the rest: the lab stays