Automation · python · 19 copies
Bulk Route Injection with Blast-Radius Guards
Injects or withdraws static routes, BGP network statements, and redistribution across many routers with the guards that stop a routing change becoming an outage. Refuses default routes and over-broad prefixes, detects martians, analyses overlap against the live routing table to show what each route would shadow, verifies next-hop reachability so a route cannot silently black-hole, caps how many routes one run may add, and captures BGP peer count before and after — automatically reverting the whole change if a session drops. Full VRF awareness on every platform.
- 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
"""
Bulk Route Injection with Blast-Radius Guards
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/bulk-route-injection
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 ipaddress
import json
import logging
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
yaml = None
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
LOG = logging.getLogger("bulkroute")
NETMIKO_DRIVER = {
"ios": "cisco_ios", "iosxe": "cisco_ios", "iosxr": "cisco_xr",
"nxos": "cisco_nxos", "junos": "juniper_junos", "eos": "arista_eos",
"fortios": "fortinet",
}
# Routing-table and BGP summary commands, global and per-VRF.
SHOW_ROUTE = {
"ios": ("show ip route", "show ip route vrf {vrf}"),
"iosxe": ("show ip route", "show ip route vrf {vrf}"),
"iosxr": ("show route", "show route vrf {vrf}"),
"nxos": ("show ip route", "show ip route vrf {vrf}"),
"junos": ("show route", "show route table {vrf}.inet.0"),
"eos": ("show ip route", "show ip route vrf {vrf}"),
"fortios": ("get router info routing-table all", "get router info routing-table all"),
}
SHOW_BGP = {
"ios": ("show ip bgp summary", "show ip bgp vpnv4 vrf {vrf} summary"),
"iosxe": ("show ip bgp summary", "show ip bgp vpnv4 vrf {vrf} summary"),
"iosxr": ("show bgp summary", "show bgp vrf {vrf} summary"),
"nxos": ("show bgp summary", "show bgp vrf {vrf} summary"),
"junos": ("show bgp summary", "show bgp summary instance {vrf}"),
"eos": ("show ip bgp summary", "show ip bgp vrf {vrf} summary"),
"fortios": ("get router info bgp summary", "get router info bgp summary"),
}
ESTABLISHED = re.compile(r"\b(Established|Establ|ESTABLISHED)\b|\s\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\S*\s*\d+\s*$")
# --------------------------------------------------------------------------
# Model
# --------------------------------------------------------------------------
@dataclass
class RouteSpec:
prefix: str # "10.20.0.0/16"
next_hop: str = "" # IP, or interface name for point-to-point
interface: str = "" # optional egress interface
vrf: str = ""
distance: int = 0 # administrative distance / preference
tag: int = 0
description: str = ""
kind: str = "static" # static | bgp_network | redistribute
# bgp_network / redistribute only:
asn: int = 0
route_map: str = ""
source_protocol: str = "" # for redistribute: connected|static|ospf 1|...
@dataclass
class DeviceResult:
host: str
os: str
status: str = "pending" # ok | dry_run | failed | skipped | aborted | rolled_back
accepted: list[str] = field(default_factory=list)
rejected: dict[str, str] = field(default_factory=dict)
overlaps: list[str] = field(default_factory=list)
bgp_before: int = -1
bgp_after: int = -1
message: str = ""
undo_file: str = ""
elapsed_s: float = 0.0
# --------------------------------------------------------------------------
# Validation guards
# --------------------------------------------------------------------------
MARTIANS = [
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("224.0.0.0/4"),
ipaddress.ip_network("240.0.0.0/4"),
]
def validate_spec(spec: RouteSpec, args) -> str:
"""Return an empty string if acceptable, otherwise the reason to reject."""
try:
net = ipaddress.ip_network(spec.prefix, strict=False)
except ValueError as exc:
return f"invalid prefix: {exc}"
if net.prefixlen == 0 and not args.allow_default:
return "default route refused (use --allow-default)"
if net.prefixlen < args.min_prefix_len and net.prefixlen != 0:
return (f"/{net.prefixlen} is shorter than --min-prefix-len "
f"/{args.min_prefix_len}; a typo here affects a huge range")
if any(net.subnet_of(m) for m in MARTIANS if net.version == m.version):
return f"{net} falls in a martian/reserved range"
if spec.kind == "static":
if not spec.next_hop and not spec.interface:
return "static route needs a next_hop or an interface"
if spec.next_hop:
try:
nh = ipaddress.ip_address(spec.next_hop)
except ValueError:
return f"invalid next-hop '{spec.next_hop}'"
if nh in net and net.prefixlen < 31:
return f"next-hop {nh} is inside the destination {net} (recursive route)"
if spec.kind == "bgp_network" and not spec.asn:
return "bgp_network needs an asn"
if spec.kind == "redistribute" and (not spec.asn or not spec.source_protocol):
return "redistribute needs an asn and a source_protocol"
return ""
def parse_route_table(raw: str, os_key: str) -> list[ipaddress.IPv4Network]:
"""Extract the prefixes already present, used for overlap analysis."""
nets: list[ipaddress.IPv4Network] = []
for m in re.finditer(r"(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})", raw):
try:
nets.append(ipaddress.ip_network(f"{m.group(1)}/{m.group(2)}", strict=False))
except ValueError:
continue
# IOS prints classful headers plus subnets without a mask on the subnet line;
# the regex above covers the common "A.B.C.D/N" form on every platform.
return nets
def analyse_overlap(spec_net: ipaddress.IPv4Network,
existing: list[ipaddress.IPv4Network]) -> list[str]:
notes = []
for e in existing:
if e == spec_net:
notes.append(f"{spec_net} already present in the table")
elif spec_net.subnet_of(e):
notes.append(f"{spec_net} is more specific than existing {e} - it will win")
elif e.subnet_of(spec_net):
notes.append(f"existing {e} is more specific - it will continue to win over {spec_net}")
return notes
def next_hop_reachable(conn, os_key: str, nh: str, vrf: str) -> bool:
"""A static route to an unreachable next-hop installs but black-holes."""
if not nh:
return True
glob, pervrf = SHOW_ROUTE[os_key]
cmd = pervrf.format(vrf=vrf) if vrf else glob
if os_key in ("ios", "iosxe", "nxos", "eos"):
cmd = f"{cmd} {nh}"
elif os_key == "iosxr":
cmd = f"{cmd} {nh}"
elif os_key == "junos":
cmd = f"{cmd} {nh}"
try:
out = conn.send_command(cmd, read_timeout=60)
except Exception: # noqa: BLE001
return False
bad = ("Network not in table", "not found", "% Network", "0 destinations")
return not any(b.lower() in out.lower() for b in bad)
def count_bgp_peers(conn, os_key: str, vrf: str) -> int:
glob, pervrf = SHOW_BGP[os_key]
cmd = pervrf.format(vrf=vrf) if vrf else glob
try:
out = conn.send_command(cmd, read_timeout=90)
except Exception: # noqa: BLE001
return -1
if "% BGP not active" in out or "not configured" in out.lower():
return -1
count = 0
for ln in out.splitlines():
if re.match(r"^\d{1,3}(\.\d{1,3}){3}|^[0-9a-fA-F:]+:", ln.strip()):
if ESTABLISHED.search(ln) or re.search(r"\s(\d+)\s*$", ln):
count += 1
return count
# --------------------------------------------------------------------------
# Command construction
# --------------------------------------------------------------------------
def build_route_cmds(os_key: str, spec: RouteSpec, withdraw: bool) -> list[str]:
net = ipaddress.ip_network(spec.prefix, strict=False)
neg = "no " if withdraw else ""
if spec.kind == "static":
if os_key in ("ios", "iosxe", "nxos", "eos"):
vrf_part = f"vrf {spec.vrf} " if spec.vrf else ""
target = spec.interface or spec.next_hop
if spec.interface and spec.next_hop:
target = f"{spec.interface} {spec.next_hop}"
tail = ""
if spec.distance:
tail += f" {spec.distance}"
if spec.tag:
tail += f" tag {spec.tag}"
if spec.description and os_key in ("ios", "iosxe"):
tail += f" name {spec.description.replace(' ', '_')[:40]}"
return [f"{neg}ip route {vrf_part}{net.network_address} {net.netmask} "
f"{target}{tail}".rstrip()]
if os_key == "iosxr":
vrf_open = [f"router static", f" vrf {spec.vrf}"] if spec.vrf else ["router static"]
fam = " address-family ipv4 unicast" if not spec.vrf else " address-family ipv4 unicast"
target = spec.interface or spec.next_hop
line = f" {neg}{net} {target}"
if spec.distance:
line += f" {spec.distance}"
return [*vrf_open, fam, line]
if os_key == "junos":
base = (f"routing-instances {spec.vrf} routing-options" if spec.vrf
else "routing-options")
verb = "delete" if withdraw else "set"
cmds = [f"{verb} {base} static route {net} next-hop {spec.next_hop or spec.interface}"]
if spec.distance and not withdraw:
cmds.append(f"set {base} static route {net} preference {spec.distance}")
if spec.tag and not withdraw:
cmds.append(f"set {base} static route {net} tag {spec.tag}")
return cmds
if os_key == "fortios":
# FortiOS uses sequence-numbered entries; caller supplies tag as the seq
seq = spec.tag or 0
if withdraw:
return ["config router static", f" delete {seq}", "end"]
return ["config router static", f" edit {seq or 0}",
f" set dst {net.network_address} {net.netmask}",
f" set gateway {spec.next_hop}" if spec.next_hop else "",
f" set device {spec.interface}" if spec.interface else "",
f" set distance {spec.distance}" if spec.distance else "",
f" set comment \"{spec.description}\"" if spec.description else "",
" next", "end"]
if spec.kind == "bgp_network":
if os_key in ("ios", "iosxe"):
cmds = [f"router bgp {spec.asn}"]
if spec.vrf:
cmds += [" address-family ipv4 vrf " + spec.vrf]
else:
cmds += [" address-family ipv4"]
cmds.append(f" {neg}network {net.network_address} mask {net.netmask}")
return cmds
if os_key in ("nxos", "eos"):
cmds = [f"router bgp {spec.asn}"]
if spec.vrf:
cmds.append(f" vrf {spec.vrf}")
cmds += [" address-family ipv4 unicast", f" {neg}network {net}"]
return cmds
if os_key == "iosxr":
cmds = [f"router bgp {spec.asn}"]
if spec.vrf:
cmds.append(f" vrf {spec.vrf}")
cmds += [" address-family ipv4 unicast", f" {neg}network {net}"]
return cmds
if os_key == "junos":
verb = "delete" if withdraw else "set"
base = (f"routing-instances {spec.vrf} routing-options" if spec.vrf
else "routing-options")
return [f"{verb} {base} static route {net} discard",
f"{verb} policy-options policy-statement ADVERTISE term {net.network_address} "
f"from route-filter {net} exact",
f"{verb} policy-options policy-statement ADVERTISE term {net.network_address} "
f"then accept"]
if spec.kind == "redistribute":
if os_key in ("ios", "iosxe", "nxos", "eos", "iosxr"):
cmds = [f"router bgp {spec.asn}"]
if spec.vrf:
cmds.append(f" vrf {spec.vrf}" if os_key != "ios" else
f" address-family ipv4 vrf {spec.vrf}")
if os_key != "ios":
cmds.append(" address-family ipv4 unicast")
line = f" {neg}redistribute {spec.source_protocol}"
if spec.route_map:
line += (f" route-policy {spec.route_map}" if os_key == "iosxr"
else f" route-map {spec.route_map}")
cmds.append(line)
return cmds
if os_key == "junos":
verb = "delete" if withdraw else "set"
return [f"{verb} protocols bgp group EXTERNAL export {spec.route_map or 'REDIST'}"]
raise ValueError(f"kind '{spec.kind}' not supported on os '{os_key}'")
# --------------------------------------------------------------------------
# Device workflow
# --------------------------------------------------------------------------
def connect(dev: dict):
params = {
"device_type": NETMIKO_DRIVER[dev["os"]],
"host": dev["host"],
"username": os.environ.get("NET_USER", ""),
"password": os.environ.get("NET_PASS", ""),
"port": dev.get("port", 22),
"conn_timeout": 20,
"fast_cli": False,
}
if os.environ.get("NET_ENABLE"):
params["secret"] = os.environ["NET_ENABLE"]
conn = ConnectHandler(**params)
if params.get("secret") and dev["os"] in ("ios", "iosxe", "nxos"):
conn.enable()
return conn
def process(dev: dict, specs: list[RouteSpec], args, stamp: str) -> DeviceResult:
res = DeviceResult(host=dev["host"], os=dev["os"])
t0 = time.time()
conn = None
applied: list[RouteSpec] = []
try:
# Offline validation first; no point connecting for a bad spec set
candidates: list[RouteSpec] = []
for s in specs:
why = validate_spec(s, args)
if why:
res.rejected[s.prefix] = why
else:
candidates.append(s)
if not candidates:
res.status = "failed" if res.rejected else "skipped"
res.message = f"no route passed validation ({len(res.rejected)} rejected)"
return res
if len(candidates) > args.max_routes:
res.status = "aborted"
res.message = (f"{len(candidates)} routes exceeds --max-routes {args.max_routes}; "
f"split the change or raise the cap deliberately")
return res
conn = connect(dev)
# Overlap and reachability analysis against live state
vrfs = {s.vrf for s in candidates}
tables: dict[str, list] = {}
for v in vrfs:
glob, pervrf = SHOW_ROUTE[dev["os"]]
cmd = pervrf.format(vrf=v) if v else glob
tables[v] = parse_route_table(conn.send_command(cmd, read_timeout=180), dev["os"])
accepted: list[RouteSpec] = []
for s in candidates:
net = ipaddress.ip_network(s.prefix, strict=False)
notes = analyse_overlap(net, tables.get(s.vrf, []))
res.overlaps.extend(f"[{s.vrf or 'global'}] {n}" for n in notes)
if any("already present" in n for n in notes) and not args.allow_existing:
res.rejected[s.prefix] = "already present in the routing table"
continue
if s.kind == "static" and s.next_hop and not args.skip_nexthop_check:
if not next_hop_reachable(conn, dev["os"], s.next_hop, s.vrf):
res.rejected[s.prefix] = (f"next-hop {s.next_hop} is not reachable "
f"(would black-hole; --skip-nexthop-check to force)")
continue
accepted.append(s)
res.accepted = [s.prefix for s in accepted]
if not accepted:
res.status = "skipped"
res.message = f"nothing to do ({len(res.rejected)} rejected)"
return res
# Build commands and the matching undo
cmds: list[str] = []
undo: list[str] = []
for s in accepted:
cmds.extend(build_route_cmds(dev["os"], s, withdraw=args.withdraw))
undo.extend(build_route_cmds(dev["os"], s, withdraw=not args.withdraw))
cmds = [c for c in cmds if c.strip()]
undo = [c for c in undo if c.strip()]
if args.undo_dir:
d = Path(args.undo_dir)
d.mkdir(parents=True, exist_ok=True)
p = d / f"{dev['host']}-{stamp}.cfg"
p.write_text("\n".join([
f"! Undo for {dev['host']} ({dev['os']}) generated {stamp}",
f"! Reverses {len(accepted)} route operation(s)",
"!", *undo, ""]), encoding="utf-8")
res.undo_file = str(p)
if not args.apply:
res.status = "dry_run"
verb = "withdraw" if args.withdraw else "inject"
res.message = (f"would {verb} {len(accepted)} route(s), "
f"{len(res.rejected)} rejected, {len(res.overlaps)} overlap note(s)")
return res
# Capture BGP health before touching routing
res.bgp_before = count_bgp_peers(conn, dev["os"], "")
out = conn.send_config_set(cmds, read_timeout=240)
for marker in ("% Invalid", "% Incomplete", "ERROR:", "Invalid input", "error:"):
if marker in out:
res.status = "failed"
res.message = f"device rejected config ({marker}); undo at {res.undo_file}"
return res
applied = accepted
time.sleep(args.settle_seconds)
res.bgp_after = count_bgp_peers(conn, dev["os"], "")
if res.bgp_before > 0 and res.bgp_after < res.bgp_before:
LOG.error("%s: BGP peers dropped %d -> %d, rolling back",
dev["host"], res.bgp_before, res.bgp_after)
conn.send_config_set(undo, read_timeout=240)
res.status = "rolled_back"
res.message = (f"BGP peers dropped {res.bgp_before} -> {res.bgp_after}; "
f"change reverted automatically")
return res
if dev["os"] in ("ios", "iosxe", "eos"):
conn.send_command_timing("write memory")
elif dev["os"] == "nxos":
conn.send_command_timing("copy running-config startup-config")
elif dev["os"] == "iosxr":
conn.send_command_timing("commit")
res.status = "ok"
verb = "withdrew" if args.withdraw else "injected"
res.message = (f"{verb} {len(accepted)} route(s), BGP peers "
f"{res.bgp_before} -> {res.bgp_after}")
except ValueError as exc:
res.status, res.message = "failed", f"spec error: {exc}"
except NetmikoAuthenticationException as exc:
res.status, res.message = "failed", f"authentication failed: {exc}"
except NetmikoTimeoutException as exc:
res.status = "failed"
res.message = (f"timeout: {exc} - ROUTING MAY BE PARTIALLY CHANGED, "
f"verify manually; undo at {res.undo_file}")
except Exception as exc: # noqa: BLE001
res.status, res.message = "failed", f"{type(exc).__name__}: {exc}"
if conn and applied:
res.message += " | attempted rollback"
try:
undo_cmds: list[str] = []
for s in applied:
undo_cmds.extend(build_route_cmds(dev["os"], s, withdraw=not args.withdraw))
conn.send_config_set([c for c in undo_cmds if c.strip()], read_timeout=240)
res.status = "rolled_back"
except Exception as rb: # noqa: BLE001
res.message += f" | ROLLBACK FAILED: {rb}"
finally:
res.elapsed_s = round(time.time() - t0, 1)
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
return res
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def load_specs(path: str) -> list[RouteSpec]:
raw = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(raw) if (yaml and path.endswith((".yaml", ".yml"))) else json.loads(raw)
items = data["routes"] if isinstance(data, dict) else data
return [RouteSpec(**r) for r in items]
def render(results: list[DeviceResult], args) -> int:
icon = {"ok": "[OK]", "dry_run": "[DRY]", "failed": "[FAIL]", "skipped": "[SKIP]",
"aborted": "[ABORT]", "rolled_back": "[RBACK]"}
for r in sorted(results, key=lambda x: x.host):
print(f"\n{icon.get(r.status, '[?]'):<8} {r.host:<20} {r.os:<7} "
f"{r.elapsed_s:>5.1f}s {r.message}")
if r.accepted and args.show_routes:
for chunk in (r.accepted[i:i + 4] for i in range(0, len(r.accepted), 4)):
print(f" {' '.join(c.ljust(20) for c in chunk)}")
for pfx, why in list(r.rejected.items())[:8]:
print(f" REJECT {pfx:<20} {why}")
if r.overlaps and args.show_overlaps:
for o in r.overlaps[:8]:
print(f" overlap: {o}")
if r.undo_file:
print(f" undo: {r.undo_file}")
tally: dict[str, int] = {}
total_rejected = 0
for r in results:
tally[r.status] = tally.get(r.status, 0) + 1
total_rejected += len(r.rejected)
print(f"\n{'=' * 78}")
print(f" {len(results)} device(s) {total_rejected} route(s) rejected by guards")
print(" " + " ".join(f"{k}={v}" for k, v in sorted(tally.items())))
if tally.get("rolled_back"):
print("\n Automatic rollback fired on BGP peer loss. Investigate before retrying.")
if tally.get("aborted"):
print("\n Aborted devices exceeded --max-routes.")
if not args.apply and tally.get("dry_run"):
print("\n Dry-run. Review the rejections and overlap notes, then re-run with --apply.")
bad = tally.get("failed", 0) + tally.get("rolled_back", 0) + tally.get("aborted", 0)
return 1 if bad else 0
def main() -> int:
ap = argparse.ArgumentParser(description="Inject or withdraw routes in bulk, safely.")
ap.add_argument("--inventory", required=True)
ap.add_argument("--routes", required=True, help="YAML/JSON list of route specs")
ap.add_argument("--apply", action="store_true", help="Write changes (default dry-run)")
ap.add_argument("--withdraw", action="store_true",
help="Remove the specified routes instead of adding them")
g = ap.add_argument_group("guards")
g.add_argument("--allow-default", action="store_true",
help="Permit 0.0.0.0/0 and ::/0. Off by default.")
g.add_argument("--min-prefix-len", type=int, default=8,
help="Refuse prefixes shorter than this. Default 8.")
g.add_argument("--max-routes", type=int, default=50,
help="Abort a device if more routes than this are accepted. Default 50.")
g.add_argument("--allow-existing", action="store_true",
help="Proceed even when the prefix is already in the table")
g.add_argument("--skip-nexthop-check", action="store_true",
help="Skip next-hop reachability verification. Risks black-holing.")
ap.add_argument("--settle-seconds", type=int, default=15,
help="Wait before re-checking BGP. Default 15.")
ap.add_argument("--undo-dir", default="./undo")
ap.add_argument("--workers", type=int, default=6)
ap.add_argument("--show-routes", action="store_true")
ap.add_argument("--show-overlaps", action="store_true")
ap.add_argument("--json-out")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s", datefmt="%H:%M:%S")
if not os.environ.get("NET_USER") or not os.environ.get("NET_PASS"):
LOG.error("Set NET_USER and NET_PASS in the environment.")
return 2
raw = Path(args.inventory).read_text(encoding="utf-8")
data = (yaml.safe_load(raw) if yaml and args.inventory.endswith((".yaml", ".yml"))
else json.loads(raw))
devices = data["devices"] if isinstance(data, dict) else data
specs = load_specs(args.routes)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
LOG.info("%s | %s %d route(s) on %d device(s) | min-prefix /%d | cap %d",
"APPLY" if args.apply else "DRY-RUN",
"withdraw" if args.withdraw else "inject",
len(specs), len(devices), args.min_prefix_len, args.max_routes)
if args.apply:
LOG.warning("Live routing change. BGP peer count is checked before and after; "
"the change reverts automatically if a peer drops.")
results: list[DeviceResult] = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = {pool.submit(process, d, specs, args, stamp): d for d in devices}
for fut in as_completed(futs):
d = futs[fut]
try:
results.append(fut.result())
except Exception as exc: # noqa: BLE001
LOG.exception("%s: worker crashed", d["host"])
results.append(DeviceResult(host=d["host"], os=d.get("os", "?"),
status="failed", message=f"crashed: {exc}"))
if args.json_out:
Path(args.json_out).write_text(
json.dumps([asdict(r) for r in results], indent=2), encoding="utf-8")
return render(results, args)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted. Routing may be partially changed - verify and use the undo files.",
file=sys.stderr)
sys.exit(130)
--inventorytextrequiredYAML/JSON inventory of routers, each with host and os.
--routestextrequiredYAML/JSON route specs: prefix, next_hop or interface, vrf, distance, tag, description, and kind (static, bgp_network, redistribute).
--applyboolWrite changes. Omit for dry-run, which is the default and reports every rejection and overlap.
--withdrawboolRemove the specified routes instead of adding them. The undo file then re-adds them.
--allow-defaultboolPermit 0.0.0.0/0 and ::/0. Refused by default — a default route in the wrong VRF leaks the internet.
--min-prefix-lennumberRefuse prefixes shorter than this. Default: 8. Stops a typo like 10.0.0.0/0 swallowing the table.
--max-routesnumberAbort a device if more routes than this are accepted. Default: 50.
--allow-existingboolProceed when the prefix is already in the routing table instead of rejecting it.
--skip-nexthop-checkboolSkip next-hop reachability verification. Risks installing a black-hole route.
--settle-secondsnumberWait before re-checking BGP peer count. Default: 15.
--undo-dirtextWhere per-device undo fragments are written. Default: ./undo
--show-routesboolPrint the accepted prefixes per device.
--show-overlapsboolPrint the overlap analysis against the existing routing table.
--workersnumberConcurrent devices. Default: 6.
--json-outtextMachine-readable results.
What This Solves
Adding routes across a set of routers is a small change with an unusually large downside. A static route to a next-hop that is not reachable installs cleanly and black-holes the traffic. A `/16` typed as a `/6` swallows the internet. A redistribution statement in the wrong place takes BGP down. None of those produce an error on the CLI - they produce an outage, some of them minutes later. This script does the same injection with the checks a careful engineer would do by hand on every router, and never skips one because it is late: It refuses the dangerous shapes outright. Default routes, prefixes shorter than --min-prefix-len (default /8), martian and reserved ranges, a static route with no next-hop, and a recursive route whose next-hop sits inside its own destination. It checks the next-hop is actually reachable. The single most common way to install a black hole. A route whose next-hop is not in the routing table is rejected, not installed. It tells you what the route will shadow. Compares each prefix against the live routing table and reports whether it is already there, whether it is more specific than something existing and will win, or whether a more specific route already exists and will keep winning - which is the case where the change appears to work and changes nothing. It watches BGP and reverts itself. Counts established BGP peers before the change, waits, counts again. If the number dropped, it pushes the undo immediately and reports ROLLED BACK. You do not have to be watching. Use it for branch summarisation, a new VRF's static routes, adding BGP network statements across a set of edge routers, or withdrawing a block of routes cleanly with --withdraw.
Is It Safe to Run
It writes to devices, and it changes routing - which is the highest-risk thing in this catalogue. Read this section fully. DRY-RUN IS THE DEFAULT. Without --apply nothing is written. A dry run connects, reads the routing table, validates every spec, checks every next-hop and prints the full rejection and overlap analysis. That analysis is worth running on its own even when you have no intention of applying anything. The guards, all on by default: Default routes refused 0.0.0.0/0 and ::/0 need --allow-default. A default route in the wrong VRF leaks everything. --min-prefix-len 8 Anything shorter is refused. This is the typo guard: 10.0.0.0/0 instead of /8. Martians refused 0.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 224.0.0.0/4, 240.0.0.0/4. Recursive routes refused A next-hop inside the destination prefix. Next-hop reachability Verified against the live table before the route is accepted. --skip-nexthop-check turns this off and the message says plainly that it risks a black hole. Existing prefix refused A prefix already in the table is rejected unless you pass --allow-existing. --max-routes 50 Per device. Exceeded aborts that device untouched. Undo fragments Written to ./undo before anything is sent, during a dry run too. With --withdraw the undo re-adds the routes. And two things that happen after the write: Automatic rollback on BGP peer loss Peers are counted before and after (with --settle-seconds between, default 15). If the count dropped, the undo is pushed immediately and the device reports [RBACK]. The change does not stay. Automatic rollback on an unexpected error If the run throws after some routes were applied, it attempts to reverse what it applied. If that reversal itself fails the message says `ROLLBACK FAILED` - that is the one case that needs you at the CLI immediately. On success it saves: `write memory` on IOS, IOS-XE and EOS, `copy running-config startup-config` on NX-OS, `commit` on IOS-XR. **One case the script cannot clean up:** a timeout mid-write. The message then says `ROUTING MAY BE PARTIALLY CHANGED, verify manually` and names the undo file. Believe it, and go look.
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 an account that can enter configuration mode. 4. A change window. The BGP guard reverts a change that drops peers, but reverting still means the routing table moved twice.
Your First Run
STEP 1 - inventory, one router devices: - host: 192.168.1.1 os: iosxe STEP 2 - the routes routes: - prefix: 10.20.0.0/16 next_hop: 192.168.1.254 description: Branch summary STEP 3 - dry run with the full analysis export NET_USER='admin' export NET_PASS='your-password' python bulk_route_injection.py \ --inventory devices.yaml --routes routes.yaml \ --show-routes --show-overlaps Nothing is written. Read three things: * the REJECT lines - every one is a guard catching something * the overlap notes - especially `a more specific route already exists`, which means your route will be installed and ignored * the undo file under ./undo STEP 4 - apply to that one router ... same command ... --apply Confirm the routing table by hand, then widen the inventory.
What the Output Means
$ export NET_USER='admin' && export NET_PASS='********' $ python bulk_route_injection.py --inventory devices.yaml --routes routes.yaml \ --show-routes --show-overlaps --apply [OK] rtr-branch-01 iosxe 19.3s injected 4 route(s), BGP peers 3 -> 3 10.20.0.0/16 10.21.0.0/16 10.22.0.0/16 10.23.0.0/16 REJECT 0.0.0.0/0 default route refused (use --allow-default) REJECT 10.99.0.0/16 next-hop 192.0.2.9 is not reachable (would black-hole; --skip-nexthop-check to force) overlap: [global] 10.20.0.0/16 is more specific than existing 10.0.0.0/8 - it will win undo: ./undo/rtr-branch-01-20260115-154801.cfg [RBACK] rtr-core-01 iosxr 34.2s BGP peers dropped 8 -> 6; change reverted automatically ============================================================================== 4 device(s) 8 route(s) rejected by guards ok=3 rolled_back=1 Automatic rollback fired on BGP peer loss. Investigate before retrying. Reading it: BGP peers 3 -> 3 The before and after count. Equal is what you want. This appears on every applied device, so you can see the change was routing-neutral rather than assume it. The statuses [OK] applied, saved, BGP peer count held [DRY] dry run [SKIP] nothing left to do on this device [ABORT] more than --max-routes accepted. NOTHING was sent [FAIL] rejected by the device, or an error. Read the message - a timeout here means routing may be partially changed [RBACK] applied, BGP peers dropped, change reverted automatically REJECT <prefix> <reason> A guard fired. These are not failures of the run - they are the script declining to do something dangerous. A run that is all rejects still exits 0 if no device failed. overlap: ... Only with --show-overlaps. Three shapes, and the third is the one people miss: already present in the table X is more specific than existing Y - it will win existing Y is more specific - it will continue to win over X ^ your route is installed and has no effect Automatic rollback fired... At least one device reverted itself. Investigate before retrying: the peers dropped for a reason, and the same change will do it again.
Exit Codes for Cron and CI
0 applied or dry-ran with no failures, aborts or rollbacks. Routes rejected by the guards do NOT make it non-zero 1 a device failed, was aborted on --max-routes, or rolled back 2 NET_USER / NET_PASS are not set 130 you pressed Ctrl-C Do not schedule this. Routing changes need a human who decided to make them; the exit codes are for gating a pipeline after that decision.
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: iosxe # REQUIRED: which OS it runs - host: rtr-core.example.com os: iosxr port: 2222 # optional, default 22 A bare list without the `devices:` key works too. `host`, `os` and `port` are the fields this script reads - no `defaults:` block, and credentials never come from this file.
Supported Platforms
ios Cisco IOS iosxe Cisco IOS-XE iosxr Cisco IOS-XR saves with `commit` nxos Cisco NX-OS eos Arista EOS junos Juniper Junos fortios Fortinet FortiOS Route syntax, the routing-table read and the BGP peer count are all per-platform. The one difference worth knowing: on FortiOS the routing table is read with `get router info routing-table all` and there is no per-VRF variant, so VRF-scoped checks there are against the whole table.
The Routes File
Three kinds of entry, selected by `kind`. The default is `static`. A static route: routes: - prefix: 10.20.0.0/16 next_hop: 192.168.1.254 vrf: CORP # optional distance: 200 # optional, administrative distance tag: 65001 # optional description: Branch summary interface: Gi0/1 # optional; for point-to-point you may # give interface instead of next_hop A BGP network statement: - prefix: 10.30.0.0/16 kind: bgp_network asn: 65001 route_map: BRANCH-OUT # optional Redistribution: - prefix: 0.0.0.0/0 # ignored for this kind, but required kind: redistribute asn: 65001 source_protocol: static # or connected, ospf 1, ... route_map: REDIST-FILTER A bare list without the `routes:` key works too. `distance` is how you inject a floating static that only takes over when the primary path is gone - set it higher than the protocol you are backing up.
Withdrawing Routes
python bulk_route_injection.py --inventory devices.yaml \ --routes routes.yaml --withdraw --apply The same specs, removed instead of added, and the undo file then re-adds them. Every guard still runs, so a withdrawal is dry-run by default too. Withdrawal is where the overlap analysis earns its keep: removing a more specific route silently hands the traffic to whatever less specific route is still there. Run it with --show-overlaps first and read what remains.
Credentials
From the environment, never from a file and never from an argument - an argument is visible in `ps auxww` and lands in shell history. NET_USER username required NET_PASS password required NET_ENABLE enable secret optional, used on ios/iosxe/nxos export NET_USER='admin' export NET_PASS='your-password' 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 routers. --routes PATH REQUIRED. What to inject or withdraw. --apply Write. Without it, dry run. --withdraw Remove these routes instead of adding them. --allow-default Permit 0.0.0.0/0 and ::/0. --min-prefix-len N Refuse anything shorter. Default 8. --allow-existing Proceed when the prefix is already in the table, instead of rejecting it. --skip-nexthop-check Do not verify the next-hop is reachable. Risks installing a black hole. --max-routes N Per-device cap. Default 50. --settle-seconds N Wait before re-counting BGP peers. Default 15. Raise it on a large table - counting too early looks like peer loss and triggers the rollback. --undo-dir PATH Default ./undo. --show-routes Print the accepted prefixes per device. --show-overlaps Print the overlap analysis. Read this. --workers N Devices in parallel. Default 6. --json-out PATH Machine-readable results - the change record.
When Something Fails
"Set NET_USER and NET_PASS in the environment." (exit 2) Not set in this terminal. See Credentials. REJECT ... "default route refused" Working as intended. If you genuinely mean to push a default, pass --allow-default, and check the `vrf:` on that entry twice. REJECT ... "/6 is shorter than --min-prefix-len /8" Almost always a typo. Verify the prefix before lowering the limit. REJECT ... "next-hop X is not reachable" The next-hop is not in the routing table being checked. Either the path is genuinely missing, or you are injecting into a VRF and the next-hop lives in a different one - check the `vrf:` field. Forcing it with --skip-nexthop-check installs a black hole. REJECT ... "next-hop X is inside the destination" A recursive route. The router would have to resolve the destination to reach the next-hop. Point it at the real upstream. REJECT ... "already present in the table" Pass --allow-existing if you intend to overwrite or re-state it. "selection of N exceeds --max-routes" Nothing was sent to that device. Split the change or raise the cap deliberately after reading --show-routes. [RBACK] "BGP peers dropped X -> Y" The change cost you peers and was reverted automatically. Before retrying, find out which peer and why - most often a redistribution statement that leaked, or a static route that shadowed the path to the peer itself. If the peers were simply slow to re-establish, raise --settle-seconds; do not disable the guard. "ROLLBACK FAILED" The run errored, tried to reverse itself, and could not. Routing is in an unknown state on that device. Go to the CLI now; the undo fragment under ./undo has the commands. "timeout ... ROUTING MAY BE PARTIALLY CHANGED" The session died mid-write. The script cannot know how much landed. Verify by hand against the undo file. The route was installed and nothing changed Read the overlap notes: `existing Y is more specific - it will continue to win`. Your route is in the table and losing the longest-prefix-match. That is not a bug in the script, it is the routing table telling you the change was not the one you needed.