diff --git a/cml/proxmox_bmp_config.py b/cml/proxmox_bmp_config.py new file mode 100644 index 0000000..e161607 --- /dev/null +++ b/cml/proxmox_bmp_config.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Apply the OpenBMP `bmp server 1` config to the Proxmox CML lab routers. + +IOS-XR BMP configuration is not exposed via the device's NETCONF YANG schema +on this release, so this applies config over the SSH CLI. It is idempotent — +re-applying an identical block commits no changes. + +PROX-R9K-03 was built without `bmp-activate` on its BGP neighbor-group; this +script adds it (the other 8 routers already have it from the re-addressing). + +Usage: + pip install paramiko + python3 cml/proxmox_bmp_config.py # all 9 routers + python3 cml/proxmox_bmp_config.py r9k-05 # one router (smoke test) + +Verify afterwards in OpenBMP: + docker exec -i obmp-psql psql -U openbmp -d openbmp \\ + -c "SELECT name, ip_address, bgp_id, isconnected FROM routers ORDER BY name;" +""" + +import sys +import time +import paramiko + +# --- BMP collector --------------------------------------------------------- +COLLECTOR_HOST = "10.40.40.202" +COLLECTOR_PORT = "5000" + +# `bmp server 1` block — flat formal form, identical to the ESXi lab. +# Each line is self-contained and applied at the (config)# prompt; a bare +# "bmp server 1" is deliberately omitted (it would drop into the bmp submode +# and the remaining flat lines would then be invalid). +BMP_LINES = [ + f"bmp server 1 host {COLLECTOR_HOST} port {COLLECTOR_PORT}", + "bmp server 1 description OpenBMP-Collector", + "bmp server 1 update-source MgmtEth0/RP0/CPU0/0", + "bmp server 1 initial-delay 60", + "bmp server 1 stats-reporting-period 300", + "bmp server 1 initial-refresh delay 60 spread 30", +] + +# Only PROX-R9K-03 needs this — its BMP-MONITORED neighbor-group was built +# without bmp-activate. AS 65021 is the Proxmox lab. +BMP_ACTIVATE_LINE = "router bgp 65021 neighbor-group BMP-MONITORED bmp-activate server 1" + +# --- router inventory ------------------------------------------------------ +# (name, mgmt_ip, user, password, needs_bmp_activate) +ROUTERS = [ + ("PROX-R9K-CORE-01", "10.100.1.100", "admin", "cisco", False), + ("PROX-R9K-CORE-02", "10.100.1.200", "admin", "cisco", False), + ("PROX-R9K-01", "10.100.1.1", "webui", "cisco", False), + ("PROX-R9K-02", "10.100.1.2", "webui", "cisco", False), + ("PROX-R9K-03", "10.100.1.3", "webui", "cisco", True), + ("PROX-R9K-04", "10.100.1.4", "webui", "cisco", False), + ("PROX-R9K-05", "10.100.1.5", "webui", "cisco", False), + ("PROX-R9K-06", "10.100.1.6", "webui", "cisco", False), + ("PROX-R9K-07", "10.100.1.7", "admin", "cisco", False), +] + + +def _drain(shell, settle=1.0, limit=15.0, until=None): + """Read from the shell. + + If `until` is given, keep reading until that substring appears (or `limit` + elapses). Otherwise return once output stops arriving for `settle` seconds. + """ + out = "" + start = time.time() + while time.time() - start < limit: + time.sleep(settle) + if shell.recv_ready(): + out += shell.recv(65535).decode(errors="replace") + if until and until in out: + break + elif until is None: + break + elif until in out: + break + return out + + +def apply_router(name, ip, user, pwd, needs_activate): + """Apply the BMP config to one router. Returns True on success.""" + print(f"\n=== {name} ({ip}) ===") + lines = list(BMP_LINES) + if needs_activate: + lines.append(BMP_ACTIVATE_LINE) + + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(ip, username=user, password=pwd, timeout=15, + look_for_keys=False, allow_agent=False) + shell = ssh.invoke_shell(width=220, height=1000) + time.sleep(2) + shell.recv(65535) # banner + + # "(config)#" is the universal IOS-XR config-prompt suffix — used as + # the wait marker so the device hostname is irrelevant. + CFG = "(config)#" + + shell.send("terminal length 0\n") + _drain(shell, 0.5, 5) + + # Enter config mode. IOS-XR may print an active-session banner first, + # so wait specifically for the (config) prompt. + shell.send("configure terminal\n") + out = _drain(shell, 0.4, 15, until=CFG) + if CFG not in out: + print(f" FAIL: could not enter config mode\n {out[-200:]}") + ssh.close() + return False + + # Send config lines, paced. + for line in lines: + shell.send(line + "\n") + time.sleep(0.4) + _drain(shell, 0.3, 8, until=CFG) + + # Confirm the candidate actually holds changes before committing. + shell.send("show configuration\n") + cand = _drain(shell, 0.3, 10, until=CFG) + if "bmp server" not in cand: + print(" OK: no changes (config already present) — nothing to commit") + shell.send("abort\n") + _drain(shell, 0.5, 5) + ssh.close() + return True + + shell.send("commit\n") + result = _drain(shell, 0.3, 25, until=CFG) + if "fail" in result.lower() or "error" in result.lower(): + print(f" FAIL: commit error\n {result[-300:]}") + shell.send("abort\n") + _drain(shell, 0.5, 5) + ssh.close() + return False + + # Leave config mode and fully drain (settle-based, no marker) so the + # verify output is clean — not contaminated by echoed config lines. + shell.send("end\n") + _drain(shell, 1.0, 10) + + shell.send("show run formal bmp\n") + verify = _drain(shell, 1.0, 12) + ok = f"host {COLLECTOR_HOST} port {COLLECTOR_PORT}" in verify + print(f" {'OK' if ok else 'FAIL'}: bmp server 1 " + f"{'present' if ok else 'NOT found'} in running config") + ssh.close() + return ok + + except Exception as e: + print(f" FAIL: {e}") + return False + + +def main(): + target = sys.argv[1].lower() if len(sys.argv) > 1 else None + results = {} + for name, ip, user, pwd, needs_activate in ROUTERS: + if target and target not in name.lower(): + continue + results[name] = apply_router(name, ip, user, pwd, needs_activate) + + print(f"\n{'='*48}\n SUMMARY") + for name, ok in results.items(): + print(f" {name:22s} {'OK' if ok else 'FAILED'}") + sys.exit(0 if all(results.values()) else 1) + + +if __name__ == "__main__": + main() diff --git a/exabgp/startup.sh b/exabgp/startup.sh index edb3c76..27100bb 100644 --- a/exabgp/startup.sh +++ b/exabgp/startup.sh @@ -3,16 +3,23 @@ set -e LOCAL_IP=${EXABGP_LOCAL_IP:-10.40.40.202} LOCAL_AS=${EXABGP_LOCAL_AS:-65100} -PEER_AS=${EXABGP_PEER_AS:-65020} -PEER_1=${EXABGP_PEER_1:-10.100.0.100} -PEER_2=${EXABGP_PEER_2:-10.100.0.200} API_PORT=${EXABGP_API_PORT:-5050} +# Peer list — ";"-separated entries of "ip:peer_as:description". +# Default reproduces the original single-lab (AS 65020) config. +EXABGP_PEERS=${EXABGP_PEERS:-10.100.0.100:65020:CML-R9K-CORE-01;10.100.0.200:65020:CML-R9K-CORE-02} + echo "================================================================" echo " ExaBGP Route Injector" echo " Local: ${LOCAL_IP} AS${LOCAL_AS}" -echo " Peers: ${PEER_1}, ${PEER_2} (AS${PEER_AS})" echo " API: http://0.0.0.0:${API_PORT}" +echo " Peers:" +IFS=';' read -ra PEER_ENTRIES <<< "$EXABGP_PEERS" +for entry in "${PEER_ENTRIES[@]}"; do + [ -z "$entry" ] && continue + IFS=':' read -r p_ip p_as p_desc <<< "$entry" + echo " - ${p_ip} AS${p_as} (${p_desc})" +done echo "================================================================" # Generate ExaBGP 5.x env file — ExaBGP looks here based on pip install prefix @@ -22,37 +29,25 @@ sed -i 's/drop = true/drop = false/' /usr/local/etc/exabgp/exabgp.env sed -i 's/cli = true/cli = false/' /usr/local/etc/exabgp/exabgp.env sed -i "s/destination = 'stdout'/destination = 'stderr'/" /usr/local/etc/exabgp/exabgp.env -# Generate exabgp.conf from environment +# Generate exabgp.conf — one neighbor block per peer-list entry cat > /tmp/exabgp.conf << EOF process api { run /usr/local/bin/python3 /exabgp/api/server.py; encoder text; } +EOF -neighbor ${PEER_1} { +for entry in "${PEER_ENTRIES[@]}"; do + [ -z "$entry" ] && continue + IFS=':' read -r p_ip p_as p_desc <<< "$entry" + cat >> /tmp/exabgp.conf << EOF + +neighbor ${p_ip} { router-id ${LOCAL_IP}; local-address ${LOCAL_IP}; local-as ${LOCAL_AS}; - peer-as ${PEER_AS}; - description "CML-R9K-CORE-01"; - hold-time 90; - - family { - ipv4 unicast; - } - - api { - processes [ api ]; - neighbor-changes; - } -} - -neighbor ${PEER_2} { - router-id ${LOCAL_IP}; - local-address ${LOCAL_IP}; - local-as ${LOCAL_AS}; - peer-as ${PEER_AS}; - description "CML-R9K-CORE-02"; + peer-as ${p_as}; + description "${p_desc}"; hold-time 90; family { @@ -65,5 +60,6 @@ neighbor ${PEER_2} { } } EOF +done exec exabgp server /tmp/exabgp.conf