Monitoring · python · 38 copies
Interface Utilization Report
Measures real interface throughput by sampling counters twice and computing the delta. Reads each interface's negotiated speed from the device so utilisation is a true percentage — a saturated 100M port is reported at 100%, not 10%. Detects 32-bit counter wraps and resets instead of silently reporting a bogus rate, and surfaces input/output errors and drops alongside the rate, because a link at 40% with rising errors is the real problem. All devices are sampled concurrently, so a fleet-wide run takes one interval, not one interval per device.
- 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"#!/usr/bin/env python3
"""
Interface Utilization Report
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/interface-utilization
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 dataclasses import dataclass
# ---------------------------------------------------------------------------
# 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 and parsing
# ---------------------------------------------------------------------------
COMMAND_MAP = {
"ios": "show interfaces",
"iosxe": "show interfaces",
"iosxr": "show interfaces",
"nxos": "show interface",
"junos": "show interfaces detail",
"eos": "show interfaces",
"panos": "show interface all",
"fortios": "get system interface physical",
}
# Interface speed, so a rate can be expressed as a percentage of capacity.
SPEED_CMD = {
"ios": "show interfaces status",
"iosxe": "show interfaces status",
"nxos": "show interface status",
"eos": "show interfaces status",
}
# 32-bit counters wrap at 2^32. 64-bit ones effectively never do. A sample
# that goes backwards is either a wrap or a counter reset; both make the
# delta meaningless, so it is reported rather than silently dropped.
WRAP_32 = 2 ** 32
@dataclass
class Sample:
in_bytes: int
out_bytes: int
in_errors: int = 0
out_errors: int = 0
discards: int = 0
def parse_counters(output: str, os_key: str) -> dict[str, Sample]:
"""Return {interface: Sample} from one poll of the interface counters."""
counters: dict[str, Sample] = {}
cur: str | None = None
if os_key in ("ios", "iosxe", "iosxr", "nxos", "eos"):
acc: dict[str, dict] = {}
for line in output.splitlines():
m = re.match(r"^(\S+)\s+is\s+\S+", line)
if m and not line.startswith((" ", "\t")):
cur = m.group(1)
acc.setdefault(cur, {})
if not cur:
continue
for key, pat in (
("in_bytes", r"(\d+) bytes input"),
("out_bytes", r"(\d+) bytes output"),
("in_errors", r"(\d+) input errors"),
("out_errors", r"(\d+) output errors"),
):
g = re.search(pat, line)
if g:
acc[cur][key] = int(g.group(1))
g = re.search(r"Total output drops:\s*(\d+)", line)
if g:
acc[cur]["discards"] = int(g.group(1))
for name, v in acc.items():
if "in_bytes" in v and "out_bytes" in v:
counters[name] = Sample(
v["in_bytes"], v["out_bytes"],
v.get("in_errors", 0), v.get("out_errors", 0), v.get("discards", 0))
elif os_key == "junos":
acc: dict[str, dict] = {}
for line in output.splitlines():
m = re.match(r"\s*Physical interface:\s+(\S+?),", line)
if m:
cur = m.group(1)
acc.setdefault(cur, {})
if not cur:
continue
for key, pat in (
("in_bytes", r"Input bytes\s*:\s*(\d+)"),
("out_bytes", r"Output bytes\s*:\s*(\d+)"),
("in_errors", r"Input errors:\s*(\d+)"),
("out_errors", r"Output errors:\s*(\d+)"),
):
g = re.search(pat, line)
if g:
acc[cur][key] = int(g.group(1))
for name, v in acc.items():
if "in_bytes" in v and "out_bytes" in v:
counters[name] = Sample(v["in_bytes"], v["out_bytes"],
v.get("in_errors", 0), v.get("out_errors", 0))
elif os_key == "panos":
for line in output.splitlines():
m = re.match(r"(\S+)\s+\S+\s+\S+.*?rx:(\d+)\s+tx:(\d+)", line)
if m:
counters[m.group(1)] = Sample(int(m.group(2)), int(m.group(3)))
elif os_key == "fortios":
acc: dict[str, dict] = {}
for line in output.splitlines():
m = re.match(r"==\s*\[(\S+)\]", line)
if m:
cur = m.group(1)
acc.setdefault(cur, {})
if not cur:
continue
for key, pat in (("in_bytes", r"rx_bytes=(\d+)"),
("out_bytes", r"tx_bytes=(\d+)"),
("in_errors", r"rx_errors=(\d+)"),
("out_errors", r"tx_errors=(\d+)")):
g = re.search(pat, line)
if g:
acc[cur][key] = int(g.group(1))
for name, v in acc.items():
if "in_bytes" in v and "out_bytes" in v:
counters[name] = Sample(v["in_bytes"], v["out_bytes"],
v.get("in_errors", 0), v.get("out_errors", 0))
return counters
def parse_speeds(output: str) -> dict[str, int]:
"""
Read negotiated speed per interface so utilisation is a true percentage.
`show interfaces status` prints e.g. a-1000, 10G, 1000. A hardcoded
assumption of 1 Gbps reports a saturated 100 Mbps port as 10% busy.
"""
speeds: dict[str, int] = {}
for line in output.splitlines():
parts = line.split()
if len(parts) < 2:
continue
name = parts[0]
for tok in parts[1:]:
m = re.fullmatch(r"a?-?(\d+)([GMgm])?", tok)
if not m:
continue
val = int(m.group(1))
unit = (m.group(2) or "M").upper()
mbps = val * 1000 if unit == "G" else val
if mbps in (10, 100, 1000, 2500, 5000, 10000, 25000, 40000, 100000):
speeds[name] = mbps
break
return speeds
def delta(prev: int, now: int) -> int | None:
"""Counter delta, or None when the counter wrapped or was reset."""
if now >= prev:
return now - prev
if prev < WRAP_32 and now < WRAP_32:
return (WRAP_32 - prev) + now # plausible 32-bit wrap
return None # reset or 64-bit wrap: unusable
# ---------------------------------------------------------------------------
# Per-device sampling
# ---------------------------------------------------------------------------
def sample_device(dev: dict, args) -> dict:
host = dev["host"]
os_key = dev["os"]
command = COMMAND_MAP[os_key]
default_speed = int(dev.get("speed_mbps", args.default_speed))
conn = None
try:
conn = connect(dev)
speeds: dict[str, int] = {}
if SPEED_CMD.get(os_key) and not args.assume_speed:
try:
speeds = parse_speeds(conn.send_command(SPEED_CMD[os_key], read_timeout=60))
except Exception: # noqa: BLE001 - speed is an optimisation, not required
speeds = {}
first = parse_counters(conn.send_command(command, read_timeout=args.read_timeout), os_key)
time.sleep(args.interval)
second = parse_counters(conn.send_command(command, read_timeout=args.read_timeout), os_key)
rows = []
for iface, a in first.items():
b = second.get(iface)
if not b:
continue
d_in = delta(a.in_bytes, b.in_bytes)
d_out = delta(a.out_bytes, b.out_bytes)
if d_in is None or d_out is None:
rows.append({"iface": iface, "wrapped": True})
continue
in_mbps = d_in * 8 / args.interval / 1_000_000
out_mbps = d_out * 8 / args.interval / 1_000_000
speed = speeds.get(iface, default_speed)
pct = max(in_mbps, out_mbps) / speed * 100 if speed else 0.0
err_in = delta(a.in_errors, b.in_errors) or 0
err_out = delta(a.out_errors, b.out_errors) or 0
drops = delta(a.discards, b.discards) or 0
if args.only_active and in_mbps < 0.01 and out_mbps < 0.01:
continue
rows.append({
"iface": iface, "wrapped": False,
"in_mbps": round(in_mbps, 2), "out_mbps": round(out_mbps, 2),
"speed_mbps": speed, "pct": round(pct, 1),
"in_errors": err_in, "out_errors": err_out, "discards": drops,
"speed_known": iface in speeds,
})
return {"host": host, "os": os_key, "status": "ok",
"rows": sorted(rows, key=lambda r: r.get("pct", 0), reverse=True)}
except NetmikoAuthenticationException as exc:
return {"host": host, "os": os_key, "status": "error", "rows": [],
"error": f"authentication failed - check NET_USER/NET_PASS ({exc})"}
except NetmikoTimeoutException as exc:
return {"host": host, "os": os_key, "status": "error", "rows": [],
"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", "rows": [],
"error": f"{type(exc).__name__}: {exc}"}
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
def main() -> int:
ap = argparse.ArgumentParser(
description="Measure real interface throughput by sampling counters twice.",
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 sample")
ap.add_argument("--interval", type=int, default=30,
help="Seconds between the two counter samples. Longer smooths out "
"bursts; shorter reacts faster. Default: 30")
ap.add_argument("--threshold", type=float, default=80.0,
help="Flag interfaces at or above this percent of capacity. Default: 80")
ap.add_argument("--default-speed", type=int, default=1000,
help="Interface speed in Mbps when it cannot be read from the "
"device. Default: 1000")
ap.add_argument("--assume-speed", action="store_true",
help="Skip reading per-interface speed and use --default-speed "
"everywhere. Faster, less accurate")
ap.add_argument("--only-active", action="store_true",
help="Hide interfaces carrying essentially no traffic")
ap.add_argument("--top", type=int, default=0,
help="Show only the N busiest interfaces per device. 0 = all")
ap.add_argument("--limit", help="Only sample hosts whose name contains this text")
ap.add_argument("--read-timeout", type=int, default=120,
help="Seconds to wait per command. Default: 120")
ap.add_argument("--workers", type=int, default=10,
help="How many devices to sample 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
# All devices are sampled concurrently, so the whole run takes roughly
# --interval seconds regardless of how many devices there are.
print(f"Sampling {len(devices)} device(s): two polls {args.interval}s apart, "
f"all in parallel...")
print("=" * 86)
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(sample_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", "rows": [],
"error": f"worker crashed: {exc}"})
high = 0
erroring = 0
for r in sorted(results, key=lambda x: x["host"]):
if r["status"] == "error":
print(f"[ERROR] {r['host']} ({r['os']}) {r['error']}")
continue
rows = r["rows"][: args.top] if args.top else r["rows"]
print(f"\n{r['host']} ({r['os']}) {len(r['rows'])} interface(s)")
if not rows:
print(" no interface carried measurable traffic")
continue
for row in rows:
if row["wrapped"]:
print(f" {row['iface']:<22} counter wrapped or was reset - "
f"rate not measurable this cycle")
continue
flag = " [HIGH]" if row["pct"] >= args.threshold else ""
if row["pct"] >= args.threshold:
high += 1
speed_mark = "" if row["speed_known"] else "*"
errs = ""
if row["in_errors"] or row["out_errors"] or row["discards"]:
errs = (f" err in/out {row['in_errors']}/{row['out_errors']}"
f" drops {row['discards']}")
erroring += 1
print(f" {row['iface']:<22} in {row['in_mbps']:>8.2f} "
f"out {row['out_mbps']:>8.2f} Mbps "
f"{row['pct']:>5.1f}% of {row['speed_mbps']}M{speed_mark}{flag}{errs}")
print("\n" + "=" * 86)
print(f"{high} interface(s) at or above {args.threshold}% of capacity. "
f"{erroring} with errors or drops in this window.")
if any(not row.get("speed_known", True)
for r in results for row in r["rows"] if not row.get("wrapped")):
print(f"* speed could not be read from the device; "
f"assumed {args.default_speed} Mbps")
if args.json_out:
Path(args.json_out).write_text(json.dumps(results, indent=2), encoding="utf-8")
return 1 if high 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 sample, each with a host and an os field. Add speed_mbps per device where the speed cannot be auto-detected. See the output example below for the full format.
--intervalnumberSeconds between the two counter samples. Longer smooths out bursts and gives a more representative average; shorter reacts faster. Default: 30
--thresholdnumberFlag interfaces at or above this percent of their capacity. Default: 80
--default-speednumberInterface speed in Mbps to assume when it cannot be read from the device. Those rows are marked with an asterisk. Default: 1000
--assume-speedboolSkip reading per-interface speed entirely and use --default-speed everywhere. One fewer command per device, but less accurate.
--only-activeboolHide interfaces carrying essentially no traffic. Useful on access switches where most ports are idle.
--topnumberShow only the N busiest interfaces per device. 0 shows all. Default: 0
--limittextOnly sample hosts whose name contains this text.
--read-timeoutnumberSeconds to wait per command. 'show interfaces' on a chassis switch can be slow. Default: 120
--workersnumberHow many devices to sample at once. Default: 10
--json-outtextWrite machine-readable results to this file, for capacity trending.
What This Solves
"Is that link saturated?" is harder to answer than it should be. The device shows you a counter, not a rate. The five-minute averages on the interface are smoothed to the point of uselessness for a burst. And the answer that matters is a percentage of **that port's** capacity - a 100Mbps port doing 100Mbps is at 100%, and a monitoring tool that reports it as 10% of a gigabit is telling you everything is fine while users cannot work. This samples the counters twice, computes the delta, reads each interface's negotiated speed from the device, and gives you a real utilisation percentage. Three details that separate it from a quick script: It reads the actual negotiated speed per interface. Not an assumption. A port that negotiated 100M is measured against 100M. It detects counter wraps and resets. A 32-bit counter wraps at 2^32, and a sample that goes backwards produces a spectacular and completely fictional rate. The script says "counter wrapped or was reset - rate not measurable this cycle" instead of printing a number you might act on. It reports errors and drops next to the rate. **A link at 40% with rising input errors is a worse problem than one at 85% that is clean.** Utilisation alone hides that.
Is It Safe to Run
Yes. Strictly read-only. It runs `show interfaces` (or the platform equivalent) twice per device, --interval seconds apart, and nothing else. It never enters configuration mode and never writes to a device. Two practical cautions, neither about safety of the configuration: It takes --interval seconds by design. The script must wait between the two samples. A 30-second interval means the run takes at least 30 seconds no matter how fast your network is. That is not the script being slow. `show interfaces` is a heavy command on a chassis. Full interface output on a large switch is thousands of lines, and this asks for it twice. Raise --read-timeout and keep --workers moderate on big boxes. 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 interfaces` is usually available at low privilege. 4. Decide your interval. Shorter catches bursts and is noisier; longer smooths them out and is a better capacity number. 30 seconds is a reasonable default for "is this busy right now"; 300 is better for "should we upgrade this link".
Your First Run
Create devices.yaml with one device: devices: - host: 192.168.1.1 os: iosxe Then: export NET_USER='admin' export NET_PASS='your-password' python interface_util.py --inventory devices.yaml --only-active --top 10 It will pause for the sampling interval, then print the ten busiest interfaces that are actually carrying traffic. On an access switch without --only-active you get every port including the forty that are idle, which is rarely what you want. For capacity planning rather than a snapshot: python interface_util.py --inventory devices.yaml \ --interval 300 --json-out util-$(date +%F).json
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python interface_util.py --inventory devices.yaml --only-active --top 4 Sampling 3 device(s): two polls 30s apart, all in parallel... ====================================================================================== rtr-core-01 (iosxe) 6 interface(s) TenGigabitEthernet0/0/0 in 8912.44 out 7103.80 Mbps 89.1% of 10000M [HIGH] GigabitEthernet0/0/1 in 412.90 out 388.15 Mbps 41.3% of 1000M err in/out 1204/0 drops 88 GigabitEthernet0/0/2 in 88.31 out 62.07 Mbps 8.8% of 1000M sw-leaf-04 (eos) 12 interface(s) Ethernet49/1 in 2201.18 out 1980.42 Mbps 22.0% of 10000M Ethernet12 counter wrapped or was reset - rate not measurable this cycle Reading it: in 8912.44 out 7103.80 Mbps Measured rates in each direction over the sampling interval. These are real deltas, not the device's smoothed averages. 89.1% of 10000M The percentage is of the **higher of the two directions** against the port's negotiated speed, which is printed so you can see what it was measured against. If that number looks wrong, the speed is what to question first. [HIGH] At or above --threshold (default 80%). err in/out 1204/0 drops 88 Only shown when non-zero. **Read this before the percentage.** The line above it at 41.3% with 1204 input errors is a failing cable, a duplex mismatch or a dying optic, and it is hurting users right now. The one at 89.1% with no errors is simply busy. counter wrapped or was reset The second sample was lower than the first. Either a 32-bit counter wrapped, or somebody cleared the counters between the two polls. Both make the delta meaningless, so no rate is printed. Re-run; a wrap is a one-off, and a port that does it repeatedly has 32-bit counters and enough traffic to wrap inside your interval - shorten the interval for that device. [ERROR] on a device line Unreachable, or the second sample never arrived.
Exit Codes for Cron and CI
0 no interface at or above --threshold 1 at least one interface is at or above --threshold 130 you pressed Ctrl-C Note that this one keys on the threshold, not on errors. An interface with rising errors and low utilisation does **not** exit 1 - it is printed, but it is not what this script alerts on. If you want to alarm on errors, read the --json-out. Hourly capacity sampling: 0 * * * * cd /opt/netops && python interface_util.py \ --inventory devices.yaml --interval 300 --only-active \ --json-out /var/log/util/$(date +\%F-\%H).json
About the Speed Reading
The percentage is only as good as the speed it is measured against, so the script reads the negotiated speed per interface from the device. When it cannot: --default-speed N The speed in Mbps to assume when the speed cannot be read. Used only as a fallback. --assume-speed Skip reading speeds entirely and use --default-speed for everything. Faster, and wrong on any fleet with mixed port speeds. Use it only when every interface in scope is the same. A sub-interface, a tunnel or a port-channel may not report a speed the same way a physical port does. If a percentage looks implausible, check the `of NNNNM` figure on that line before anything else.
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: iosxe # REQUIRED: which OS it runs - host: sw-leaf-04 os: eos port: 2222 # optional, default 22 device_name: Leaf 4 # 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: sw-leaf-04 os: eos # 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 "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
Every Flag
--inventory PATH REQUIRED. The devices. --interval N Seconds between the two counter samples. The run takes at least this long. Longer smooths bursts and gives a better capacity number. --threshold N Flag interfaces at or above this percent. Default 80. This is what the exit code keys on. --default-speed N Mbps to assume when the speed cannot be read. --assume-speed Skip reading speeds; use --default-speed everywhere. Only correct on a uniform fleet. --only-active Hide interfaces carrying essentially nothing. Almost always what you want on an access switch. --top N Only the N busiest per device. 0 shows all. --limit TEXT Only hosts whose name contains TEXT. --read-timeout N Seconds per command. Raise it on a chassis - `show interfaces` there is slow and is run twice. --workers N Devices at once. Default 10. --json-out PATH Machine-readable results, for capacity trending.
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, or the second sample never arrived. Raise --read-timeout: a chassis printing full interface output twice can exceed a short timeout even when the device is perfectly healthy. "counter wrapped or was reset" One-off: re-run. Repeatedly on the same port: that interface has 32-bit counters and enough traffic to wrap inside your interval. Shorten --interval for it. Also check that nothing else is running `clear counters` on a schedule. A percentage that cannot be right Look at the `of NNNNM` on that line. The rate is almost certainly correct and the speed reading is not - a sub-interface, a tunnel or a port-channel. Confirm the real capacity by hand. Every interface reports 0 Either the device is genuinely idle, or the `os` value is wrong so the counters were not parsed from the output. Check the `os` first. The run seems to hang It is waiting out --interval. Two samples require a pause between them; there is no way around it. You want to alert on errors, not utilisation The exit code only reflects --threshold. Read the --json-out, where the error and discard counts are per interface.