Explain route decisions
Find the rbgp command that explains each stage of a route decision.
Find the rbgp command that explains each stage of a route decision.
Every stage of a route's life through the daemon can explain itself, from the live RIB, in one command — no debug rebuild, no external looking-glass daemon, no packet capture. This page is the catalog: which question you have, which command answers it, and what the answer looks like. Deep semantics live in OPERATIONS.md; this page gets you to the right command.
Every command below also takes --json for scripting and portal
backends.
The TUI exposes one focused part of this catalog: from a selected peer's detail,
press r to open the on-demand route explorer over the global unicast Best
table and the peer's Received, Advertised, and Rejected tables, with an exact
prefix filter. Enter on a Best row, or e with a typed or selected prefix,
explains the export decision for that prefix and peer: advertise/deny, ordered
gates and policy reasons, and modifications, including denials absent from
Adj-RIB-Out. It is not a live RIB feed and does not cover best-path comparison,
VPN, labeled-unicast, or source candidates; use the commands below for the full
catalog.
| Question | Command |
|---|---|
| Why was this path selected as best? | rbgp rib --prefix <cidr> --explain |
| Why did/didn't this prefix go to that peer? | rbgp rib --prefix <cidr> advertised <peer> --explain, or the same answer as rbgp policy explain --neighbor <peer> --prefix <cidr> --direction export |
What did import policy decide for this prefix from that peer? (opt-in: [policy.explain] enabled = true) | rbgp policy explain --neighbor <peer> --prefix <cidr> --direction import |
| Which of a member's routes were filtered, and why? | rbgp rib received <peer> --rejected |
| What would this candidate policy do to the live RIB? | rbgp policy test <file> --policy <name> --direction import |
| Which policy terms are actually firing? | rbgp policy stats --direction both |
| What would this config change touch? | rbgp config diff <candidate.toml> |
For operators coming from FRR/BIRD, the familiar command map translates the usual show-commands into these.
SRv6 service candidates excluded from selection
A route with a semantically unusable applicable SRv6 Service TLV remains in
Adj-RIB-In but is excluded from selection and export. For unicast, run
rbgp rib --prefix <cidr> --explain; for EVPN, use an exact selector such as
rbgp evpn explain ip-prefix --rd <rd> --prefix <cidr>. Both report
srv6_sid_invalid; this is not an import-policy rejection. VPN has no
received-route query. See the operator troubleshooting entry
for the distinction from malformed Prefix-SID handling and the
canonical SRv6 contract.
Best-path explain
Best-path and export explanations infer IPv4 or IPv6 from the exact prefix.
An optional --family must be a matching IPv4/IPv6 unicast alias; conflicting
or unsupported families fail before connecting. The --rd and --labeled
selectors on rib advertised --explain choose VPN and labeled-unicast explanations.
Why did this path win? Every losing candidate is annotated with the
decisive comparison step (only_path, higher_local_pref,
shorter_as_path, lower_origin, lower_med, ... down to
lower_peer_address and the same-peer Add-Path identity tie
lower_path_id) and the compared values behind it, plus its
equal-cost multipath classification. The RFC 4271 §9.1.2.2 step (f)
identifier comparison reports as lower_originator_id when both routes
carried ORIGINATOR_ID and as lower_bgp_identifier when at least one
side was compared by its advertising peer's BGP Identifier (RFC 4456 §9
substitution); it precedes shorter_cluster_list, and a locally
originated route, which has no BGP Identifier, wins it against any
session-learned route (detail bgp_identifier local < ...). A path that
arrived carrying the LLGR_STALE community is least preferred (RFC 9494
§4.3/§4.4) and reports as llgr_stale_community; stale_preference names
local GR or LLGR stale state:
$ rbgp rib --prefix 203.0.113.0/24 --explain
Best-path explanation for 203.0.113.0/24
Best route: peer=10.0.0.2, next_hop=10.0.0.2, as_path=[65002]
Selected: only path for this prefix
No candidatesWith competing paths, the candidate table lists each loser with its
Reason / Detail (e.g. local_pref 100 < 200) and multipath
eligibility. --json returns the same as best_reason,
best_reason_detail, and a candidates[] array.
Scope the explanation to one peer with --explain-peer <addr>. For an
Add-Path sender, candidates the peer would actually receive get their
advertised rank and filtered candidates remain at rank 0. For a peer with a
resolved RFC 9107 ORR vantage, the command instead selects and compares the
best route from that client's vantage and prints the effective ORR vantage:
$ rbgp rib --prefix 203.0.113.0/24 --explain --explain-peer 10.0.0.7
Best-path explanation for 203.0.113.0/24
Scope: peer 10.0.0.7 (Add-Path send_max=0)
ORR vantage: 10.0.0.7
Selected: orr_interior_cost (orr_cost 12 < 40) vs runner-upThe orr_interior_cost reason is emitted only when vantage interior cost
is decisive. Its detail prints both costs; unreachable means the vantage
has no known cost to that next hop and therefore ranks it last. A non-ORR peer,
an unresolved vantage, or an explanation without --explain-peer retains
the ordinary global Loc-RIB ladder.
Details: OPERATIONS.md.
Export explain
Why did (or didn't) this exact prefix reach this exact peer? The answer is produced by a read-only dry run of the same staging body live distribution executes — update groups, split horizon per member, and all — so it cannot drift from what the wire does:
$ rbgp rib --prefix 203.0.113.0/24 advertised 10.0.0.2 --explain
Advertise: 203.0.113.0/24 to 10.0.0.2
Update group: 3 (shared staging; split horizon applied per member)
Route peer: 10.0.0.9
Route type: external
Next hop: 10.0.0.9
Gate ladder (live evaluation order):
[pass] best_route best route was learned from an eBGP peer (Loc-RIB best from 10.0.0.9)
[pass] split_horizon route did not originate from the target peer
[pass] rr_reflection iBGP split-horizon / RFC 4456 reflection rules permit this route
[pass] family peer negotiated ipv4 unicast
[pass] llgr route is not LLGR-stale
[n/a ] orf peer installed no Outbound Route Filter
[pass] export_policy export policy "chain_default_permit" permitted this route
[n/a ] otc RFC 9234 OTC egress suppression does not apply to this local role
[pass] adj_rib_out prefix not yet advertised to this peer — would announce
Reasons:
- ebgp_route: best route was learned from an eBGP peer
- policy_permitted: export policy "chain_default_permit" permitted this routeThis is the ladder for a plain single-best peer (no ORR vantage, Add-Path
send, or per-client best); addresses and the group ID are illustrative. The
rung set and order depend on the peer's selection shape. An ORR, Add-Path, or
per-client-best peer runs family and orf first, then best_route for its
own candidate, then split_horizon, rr_reflection, llgr, export_policy,
otc, and adj_rib_out. Rungs that only appear when they stop a route —
no_advertise, no_export, rs_control, and the Add-Path add_path_send_max
limit — sit between llgr and otc. A denial shows [STOP] at the gate that
held the route back, with per-term policy attribution for .rpol chains.
--rd <rd> explains the VPNv4/VPNv6 (RD, prefix) ladder including the RFC 4684
RT-Constrain membership gate; --labeled explains the RFC 8277
labeled-unicast ladder. Rung-by-rung semantics:
OPERATIONS.md.
When RFC 8212 enforcement ([global] ebgp_requires_policy) is on and this
eBGP peer has no explicit operator export policy, the ladder stops at
export_policy with the rfc8212_missing_export_policy code rather than
policy_denied: the route was not rejected by a policy, there is no policy.
The import side is labeled the same way — rbgp policy explain annotates the
reserved chain's default-action line. Neither name can belong to an operator
policy; both are refused at config load. See
CONFIGURATION.md.
For a negotiated unicast Add-Path send peer, add the paired
--source-peer <addr> --source-path-id <id> flags to answer the same question
for one exact Adj-RIB-In candidate (including inbound ID 0). The source
identity is echoed separately from the outbound path_id: RFC 7911 requires
the re-advertiser to assign its own ID, so the latter is the compact eligible,
policy-permitted rank. It stays 0 before ranking or beyond send_max; an OTC
or exact-wire denial after ranking retains the attempted rank. Omit the flags
for the legacy winner-oriented explanation.
ORR client differences
For an ORR client, the advertised-route explanation ranks the same split-horizon/RR-eligible candidates as live distribution, using that target's resolved vantage:
$ rbgp rib --prefix 203.0.113.0/24 advertised 10.0.0.7 --explain
ORR vantage: 10.0.0.7
ORR candidates (per-vantage best first):
- 192.0.2.2 next-hop 10.0.2.1 cost=12 (selected)
- 192.0.2.1 next-hop 10.0.1.1 cost=40In JSON, orr_vantage identifies the applied vantage,
orr_candidates contains the per-vantage ranking, the best_route gate
uses code orr_vantage_best, and the reasons include
orr_interior_cost when cost broke the tie. With
orr_vantage = "peer_address" on a dynamic-neighbor range, each client
resolves to its own address; different clients can therefore select different
winners for the same prefix. Run the advertised explanation once per client,
then use --explain-peer <client> for the candidate-by-candidate comparison.
Import explain
What did import policy decide when this prefix arrived from this peer — including paths that were denied and are therefore not in the RIB anymore?
This one is opt-in. It is backed by a bounded decision cache held per session, so its memory cost multiplies by peer count, and a stock daemon does not pay it. Add to the config, reload, and let the session re-establish:
[policy.explain]
enabled = true$ rbgp policy explain --neighbor 10.0.0.2 --prefix 10.10.1.0/24 --direction import
import policy explain — peer 10.0.0.2 prefix 10.10.1.0/24 (policy generation 3)
permit
decision: no policy rejected; chain default permit
statements:
[0] policy customer-in(200) term customer-routes permit match: guard route.prefix in customers set: local_pref 100 -> 200
term rpki-guard: route.rpki == invalid => reject [not matched]
term customer-routes: route.prefix in customers => set local-pref 200; accept [matched]For a Permit, chain_default_permit means a nonempty chain completed without
any member rejecting the route; it is not the name of the last member. An
absent or genuinely empty chain remains inline. JSON and protobuf responses
retain the machine-stable matched_policy: "chain_default_permit"; a Deny
always keeps its actual named member (including an operator policy that happens
to use that spelling), or inline for an anonymous member.
Outcomes are permit / deny / withdrawn / not_seen / evicted
/ stale; a disabled cache or missing session errors distinctly
instead of pretending not_seen — against a daemon that has not
enabled the cache, rbgp policy explain names the two config lines
above and exits nonzero. --path-id narrows to one Add-Path identity.
cache_size (default 4096) is per session, and both settings are
global — there is no per-peer or per-group override. At the default
size retention is partial-table: a peer announcing more than 4096
distinct prefixes keeps the cache saturated, so a query for an
arbitrary prefix of theirs answers evicted rather than a decision
(never not_seen: every evicted key is remembered until session reset). The
answer ends with the session's eviction count and cache_size.
Budget roughly
sum over nonempty peer caches (~1 KiB + min(max(1, cache_size), recorded decisions) × ~600 B);
the index grows with entries. The minimal first-insert probe requested
1,428 heap bytes; actual memory depends on attributes and allocator.
Add about 19 B per evicted key (27–34 B
with a nonzero Add-Path identifier), capped at 2,097,152 keys per session
(about 38 MB, or up to about 72 MB for nonzero Add-Path identifiers).
The cache_size ceiling is 2,097,152, and zero acts as one. Raise it
toward a peer's retained decision count when you need fuller answers.
Details:
CONFIGURATION.md
and
OPERATIONS.md.
Filtered routes
The route-server member-support question — "you're eating my
routes, which ones and why?" — without knowing a prefix in advance.
The daemon retains rejected inbound routes per session
([policy.reject_retention], bounded LRU) with a canonical reason
token:
$ rbgp rib received 198.51.100.2 --rejected
Prefix PathId Reason Detail Next Hop RPKI ASPA AS Path
-----------------------------------------------------------------------------------------------------------------------------------
203.0.113.0/24 0 policy_reject member-import 198.51.100.2 invalid unknown 64500 64501Reason tokens: policy_reject, otc_route_leak,
next_hop_ownership, as_path_loop, rr_loop,
treat_as_withdraw. The same rows back a looking glass via the
birdwatcher adapter,
which maps each token to an Alice-LG-matchable large community. The
member-support workflow around this view is in the
route-server cookbook.
The support-ticket workflow
"Member AS64500 says their prefix isn't visible." In order:
# 1. Is the session even up, and are we retaining anything from them?
rbgp summary
rbgp rib received 198.51.100.2 --rejected
# 2. Rejected with a reason token? Get the statement-level why.
rbgp policy explain --neighbor 198.51.100.2 --prefix 203.0.113.0/24 --direction import
# 3. If RPKI drove the decision, inspect the current complete-table verdict
# and its bounded effective covering-VRP evidence.
rbgp rpki validate 203.0.113.0/24 64501
# 4. Accepted but another member doesn't see it? Walk the export ladder
# toward that member (either spelling; the second needs no config).
rbgp rib --prefix 203.0.113.0/24 advertised 198.51.100.7 --explain
rbgp policy explain --neighbor 198.51.100.7 --prefix 203.0.113.0/24 --direction export
# 5. Accepted but lost best-path selection? See what beat it.
rbgp rib --prefix 203.0.113.0/24 --explainEach step either answers the ticket or names the exact gate, term, or comparison to look at next.
Related introspection surfaces
| Surface | What it answers |
|---|---|
rbgp policy test <file> --policy <p> --direction <d> | Read-only dry run of a candidate policy against the live RIB: accepted/rejected/modified counts, term hits, per-attribute before/after diffs (rpol-language.md) |
rbgp policy check <file> | Offline parse/typecheck plus the file's in-language test blocks — no daemon needed |
rbgp policy stats --direction import|export|both (alias counters) | Live per-term hit counters: which policy terms actually fire |
rbgp config diff <candidate> / rbgp config plan <candidate> | What a config change would touch, each field annotated hot-applied / session reset / restart required (OPERATIONS.md) |
rbgp diff advertised --against <snapshot> | Live Adj-RIB-Out vs a recorded snapshot — the shadow-cutover gate (ribdiff.md) |
rbgp doctor | Red/green triage checks plus a redacted support bundle (OPERATIONS.md) |
Check ASPA evidence
For an ASPA rejection shown by rbgp rib received PEER --rejected, use
rbgp rpki aspa CUSTOMER_ASN to inspect the reported customer's merged
provider set. To check the path against today's data, run
rbgp rpki verify-path --role ROLE --neighbor-asn ASN "AS_PATH" with the
receiving speaker's local role and effective neighbor ASN. This recomputes a
hypothetical eBGP-unicast verdict; it does not replay the historical import
snapshot or the complete import-policy decision. The
API contract describes
limits and the difference between unavailable and empty data.