Troubleshooting · python · 39 copies
BGP Route & Path Selection Analyzer
Explains which BGP path wins for a prefix and exactly which step of the decision process chose it. Walks all ten steps in order — weight, local-pref, AS-path length, origin, MED, eBGP over iBGP, IGP metric, router-id, neighbor address — and names the step that actually narrowed the field to one, rather than the first attribute that happened to differ. Queries several routers at once and reports when they disagree on the best exit, which is the answer to why traffic from one site takes a different path than another.
- Arista EOS
- FortiOS
- Cisco IOS
- Cisco IOS-XE
- Cisco IOS-XR
- Junos
- Cisco NX-OS
pip install "netmiko>=4.3" "PyYAML>=6.0"#!/usr/bin/env python3
"""
BGP Route & Path Selection Analyzer
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/bgp-route-analyzer
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "netmiko>=4.3" "PyYAML>=6.0"
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
import re
from collections import defaultdict
# ---------------------------------------------------------------------------
# Inventory loading and connection
# ---------------------------------------------------------------------------
NETMIKO_DRIVER = {
"ios": "cisco_ios",
"iosxe": "cisco_ios",
"iosxr": "cisco_xr",
"nxos": "cisco_nxos",
"junos": "juniper_junos",
"eos": "arista_eos",
"panos": "paloalto_panos",
"fortios": "fortinet",
}
def load_inventory(path: str) -> list[dict]:
"""
Read devices.yaml (or .json) and return a list of device dicts.
Applies the `defaults:` block to every device, validates the `os` field,
and fails with a readable message rather than a stack trace.
"""
p = Path(path)
if not p.exists():
raise SystemExit(
f"Inventory file not found: {path}\n"
f"Create it with:\n\n"
f" devices:\n"
f" - host: 192.168.1.1\n"
f" os: ios\n"
)
raw = p.read_text(encoding="utf-8")
try:
if p.suffix.lower() in (".yaml", ".yml"):
if yaml is None:
raise SystemExit("PyYAML is not installed. Run: pip install PyYAML")
data = yaml.safe_load(raw)
else:
data = json.loads(raw)
except SystemExit:
raise
except Exception as exc:
raise SystemExit(f"Could not parse {path}: {exc}\n"
f"Check for tabs (use spaces) and missing spaces after colons.")
if isinstance(data, dict):
defaults = data.get("defaults", {}) or {}
devices = data.get("devices", []) or []
else:
defaults, devices = {}, data
if not devices:
raise SystemExit(f"No devices found in {path}. The file needs a `devices:` list.")
out = []
for i, d in enumerate(devices, 1):
if not isinstance(d, dict):
raise SystemExit(f"Device #{i} in {path} is not a mapping. "
f"Each entry needs at least `host:` and `os:`.")
merged = {**defaults, **d}
if "host" not in merged:
raise SystemExit(f"Device #{i} in {path} has no `host:` field.")
os_key = merged.get("os")
if not os_key:
raise SystemExit(f"Device '{merged['host']}' has no `os:` field. "
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}")
if os_key not in NETMIKO_DRIVER:
raise SystemExit(
f"Device '{merged['host']}' has os '{os_key}', which is not supported.\n"
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}\n"
f"Note: 'cisco' is a vendor, not an OS - use ios, iosxe, iosxr, or nxos."
)
merged.setdefault("vrfs", [])
out.append(merged)
return out
def credentials() -> tuple[str, str, str]:
"""Read credentials from the environment and fail clearly if missing."""
user = os.environ.get("NET_USER", "")
pwd = os.environ.get("NET_PASS", "")
enable = os.environ.get("NET_ENABLE", "")
if not user or not pwd:
raise SystemExit(
"Set NET_USER and NET_PASS in your environment before running.\n\n"
" Linux/macOS: export NET_USER='admin'\n"
" export NET_PASS='your-password'\n\n"
" PowerShell: $env:NET_USER = 'admin'\n"
" $env:NET_PASS = 'your-password'\n"
)
return user, pwd, enable
def connect(dev: dict):
"""Open an SSH session to one device using netmiko."""
user, pwd, enable = credentials()
params = {
"device_type": NETMIKO_DRIVER[dev["os"]],
"host": dev["host"],
"username": dev.get("username") or user,
"password": dev.get("password") or pwd,
"port": dev.get("port", 22),
"conn_timeout": 20,
"fast_cli": False,
}
secret = dev.get("secret") or enable
if secret:
params["secret"] = secret
conn = ConnectHandler(**params)
if secret and dev["os"] in ("ios", "iosxe", "nxos"):
conn.enable()
return conn
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
PREFIX_CMDS = {
"ios": "show ip bgp {prefix}",
"iosxe": "show ip bgp {prefix}",
"iosxr": "show bgp ipv4 unicast {prefix}",
"nxos": "show bgp ipv4 unicast {prefix}",
"junos": "show route {prefix} detail protocol bgp",
"eos": "show ip bgp {prefix}",
"fortios": "get router info bgp network {prefix}",
}
VRF_PREFIX_CMDS = {
"ios": "show ip bgp vpnv4 vrf {vrf} {prefix}",
"iosxe": "show ip bgp vpnv4 vrf {vrf} {prefix}",
"iosxr": "show bgp vrf {vrf} ipv4 unicast {prefix}",
"nxos": "show bgp vrf {vrf} ipv4 unicast {prefix}",
"junos": "show route table {vrf}.inet.0 {prefix} detail protocol bgp",
"eos": "show ip bgp {prefix} vrf {vrf}",
"fortios": "get router info bgp network {prefix}",
}
NO_BGP = {"panos": "PAN-OS exposes BGP paths only through its XML API"}
# The BGP decision process, in the order the RFC and every vendor applies it.
# Each entry returns a sort key where LOWER wins, so one comparison works for
# attributes where higher is better (weight, local-pref) and where lower is
# better (AS-path length, MED).
DECISION_STEPS = [
("WEIGHT", lambda p: -p.get("weight", 0),
"highest weight (Cisco-proprietary, local to this router)"),
("LOCAL_PREF", lambda p: -p.get("locpref", 100),
"highest local-preference"),
("LOCAL_ORIG", lambda p: 0 if p.get("local_origin") else 1,
"locally originated route preferred"),
("AS_PATH", lambda p: _aspath_len(p),
"shortest AS-PATH"),
("ORIGIN", lambda p: {"i": 0, "igp": 0, "e": 1, "egp": 1}.get(
str(p.get("origin", "?")).lower(), 2),
"best origin code (IGP < EGP < Incomplete)"),
("MED", lambda p: p.get("metric", 0),
"lowest MED"),
("EBGP_IBGP", lambda p: 1 if p.get("ibgp") else 0,
"eBGP preferred over iBGP"),
("IGP_METRIC", lambda p: p.get("igp_metric", 0),
"lowest IGP metric to the next-hop"),
("ROUTER_ID", lambda p: _ip_key(p.get("router_id", "255.255.255.255")),
"lowest BGP router-id"),
("NEIGHBOR_IP", lambda p: _ip_key(p.get("nexthop", "255.255.255.255")),
"lowest neighbor address"),
]
def _aspath_len(p: dict) -> int:
ap = (p.get("aspath") or "").strip()
if not ap or ap.lower() in ("local", "i", "?"):
return 0
return len([t for t in ap.split() if t.isdigit()])
def _ip_key(ip: str) -> tuple:
try:
return tuple(int(o) for o in ip.split("."))
except (ValueError, AttributeError):
return (255, 255, 255, 255)
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse_cisco(output: str) -> list[dict]:
"""Parse IOS / IOS-XE / NX-OS / EOS `show ip bgp <prefix>` output."""
paths: list[dict] = []
cur: dict | None = None
for line in output.splitlines():
# The AS-path line starts a new path block; it is the first line after
# " Refresh Epoch N" or follows the previous block's attributes.
m = re.match(r"^\s{2}([\d\s]*|Local)\s*$", line)
if m and m.group(1).strip():
if cur:
paths.append(cur)
ap = m.group(1).strip()
cur = {"aspath": "" if ap == "Local" else ap,
"local_origin": ap == "Local",
"weight": 0, "locpref": 100, "metric": 0,
"origin": "?", "best": False, "ibgp": False,
"communities": [], "nexthop": "", "router_id": ""}
continue
if cur is None:
continue
m = re.search(r"^\s+(\d{1,3}(?:\.\d{1,3}){3})\s+from\s+"
r"(\d{1,3}(?:\.\d{1,3}){3})\s+\(([\d.]+)\)", line)
if m:
cur["nexthop"] = m.group(1)
cur["peer"] = m.group(2)
cur["router_id"] = m.group(3)
continue
m = re.search(r"Origin (\S+?),", line)
if m:
cur["origin"] = {"IGP": "i", "EGP": "e"}.get(m.group(1), "?")
m = re.search(r"metric (\d+)", line)
if m:
cur["metric"] = int(m.group(1))
m = re.search(r"localpref (\d+)", line)
if m:
cur["locpref"] = int(m.group(1))
m = re.search(r"weight (\d+)", line)
if m:
cur["weight"] = int(m.group(1))
if "internal" in line:
cur["ibgp"] = True
if re.search(r"\bbest\b", line):
cur["best"] = True
m = re.search(r"Community:\s*(.+)", line)
if m:
cur["communities"].extend(
re.findall(r"\d+:\d+|no-export|no-advertise|local-AS", m.group(1)))
if cur:
paths.append(cur)
return paths
def parse_iosxr(output: str) -> list[dict]:
paths: list[dict] = []
cur: dict | None = None
for line in output.splitlines():
m = re.match(r"\s+Next hop:\s+(\S+)", line)
if m:
if cur:
paths.append(cur)
cur = {"nexthop": m.group(1), "locpref": 100, "weight": 0, "metric": 0,
"aspath": "", "origin": "?", "best": False, "ibgp": False,
"local_origin": False, "communities": [], "router_id": ""}
continue
if cur is None:
continue
for pat, key, cast in (
(r"Local pref:\s+(\d+)", "locpref", int),
(r"MED:\s+(\d+)", "metric", int),
(r"Weight:\s+(\d+)", "weight", int),
(r"AS-path:\s+(.*)", "aspath", str),
(r"Origin:\s+(\S+)", "origin", str),
(r"Router ID:\s+(\S+)", "router_id", str),
):
m = re.search(pat, line)
if m:
cur[key] = cast(m.group(1).strip())
if "best path" in line.lower() or re.search(r"\bbest\b", line):
cur["best"] = True
if "internal" in line.lower():
cur["ibgp"] = True
m = re.search(r"Community:\s*(.+)", line)
if m:
cur["communities"].extend(
re.findall(r"\d+:\d+|no-export|no-advertise", m.group(1)))
if cur:
paths.append(cur)
return paths
def parse_junos(output: str) -> list[dict]:
paths: list[dict] = []
cur: dict | None = None
for line in output.splitlines():
m = re.search(r"Next hop:\s+(\d{1,3}(?:\.\d{1,3}){3})", line)
if m:
if cur:
paths.append(cur)
cur = {"nexthop": m.group(1), "locpref": 100, "weight": 0, "metric": 0,
"aspath": "", "origin": "?", "best": False, "ibgp": False,
"local_origin": False, "communities": [], "router_id": ""}
continue
if cur is None:
continue
m = re.search(r"Localpref:\s+(\d+)", line)
if m:
cur["locpref"] = int(m.group(1))
m = re.search(r"Metric:\s+(\d+)", line)
if m:
cur["metric"] = int(m.group(1))
m = re.search(r"AS path:\s+(.*?)(?:\s+I|\s+E|\s+\?|$)", line)
if m:
cur["aspath"] = m.group(1).strip()
if re.search(r"AS path:.*\bI\b", line):
cur["origin"] = "i"
elif re.search(r"AS path:.*\bE\b", line):
cur["origin"] = "e"
m = re.search(r"Communities:\s*(.+)", line)
if m:
cur["communities"].extend(re.findall(r"\d+:\d+|no-export", m.group(1)))
if "Active" in line and "Inactive" not in line:
cur["best"] = True
m = re.search(r"Peer ID:\s+(\S+)", line)
if m:
cur["router_id"] = m.group(1)
if cur:
paths.append(cur)
return paths
# ---------------------------------------------------------------------------
# Decision process
# ---------------------------------------------------------------------------
def rank_paths(paths: list[dict]) -> tuple[list[dict], str]:
"""
Walk the BGP decision process and record which step eliminated each path.
Returns (paths, deciding_step). The deciding step is the LAST comparison
that narrowed the field - the one that actually chose the winner. Reporting
the first elimination instead would name a step that merely removed an
also-ran.
"""
if len(paths) <= 1:
if paths:
paths[0]["best"] = True
return paths, "only one path available"
for p in paths:
p["best"] = False
p.pop("eliminated_by", None)
survivors = list(range(len(paths)))
deciding = "no attribute differed - selection fell through to a tiebreaker"
for name, key_fn, reason in DECISION_STEPS:
if len(survivors) <= 1:
break
try:
scores = {i: key_fn(paths[i]) for i in survivors}
except Exception: # noqa: BLE001 - a missing attribute must not abort the walk
continue
best_val = min(scores.values())
kept = [i for i in survivors if scores[i] == best_val]
if len(kept) < len(survivors):
for i in survivors:
if i not in kept:
paths[i]["eliminated_by"] = f"{name}: {reason}"
deciding = f"{name}: {reason}"
survivors = kept
if survivors:
paths[survivors[0]]["best"] = True
return paths, deciding
# ---------------------------------------------------------------------------
# Per-device analysis
# ---------------------------------------------------------------------------
def analyse_device(dev: dict, args) -> dict:
host = dev["host"]
os_key = dev["os"]
conn = None
if os_key in NO_BGP:
return {"host": host, "os": os_key, "status": "skip", "note": NO_BGP[os_key]}
try:
conn = connect(dev)
table = VRF_PREFIX_CMDS if args.vrf else PREFIX_CMDS
cmd = table[os_key].format(prefix=args.prefix, vrf=args.vrf or "")
raw = conn.send_command(cmd, read_timeout=args.read_timeout)
if os_key == "iosxr":
paths = parse_iosxr(raw)
elif os_key == "junos":
paths = parse_junos(raw)
else:
paths = parse_cisco(raw)
if not paths:
return {"host": host, "os": os_key, "status": "nopath",
"note": f"no BGP path for {args.prefix}", "raw": raw[:600]}
paths, deciding = rank_paths(paths)
best = next((p for p in paths if p.get("best")), None)
return {"host": host, "os": os_key, "status": "ok", "paths": paths,
"deciding": deciding,
"best_nexthop": best.get("nexthop") if best else "",
"best_aspath": (best.get("aspath") or "local") if best else ""}
except NetmikoAuthenticationException as exc:
return {"host": host, "os": os_key, "status": "error",
"error": f"authentication failed - check NET_USER/NET_PASS ({exc})"}
except NetmikoTimeoutException as exc:
return {"host": host, "os": os_key, "status": "error",
"error": f"unreachable or SSH timed out ({exc})"}
except Exception as exc: # noqa: BLE001 - one device must not abort the run
return {"host": host, "os": os_key, "status": "error",
"error": f"{type(exc).__name__}: {exc}"}
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
def main() -> int:
ap = argparse.ArgumentParser(
description="Explain which BGP path wins for a prefix, and why.",
epilog="Credentials come from NET_USER and NET_PASS. See the docstring at "
"the top of this file for the full inventory format.",
)
ap.add_argument("--inventory", required=True,
help="Path to devices.yaml listing which routers to query")
ap.add_argument("--prefix", required=True,
help="The prefix to analyse, e.g. 203.0.113.0/24")
ap.add_argument("--vrf", help="Analyse inside this VRF instead of the global table")
ap.add_argument("--limit", help="Only query hosts whose name contains this text")
ap.add_argument("--show-all-paths", action="store_true",
help="Print every path with its attributes, not just the winner")
ap.add_argument("--read-timeout", type=int, default=60,
help="Seconds to wait per command. Default: 60")
ap.add_argument("--workers", type=int, default=10,
help="How many routers to query at once. Default: 10")
ap.add_argument("--json-out", help="Write machine-readable results here")
args = ap.parse_args()
devices = load_inventory(args.inventory)
if args.limit:
devices = [d for d in devices if args.limit in d["host"]]
if not devices:
raise SystemExit(f"No device name contains '{args.limit}'.")
credentials() # fail fast before opening any connection
scope = f" in VRF {args.vrf}" if args.vrf else ""
print(f"Analysing {args.prefix}{scope} on {len(devices)} router(s)...")
print("=" * 84)
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(analyse_device, d, args): d["host"] for d in devices}
for fut in as_completed(futures):
host = futures[fut]
try:
results.append(fut.result())
except Exception as exc: # noqa: BLE001
results.append({"host": host, "os": "?", "status": "error",
"error": f"worker crashed: {exc}"})
for r in sorted(results, key=lambda x: x["host"]):
label = f"{r['host']} ({r['os']})"
if r["status"] == "error":
print(f"[ERROR] {label:<26} {r['error']}")
continue
if r["status"] == "skip":
print(f"[SKIP] {label:<26} {r['note']}")
continue
if r["status"] == "nopath":
print(f"[NONE] {label:<26} {r['note']}")
continue
n = len(r["paths"])
print(f"\n{label} - {n} path(s)")
print(f" WINNER next-hop {r['best_nexthop']} via AS-path "
f"{r['best_aspath'] or 'local'}")
print(f" DECIDED BY {r['deciding']}")
if args.show_all_paths:
for i, p in enumerate(r["paths"], 1):
mark = "BEST" if p.get("best") else " "
print(f" [{mark}] path {i}: nh {p.get('nexthop', '?'):<16} "
f"w={p.get('weight', 0)} lp={p.get('locpref', 100)} "
f"as={p.get('aspath') or 'local'} "
f"origin={p.get('origin', '?')} med={p.get('metric', 0)}"
+ (" iBGP" if p.get("ibgp") else ""))
if p.get("communities"):
print(f" communities: {' '.join(p['communities'])}")
if p.get("eliminated_by"):
print(f" eliminated by {p['eliminated_by']}")
# Different routers choosing different exits is the finding that matters
# when you are asking why traffic from site A takes a different path.
live = [r for r in results if r["status"] == "ok"]
exits = defaultdict(list)
for r in live:
exits[r["best_nexthop"]].append(r["host"])
print("\n" + "=" * 84)
if len(exits) > 1:
print(f"ROUTERS DISAGREE: {len(exits)} different best next-hops for {args.prefix}")
for nh, hosts in sorted(exits.items()):
print(f" {nh:<18} chosen by {', '.join(sorted(hosts))}")
print("\nThat is expected when the routers sit at different points in the\n"
"topology, and a problem when they should be symmetric.")
elif live:
nh = next(iter(exits))
print(f"All {len(live)} router(s) agree: best next-hop is {nh}")
if args.json_out:
Path(args.json_out).write_text(json.dumps(results, indent=2), encoding="utf-8")
errs = sum(1 for r in results if r["status"] == "error")
return 1 if errs else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredPath to your devices.yaml file. Lists which routers to query, each with a host and an os field. See the output example below for the full format and every valid os value.
--prefixtextrequiredThe prefix to analyse, e.g. 203.0.113.0/24. Use the exact form the platform expects.
--vrftextAnalyse the prefix inside this VRF instead of the global table.
--limittextOnly query hosts whose name contains this text.
--show-all-pathsboolPrint every path with its full attribute set and the step that eliminated it, not just the winner.
--read-timeoutnumberSeconds to wait per command. Default: 60
--workersnumberHow many routers to query at once. Default: 10
--json-outtextWrite machine-readable results to this file.
What This Solves
"Why is traffic going that way?" The router will show you the paths. It will mark one `>` as best. It will not tell you **which step of the decision process** chose it, and that is the only thing you actually need to know, because it tells you which knob to turn. If local-preference decided it, changing the MED does nothing - MED is never reached. If it came down to router-id, you have two genuinely equal paths and the fix is a deliberate tie-break, not an accident of which router has the higher loopback. This asks every router in the inventory about one prefix, walks all ten steps of the decision process in order, and names the step that actually narrowed the field to one. With --show-all-paths it also tells you which step eliminated each losing path. Run it when traffic takes a path you did not expect, before a policy change to see what you are actually competing with, and after one to confirm the step you intended is the step that decided.
Is It Safe to Run
Yes. Strictly read-only. One show command per router, two if you use --vrf. It never enters configuration mode and never writes to a device. ios, iosxe, eos show ip bgp <prefix> iosxr show bgp ipv4 unicast <prefix> nxos show bgp ipv4 unicast <prefix> junos show route <prefix> detail protocol bgp fortios get router info bgp network <prefix> With --vrf the per-VRF form is used - `show ip bgp vpnv4 vrf <vrf> <prefix>` on IOS and IOS-XE, and the equivalent elsewhere. **Query one prefix, not the table.** This asks about a single prefix by design, so the output is small and the device barely notices. It is safe to run against a production edge router during an incident. PAN-OS is not supported: it exposes BGP paths only through its XML API, not the CLI. That is reported rather than silently returning nothing. On your own machine it writes only with --json-out.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "netmiko>=4.3" "PyYAML>=6.0" 3. SSH access with enough privilege for the BGP show commands. 4. Know the exact prefix. The script passes it to the platform verbatim, so use the form the device uses - `203.0.113.0/24`. Asking about a host address inside the prefix works on some platforms and not others; ask about the prefix itself.
Your First Run
Create devices.yaml with the routers that should know about the prefix: devices: - host: 192.168.1.1 os: iosxe - host: 192.168.1.2 os: iosxe Then: export NET_USER='admin' export NET_PASS='your-password' python bgp_route_analyzer.py --inventory devices.yaml \ --prefix 203.0.113.0/24 That gives you the winner and the deciding step per router. Then add the detail: ... --show-all-paths which prints every path with its full attribute set and the step that eliminated it. **That is the version to use while troubleshooting** - the losing paths and why they lost are the actual answer. Inside a VRF: ... --vrf CORP
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python bgp_route_analyzer.py --inventory devices.yaml --prefix 203.0.113.0/24 --show-all-paths Analysing 203.0.113.0/24 on 3 router(s)... ==================================================================================== rtr-edge-01 (iosxe) - 3 path(s) WINNER next-hop 10.0.0.1 via AS-path 65100 65200 DECIDED BY LOCAL_PREF: highest local-preference [BEST] path 1: nh 10.0.0.1 w=0 lp=200 as=65100 65200 origin=i med=0 [ ] path 2: nh 10.0.0.5 w=0 lp=100 as=65300 origin=i med=0 eliminated by LOCAL_PREF: highest local-preference [ ] path 3: nh 10.0.0.9 w=0 lp=100 as=65400 65200 origin=i med=50 iBGP eliminated by LOCAL_PREF: highest local-preference rtr-edge-02 (iosxe) - 3 path(s) WINNER next-hop 10.0.0.5 via AS-path 65300 DECIDED BY IGP_METRIC: lowest IGP metric to the next-hop Reading it: DECIDED BY LOCAL_PREF **The most useful line in the output.** It is the step that narrowed the field to one. Everything below that step in the list was never evaluated - so in this example, the AS-path lengths and the MEDs on those paths are irrelevant. Changing them changes nothing. eliminated by LOCAL_PREF Per losing path, the step that knocked it out. Note that path 2 has the *shortest* AS-path here and still lost, because local-preference comes first. That is the shape of most "why isn't it using the better path" questions. Two routers deciding differently rtr-edge-01 decided on LOCAL_PREF and rtr-edge-02 on IGP_METRIC. That is normal and it is why the script queries several routers: best-path is a per-router decision, and an asymmetry in your network is usually two routers reaching different conclusions about the same prefix. iBGP Marked on paths learned over iBGP, because eBGP-over-iBGP is itself a decision step. The ten steps, in the order they are applied: 1 WEIGHT highest. Cisco-proprietary and local to the router, so it never leaves the box - which is exactly why it is useful for a local override and dangerous as a fleet-wide policy 2 LOCAL_PREF highest. The usual way policy is expressed inside an AS, and the step that decides most questions 3 LOCAL_ORIG locally originated preferred 4 AS_PATH shortest 5 ORIGIN IGP < EGP < incomplete 6 MED lowest. Only compared between paths from the same neighbouring AS by default 7 EBGP_OVER_IBGP 8 IGP_METRIC lowest metric to the next-hop. This is the one that makes two routers in the same AS choose differently 9 ROUTER_ID lowest. Reaching this step means the paths are genuinely equal 10 NEIGHBOR_ADDR lowest If the deciding step is ROUTER_ID or NEIGHBOR_ADDR, nothing about the paths differs in any way you configured. The selection is an accident of addressing - fix it with an intentional tie-break, not by hoping.
Exit Codes for Cron and CI
0 every router answered 1 at least one router could not be queried 130 you pressed Ctrl-C The exit code reflects whether the **query** worked, not what it found. There is no such thing as a "bad" best-path for this script to alarm on - it is a troubleshooting tool, not a monitor. For alerting on prefix counts and leaks, use `bgp-prefix-monitor`; for session state and flaps, `bgp-neighbor-monitor`.
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: iosxe # REQUIRED: which OS it runs - host: rtr-core.example.com os: iosxr port: 2222 # optional, default 22 device_name: Core 1 # optional label for the output This script uses the shared inventory loader, so a `defaults:` block applies to every device below it unless that device overrides it: defaults: os: iosxe port: 22 devices: - host: 192.168.1.1 - host: 192.168.1.2 - host: rtr-core.example.com os: iosxr # overrides the default Put every router that should have an opinion about the prefix in the file. Comparing their answers is most of the value. Credentials never go in this file. YAML rules that trip people up: * Indentation must be SPACES, never tabs. * The "- " before host marks a new list item. Keep it aligned. * A space after the colon is required. host: 192.168.1.1 correct host:192.168.1.1 WRONG - YAML parse error
All Valid OS Values
ios Cisco IOS show ip bgp iosxe Cisco IOS-XE show ip bgp iosxr Cisco IOS-XR show bgp ipv4 unicast nxos Cisco NX-OS show bgp ipv4 unicast junos Juniper Junos show route detail protocol bgp eos Arista EOS show ip bgp fortios Fortinet FortiOS get router info bgp network panos Palo Alto PAN-OS NOT SUPPORTED - CLI exposes no paths "cisco" is a vendor, not an OS. Use ios, iosxe, iosxr or nxos. Attribute coverage differs by platform: weight is Cisco-only, and not every platform prints every attribute for every path. A missing attribute is treated as its default (weight 0, local-pref 100), which is what the router itself does.
Credentials
From the environment - never from a file, never from an argument. An argument is visible to every user on the machine in `ps auxww` while the script runs, and it lands in your shell history. NET_USER username required NET_PASS password required NET_ENABLE enable secret optional, falls back to NET_PASS Linux or macOS: export NET_USER='admin' export NET_PASS='your-password' export NET_ENABLE='your-enable-secret' Windows PowerShell: $env:NET_USER = 'admin' $env:NET_PASS = 'your-password' Windows Command Prompt: set NET_USER=admin set NET_PASS=your-password
Every Flag
--inventory PATH REQUIRED. The routers to ask. --prefix CIDR REQUIRED. One prefix, in the form the platform uses: 203.0.113.0/24. --vrf NAME Analyse inside this VRF instead of the global table. --show-all-paths Print every path with its full attribute set and the step that eliminated it. Use this while troubleshooting - the losing paths are the answer. --limit TEXT Only hosts whose name contains TEXT. --read-timeout N Seconds per command. Default 60. --workers N Routers at once. Default 10. --json-out PATH Machine-readable results.
When Something Fails
"Set NET_USER and NET_PASS" Not set in this terminal. See Credentials. "authentication failed" Wrong credentials, or the account lacks access. The BGP show commands usually need privileged exec - set NET_ENABLE. A router reports 0 paths It does not have the prefix. That is a finding, not an error - usually a filter inbound from its neighbour, or the prefix was never advertised to it. Check from the neighbour's side. PAN-OS reports not supported Correct. PAN-OS exposes BGP paths only through the XML API. Query it there, or compare from the Cisco/Juniper side of the session. The deciding step is WEIGHT and you did not set a weight Weight is local to one router and does not propagate, so it is often set long ago in a route-map nobody remembers. Look at the inbound policy on that specific router, not at the fleet. The deciding step is MED and you expected it to be ignored MED is only compared between paths from the same neighbouring AS unless `always-compare-med` is on. If MED decided it, those paths came from the same AS - or somebody turned that knob. The deciding step is ROUTER_ID or NEIGHBOR_ADDR The paths are genuinely equal in everything you configured. Do not "fix" this by changing a router-id; set the policy you actually want with local-preference or weight. Two routers choose different paths Expected, and usually the point. Compare their DECIDED BY lines - if one says IGP_METRIC, the difference is in the underlay, not in BGP policy. The prefix exists but the output looks empty Check the prefix form. The script sends it verbatim; a mask the platform does not expect returns nothing rather than erroring.