Config Management · python · 29 copies
BGP Community Tagger
Generates BGP community tagging policy for IOS/IOS-XE, IOS-XR, NX-OS, Arista EOS and Junos from one set of prefix rules, and optionally pushes it with verification and automatic rollback. Gives each rule its own prefix-list so communities are not cross-applied, emits the trailing permit that stops a route-map silently dropping every unmatched prefix, and validates every prefix and community string before generating anything. Dry-run by default; pushes use commit-confirmed on IOS-XR and Junos.
- Arista EOS
- Cisco IOS
- Cisco IOS-XE
- Cisco IOS-XR
- Junos
- Cisco NX-OS
pip install "netmiko>=4.3" "PyYAML>=6.0"#!/usr/bin/env python3
"""
BGP Community Tagger
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/bgp-community-tagger
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 ipaddress
import re
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Community handling
# ---------------------------------------------------------------------------
WELL_KNOWN = {
"no-export": "no-export",
"no-advertise": "no-advertise",
"local-as": "local-AS",
"no-export-subconfed": "local-AS",
"graceful-shutdown": "graceful-shutdown",
"blackhole": "blackhole",
}
COMMUNITY_RE = re.compile(r"^(\d{1,5}:\d{1,5}|[\d]{1,10}|no-export|no-advertise|"
r"local-AS|graceful-shutdown|blackhole)$", re.IGNORECASE)
def normalise_community(value: str) -> str:
return WELL_KNOWN.get(str(value).lower(), str(value))
def validate_rules(rules: list[dict]) -> None:
"""Catch malformed rules before any config is generated or pushed."""
if not rules:
raise SystemExit("No tagging rules found. The file needs a `rules:` list.")
for i, r in enumerate(rules, 1):
if not isinstance(r, dict):
raise SystemExit(f"Rule #{i} is not a mapping.")
for key in ("prefix", "community", "action"):
if key not in r:
raise SystemExit(f"Rule #{i} is missing `{key}:`")
try:
ipaddress.ip_network(r["prefix"], strict=False)
except ValueError as exc:
raise SystemExit(f"Rule #{i}: invalid prefix '{r['prefix']}' - {exc}")
if r["action"] not in ("add", "remove", "replace"):
raise SystemExit(f"Rule #{i}: action must be add, remove or replace, "
f"got '{r['action']}'")
if not COMMUNITY_RE.match(str(r["community"])):
raise SystemExit(
f"Rule #{i}: '{r['community']}' is not a valid community.\n"
f"Use ASN:VALUE (e.g. 65000:100) or a well-known name "
f"(no-export, no-advertise, local-as, graceful-shutdown).")
# ---------------------------------------------------------------------------
# Config generation, per platform
# ---------------------------------------------------------------------------
def generate_ios(rules: list[dict], rm: str, pl: str) -> list[str]:
"""
IOS / IOS-XE / NX-OS / EOS route-map.
Each rule gets its OWN prefix-list and its own route-map sequence. The
original approach of one shared prefix-list made every sequence match
every prefix, so whichever community came first won for all of them.
"""
out: list[str] = []
for i, r in enumerate(rules, 1):
name = f"{pl}-{i}"
out.append(f"ip prefix-list {name} seq 10 permit {r['prefix']}")
out.append("!")
for i, r in enumerate(rules, 1):
seq = i * 10
comm = normalise_community(r["community"])
out.append(f"route-map {rm} permit {seq}")
out.append(f" match ip address prefix-list {pl}-{i}")
if r["action"] == "add":
out.append(f" set community {comm} additive")
elif r["action"] == "remove":
out.append(f" set comm-list {rm}-DEL delete")
else:
out.append(f" set community {comm}")
out.append("!")
# A route-map ends with an implicit deny; without this every prefix not
# matched above would be dropped by the policy.
out.append(f"route-map {rm} permit 65535")
out.append("!")
removals = [normalise_community(r["community"]) for r in rules
if r["action"] == "remove"]
if removals:
out.insert(0, f"ip community-list expanded {rm}-DEL permit "
f"{'|'.join(removals)}")
out.insert(1, "!")
return out
def generate_iosxr(rules: list[dict], policy: str) -> list[str]:
out = [f"route-policy {policy}"]
for i, r in enumerate(rules):
comm = normalise_community(r["community"])
kw = "if" if i == 0 else "elseif"
out.append(f" {kw} destination in ({r['prefix']}) then")
if r["action"] == "add":
out.append(f" set community ({comm}) additive")
elif r["action"] == "remove":
out.append(f" delete community in ({comm})")
else:
out.append(f" set community ({comm})")
out.append(" endif")
out.append(" pass") # without this, unmatched routes are dropped
out.append("end-policy")
return out
def generate_junos(rules: list[dict], policy: str) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for r in rules:
comm = normalise_community(r["community"])
cname = "COMM-" + re.sub(r"[^A-Za-z0-9]", "-", comm).upper()
if cname not in seen:
out.append(f"set policy-options community {cname} members {comm}")
seen.add(cname)
for i, r in enumerate(rules, 1):
comm = normalise_community(r["community"])
cname = "COMM-" + re.sub(r"[^A-Za-z0-9]", "-", comm).upper()
term = f"TERM-{i}"
out.append(f"set policy-options policy-statement {policy} term {term} "
f"from route-filter {r['prefix']} exact")
verb = {"add": "add", "remove": "delete", "replace": "set"}[r["action"]]
out.append(f"set policy-options policy-statement {policy} term {term} "
f"then community {verb} {cname}")
out.append(f"set policy-options policy-statement {policy} term {term} "
f"then accept")
out.append(f"set policy-options policy-statement {policy} term DEFAULT then accept")
return out
def generate(os_key: str, rules: list[dict], args) -> tuple[list[str], str]:
if os_key in ("ios", "iosxe"):
return generate_ios(rules, args.route_map, args.prefix_list), "IOS/IOS-XE route-map"
if os_key == "nxos":
return generate_ios(rules, args.route_map, args.prefix_list), "NX-OS route-map"
if os_key == "eos":
return generate_ios(rules, args.route_map, args.prefix_list), "Arista EOS route-map"
if os_key == "iosxr":
return generate_iosxr(rules, args.policy), "IOS-XR route-policy"
if os_key == "junos":
return generate_junos(rules, args.policy), "Junos policy-statement"
raise ValueError(f"BGP community tagging is not generated for os '{os_key}'")
UNSUPPORTED = {
"panos": "PAN-OS BGP policy is configured through its own object model",
"fortios": "FortiOS route-map syntax differs enough to need its own template",
}
# ---------------------------------------------------------------------------
# Push with verification
# ---------------------------------------------------------------------------
VERIFY_CMD = {
"ios": "show route-map {rm}",
"iosxe": "show route-map {rm}",
"nxos": "show route-map {rm}",
"eos": "show route-map {rm}",
"iosxr": "show rpl route-policy {policy}",
"junos": "show configuration policy-options policy-statement {policy}",
}
def apply_to_device(dev: dict, lines: list[str], args) -> dict:
host = dev["host"]
os_key = dev["os"]
conn = None
try:
conn = connect(dev)
if os_key == "iosxr":
conn.send_config_set(lines, exit_config_mode=False)
out = conn.send_command_timing(f"commit confirmed {args.confirm_minutes} minutes")
if "rror" in out or "ailed" in out:
conn.send_command_timing("abort")
return {"host": host, "os": os_key, "status": "failed",
"error": f"commit confirmed rejected: {out.strip()[:200]}"}
conn.exit_config_mode()
token = "iosxr"
elif os_key == "junos":
conn.config_mode()
for ln in lines:
r = conn.send_command_timing(ln)
if "error" in r.lower():
conn.send_command_timing("rollback 0")
conn.exit_config_mode()
return {"host": host, "os": os_key, "status": "failed",
"error": f"rejected '{ln}': {r.strip()[:160]}"}
r = conn.send_command_timing(f"commit confirmed {args.confirm_minutes}")
if "error" in r.lower():
conn.send_command_timing("rollback 0")
conn.exit_config_mode()
return {"host": host, "os": os_key, "status": "failed",
"error": f"commit confirmed failed: {r.strip()[:200]}"}
token = "junos"
else:
out = conn.send_config_set(lines, read_timeout=120)
for marker in ("% Invalid", "% Incomplete", "% Ambiguous", "ERROR:"):
if marker in out:
return {"host": host, "os": os_key, "status": "failed",
"error": f"device rejected config ({marker})"}
token = "plain"
# Verify the policy exists before confirming anything
vcmd = VERIFY_CMD[os_key].format(rm=args.route_map, policy=args.policy)
verify = conn.send_command(vcmd, read_timeout=60)
target = args.policy if os_key in ("iosxr", "junos") else args.route_map
if target not in verify:
if token == "iosxr":
conn.send_command_timing("abort")
elif token == "junos":
conn.config_mode()
conn.send_command_timing("rollback 1")
conn.send_command_timing("commit")
conn.exit_config_mode()
return {"host": host, "os": os_key, "status": "rolled_back",
"error": f"'{target}' not present after push - change reverted"}
if token == "iosxr":
conn.send_command_timing("commit")
elif token == "junos":
conn.config_mode()
conn.send_command_timing("commit")
conn.exit_config_mode()
elif os_key in ("ios", "iosxe", "eos"):
conn.send_command_timing("write memory")
elif os_key == "nxos":
conn.send_command_timing("copy running-config startup-config")
return {"host": host, "os": os_key, "status": "ok",
"lines": len(lines), "verified": target}
except NetmikoAuthenticationException as exc:
return {"host": host, "os": os_key, "status": "failed",
"error": f"authentication failed - check NET_USER/NET_PASS ({exc})"}
except NetmikoTimeoutException as exc:
note = ("device will self-revert" if os_key in ("iosxr", "junos")
else "VERIFY DEVICE STATE MANUALLY")
return {"host": host, "os": os_key, "status": "failed",
"error": f"timeout: {exc} - {note}"}
except Exception as exc: # noqa: BLE001 - one device must not abort the run
return {"host": host, "os": os_key, "status": "failed",
"error": f"{type(exc).__name__}: {exc}"}
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
def load_rules(path: str) -> list[dict]:
p = Path(path)
if not p.exists():
raise SystemExit(
f"Rules file not found: {path}\n"
f"Create it with:\n\n"
f" rules:\n"
f" - prefix: 10.0.0.0/8\n"
f" community: \"65000:100\"\n"
f" action: add\n")
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}")
rules = data.get("rules", data) if isinstance(data, dict) else data
validate_rules(rules)
return rules
def main() -> int:
ap = argparse.ArgumentParser(
description="Generate and optionally apply BGP community tagging policy.",
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 routers to target")
ap.add_argument("--rules", required=True,
help="YAML/JSON file of prefix/community/action tagging rules")
ap.add_argument("--apply", action="store_true",
help="Push the generated config. Omit for dry-run, the default")
ap.add_argument("--confirm-minutes", type=int, default=5,
help="Auto-revert window on IOS-XR and Junos. Default: 5")
ap.add_argument("--route-map", default="SET-BGP-COMMUNITIES",
help="Route-map name on IOS/IOS-XE/NX-OS/EOS")
ap.add_argument("--prefix-list", default="PFX-COMMUNITY-TAG",
help="Prefix-list name prefix; each rule gets its own list")
ap.add_argument("--policy", default="BGP-COMMUNITY-POLICY",
help="Policy name on IOS-XR and Junos")
ap.add_argument("--limit", help="Only target hosts whose name contains this text")
ap.add_argument("--out-dir", help="Write the generated config per device to files here")
ap.add_argument("--workers", type=int, default=6)
ap.add_argument("--json-out", help="Write machine-readable results here")
args = ap.parse_args()
rules = load_rules(args.rules)
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}'.")
if args.apply:
credentials()
mode = "APPLY" if args.apply else "DRY-RUN"
print(f"{mode} | {len(rules)} tagging rule(s) | {len(devices)} device(s)")
if args.apply:
print("Live change. IOS-XR and Junos devices self-revert if this script dies.")
print("=" * 80)
generated: list[tuple[dict, list[str], str]] = []
for dev in devices:
try:
lines, kind = generate(dev["os"], rules, args)
except ValueError:
note = UNSUPPORTED.get(dev["os"], f"not generated for {dev['os']}")
print(f"[SKIP] {dev['host']} ({dev['os']}) {note}")
continue
generated.append((dev, lines, kind))
print(f"\n{dev['host']} ({dev['os']}) - {kind}, {len(lines)} line(s)")
if not args.apply:
for ln in lines:
print(f" {ln}")
if args.out_dir:
d = Path(args.out_dir)
d.mkdir(parents=True, exist_ok=True)
(d / f"{dev['host']}.cfg").write_text("\n".join(lines) + "\n", encoding="utf-8")
if args.out_dir and generated:
print(f"\nWrote {len(generated)} config file(s) to {args.out_dir}/")
print("Review them, then lint with config_validator.py before pushing.")
if not args.apply:
print("\n" + "=" * 80)
print("DRY-RUN. Nothing was sent. Re-run with --apply to push.")
return 0
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(apply_to_device, dev, lines, args): dev["host"]
for dev, lines, _ in generated}
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": "failed",
"error": f"worker crashed: {exc}"})
print("\n" + "=" * 80)
for r in sorted(results, key=lambda x: x["host"]):
tag = {"ok": "[OK]", "failed": "[FAIL]", "rolled_back": "[RBACK]"}[r["status"]]
detail = (f"{r['lines']} line(s) applied, '{r['verified']}' verified on device"
if r["status"] == "ok" else r.get("error", ""))
print(f"{tag:<8} {r['host']:<24} {r['os']:<8} {detail}")
ok = sum(1 for r in results if r["status"] == "ok")
bad = len(results) - ok
print(f"\n{ok} applied and verified, {bad} failed or reverted.")
if args.json_out:
Path(args.json_out).write_text(json.dumps(results, indent=2), encoding="utf-8")
return 1 if bad else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted. Devices inside a confirm window will self-revert.",
file=sys.stderr)
sys.exit(130)
--inventorytextrequiredPath to your devices.yaml file. Lists which routers to target, each with a host and an os field. See the output example below for the full format.
--rulestextrequiredYAML/JSON file of tagging rules: prefix, community and action (add, remove or replace) per entry. Validated before any config is generated.
--applyboolPush the generated config to the devices. Omit for dry-run, which is the default and only prints what would be sent.
--confirm-minutesnumberAuto-revert window on IOS-XR and Junos. The device undoes the change by itself if the script never confirms. Default: 5
--route-maptextRoute-map name on IOS/IOS-XE/NX-OS/EOS. Default: SET-BGP-COMMUNITIES
--prefix-listtextBase name for the prefix-lists. Each rule gets its own, suffixed with its number. Default: PFX-COMMUNITY-TAG
--policytextPolicy name on IOS-XR and Junos. Default: BGP-COMMUNITY-POLICY
--limittextOnly target hosts whose name contains this text.
--out-dirtextWrite the generated config per device to .cfg files here, for review or for config-validator.
--workersnumberHow many devices to push to at once. Default: 6
--json-outtextWrite machine-readable results to this file.
What This Solves
BGP communities are how you tag routes so the rest of the network can act on them: prefer this path, do not export that prefix, blackhole this one upstream. The tagging itself is simple. Writing it correctly on five different platforms, and not breaking BGP while you do it, is not. Two mistakes account for most of the damage: One prefix-list shared by every rule. Write the rules in a hurry and every prefix ends up matching the same list, so every route gets every community. The symptom is subtle - routes are still advertised, just tagged wrong, and the upstream acts on them. The missing trailing permit. A route-map ends with an implicit deny. A policy that tags three prefixes and does not end with a permit **drops every prefix it did not match**. That is a full outage from a tagging change, and it is the single most common way this goes wrong. This script generates the policy from one set of rules for IOS/IOS-XE, IOS-XR, NX-OS, EOS and Junos, gives every rule its own prefix-list, always emits the trailing permit, validates the prefixes and the community strings before generating anything, and can push it with verification and automatic rollback. Use it to roll out a community scheme across a fleet, or just to generate the config correctly and review it before pushing it yourself.
Is It Safe to Run
DRY-RUN IS THE DEFAULT. Without --apply it prints the config and sends nothing. In dry-run it does not even connect to push - it generates the policy per platform and shows it to you, line by line. With --out-dir it also writes one `.cfg` per device for review. The intended workflow is three tools, in order: 1. this script, dry-run generate and read the policy 2. config_validator.py lint it 3. this script --apply or push with config_rollback.py When you do pass --apply: Rules are validated first. Before any config is generated: the prefix must parse, the action must be add, remove or replace, and the community must be ASN:VALUE or a well-known name (no-export, no-advertise, local-as, graceful-shutdown). A bad rule stops the run with a message naming the rule number - nothing is sent to anything. IOS-XR and Junos push inside a confirmation window. `commit confirmed <--confirm-minutes>`. If the script dies before confirming, the device reverts by itself. These two are the safest platforms to do this on. The policy is verified before the commit is confirmed. After pushing, it reads the policy back off the device. If it is not there, the change is reverted - `abort` on IOS-XR, `rollback 1` and commit on Junos - and the device reports ROLLED BACK. Saving happens only after that verification passes. `write memory` on IOS, IOS-XE and EOS; `copy running-config startup-config` on NX-OS; the commit itself on IOS-XR and Junos. **What it does not do:** it creates the policy, it does not attach it to a neighbour. Nothing takes effect until you apply the route-map or route-policy to a BGP neighbour or peer-group yourself. That is deliberate - attaching a policy is the step that changes what your neighbours see, and it should be a decision you make at the CLI, with the policy already in place and verified.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "netmiko>=4.3" "PyYAML>=6.0" 3. For --apply only: SSH access with configuration privilege. Generating and reviewing the config needs neither. 4. Know your community scheme. This script writes the policy you describe correctly; it cannot tell you that 65000:100 is the right tag.
Your First Run
STEP 1 - the rules rules: - prefix: 10.0.0.0/8 community: "65000:100" action: add - prefix: 192.168.1.0/24 community: "65000:200" action: add STEP 2 - the inventory devices: - host: 192.168.1.1 os: iosxe STEP 3 - generate and read it python bgp_community_tagger.py --inventory devices.yaml --rules tags.yaml Nothing is sent. You get the full policy for each platform printed. Read it, and specifically confirm two things: * each rule has its OWN prefix-list, numbered * the last line is `route-map <name> permit 65535` (or the equivalent catch-all on IOS-XR and Junos) STEP 4 - keep it for review ... --out-dir ./generated One `.cfg` per device, ready to lint with `config_validator.py` or push with `config_rollback.py`. STEP 5 - push, if you want this script to do it export NET_USER='admin' export NET_PASS='your-password' python bgp_community_tagger.py --inventory devices.yaml --rules tags.yaml --apply Then attach the policy to the neighbours yourself. Until you do, nothing has changed about what you advertise.
What the Output Means
$ python bgp_community_tagger.py --inventory devices.yaml --rules tags.yaml DRY-RUN | 3 tagging rule(s) | 3 device(s) ================================================================================ rtr-edge-01 (iosxe) - IOS/IOS-XE route-map, 17 line(s) ip prefix-list PFX-COMMUNITY-TAG-1 seq 10 permit 10.0.0.0/8 ip prefix-list PFX-COMMUNITY-TAG-2 seq 10 permit 192.168.1.0/24 ip prefix-list PFX-COMMUNITY-TAG-3 seq 10 permit 172.16.0.0/12 ! route-map SET-BGP-COMMUNITIES permit 10 match ip address prefix-list PFX-COMMUNITY-TAG-1 set community 65000:100 additive ! route-map SET-BGP-COMMUNITIES permit 20 match ip address prefix-list PFX-COMMUNITY-TAG-2 set community 65000:200 additive ! route-map SET-BGP-COMMUNITIES permit 65535 ! The two lines to check every time: PFX-COMMUNITY-TAG-1, -2, -3 One prefix-list per rule, suffixed with the rule number. If you ever see every sequence matching the same list, the policy is wrong. route-map SET-BGP-COMMUNITIES permit 65535 The trailing permit. Without it the implicit deny at the end of a route-map drops every prefix the policy did not match. Its presence is the difference between a tagging change and an outage. `set community ... additive` `action: add` emits `additive`, which preserves communities the route already carries. `action: replace` does not. That is the whole difference between the two actions, and it matters when an upstream already tagged the route. With --apply you get a per-device result line instead of the config: [OK] pushed, policy verified on the device, saved [SKIP] no generator for this platform - see Supported Platforms [FAIL] the device rejected the configuration [RBACK] pushed, the policy was not present when read back, change reverted
Exit Codes for Cron and CI
0 dry-run completed, or every device applied and verified 1 at least one device failed or rolled back 130 you pressed Ctrl-C A malformed rule does not return a code - it stops the run immediately with a message naming the rule number, before anything is generated. A dry run always exits 0: it is a report, not a verdict. If you want a gate, lint the generated files with `config_validator.py`, which does return 2 on a blocking error.
The Rules File
rules: - prefix: 10.0.0.0/8 community: "65000:100" action: add - prefix: 192.168.1.0/24 community: no-export action: add - prefix: 172.16.0.0/12 community: "65000:666" action: replace Every rule needs all three keys. The validation is strict on purpose: prefix must parse as a network. `10.0.0.0/8`, not `10.0.0.0 /8`. community `ASN:VALUE`, or one of the well-known names: no-export, no-advertise, local-as, graceful-shutdown. **Quote it in YAML** - `65000:100` unquoted is read as a sexagesimal number by some YAML parsers and will not be what you typed. action add tag the route, keeping existing communities (emits `additive`) replace tag the route, discarding existing communities remove strip the matching community. On IOS-family platforms this also generates an expanded community-list named `<route-map>-DEL` A bad rule stops everything with `Rule #2: action must be add, remove or replace, got 'delete'` - naming the rule, not just complaining.
Supported Platforms
ios Cisco IOS route-map + prefix-lists iosxe Cisco IOS-XE route-map + prefix-lists nxos Cisco NX-OS route-map + prefix-lists eos Arista EOS route-map + prefix-lists iosxr Cisco IOS-XR route-policy, commit confirmed junos Juniper Junos policy-statement, commit confirmed Two platforms are deliberately not generated, and are reported [SKIP] with the reason rather than being given something approximate: panos PAN-OS BGP policy uses its own object model fortios FortiOS route-map syntax needs its own template --confirm-minutes only applies to IOS-XR and Junos, because they are the two with a native confirmation window.
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-01 os: iosxr port: 2222 # optional, default 22 This script uses the shared inventory loader, so a `defaults:` block applies to every device below it: defaults: os: iosxe port: 22 devices: - host: 192.168.1.1 - host: 192.168.1.2 - host: rtr-core-01 os: iosxr # overrides the default Credentials never go in this file.
Credentials
Only needed for --apply. From the environment - never from a file, never from an argument (visible in `ps auxww`, kept in shell history): NET_USER username required NET_PASS password required NET_ENABLE enable secret optional 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. --rules PATH REQUIRED. The tagging rules. --apply Push. Without it, generate and print only. --confirm-minutes N Auto-revert window on IOS-XR and Junos. The device undoes the change by itself if the script never confirms. --route-map NAME Name on IOS/IOS-XE/NX-OS/EOS. Default SET-BGP-COMMUNITIES. --prefix-list NAME Base name for the per-rule prefix-lists, each suffixed with its rule number. --policy NAME Name on IOS-XR and Junos. Default BGP-COMMUNITY-POLICY. --limit TEXT Only hosts whose name contains TEXT. --out-dir PATH Write the generated config per device as .cfg files, for review or for config_rollback.py. --workers N Devices in parallel. Default 6. --json-out PATH Machine-readable results. If you are replacing an existing policy, set --route-map and --policy to the names already in use on the devices. Otherwise you will create a second, unattached policy and wonder why nothing changed.
When Something Fails
"Rule #2 is missing `community:`" Exactly what it says, with the rule number. Nothing was generated. "Rule #3: '65000.100' is not a valid community" Use ASN:VALUE with a colon, or a well-known name. The message lists the accepted names. The community came out as a strange number Unquoted `65000:100` in YAML. Quote it: `community: "65000:100"`. [SKIP] on a PAN-OS or FortiOS device No generator for those platforms, by design. The message says why. [FAIL] "device rejected config" The platform refused a line. The usual causes: the route-map or prefix-list name collides with an existing object of a different type, or the account cannot enter configuration mode. [RBACK] "'SET-BGP-COMMUNITIES' not present after push" The device accepted the commands and the policy was not there when read back. The change was reverted. On IOS-XR and Junos this is usually an uncommitted candidate; elsewhere, check whether something else owns that name. You pushed it and nothing changed The policy exists and is not attached to anything. This script creates the policy only - apply the route-map or route-policy to the BGP neighbour or peer-group yourself, then soft-clear the session. Routes disappeared after applying the policy Check for the trailing permit. If you are attaching a hand-written policy rather than this one, a route-map without a final permit sequence drops every prefix it does not match. That is the mistake this script exists to prevent.