#!/usr/bin/env python3 """BF-Netzwerk -> Nautobot: ap1/ap2 As-built als REINE Access Points (TP3-Rebuild, 2026-08-08). Governance-Auflage "intended config zurueckschreiben" nach dem WLAN-Neuaufbau (CAPsMAN abgeschafft, L3 auf crs326-core). Re-modelliert ap1 + ap2 identisch als Standalone-APs: - Rolle Router -> Wireless AP, Status Active - Mgmt vlan99: ap1 10.40.99.11, ap2 10.40.99.12 (primary_ip4) - Uplink = ether1 (PoE-Port), Trunk: native VLAN1 + tagged 10/20/30/40/50/90/99 - WLAN-VAPs (wifi1/wifi2 master + Slaves) je SSID->VLAN - ENTFERNT die stale L3-Objekte von ap1: vlan10/20/30/40/50/90/100 (SVIs), wg-hub Muster wie ingest-crs326-ports.py: get-or-create + Delta-PATCH, idempotent. Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als null). PSKs werden bewusst NICHT modelliert (liegen lokal am Geraet). export NAUTOBOT_TOKEN=... python3 nautobot/ingest-ap-rebuild.py # Dry-Run python3 nautobot/ingest-ap-rebuild.py --apply # schreiben """ import json, os, sys, urllib.request, urllib.error, urllib.parse BASE = "http://10.40.20.154:8080/api" TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") DRY = "--apply" not in sys.argv log = [] def req(method, path, data=None): url = path if path.startswith("http") else f"{BASE}{path}" body = json.dumps(data).encode() if data is not None else None r = urllib.request.Request(url, data=body, method=method) r.add_header("Authorization", f"Token {TOKEN}") r.add_header("Content-Type", "application/json") r.add_header("Accept", "application/json") try: with urllib.request.urlopen(r, timeout=30) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: print(f"!! {method} {url}\n payload={json.dumps(data)}\n -> {e.code} {e.read().decode()[:500]}") raise def find(path, **params): q = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items()) res = req("GET", f"{path}?{q}&limit=2") return res["results"][0] if res.get("count") else None def goc(path, match, payload, label=None): name = label or payload.get("name") or str(match) obj = find(path, **match) if obj: log.append(f" = {name}") return obj if DRY: log.append(f" + {name} (dry-run)") return {"id": f"DRY-{name}"} obj = req("POST", path + "/", payload) log.append(f" + {name}") return obj def patch(path, obj, payload, label): delta = {} for k, v in payload.items(): cur = obj.get(k) cur_id = cur.get("id") if isinstance(cur, dict) else cur if cur_id != v: delta[k] = v if not delta: log.append(f" = {label} (unveraendert)") return obj if DRY: log.append(f" ~ {label}: {list(delta)} (dry-run)") return obj out = req("PATCH", f"{path}/{obj['id']}/", delta) log.append(f" ~ {label}: {list(delta)}") return out def sid(name): return find("/extras/statuses", name=name)["id"] def rid(name): return find("/extras/roles", name=name)["id"] ETH, VIRT, BR, WIFI = "1000base-t", "virtual", "bridge", "ieee802.11ax" S_ACTIVE = sid("Active") R_AP = rid("Wireless AP") vg = find("/ipam/vlan-groups", name="BF") V = {vid: find("/ipam/vlans", vid=vid, vlan_group=vg["id"]) for vid in (1, 10, 20, 30, 40, 50, 90, 99)} ns_global = find("/ipam/namespaces", name="Global")["id"] DEVS = {n: find("/dcim/devices", name=n) for n in ("ap1", "ap2")} TRUNK = [10, 20, 30, 40, 50, 90, 99] STALE = ["vlan10", "vlan20", "vlan30", "vlan40", "vlan50", "vlan90", "vlan100", "wg-hub"] # VAP -> (Band-Beschreibung, VLAN) — Master fuehren dirtyvader-main (VLAN10) VAPS = { "wifi1": ("5GHz master - dirtyvader-main", 10), "wifi2": ("2.4GHz master - dirtyvader-main", 10), "w5-infra": ("5G slave - dirtyvader-infra", 20), "w5-media": ("5G slave - dirtyvader-media", 40), "w5-untrusted": ("5G slave - dirtyvader-untrusted", 50), "w5-guest": ("5G slave - dirtyvader-guest", 90), "w2-infra": ("2.4G slave - dirtyvader-infra", 20), "w2-iot": ("2.4G slave - dirtyvader-iot (nur 2.4GHz)", 30), "w2-media": ("2.4G slave - dirtyvader-media", 40), "w2-untrusted": ("2.4G slave - dirtyvader-untrusted", 50), "w2-guest": ("2.4G slave - dirtyvader-guest", 90), } MGMT = {"ap1": "10.40.99.11/24", "ap2": "10.40.99.12/24"} def ifc(dev, name, typ, desc, mode=None, untagged=None, tagged=None): obj = goc("/dcim/interfaces", {"device": dev, "name": name}, {"device": DEVS[dev]["id"], "name": name, "type": typ, "status": S_ACTIVE, "description": desc}, label=f"{dev}:{name}") if DRY or str(obj["id"]).startswith("DRY"): return obj # Beim Zurueckstufen (kein tagged-Mode) zuerst tagged_vlans leeren, sonst 400 beim mode-Clear if not tagged: req("PATCH", f"/dcim/interfaces/{obj['id']}/", {"tagged_vlans": []}) p = {"description": desc, "type": typ, "mode": mode or "", "untagged_vlan": V[untagged]["id"] if untagged is not None else None} obj = patch("/dcim/interfaces", obj, p, f"{dev}:{name}") if tagged: req("PATCH", f"/dcim/interfaces/{obj['id']}/", {"tagged_vlans": [V[t]["id"] for t in tagged]}) return obj def remove_iface(dev, name): o = find("/dcim/interfaces", device=dev, name=name) if not o: log.append(f" . {dev}:{name} (schon weg)") return if DRY: log.append(f" - {dev}:{name} (dry-run)") return req("DELETE", f"/dcim/interfaces/{o['id']}/") log.append(f" - {dev}:{name}") def assign_mgmt(dev_obj, addr, desc): """IP get-or-create, exklusiv an dev:vlan99 haengen (fremde Assignments loesen), primary setzen.""" ip = find("/ipam/ip-addresses", address=addr) or goc( "/ipam/ip-addresses", {"address": addr}, {"address": addr, "status": S_ACTIVE, "namespace": ns_global, "description": desc}, label=addr) if DRY or str(ip["id"]).startswith("DRY"): return intf = find("/dcim/interfaces", device=dev_obj["name"], name="vlan99") assigns = req("GET", f"/ipam/ip-address-to-interface/?ip_address={ip['id']}&limit=50").get("results", []) have = False for a in assigns: if a["interface"]["id"] == intf["id"]: have = True else: req("DELETE", f"/ipam/ip-address-to-interface/{a['id']}/") log.append(f" - {addr} losgeloest von {a['interface'].get('display', '?')}") if not have: req("POST", "/ipam/ip-address-to-interface/", {"ip_address": ip["id"], "interface": intf["id"]}) log.append(f" + {addr} -> {dev_obj['name']}:vlan99") req("PATCH", f"/dcim/devices/{dev_obj['id']}/", {"primary_ip4": ip["id"]}) def set_mgmt(dev, addr): assign_mgmt(DEVS[dev], addr, f"{dev} Mgmt (VLAN99)") # --- Core-Mgmt-Konsistenz: crs326-core primary = .99.1 (das stale .99.11 wandert unten zu ap1) --- core = find("/dcim/devices", name="crs326-core") if core: print("== crs326-core: Mgmt-IP korrigieren -> 10.40.99.1") assign_mgmt(core, "10.40.99.1/24", "crs326-core Mgmt/GW SVI (VLAN99)") COMMENT = ("TP3-Rebuild 2026-08-08: reiner Standalone-AP (kein CAPsMAN). L3 auf crs326-core. " "Uplink ether1 (PoE, poe-out=off), Trunk native VLAN1 + tagged 10/20/30/40/50/90/99. " "6 SSIDs dirtyvader-main/-infra/-iot/-media/-untrusted/-guest. PSKs lokal am Geraet.") for dev in ("ap1", "ap2"): if not DEVS[dev]: print(f"!! Device {dev} nicht in Nautobot gefunden - uebersprungen") continue print(f"== {dev}: -> reiner Access Point") patch("/dcim/devices", DEVS[dev], {"role": R_AP, "status": S_ACTIVE}, f"{dev} role/status") ifc(dev, "bridge", BR, "vlan-filtering=yes; VLAN1 untagged, 10-99 tagged") ifc(dev, "ether1", ETH, "Uplink-Trunk (PoE-Port, poe-out=off): native VLAN1 + tagged 10/20/30/40/50/90/99", mode="tagged", untagged=1, tagged=TRUNK) ifc(dev, "ether2", ETH, "frei (Alt-Uplink, seit ether1-Umstellung ungenutzt)") for n in (3, 4, 5): ifc(dev, f"ether{n}", ETH, "frei") ifc(dev, "vlan99", VIRT, "Mgmt SVI (VLAN99)", mode="tagged", tagged=[99]) if not DRY: v99 = find("/dcim/interfaces", device=dev, name="vlan99") br = find("/dcim/interfaces", device=dev, name="bridge") req("PATCH", f"/dcim/interfaces/{v99['id']}/", {"parent_interface": br["id"]}) for name, (desc, vlan) in VAPS.items(): ifc(dev, name, WIFI, desc, mode="access", untagged=vlan) for name in STALE: remove_iface(dev, name) set_mgmt(dev, MGMT[dev]) if not DRY: req("PATCH", f"/dcim/devices/{DEVS[dev]['id']}/", {"comments": COMMENT}) print("\n".join(log)) print(f"\n{'DRY-RUN - nichts geschrieben. Mit --apply ausfuehren.' if DRY else 'ANGEWENDET.'}")