rustbgpd

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.

QuestionCommand
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 candidates

With 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-up

The 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 route

This 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=40

In 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 64501

Reason 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 --explain

Each step either answers the ticket or names the exact gate, term, or comparison to look at next.

SurfaceWhat 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 doctorRed/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.

Source on GitHub

On this page