Route-server migration notes
Map incumbent route-server concepts to rustbgpd.
Map incumbent route-server concepts to rustbgpd.
This page maps common FRR, BIRD, OpenBGPD, and ARouteServer route-server
concepts to rustbgpd's config and verification surfaces. Structure is
mechanical for BIRD 2/3, FRR, and GoBGP: rbgp config import translates their
supported structural subset and refuses to guess at anything else. There is
no OpenBGPD importer; use the manual mapping below. In every case, hand-map
untranslated policy and run the shadow trial before carrying production
traffic.
Mechanical import: BIRD, FRR, and GoBGP
# 1. Translate the structure; the report lists every warning and stanza that
# needs review, with source line numbers where available (exit 0 only
# when neither exists; 2 = translated but needs review; 3 = nothing).
rbgp config import bird.conf --out config.toml
# 2. Hand-translate the reported policy stanzas to .rpol
# (docs/reference/rpol-language.md), wire them into import/export chains, then
# validate — --check also compiles every referenced .rpol file, and
# warns (exit stays 0) for every eBGP neighbor still resolving no
# explicit policy, by name and direction.
rustbgpd --check config.toml
# 3. Shadow trial (the cutover checklist below), then compare the
# advertised view against the incumbent per member:
rbgp diff advertised --neighbor 198.51.100.2 --against bird-member.ndjsonThe importer covers local AS, router-id, neighbors (address, remote AS, description), peer groups, address families, hold timers, and max-prefix limits. MD5/auth presence is flagged but secrets are never imported. BIRD filters, FRR route-maps/prefix-lists, and GoBGP policy-definitions are deliberately not translated — a wrong mechanical policy translation would be worse than the honest list; the sections below are the hand-translation map for exactly those stanzas.
Because no policy is translated, the emitted [global] sets
ebgp_requires_policy = true (ADR-0112) — a knob the source config did not
ask for, so the import report says so and why. Every eBGP direction that
resolves no explicit policy runs the RFC 8212 reserved deny until you
configure one: the session establishes and carries nothing in that direction,
rather than silently passing everything. Each direction starts carrying
traffic as its chain lands. Delete the line from the emitted config to run
permit-all instead; it is startup-only, so changing it later needs a restart
rather than a reload.
Either way, rustbgpd --check names every eBGP neighbor that still resolves
no explicit policy and the directions it is missing, and summarizes as
config VALID, <n> WARNINGS — NOT a clean check rather than config OK. It
does not fail: a permit-all route server is a legitimate configuration, and
so is a deliberately empty one mid-migration.
The importer deliberately reads one BIRD source file and does not resolve files
named by standalone include statements. Flatten every referenced file into
one source before importing. Standalone include statements that remain are
reported with their source line and make the translated skeleton exit 2 for
operator review.
Do not pass an OpenBGPD bgpd.conf under another --format: its grammar and
semantics are not supported. Start with the baseline below and migrate each
peer and policy explicitly.
Baseline rustbgpd shape
Start from:
Core member shape:
[[neighbors]]
address = "198.51.100.2"
remote_asn = 64501
description = "member-alpha"
families = ["ipv4_unicast", "ipv6_unicast"]
route_server_client = true
role = "route_server"
max_prefixes = 50000
import_policy_chain = ["reject-rpki-invalid", "ixp-hygiene", "prefer-rpki-valid"]For path hiding, prefer Add-Path receive on the member:
[neighbors.add_path]
send = true
send_max = 8For members that cannot receive Add-Path, use the RFC 7947 §2.3 per-client-best fallback:
per_client_best = trueFRR
Common FRR route-server member shape:
router bgp 65500
neighbor 198.51.100.2 remote-as 64501
neighbor 198.51.100.2 route-server-client
neighbor 198.51.100.2 local-role rs
neighbor 198.51.100.2 strict-role
neighbor 198.51.100.2 maximum-prefix 50000
no bgp ebgp-requires-policyrustbgpd equivalent:
[[neighbors]]
address = "198.51.100.2"
remote_asn = 64501
route_server_client = true
role = "route_server"
strict_role = true
max_prefixes = 50000Notes:
-
FRR peers receiving transparent route-server paths need first-AS enforcement relaxed on the member side, because the route server's AS is not first in the AS_PATHs it forwards. The working form is per-neighbor:
! in the member's own FRR config, once per route-server neighbor no neighbor 198.51.100.1 enforce-first-asThe global
no bgp enforce-first-asalone is insufficient in FRR 10.3.1. Getting this wrong fails silently: FRR treats the offending updates as withdrawn (RFC 7606) instead of resetting, so the session stays Established, the member holds zero routes, and neither side logs an error.rbgp rib advertisedon the route server still shows the routes sent — it reports local send-side state, not what the member accepted. CheckPfxRcdon the member. -
FRR route-maps map naturally to TOML policy definitions for simple match/set chains, or to
.rpolfor reusable hygiene logic. The M80 receipt proves route-for-route parity between.rpoland FRR route-maps for the core import/export pattern. -
neighbor ... addpath-tx-all-pathsmaps to[neighbors.add_path] send = truewith an explicitsend_max.
BIRD
Common BIRD route-server ideas:
protocol bgp member_alpha {
local as 65500;
neighbor 198.51.100.2 as 64501;
rs client;
enforce first as off;
ipv4 {
import filter ixp_import;
export filter ixp_export;
add paths tx;
};
}rustbgpd equivalent:
[[neighbors]]
address = "198.51.100.2"
remote_asn = 64501
route_server_client = true
role = "route_server"
families = ["ipv4_unicast", "ipv6_unicast"]
import_policy_chain = ["ixp-hygiene"]
[neighbors.add_path]
send = true
send_max = 8Notes:
- BIRD's
rs clientmaps toroute_server_client = true. - BIRD's
secondarypath-hiding mitigation maps toper_client_best = truefor non-Add-Path members. - BIRD filter functions map best to
.rpolnamed policies and parameterized policies. Keep prefix/community data in named sets sorbgp policy checkandrbgp policy testcan validate changes before reload.
OpenBGPD
OpenBGPD route-server deployments commonly combine global
transparent-as yes with per-neighbor policy and,
where path hiding must be mitigated,
rde evaluate all. Map those concepts as follows:
- The closest mapping for transparent route-server export is
route_server_client = trueon each member; also setrole = "route_server"so RFC 9234 role negotiation is explicit. The flags are not a complete policy conversion. One important non-equivalence isNO_ADVERTISE: OpenBGPD'stransparent-asdisables its automatic well-known-community filtering, while rustbgpd always enforces RFC 1997NO_ADVERTISE. Audit any site policy that expected that community to pass through the incumbent. - Map inbound and outbound rules to
import_policy_chainandexport_policy_chain; translate the policy itself by hand to.rpol. rde evaluate allmaps toper_client_best = truefor a member that cannot receive Add-Path. Prefer negotiated Add-Path where the member supports it.- Translate authentication, timers, max-prefix limits, and address-family enablement peer by peer. The importer cannot inventory or warn about omitted OpenBGPD-only settings for you.
Validate the completed config with rustbgpd --check --strict, then compare
each member's post-filter view manually as described below.
ARouteServer
ARouteServer-generated configs usually encode member inventory, max-prefix
limits, bogon / hygiene / RPKI policy, route-server transparency, and
path-hiding settings. Most of this does not need hand-migration:
tools/rs-config-render/ renders
rustbgpd config and .rpol filters directly from arouteserver template-context output, keeping the existing general.yml/clients.yml
workflow — the end-to-end walkthrough is
ixp-filter-pipeline.md. The renderer refuses knobs
it cannot map faithfully (see its README); a site relying on those, or
hand-tuned ARouteServer output, follows the manual path:
- Export member inventory to
[[neighbors]]rows. - Convert shared prefix/community lists to
.rpolprefix-set/community-setdeclarations. - Convert import hygiene to a named
.rpolpolicy and keep per-member exceptions as parameters or per-neighbor chain overrides. - Choose one path-hiding mode per member:
- Add-Path-capable members:
[neighbors.add_path] send = true - legacy members:
per_client_best = true
- Add-Path-capable members:
- Run a shadow trial and compare
rbgp rib advertisedoutput against the incumbent route server's BMP/MRT/looking-glass view.
Minimal generated-neighbor target shape:
[[neighbors]]
address = "198.51.100.2"
remote_asn = 64501
description = "member-alpha"
route_server_client = true
role = "route_server"
max_prefixes = 50000
import_policy_chain = ["ixp-hygiene", "member-alpha-in"]
export_policy_chain = ["member-alpha-out"]
per_client_best = trueCapturing the incumbent's advertised view
One snapshot per member, produced on (or from) the incumbent route
server with the bundled adapters (scripts/ribsnap/, stdlib-only
Python 3; adapter contract in docs/how-to/ribdiff.md). All
converters exit 0 with the snapshot on stdout, or 2 (nothing emitted)
when the input is malformed or not the expected form — a truncated or
wrong-form capture can never read as "in sync".
BIRD 2/3 (verified: 2.0.12 and 3.3.1)
birdc show route export <member-protocol> all > bird-<member>.txt
scripts/ribsnap/bird2-export-to-ribsnap.py \
--peer <member-ip> --peer-asn <member-asn> bird-<member>.txt \
> bird-<member>.ndjsonOn BIRD 3, enable export table on on the member channel and capture the
retained post-policy view with birdc show route export table <member-channel> all. The adapter accepts BIRD 3's lowercase bgp_*
attributes while retaining the historical bird2-export/1 source ID.
Preconfigure the option before shadowing or use a maintenance window: changing
it restarts the channel. If a restart is unacceptable, use the on-the-fly
show route export <member-protocol> all view instead; it is less exact.
Prerequisites and limitations:
show route export Pcomputes P's export filters on the fly — no config change needed, but it reflects the table now, not what was actually sent. If the incumbent hasexport table on(an Adj-RIB-Out kept per protocol, at ~one table's memory cost per member), capture that instead (show route export table <member-channel> all) for a true sent-view; the converter accepts both (same text format).- The view is pre-encoding: locally-originated routes carry no
BGP.as_path/BGP.next_hop/BGP.origin— those fields are omitted, never fabricated. A transparent route server neither prepends nor rewrites towardrs clientmembers, so member-learned routes compare fully; if your capture includes RS-originated routes, add--ignore-attributefor the missing fields. BGP.ext_communityis printed symbolically and is skipped (stderr note); compare with--ignore-attribute extended_communities.
FRR (verified: 10.3.1)
vtysh -c "show ip bgp neighbor <member-ip> advertised-routes detail json" \
> frr-<member>.json
scripts/ribsnap/frr-advertised-to-ribsnap.py \
--peer <member-ip> --peer-asn <member-asn> frr-<member>.json \
> frr-<member>.ndjsonPrerequisites and limitations:
- The
detailform is required — the summary form (advertised-routes json) has no community information at all, and the converter refuses it (exit 2) rather than emit a snapshot that can never show a communities difference. Both forms are post-policy (outbound route-map rewrites are reflected; verified on 10.3.1). - The detail form lists every RIB path per advertised prefix, including
paths not advertised to this member; only the
bestpath.overall == truepath is converted. Members withaddpath-tx-all-pathsare not supported by this converter. - The view is pre-prepend / pre-nexthop-rewrite:
aspathexcludes FRR's own ASN and self-originated routes show next hop0.0.0.0(omitted, never fabricated). Towardroute-server-clientmembers there is no prepend or rewrite, so member-learned routes compare fully. - FRR emits
metric: 0whether MED was absent or zero; both convert to an omittedmedbecause the incumbent source cannot distinguish them. extendedCommunityis rendered symbolically and skipped (stderr note).
GoBGP (verified: 3.37.0)
gobgp neighbor <member-ip> adj-out -j > gobgp-<member>.json
scripts/ribsnap/gobgp-adjout-to-ribsnap.py \
--peer <member-ip> --peer-asn <member-asn> gobgp-<member>.json \
> gobgp-<member>.ndjsonPrerequisites and limitations:
adj-outis a true post-policy Adj-RIB-Out (own-ASN prepend and next-hop rewrite included), so no attribute needs to be ignored.- With Add-Path send negotiated,
adj-out -jemits one entry per path but no path identifier (verified on 3.37.0): duplicates become repeated route records. The diff compares multiplicity and never compares path IDs, so this is lossless for the verdict. - Extended communities are rendered structurally and skipped (stderr note).
OpenBGPD (manual post-filter view)
OpenBGPD's documented show rib out view of the
filtered routes sent to one neighbor is:
bgpctl show rib out neighbor <member-ip> detailInspect this per member during the shadow trial. rustbgpd does not ship a
bgpctl output adapter, so this text cannot be passed directly to rbgp diff advertised and must not be relabeled as an rbgp-ribsnap/1 snapshot.
OpenBGPD's generic dump table-v2 is a dump of a named RIB,
not proof of a per-member post-policy Adj-RIB-Out. Likewise, neighbor-scoped
dump updates out records ongoing BGP activity after
capture starts; it is not a complete point-in-time advertised snapshot.
Neither source satisfies
--view adj-rib-out-capture on its own. Use the manual bgpctl view, or an
independently captured source that can genuinely attest to the MRT/BMP
post-policy boundaries below.
MRT dumps
If the incumbent's advertised view exists as an MRT TABLE_DUMP_V2
file, convert it in-binary:
rbgp diff snapshot from-mrt capture.mrt --view adj-rib-out-capture \
--neighbor <member-ip> --neighbor-asn <member-asn> > mrt-<member>.ndjson--view is the honesty gate: TABLE_DUMP_V2 is by default a collector
RIB view, and only a dump you can attest is a per-client post-policy
capture (adj-rib-out-capture) is comparable. --view loc-rib and
--view adj-rib-in are refused with exit 2 — a Loc-RIB compared against
an Adj-RIB-Out reports every export-policy effect as divergence.
BMP (RFC 8671 post-policy Adj-RIB-Out)
If the incumbent supports BMP with the RFC 8671 post-policy Adj-RIB-Out
view, its BMP feed is a wire-true multi-member capture — no per-member
CLI exports, and it carries attributes no CLI view renders (preserved
as unknown_attrs). Point the incumbent's BMP export at a listener,
capture the raw bytes from the start of the BMP session, and stop
only after every member's initial dump has completed:
nc -l 11019 > incumbent.bmp
rbgp diff snapshot from-bmp incumbent.bmp > incumbent.ndjsonPrerequisites and limitations:
- The incumbent must be configured to export the post-policy Adj-RIB-Out monitoring view (O=1/L=1). The default Adj-RIB-In feed is skipped with a note; a pre-policy Adj-RIB-Out feed (L=0) is refused as non-comparable.
- The capture must include the session start (Initiation and Peer Ups
carry the negotiated OPENs that drive Add-Path decoding) and each
member's End-of-RIB. A peer/family without End-of-RIB is an
incomplete dump and the conversion is refused (exit 2) — a
truncated capture must never read as "in sync". Exclude peers you
don't care about with
--neighbor(alias--peer) if they never completed. - Live churn during the capture is fine: updates that interleave with the initial dump supersede it, and post-End-of-RIB stats (RFC 8671 types 15/17) are cross-checked against the folded state.
- Offline only: capture to a file first; the adapter does not read from
a socket. Full contract in
docs/how-to/ribdiff.md.
Example reports
From the M83 multi-stack lab (FRR member AS 65003 advertising three prefixes with MED 55 and community 65003:99 toward the route server at 10.83.3.1). The equal outcome, exit 0:
diff advertised: incumbent "frr-advertised/1 view=adj-rib-out-capture m83-frr-member" (generation 7) vs rustbgpd-grpc (adj-rib-out, advertised)
schema rbgp-ribdiff/1; normalization v1; ignored attributes: as_path, next_hop
live-source notes:
- as_path: the daemon proto exposes a flattened ASN list, so AS_PATH is compared as a single AS_SEQUENCE on both sides; AS_SET structure is not compared
- unknown attributes: path attributes outside the typed set (origin, as_path, next_hop, med, local_pref, communities, extended/large communities) are not visible over gRPC and are not compared
- encode-time attributes: the transport finalizes each UPDATE after the advertised view is computed — the eBGP local-ASN prepend and remove-private-as, the eBGP next-hop rewrite to the local address (default for peers that are not route-server clients, or `set next-hop self`), and the GRACEFUL_SHUTDOWN community added by `rbgp gshut` — so a BMP rib_out_post capture carries them and the live side does not; a `set next-hop <ip>` is stored and visible
- update groups: a peer sharing an update group has no stored per-peer Adj-RIB-Out; its live view is synthesized from the group table minus its own-sourced routes, exact-export rejections, and outbound prefix-limit exclusions
- aggregation: AGGREGATOR and ATOMIC_AGGREGATE are exposed over gRPC but rbgp-ribsnap/1 has no field for them, so the live side drops them; a from-bmp snapshot keeps them in unknown_attrs, which diverges unless --ignore-attribute unknown is passed
- generation: the route-page epoch/generation pair is a process-local consistency fence, compared only across live pages and peers; it is not a RIB snapshot generation and is never compared with the producer-local snapshot header generation
verdict: in_sync
per-peer summary:
10.83.3.1 AS65500 ipv4_unicast: matched 3, incumbent-only 0, rustbgpd-only 0, attribute-changed 0, multiplicity-changed 0The explained-difference outcome, exit 1: rustbgpd rejected 100.68.0.0/24 at import (RPKI-invalid under the lab's VRP set), so the incumbent advertises one route the shadow does not — an expected, explainable divergence during a migration that tightens ROV:
verdict: divergent
per-peer summary:
10.83.3.1 AS65500 ipv4_unicast: matched 2, incumbent-only 1, rustbgpd-only 0, attribute-changed 0, multiplicity-changed 0
differences (1 total, showing 1):
- 10.83.3.1 ipv4_unicast 100.68.0.0/24 [incumbent-only](Header and live-source notes identical to the equal report and elided here.) Divergences you cannot explain from a deliberate policy delta are cutover blockers.
Route-reflector snapshot comparison
rbgp diff snapshots compares two post-policy BMP-derived snapshots,
including ORIGINATOR_ID, CLUSTER_LIST, and NEXT_HOP. The live advertised
query runs before the transport adds the reflection attributes. The offline
command uses the existing RPC-independent comparison engine and snapshot
schema.
Incumbent RR qualification remains outstanding. The pinned FRR 10.7.1 and GoBGP 4.10.0 BMP exporters provide received-route pre/post-policy views and a local-RIB view, without the required RFC 8671 Adj-RIB-Out flag. GoBGP 4.10.0 source inspection confirms that the newer pin still selects received-route and best-path events. These feeds therefore cannot supply this comparison. The synthetic BMP fixtures prove conversion and comparison mechanics only; there is no end-to-end FRR/GoBGP or vendor-RR shadow-trial receipt for this command.
A future qualified capture must meet these conditions:
- Both daemons export BMP version 3 post-policy Adj-RIB-Out (O=1, L=1) toward the same observer address and ASN, with the same families and negotiated Add-Path mode.
- Both see the same settled iBGP routes and policies. The observer does not use the shadow's routes for forwarding.
- rustbgpd's
[global].cluster_idmatches the incumbent's cluster ID for the comparison, so a deliberate cluster-ID difference does not produce a CLUSTER_LIST difference. Keep router IDs distinct. This does not cover ORR-specific selection or normalize cluster IDs. - Each selected family has Peer Up and End-of-RIB in the capture. A late
rustbgpd collector has no automatic outbound reconnect dump; the
complete capture/replay procedure
explains its boundary.
refresh-outscheduling alone is insufficient.
Given captures that meet those conditions, compare the files offline:
if rbgp diff snapshot from-bmp incumbent.bmp --neighbor 192.0.2.30 \
--generation 7 > incumbent.ndjson &&
rbgp diff snapshot from-bmp rustbgpd.bmp --neighbor 192.0.2.30 \
--generation 7 > rustbgpd.ndjson; then
rbgp diff snapshots incumbent.ndjson rustbgpd.ndjson --json > rr-diff.json
echo $? # 0 in sync, 1 divergent, 2 refused
fiAll attributes remain included. In attribute_deltas, unknown entries
with type_code: 9 carry ORIGINATOR_ID and type 10 carries CLUSTER_LIST,
including every value byte. A next-hop change has its own next_hop delta.
A generation label is self-attested; it names a capture round without
proving the routers were synchronized. Preserve both BMP files, converted
snapshots, daemon versions/configurations, and report for qualification.
Cutover checklist
-
Build the candidate config and run
rustbgpd --check --strict. -
Run
rbgp policy checkfor every.rpolfile. -
Shadow-peer the same members with a non-production listener (or
listen_port = 0) so no accidental TCP/179 collision occurs, peering to safe member-session copies where possible. Keeproute_server_client = true,role = "route_server", and the same import/export chains you intend to use after cutover, so the trial exercises the production policy. -
Compare received and advertised views:
rbgp rib recv <member> rbgp rib sent <member> rbgp rib --prefix <prefix> advertised <member> --explainThen run the systematic per-member advertised-view diff: export the incumbent's advertised routes to an
rbgp-ribsnap/1NDJSON snapshot with a bundled BIRD/FRR/GoBGP adapter (below; OpenBGPD is manual unless an independently captured MRT/BMP source satisfies the stated boundary; format details indocs/how-to/ribdiff.md) and compare it against the live Adj-RIB-Out:rbgp diff advertised --against incumbent.ndjson # all snapshot members rbgp diff advertised --neighbor <member> --against incumbent.ndjsonExit code 0 means complete inputs with no semantic differences, 1 means differences (listed in the report), and 2 means the comparison was refused (incomplete, stale, or over-limit input is never treated as equal). Gate each cutover batch on exit code 0 for its members.
-
Validate the path-hiding mitigation per member: for Add-Path members, verify multiple candidate paths are present; for non-Add-Path members, verify
Distribution Mode: per-client-best(rbgp neighbor <member>) and inspect the candidate ladder with--explain. -
Confirm counters stay quiet after convergence:
rbgp policy stats --direction both rbgp metrics | grep -E 'route_refresh|session_state|update_group' -
Generate a support bundle before and after the trial:
rbgp doctor --output ./support-rs-shadow.tar.gz -
Cut member sessions in small batches. Keep the incumbent read-only during the first batch so advertised-view diffs remain available.