Loading_
Loading_
Compares VLAN databases and trunk allow-lists across every switch, finding the mismatches that cause intermittent connectivity nobody can reproduce.
"""Cisco IOS VLAN and trunk consistency checker. Read-only. Runs show commands over SSH and reports inconsistencies. pip install netmiko python cisco_vlan_check.py --inventory switches.yaml --out report.txt switches.yaml: username: netops devices: - host: sw-core-01 - host: sw-access-03""" from __future__ import annotations import argparseimport getpassimport reimport sysfrom collections import defaultdict import yamlfrom netmiko import ConnectHandler def parse_vlan_brief(output: str) -> dict[int, str]: """Parse 'show vlan brief' into {vlan_id: name}.""" vlans: dict[int, str] = {} for line in output.splitlines(): match = re.match(r"^(\d{1,4})\s+(\S+)\s+(active|suspended|act/lshut)", line) if match: vlans[int(match.group(1))] = match.group(2) return vlans def parse_trunks(output: str) -> dict[str, dict]: """Parse 'show interfaces trunk' into {interface: {native, allowed}}.""" trunks: dict[str, dict] = {} section = None for line in output.splitlines(): stripped = line.strip() if stripped.startswith("Port") and "Native vlan" in line: section = "native" continue if stripped.startswith("Port") and "Vlans allowed on trunk" in line: section = "allowed" continue if not stripped or stripped.startswith("Port"): continue parts = stripped.split() if section == "native" and len(parts) >= 4 and parts[3].isdigit(): trunks.setdefault(parts[0], {})["native"] = int(parts[3]) elif section == "allowed" and len(parts) >= 2: allowed: set[int] = set() for chunk in parts[1].split(","): if "-" in chunk: try: start, end = chunk.split("-") allowed.update(range(int(start), int(end) + 1)) except ValueError: continue elif chunk.isdigit(): allowed.add(int(chunk)) trunks.setdefault(parts[0], {})["allowed"] = allowed return trunks def collect(host: str, username: str, password: str) -> dict: conn = ConnectHandler( device_type="cisco_ios", host=host, username=username, password=password, fast_cli=False, conn_timeout=20, ) try: return { "host": host, "vlans": parse_vlan_brief(conn.send_command("show vlan brief")), "trunks": parse_trunks(conn.send_command("show interfaces trunk")), "version": conn.send_command("show version | include Version").strip(), } finally: conn.disconnect() def analyse(devices: list[dict]) -> tuple[list[str], list[str]]: findings: list[str] = [] matrix: list[str] = [] all_vlans: set[int] = set() for device in devices: all_vlans.update(device["vlans"]) # VLAN presence matrix width = max(len(d["host"]) for d in devices) + 2 header = "VLAN".ljust(7) + "NAME".ljust(22) + "".join(d["host"].ljust(width) for d in devices) matrix.append(header) matrix.append("-" * len(header)) for vlan in sorted(all_vlans): if vlan in (1002, 1003, 1004, 1005): # legacy defaults, always present continue names = {d["vlans"].get(vlan) for d in devices if vlan in d["vlans"]} name = sorted(n for n in names if n)[0] if names else "?" row = str(vlan).ljust(7) + name[:20].ljust(22) missing = [] for device in devices: if vlan in device["vlans"]: row += "yes".ljust(width) else: row += "-".ljust(width) missing.append(device["host"]) matrix.append(row) if missing and len(missing) < len(devices): findings.append( "MEDIUM VLAN " + str(vlan) + " (" + name + ") missing on: " + ", ".join(missing) ) if len(names) > 1: findings.append( "LOW VLAN " + str(vlan) + " has inconsistent names: " + ", ".join(sorted(n for n in names if n)) ) # Native VLAN mismatches across the estate natives: dict[int, list[str]] = defaultdict(list) for device in devices: for interface, data in device["trunks"].items(): native = data.get("native") if native is not None: natives[native].append(device["host"] + ":" + interface) if len(natives) > 1: summary = "; ".join( str(vlan) + " on " + str(len(ports)) + " trunks" for vlan, ports in sorted(natives.items()) ) findings.append("HIGH Native VLAN is not consistent across trunks: " + summary) if 1 in natives: findings.append( "HIGH VLAN 1 is the native VLAN on " + str(len(natives[1])) + " trunks - move it to an unused VLAN" ) # Trunks that do not carry a VLAN the switch has defined for device in devices: defined = set(device["vlans"]) - {1, 1002, 1003, 1004, 1005} for interface, data in device["trunks"].items(): allowed = data.get("allowed", set()) if not allowed: continue gap = defined - allowed if gap and len(gap) <= 12: findings.append( "MEDIUM " + device["host"] + " " + interface + " does not allow VLANs " + ",".join(str(v) for v in sorted(gap)) ) rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} findings.sort(key=lambda f: rank.get(f.split()[0], 3)) return matrix, findings def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--inventory", required=True) parser.add_argument("--out", default="vlan-report.txt") args = parser.parse_args() with open(args.inventory, encoding="utf-8") as handle: inventory = yaml.safe_load(handle) username = inventory.get("username") or getpass.getuser() password = getpass.getpass("Password for " + username + ": ") devices = [] for entry in inventory["devices"]: host = entry["host"] print("Collecting from " + host + "...", file=sys.stderr) try: devices.append(collect(host, username, password)) except Exception as exc: print(" failed: " + str(exc), file=sys.stderr) if len(devices) < 2: print("Need at least two reachable devices to compare.", file=sys.stderr) return 2 matrix, findings = analyse(devices) lines = ["VLAN CONSISTENCY REPORT", "=" * 60, ""] lines += matrix lines += ["", "FINDINGS", "=" * 60, ""] lines += findings if findings else ["No inconsistencies found."] report = "\n".join(lines) print(report) with open(args.out, "w", encoding="utf-8") as handle: handle.write(report + "\n") return 1 if any(f.startswith("HIGH") for f in findings) else 0 if __name__ == "__main__": raise SystemExit(main())A VLAN missing from one switch in a stack, or pruned from one trunk in a path, produces the worst class of network fault: it works for most people most of the time.
This collects the VLAN database and trunk configuration from every device, builds a matrix, and reports VLANs defined on some switches but not others, trunks that do not carry a VLAN both ends expect, and native VLAN mismatches.
Read-only over SSH, no config mode. Output is a matrix you can hand to anyone plus a findings list ranked by how likely each is to be causing a live problem.
| Name | Type | Required | Description |
|---|---|---|---|
--inventory | path | Required | YAML inventory of switches. |
--out | path | Optional | Report destination. |
The platform turns any script into a governed automation — versioned, gated, audited and reversible.