#!/usr/bin/env python3 r"""BF-Netzwerk -> Nautobot: ap3 (cAP ax) als Device anlegen. TP28-Nachtrag, 2026-08-30. WOZU ---- ap3 ist der dritte Access Point und laeuft seit dem 2026-08-30 als reiner CAP unter `crs326-core`. Damit Golden Config ihn rendern und pruefen kann, braucht er ein DCIM-Modell: DeviceType, Device, Interfaces, Mgmt-IP. MUSTER = ap1 (per GraphQL gelesen, nicht geraten -- `tagged_vlans` liest sich per REST als null, das war schon in `ingest-ap-rebuild.py` eine Falle). DELTAS ZU ap1/ap2 (hAP ax2), und beide sind der Grund fuer die ganze Uebung: - cAP ax hat nur ZWEI Ethernet-Ports (ether1 PoE-in, ether2 PoE-out). Kein ether3/4/5. Deshalb `ap.extra_ports = []` in local_config_context_data; das GC-Template rendert die Bridge-Ports seit 2026-08-30 aus diesem Schluessel statt hartkodiert. - ether2 ist bewusst KEIN Bridge-Port (Zielzustand wie ap2). NEBENKORREKTUR: ap1 stand in Nautobot auf "BF -> Wohnzimmer Sideboard", steht physisch aber in Technik UG (User-Bestaetigung 2026-08-30). Der Sideboard-Eintrag war der ZIEL- Standort nach dem geplanten Umzug, nicht der Ist-Zustand. Wird hier mitkorrigiert; beim tatsaechlichen Umzug wandert er zurueck auf Sideboard. export NAUTOBOT_TOKEN=... python3 nautobot/ingest-ap3-device.py # Dry-Run python3 nautobot/ingest-ap3-device.py --apply # schreiben """ import json, os, sys, urllib.error, urllib.parse, urllib.request BASE = "http://10.40.20.154:8080/api" TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") DRY = "--apply" not in sys.argv DEV = "ap3" MODEL = "cAP ax" # board-name, wie "hAP ax2" bei ap1/ap2 MANUFACTURER = "MikroTik" LOCATION = "BF → Technik UG" ROLE = "Wireless AP" PLATFORM = "RouterOS" MGMT_IP = "10.40.99.13/24" TRUNK_VLANS = [10, 20, 30, 40, 50, 90, 99] # Interfaces in Anlegereihenfolge: bridge zuerst (die anderen referenzieren sie). # radio-mac steht in der description, weil die CAPsMAN-Provisionierung am Core genau # daran haengt -- wer das Geraet tauscht, findet hier, was er am Manager nachziehen muss. IFACES = [ ("bridge", "bridge", None, None, [], None, None, "vlan-filtering=yes; VLAN1 untagged, 10-99 tagged"), ("ether1", "1000base-t", "tagged", 1, TRUNK_VLANS, None, None, "Uplink-Trunk (PoE-in) -> crs326-core ether21"), ("ether2", "1000base-t", None, None, [], None, None, "PoE-out, frei. KEIN Bridge-Port - cAP ax hat nur 2 Ports"), ("lo", "virtual", None, None, [], None, None, "loopback"), ("vlan99", "virtual", "tagged", None, [99], None, "bridge", "Mgmt SVI (VLAN99)"), ("wifi1", "ieee802.11ax", "access", 10, [], "bridge", None, "5GHz master - dirtyvader-main (radio-mac D0:EA:11:FF:16:9F)"), ("wifi2", "ieee802.11ax", "access", 10, [], "bridge", None, "2.4GHz master - dirtyvader-main (radio-mac D0:EA:11:FF:16:A0)"), ("w5-infra", "ieee802.11ax", "access", 20, [], "bridge", None, "5G slave - dirtyvader-infra"), ("w5-media", "ieee802.11ax", "access", 40, [], "bridge", None, "5G slave - dirtyvader-media"), ("w5-untrusted", "ieee802.11ax", "access", 50, [], "bridge", None, "5G slave - dirtyvader-untrusted"), ("w5-guest", "ieee802.11ax", "access", 90, [], "bridge", None, "5G slave - dirtyvader-guest"), ("w2-infra", "ieee802.11ax", "access", 20, [], "bridge", None, "2.4G slave - dirtyvader-infra"), ("w2-iot", "ieee802.11ax", "access", 30, [], "bridge", None, "2.4G slave - dirtyvader-iot (nur 2.4GHz)"), ("w2-media", "ieee802.11ax", "access", 40, [], "bridge", None, "2.4G slave - dirtyvader-media"), ("w2-untrusted", "ieee802.11ax", "access", 50, [], "bridge", None, "2.4G slave - dirtyvader-untrusted"), ("w2-guest", "ieee802.11ax", "access", 90, [], "bridge", None, "2.4G slave - dirtyvader-guest"), ] 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 -> {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 main(): if not TOKEN: sys.exit("!! NAUTOBOT_TOKEN nicht gesetzt") mode = "DRY-RUN" if DRY else "APPLY" print(f"== ap3 -> Nautobot ({mode}) ==\n") status_active = find("/extras/statuses", name="Active") role = find("/extras/roles", name=ROLE) plat = find("/dcim/platforms", name=PLATFORM) loc = find("/dcim/locations", name="Technik UG") manu = find("/dcim/manufacturers", name=MANUFACTURER) # 🔴 OHNE SecretsGroup kein Backup. Das Geraet landet trotzdem in der GC-DynamicGroup, # die intended config wird sauber gerendert -- nur der Backup-Job kann sich nicht # anmelden, und ohne Backup entstehen KEINE Compliance-Eintraege. Die Gesamtzahl # bleibt dann unauffaellig (104/104), weil ein Geraet ohne Ergebnisse auch keine # Drift meldet. Aufgeschlagen am 2026-08-30 bei ap3. secrets = find("/extras/secrets-groups", name="RouterOS Nornir") for label, obj in [("Status Active", status_active), (f"Role {ROLE}", role), (f"Platform {PLATFORM}", plat), ("Location Technik UG", loc), (f"Manufacturer {MANUFACTURER}", manu), ("SecretsGroup 'RouterOS Nornir'", secrets)]: if not obj: sys.exit(f"!! {label} nicht gefunden") vlans = {v["vid"]: v["id"] for v in req("GET", "/ipam/vlans/?limit=50")["results"]} # --- DeviceType --- dt = find("/dcim/device-types", model=MODEL) if dt: print(f" = DeviceType {MODEL!r}") elif DRY: print(f" + DeviceType {MODEL!r} (dry-run)") else: dt = req("POST", "/dcim/device-types/", {"manufacturer": manu["id"], "model": MODEL, "u_height": 0}) print(f" + DeviceType {MODEL!r}") # --- Device --- dev = find("/dcim/devices", name=DEV) if dev: print(f" = Device {DEV}") elif DRY: print(f" + Device {DEV} (dry-run)") else: dev = req("POST", "/dcim/devices/", { "name": DEV, "device_type": dt["id"], "role": role["id"], "platform": plat["id"], "location": loc["id"], "status": status_active["id"], "secrets_group": secrets["id"], "comments": "cAP ax (cAPGi-5HaxD2HaxD), reiner CAP unter crs326-core. Bring-up 2026-08-30 (TP28-Nachtrag).", }) print(f" + Device {DEV}") if DRY and not dev: print("\n (Interfaces/IP koennen im Dry-Run nicht geplant werden, solange das Device fehlt)") print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.") return # SecretsGroup auch nachziehen, wenn das Device schon existierte if dev and not DRY: cur = req("GET", f"/dcim/devices/{dev['id']}/") if (cur.get("secrets_group") or {}).get("id") != secrets["id"]: req("PATCH", f"/dcim/devices/{dev['id']}/", {"secrets_group": secrets["id"]}) print(" ~ SecretsGroup 'RouterOS Nornir' gesetzt") # --- Interfaces --- print(f"\n Interfaces ({len(IFACES)}):") have = {i["name"]: i for i in req("GET", f"/dcim/interfaces/?device={DEV}&limit=100")["results"]} ids = {n: o["id"] for n, o in have.items()} for name, typ, mode_, untag, tagged, bridge, parent, descr in IFACES: payload = { "device": dev["id"], "name": name, "type": typ, "status": status_active["id"], "enabled": True, "description": descr, } if mode_: payload["mode"] = mode_ if untag is not None: payload["untagged_vlan"] = vlans[untag] if tagged: payload["tagged_vlans"] = [vlans[v] for v in tagged] if bridge: payload["bridge"] = ids.get(bridge) if parent: payload["parent_interface"] = ids.get(parent) if name in have: print(f" = {name}") continue if DRY: print(f" + {name:<13} type={typ} mode={mode_} untag={untag} tagged={tagged} (dry-run)") continue new = req("POST", "/dcim/interfaces/", payload) ids[name] = new["id"] print(f" + {name:<13} type={typ} mode={mode_} untag={untag} tagged={tagged}") # --- Mgmt-IP + primary_ip4 --- print("\n Mgmt-IP:") ip = find("/ipam/ip-addresses", address=MGMT_IP.split("/")[0]) if not ip: if DRY: print(f" + {MGMT_IP} (dry-run)") else: ns = find("/ipam/namespaces", name="Global") ip = req("POST", "/ipam/ip-addresses/", { "address": MGMT_IP, "status": status_active["id"], "type": "host", "namespace": ns["id"], "description": "ap3 Mgmt (VLAN99)"}) print(f" + {MGMT_IP}") else: print(f" = {MGMT_IP}") if not DRY and ip and ids.get("vlan99"): assigned = req("GET", f"/ipam/ip-address-to-interface/?ip_address={ip['id']}&interface={ids['vlan99']}") if assigned.get("count"): print(" = Zuordnung vlan99") else: req("POST", "/ipam/ip-address-to-interface/", {"ip_address": ip["id"], "interface": ids["vlan99"]}) print(" + Zuordnung vlan99") cur = req("GET", f"/dcim/devices/{dev['id']}/") if (cur.get("primary_ip4") or {}).get("id") != ip["id"]: req("PATCH", f"/dcim/devices/{dev['id']}/", {"primary_ip4": ip["id"]}) print(" ~ primary_ip4 gesetzt") else: print(" = primary_ip4") # --- Nebenkorrektur ap1-Standort --- print("\n Nebenkorrektur ap1-Standort:") ap1 = find("/dcim/devices", name="ap1") if ap1: cur = req("GET", f"/dcim/devices/{ap1['id']}/?depth=1") curloc = (cur.get("location") or {}).get("name") if curloc == "Technik UG": print(" = ap1 steht bereits auf Technik UG") elif DRY: print(f" ~ ap1: {curloc!r} -> 'Technik UG' (dry-run)") else: req("PATCH", f"/dcim/devices/{ap1['id']}/", {"location": loc["id"]}) print(f" ~ ap1: {curloc!r} -> 'Technik UG'") if DRY: print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.") if __name__ == "__main__": main()