Monitoring · python · 45 copies
VXLAN VTEP & EVPN Fabric Monitor
Monitors VXLAN fabric health across Cisco NX-OS, IOS-XE, Arista EOS and Juniper Junos. Beyond per-device VTEP, VNI and BGP EVPN state, it compares leaves against each other and reports the asymmetries no single device can see: a leaf that sees a peer which does not see it back, a VNI configured on three leaves and missing from a fourth, or a node with fewer tunnels than the rest of the fabric. Correctly reads the vPC anycast VTEP secondary address on NX-OS, so a healthy vPC pair is not reported as broken.
- Arista EOS
- Cisco IOS-XE
- Junos
- Cisco NX-OS
pip install "netmiko>=4.3" "PyYAML>=6.0"#!/usr/bin/env python3
"""
VXLAN VTEP & EVPN Fabric Monitor
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/vxlan-vtep-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"
"""
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
import time
from collections import defaultdict
from dataclasses import dataclass, field, asdict
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# VXLAN EVPN fabrics run on NX-OS, IOS-XE, Arista EOS and Junos. IOS-XR,
# PAN-OS and FortiOS do not build VXLAN EVPN fabrics, so devices with those
# OS values are reported as skipped rather than guessed at.
FABRIC_CMDS = {
"nxos": {
"peers": "show nve peers",
"vni": "show nve vni",
"bgp": "show bgp l2vpn evpn summary",
"nve": "show nve interface nve1 detail",
},
"iosxe": {
"peers": "show l2vpn evpn peers vxlan",
"vni": "show l2vpn evpn evi",
"bgp": "show bgp l2vpn evpn summary",
"nve": "",
},
"eos": {
"peers": "show vxlan vtep",
"vni": "show vxlan vni",
"bgp": "show bgp evpn summary",
"nve": "show interfaces vxlan 1",
},
"junos": {
"peers": "show ethernet-switching vxlan-tunnel-end-point remote",
"vni": "show evpn instance",
"bgp": "show bgp summary",
"nve": "",
},
}
NOT_FABRIC = {
"ios": "classic IOS does not build VXLAN EVPN fabrics",
"iosxr": "IOS-XR uses EVPN but not the NVE/VTEP model this script checks",
"panos": "PAN-OS is a firewall, not a VXLAN fabric node",
"fortios": "FortiOS is a firewall, not a VXLAN fabric node",
}
IPV4 = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
@dataclass
class Fabric:
host: str
os: str
peers: dict[str, str] = field(default_factory=dict) # peer_ip -> state
vnis: dict[str, str] = field(default_factory=dict) # vni -> state
bgp: dict[str, str] = field(default_factory=dict) # neighbor -> state
source_ip: str = ""
issues: list[str] = field(default_factory=list)
status: str = "ok"
error: str = ""
# ---------------------------------------------------------------------------
# Parsers
# ---------------------------------------------------------------------------
def parse_peers(output: str, os_key: str) -> dict[str, str]:
peers: dict[str, str] = {}
if os_key == "nxos":
# nve1 10.1.0.2 Up CP 1d02h 5001.0000.1b08
for line in output.splitlines():
m = re.match(r"nve\d+\s+(\d+\.\d+\.\d+\.\d+)\s+(\S+)", line.strip())
if m:
peers[m.group(1)] = m.group(2)
return peers
if os_key == "eos":
# Remote VTEPS for Vxlan1: 10.1.0.2, 10.1.0.3 (or a per-line table)
for line in output.splitlines():
for ip in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b", line):
state = "Up"
if re.search(r"\b(down|unreachable)\b", line, re.IGNORECASE):
state = "Down"
peers[ip] = state
return peers
if os_key == "junos":
for line in output.splitlines():
m = re.search(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b.*?\b(RNVE|up|down)\b",
line, re.IGNORECASE)
if m:
peers[m.group(1)] = "Up" if m.group(2).lower() != "down" else "Down"
elif IPV4.match(line.strip().split()[0] if line.strip() else ""):
peers[line.split()[0]] = "Up"
return peers
# iosxe
for line in output.splitlines():
parts = line.split()
if parts and IPV4.match(parts[0]):
up = any(p.lower().startswith(("up", "estab")) for p in parts[1:])
peers[parts[0]] = "Up" if up else (parts[1] if len(parts) > 1 else "Unknown")
return peers
def parse_vnis(output: str, os_key: str) -> dict[str, str]:
vnis: dict[str, str] = {}
if os_key == "nxos":
# nve1 10010 UnicastBGP Up CP L2 [10]
for line in output.splitlines():
m = re.match(r"nve\d+\s+(\d+)\s+\S+\s+(\S+)", line.strip())
if m:
vnis[m.group(1)] = m.group(2)
return vnis
if os_key == "eos":
for line in output.splitlines():
m = re.search(r"\bVNI\s+(\d+)\b.*?\b(\w+)\s*$", line) or \
re.match(r"\s*(\d{4,})\s+\S+\s+(\S+)", line)
if m:
vnis[m.group(1)] = m.group(2)
return vnis
if os_key == "junos":
for line in output.splitlines():
m = re.search(r"VNI[:\s]+(\d+)", line)
if m:
vnis[m.group(1)] = "Up"
return vnis
# iosxe: EVI VLAN state
for line in output.splitlines():
m = re.match(r"\s*(\d+)\s+(\d+)\s+(\S+)", line)
if m:
vnis[m.group(1)] = m.group(3)
return vnis
def parse_bgp_evpn(output: str) -> dict[str, str]:
"""BGP EVPN summary shares the standard layout: a count means Established."""
sessions: dict[str, str] = {}
for line in output.splitlines():
parts = line.split()
if len(parts) < 8 or not IPV4.match(parts[0]):
continue
sessions[parts[0]] = "Established" if parts[-1].isdigit() else parts[-1]
return sessions
def parse_source_ip(output: str, os_key: str) -> str:
"""
The VTEP source address other leaves peer with.
On NX-OS with vPC the NVE loopback carries a primary and a secondary; the
SECONDARY is the anycast VTEP address remote leaves see. Reporting the
primary here would make a correct fabric look broken.
"""
if os_key == "nxos":
m = re.search(r"secondary:\s*(\d{1,3}(?:\.\d{1,3}){3})", output, re.IGNORECASE)
if m:
return m.group(1)
m = re.search(r"primary:\s*(\d{1,3}(?:\.\d{1,3}){3})", output, re.IGNORECASE)
return m.group(1) if m else ""
if os_key == "eos":
m = re.search(r"Source interface is \S+ and is active with "
r"(\d{1,3}(?:\.\d{1,3}){3})", output)
return m.group(1) if m else ""
return ""
# ---------------------------------------------------------------------------
# Per-device collection
# ---------------------------------------------------------------------------
def check_device(dev: dict, args) -> Fabric:
host = dev["host"]
os_key = dev["os"]
f = Fabric(host=host, os=os_key)
conn = None
if os_key not in FABRIC_CMDS:
f.status = "skip"
f.error = NOT_FABRIC.get(os_key, f"VXLAN EVPN not supported on {os_key}")
return f
cmds = FABRIC_CMDS[os_key]
try:
conn = connect(dev)
f.peers = parse_peers(conn.send_command(cmds["peers"], read_timeout=args.read_timeout),
os_key)
f.vnis = parse_vnis(conn.send_command(cmds["vni"], read_timeout=args.read_timeout),
os_key)
f.bgp = parse_bgp_evpn(conn.send_command(cmds["bgp"], read_timeout=args.read_timeout))
if cmds["nve"]:
try:
f.source_ip = parse_source_ip(
conn.send_command(cmds["nve"], read_timeout=60), os_key)
except Exception: # noqa: BLE001 - source IP is an enrichment
pass
for ip, state in f.peers.items():
if state.lower() != "up":
f.issues.append(f"VTEP peer {ip} is {state}")
for vni, state in f.vnis.items():
if state.lower() not in ("up", "established"):
f.issues.append(f"VNI {vni} is {state}")
for nbr, state in f.bgp.items():
if state != "Established":
f.issues.append(f"BGP EVPN neighbor {nbr} is {state}")
expected = dev.get("expected_vnis")
if expected is not None and len(f.vnis) < int(expected):
f.issues.append(f"only {len(f.vnis)} VNI(s) present, expected {expected}")
return f
except NetmikoAuthenticationException as exc:
f.status, f.error = "error", f"authentication failed - check NET_USER/NET_PASS ({exc})"
except NetmikoTimeoutException as exc:
f.status, f.error = "error", f"unreachable or SSH timed out ({exc})"
except Exception as exc: # noqa: BLE001 - one device must not abort the run
f.status, f.error = "error", f"{type(exc).__name__}: {exc}"
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
return f
# ---------------------------------------------------------------------------
# Fabric-wide analysis
# ---------------------------------------------------------------------------
def analyse_fabric(fabrics: list[Fabric]) -> list[str]:
"""
Compare leaves against each other.
A leaf reporting its own peers and VNIs as healthy tells you nothing about
whether the FABRIC is healthy. The failures that matter are asymmetric:
leaf-01 sees leaf-04 but leaf-04 does not see leaf-01; a VNI configured on
three leaves and missing from a fourth. Neither is visible from any single
device.
"""
issues: list[str] = []
live = [f for f in fabrics if f.status == "ok"]
if len(live) < 2:
return issues
# Peer symmetry
by_source = {f.source_ip: f for f in live if f.source_ip}
for f in live:
for peer_ip, state in f.peers.items():
if state.lower() != "up":
continue
other = by_source.get(peer_ip)
if other and f.source_ip and f.source_ip not in other.peers:
issues.append(
f"asymmetric adjacency: {f.host} sees VTEP {peer_ip} ({other.host}), "
f"but {other.host} does not list {f.source_ip} as a peer")
# Peer-count outliers
counts = {f.host: len([s for s in f.peers.values() if s.lower() == "up"]) for f in live}
if counts:
expected = max(set(counts.values()), key=list(counts.values()).count)
for host, n in sorted(counts.items()):
if n < expected:
issues.append(f"{host}: {n} VTEP peer(s) up, most leaves report "
f"{expected} - {expected - n} tunnel(s) missing")
# VNI presence across the fabric
everywhere: dict[str, set[str]] = defaultdict(set)
for f in live:
for vni in f.vnis:
everywhere[vni].add(f.host)
hosts = {f.host for f in live}
for vni, present in sorted(everywhere.items()):
if len(present) < len(hosts):
missing = sorted(hosts - present)
issues.append(f"VNI {vni}: on {len(present)}/{len(hosts)} leaves, "
f"missing from {', '.join(missing[:4])}"
+ (f" +{len(missing)-4}" if len(missing) > 4 else ""))
# BGP EVPN session counts
bgp_counts = {f.host: sum(1 for s in f.bgp.values() if s == "Established") for f in live}
if bgp_counts:
norm = max(set(bgp_counts.values()), key=list(bgp_counts.values()).count)
for host, n in sorted(bgp_counts.items()):
if n < norm:
issues.append(f"{host}: {n} BGP EVPN session(s) established, "
f"most leaves have {norm}")
return issues
def main() -> int:
ap = argparse.ArgumentParser(
description="Monitor VXLAN VTEP, VNI and BGP EVPN health across a fabric.",
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 the fabric leaves and spines")
ap.add_argument("--daemon", action="store_true",
help="Run continuously instead of checking once and exiting")
ap.add_argument("--interval", type=int, default=60,
help="Seconds between polls in daemon mode. Default: 60")
ap.add_argument("--limit", help="Only check hosts whose name contains this text")
ap.add_argument("--show-detail", action="store_true",
help="List every VTEP peer and VNI per device")
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 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
def run_once() -> int:
print(f"\nChecking VXLAN fabric health on {len(devices)} device(s)...")
print("=" * 86)
fabrics: list[Fabric] = []
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:
fabrics.append(fut.result())
except Exception as exc: # noqa: BLE001
f = Fabric(host=host, os="?")
f.status, f.error = "error", f"worker crashed: {exc}"
fabrics.append(f)
for f in sorted(fabrics, key=lambda x: x.host):
label = f"{f.host} ({f.os})"
if f.status == "error":
print(f"[ERROR] {label:<26} {f.error}")
continue
if f.status == "skip":
print(f"[SKIP] {label:<26} {f.error}")
continue
up = sum(1 for s in f.peers.values() if s.lower() == "up")
vni_up = sum(1 for s in f.vnis.values()
if s.lower() in ("up", "established"))
bgp_up = sum(1 for s in f.bgp.values() if s == "Established")
tag = "[OK] " if not f.issues else "[WARN] "
src = f" src {f.source_ip}" if f.source_ip else ""
print(f"{tag} {label:<26} VTEP {up}/{len(f.peers)} "
f"VNI {vni_up}/{len(f.vnis)} BGP EVPN {bgp_up}/{len(f.bgp)}{src}")
for i in f.issues:
print(f" - {i}")
if args.show_detail:
for ip, st in sorted(f.peers.items()):
print(f" peer {ip:<16} {st}")
for vni, st in sorted(f.vnis.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 0):
print(f" vni {vni:<16} {st}")
fabric_issues = analyse_fabric(fabrics)
print("=" * 86)
if fabric_issues:
print(f"FABRIC CONSISTENCY: {len(fabric_issues)} issue(s)\n")
for i in fabric_issues:
print(f" ! {i}")
else:
live = sum(1 for f in fabrics if f.status == "ok")
print(f"FABRIC CONSISTENCY: symmetric across {live} node(s)")
device_issues = sum(len(f.issues) for f in fabrics)
errs = sum(1 for f in fabrics if f.status == "error")
print(f"\n{device_issues} device-level issue(s), {len(fabric_issues)} "
f"fabric-level issue(s), {errs} unreachable.")
if args.json_out:
Path(args.json_out).write_text(json.dumps(
{"devices": [asdict(f) for f in fabrics], "fabric_issues": fabric_issues},
indent=2), encoding="utf-8")
return 1 if (device_issues or fabric_issues or errs) else 0
if not args.daemon:
return run_once()
while True:
run_once()
print(f"\nNext check in {args.interval}s. Ctrl-C to stop.")
time.sleep(args.interval)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nStopped.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredPath to your devices.yaml file. Lists the fabric leaves and spines, each with a host and an os field. Add expected_vnis per node. See the output example below for the full format.
--daemonboolRun continuously instead of checking once and exiting.
--intervalnumberSeconds between checks in daemon mode. Default: 60
--limittextOnly check hosts whose name contains this text, e.g. --limit leaf
--show-detailboolList every VTEP peer and VNI per device with its state, not just the counts.
--read-timeoutnumberSeconds to wait per command. Default: 90
--workersnumberHow many devices to contact at once. Default: 10
--json-outtextWrite machine-readable results including the fabric-level issues.
What This Solves
Every leaf in a VXLAN fabric can report itself perfectly healthy while the fabric is broken, because the failures that matter are **asymmetric** and no single device can see them. leaf-01 sees leaf-04. leaf-04 does not see leaf-01. Both devices look fine. Traffic between them works in one direction, or not at all, depending on where the MAC was learned. A VNI exists on three leaves out of four. The three are healthy. The hosts on the fourth are isolated, and the fourth has nothing wrong with it except an absence. One leaf has two EVPN sessions where everybody else has four. It is "up". It is also missing half the fabric's routes. This checks per-device VTEP, VNI and BGP EVPN state - and then compares the leaves against each other and reports the asymmetries. That second part is the point; the per-device check is what you could already do by hand. It covers Cisco NX-OS, Cisco IOS-XE, Arista EOS and Juniper Junos.
Is It Safe to Run
Yes. Strictly read-only. VTEP peer, VNI and BGP EVPN show commands per platform, and nothing else. It never enters configuration mode and never writes anything to a device. On your own machine it writes only with --json-out. In --daemon mode it reconnects to every device every --interval seconds (default 60, which is short). On a large fabric raise it.
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 NVE and BGP EVPN show commands. 4. **Put the whole fabric in one inventory.** This is the requirement that makes the script worth running. The cross-leaf comparison can only find an asymmetry between two devices it polled in the same run. A fabric split across two inventory files is two half-blind checks.
Your First Run
Create fabric.yaml with every leaf and spine: devices: - host: 10.1.0.11 os: nxos - host: 10.1.0.12 os: nxos - host: 10.1.0.13 os: eos - host: 10.1.0.14 os: eos Then: export NET_USER='admin' export NET_PASS='your-password' python vxlan_vtep_monitor.py --inventory fabric.yaml --show-detail --show-detail lists every VTEP peer and VNI per device. Run it that way first: the per-device counts only mean something once you have seen what a healthy leaf looks like on your fabric. Then, as a fabric alarm: python vxlan_vtep_monitor.py --inventory fabric.yaml \ --json-out /var/log/vxlan.json
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python vxlan_vtep_monitor.py --inventory fabric.yaml Checking VXLAN fabric health on 5 device(s)... ====================================================================================== [OK] leaf-01 (nxos) VTEP 4/4 VNI 12/12 BGP EVPN 2/2 src 10.1.0.11 [OK] leaf-02 (nxos) VTEP 4/4 VNI 12/12 BGP EVPN 2/2 src 10.1.0.11 [WARN] leaf-03 (eos) VTEP 3/4 VNI 11/12 BGP EVPN 2/2 src 10.1.0.13 - VTEP peer 10.1.0.14 is Down - only 11 VNI(s) present, expected 12 [OK] leaf-04 (eos) VTEP 4/4 VNI 12/12 BGP EVPN 2/2 src 10.1.0.14 Reading it: VTEP 4/4 VNI 12/12 BGP EVPN 2/2 Up over total, for each of the three things that have to be true. The VNI total comes from what is configured on that device, or from `expected_vnis` if you declare it. src 10.1.0.11 on two different leaves Not a bug. On a vPC or MLAG pair both switches advertise the same **anycast** VTEP address, and that is what remote leaves see. The script reports the NVE secondary address for exactly this reason - reporting the primary would make a vPC pair look like two unrelated VTEPs and every remote adjacency look asymmetric. The per-device issues VTEP peers that are Down, VNIs not up, EVPN neighbours not established, and a VNI count below what was expected. The fabric-level issues (printed after the device list) This is the part no device can tell you: asymmetric adjacency leaf-01 sees leaf-04 but leaf-04 does not see leaf-01 back VTEP peer count outlier one leaf has N peers where most have more. Being the odd one out is the finding VNI on N/M leaves a VNI missing from some leaves. The hosts there are isolated EVPN session outlier one leaf with fewer established EVPN sessions than its peers Each of those names the devices involved, because the fix is always at one specific end. [SKIP] A platform without VXLAN support in this script - a firewall in the inventory, for example. Not checked, not healthy.
Exit Codes for Cron and CI
0 no device issues, no fabric issues, every device answered 1 any issue, OR a device could not be reached 130 you pressed Ctrl-C An unreachable leaf exits 1 and also weakens every comparison below it - an asymmetry involving that leaf cannot be detected when only one side was polled. */5 * * * * cd /opt/netops && python vxlan_vtep_monitor.py \ --inventory fabric.yaml --json-out /var/log/vxlan.json
The Inventory File
devices: - host: 10.1.0.11 # REQUIRED: IP address or DNS name os: nxos # REQUIRED: which OS it runs - host: leaf-03.example.com os: eos port: 2222 # optional, default 22 expected_vnis: 12 # optional: assert the VNI count device_name: Leaf 3 # 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: nxos port: 22 devices: - host: 10.1.0.11 - host: 10.1.0.12 - host: 10.1.0.13 os: eos # overrides the default Include spines as well as leaves. 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: 10.1.0.11 correct host:10.1.0.11 WRONG - YAML parse error
All Valid OS Values
nxos Cisco NX-OS Nexus - the common VXLAN EVPN platform iosxe Cisco IOS-XE Catalyst 9000 VXLAN eos Arista EOS all Arista switches junos Juniper Junos QFX Only these four. A device on any other platform is reported [SKIP] - it is not a VXLAN fabric member as far as this script is concerned. "cisco" is a vendor, not an OS. Use nxos or iosxe here.
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 whole fabric - leaves and spines. --daemon Check continuously instead of once. --interval N Seconds between checks in daemon mode. Default 60. Raise it on a large fabric. --limit TEXT Only hosts whose name contains TEXT, e.g. --limit leaf. **Note this weakens the cross-leaf comparison** - leaves you filtered out cannot be compared against. --show-detail List every VTEP peer and VNI per device with its state, not just the counts. --read-timeout N Seconds per command. Default 90. --workers N Devices at once. Default 10. --json-out PATH Machine-readable results including the fabric-level issues.
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. "unreachable or SSH timed out" No answer. Remember this also blinds the fabric comparison for that leaf - re-run once it is back before trusting a clean report. "asymmetric adjacency: leaf-01 sees VTEP 10.1.0.14 (leaf-04), ..." One side has the adjacency and the other does not. On a vPC or MLAG pair, check first that **both peers advertise the same NVE secondary (anycast) address** - a mismatch there is the most common cause and it looks exactly like this. Otherwise look at the underlay: the two VTEPs need reachability to each other's source address, and an EVPN route-type-3 from each. "VNI X: on 3/4 leaves, missing from leaf-03" The VNI is not configured, or not up, on that leaf. Hosts in it on that leaf are isolated. Check the NVE member and the VLAN-to-VNI mapping there. "N VTEP peer(s) up, most leaves report more" This leaf is the outlier. Usually its underlay is partially broken, or it is missing EVPN sessions - check the BGP EVPN column on the same line first, because fewer sessions explains fewer peers. A leaf shows 0 VNIs Either VXLAN is not configured there, or the `os` value is wrong so the wrong commands were sent. Check the `os` first. Two leaves share a source address and you did not expect it A vPC/MLAG pair using an anycast VTEP. Expected. If those two switches are **not** a pair, that is a real and serious misconfiguration.