IXP filter pipeline: arouteserver → rs-config-render → rustbgpd → Alice-LG
Build an IXP filter pipeline around rustbgpd.
Build an IXP filter pipeline around rustbgpd.
The toolchain an IXP already runs — arouteserver for
IRR/PeeringDB/RPKI member-filter generation, a looking glass for
member support — end to end on rustbgpd. You keep your existing
general.yml / clients.yml and the arouteserver refresh cadence;
rustbgpd replaces only the daemon, via one render step
(tools/rs-config-render/,
ADR-0110).
This is the ARouteServer-driven provisioning mode. With no external member source, hand-write the IXP route server instead; with IXP Manager v7.4 as the member database, use the IXP Manager route server — the three-way fork is in the cookbook index.
The pipeline:
general.yml + clients.yml
│ arouteserver template-context (IRR/PeeringDB/RPKI ingest)
▼
context.yml
│ rs-config-render (fail-stale rendering)
▼
config.toml + policy/*.rpol + datasets/*.list + render-receipt.json
│ rustbgpd --check --strict (full config validation)
▼
swap + SIGHUP (one runtime generation)
│
▼
rbgp verification + Alice-LG via the birdwatcher adapterThis recipe assumes the route-server cookbook
shapes: transparent route_server_client sessions, RFC 9234
route_server role, per-client best-path. Migration mapping from an existing
BIRD/OpenBGPD deployment—including the manual OpenBGPD advertised-view
boundary—is in route-server-migration.md.
1. Generate the resolved member data
arouteserver's template-context command dumps its fully resolved
data model — bgpq4-expanded IRR prefix/origin sets, PeeringDB
max-prefix ceilings, RPKI knobs — as YAML, using your existing site
files and caches:
arouteserver template-context --output /var/lib/rs/context.ymlInstall and configure arouteserver itself per its
documentation (arouteserver setup, then your
general.yml / clients.yml).
rustbgpd implements ARouteServer's shutdown, OpenBGPD-style timed-restart,
block, and warning actions. ARouteServer's BIRD target
restarts immediately and ignores restart_after; this is not BIRD timed parity.
block renders max_prefix_action = "block" (net-new prefixes beyond the
bound are withheld while the session stays Established) and warning renders
max_prefix_action = "warning" (one warning per crossing, no teardown).
Configure the model explicitly:
cfg:
filtering:
max_prefix:
action: restart
restart_after: 15 # ARouteServer minutes; rendered as 900 seconds
count_rejected_routes: falseAn absent effective action disables max-prefix enforcement even when
ARouteServer leaves resolved limit values in the context; a zero family
limit is likewise treated as unset, and a restart timer is emitted only with a
positive family limit. count_rejected_routes selects the counting model:
ARouteServer 1.23.2 defaults it to true, which renders the pre-policy
max_prefixes_received_ipv4/_ipv6 bounds (every unicast prefix the member
announces counts, accepted or rejected); false renders the accepted-route
max_prefixes_ipv4/_ipv6 bounds.
The command's output format is arouteserver's, not ours: 1.23.2 emits
a sectioned report (per-key heading plus a YAML fragment). The
renderer auto-detects and ingests that form directly, alongside the
single-document YAML equivalent its fixtures use — no conversion step.
This exact pipeline runs for real against the pinned official
arouteserver image in
tests/interop/m90-differential/prove-context-ingestion.sh,
which asserts both input forms render identical configuration.
2. Render rustbgpd configuration
rs-config-render --context /var/lib/rs/context.yml \
--out-dir /var/lib/rs/candidate --rtr-cache 127.0.0.1:3323This emits config.toml (one [[neighbors]] per client with
per-family max-prefix ceilings and a per-client import chain),
policy/rs-hygiene.rpol (the shared hygiene chain: AS_SET reject,
bogons, transit-free, path-length cap, RPKI origin validation),
policy/client-<id>.rpol (the client's IRR-derived prefix/origin
tests and dataset bindings), datasets/client-<id>-origins.list and
datasets/client-<id>-prefixes.list (the generated IRR membership, plus a
blackhole-cover dataset when configured), birdwatcher-reject-communities.json
(when any client uses reject_policy: tag_and_reject; the looking-glass
adapter's reject-cause map), and render-receipt.json
(fingerprint, cardinalities,
warnings). --rtr-cache is required whenever the context enables
RPKI origin validation or irrdb.use_rpki_roas_as_route_objects — the
context carries no cache address. The
renderer ships in the release tarball alongside rustbgpd and rbgp
(install); from a checkout, build it with
cargo build --release -p rs-config-render.
The renderer is deliberately fail-stale, never fail-open: a refused knob (exit 2), an implausibly empty IRR set (exit 3), or context-shape drift (exit 4) aborts the whole render and leaves the previous configuration running. The full refused-knob table and failure policy are in the renderer README.
Shared hygiene scrubs every configured arouteserver *_validated_* tag
(white list, RPKI ROAs, ARIN and registro.br whois dumps) from received
routes, whether or not the site tags routes with it, as arouteserver's
scrub_communities_in() does. A member therefore cannot pass a lookalike
validation tag through to other clients. The same scrub covers the rest of
upstream's inbound list that has a fixed value: the internal
rpki_bgp_origin_validation_valid/unknown/invalid and
reject_cause_map_* communities and every custom_communities entry. The
renderer sets none of these, so the scrub only removes member-sent copies.
An ext form or a malformed value is refused, and so is a client's
attach_custom_communities, which the renderer does not reproduce.
rejected_route_announced_by, the other internal community with a dyn_val
range, is refused whenever it is configured.
One internal community is not scrubbed: reject_cause. Upstream removes
its whole dyn_val range, and rpol has no removal pattern for that form, so
a member-sent value in the reject_cause range reaches other clients
unchanged. rustbgpd does not act on it: a rejected route is retained with a
structured reason rather than tagged. For tag_and_reject clients, the
Birdwatcher adapter replaces wire copies of the reject communities when it
displays a filtered route.
Try it from this repository
The renderer's checked-in test fixture is a three-client context whose third client deliberately carries an empty IRR prefix set, so a render demonstrates the fail-closed abort:
$ cargo run -q -p rs-config-render -- \
--context tools/rs-config-render/tests/fixtures/context-small.yml \
--out-dir /tmp/rs-out --rtr-cache 127.0.0.1:3323
rs-config-render: render aborted — implausible generated sets:
- client AS51325_1 (AS51325): 0 IRR prefix(es) resolved, floor is 1 — an empty or shrunken set means the upstream IRR answer is broken, not that the client deregistered everything
no output written; the last good configuration stays live (fail-stale)What a successful render of the same context (minus the broken
client) emits is checked in verbatim as the golden outputs —
tools/rs-config-render/tests/golden/:
config.toml, rs-hygiene.rpol, two client-*.rpol files, and each
client's origin and prefix dataset files. The test maps the flat checked-in
fixture names to their rendered policy/ and datasets/ paths. The
generated policies carry in-language test blocks derived from the
site's own data, runnable offline:
$ rbgp policy check tools/rs-config-render/tests/golden/rs-hygiene.rpol
tools/rs-config-render/tests/golden/rs-hygiene.rpol: 7 passed, 0 failed3. Validate, swap, reload
The deployment loop is the same fail-stale cron shape arouteserver deployments already use — every step must succeed or the previous configuration stays live:
#!/bin/sh -e
# cron: refresh member filters (arouteserver caches govern data staleness)
arouteserver template-context --output "$STATE/context.yml"
rs-config-render --context "$STATE/context.yml" \
--out-dir "$STATE/candidate" --rtr-cache 127.0.0.1:3323
# --strict: warnings fail the gate, so a refresh never swaps in a config
# the daemon had something to say about. Rendered output is clean.
rustbgpd --check --strict "$STATE/candidate/config.toml"
# The generated rpol resolves dataset paths relative to this tree. Install the
# config, policy, and datasets together; never update only one directory.
rsync -a --delete "$STATE/candidate/" /etc/rustbgpd/
systemctl reload rustbgpd # SIGHUP: one runtime generationBefore the first cutover — or any time you want to see what a refresh will change — compare the candidate with the running daemon:
rbgp config diff "$STATE/candidate/config.toml"A joining member is listed under Neighbors: as + <address> (AS <asn>)
and a leaving one as -; changed neighbor fields are annotated hot-applied /
session reset / restart required. The output ends with the route the reload
will take:
Reload-applied changes:
Neighbors:
+ 192.0.2.33 (AS 4242)
Policy:
~ rpol_files / rpol_roots / rpol_max_graph_bytes / .rpol graph
~ policy dataset bindings / paths
SIGHUP reload route: generation (one owned runtime generation; a late failure restores the prior generation)
Plan: 1 to add · no session resets expectedExit code 2 means changes are present, 0 none, and 1 an error.
The rendered configuration names its .rpol and dataset files relative to
its own directory. rbgp config diff resolves those paths from the candidate
file's directory before sending the TOML, and the daemon then reads the files
itself, so run it on the route-server host where the daemon can read the
candidate tree; a file the daemon cannot read fails the diff with exit 1.
Because the candidate sits in a different directory from the installed copy,
the diff also reports the .rpol and dataset path lines as changed on every
run; read the Neighbors: section and the route line.
Previews compare configuration and bindings; they do not evaluate dataset
file contents. When the candidate declares datasets, rbgp config diff and
rustbgpd --diff report datasets: contents not compared (N declared); a reload re-reads them.
For one member, preview whether the candidate IRR lists would reject routes
that member currently has admitted before the reload:
rbgp policy test "$STATE/candidate/policy/client-as4242-1.rpol" \
--policy client-as4242-1 --direction import --neighbor 192.0.2.11 \
--dataset client-as4242-1-origins="$STATE/candidate/datasets/client-as4242-1-origins.list" \
--dataset client-as4242-1-prefixes="$STATE/candidate/datasets/client-as4242-1-prefixes.list" \
--show-rejected 20The rejected total counts candidate rejections among retained post-policy
routes; the list is capped at 20 samples. This is a read-only, single-policy
view for one member. See the dry-run scope
before using it to judge a refresh.
For an IRR-only refresh where member prefix or origin lists change while the
member roster and config text remain identical, the diff flags the uncompared
datasets and omits No changes.; SIGHUP re-reads the updated dataset files
on disk and applies the new filters as a generation. The exit code is still
0 for such a refresh, so a cron must not gate systemctl reload on the
diff's exit code when datasets are declared; reload on every rendered refresh,
as the block above does.
Without a reachable daemon, rustbgpd --diff prints the same report offline.
It reads /etc/rustbgpd/config.toml as the current side and resolves each
file's paths from that file's location:
rustbgpd --diff "$STATE/candidate/config.toml"A rendered refresh — changed IRR data, a member joining or leaving with its
[[neighbors]] entry and its two datasets, or both — takes the generation
route. SIGHUP applies it as one runtime generation: unchanged members keep
their sessions, and a failure part-way restores the prior member set,
policies, and datasets and rejects the reload (a restore that cannot be proven
fences the daemon instead). A candidate whose TOML,
policy, or dataset files fail to load at SIGHUP is rejected before any effect.
Either way the daemon keeps serving its previous configuration while the
candidate stays installed on disk, and the next refresh signals it again.
Member joins and leaves take the same route when the member has an MD5
session password, and in fleets that render GTSM (ttl_security = true) for every member. The daemon installs the
joining member's listener MD5 key or GTSM selector before it adds the
session, and withdraws a leaving member's entry only after the session is
gone. If the reload fails, the prior entries return. Changing the
password or GTSM setting of a member that stays, in the same refresh as
dataset changes, is rejected before any effect: apply that edit in its own
reload.
The one rendered knob outside the generation is honor_graceful_shutdown
(from graceful_shutdown.enabled): a refresh that flips it together with
member or IRR-data changes is rejected before any effect, so make that site
change when nothing else changes, or restart. The
SIGHUP reload routes
table lists every combination.
Run the loop at the cadence your IRR data actually changes — arouteserver deployments typically refresh every 6–24 hours, and the renderer adds no reason to differ. The arouteserver cache TTLs govern data staleness; the refresh script above only re-renders what those caches resolve.
rs-config-render distinguishes its failure classes by exit code so
the cron wrapper can alert on why the loop is stuck, not just that
it is:
| Exit | Meaning | Operator action |
|---|---|---|
0 | Rendered; receipt written | none |
1 | Context parse error | inspect the template-context output |
2 | Refused — unsupported context knobs (listed on stderr) | remove the knob or wait for renderer support; repeats every run until the site config changes |
3 | Implausible data — empty/collapsed member sets | usually an upstream IRR/PeeringDB outage; the previous config stays live by design |
4 | Context shape mismatch — arouteserver output changed | pin the arouteserver version or update the renderer |
8 | Output unusable — the output directory could not be created or written | fix the path, ownership, or free space; the previous config stays live |
Exit 3 is the fail-stale case the pipeline exists for: a transient
upstream outage must never strip a member's filters, so nothing is
emitted and the daemon keeps serving the last good generation.
Alert on the age of render-receipt.json — a pipeline stuck for more
than a couple of refresh intervals should page. The matching
daemon-side signal is bgp_policy_generation_loaded_timestamp_seconds
("Policy artifact freshness" in OPERATIONS.md),
which deliberately stays frozen when a reload is rejected — file
mtimes can lie about what the daemon actually accepted.
4. Verify member sessions and filters
rbgp summary # members Established
rbgp rib received 198.51.100.2 # a member's accepted routes
rbgp policy stats --direction import # hygiene/client terms firing
rbgp policy explain --neighbor 198.51.100.2 --prefix 203.0.113.0/24 --direction importThe last one needs [policy.explain] enabled = true — import explain is
opt-in, since the decision cache is per session and its cost multiplies
by member count. Without it the query answers cache_disabled and names
the lines to add.
5. Member support: the filtered-route view
The daemon retains each member's rejected routes with canonical reason tokens — the "which of my routes are you filtering, and why" answer, queryable without knowing a prefix in advance:
rbgp rib received 198.51.100.2 --rejectedThe route-server cookbook covers this view, its retention knobs, and the follow-up explain workflow; the full explain surface catalog is in explain.md.
6. Alice-LG via the birdwatcher adapter
Alice-LG uses the external
examples/birdwatcher-adapter
sidecar, which serves a Birdwatcher-shaped REST subset from the daemon's gRPC
API —
status, peers, a member's accepted routes, the filtered-route view
above, and a noexport view (routes withheld from a member, each named
by the export gate that stopped it, tagged 64496:65521:<id>). For the
filtered view it synthesizes one reject-reason large
community (64496:65520:<id>, one stable id per reason token) on each
route, matchable by Alice-LG's [rejection_reasons] config exactly
like arouteserver's reject-reason tagging on BIRD; the adapter README
carries the mapping table and a ready Alice-LG config snippet.
cargo run --release -p birdwatcher-adapter -- \
--grpc-addr unix:///var/lib/rustbgpd/grpc.sock \
--listen 0.0.0.0:8080The owner-only socket is convenient but grants operator-tier local-operator;
the adapter README shows a dedicated token-authenticated observer listener
for least privilege. The adapter also needs
[policy.reject_retention] enabled for the filtered view. The noexport
view is served from the export-explain surface (best-routes-minus-
advertised, one export-ladder dry run per suppressed prefix). Honest
boundary: this is a maintained single-table unicast subset — a few
Birdwatcher fields are served as sentinels (the adapter README lists
every gap).
The pinned compatibility gate exercises this wiring through Alice-LG 6.2.0:
four live neighbor IDs, seven accepted routes, empty filtered arrays, and the
labeled 192.0.2.0/24 split-horizon noexport route. A pinned MANRS consumer
then traverses Alice's received routes and finds one deliberate synthetic ROA
mismatch. After freezing that baseline, the gate runtime-adds a fifth live
member, atomically reloads the adapter alias, and restarts Alice to clear its
caches. The four original peers and seven routes remain unchanged; the fifth
peer has an empty accepted view and exactly one filtered 198.18.0.0/24 with
64496:65520:4, joined through /config.reject_reasons to Receiver AS appears in AS_PATH. That is a backend/API proof, not a rendered-browser,
MANRS certification, rustbgpd RPKI-enforcement, or route-server-policy-
conformance claim.
Route-server shadow pilot — a standing non-authoritative deployment
The migration cookbook's shadow trial is a step on the way to a cutover.
IXP Manager route server: Foil export → render → activate → lifecycle
When this is you: your exchange runs IXP Manager v7.4 as the member and router database, and you want a rustbgpd route server that IXP Manager provisions the way it provisions your BIRD route servers — members, IRR sets, max-prefix, MD5, RPKI, and route-server UI filters come from the member DB; nothing is hand-written per member; IXP Manager's router lock/update lifecycle drives every reconfiguration; members see the route server through IXP Manager's looking glass.