Monitoring · python · 41 copies
BGP Neighbor State Monitor
Tracks BGP session state, flaps and prefix counts per peer and per VRF across all eight platforms. Counts flaps over a rolling time window rather than a lifetime total, so a peer that flapped twice last year reads as healthy while twice in the last hour is flagged unstable. Distinguishes a peer that is down from one that has never come up since monitoring began, and a down session from one Established with zero prefixes. Persists history atomically, supports webhook and syslog alerting, and returns a non-zero exit code for cron.
- Arista EOS
- FortiOS
- Cisco IOS
- Cisco IOS-XE
- Cisco IOS-XR
- Junos
- Cisco NX-OS
- PAN-OS
pip install "netmiko>=4.3" "PyYAML>=6.0" "requests>=2.28" "tabulate>=0.9"#!/usr/bin/env python3
"""
BGP Neighbor State Monitor
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/bgp-neighbor-monitor
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" "requests>=2.28" "tabulate>=0.9"
"""
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 logging
import logging.handlers
import re
import time
try:
import requests
except ImportError:
requests = None
try:
from tabulate import tabulate
except ImportError:
tabulate = None
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
BGP_CMDS = {
"ios": {"global": "show ip bgp summary",
"vrf": "show ip bgp vpnv4 vrf {vrf} summary"},
"iosxe": {"global": "show ip bgp summary",
"vrf": "show ip bgp vpnv4 vrf {vrf} summary"},
"iosxr": {"global": "show bgp summary",
"vrf": "show bgp vrf {vrf} summary"},
"nxos": {"global": "show bgp summary",
"vrf": "show bgp vrf {vrf} summary"},
"junos": {"global": "show bgp summary",
"vrf": "show bgp summary instance {vrf}"},
"eos": {"global": "show ip bgp summary",
"vrf": "show ip bgp vrf {vrf} summary"},
"panos": {"global": "show routing protocol bgp peer",
"vrf": "show routing protocol bgp peer virtual-router {vrf}"},
"fortios": {"global": "get router info bgp summary",
"vrf": "get router info bgp summary vrf {vrf}"},
}
IPV4 = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
# Every FSM state that is not Established, spelled as each platform prints it.
FSM_STATES = {"idle", "active", "connect", "opensent", "openconfirm",
"established", "estab", "establ"}
def parse_bgp_summary(output: str, os_key: str, vrf: str) -> list[dict]:
"""
Return [{peer, state, prefixes, vrf}].
The last column of a BGP summary is State/PfxRcd: a number when the session
is Established, an FSM state word otherwise. `prefixes` is None for a peer
that is not Established, so "down" is never confused with "up and sending
zero prefixes" - those mean completely different things.
"""
peers: list[dict] = []
if os_key == "junos":
# Peer AS InPkt OutPkt OutQ Flaps Last-Up/Dwn State|#Active/Received/...
for line in output.splitlines():
parts = line.split()
if len(parts) < 7 or not IPV4.match(parts[0]):
continue
tail = parts[-1]
m = re.match(r"(\d+)/(\d+)/(\d+)/(\d+)", tail)
if m:
peers.append({"peer": parts[0], "state": "Established",
"prefixes": int(m.group(2)), "vrf": vrf,
"flaps_device": _int_or_none(parts, 5)})
else:
peers.append({"peer": parts[0], "state": tail, "prefixes": None,
"vrf": vrf, "flaps_device": _int_or_none(parts, 5)})
return peers
if os_key == "panos":
for line in output.splitlines():
parts = line.split()
if len(parts) < 3 or not IPV4.match(parts[0]):
continue
state = next((p for p in parts if p.lower() in FSM_STATES), "unknown")
nums = [int(p) for p in parts if p.isdigit()]
established = state.lower() == "established"
peers.append({"peer": parts[0],
"state": "Established" if established else state,
"prefixes": (nums[-1] if nums else None) if established else None,
"vrf": vrf, "flaps_device": None})
return peers
if os_key == "eos":
for line in output.splitlines():
parts = line.split()
if len(parts) < 8 or not IPV4.match(parts[0]):
continue
state = next((p for p in parts if p.lower() in FSM_STATES), None)
if state and state.lower().startswith("estab"):
pfx = int(parts[-1]) if parts[-1].isdigit() else None
peers.append({"peer": parts[0], "state": "Established",
"prefixes": pfx, "vrf": vrf, "flaps_device": None})
elif state:
peers.append({"peer": parts[0], "state": state, "prefixes": None,
"vrf": vrf, "flaps_device": None})
return peers
# IOS / IOS-XE / IOS-XR / NX-OS / FortiOS
for line in output.splitlines():
parts = line.split()
if len(parts) < 8 or not IPV4.match(parts[0]):
continue
last = parts[-1]
if last.isdigit():
peers.append({"peer": parts[0], "state": "Established",
"prefixes": int(last), "vrf": vrf, "flaps_device": None})
else:
peers.append({"peer": parts[0], "state": last, "prefixes": None,
"vrf": vrf, "flaps_device": None})
return peers
def _int_or_none(parts: list[str], idx: int) -> int | None:
try:
return int(parts[idx])
except (IndexError, ValueError):
return None
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
def load_state(path: str) -> dict:
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except FileNotFoundError:
return {"poll_count": 0, "peers": {}}
except json.JSONDecodeError as exc:
raise SystemExit(f"State file {path} is corrupt: {exc}\n"
f"Delete it to start tracking again from scratch.")
def save_state(state: dict, path: str) -> None:
tmp = Path(path).with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
tmp.replace(path) # atomic, so a crash cannot truncate the file
def update_peer(state: dict, host: str, p: dict, args) -> list[str]:
"""
Fold one observation into the tracked history and return any alerts.
Called single-threaded after every worker has finished. Mutating this dict
from inside the workers would be a data race on a shared structure.
"""
key = f"{host}|{p['vrf']}|{p['peer']}"
now = time.time()
up = p["state"] == "Established"
e = state["peers"].setdefault(key, {
"host": host, "peer": p["peer"], "vrf": p["vrf"],
"total_polls": 0, "established_polls": 0,
"flaps": [], "last_state": None, "last_prefixes": None,
"first_seen": now, "ever_established": False,
})
alerts: list[str] = []
scope = "global" if p["vrf"] == "global" else f"vrf:{p['vrf']}"
if e["last_state"] == "Established" and not up:
e["flaps"].append(now)
alerts.append(f"FLAP {host} [{scope}] peer {p['peer']} went "
f"Established -> {p['state']}")
elif not up:
if e["ever_established"]:
alerts.append(f"DOWN {host} [{scope}] peer {p['peer']} is {p['state']}")
else:
alerts.append(f"NEVERUP {host} [{scope}] peer {p['peer']} is {p['state']} "
f"and has never reached Established since monitoring began")
if up and e["last_prefixes"] and p["prefixes"] is not None and e["last_prefixes"] > 0:
drop = (e["last_prefixes"] - p["prefixes"]) / e["last_prefixes"]
if drop > args.drop_pct:
alerts.append(f"DROP {host} [{scope}] peer {p['peer']} prefixes "
f"{e['last_prefixes']:,} -> {p['prefixes']:,} "
f"({drop*100:.1f}% down)")
# Flap rate over a window beats a lifetime counter: a peer that flapped
# twice a year ago is healthy; twice in the last hour is not.
window = now - args.flap_window
e["flaps"] = [t for t in e["flaps"] if t >= window]
if len(e["flaps"]) >= args.flap_threshold:
alerts.append(f"UNSTABLE {host} [{scope}] peer {p['peer']} flapped "
f"{len(e['flaps'])} time(s) in the last "
f"{int(args.flap_window/60)} minutes")
e["total_polls"] += 1
if up:
e["established_polls"] += 1
e["ever_established"] = True
e["last_state"] = p["state"]
if p["prefixes"] is not None:
e["last_prefixes"] = p["prefixes"]
return alerts
# ---------------------------------------------------------------------------
# Polling
# ---------------------------------------------------------------------------
def poll_device(dev: dict, args) -> dict:
"""Collect only. State is updated single-threaded by the caller."""
host = dev["host"]
os_key = dev["os"]
cmds = BGP_CMDS[os_key]
conn = None
peers: list[dict] = []
try:
conn = connect(dev)
for vrf in [None] + list(dev.get("vrfs", [])):
scope = vrf or "global"
cmd = cmds["global"] if vrf is None else cmds["vrf"].format(vrf=vrf)
out = conn.send_command(cmd, read_timeout=args.read_timeout)
peers.extend(parse_bgp_summary(out, os_key, scope))
return {"host": host, "os": os_key, "status": "ok", "peers": peers}
except NetmikoAuthenticationException as exc:
return {"host": host, "os": os_key, "status": "error", "peers": [],
"error": f"authentication failed - check NET_USER/NET_PASS ({exc})"}
except NetmikoTimeoutException as exc:
return {"host": host, "os": os_key, "status": "error", "peers": [],
"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", "peers": [],
"error": f"{type(exc).__name__}: {exc}"}
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
def send_webhook(alerts: list[str], url: str) -> None:
if not url or not alerts or requests is None:
return
try:
requests.post(url, json={"text": "*BGP alerts*\n" + "\n".join(alerts)}, timeout=5)
except Exception as exc: # noqa: BLE001 - a failed webhook must not stop monitoring
print(f"[WARN] webhook failed: {exc}")
def run_once(devices: list[dict], state: dict, args, syslog_logger) -> int:
state["poll_count"] = state.get("poll_count", 0) + 1
print(f"\nPolling BGP on {len(devices)} device(s) [poll #{state['poll_count']}]")
print("=" * 88)
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(poll_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", "peers": [],
"error": f"worker crashed: {exc}"})
# Single-threaded from here: shared state and ordered output.
all_alerts: list[str] = []
rows = []
for r in sorted(results, key=lambda x: x["host"]):
if r["status"] == "error":
print(f"[ERROR ] {r['host']:<22} {r['error']}")
continue
for p in sorted(r["peers"], key=lambda x: (x["vrf"], x["peer"])):
alerts = update_peer(state, r["host"], p, args)
all_alerts.extend(alerts)
key = f"{r['host']}|{p['vrf']}|{p['peer']}"
e = state["peers"][key]
uptime = (e["established_polls"] / e["total_polls"] * 100
if e["total_polls"] else 0.0)
scope = "[global]" if p["vrf"] == "global" else f"[vrf:{p['vrf']}]"
pfx = f"{p['prefixes']:,}" if p["prefixes"] is not None else "-"
if alerts:
for a in alerts:
print(f"[{a.split()[0]:<7}] {r['host']:<22} {scope:<14} "
f"peer {p['peer']:<16} {a.split(' ', 1)[1]}")
else:
print(f"[OK ] {r['host']:<22} {scope:<14} peer {p['peer']:<16} "
f"{p['state']} {pfx:>10} prefixes uptime {uptime:.1f}%")
rows.append([p["peer"], r["host"], p["vrf"], p["state"], pfx,
f"{uptime:.1f}%", len(e["flaps"])])
if rows and args.summary:
hdrs = ["Peer", "Device", "VRF", "State", "Prefixes", "Uptime", "Flaps"]
print("\nPeer summary:")
if tabulate:
print(tabulate(rows, headers=hdrs, tablefmt="simple"))
else:
print(" ".join(h.ljust(14) for h in hdrs))
for row in rows:
print(" ".join(str(c).ljust(14) for c in row))
save_state(state, args.state_file)
print("=" * 88)
kinds = {}
for a in all_alerts:
kinds[a.split()[0]] = kinds.get(a.split()[0], 0) + 1
errs = sum(1 for r in results if r["status"] == "error")
if kinds or errs:
print(" " + " ".join(f"{k}={v}" for k, v in sorted(kinds.items()))
+ (f" unreachable={errs}" if errs else ""))
else:
total = sum(len(r["peers"]) for r in results)
print(f" All {total} peer(s) Established across {len(results)} device(s).")
if all_alerts:
send_webhook(all_alerts, args.webhook)
if syslog_logger:
for a in all_alerts:
syslog_logger.warning(a)
return 1 if (all_alerts or errs) else 0
def main() -> int:
ap = argparse.ArgumentParser(
description="Monitor BGP neighbor state, flaps and prefix counts.",
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 devices to poll")
ap.add_argument("--daemon", action="store_true",
help="Run continuously instead of polling once and exiting")
ap.add_argument("--interval", type=int, default=60,
help="Seconds between polls in daemon mode. Default: 60")
ap.add_argument("--drop-pct", type=float, default=0.10,
help="Alert when prefixes fall this fraction between polls. "
"Default: 0.10 (10 percent)")
ap.add_argument("--flap-window", type=int, default=3600,
help="Seconds over which flaps are counted. Default: 3600")
ap.add_argument("--flap-threshold", type=int, default=3,
help="Flaps within the window before a peer is called unstable. "
"Default: 3")
ap.add_argument("--state-file", default="bgp_monitor_state.json",
help="Where peer history persists between runs")
ap.add_argument("--webhook", default="",
help="Slack or Teams incoming webhook URL for alerts")
ap.add_argument("--syslog", action="store_true", help="Also write alerts to syslog")
ap.add_argument("--summary", action="store_true",
help="Print a table of every peer after the per-peer lines")
ap.add_argument("--limit", help="Only poll hosts whose name contains this text")
ap.add_argument("--read-timeout", type=int, default=90,
help="Seconds to wait per command. Default: 90")
ap.add_argument("--workers", type=int, default=10,
help="How many devices to poll at once. Default: 10")
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
syslog_logger = None
if args.syslog:
syslog_logger = logging.getLogger("bgp_monitor")
syslog_logger.setLevel(logging.WARNING)
try:
syslog_logger.addHandler(logging.handlers.SysLogHandler(address="/dev/log"))
except OSError:
syslog_logger.addHandler(logging.StreamHandler())
state = load_state(args.state_file)
print(f"Tracking {len(state.get('peers', {}))} peer(s) from {args.state_file}")
if not args.daemon:
return run_once(devices, state, args, syslog_logger)
while True:
run_once(devices, state, args, syslog_logger)
print(f"\nNext poll in {args.interval}s. Ctrl-C to stop.")
time.sleep(args.interval)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nStopped. State was saved after the last completed poll.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredPath to your devices.yaml file. Lists which devices to poll, each with a host and an os field. Add vrfs per device for VRF-aware polling. See the output example below for the full format.
--daemonboolRun continuously instead of polling once and exiting.
--intervalnumberSeconds between polls in daemon mode. Default: 60
--drop-pctnumberAlert when the prefix count falls this fraction between consecutive polls. Default: 0.10, meaning a 10 percent drop.
--flap-windownumberSeconds over which flaps are counted. Older flaps age out. Default: 3600
--flap-thresholdnumberHow many flaps inside the window before a peer is reported unstable. Default: 3
--state-filetextWhere peer history persists between runs. Written atomically so a crash cannot truncate it. Default: bgp_monitor_state.json
--webhooktextSlack or Teams incoming webhook URL. Alerts are posted there as well as printed.
--syslogboolAlso write alerts to local syslog, for pickup by your SIEM.
--summaryboolPrint a table of every peer with state, prefixes, uptime and flap count after the per-peer lines.
--limittextOnly poll hosts whose name contains this text.
--read-timeoutnumberSeconds to wait per command. Default: 90
--workersnumberHow many devices to poll at once. Default: 10
What This Solves
`show ip bgp summary` tells you what is true right now. Most BGP problems are not about right now. A peer that is Established at the moment you look, and was down twice in the last hour, is the one causing the intermittent complaints. A peer carrying 400,000 prefixes where it carried 940,000 an hour ago is still "up" and is quietly black-holing half the internet. A peer that has never come up since you configured it looks identical, in a state check, to one that went down a minute ago - and they need completely different work. This polls every peer on every device and tracks what changed: Flaps in a rolling window, not a lifetime counter. A peer that flapped twice last year reads as healthy. Twice in the last hour is reported unstable. The window and the threshold are yours to set. Prefix count drops between polls. A sharp fall while the session stays up is one of the most damaging and least visible BGP failures. Never-up versus went-down. Distinguished explicitly, because the first is a configuration problem and the second is an operational one. Run it once for a snapshot, or as a daemon with alerts to Slack, Teams or syslog.
Is It Safe to Run
Yes. Strictly read-only on the device. It sends one `show` command per device, plus one per declared VRF, and nothing else. It never enters configuration mode and never writes anything to a device. ios, iosxe show ip bgp summary show ip bgp vpnv4 vrf {vrf} summary and the equivalent per-platform summary elsewhere On your own machine it writes one file: the state file (default `bgp_monitor_state.json`), which holds the peer history between runs. **It is written atomically** - to a temporary file and then renamed - so a crash or a kill mid-write cannot leave you with a truncated state file that loses the history. Two things worth knowing before you point it at production: Daemon mode is a long-lived SSH client. With --daemon it reconnects every --interval seconds, to every device, forever. On a fleet of 200 with a 60-second interval that is a constant login load, and TACACS usually notices before the devices do. Raise --interval and use --limit. A webhook failure never stops monitoring. If Slack is unreachable the script prints a warning and carries on. Alerting that takes down the monitor is worse than no alerting.
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 `show ip bgp summary`. 4. Understand that **the first poll establishes the baseline.** Flaps and prefix drops are differences between polls, so the first run can only report current state. Everything interesting starts on the second.
Your First Run
Create devices.yaml with one router: devices: - host: 192.168.1.1 os: ios Then poll once: export NET_USER='admin' export NET_PASS='your-password' python bgp_monitor.py --inventory devices.yaml --summary --summary prints the full table of peers: state, prefix count, uptime percentage and flap count. On a first run the uptime will read 100% and the flap count 0 for everything, because there is no history yet - that is expected. Run it a second time and the numbers start meaning something. Then leave it running: python bgp_monitor.py --inventory devices.yaml --daemon --interval 300 And when you trust it, add alerting: python bgp_monitor.py --inventory devices.yaml --daemon --syslog \ --webhook https://hooks.slack.com/services/XXX/YYY/ZZZ
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python bgp_monitor.py --inventory devices.yaml --daemon Tracking 11 peer(s) from bgp_monitor_state.json Polling BGP on 4 device(s) [poll #37] ======================================================================================== [OK ] rtr-edge-01 [global] peer 203.0.113.1 Established 948,221 prefixes uptime 99.8% [OK ] rtr-core-01 [vrf:CORP] peer 10.0.0.9 Established 1,840 prefixes uptime 100.0% [FLAP ] rtr-dist-02 [global] peer 10.0.0.21 went Established -> Idle [UNSTABLE] rtr-dist-02 [global] peer 10.0.0.21 flapped 4 time(s) in the last 60 minutes [DROP ] rtr-edge-02 [global] peer 198.51.100.9 prefixes 940,112 -> 402,338 (57.2% down) The five alert types, and what each one actually means: FLAP The peer was Established at the previous poll and is not now. A single event. One flap is noise; it is the pattern that matters, which is what UNSTABLE is for. UNSTABLE More than --flap-threshold flaps inside --flap-window. **This is the alert to act on.** It is the difference between "a peer bounced" and "a peer is bouncing", and it is why this script keeps state instead of just reading the summary. DOWN Not Established, and it has been Established before. An operational problem - something broke. NEVERUP Not Established, and it has never been Established since monitoring began. Almost always a configuration problem: wrong remote-as, wrong source interface, a missing route to the peer, or an ACL. Chasing this as an outage wastes an hour. DROP The session is up and the prefix count fell by more than --drop-pct between two consecutive polls. The default is 0.30, meaning 30%. A peer at 57% down while Established is usually an upstream filter change, a route-map mistake, or a max-prefix threshold being approached. uptime 99.8% The fraction of polls in which this peer was Established, since the state file started. Not a time-based SLA - it is poll-based, so its resolution is your --interval. [vrf:CORP] / [global] Which table the peer is in. Peers are tracked per VRF, so the same neighbour address in two VRFs is two separate histories.
Exit Codes for Cron and CI
0 no alerts and every device answered 1 at least one alert, OR a device could not be reached 130 you pressed Ctrl-C In --daemon mode the script does not exit, so the codes apply to a single-poll run - which is the better fit for cron: */5 * * * * cd /opt/netops && python bgp_monitor.py \ --inventory devices.yaml \ --state-file /var/lib/netops/bgp_state.json Use cron **or** --daemon, not both: two processes sharing one state file will each overwrite the other's history. If you run it from cron, give --state-file an absolute path, because the history is the entire value of the tool and a relative path will follow the working directory.
The State File
Default `bgp_monitor_state.json` in the working directory. It holds, per peer: the last state, whether it has ever been Established, the last prefix count, the timestamps of recent flaps, and the poll counts behind the uptime percentage. Three consequences worth knowing: Delete it and you lose the history. Not the monitoring - the next poll rebuilds a baseline - but every peer reverts to 100% uptime, zero flaps, and "never up" for anything currently down. Keep it somewhere that is backed up. It is written atomically. A crash cannot truncate it. Flaps age out of it. Only events inside --flap-window are kept, so the file does not grow without limit and an old flap stops counting against a peer.
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs - host: rtr-core.example.com os: iosxr port: 2222 # optional, default 22 vrfs: [CORP, MGMT] # optional: also poll these VRFs 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: ios port: 22 devices: - host: 192.168.1.1 - host: 192.168.1.2 - host: rtr-core.example.com os: iosxr # overrides the default 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 older routers, older Catalyst switches iosxe Cisco IOS-XE Catalyst 9000, ISR/ASR, Catalyst 8000 iosxr Cisco IOS-XR ASR 9000, NCS, service provider gear nxos Cisco NX-OS Nexus data center switches junos Juniper Junos MX, EX, QFX, SRX eos Arista EOS all Arista switches panos Palo Alto PAN-OS PA firewalls, VM-Series fortios Fortinet FortiOS FortiGate firewalls All eight are supported, each with its own summary command and per-VRF form. "cisco" is a vendor, not an OS - use ios, iosxe, iosxr or nxos.
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. For a daemon that runs for weeks, that matters more than usual. 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 For a systemd unit or a cron job, put them in an environment file with restricted permissions - not in the unit or crontab line, which other users can read.
Every Flag
--inventory PATH REQUIRED. The devices. --daemon Poll continuously instead of once. --interval N Seconds between polls in daemon mode. Default 60. Raise it on a large fleet - this is a full SSH login per device per poll. --drop-pct F Alert when the prefix count falls by this fraction between consecutive polls. Default 0.30 (30%). --flap-window N Seconds over which flaps are counted. Older ones age out. Default 3600. --flap-threshold N Flaps inside the window before a peer is reported UNSTABLE. Default 3. --state-file PATH Where the history lives. Use an absolute path under cron. --webhook URL Slack or Teams incoming webhook. Alerts are posted there as well as printed. A failure warns and does not stop monitoring. --syslog Also write alerts to local syslog, for a SIEM to pick up. --summary Print a table of every peer with state, prefixes, uptime and flap count after the poll. --limit TEXT Only hosts whose name contains TEXT. --read-timeout N Seconds per command. Default 90. --workers N Devices at once. Default 10.
When Something Fails
"Set NET_USER and NET_PASS" Not set in this terminal, or not in the daemon's environment. "authentication failed" Wrong credentials, or the account lacks access. Verify by hand: ssh admin@192.168.1.1 "unreachable or SSH timed out" No answer on the SSH port. A device that does not answer is not polled, so its peers keep their previous state - they are not reported down. Every peer shows 100% uptime and 0 flaps No history yet. Either this is the first poll, or the state file was deleted or is not where you think it is. Check --state-file, especially if you are running from cron with a relative path. NEVERUP on a peer you know used to work "Never" means since this state file started. A fresh state file reports every currently-down peer as NEVERUP. If you just lost the history, that is the explanation. Constant FLAP alerts with no UNSTABLE Flaps below the threshold inside the window. Either the peer really is bouncing occasionally, or --flap-window is too short to see the pattern. Lengthen the window before lowering the threshold. A DROP alert you believe is normal Some peers legitimately swing - a customer withdrawing a block, a peer that only advertises during a maintenance. Raise --drop-pct rather than ignoring the alert type, so a real 90% drop still reaches you. Prefix counts show as missing Not every platform reports a prefix count for every peer state. The DROP check only runs when both the previous and the current count are present, so a missing count means no comparison, not a drop. The daemon is hammering TACACS Raise --interval and lower --workers. A 60-second interval against 200 devices is 200 logins a minute.