Policy quickstart (.rpol)
Build and validate your first rustbgpd routing policy.
Build and validate your first rustbgpd routing policy.
When this is you: You're about to write your first real rustbgpd
routing policy. You want the workflow that keeps it honest — a typed,
compiled policy file with its unit tests in the file, checked before
it ever touches the daemon, dry-run against the live RIB before it's
attached, hot-swapped under traffic with SIGHUP, and explainable per
term afterwards. Full language reference:
rpol-language.md
(ADR-0096).
Proven by: M80
(route-for-route parity against FRR route-maps, rbgp policy check
gating, and a SIGHUP hot-apply under traffic that refreshes only the
peers whose chains changed) and M34 (SIGHUP policy soft-reset
auto-fire). The fixture this quickstart is modeled on:
tests/interop/configs/rustbgpd-m80-policy.rpol.
1. Write the policy — edge.rpol
An eBGP edge shape: bogon and transit-leak guards, a customer prefix-set with per-peer local-pref, community bookkeeping, and tests.
prefix-set bogons {
10.0.0.0/8 le 32,
172.16.0.0/12 le 32,
192.168.0.0/16 le 32,
100.64.0.0/10 le 32,
0.0.0.0/8 le 32
}
prefix-set customers { 203.0.113.0/24 ge 25 le 28, 198.51.100.0/24 }
community-set scrub-marks { 65000:666 }
# Predicate policy: decides both ways so `apply()` is meaningful.
policy bogon-filter {
term drop-bogons { if route.prefix in bogons { reject } }
term clean { accept }
}
# Parameterized import template: each peer instantiates its own
# local-pref. peer.group matching keeps one policy for the whole
# customer fleet — note the update-group consequence in §6.
policy customer-in(peer_lp: u32) {
term bogon-guard { if !apply(bogon-filter) { reject } }
term transit-guard {
# A customer must never send us a path through our transit.
if route.as-path matches "_64620_" { reject }
}
term scrub {
# Modify-and-continue: no verdict, keep walking.
if route.communities in scrub-marks {
remove community 65000:666;
set med 0
}
}
term customer-routes {
if route.prefix in customers && peer.group == "customers" {
set local-pref peer_lp;
add community 65000:100;
accept
}
}
term default-reject { reject }
}
policy edge-out {
term no-internal-leak { if route.communities has 65000:900 { reject } }
term tag {
add community 65000:200;
accept
}
}
# Tests live with the policy and run offline (`rbgp policy check`).
test accepts-customer-prefix-with-lp {
route { prefix 203.0.113.0/26; communities [65000:666]; med 300 }
peer { group "customers" }
expect customer-in(150) == accept with local-pref 150, med 0, community 65000:100
}
test rejects-customer-prefix-from-wrong-group {
route { prefix 203.0.113.0/26 }
peer { group "transit" }
expect customer-in(150) == reject
}
test rejects-bogon {
route { prefix 10.1.0.0/16 }
expect customer-in(150) == reject
}
test rejects-transit-aspath {
route { prefix 203.0.113.0/26; as-path "65010 64620 3356" }
expect customer-in(150) == reject
}
test export-blocks-internal {
route { prefix 198.51.100.0/24; communities [65000:900] }
expect edge-out == reject
}Note the peer { ... } fixture blocks: a policy that matches on
peer.group (or peer.address / peer.asn) needs the test to say
which peer it is evaluating as — an omitted peer block means an empty
peer context and those predicates never match.
2. Check it — no daemon needed
$ rbgp policy check edge.rpolParse, typecheck, and every test block — exit 0 clean, 1 for
diagnostics (rendered with source spans), 2 for test failures. Put
this in CI next to the config lint; M80 gates on it.
Keep the file in the canonical style with rbgp policy fmt edge.rpol
(one style, no options; comments and semicolons survive), and add
rbgp policy fmt --check edge.rpol beside policy check in CI —
exit 1 with a diff when a file drifts. See "Formatting" in
the language reference.
3. Wire it into the config
[global]
asn = 65000
router_id = "192.0.2.1"
listen_port = 179
# RFC 8212: an eBGP direction without an explicit chain carries nothing.
ebgp_requires_policy = true
[global.telemetry]
prometheus_addr = "127.0.0.1:9179"
log_format = "json"
# Owner-only local socket (default mode 0600): clients are authorized as the
# implicit "local-operator" principal — no [security.grpc] block needed.
[global.telemetry.grpc_uds]
path = "/var/lib/rustbgpd/grpc.sock"
[policy]
rpol_files = ["policies/edge.rpol"] # relative to this config file
[peer_groups.customers]
hold_time = 90
families = ["ipv4_unicast"]
[[neighbors]]
address = "192.0.2.10"
remote_asn = 64500
description = "customer-a"
peer_group = "customers"
import_policy_chain = ["customer-in(150)"]
export_policy_chain = ["edge-out"]
[[neighbors]]
address = "192.0.2.20"
remote_asn = 64501
description = "customer-b"
peer_group = "customers"
import_policy_chain = ["customer-in(200)"] # same template, higher LP
export_policy_chain = ["edge-out"]rustbgpd --check --strict config.toml validates the whole thing — including
compiling the .rpol file — before you restart or reload anything; --strict
also fails on warnings, such as an eBGP direction left without policy.
Before attaching a candidate policy on a running daemon, dry-run it against the live RIB — read-only, no route state or session touched:
$ rbgp policy test edge.rpol --policy "customer-in(150)" \
--direction import --neighbor 192.0.2.10Output: accept/reject counts, per-term hit counters, and before/after
attribute diffs for the first N modified routes. An import dry run
evaluates only the retained post-policy Adj-RIB-In (routes import policy
admitted when they were received or last re-evaluated; during GR/LLGR
retention or before a pending re-evaluation completes, that can include
routes the installed policy would now reject). It cannot show routes the
candidate would newly admit; to see recent rejections,
run rbgp rib received <peer> --rejected.
4. Hot-swap under traffic
Edit edge.rpol, re-run rbgp policy check, then:
$ kill -HUP $(pidof rustbgpd)The planner diffs policy content: only peers whose effective chains changed get a route refresh / re-evaluation (M80 asserts exactly one peer refreshes when one peer's policy changes; M34 proves the soft-reset auto-fire). Peers on unchanged chains are untouched.
5. Explain it afterwards — the trilogy
# Import: why was this prefix permitted/denied from this peer?
# Per-term trace for .rpol members; backed by the per-session
# decision cache, which is OPT-IN (ADR-0073) — set
# `[policy.explain] enabled = true`, reload, and let the session
# re-establish, or this answers `cache_disabled`.
$ rbgp policy explain --neighbor 192.0.2.10 --prefix 203.0.113.0/26 --direction import
# Export: the full gate ladder toward a peer; the export_policy rung
# reports chain_default_permit for nonempty Permit, or the deciding
# policy[:term] for Deny.
$ rbgp rib --prefix 203.0.113.0/26 advertised 192.0.2.20 --explain
# Best path: why this candidate won in the Loc-RIB.
$ rbgp rib --prefix 203.0.113.0/26 --explainAnd the live counters — which terms are actually doing work since the
chain was installed (counters reset on chain replace). --direction is
required (import, export, or both); ask for both to see
customer-in(200) beside edge-out:
$ rbgp policy stats --neighbor 192.0.2.20 --direction both6. The update-group footnote
peer.group / peer.address / peer.asn matches make a policy's
verdict peer-dependent, so any peer whose export chain uses them
drops to the per-peer staging path (policy_peer_context in
rbgp neighbor <address>, reason table).
Identical semantics, less sharing. Import chains don't affect
grouping, and parameterized instantiations like customer-in(150) vs
customer-in(200) on the import side are free. Keep peer-context
matches out of widely shared export chains on a big reflector.
Failure modes
--check fails after an .rpol edit but the daemon is still
running the old policy. That is the designed order — compile errors
never reach the live daemon. On SIGHUP with a broken file, the reload
is rejected as a unit and the running config stays; fix and re-signal.
A route you expected accepted is missing. rbgp policy explain
first: not_seen means it never arrived (look upstream), a term-named
denial means your policy did it. A denied route keeps no
modifications — a set executed before a later reject is discarded
by design.
A test passes but the live daemon disagrees. Usually chain
composition — another member of the effective chain rejected first —
or a peer-context mismatch between the test's peer fixture and the
real neighbor. rbgp policy chain show --neighbor <addr> prints the
effective chain order; rbgp policy test with --neighbor reproduces
the live evaluation against the real peer context.