network/mikrotik-tool/netbox_v6_sync.py
2026-05-09 11:21:27 -05:00

271 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
netbox_v6_sync.py — keep NetBox in sync with the live IPv6 state on the fleet.
What it does:
1. Updates prefix descriptions + statuses for known v6 prefixes (so
reading NetBox tells you what's actually live).
2. For each router with v6 configured today (verona, climax, core),
pulls /ipv6/address/print over the router API and creates/updates
matching NetBox IPAM IP-address records, assigned to the correct
device + interface.
3. Associates prefixes to sites where the link is wholly within one
site (mgmt LAN, customer PD), and leaves cross-site backbone P2Ps
unsited.
It is idempotent — re-running it won't duplicate IPs; it'll just update
descriptions if anything has changed. Re-run after each new tower's v6
apply.
Usage:
python3 netbox_v6_sync.py [--dry-run]
Auth: reads NETBOX_TOKEN (or NETBOX_KEY as fallback) from env. Needs the
RouterOS API creds in routers.yaml (uses the same `mikrotik-tool api`
binary in this directory).
"""
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
DRY_RUN = "--dry-run" in sys.argv
NETBOX = "https://netbox.vntx.net/api"
TOKEN = os.environ.get("NETBOX_TOKEN") or os.environ.get("NETBOX_KEY") \
or "e50298f7fd20f7fd6f1931f635511b34f6e8cfde"
# Routers that have v6 configured (extend as more towers come online).
# Each tuple: (router-name-in-routers.yaml, netbox-device-name, site-slug)
ROUTERS = [
("verona", "verona-router", "verona"),
("climax", "climax-router", "climax"),
("core", "380-core-router", "380"),
]
# Prefix updates: (prefix, status, description)
PREFIX_UPDATES = [
("2606:1c80:0:10::/64", "active",
"Router loopbacks — one /128 per router (verona=:101, climax=:102, core=:253; others as towers come online)"),
("2606:1c80:0:11::/64", "active",
"P2P edge↔core direct (sfp-sfpplus7-core-direct ↔ ether3-edge-direct); BACKUP path (v6 distance 2)"),
("2606:1c80:0:12::/64", "active",
"P2P core↔climax (ether5-climax ↔ ether4-380-airfiber24); both ends /64"),
("2606:1c80:0:20::/64", "active",
"P2P verona↔climax 11GHz (ether3-climax-11ghz ↔ ether6-verona-11ghz); both ends /64"),
("2606:1c80:0:1002::/64", "active",
"P2P edge↔core via Preseem inline shaper (transparent L2); PRIMARY path (v6 distance 1). Preseem rate-limits customer traffic per plan tier"),
("2606:1c80:1000::/64", "active",
"verona mgmt LAN (gateway 2606:1c80:1000::1 on bridge `verona`)"),
("2606:1c80:1100::/64", "active",
"climax mgmt LAN (gateway 2606:1c80:1100::1 on bridge `mgmt`)"),
("2606:1c80:1400::/64", "active",
"core/380 mgmt LAN (gateway 2606:1c80:1400::1 on vlan10_combo1)"),
("2606:1c80:1500::/64", "active",
"altoga mgmt LAN, served from verona (gateway 2606:1c80:1500::1 on ether6-switch)"),
("2606:1c80:1001::/48", "active",
"verona customer PD pool (256 × /56) — live in pool `verona-cust-pd-1`"),
("2606:1c80:1101::/48", "active",
"climax customer PD pool (256 × /56) — live in pool `climax-cust-pd-1`"),
("2606:1c80:1401::/48", "active",
"core/380 customer PD pool (256 × /56) — live in pool `core-cust-pd-1`"),
("2606:1c80:1501::/48", "active",
"altoga customer PD pool (256 × /56) — live in pool `altoga-cust-pd-1` on verona"),
]
# Site-attached prefixes (mgmt LAN + customer PD that map cleanly to one site)
PREFIX_SITES = {
"2606:1c80:1000::/64": "verona",
"2606:1c80:1001::/48": "verona",
"2606:1c80:1100::/64": "climax",
"2606:1c80:1101::/48": "climax",
"2606:1c80:1400::/64": "380",
"2606:1c80:1401::/48": "380",
"2606:1c80:1500::/64": "altoga",
"2606:1c80:1501::/48": "altoga",
}
def nb(method, path, payload=None):
headers = {"Authorization": f"Token {TOKEN}", "Accept": "application/json"}
data = None
if payload is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(payload).encode()
req = urllib.request.Request(f"{NETBOX}{path}", data=data, method=method,
headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as r:
body = r.read()
return r.status, (json.loads(body) if body else None)
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"null")
def find_prefix_id(prefix):
code, data = nb("GET", f"/ipam/prefixes/?prefix={prefix}")
if code != 200 or not data["results"]:
return None
return data["results"][0]
def find_site_id(slug):
code, data = nb("GET", f"/dcim/sites/?slug={slug}")
if code != 200 or not data["results"]:
return None
return data["results"][0]["id"]
def find_interface_id(device_name, interface_name):
code, data = nb("GET",
f"/dcim/interfaces/?device={device_name}&name={interface_name}")
if code != 200 or not data["results"]:
return None
return data["results"][0]["id"]
def ensure_interface(device_name, interface_name):
"""Get or create a NetBox interface for this device. Picks a sensible
type based on the interface name pattern. Returns the interface id."""
iface_id = find_interface_id(device_name, interface_name)
if iface_id is not None:
return iface_id
device_id = find_device_id(device_name)
if device_id is None:
return None
# Pick type from name
nm = interface_name.lower()
if nm == "lo" or nm == "loopback" or nm.startswith("vlan") \
or nm in ("verona", "mgmt") or nm.endswith("-bridge"):
if_type = "virtual"
elif nm.startswith("sfp"):
if_type = "10gbase-x-sfpp"
else:
if_type = "1000base-t"
payload = {
"device": device_id,
"name": interface_name,
"type": if_type,
"description": f"auto-created by netbox_v6_sync.py",
}
if DRY_RUN:
print(f" would CREATE iface {interface_name} ({if_type})")
return None
code, body = nb("POST", "/dcim/interfaces/", payload)
if code >= 400:
print(f" iface CREATE failed: {code} {body}")
return None
return body["id"]
def find_device_id(device_name):
code, data = nb("GET", f"/dcim/devices/?name={device_name}")
if code != 200 or not data["results"]:
return None
return data["results"][0]["id"]
def upsert_ip(address, device_name, interface_name, description):
"""Create or update a NetBox IP-address record assigned to the given
device+interface. address is in CIDR form (e.g. 2606:1c80:0:10::101/128).
"""
code, data = nb("GET", f"/ipam/ip-addresses/?address={address}")
if code != 200:
return f"GET failed: {code}"
iface_id = ensure_interface(device_name, interface_name)
if iface_id is None:
return f"interface {device_name}:{interface_name} could not be created"
payload = {
"address": address,
"status": "active",
"assigned_object_type": "dcim.interface",
"assigned_object_id": iface_id,
"description": description,
}
if data["results"]:
existing = data["results"][0]
if DRY_RUN:
return f"would PATCH id={existing['id']}"
c, _ = nb("PATCH", f"/ipam/ip-addresses/{existing['id']}/", payload)
return f"PATCH id={existing['id']} -> {c}"
if DRY_RUN:
return "would CREATE"
c, body = nb("POST", "/ipam/ip-addresses/", payload)
return f"CREATE -> {c}" + (f" err={body}" if c >= 400 else "")
def update_prefix(prefix, pstatus, description, site_slug=None):
p = find_prefix_id(prefix)
if not p:
return f"prefix {prefix} not in NetBox"
payload = {"status": pstatus, "description": description}
if site_slug:
site_id = find_site_id(site_slug)
if site_id:
payload["site"] = site_id
if DRY_RUN:
return f"would PATCH id={p['id']} status={pstatus} site={site_slug}"
c, _ = nb("PATCH", f"/ipam/prefixes/{p['id']}/", payload)
return f"PATCH id={p['id']} -> {c}"
def get_router_v6_addresses(router_name):
"""Returns list of (address-cidr, interface-name, comment) tuples."""
cmd = ["./mikrotik-tool", "api", router_name,
"/ipv6/address/print", "?dynamic=false"]
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if out.returncode != 0:
return []
rows = []
for line in out.stdout.strip().split("\n"):
if not line.strip():
continue
kv = {}
for part in line.split(" "):
if "=" in part:
k, _, v = part.partition("=")
kv[k.strip()] = v.strip()
addr = kv.get("address", "")
iface = kv.get("interface", "")
comment = kv.get("comment", "")
if addr and iface:
rows.append((addr, iface, comment))
return rows
def main():
print(f"netbox_v6_sync.py — {'DRY RUN' if DRY_RUN else 'LIVE'}")
print()
# 1. Prefix updates
print("== updating prefix descriptions/statuses ==")
for prefix, pstatus, desc in PREFIX_UPDATES:
site_slug = PREFIX_SITES.get(prefix)
result = update_prefix(prefix, pstatus, desc, site_slug)
print(f" {prefix:32s} {result}")
print()
# 2. Per-router IP-address sync
for router_name, device_name, _site in ROUTERS:
print(f"== syncing IPv6 addresses for {router_name} ==")
if find_device_id(device_name) is None:
print(f" device {device_name} not in NetBox — skipping")
continue
addrs = get_router_v6_addresses(router_name)
if not addrs:
print(" no v6 addresses (or API failed)")
continue
for cidr, iface, comment in addrs:
# Skip auto-assigned link-local (auto-link-local=true)
if cidr.lower().startswith("fe80:"):
continue
desc = comment or f"{router_name} {iface}"
result = upsert_ip(cidr, device_name, iface, desc)
print(f" {cidr:42s} -> {iface:32s} {result}")
print()
if __name__ == "__main__":
main()