From b0a0b165565fbb937394b6e05f38f401c80d0d8d Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Wed, 22 Jul 2026 21:28:28 +0200 Subject: [PATCH] TP21: Nautobot-Basisdaten (Ist-Stand) + TP15-Kurskorrektur CRS326 = L3-Router Nautobot-Ingest (Ist, nicht Ziel): - Locations: LocationType "Bereich"; Technik UG (Diele, kein Keller) = crs326-core, Technik OG = crs326-branch, Wohnzimmer Sideboard = ap1 - Devices: ap2 -> Rolle "Wireless AP" + Offline; beide CRS326 Platform RouterOS nachgetragen (fehlte -> haette jeden GC-Job auf ihnen verhindert); sw1/sw2/cudy1 als Uebergangs-Devices ohne Platform - IPAM: VLAN-Group BF, 9 VLANs (VLAN1 deprecated), 8 Prefixes je VLAN-gebunden - ap1: 26 Interfaces inkl. MACs, ether2 als Trunk, 9 GW-IPs - WLAN: logischer Controller bf-wlan (kein CAPsMAN), 6 SSIDs mit VLAN-Bindung, 2 RadioProfiles Entscheide 2026-07-22: - crs326-core wird L3-Router; Routing/DNS/DHCP wandern von ap1, APs werden reine APs. Ueberschreibt die fruehere "Router bleibt ap1/ap2"-Zeile desselben Tages. - CAPsMAN wird abgeschafft, beide APs von Nautobot identisch generiert. - AP-Uplink wandert auf ether1 (PoE-Port). RouterOS nummeriert ab 1, nie ether0. Befunde: - Root Cause des ap1-VLAN1-Lecks: cfg-main-2g hat gar keinen datapath-Block -> 2,4-GHz-main taggt nicht, Bandwechsel = Subnetzsprung. Nicht das frame-types-Thema. - Beide CRS326 laufen bereits (RouterOS 7.19.5, Werkszustand), beide auf 192.168.88.1 im selben L2 = IP-Konflikt. - hAP ax2 PoE ist passiv 18-28V, nicht 802.3af/at; ether1 steht auf poe-out=auto-on. Co-Authored-By: Claude Opus 4.8 --- docs/teilprojekte.md | 23 ++- nautobot/README.md | 26 +++ nautobot/ingest-baseline.py | 343 ++++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 nautobot/README.md create mode 100644 nautobot/ingest-baseline.py diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index ee0294f..cb42895 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -23,7 +23,7 @@ Omada-Controller **`.175`** (DS923/macvlan, mbentley 6.2, 🟢 live). omada-mcp 2× hAP ax² (7.23.1): ap1 `.1` (ALLNET P1) — **TP4 ✅ 2026-07-01: Router + WAN-Edge** (VLAN-Subinterfaces, DHCP, DNS, NAT, Public-Edge). ap2 `.2` (Omada P2 — Trunk). **Edge-Cutover ✅ 2026-07-01:** FB6660 physisch entfernt (Liwest-Kabelmodemtausch, kein Zurück), Arris-Kabelmodem im Bridge-Mode; WAN = `sw1 gi16 Access VLAN100 → gi15 Trunk → ap1 ether2 → vlan100 auf bridge` (**NICHT** ether1); ap1 hält Public-IP direkt (Liwest, kein CGNAT), Double-NAT weg. Telefonie war auf der FB und wird nicht weitergeführt. Vorarbeit: TP10-Kern durch (2026-06-27, WG-Hub live) → Bridge-Gate vorher gelöst. Runbook: `routeros/wan-cutover-runbook.md`. Kontrolle: MikroMCP (MikroTik) live · ALLNET = Klickanleitungen · Omada → Controller + omada-mcp 🟢. -**Nächster Schritt: TP21 (Nautobot Golden Config).** ⚪ **Omada-PVID-Fix ist obsolet** (User-Entscheid 2026-07-22 — Omada spielt seit der TP15-Korrektur keine Rolle mehr im Netz, nie mehr aufgreifen). TP5 Deny-by-default folgt nach TP21/CRS326-Adoption (Sonos-/HA-/mDNS-Holes und WAN-Input-Seal stehen bereits). +**Nächster Schritt (Stand 2026-07-22): TP15 CRS326-Bring-up als neuer L3-Core** (Routing/DNS/DHCP von ap1 → `crs326-core`), danach **WLAN-Neuprovisionierung ohne CAPsMAN** (TP3). Nautobot-Basisdaten sind eingepflegt (TP21). ⚪ **Omada-PVID-Fix ist obsolet** (User-Entscheid 2026-07-22 — Omada spielt seit der TP15-Korrektur keine Rolle mehr im Netz, nie mehr aufgreifen). TP5 Deny-by-default folgt nach TP21/CRS326-Adoption (Sonos-/HA-/mDNS-Holes und WAN-Input-Seal stehen bereits). > 🎯 **Ziel: sauberes System „from scratch", keine Leichen/Altlasten.** Migrations-Session-Endstand = **VLAN1 + altes cudy-WLAN komplett weg** (VLAN1 nur noch Mgmt, `defconf`-Client-DHCP zurückgezogen, cudy-Uplinks aufs Ziel-Client-VLAN getaggt oder cudy abgebaut). **Unterpunkte/Aufräum-Liste bei Migration:** (a) die Interim-Härtung ap2 `wifi1/wifi2 frame-types=admit-only-vlan-tagged` (TP3.1, 2026-07-06) bleibt gültig — verschwindet automatisch mit VLAN1; (b) `st-main rrm/wnm` = reines Roaming-Provisorium, prüfen ob nach Migration noch nötig; (c) **cudy1 = verbleibender aktiver VLAN1-Leak** (Dual-DHCP-Race, Doku Memory `project_vlan1_dhcp_incident`). Bis dahin sind diese Griffe Krücken, kein Zielzustand. > ⚠️ **HW-Constraint hAP ax²:** 1 GB RAM, aber nur **128 MB Flash + KEIN USB** → **Container auf dem Router NICHT praktikabel**. > Folge: **native RouterOS-Features bevorzugen** (z.B. Built-in-mDNS statt Container); Container (mDNS-Reflector, BIND9-DNS, Omada-Controller) laufen auf **NAS/pve**, nicht am hAP. @@ -102,6 +102,10 @@ Kontrolle: MikroMCP (MikroTik) live · ALLNET = Klickanleitungen · Omada → Co per Auth" nur via 802.1X (Geräte ohne EAP wie Sonos/TV/IoT brauchen ohnehin PSK) → Hybrid in TP8. - **Mesh = NEIN** (beide APs wired-trunk → Loop-Risiko). DoH-Upstream = Cloudflare Gateway (seit TP4 aktiv). - Abh. erfüllt: TP4 ✅ (L3/VLAN-Interfaces). Folge: TP8 (802.1X), Sonos-Migration. +- 🔄 **CAPsMAN wird abgeschafft (User-Entscheid 2026-07-22) — WLAN-Neuaufbau from scratch.** Anlass: WLAN dauerhaft instabil, besonders wenn **beide** APs laufen. Beide APs bekommen künftig **eigenständige, von Nautobot Golden Config identisch generierte RouterOS-Configs** statt Manager/CAP. + - **Begründung (kein Widerspruch zu Nautobot, aber ein echter Reibungspunkt):** CAPsMAN erzeugt auf dem CAP **dynamische** Objekte (`wifi13–22`), die nie in dessen eigener Config stehen → Golden-Config-Compliance sieht auf ap2 faktisch nichts. Genau diese Halbheit war die Ursache von TP3.1 (CAPsMAN schrieb `datapath.vlan-id` nicht auf die Leitung und legte die tagged-Member am CAP nicht an). + - **Was NICHT verloren geht:** 802.11r/k/v braucht kein CAPsMAN, sondern nur gleiche SSID/PSK/Mobility-Domain → Fast-Roaming bleibt. Preis: Radio-/Kanaländerungen sind zwei Deployments statt eines Provision-Pushes — mit Nautobot ohnehin ein Job-Run. + - **Reihenfolge:** WLAN-Neuprovisionierung erst **nach** dem CRS326-Bring-up (TP15), da Routing/DHCP/DNS vorher umziehen. ### TP3.1 — ap2 VLAN-Trunk-Fix ▸ ✅ Config live (2026-07-01) — `routeros/tp3.1-ap2-vlan.rsc` - **Root Cause:** ap2 (CAP, local forwarding) hatte `bridge vlan-filtering=no` → `datapath.vlan-id` wurde nie @@ -131,6 +135,7 @@ Kontrolle: MikroMCP (MikroTik) live · ALLNET = Klickanleitungen · Omada → Co ⚠️ **Rest-Leak bleibt: cudy1 (altes WLAN)** bridged Clients weiter untagged in VLAN1 — nur per Switch-Port-Tag bzw. Alt-WLAN-Abbau (Migration) zu schließen, NICHT auf der MikroTik-Seite. Zusätzlich `st-main rrm=yes wnm=yes` (802.11k/v, behandelt nur das Roaming-Symptom). SSoT-Detail: Memory `project_vlan1_dhcp_incident`. +- 🎯 **ROOT CAUSE des ap1-Lecks gefunden (2026-07-22, live aus `/interface/wifi/configuration print`) — es ist KEIN `frame-types`-Problem:** `cfg-main-2g` (SSID `dirtyvader-main` auf 2,4 GHz, gebunden an Master-Radio `wifi2`) hat **überhaupt keinen `datapath`-Block** — weder `bridge` noch `vlan-id`. Das 5-GHz-Pendant `cfg-main-5g`/`wifi1` taggt sauber nach VLAN 10, **2,4 GHz taggt gar nicht** → egress ungetaggt in VLAN 1. Ein Client, der zwischen den Bändern **derselben SSID** wechselt, springt damit `10.40.10.x` ↔ `10.40.41.x` — Subnetzwechsel mitten in der Verbindung. Erklärt das Dual-DHCP-Race und das „verbunden, aber sporadisch kein WLAN" ohne jede Zusatzannahme. **Fix nicht mehr direkt anwenden** — geht mit der WLAN-Neuprovisionierung (CAPsMAN-Abschaffung, TP3) über Nautobot ein. Nebenbefund: `cfg-infra` existiert auf ap1, wird von **keinem** Interface benutzt (deckt sich mit TP3.3), und ap1 funkt real auf **ch36/ch1** — die Interfaces überschreiben das Channel-Profil `ch-5g` (5745/ch149). - 🔴 **Update 2026-07-15: gleiches Leck jetzt auf ap1 vermutet.** `dirtyvader-main`-Clients bekommen zeitweise `10.40.41.x`-IPs statt `10.40.10.x` — identisches Dual-DHCP-Race-Muster wie oben, aber die 2026-07-06-Härtung wurde **nur auf ap2** gemacht. Nächster Schritt: gleiche `admit-only-vlan-tagged`-Umstellung auf ap1s lokalen @@ -345,7 +350,13 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". - ✅ **CRS = RouterOS-fähig → in-model:** MikroMCP-Write + Nautobot Golden Config (TP21) managen auch die **Switches**, nicht nur den Router → config-as-code deckt den **ganzen Kabel-Fabric**. (SwOS-Boot bliebe als Fallback möglich, aber nicht genutzt.) - ⚠️ **kein PoE** (Sonoff/PoE-Verbraucher via Injektor, falls nötig). - **📶 WLAN bleibt hAP ax²/CAPsMAN** — kein Hardware-/Ecosystem-Wechsel. Coverage-Schwachstellen (P750W-Fall, Terrasse/Kanten) bleiben Platzierungs-/Roaming-Thema von **TP3.3**, nicht durch neue Hardware gelöst. -- **🧭 Router-HW final (User-Entscheid 2026-07-22): kein RB5009.** Router bleibt dauerhaft **ap1/ap2** (hAP ax²). L3/CAKE/Firewall bleiben auf ap1; device-mode=home-Einschränkungen (kein scheduler/fetch) bleiben bekannter Kompromiss, kein HW-Wechsel geplant. +- **🧭 Router-HW final (User-Entscheid 2026-07-22): kein RB5009.** Kein Zukauf einer dedizierten Router-HW. +- **🔄 KORREKTUR (User-Entscheid 2026-07-22, später am selben Tag): der CRS326-Core WIRD der neue L3-Router.** Die frühere Fassung dieser Zeile („Router bleibt dauerhaft ap1/ap2, L3/CAKE/Firewall bleiben auf ap1") ist damit **überholt**. **Routing, DNS und DHCP wandern vollständig von ap1 auf `crs326-core`**; ap1/ap2 werden zu **reinen Access Points**. Auch der CF-Mesh-Endpoint terminiert dort (s. TP16.1, war schon vorher auf CRS326 retargetet — passt jetzt konsistent zusammen). Damit entfallen die `device-mode=home`-Einschränkungen des hAP (kein scheduler/fetch) für die Router-Rolle. **Reihenfolge (User): erst Switches aufsetzen und in Betrieb nehmen, danach WLAN-Neuprovisionierung der APs.** Betroffen/nachzuziehen: TP4 (Router-Rolle), TP5/TP6 (Firewall + CAKE landen auf dem CRS326), TP21 (Scope-Grenze). +- **📍 CRS326 Live-Stand (2026-07-22, via MNDP von ap1):** **beide Geräte sind bereits am Netz und laufen** — RouterOS **7.19.5**, Werkszustand. `crs326-core` = MAC `04:F4:1C:B4:17:76` (software-id `4W04-URHT`), `crs326-branch` = MAC `04:F4:1C:B4:15:58` (`RGZJ-UK0T`). ⚠️ **Beide auf Werks-IP `192.168.88.1` im selben L2-Segment = IP-Konflikt** → beim Bring-up nur eines nach dem anderen adressieren, Unterscheidung ausschließlich per MAC. Die frühere Notiz „Uplink-Port bislang ungenutzt/down" ist überholt. **Standorte: `crs326-core` = „Technik UG" (virtueller Technikbereich im UG/Diele, KEIN Keller), `crs326-branch` = „Technik OG".** +- **🔌 PoE-Anschluss der APs (User-Vorgabe 2026-07-22):** Verkabelung/Portbelegung bleibt sonst **unverändert**; ap1/ap2 sollen künftig aber am **PoE-Port** hängen. PoE selbst ist noch kein Thema, die Config soll es aber schon vorsehen. + - ✅ **Port = `ether1`.** RouterOS nummeriert Interfaces ab **1** (`ether1`…`ether5`), nie `ether0` — die Unix-Konvention `eth0` gilt hier nicht. Auf dem hAP ax² ist `ether1` der PoE-Port (live bestätigt: `/interface/ethernet/poe` listet ausschließlich `ether1`). + - ⚠️ **Aber: passives PoE 18–28 V, NICHT 802.3af/at.** Ein normaler 802.3af/at-Switch oder -Injektor speist den hAP ax² **nicht**. Es braucht einen **passiven PoE-Injektor** (MikroTik-Standard). Dazu passend: **der CRS326 hat gar kein PoE** (bereits dokumentiert) → Strom kommt ohnehin aus einem Inline-Injektor zwischen Switchport und AP, nicht aus dem Switch. + - ⚠️ **Config-Konsequenz:** ap1s Trunk liegt heute auf **`ether2`** → wandert bei der Neuprovisionierung auf **`ether1`**. Und `ether1` steht aktuell auf **`poe-out=auto-on`** (der hAP kann über denselben Port auch Strom *abgeben*) — beim Umbau auf „ether1 = versorgter Uplink" muss `poe-out` auf diesem Port **`off`** gesetzt werden, sonst speisen Injektor und AP gegeneinander. - **🔋 DC-USV** (unverändert gültig): MeanWell LRS-150-48 + DDR-120-48 + LiFePO4 20Ah (~€190), 48V-Bus; CRS326 DC-input passt. - **Offen:** Standort-Zuordnung der 2 CRS326 (Diele/OG) nach TP19-VLAN1-Retirement; ob/wann sw2 durch einen 3. CRS326 abgelöst wird. - **Hinweis:** die A/B/C/D-Switch-Varianten in `hw-refresh-requirements.md` sind durch die CRS326-Beschaffung überholt (Doc = Recherche-Archiv + USV/Energie-Referenz). @@ -495,6 +506,14 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". - ✅ **End-to-End verifiziert:** `/api/status/` mit Token 200, Nautobot-Version 3.1.7, alle 4 Container laufen. Superuser `admin` angelegt, Creds im Stack-File (gitignored) + `postgres/.secrets` (DB-Seite). - ⚠️ **Bekannt offen, nicht blockierend:** kein HTTPS/Reverse-Proxy vor Nautobot (Django-Security-Warnings beim Start, erwartet für internen HTTP-only-Betrieb auf VLAN20 — analog anderen internen Diensten); Celery-Worker-Registrierung beim allerersten Start nicht sofort sichtbar (`celery-workers-running:0` initial), unkritisch, braucht kurz zum Einpendeln. - **PoC-Pfad (angepasst an die Montag-Deadline):** ~~Nautobot-Stack hoch~~ ✅ → **ap1 + ap2 in einem Rutsch adoptieren** (ap2 bekommt dabei gleich seinen VLAN99-Mgmt-Umzug aus TP19.1, s.o. — nicht isoliert vorher) → sobald CRS326 verkabelt sind, **direkt als Nautobot-verwaltete Geräte adoptieren statt erst manuell konfigurieren** → Compliance/Drift + Templates/intended für VLAN-/Firewall-Standards danach. +- ✅ **Basisdaten-Ingest durchgeführt (2026-07-22)** — Ist-Zustand, nicht Zielzustand (User-Entscheid: „wir gehen vom status quo aus"). Quelle = ap1 live via MikroMCP. Skript-Muster idempotent (get-or-create + Delta-PATCH). + - **Locations:** neuer LocationType `Bereich` unter `Site`; `BF → Technik UG` (crs326-core, crs326-branch, ap2) und `BF → Wohnzimmer Sideboard` (ap1). + - **Devices:** ap1 = Rolle *Router* (Ist!), ap2 = neue Rolle *Wireless AP* + Status **Offline**, beide CRS326 **Platform RouterOS nachgetragen** (fehlte → hätte jeden GC-Job auf ihnen verhindert) + Werks-MAC/software-id in `comments`. Neu als Übergangs-Devices **ohne Platform** (nie GC-fähig, bewusst): `sw1` (ALLNET), `sw2` (ES208G), `cudy1`. + - **IPAM:** VLAN-Group `BF` + **9 VLANs** (1 *Deprecated*, 10/20/30/40/50/90/99/100 aktiv) + **8 Prefixes**, jeweils an ihr VLAN gebunden. VLAN100/WAN bewusst ohne Prefix (öffentliches Liwest-/24, gehört uns nicht). + - **ap1-Interfaces (26):** ether1-5, bridge, lo, wg-hub, vlan10-100, wifi1/wifi2, 7 VAPs — inkl. MACs, `ether2` als Trunk (untagged VLAN1 + tagged 10-100), VAPs als Access-Ports auf ihrem VLAN. 9 Gateway-IPs an ihren Interfaces. Platzhalter-Interface `mgmt` auf ap1 gelöscht (existiert real nicht). + - **WLAN:** logischer Controller `bf-wlan` (**kein** CAPsMAN — `controller_device` bewusst leer, die Control-Plane *ist* Nautobot GC) + Gruppe `BF-WLAN-APs` (ap1+ap2) + **6 WirelessNetworks mit VLAN-Bindung** + 2 RadioProfiles (2,4 GHz ch1 / 5 GHz ch36, Domain AT). + - 🐞 **API-Gotcha:** `tagged_vlans` wird vom REST-Serializer in Detail-/List-Views als `null` zurückgegeben, **obwohl korrekt gespeichert** — Verifikation nur über **GraphQL** verlässlich. Kostet sonst eine Stunde Fehlersuche an einem Nicht-Problem. Ebenso: Relationen kommen per Default ohne `display` (`?depth=1` nötig), und `VLANGroup`/`Prefix`/`VLAN` brauchen passende `content_types` am LocationType, sonst 400. + - ⚠️ **Offen/nachzutragen:** exaktes Modell von sw1 + cudy1 (aktuell `… (Modell TBD)`); Standort von sw1/sw2/cudy1 (hängen an `BF` statt an einem Bereich); ap2s `primary_ip4` zeigt noch auf das tote `10.40.41.2`; CRS326-Ports bewusst noch nicht angelegt (erst wenn die Portbelegung steht). - **Offen:** Git-Backend-Anbindung an Gitea; ap1/ap2-Adoption via RouterOS-API; ob OpenWrt je in-model muss (custom Nornir-Dispatcher — nur relevant falls der Router-Pfad je auf OpenWrt+cake-autorate wechselt, aktuell nicht geplant). - Abh.: **TP20** (Postgres muss stehen), Gitea ✅, RouterOS-Router (ap1/ap2, dauerhaft). diff --git a/nautobot/README.md b/nautobot/README.md new file mode 100644 index 0000000..61c261d --- /dev/null +++ b/nautobot/README.md @@ -0,0 +1,26 @@ +# Nautobot-Tooling + +## `ingest-baseline.py` +Idempotenter Basisdaten-Ingest (Locations, Rollen, Device-Types, Devices, VLANs/Prefixes, +ap1-Interfaces + IPs, WLAN-Modelle). Bildet den **Ist-Zustand** ab, nicht den Zielzustand. + +```bash +export NAUTOBOT_TOKEN=... # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored) +python3 nautobot/ingest-baseline.py # Dry-Run +python3 nautobot/ingest-baseline.py --apply # schreiben +``` + +### API-Fallen (teuer gelernt) +- **`tagged_vlans` liest sich per REST als `null`, obwohl korrekt gespeichert.** Verifikation + nur über GraphQL: `POST /api/graphql/` mit + `{interfaces(device:"ap1"){name mode untagged_vlan{vid} tagged_vlans{vid}}}`. +- Relationen kommen ohne `?depth=1` ohne `display` → ein voll gepflegtes Device sieht leer aus. +- `LocationType.content_types` muss `ipam.vlangroup`/`ipam.vlan`/`ipam.prefix`/`dcim.controller` + enthalten, sonst 400 beim Anlegen. +- `parent_interface` nur für virtuelle Interfaces — WLAN-VAPs können ihren Master nicht als + Parent tragen. +- `WirelessNetwork` hat **kein** VLAN-Feld; die VLAN-Bindung hängt an + `wireless/controller-managed-device-group-wireless-network-assignments` und braucht zwingend + einen `Controller` (bei uns logisch, `controller_device` leer — kein CAPsMAN). +- `RadioProfile.channel_width` will Integer (`[20,40,80]`), nicht `"20mhz"`. +- IP↔Interface läuft über `/ipam/ip-address-to-interface/`. diff --git a/nautobot/ingest-baseline.py b/nautobot/ingest-baseline.py new file mode 100644 index 0000000..757fcfe --- /dev/null +++ b/nautobot/ingest-baseline.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""BF-Netzwerk -> Nautobot Basisdaten-Ingest (idempotent). + +Bildet den IST-Zustand ab (Stand 2026-07-22), nicht den Zielzustand. +Quelle: ap1 live via MikroMCP + docs/teilprojekte.md. +""" +import json, sys, urllib.request, urllib.error + +BASE = "http://10.40.20.30:8080/api" +import os +TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored) +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): + """get-or-create; gibt das Objekt zurueck.""" + name = label or payload.get("name") or payload.get("model") or str(match) + # Im Dry-Run haengen Folgeobjekte an Platzhalter-IDs -> Lookup nicht moeglich. + if any(str(v).startswith("DRY-") for v in match.values()): + log.append(f" + {name} (dry-run, Abhaengigkeit noch nicht angelegt)") + return {"id": f"DRY-{name}"} + 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): + """Nur die Felder patchen, die sich real unterscheiden.""" + 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"] + + +# ---------------------------------------------------------------- Statuses +S_ACTIVE, S_OFFLINE, S_DEPR, S_PLANNED = (sid(n) for n in ("Active", "Offline", "Deprecated", "Planned")) + +print("== 1. Location-Types + Locations") +site_lt = find("/dcim/location-types", name="Site") +bereich = goc("/dcim/location-types", {"name": "Bereich"}, { + "name": "Bereich", "parent": site_lt["id"], "nestable": True, + "content_types": ["dcim.device"], + "description": "Raum/Etage innerhalb einer Site", +}) +bf = find("/dcim/locations", name="BF") +LOC = {} +for n, d in [("Technik UG", "Technikraum Untergeschoss - Core/Branch-Switch, ap2"), + ("Wohnzimmer Sideboard", "Sideboard Wohnzimmer - ap1 (Router + AP)")]: + LOC[n] = goc("/dcim/locations", {"name": n}, { + "name": n, "location_type": bereich["id"], "parent": bf["id"], + "status": S_ACTIVE, "description": d}) + +print("== 2. Rollen / Hersteller / Device-Types") +role_router = find("/extras/roles", name="Router") +role_switch = find("/extras/roles", name="Switch") +role_ap = goc("/extras/roles", {"name": "Wireless AP"}, { + "name": "Wireless AP", "color": "2196f3", "content_types": ["dcim.device"]}) + +MFR = {} +for m in ("ALLNET", "TP-Link", "Cudy"): + MFR[m] = goc("/dcim/manufacturers", {"name": m}, {"name": m}) +mt = find("/dcim/manufacturers", name="MikroTik") + +DT = {} +for mfr, model, note in [ + ("ALLNET", "ALLNET Managed Switch (Modell TBD)", "sw1 Core-Switch, FW 2.2.1, 16x GbE - exaktes Modell noch nachzutragen"), + ("TP-Link", "ES208G", "sw2 Omada Easy Managed, 8 Ports, standalone"), + ("Cudy", "Cudy AP (Modell TBD)", "cudy1 - Standalone-WLAN waehrend der Umstellungsphase"), +]: + DT[model] = goc("/dcim/device-types", {"model": model}, { + "manufacturer": MFR[mfr]["id"], "model": model, "u_height": 1, + "comments": note}, label=model) + +dt_hap = find("/dcim/device-types", model="hAP ax2") +dt_crs = find("/dcim/device-types", model="CRS326-24G-2S+RM") +plat_ros = find("/dcim/platforms", name="RouterOS") + +print("== 3. Bestands-Devices korrigieren") +D = {} +for name, payload in [ + ("ap1", {"location": LOC["Wohnzimmer Sideboard"]["id"], "role": role_router["id"], + "platform": plat_ros["id"], "status": S_ACTIVE}), + ("ap2", {"location": LOC["Technik UG"]["id"], "role": role_ap["id"], + "platform": plat_ros["id"], "status": S_OFFLINE}), + ("crs326-core", {"location": LOC["Technik UG"]["id"], "platform": plat_ros["id"], + "role": role_switch["id"], "status": S_PLANNED}), + ("crs326-branch", {"location": LOC["Technik UG"]["id"], "platform": plat_ros["id"], + "role": role_switch["id"], "status": S_PLANNED}), +]: + dev = find("/dcim/devices", name=name) + D[name] = patch("/dcim/devices", dev, payload, name) + +# Werks-Identitaeten der CRS326 als Kommentar festhalten (per MAC unterscheidbar) +if not DRY: + for name, note in [ + ("crs326-core", "Werkszustand. RouterOS 7.19.5. MAC 04:F4:1C:B4:17:76, software-id 4W04-URHT, Werks-IP 192.168.88.1."), + ("crs326-branch", "Werkszustand. RouterOS 7.19.5. MAC 04:F4:1C:B4:15:58, software-id RGZJ-UK0T, Werks-IP 192.168.88.1. ap2 haengt an Port 24."), + ]: + req("PATCH", f"/dcim/devices/{D[name]['id']}/", {"comments": note}) + +print("== 4. Uebergangs-Devices (ohne Platform -> nie Golden Config)") +for name, dt, role, loc, note in [ + ("sw1", "ALLNET Managed Switch (Modell TBD)", role_switch, bf, + "ALLNET Core-Switch, Mgmt 10.40.41.11 (VLAN1). Nur Telnet-CLI, kein RouterOS. Wird von crs326-core abgeloest (TP15)."), + ("sw2", "ES208G", role_switch, bf, + "Omada ES208G, Mgmt 10.40.99.12 (statisch). Standalone ohne Controller (TP2.1). Interim bis CRS326-Ausbau."), + ("cudy1", "Cudy AP (Modell TBD)", role_ap, bf, + "Standalone-WLAN 'elektrosmog.c' auf 10.40.10.246. Uebergangssender, kein Abbauziel mehr (TP19)."), +]: + goc("/dcim/devices", {"name": name}, { + "name": name, "device_type": DT[dt]["id"], "role": role["id"], + "location": loc["id"], "status": S_ACTIVE, "comments": note}) + +print("== 5. VLAN-Group + VLANs") +vg = goc("/ipam/vlan-groups", {"name": "BF"}, { + "name": "BF", "location": bf["id"], "description": "Kanonisches VLAN-Modell Standort BF"}) + +VLANS = [ + (1, "LEGACY", S_DEPR, "Alt-/Default-VLAN. Abbau laeuft (TP19/TP19.1). Traegt noch sw1-Mgmt + ap1-Bridge-IPs."), + (10, "MAIN", S_ACTIVE, "Trusted: Handy/Laptop/Apple Watch"), + (20, "INFRA", S_ACTIVE, "NAS/VMs/Docker/HA"), + (30, "IOT", S_ACTIVE, "Smart-Home (HA-managed), Shellys"), + (40, "MEDIA", S_ACTIVE, "Sonos, AppleTV, TV, Cast-Ziele"), + (50, "UNTRUSTED", S_ACTIVE, "Spielekonsole, eBook-Reader, Low-Trust"), + (90, "GUEST", S_ACTIVE, "nur Internet"), + (99, "MGMT", S_ACTIVE, "Switch-/AP-/Hypervisor-Mgmt"), + (100, "WAN", S_ACTIVE, "Transfernetz zum Arris-Kabelmodem (Liwest, Bridge-Mode)"), +] +V = {} +for vid, nm, st, desc in VLANS: + V[vid] = goc("/ipam/vlans", {"vid": vid, "vlan_group": vg["id"]}, { + "vid": vid, "name": nm, "vlan_group": vg["id"], "status": st, + "location": bf["id"], "description": desc}, label=f"VLAN{vid} {nm}") + +print("== 6. Prefixes") +PFX = [ + ("10.40.41.0/24", 1, S_DEPR, "VLAN1 LEGACY - Abbau laeuft (TP19)"), + ("10.40.10.0/24", 10, S_ACTIVE, "VLAN10 MAIN"), + ("10.40.20.0/24", 20, S_ACTIVE, "VLAN20 INFRA"), + ("10.40.30.0/24", 30, S_ACTIVE, "VLAN30 IOT"), + ("10.40.40.0/24", 40, S_ACTIVE, "VLAN40 MEDIA"), + ("10.40.50.0/24", 50, S_ACTIVE, "VLAN50 UNTRUSTED"), + ("10.40.90.0/24", 90, S_ACTIVE, "VLAN90 GUEST"), + ("10.40.99.0/24", 99, S_ACTIVE, "VLAN99 MGMT"), +] +for pfx, vid, st, desc in PFX: + ex = find("/ipam/prefixes", prefix=pfx) + payload = {"vlan": V[vid]["id"], "status": st, "description": desc, "location": bf["id"]} + if ex: + patch("/ipam/prefixes", ex, payload, pfx) + else: + goc("/ipam/prefixes", {"prefix": pfx}, dict(prefix=pfx, **payload), label=pfx) + +print("== 7. ap1-Interfaces (Ist, live von ap1)") +ETH = "1000base-t" +IFACES = [ + # name, type, mac, description + ("ether1", ETH, "D0:EA:11:4D:40:E0", "ungenutzt (WAN lief hier vor dem Edge-Cutover)"), + ("ether2", ETH, "D0:EA:11:4D:40:E1", "Trunk zu sw1 GE1 - traegt WAN vlan100 + alle Client-VLANs"), + ("ether3", ETH, "D0:EA:11:4D:40:E2", ""), + ("ether4", ETH, "D0:EA:11:4D:40:E3", ""), + ("ether5", ETH, "D0:EA:11:4D:40:E4", ""), + ("bridge", "bridge", "D0:EA:11:4D:40:E1", "vlan-filtering=yes; VLAN1 untagged, 10-100 tagged"), + ("lo", "virtual", None, "loopback"), + ("wg-hub", "virtual", None, "TP10 Multi-Site VPN Hub (FM/DW spokes), UDP 51821, MTU 1412"), +] +for vid in (10, 20, 30, 40, 50, 90, 99, 100): + IFACES.append((f"vlan{vid}", "virtual", None, "")) +WIFI = [ + ("wifi1", None, "5 GHz Master, cfg-main-5g, ch36/5180, SSID dirtyvader-main -> VLAN10"), + ("wifi2", None, "2.4 GHz Master, cfg-main-2g, ch1/2412, SSID dirtyvader-main -> KEIN datapath/VLAN (egress untagged VLAN1!)"), + ("w2-guest", "wifi2", "cfg-guest -> VLAN90"), + ("w2-iot", "wifi2", "cfg-iot -> VLAN30 (multicast-enhance)"), + ("w2-media", "wifi2", "cfg-media -> VLAN40"), + ("w2-untrusted", "wifi2", "cfg-untrusted -> VLAN50"), + ("w5-guest", "wifi1", "cfg-guest -> VLAN90"), + ("w5-media", "wifi1", "cfg-media -> VLAN40"), + ("w5-untrusted", "wifi1", "cfg-untrusted -> VLAN50"), +] + +ap1 = D["ap1"] +IF = {} +for nm, typ, mac, desc in IFACES: + p = {"device": ap1["id"], "name": nm, "type": typ, "status": S_ACTIVE, "description": desc} + if mac: + p["mac_address"] = mac + IF[nm] = goc("/dcim/interfaces", {"device": "ap1", "name": nm}, p, label=f"ap1:{nm}") + +for nm, master, desc in WIFI: + full = f"master-interface={master}; {desc}" if master else desc + p = {"device": ap1["id"], "name": nm, "type": "ieee802.11ax", + "status": S_ACTIVE, "description": full} + IF[nm] = goc("/dcim/interfaces", {"device": "ap1", "name": nm}, p, label=f"ap1:{nm}") + +# VLAN-Subinterfaces an die Bridge haengen + tagged-VLAN am Trunk +if not DRY: + for vid in (10, 20, 30, 40, 50, 90, 99, 100): + req("PATCH", f"/dcim/interfaces/{IF[f'vlan{vid}']['id']}/", { + "parent_interface": IF["bridge"]["id"], "mode": "tagged", + "tagged_vlans": [V[vid]["id"]]}) + # WLAN-VAPs: Nautobot erlaubt parent_interface nur fuer virtuelle Interfaces. + # Master-Beziehung steht daher in der description; hier nur die VLAN-Zuordnung. + for nm, vid in [("w2-guest", 90), ("w2-iot", 30), ("w2-media", 40), ("w2-untrusted", 50), + ("w5-guest", 90), ("w5-media", 40), ("w5-untrusted", 50), ("wifi1", 10)]: + req("PATCH", f"/dcim/interfaces/{IF[nm]['id']}/", { + "bridge": IF["bridge"]["id"], "mode": "access", "untagged_vlan": V[vid]["id"]}) + req("PATCH", f"/dcim/interfaces/{IF['ether2']['id']}/", { + "mode": "tagged", "untagged_vlan": V[1]["id"], + "tagged_vlans": [V[v]["id"] for v in (10, 20, 30, 40, 50, 90, 99, 100)]}) + +print("== 8. ap1-IP-Adressen") +IPS = [ + ("10.40.41.1/24", "bridge", "defconf VLAN1 (Legacy)"), + ("10.40.41.254/24", "bridge", "Secondary = alte FB-GW-IP, haelt statische .41-Hosts online"), + ("10.40.10.1/24", "vlan10", "MAIN GW"), + ("10.40.20.1/24", "vlan20", "INFRA GW"), + ("10.40.30.1/24", "vlan30", "IOT GW"), + ("10.40.40.1/24", "vlan40", "MEDIA GW"), + ("10.40.50.1/24", "vlan50", "UNTRUSTED GW"), + ("10.40.90.1/24", "vlan90", "GUEST GW"), + ("10.40.99.1/24", "vlan99", "MGMT GW"), +] +for addr, ifname, desc in IPS: + ip = find("/ipam/ip-addresses", address=addr) + if not ip: + ip = goc("/ipam/ip-addresses", {"address": addr}, + {"address": addr, "status": S_ACTIVE, "description": desc, + "namespace": find("/ipam/namespaces", name="Global")["id"]}, label=addr) + if not DRY and not find("/dcim/interfaces", device="ap1", name=ifname, ip_addresses=ip["id"]): + try: + req("POST", "/ipam/ip-address-to-interface/", { + "ip_address": ip["id"], "interface": IF[ifname]["id"]}) + log.append(f" + {addr} -> ap1:{ifname}") + except urllib.error.HTTPError: + log.append(f" = {addr} -> ap1:{ifname} (bereits verknuepft)") + +print("== 9. WLAN: Controller + Gruppe + Netze") +ctrl = goc("/dcim/controllers", {"name": "bf-wlan"}, { + "name": "bf-wlan", "status": S_ACTIVE, "location": bf["id"], + "description": "Logischer WLAN-Controller = Nautobot Golden Config. KEIN CAPsMAN " + "(Entscheid 2026-07-22): beide APs bekommen eigenstaendige, identisch " + "generierte RouterOS-Configs."}) +grp = goc("/dcim/controller-managed-device-groups", {"name": "BF-WLAN-APs"}, { + "name": "BF-WLAN-APs", "controller": ctrl["id"], + "description": "ap1 + ap2 - standalone provisioniert, kein CAPsMAN"}) +if not DRY: + for n in ("ap1", "ap2"): + req("PATCH", f"/dcim/devices/{D[n]['id']}/", {"controller_managed_device_group": grp["id"]}) + +# ssid, vlan, auth, ros-detail +NETS = [ + ("dirtyvader-main", 10, "WPA3 Personal", + "RouterOS wpa3-psk, FT+FT-over-DS=yes. 5 GHz (wifi1) taggt VLAN10; " + "2.4 GHz (wifi2/cfg-main-2g) hat KEINEN datapath -> egress untagged VLAN1. Bekannter Defekt."), + ("dirtyvader-infra", 20, "WPA2 Personal", + "RouterOS wpa2-psk+wpa3-psk, ft=no. Konfiguration cfg-infra existiert auf ap1, " + "wird aber von KEINEM Interface benutzt -> sendet dort nicht (TP3.3)."), + ("dirtyvader-iot", 30, "WPA2 Personal", + "RouterOS wpa2-psk+wpa3-psk, ft=no, multicast-enhance. Nur 2.4 GHz (w2-iot)."), + ("dirtyvader-media", 40, "WPA2 Personal", + "RouterOS wpa2-psk+wpa3-psk, ft=no (bewusst, wegen Sonos), multicast-enhance. 2.4 + 5 GHz."), + ("dirtyvader-untrusted", 50, "WPA2 Personal", + "RouterOS wpa2-psk+wpa3-psk, ft=no. 2.4 + 5 GHz."), + ("dirtyvader-guest", 90, "WPA2 Personal", + "RouterOS wpa2-psk+wpa3-psk, FT+FT-over-DS=yes. 2.4 + 5 GHz."), +] +for ssid, vid, auth, desc in NETS: + wn = goc("/wireless/wireless-networks", {"name": ssid}, { + "name": ssid, "ssid": ssid, "mode": "Standalone (Autonomous)", + "authentication": auth, "enabled": True, "hidden": False, + "description": desc}, label=ssid) + if not DRY: + ex = find("/wireless/controller-managed-device-group-wireless-network-assignments", + wireless_network=wn["id"], controller_managed_device_group=grp["id"]) + if not ex: + req("POST", "/wireless/controller-managed-device-group-wireless-network-assignments/", { + "controller_managed_device_group": grp["id"], "wireless_network": wn["id"], + "vlan": V[vid]["id"]}) + log.append(f" + {ssid} -> VLAN{vid}") + +print("== 10. Radio-Profile (Ist ap1)") +for nm, freq, chans, width, desc in [ + ("BF 2.4GHz ap1", "2.4GHz", [1], [20], "ap1 wifi2: 2412 MHz, tx-power 20 dBm"), + ("BF 5GHz ap1", "5GHz", [36], [20, 40, 80], "ap1 wifi1: 5180 MHz, skip-dfs-channels=10min-cac"), +]: + rp = goc("/wireless/radio-profiles", {"name": nm}, { + "name": nm, "frequency": freq, "regulatory_domain": "AT", + "allowed_channel_list": chans, "channel_width": width, "description": desc}) + if not DRY: + ex = find("/wireless/controller-managed-device-group-radio-profile-assignments", + radio_profile=rp["id"], controller_managed_device_group=grp["id"]) + if not ex: + req("POST", "/wireless/controller-managed-device-group-radio-profile-assignments/", { + "controller_managed_device_group": grp["id"], "radio_profile": rp["id"]}) + +print("\n".join(log)) +print(f"\n{'DRY-RUN — nichts geschrieben. Mit --apply ausfuehren.' if DRY else 'ANGEWENDET.'}")