Monitoring · python · 18 copies
OSPF Neighbor State Checker
Checks OSPF adjacencies across your fleet and explains what each stuck state means. Detects neighbors in EXSTART, EXCHANGE, INIT and other faulty states, and — by declaring expected_neighbors per device — also detects adjacencies that disappeared entirely, which a state check alone cannot see. Accepts 2WAY as normal between DR-OTHER routers unless you ask otherwise. VRF-aware on IOS-XR, NX-OS, EOS and Junos. Returns a non-zero exit code so it works as a cron or monitoring check.
- 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
"""
OSPF Neighbor State Checker
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/ospf-neighbor-check
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
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# OSPF neighbor commands and parsing
# ---------------------------------------------------------------------------
COMMAND_MAP = {
"ios": {"global": "show ip ospf neighbor"},
"iosxe": {"global": "show ip ospf neighbor"},
"nxos": {"global": "show ip ospf neighbors",
"vrf_fmt": "show ip ospf neighbors vrf {vrf}"},
"iosxr": {"global": "show ospf neighbor",
"vrf_fmt": "show ospf vrf {vrf} neighbor"},
"junos": {"global": "show ospf neighbor",
"vrf_fmt": "show ospf neighbor instance {vrf}"},
"eos": {"global": "show ip ospf neighbor",
"vrf_fmt": "show ip ospf neighbor vrf {vrf}"},
"fortios": {"global": "get router info ospf neighbor"},
# PAN-OS runs OSPF but exposes it only through the XML API, not a parseable
# CLI table. Those devices are reported as skipped rather than guessed at.
}
# A healthy adjacency is FULL. On a broadcast segment, DR-OTHER routers settle
# at 2WAY with each other by design - that is not a fault, so it is only
# flagged when --strict-2way is used.
HEALTHY = {"FULL"}
BENIGN = {"2WAY"}
IPV4 = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
def parse_neighbors(output: str, os_key: str) -> list[tuple[str, str, str]]:
"""
Return a list of (neighbor_id, state, interface).
Column layouts differ per platform:
IOS/XE/XR/NX-OS/EOS Neighbor-ID Pri State Dead Address Interface
JunOS Address Interface State ID Pri Dead
FortiOS Neighbor-ID Pri State Dead Address Interface
"""
out: list[tuple[str, str, str]] = []
for line in output.splitlines():
parts = line.split()
if len(parts) < 3 or not IPV4.match(parts[0]):
continue
if os_key == "junos":
# Address Interface State ID Pri Dead -> state is column 3
state = parts[2].upper()
iface = parts[1]
nbr = parts[3] if len(parts) > 3 and IPV4.match(parts[3]) else parts[0]
else:
# Neighbor-ID Pri State Dead Address Interface -> state is column 3,
# often written FULL/DR or FULL/BDR; the role after the slash is not
# a health signal.
state = parts[2].split("/")[0].upper()
iface = parts[-1]
nbr = parts[0]
out.append((nbr, state, iface))
return out
# ---------------------------------------------------------------------------
# Per-device check
# ---------------------------------------------------------------------------
def check_device(dev: dict, args) -> dict:
host = dev["host"]
os_key = dev["os"]
cmds = COMMAND_MAP.get(os_key)
conn = None
if not cmds:
return {"host": host, "os": os_key, "status": "skip",
"note": f"OSPF neighbor table is not readable over CLI on {os_key}"}
expected = dev.get("expected_neighbors")
issues: list[str] = []
neighbors: list[tuple[str, str, str]] = []
try:
conn = connect(dev)
contexts = [("global", cmds["global"])]
vrf_fmt = cmds.get("vrf_fmt")
for vrf in dev.get("vrfs", []):
if vrf_fmt:
contexts.append((vrf, vrf_fmt.format(vrf=vrf)))
for ctx, cmd in contexts:
raw = conn.send_command(cmd, read_timeout=args.read_timeout)
found = parse_neighbors(raw, os_key)
neighbors.extend(found)
where = "" if ctx == "global" else f" (VRF {ctx})"
for nbr, state, iface in found:
if state in HEALTHY:
continue
if state in BENIGN and not args.strict_2way:
continue
issues.append(f"{nbr} on {iface} is {state}{where}")
# A neighbor stuck in EXSTART is visible. A neighbor that disappeared
# entirely is not - unless you declare how many you expect.
if expected is not None and len(neighbors) < int(expected):
issues.append(
f"only {len(neighbors)} neighbor(s) present, expected {expected} "
f"- {int(expected) - len(neighbors)} adjacency is missing entirely"
)
return {"host": host, "os": os_key, "status": "ok",
"total": len(neighbors),
"full": sum(1 for _, s, _ in neighbors if s in HEALTHY),
"expected": expected, "issues": issues,
"neighbors": [{"id": n, "state": s, "interface": i}
for n, s, i in neighbors]}
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
# ---------------------------------------------------------------------------
# What each stuck state actually means
# ---------------------------------------------------------------------------
STATE_HELP = {
"DOWN": "no hellos received - check the interface, VLAN, and that OSPF is enabled on it",
"ATTEMPT": "NBMA only - hellos sent to a configured neighbor with no reply",
"INIT": "hellos received but ours are not reaching the peer - one-way. Check an ACL or an MTU mismatch",
"2WAY": "normal between DR-OTHER routers on a broadcast segment; a fault only if this pair should be adjacent",
"EXSTART": "classic MTU mismatch between the two interfaces, or a duplicate router-id",
"EXCHANGE": "DBD exchange stalled - almost always MTU, sometimes an ACL dropping large packets",
"LOADING": "LSA request stalled - usually a corrupt or oversized LSA",
}
def explain(issues: list[str]) -> list[str]:
"""Attach the likely cause to each stuck state, so the output is actionable."""
notes = []
for state, help_text in STATE_HELP.items():
if any(f"is {state}" in i for i in issues):
notes.append(f"{state}: {help_text}")
return notes
def main() -> int:
ap = argparse.ArgumentParser(
description="Check OSPF neighbor adjacencies across many devices.",
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 check")
ap.add_argument("--limit", help="Only check hosts whose name contains this text")
ap.add_argument("--strict-2way", action="store_true",
help="Treat 2WAY as a fault. By default it is accepted, because "
"DR-OTHER routers on a broadcast segment sit at 2WAY by design")
ap.add_argument("--show-all", action="store_true",
help="List every neighbor, not just the ones with problems")
ap.add_argument("--read-timeout", type=int, default=60,
help="Seconds to wait for each command. Default: 60")
ap.add_argument("--workers", type=int, default=10,
help="How many devices to contact 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
print(f"Checking OSPF neighbors on {len(devices)} device(s)...")
print("=" * 74)
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(check_device, d, args): d["host"] for d in devices}
for fut in as_completed(futures):
host = futures[fut]
try:
r = fut.result()
except Exception as exc: # noqa: BLE001
r = {"host": host, "os": "?", "status": "error",
"error": f"worker crashed: {exc}"}
results.append(r)
for r in sorted(results, key=lambda x: x["host"]):
label = f"{r['host']} ({r['os']})"
if r["status"] == "error":
print(f"[ERROR] {label:<28} {r['error']}")
elif r["status"] == "skip":
print(f"[SKIP] {label:<28} {r['note']}")
elif r["issues"]:
exp = f"/{r['expected']} expected" if r.get("expected") else ""
print(f"[WARN] {label:<28} {r['full']}/{r['total']}{exp} FULL")
for i in r["issues"]:
print(f" - {i}")
else:
print(f"[OK] {label:<28} {r['total']} neighbor(s), all FULL")
if args.show_all and r.get("neighbors"):
for n in r["neighbors"]:
print(f" {n['id']:<17} {n['state']:<10} {n['interface']}")
all_issues = [i for r in results for i in r.get("issues", [])]
notes = explain(all_issues)
print("=" * 74)
ok = sum(1 for r in results if r["status"] == "ok" and not r["issues"])
warn = sum(1 for r in results if r["status"] == "ok" and r["issues"])
err = sum(1 for r in results if r["status"] == "error")
print(f"{ok} healthy, {warn} with issues, {err} unreachable "
f"(of {len(devices)} device(s)).")
if notes:
print("\nWhat these states mean:")
for n in notes:
print(f" {n}")
if args.json_out:
Path(args.json_out).write_text(json.dumps(results, indent=2), encoding="utf-8")
return 1 if (warn or err) 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 devices to check, each with a host and an os field. Add expected_neighbors per device to also detect adjacencies that disappeared. See the output example below for the full format.
--limittextOnly check hosts whose name contains this text.
--strict-2wayboolTreat 2WAY as a fault. Off by default: DR-OTHER routers on a broadcast segment sit at 2WAY by design and flagging them produces constant false alarms.
--show-allboolList every neighbor with its state and interface, not just the ones with problems.
--read-timeoutnumberSeconds to wait for each command. Default: 60
--workersnumberHow many devices to contact at once. Default: 10
--json-outtextWrite machine-readable results to this file, for dashboards or alerting.
What This Solves
An OSPF adjacency that is stuck tells you exactly what is wrong, if you know how to read it. EXSTART is an MTU mismatch or a duplicate router-id. INIT means your hellos are not reaching the other side. EXCHANGE means the DBD exchange stalled, which is almost always MTU again. Most people know this and still spend twenty minutes remembering which state means which, on five routers, with a different command on each platform. This checks every adjacency in the fleet and prints the diagnosis next to the fault. And it catches the one thing a state check cannot see on its own: An adjacency that vanished completely. A neighbour stuck in EXSTART is visible. A neighbour that is gone leaves nothing behind to look at - the table is just shorter. By declaring `expected_neighbors` on a device, the script compares the count and tells you one is missing entirely. It also knows that 2WAY is **normal** between DR-OTHER routers on a broadcast segment, so it does not cry wolf about the most common not-a-problem in OSPF.
Is It Safe to Run
Yes. Strictly read-only. One command per device, two if the device has VRFs declared. It never enters configuration mode and never writes anything to a device. ios, iosxe, eos show ip ospf neighbor nxos show ip ospf neighbors iosxr show ospf neighbor junos show ospf neighbor Per-VRF variants exist on NX-OS, IOS-XR, Junos and EOS and are used when a device declares `vrfs:`. PAN-OS and FortiOS are reported [SKIP]: the OSPF neighbour table is not readable over their CLI in a form worth parsing. That is stated rather than silently returning zero neighbours. 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. `show ip ospf neighbor` is usually available at low privilege.
Your First Run
Create devices.yaml with one router: devices: - host: 192.168.1.1 os: ios Then: export NET_USER='admin' export NET_PASS='your-password' python ospf_check.py --inventory devices.yaml That is the state check. It needs nothing else and reports any adjacency that is not FULL. Then add the count check, which is what makes it genuinely useful: devices: - host: 192.168.1.1 os: ios expected_neighbors: 4 Now a missing adjacency is a finding instead of a shorter table. Set it from what the device has when you know the network is healthy - run with --show-all first and count.
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python ospf_check.py --inventory devices.yaml Checking OSPF neighbors on 5 device(s)... ========================================================================== [OK] rtr-core-01 (iosxr) 4 neighbor(s), all FULL [WARN] rtr-dist-02 (ios) 2/3 FULL - 10.0.0.9 on Gi0/2 is EXSTART [WARN] rtr-branch-07 (iosxe) 3/3/4 expected FULL - only 3 neighbor(s) present, expected 4 - 1 adjacency is missing entirely [OK] sw-leaf-04 (eos) 2 neighbor(s), all FULL [SKIP] fw-edge-01 (panos) OSPF neighbor table is not readable over CLI on panos ========================================================================== 2 healthy, 2 with issues, 0 unreachable (of 5 device(s)). Reading it: 2/3 FULL Two of the three adjacencies this device has are FULL. The ones that are not are listed below with the interface and the state. 3/3/4 expected FULL Three FULL, of three present, of four expected. The third number only appears when the device declares `expected_neighbors`, and it is the one that catches a neighbour that disappeared. [SKIP] The platform's neighbour table cannot be read this way. Not a failure, and not a clean result either - that device was not checked. [ERROR] Unreachable. Counted separately in the summary line, because an unreachable router is not a healthy one. When a fault state appears, the script prints what it means underneath. The full set: DOWN no hellos received - check the interface, the VLAN, and that OSPF is enabled on it ATTEMPT NBMA only - hellos sent to a configured neighbour with no reply INIT hellos received but ours are not reaching the peer. It is one-way: check an ACL or an MTU mismatch 2WAY normal between DR-OTHER routers on a broadcast segment. A fault only if this specific pair should be adjacent EXSTART classic MTU mismatch between the two interfaces, or a duplicate router-id EXCHANGE DBD exchange stalled - almost always MTU, sometimes an ACL dropping large packets LOADING LSA request stalled - usually a corrupt or oversized LSA **EXSTART and EXCHANGE both mean "check the MTU on both sides first."** That single habit resolves most OSPF adjacency problems.
Exit Codes for Cron and CI
0 every adjacency healthy and every device answered 1 an adjacency has a problem, OR a device was unreachable 130 you pressed Ctrl-C An unreachable device exits 1 on purpose: it has not been checked, and "not checked" must not look like "fine". Every five minutes, as an adjacency alarm: */5 * * * * cd /opt/netops && python ospf_check.py \ --inventory devices.yaml --json-out /var/log/ospf.json
About 2WAY
By default 2WAY is **not** reported as a fault, and that is correct for most networks. On a broadcast segment, routers elect a DR and a BDR. Every other router on that segment forms a full adjacency with the DR and the BDR, and stays at 2WAY with each other. That is the protocol working, not a problem, and a script that flags it will flag most of your access layer. Pass --strict-2way only when you know the pair in question should be adjacent - a point-to-point link where you see 2WAY is genuinely wrong.
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs expected_neighbors: 4 # optional, see below - host: rtr-core.example.com os: iosxr port: 2222 # optional, default 22 vrfs: [CORP, MGMT] # optional: also check 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 `expected_neighbors` is the field that makes this a monitoring tool rather than a status display. Without it the script can only tell you about adjacencies it can see; with it, it can tell you one is gone. 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 ospf neighbor iosxe Cisco IOS-XE show ip ospf neighbor nxos Cisco NX-OS show ip ospf neighbors, per-VRF iosxr Cisco IOS-XR show ospf neighbor, per-VRF junos Juniper Junos show ospf neighbor, per-instance eos Arista EOS show ip ospf neighbor, per-VRF panos Palo Alto PAN-OS not readable - reported [SKIP] fortios Fortinet FortiOS not readable - reported [SKIP] "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. 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 cron job, put them in the job's own environment file with restricted permissions - not in the crontab line, which other users can read.
Every Flag
--inventory PATH REQUIRED. The devices. --limit TEXT Only hosts whose name contains TEXT. --strict-2way Treat 2WAY as a fault. Off by default - see About 2WAY. --show-all List every neighbour with its state and interface, not just the ones with problems. Use this to find the right `expected_neighbors` value. --read-timeout N Seconds per command. Default 60. --workers N Devices at once. Default 10. --json-out PATH Machine-readable results, for dashboards or alerting.
When Something Fails
"Set NET_USER and NET_PASS" Not set in this terminal, or not in the cron job'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. The device is counted as unreachable in the summary and the run exits 1. A neighbour is EXSTART or EXCHANGE Check the MTU on both interfaces first - it is the cause most of the time. If the MTUs match, look for a duplicate router-id, and for an ACL between them dropping large packets. A neighbour is INIT One-way: they hear you, you do not hear them, or the reverse. An ACL or a unidirectional link. Check from both sides. Everything is 2WAY on a segment Normal, if those routers are DR-OTHER on a broadcast network. Find the DR with `show ip ospf interface` - if there is no DR, that is the real problem. "only N neighbor(s) present, expected M" An adjacency is gone, not stuck. Nothing in the neighbour table will tell you which one - compare against your topology, or run with --show-all on this device and the ones it should be peering with. A device reports 0 neighbours and you expected some Either OSPF is not running, or the `os` value is wrong for that box so the wrong command was sent. Check the `os` first; it is the quicker of the two to rule out. [SKIP] on PAN-OS or FortiOS Those platforms are not checked. Treat them as unknown, not healthy.