#!/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()