#!/usr/bin/env python3 r"""BF-Netzwerk -> Nautobot: ConfigContext fuer die Access Points (TP22 Schritt 1, 2026-08-13). WOZU ---- Das Golden-Config-Template soll ap1 UND ap2 aus EINEM Zweig rendern. Beide unterscheiden sich in exakt 4 Werten (identity, 5G-Kanal, 2.4G-Kanal, Mgmt-IP). identity und Mgmt-IP stehen schon im DCIM-Modell (obj.name / obj.primary_ip4) - fehlen also nur die Kanaele, plus die geteilte SSID-Tabelle. DATENMODELL-ENTSCHEID (Alternativen bewusst verworfen): - Wireless-Modelle (RadioProfile): haengen ueber ControllerManagedDeviceGroup an einem Controller. Den gibt es hier bewusst NICHT mehr (CAPsMAN abgeschafft, TP3-Rebuild) -> ein Fake-Controller nur als Aufhaenger waere Modellschuld. - CustomFields: 2 Integer taeten es heute, skalieren aber nicht (jeder weitere AP-Knopf = ein weiteres CF auf ALLEN Devices) und bieten keinen Platz fuer die SSID-Tabelle. - Ableitung aus VAP-Descriptions: die SSID steht dort nur als Freitext -> stille Brueche. => ConfigContext: Nautobot-Idiom fuer Template-Variablen, keine Schema-Migration, und der natuerliche Ort fuer spaetere SSIDs (z.B. TP13 "Botschafts-WLANs"). AUFTEILUNG - ConfigContext "ap-common": Scope Role "Wireless AP" + Platform RouterOS. Traegt die geteilte Wahrheit (zones/radios/site-Konstanten). - Device.local_config_context_data je AP: nur die 2 Kanalnummern. Hoechste Praezedenz, kein zusaetzliches ConfigContext-Objekt noetig. MERGE-FALLE: Nautobot merged verschachtelte Dicts TIEF, Listen aber ERSETZEND. Der per-Device-Teil haengt deshalb ausschliesslich neue Keys unter "ap" ein und fasst zones/radios nie an. CROSS-CHECK: die zones-Tabelle wird gegen die real modellierten VAP-Interfaces (Name-Praefix + untagged_vlan) validiert. Divergenz => Abbruch. Damit koennen ConfigContext und DCIM-Modell nicht auseinanderlaufen. export NAUTOBOT_TOKEN=... python3 nautobot/ingest-ap-configcontext.py # Dry-Run python3 nautobot/ingest-ap-configcontext.py --apply # schreiben """ import json, os, sys, urllib.error, urllib.parse, urllib.request BASE = "http://10.40.20.30:8080/api" TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") DRY = "--apply" not in sys.argv # --- Geteilte Wahrheit (Site-Konstanten + SSID-Tabelle) -------------------------------- # Quelle: echter gescrubbter /export von ap1 (routeros/state-backups/ # ap1-gc-backup-scrubbed-20260813.cfg) - NICHT die handgesetzte .rsc, deren # Attributreihenfolge/Padding vom Export abweicht. AP_COMMON = { "ap": { "country": "Austria", "bridge": "bridge", "uplink": "ether1", # PoE-Port, poe-out=off # Bridge-Ports AUSSER Uplink und Radios. Bis 2026-08-30 standen ether3/4/5 # hartkodiert im Template — das galt still fuer "AP == hAP ax2 mit 5 Ports". # ap3 ist ein cAP ax mit ZWEI Ports (ether1 PoE-in, ether2 PoE-out); dort ist # die Liste leer. Default hier = hAP-ax2-Bestand, ap3 ueberschreibt per # local_config_context_data. (Nautobot merged Dicts tief, LISTEN ERSETZEND — # genau das ist hier gewollt.) "extra_ports": ["ether3", "ether4", "ether5"], "mgmt": {"vlan": 99, "gateway": "10.40.99.1"}, "native_vlan": 1, # Liste (nicht Dict): Reihenfolge ist garantiert und diffbar. "radios": [ # TP25 (2026-08-17): 80 -> 40 MHz. Im ETSI-Indoor-Band (5180-5240) gibt es nur # EINEN 80-MHz-Block (ch36-48) - zwei APs koennen dort nicht beide 80 MHz fahren, # ohne sich zu ueberlappen. Genau daran war ap2 auf ch149 (Band C) gelandet und # sendete dort mit 9 statt 18 dBm. Mit 40 MHz passen beide ins Indoor-Band. {"key": "5g", "iface": "wifi1", "band": "5ghz-ax", "width": "20/40mhz", "prefix": "w5"}, {"key": "2g", "iface": "wifi2", "band": "2ghz-ax", "width": "20mhz", "prefix": "w2"}, ], # main laeuft auf den MASTER-Radios (2 configurations mit channel=), # alle anderen als Slave-VAPs (1 configuration ohne channel=). "zones": [ {"name": "main", "ssid": "dirtyvader-main", "vlan": 10, "bands": ["5g", "2g"], "auth": "wpa3-psk", "ft": "yes", "ft_over_ds": "no", "master": True}, {"name": "infra", "ssid": "dirtyvader-infra", "vlan": 20, "bands": ["5g", "2g"], "auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False}, {"name": "iot", "ssid": "dirtyvader-iot", "vlan": 30, "bands": ["2g"], "auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False}, {"name": "media", "ssid": "dirtyvader-media", "vlan": 40, "bands": ["5g", "2g"], "auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False}, {"name": "untrusted", "ssid": "dirtyvader-untrusted", "vlan": 50, "bands": ["5g", "2g"], "auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False}, {"name": "guest", "ssid": "dirtyvader-guest", "vlan": 90, "bands": ["5g", "2g"], "auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False}, ], } } # --- Per-Device: nur die Kanalnummern ------------------------------------------------- # MHz wird im Template gerechnet (5000+5*ch bzw. 2407+5*ch), nicht gespeichert: # eine Quelle der Wahrheit, und kompatibel zu einer spaeteren RadioProfile-Migration. # ap1 ch36 -> 5180, ch1 -> 2412 # ap2 ch149-> 5745, ch11 -> 2462 DEVICE_CHANNELS = { # 5 GHz mit 40 MHz: ap1 ch36 belegt 5170-5210 (36+40), ap2 ch44 belegt 5210-5250 # (44+48) -> benachbart, ueberlappungsfrei, beide mit /I-Indoor-Freigabe und 18 dBm. # ap2 war bis 2026-08-17 auf ch149 (5745, ETSI-Band C) und dort auf 9 dBm gedrosselt. # ap3 (cAP ax, seit 2026-08-30) uebernimmt in Technik UG ch36; ap1 steht bis zu seinem # Umzug daneben und ist deshalb FUNKSTILL (keine Provisioning-Regel am Manager) -- # seine Kanaele bleiben hier stehen, weil sie beim Umzug wieder gebraucht werden. # 2,4 GHz: mit ap3 auf ch6 ist 1/6/11 erstmals vollstaendig ueberlappungsfrei. "ap1": {"5g": 36, "2g": 1}, "ap2": {"5g": 44, "2g": 11}, "ap3": {"5g": 36, "2g": 6}, } # --- Per-Device: Abweichungen vom ap-common-Default ----------------------------------- # Nur eintragen, was wirklich abweicht. Nautobot merged Dicts TIEF, Listen ERSETZEND -- # eine leere Liste hier loescht den Default, genau das ist beim cAP ax gewollt. DEVICE_OVERRIDES = { # cAP ax hat nur ether1 (PoE-in) + ether2 (PoE-out); ether2 ist bewusst kein # Bridge-Port. Es bleibt also KEIN zusaetzlicher Bridge-Port uebrig. "ap3": {"extra_ports": []}, } CTX_NAME = "ap-common" ROLE = "Wireless AP" PLATFORM = "RouterOS" 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()[:400]}") 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 crosscheck(): """zones gegen die real modellierten VAP-Interfaces validieren.""" problems = [] zones = {z["name"]: z for z in AP_COMMON["ap"]["zones"]} radios = {r["key"]: r for r in AP_COMMON["ap"]["radios"]} for dev in DEVICE_CHANNELS: res = req("GET", f"/dcim/interfaces/?device={dev}&limit=60&depth=1") ifaces = {i["name"]: i for i in res.get("results", [])} for zname, z in zones.items(): for band in z["bands"]: # main sitzt auf dem Master-Radio, nicht auf einem w5-/w2-Slave iname = radios[band]["iface"] if z.get("master") else f"{radios[band]['prefix']}-{zname}" i = ifaces.get(iname) if not i: problems.append(f"{dev}: Interface {iname} fehlt (zone {zname}/{band})") continue got = (i.get("untagged_vlan") or {}).get("vid") if got != z["vlan"]: problems.append(f"{dev}: {iname} untagged_vlan={got}, zones sagt {z['vlan']}") return problems def main(): if not TOKEN: sys.exit("!! NAUTOBOT_TOKEN nicht gesetzt") print("== Cross-Check zones <-> DCIM-VAP-Interfaces ==") problems = crosscheck() if problems: print("!! Divergenz zwischen ConfigContext-Tabelle und DCIM-Modell - NICHTS geschrieben:") for p in problems: print(" -", p) sys.exit(1) nz = len(AP_COMMON["ap"]["zones"]) print(f" {nz} Zonen x {len(DEVICE_CHANNELS)} Geraete gegen DCIM verifiziert - konsistent\n") role = find("/extras/roles", name=ROLE) plat = find("/dcim/platforms", name=PLATFORM) if not role or not plat: sys.exit(f"!! Role {ROLE!r} oder Platform {PLATFORM!r} nicht gefunden") print(f"== ConfigContext {CTX_NAME!r} (Scope: Role {ROLE} + Platform {PLATFORM}) ==") payload = { "name": CTX_NAME, "weight": 1000, "description": "Geteilte AP-Wahrheit fuer das Golden-Config-Template (TP22): SSID-/VLAN-Tabelle, Radios, Site-Konstanten. PSKs bewusst NICHT hier.", "is_active": True, "roles": [role["id"]], "platforms": [plat["id"]], "data": AP_COMMON, } ctx = find("/extras/config-contexts", name=CTX_NAME) if ctx: if ctx.get("data") == AP_COMMON: print(" = ap-common (unveraendert)") elif DRY: print(" ~ ap-common data wuerde aktualisiert (dry-run)") else: req("PATCH", f"/extras/config-contexts/{ctx['id']}/", {"data": AP_COMMON, "weight": 1000}) print(" ~ ap-common data aktualisiert") elif DRY: print(" + ap-common (dry-run)") else: req("POST", "/extras/config-contexts/", payload) print(" + ap-common") print("\n== local_config_context_data je AP (Kanaele) ==") for dev, ch in DEVICE_CHANNELS.items(): d = find("/dcim/devices", name=dev) if not d: print(f" !! Device {dev} nicht gefunden") continue ov = "".join(f" {k}={v}" for k, v in DEVICE_OVERRIDES.get(dev, {}).items()) want = {"ap": {"channels": ch, **DEVICE_OVERRIDES.get(dev, {})}} if d.get("local_config_context_data") == want: print(f" = {dev} 5g=ch{ch['5g']} 2g=ch{ch['2g']}{ov} (unveraendert)") elif DRY: print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']}{ov} (dry-run)") else: req("PATCH", f"/dcim/devices/{d['id']}/", {"local_config_context_data": want}) print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']}{ov}") if DRY: print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.") if __name__ == "__main__": main()