Files
bf-infra/nautobot/ingest-ap-configcontext.py
Christoph Emsenhuber 1006f91e6a TP28: ap3 (cAP ax) live als CAP, ap1 funkstill, Compliance 130/130 ueber 5 Geraete
Der dritte Access-Point laeuft: 10.40.99.13 auf vlan99, Uplink ether1 -> core ether21,
reiner CAP unter crs326-core, Kanaele 5180/ax/Ce/I und 2437/ax (ch6) -- am Geraet
nachgemessen, nicht aus dem Plan abgeschrieben. Der Kanalplan stammt aus TP25 vom
2026-08-24; mein erster Entwurf hatte ihn neu erfunden und waere bei ch1 statt ch6
gelandet. Lehre: den TP-Body lesen, nicht nur den Quick-Index.

ap1 hat bewusst KEINE Provisioning-Regel mehr: es steht bis zu seinem Umzug ans
Sideboard direkt neben ap3 und wuerde auf 5180 kollidieren. Geraet bleibt erreichbar,
die Objekte ch-*-ap1 / cfg-main-*-ap1 bleiben fuer den Umzug stehen.

Bring-up in zwei Phasen ueber ein Patchkabel ap3 ether2 -> core ether22, weil ueber
ether1 nichts ging (s.u.). Loop-Schutz: ether21 war waehrend Phase 1 abgeschaltet und
wurde im selben Befehl gegen ether22 getauscht. RouterOS 7.20.8 -> 7.24.1 plus
RouterBOOT (zwei Reboots, TP27-Kriterium erfuellt). Die anderen vier stehen auf 7.24 --
die Angleichung ist ein eigener Durchgang, der Core-Reboot ist ein Standort-Ausfall.

Nautobot: DeviceType "cAP ax", Device ap3 mit 16 Interfaces, primary_ip4, secrets_group,
ConfigContext 5g=ch36 2g=ch6 extra_ports=[]. Nebenkorrektur: ap1 stand auf "Wohnzimmer
Sideboard", steht physisch aber in Technik UG (User-Bestaetigung).

Sechs Funde, alle als Gotcha in CLAUDE.md und im Handoff:
- ether1 ist am Werksgeraet WAN: Discovery aus, Input-TCP verworfen, ICMP aber erlaubt.
  Es pingt, und das liest sich wie "erreichbar". MikroMCP hilft nicht -- Port 80 ist zu.
- pvid ist Ingress, tagged/untagged ist Egress. Ein Lease hing ewig auf "offered", weil
  der ACK getaggt rausging und ein Werksgeraet das nicht versteht.
- `cap enabled=yes` reicht nicht: jedes Master-Radio braucht configuration.manager=capsman,
  sonst bietet der CAP seine Radios gar nicht an -- state=Ok, radio-Tabelle leer.
- Ein Geraet ohne secrets_group erzeugt eine LUECKE, keine Fehlermeldung: Backup scheitert
  still, keine Compliance-Eintraege, Zahl bleibt unauffaellig bei 104/104. Eine
  gleichbleibende Compliance-Zahl nach dem Hinzufuegen eines Geraets ist ein BEFUND.
- CAPsMAN verteilt keine Login-Passwoerter, nur WLAN-PSKs. Der Geraete-Login ist eine
  lokale DB pro Geraet; das einheitliche Admin-PW ist eine Konvention von Hand.
- Firewall-Regelnummern aus `print where` sind Handles, keine Positionen. Beweis ist der
  Zaehler, nicht die Zahl.

Abnahme: 3 CAPs state=Ok, VAP-Satz vollstaendig und running, 20 IOT-Clients auf ap3 mit
0 % Ping-Verlust ueber VLAN30 (bewusst nicht ueber die Registration-Table abgenommen),
Compliance 130/130 / 0 Drifts, Core-Temp-Pfad restlos abgeraeumt.

Offen: TP25-Messrunde (braucht Rundgang), PW-Rotation TP30, MikroMCP-Neustart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:54:07 +02:00

233 lines
11 KiB
Python

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