Automation · python · 28 copies
Multi-Device SSH Command Runner
Runs a read-only show command across your whole fleet at once and collects the output. Read-only by design: any command that could enter config mode or change state is refused before a single session opens. Supports a different command per OS in the same job, VRF-scoped queries, regex filtering of the output, and saving everything to files. Devices come from a devices.yaml inventory; credentials from environment variables, never the file. Supports Cisco IOS/IOS-XE/IOS-XR/NX-OS, Juniper Junos, Arista EOS, Palo Alto PAN-OS, and Fortinet FortiOS.
- 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
"""
Multi-Device SSH Command Runner
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/ssh-command-runner
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
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Default command per OS when you do not pass --command.
DEFAULT_COMMANDS = {
"ios": "show version",
"iosxe": "show version",
"iosxr": "show version",
"nxos": "show version",
"junos": "show version",
"eos": "show version",
"panos": "show system info",
"fortios": "get system status",
}
# This script is READ-ONLY by design. Anything that could enter config mode or
# change device state is refused before a single session is opened. Use
# config-rollback or the bulk-* scripts for changes - they have snapshots,
# verification, and undo. This one has none of that.
WRITE_COMMANDS = re.compile(
r"^\s*(conf(ig(ure)?)?\b|wr(ite)?\b|copy\b|reload\b|reboot\b|erase\b|delete\b|"
r"clear\b|no\s|set\b|request\b|execute\b|install\b|rollback\b|commit\b|"
r"archive\b|format\b|shutdown\b)",
re.IGNORECASE,
)
# How each OS scopes a show command to a VRF / routing-instance / virtual-router.
VRF_SUFFIX = {
"ios": "vrf {vrf}",
"iosxe": "vrf {vrf}",
"iosxr": "vrf {vrf}",
"nxos": "vrf {vrf}",
"eos": "vrf {vrf}",
"junos": "instance {vrf}",
"fortios": "vrf {vrf}",
"panos": "virtual-router {vrf}",
}
def build_command(base: str, os_key: str, vrf: str | None) -> str:
if not vrf:
return base
suffix = VRF_SUFFIX.get(os_key, "")
return f"{base} {suffix.format(vrf=vrf)}".strip() if suffix else base
def load_command_map(path: str | None, override: str | None) -> dict[str, str]:
"""
Resolve which command runs on which OS.
--command wins for every OS. --commands-file lets you run a different
command per OS in one job, which matters because the same question has
different syntax on each platform.
"""
if override:
return {k: override for k in DEFAULT_COMMANDS}
if not path:
return dict(DEFAULT_COMMANDS)
p = Path(path)
if not p.exists():
raise SystemExit(f"Commands file not found: {path}")
raw = p.read_text(encoding="utf-8")
try:
data = (yaml.safe_load(raw) if p.suffix.lower() in (".yaml", ".yml") and yaml
else json.loads(raw))
except Exception as exc:
raise SystemExit(f"Could not parse {path}: {exc}")
cmds = data.get("commands", data) if isinstance(data, dict) else {}
if not isinstance(cmds, dict) or not cmds:
raise SystemExit(
f"{path} must map an os key to a command, for example:\n\n"
f" commands:\n"
f" ios: show ip interface brief\n"
f" junos: show interfaces terse\n"
f" fortios: get system interface physical\n"
)
unknown = set(cmds) - set(NETMIKO_DRIVER)
if unknown:
raise SystemExit(f"{path} has unknown os key(s): {', '.join(sorted(unknown))}\n"
f"Valid: {', '.join(sorted(NETMIKO_DRIVER))}")
return cmds
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
def run_on_device(dev: dict, commands: dict[str, str], args) -> dict:
host = dev["host"]
os_key = dev["os"]
base = commands.get(os_key)
conn = None
if not base:
return {"host": host, "os": os_key, "status": "skip",
"note": f"no command defined for os '{os_key}'"}
outputs: dict[str, str] = {}
try:
conn = connect(dev)
outputs["global"] = conn.send_command(base, read_timeout=args.read_timeout)
for vrf in dev.get("vrfs", []):
cmd = build_command(base, os_key, vrf)
if cmd == base:
continue # this OS has no VRF form for show commands
outputs[vrf] = conn.send_command(cmd, read_timeout=args.read_timeout)
if args.output_dir:
d = Path(args.output_dir)
d.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
body = "\n".join(
f"! === {host} ({os_key}) [{ctx}] {ts} UTC\n! {build_command(base, os_key, None if ctx == 'global' else ctx)}\n{out}"
for ctx, out in outputs.items()
)
(d / f"{host}_{ts}.txt").write_text(body, encoding="utf-8")
return {"host": host, "os": os_key, "status": "ok",
"command": base, "outputs": outputs}
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
def main() -> int:
ap = argparse.ArgumentParser(
description="Run a read-only show command across many devices at once.",
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 query")
ap.add_argument("--command", "-c",
help="Run this exact command on every device, regardless of OS")
ap.add_argument("--commands-file",
help="YAML/JSON mapping each os key to its own command, for when "
"the same question has different syntax per platform")
ap.add_argument("--limit", help="Only query hosts whose name contains this text")
ap.add_argument("--os", dest="os_filter",
help="Only query devices running this OS, e.g. --os nxos")
ap.add_argument("--output-dir",
help="Also save each device's output to a file in this directory")
ap.add_argument("--grep",
help="Only print output lines matching this regex. The full "
"output is still saved when --output-dir is used")
ap.add_argument("--quiet", action="store_true",
help="Print only the status line per device, not the output")
ap.add_argument("--read-timeout", type=int, default=90,
help="Seconds to wait for each 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()
if args.command and args.commands_file:
raise SystemExit("Use --command or --commands-file, not both.")
commands = load_command_map(args.commands_file, args.command)
# Read-only guard, checked before any connection is opened.
for os_key, cmd in commands.items():
if WRITE_COMMANDS.match(cmd):
raise SystemExit(
f"Refusing to run '{cmd}' - this script is read-only.\n\n"
f"It has no backup, no verification, and no undo. For configuration\n"
f"changes use config-rollback (snapshot + automatic rollback) or one\n"
f"of the bulk-* scripts, which are built for writes."
)
devices = load_inventory(args.inventory)
if args.limit:
devices = [d for d in devices if args.limit in d["host"]]
if args.os_filter:
devices = [d for d in devices if d["os"] == args.os_filter]
if not devices:
raise SystemExit("No device matched the --limit / --os filters.")
credentials() # fail fast before opening any connection
label = args.command or ("per-OS commands from " + args.commands_file
if args.commands_file else "OS-specific defaults")
print(f"Running: {label}")
print(f"Devices: {len(devices)}")
print("=" * 70)
pattern = re.compile(args.grep) if args.grep else None
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(run_on_device, d, commands, 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)
tag = {"ok": "[OK]", "error": "[FAIL]", "skip": "[SKIP]"}[r["status"]]
head = f"{tag} {r['host']} ({r['os']})"
if r["status"] == "error":
print(f"\n{head}\n {r['error']}")
continue
if r["status"] == "skip":
print(f"\n{head}\n {r['note']}")
continue
print(f"\n{head} $ {r['command']}")
if args.quiet:
continue
for ctx, out in r["outputs"].items():
if ctx != "global":
print(f" --- VRF: {ctx} ---")
lines = out.splitlines()
if pattern:
lines = [ln for ln in lines if pattern.search(ln)]
if not lines:
print(" (no line matched --grep)")
continue
for ln in lines:
print(f" {ln}")
if args.json_out:
Path(args.json_out).write_text(json.dumps(results, indent=2), encoding="utf-8")
ok = sum(1 for r in results if r["status"] == "ok")
failed = sum(1 for r in results if r["status"] == "error")
skipped = sum(1 for r in results if r["status"] == "skip")
print("\n" + "=" * 70)
print(f"Done. {ok} ok, {skipped} skipped, {failed} failed (of {len(devices)}).")
return 1 if failed 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 connect to, each with a host and an os field. See the output example below for the full format and every valid os value.
--commandtextRun this exact command on every device regardless of OS. Short form: -c. Mutually exclusive with --commands-file.
--commands-filetextYAML/JSON file mapping each os key to its own command. Use this when the same question has different syntax per platform — 'show interfaces terse' on Junos vs 'show ip interface brief' on IOS.
--limittextOnly query hosts whose name contains this text.
--ostextOnly query devices running this OS, e.g. --os nxos.
--output-dirtextAlso save each device's full output to a timestamped file in this directory.
--greptextOnly print output lines matching this regex. The saved files still contain everything.
--quietboolPrint only the status line per device, not the command output. Useful with --output-dir.
--read-timeoutnumberSeconds to wait for each command. Raise it for slow commands like 'show tech-support'. Default: 90
--workersnumberHow many devices to contact at once. Default: 10
--json-outtextWrite machine-readable results to this file.
What This Solves
"Which of our switches still has that old NTP server?" "Is the new ACL on every edge router?" "Which boxes are running the version with the bug?" Every one of those is a `show` command run forty times, and the slow part is not the command - it is logging into forty devices and keeping track of which ones you already did. This runs one read-only command across the whole fleet in parallel and collects the answers in one place. It can run a different command per platform in the same job, scope a query to a VRF, filter the output with a regex, and save every device's full output to a file. It is the tool for answering a question about the fleet. It is not a tool for changing anything - see the next section.
Is It Safe to Run
Yes, and the refusal is enforced rather than promised. THIS SCRIPT IS READ-ONLY BY DESIGN. It will not run a command that could change state, and it checks BEFORE opening a single session. A command starting with any of these is refused outright: config / configure write copy reload reboot erase delete clear no ... set ... request execute install rollback commit archive format shutdown Note `clear` is on that list. `clear counters` and `clear ip bgp` are read-only in the sense that they change no configuration, and they are absolutely not read-only in the sense that matters - `clear ip bgp *` drops every session on the box. The refusal message points you at the right tool: Refusing to run 'configure terminal' - this script is read-only. It has no backup, no verification, and no undo. For configuration changes use config-rollback (snapshot + automatic rollback) or one of the bulk-* scripts, which are built for writes. That is the honest reason. This script has none of the machinery that makes a write safe, so it does not do writes. **What it still can do:** run an expensive command on your whole fleet at once. `show tech-support` across 200 devices in parallel is read-only and is also a way to make a management network unhappy. Use --workers and --limit deliberately.
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. Most `show` commands need privileged exec - set NET_ENABLE if your account starts unprivileged.
Your First Run
Create devices.yaml with one device: devices: - host: 192.168.1.1 os: ios Set your credentials and run the default command: export NET_USER='admin' export NET_PASS='your-password' python ssh_runner.py --inventory devices.yaml With no --command it runs `show version` (or the platform equivalent: `show system info` on PAN-OS, `get system status` on FortiOS). That is a deliberate, harmless first run - it proves the credentials, the reachability and the `os` values in your inventory all work. Then ask a real question: python ssh_runner.py --inventory devices.yaml -c "show ip int brief" And narrow the output to what you care about: python ssh_runner.py --inventory devices.yaml \ -c "show ip int brief" --grep "up\s+up"
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python ssh_runner.py --inventory devices.yaml -c "show ip int brief" --grep "up\s+up" Running: show ip int brief Devices: 4 ====================================================================== [OK] rtr-core-01 (ios) $ show ip int brief GigabitEthernet0/0 10.0.0.1 YES NVRAM up up GigabitEthernet0/1 10.0.1.1 YES NVRAM up up --- VRF: MGMT --- GigabitEthernet0/2 192.168.9.1 YES NVRAM up up [OK] sw-leaf-04 (eos) $ show ip int brief Vlan100 10.100.0.2 up up [FAIL] rtr-branch-09 (iosxe) unreachable or SSH timed out ====================================================================== Done. 3 ok, 0 skipped, 1 failed Reading it: The command is echoed per device Because it is not always the same one. With --commands-file each platform gets its own, and the echoed line is what actually ran on that box - including the VRF suffix. --- VRF: MGMT --- A device with `vrfs:` in the inventory runs the command once per VRF as well as in the global table, and the blocks are labelled. [FAIL] rtr-branch-09 Did not answer. **This is the line people skim past and should not.** A fleet query where three devices failed has not told you about your fleet - it has told you about the devices that answered. If you are asking "is the ACL everywhere", a FAIL is not a "no", it is an "unknown". Done. 3 ok, 0 skipped, 1 failed `skipped` counts devices filtered out or without a command for their platform. With --grep, only matching lines are printed - but the files written by --output-dir always contain the complete output, so you can grep differently later without re-running the job.
Exit Codes for Cron and CI
0 every device answered 1 at least one device failed 130 you pressed Ctrl-C The script also stops immediately, before connecting to anything, with a plain message and no exit code of its own when: * --command and --commands-file are both given * the command would change state (the read-only guard) * --limit / --os matched no devices Exit 1 on any failure is the useful behaviour for a scheduled audit: a device that did not answer should not be silently excluded from the result.
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs - host: sw-floor2.example.com os: eos port: 2222 # optional, default 22 device_name: Floor 2 IDF # optional label for the output vrfs: [MGMT, CORP] # optional: also run per VRF 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: 192.168.1.3 os: nxos # except this one 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. The `os` value decides the default command, the VRF syntax and the netmiko driver, so getting it wrong produces a confusing answer rather than an error.
One Command per Platform
The same question is spelled differently on each platform. Rather than running the job once per vendor, map them in a file: ios: show ip interface brief iosxe: show ip interface brief nxos: show ip interface brief junos: show interfaces terse eos: show ip interface brief panos: show interface all fortios: get system interface python ssh_runner.py --inventory devices.yaml --commands-file cmds.yaml A device whose `os` has no entry in the file is reported as skipped, not failed - it was never asked anything. --command and --commands-file are mutually exclusive; passing both stops the run with a message.
Scoping to a VRF
Add `vrfs:` to a device and the command runs in the global table and then once per VRF, with the right syntax for the platform: ios, iosxe, iosxr, nxos, eos, fortios <command> vrf <name> junos <command> instance <name> panos <command> virtual-router <name> This only works for commands that accept the suffix. `show version vrf MGMT` is not a command - put the VRFs only on devices where the question is actually VRF-scoped.
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 They last only for the current terminal session, so the password never lands on disk.
Every Flag
--inventory PATH REQUIRED. The devices. --command TEXT The command, on every device. Short form -c. Mutually exclusive with --commands-file. --commands-file PATH One command per OS key. --limit TEXT Only hosts whose name contains TEXT. --os KEY Only devices running this OS, e.g. --os nxos. --grep REGEX Print only matching output lines. The files written by --output-dir keep everything. --quiet Print only the status line per device. Use it with --output-dir for a large job. --output-dir PATH Save each device's full output to a timestamped file. --read-timeout N Seconds per command. Raise it for slow ones like `show tech-support`. --workers N Devices at once. Default 10. --json-out PATH Machine-readable results.
When Something Fails
"Refusing to run '<command>' - this script is read-only." Working as intended. If you need to change something, use config-rollback or a bulk-* script, which have snapshots, verification and undo. If the command is genuinely read-only and merely starts with a blocked word, run it through its platform's `show` form instead. "Use --command or --commands-file, not both." Pick one. "No device matched the --limit / --os filters." The filters excluded everything. --limit is a plain substring of the host, and --os must be one of the eight keys exactly. "Set NET_USER and NET_PASS" Not set in this terminal. See Credentials. "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. Check with `ping` and `ssh` directly. If only some devices time out, raise --read-timeout; if all do, it is the path, not the timeout. A slow command times out on every device Raise --read-timeout. `show tech-support` can take minutes, and the default is not sized for it. Lower --workers at the same time. The output is empty but the device says [OK] The command ran and returned nothing, or --grep filtered everything out. Re-run without --grep, or read the full file under --output-dir. The command worked on one platform and not another Use --commands-file. A single --command is sent verbatim to everything, and `show ip interface brief` is not a Junos command.