271 lines
10 KiB
Python
271 lines
10 KiB
Python
#!/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()
|