diff --git a/CLAUDE.md b/CLAUDE.md index e892c91..316bf6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,4 +74,5 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`, - **✅✅ Edge-Cutover (2026-07-01; Router-Umzug 2026-08-08): FritzBox ausgebaut, Arris im Bridge-Mode.** Liwest hat die FB6660 dauerhaft gegen ein Arris getauscht (physisch weg). WAN terminiert auf **`crs326-core vlan100`** (VLAN100-Transfer), core zieht die öffentliche Liwest-IP `90.146.33.177` → kein Double-NAT. **core = Public-Edge + WG-Hub** (fm/dw live), NICHT mehr ap1. Telefonie nicht weitergeführt. Runbook `routeros/wan-cutover-runbook.md` (ap1-Teile historisch). - **hAP `device-mode=home` (ap1/ap2): scheduler + fetch blockiert** (`not allowed by device-mode`) → AP-Provisionierung idempotent **ohne** Scheduler. **`[/ping]` gibt nil in 7.23**. REST je AP `http://10.40.99.11|12/rest` (Creds = `ROUTER_AP{1,2}` in `~/.claude.json`). **Der L3-Router crs326-core ist KEIN device-mode=home** → dort gehen scheduler/fetch (z.B. DDNS-Updater). - **Erstzugriff Werks-hAP am CRS-Fabric:** Uplink `ether1` = Werks-WAN → `192.168.88.1` (LAN) darüber NICHT erreichbar. Weg: Gerät auf **LAN-Port (ether2)** patchen ODER **Core-Temp-Pfad** `192.168.88.2/24` auf core-`bridge` + srcnat masq `192.168.88.0/24`, dann scp+`/import` (danach Temp-Pfad + stale ARP `.88.1` entfernen). Winbox-Neighbors zeigt die APs NICHT (MNDP pro-VLAN, Mgmt auf vlan99) — kein L2-Fehler. -- **P750W (Brother, VLAN30) läuft auf `dirtyvader-iot`** (User-bestätigt 2026-08-08) — **kein** Access-List-Pin nötig; die alte „nur am co-located L3-GW-AP erreichbar"-Logik ist mit L3-GW auf dem Core hinfällig. **Merkregel bleibt: DHCP-geht-aber-Ping-nicht = ARP/Power-Save, NICHT Routing/L2/MTU/Switch.** Coverage-Kanten/Terrasse grenzwertig (−76..−89 dBm); TX am AT-Max (18/15 dBm); echter Fix = Platzierung. SSoT `docs/wifi-p750w-coverage-and-map.md`. +- **P750W (Brother, VLAN30) läuft auf `dirtyvader-iot`** (User-bestätigt 2026-08-08) — **kein** Access-List-Pin nötig; die alte „nur am co-located L3-GW-AP erreichbar"-Logik ist mit L3-GW auf dem Core hinfällig. Coverage-Kanten/Terrasse grenzwertig (−76..−89 dBm); TX am AT-Max (18/15 dBm); echter Fix = Platzierung. SSoT `docs/wifi-p750w-coverage-and-map.md`. ⚠️ **Power-Save ist KEIN Thema dieses Netzes** — die früher hier stehende Merkregel „DHCP-geht-aber-Ping-nicht = ARP/Power-Save" war eine Fehldiagnose (2026-08-13 korrigiert: der Drucker war schlicht ausgeschaltet, das Ping-Symptom war WARP, s.u.). Nicht wieder einführen. +- **Ping auf JEDE lokale IP tot, aber Web/App/Print (TCP) geht überall = WARP-Client lokal versehentlich noch aktiv** (2026-08-13 live beobachtet, BF vor Ort): `cloudflared`/WARP ist QUIC-Proxy, kann **kein ICMP**, reicht TCP aber durch — auch wenn man physisch im BF-LAN steht, nimmt der Client bei aktivem WARP trotzdem den Tunnel-Umweg. **Fix: WARP trennen**, kein Router-Eingriff nötig. **Vor jeder Firewall-/Mangle-/VLAN-Suche zuerst prüfen**, wenn das Symptom „Ping auf ALLES tot, TCP geht" lautet — das ist ein Client-, kein Netzproblem. Strukturell schließt **TP16.1** (natives WireGuard-CF-Mesh auf `crs326-core` statt cloudflared-QUIC) genau diese ICMP-Lücke. Memory `ref_warp_local_icmp_trap`. diff --git a/docs/nas-vlan20/nautobot-image/nautobot_config.py b/docs/nas-vlan20/nautobot-image/nautobot_config.py index 6df8ddd..6164991 100644 --- a/docs/nas-vlan20/nautobot-image/nautobot_config.py +++ b/docs/nas-vlan20/nautobot-image/nautobot_config.py @@ -41,16 +41,40 @@ PLUGINS_CONFIG = { }, "nautobot_golden_config": { # Backup + Intended-Config + Compliance enabled 2026-07-22 (TP21 follow-up - # session) once a Jinja template + Git repo target existed. Plan/Deploy - # (device push) stay off on purpose -- this pipeline is pull/read-only - # for now, pushing config is a deliberately separate future step. + # session) once a Jinja template + Git repo target existed. + # + # Plan + Deploy enabled 2026-08-13 (TP22 Schritt 3), after backup scrubbing + # (ConfigReplace) and compliance were green on all four RouterOS devices. + # Two binding governance rules apply -- see docs/teilprojekte.md TP22: + # 1. Config Plans ONLY feature-/manual-scoped, never "full intended": + # a full push would write the literal `passphrase="<>"` to the + # APs (kicking every WLAN client) and `private-key="<>"` to the + # core (killing the WireGuard hub). Sections containing the mask are + # excluded from any push. + # 2. The AP template is never pushed line-by-line to a live device: it + # transiently sets vlan-filtering=no, which severs the vlan99 management + # path mid-session. Full re-provisioning goes via file upload + /import. + # + # enable_postprocessing stays OFF on purpose: render_secrets would not fix the + # compliance side (the stored intended would keep the un-rendered Jinja + # expression -> permanent false positive) and pushing PSKs has no use here. "enable_backup": True, "enable_compliance": True, "enable_intended": True, "enable_sotagg": True, - "enable_plan": False, - "enable_deploy": False, + "enable_plan": True, + "enable_deploy": True, "enable_postprocessing": False, + # REQUIRED for deploy on RouterOS (found the hard way, 2026-08-13): + # the default dispatcher resolves to NAPALM because Platform.network_driver_mappings + # carries `napalm: ros` -- but napalm-ros is a READ-ONLY driver. Backup (get_config) + # works, merge_config does not and fails with an empty error: + # NornirNautobotException: E1015: The method merge_config failed ... `` + # nornir-nautobot ships a RouterOS-specific Netmiko dispatcher whose merge_config + # pushes via netmiko_send_config -- pin it explicitly. Key = network_driver name. + "custom_dispatcher": { + "mikrotik_routeros": "nornir_nautobot.plugins.tasks.dispatcher.mikrotik_routeros.NetmikoMikrotikRouteros", + }, "postprocessing_callables": [], "postprocessing_subscribed": [], "per_feature_bar": True, diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index 24870fc..8db3fd4 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -427,14 +427,15 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". - ✅ **Recherche-Härtung 2026-07-17 (Primärquellen animmouse + offizielle CF-Docs + OpenAPI-Spec, alles verifiziert, gilt unabhängig vom Zielgerät):** (1) Produkt heißt seit 2026-04-14 offiziell **Cloudflare Mesh** (API-Pfade bleiben `warp_connector`, im Spec bestätigt inkl. Token- und sogar HA-Endpoints); (2) ICMP-Proxy ist **bereits AN** (User-Korrektur: fm-Ping `10.101.0.0/24` beweist es — kein To-Do; mein „fehlt" war ein Fehlschluss aus dem API-Spec, das Flag ist Dashboard-only); (3) Split-Tunnel-Exclude auf **beiden** Device-Profilen (Default + Mesh); (4) **vnet-Gotcha** beim Route-Umzug („virtual networks only for cloudflared") → Weg A PATCH, Weg B DELETE+POST dokumentiert; (5) **v4-only-Entscheid** (Mesh-IPv6-site-to-site laut Guide broken, NAT66-Gefrickel nicht nötig); (6) keepalive 1m statt 25s ok. Alles in `docs/tp16.1-warp-mesh-native-crs326.md` §2/§4 eingearbeitet (Config-Block dort als Platzhalter markiert, IP/Interface TBD). - Abh.: TP16 ✅ (Public-Teil), TP10 ✅ (WG-Grundmuster, jetzt als Vorbild statt Direktziel), **TP15** (CRS326-Bring-up), **TP21** (Nautobot-Adoption des CRS326, Voraussetzung fürs Scharfschalten). SSoT: `docs/tp16.1-warp-mesh-native-crs326.md`, `docs/cf-tunnel-mapping.md §14`. -### TP17 — Shelly-Übersiedelung VLAN1 → VLAN30 (IOT) + `shelly-mcp` ▸ ✅ WLAN+IP DURCH (22/26, 2026-07-13); Geräte-PW + cudy-Teardown offen — `mcp/shelly/`, Runbook `docs/tp17-shelly-go-runbook.md` -- ✅ **GO durchgezogen 2026-07-13 (16 Shellys VLAN1→VLAN30, 0 Fehler):** Pilot s26 + Welle A (s4–s9) + Welle B (s10/11/13/15/17/19/20/21/25) → alle auf `dirtyvader-iot`/`10.40.30.` verifiziert (SSID+IP+rssi). Mit den 6 Vorab-Migrierten (s14/16/18/22/23/24) = **22/26 auf VLAN30**; VLAN1-Sweep `.180-250` = **0 Shellys**. Offen nur 4 saisonal ausgesteckte (s1/s2/s3/s12 → beim Einstecken). +### TP17 — Shelly-Übersiedelung VLAN1 → VLAN30 (IOT) + `shelly-mcp` ▸ ✅ **KOMPLETT (User-Bestätigt 2026-08-13): alle Shellys auf VLAN30** — Geräte-PW (TP17.2) weiterhin bewusst zurückgestellt — `mcp/shelly/`, Runbook `docs/tp17-shelly-go-runbook.md` +- ✅ **Abschluss 2026-08-13 (User-Bestätigt):** auch die zuletzt offenen saisonalen Shellys (s1/s2/s3/s12) sind inzwischen umgeklemmt — **alle Shellys auf `dirtyvader-iot`/VLAN30**, kein VLAN1-Shelly-Rest mehr. Migration-Teil von TP17 damit vollständig abgeschlossen. +- ✅ **GO durchgezogen 2026-07-13 (16 Shellys VLAN1→VLAN30, 0 Fehler):** Pilot s26 + Welle A (s4–s9) + Welle B (s10/11/13/15/17/19/20/21/25) → alle auf `dirtyvader-iot`/`10.40.30.` verifiziert (SSID+IP+rssi). Mit den 6 Vorab-Migrierten (s14/16/18/22/23/24) = **22/26 auf VLAN30**; VLAN1-Sweep `.180-250` = **0 Shellys**. Restliche 4 saisonal ausgesteckte (s1/s2/s3/s12) inzwischen ebenfalls migriert (s.o.). - 🔧 **Anker-Verfahren (User-Idee, statt sta1-Backup):** je Gerät **vor** dem sta-Write die geräteeigene **AP-Funktion** aktiviert (`WiFi.SetConfig ap.enable` — Firmware verlangt WPA2, kein offenes AP → PSK=IOT-PSK, live/kein Reboot) = Notanker, falls VLAN30-Join scheitert (Gerät bleibt über `Shelly*-`-AP erreichbar, **kein physischer Reset**). Nach Verify alle 22 AP-Anker wieder `deanchor`t. Runner: `mcp/shelly/migrate.py` (`migrate`/`deanchor`/`auth`/`table`, Dry-Run default, stoppt bei erstem Fehler, reused `discover.py`). - ✅ **HA-Cross-Check nach Batch:** einzige `unavailable` Shelly-Entities = **s12** (saisonal aus) — alle 22 migrierten reconnecten via mDNS/zeroconf (TP7-Repeater vlan30↔vlan20-HA) auf neuer `.30.x`-IP, **Entity-IDs unverändert** (keyt per MAC). Inventar `inventory/bf.csv` .41→.30 nachgezogen (22 Zeilen, Section→IOT). - ⚠️ **Coverage-Flag:** `s4 kueche_mikrowelle` verbindet mit **rssi −93** (Metallgehäuse), hat aber verifiziert — grenzwertig, im Auge behalten. s7 Tiefkühl −85. Rest gut (−26…−81). - ✅ **Eco-Mode-Policy (2026-07-13, PROVISORISCH):** eco senkt CPU-Takt + WiFi-TX-Leistung → **eco AN für RSSI ≥ −80** (Energie-Win, TX irrelevant), **eco AUS für < −80** (Uplink-Headroom): s4/−91, s7/−88, s17/−81, s18/−79…−82, s19/−86 → 17× ON / 5× OFF, live via `Sys.SetConfig device.eco_mode` (kein Reboot). s4 (Küchen-Sockel/Metall, −91, fix) bewusst eco-OFF = schwächster Link. ⏳ **2. Eval nötig nach TP3.3** (WLAN-Ausrichtung/Aufbau ändert RSSI → Grenzfälle neu bewerten; User 2026-07-13). - ✅ **HA-Referenz http vs https geklärt (2026-07-13):** HA speichert `configuration_url` für **alle 22 Shellys bereits `http://10.40.30.x:80`** (Integration korrekt + neue IPs übernommen). Das „https" ist **client-seitig** (Mixed-Content-Upgrade, wenn HA selbst über https/Tunnel geöffnet) — **kein HA-Fix möglich/nötig, Shelly-SSL bewusst NICHT** (CPU/Energie, IoT-Netz). ⚠️ Rand-Fund: 1 HA-Shelly-Device zeigt auf `10.95.10.206` (Fremd-Subnetz, nicht BF) → separat prüfen. -- ⏳ **NOCH OFFEN:** (1) einheitliches Geräte-PW → **zurückgestellt als [[#TP17.2]]** (Kosmetik, User 2026-07-13). (2) Nicht-Shelly-Mover + cudy-Teardown + VLAN1-Client-Retirement → **eigener aktiver TP19** (nächste Priorität, User 2026-07-13). (3) DNS/ip-plan Restfeinschliff (mit TP19). +- ⏳ **Rest (nicht mehr Migration, s. TP17.2/TP19):** (1) einheitliches Geräte-PW → **zurückgestellt als [[#TP17.2]]** (Kosmetik). (2) Nicht-Shelly-Mover + cudy-Teardown + VLAN1-Client-Retirement → läuft in **TP19**. (3) DNS/ip-plan Restfeinschliff (mit TP19). #### TP17.2 — einheitliches Geräte-Passwort (Shelly `SetAuth`) ▸ ⏸️ ZURÜCKGESTELLT (Kosmetik, User 2026-07-13) - **Was:** je Gerät `Shelly.SetAuth` (User `admin`, realm-individueller `ha1` = sha256("admin::"), einheitliches Klartext-PW) via `mcp/shelly/migrate.py auth --go`. `SHELLY_DEVICE_PW` liegt in `mcp/shelly/.env` (gesetzt, len 13). Runner + Digest-Auth-Fallback in `_rpc` fertig. @@ -573,9 +574,41 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". 4. **core `ether4`** prüfen/geraderücken — laut User evtl. sein NIC (Dock/Monitor), steht aber als tagged Trunk in der Bridge-VLAN-Tabelle. 5. **`ap1`/`ap2` Neuprovisionierung** (WLAN-Rebuild, eigene Session) — ap1 ist Werksreset; danach greifen die `.99.11`/`.99.12`-Registry-Einträge. -### TP22 — Device-Adoption + Golden-Config Compliance-Push (RouterOS-Fleet) ▸ 🔜 TODO (2026-08-08 aus TP21 ausgelagert) +### TP22 — Device-Adoption + Golden-Config Compliance-Push (RouterOS-Fleet) ▸ ⏳ **AKTUELL IN ARBEIT** (User-Entscheid 2026-08-13, nächstes Paket; aus TP21 ausgelagert 2026-08-08) - **Ziel:** die 4 Live-Geräte (crs326-core, crs326-branch, ap1, ap2) real unter **Nautobot Golden Config** nehmen — Backup → Compliance/Drift → intended → **Push (`enable_deploy`)**. Damit greift der Governance-Entscheid „Config exklusiv über Nautobot" operativ. Macht die aktuellen `routeros/.secrets/*.rsc`-Handoffs mittelfristig überflüssig. - **Stand aus TP21:** Plattform + Datenmodell + Git-Template + intended-config für core stehen; ap1/ap2 als reine APs zurückgeschrieben (`nautobot/ingest-ap-rebuild.py`, 2026-08-08). `enable_plan`/`enable_deploy` in `nautobot_config.py` noch **bewusst aus** (pull/read-only). +- ✅ **Schritt 0 DURCH (2026-08-13) — Backup-Scrubbing + Repo-Neuanlage.** Vorgezogen aus Schritt 4, weil ohne Scrubbing jedes weitere Backup Secrets geleakt hätte. + - 🔴 **Root-Cause-Fund:** Es gab **keine** ConfigReplace/ConfigRemove-Regeln → der Backup-Job vom 2026-07-22 hatte den ap1-`/export` ungefiltert nach Gitea geschrieben. `backups/ap1.cfg` enthielt im Klartext den **damals wie heute live genutzten WG-Hub-private-key** (bei der Core-Recovery unverändert auf `crs326-core` übernommen, damit FM/DW-Spokes matchen) **und 6 WLAN-PSKs**. + - ✅ **5 ConfigReplace-Regeln** (Platform RouterOS) via `nautobot/ingest-gc-scrubbing.py` (Dry-Run-Default, Regex-Selbsttest bei jedem Lauf): `10-wifi-passphrase`, `20-wireguard-private-key`, `30-preshared-key`, `40-password`, `50-secret` → Maske **`"<>"`**. Gegen den echten Export kalibriert: RouterOS quotet nur bei Bedarf (4 von 6 PSKs standen **unquotiert**), deshalb Alternation `("(?:[^"\\]|\\.)*"|\S+)` mit der **quotierten Variante zuerst** (`\S+` ist greedy und würde bei einem PSK mit Leerzeichen den Rest exponieren). Replace normalisiert immer auf die quotierte Form → Maske ist formunabhängig byte-identisch (Voraussetzung für das Template in Schritt 1). Negativtest: `public-key=` wird **nicht** maskiert (sonst bräche der WG-Peer-Block). + - 🐞 **Blocker nebenbei gefunden + gefixt: Nautobot hatte das ALTE Router-Passwort.** Der erste Backup-Lauf meldete Job-`SUCCESS`, schrieb aber nichts — `backup_last_attempt_date` sprang, `backup_last_success_date` nicht (**GC meldet Job-Erfolg auch bei Geräte-Fehlern; nur das Success-Datum je Gerät zählt**). Ursache: `NAUTOBOT_ROUTEROS_PASSWORD` im Stack war 12 Zeichen aus der Zeit vor der PW-Vereinheitlichung (2026-08-08, 32 Zeichen, identisch auf allen 4 MikroTik). Fix = Stack-Env nachgezogen + Stack 203 redeployed; Datei zusätzlich auf `chmod 600` (Backup `.bak-20260813`). + - ✅ **Gitea-Repo `admin/bf-golden-config` gelöscht + neu angelegt** (User-Entscheid: Repo neu, **nicht** rotieren — History-Rewrite verworfen, weil Gitea unerreichbare Blobs bis zum server-seitigen `gc` behält). Struktur + Template + README wiederhergestellt. **Vollscan aller Blobs der neuen History: 0 unmaskierte Secrets** (die 6 Regex-Treffer sind 5× README-Doku der Feldnamen + 1× der Template-Platzhalter). Nautobot-GitRepository re-synct, End-to-End-Backup ins frische Repo verifiziert (112 Z., 6/6 PSKs maskiert). + - 📌 **Risiko-Akzeptanz (User 2026-08-13): keine Rotation.** Faktenlage: Repo `private=true`, anonymer Zugriff 404. Aber Gitea hängt auf CF-`bypass` am Internet und `ha-xstate` ist ein **Service-Account mit Gitea-Admin** (kann jedes private Repo lesen) → Lesbarkeit für 2 Accounts, nicht fürs Internet. **Trigger für nachträgliche Rotation:** ändert sich die Gitea-Exposition oder der `ha-xstate`-Account → WG-Key rotieren (FM/DW-Klickanleitung `routeros/fritzbox-wg-lanlan-klickanleitung.md`). + - ⚠️ **DR-Konsequenz:** ein maskiertes Backup ist **kein Wiederherstellungs-Artefakt**. Restore hängt weiter an `~/.secrets/`, `routeros/.secrets/*` und `routeros/ap1-wg-hub.privkey` (alle gitignored). +- ✅ **Schritt 1 DURCH (2026-08-13) — AP-Zweig im Golden-Config-Template.** + - **Datenmodell = ConfigContext** (`nautobot/ingest-ap-configcontext.py`). Verworfen: Wireless-`RadioProfile` (haengt über `ControllerManagedDeviceGroup` an einem *Controller* — den gibt es seit der CAPsMAN-Abschaffung bewusst nicht mehr; ein Fake-Controller wäre Modellschuld), CustomFields (skalieren nicht), Ableitung aus VAP-Descriptions (SSID nur als Freitext → stille Brüche). Angelegt: ConfigContext **`ap-common`** (Scope Role *Wireless AP* + Platform RouterOS, weight 1000) mit `zones`/`radios`/Site-Konstanten + **`local_config_context_data`** je AP mit nur den 2 Kanalnummern. **Frequenz wird im Template gerechnet** (`5000+5*ch` / `2407+5*ch`) statt gespeichert. + - **Cross-Check im Skript:** die `zones`-Tabelle wird gegen die 11 real modellierten VAP-Interfaces (Name-Präfix + `untagged_vlan`) validiert, Divergenz = Abbruch → ConfigContext und DCIM können nicht auseinanderlaufen. + - **Template:** neuer Zweig `{% elif obj.role.name == "Wireless AP" %}` (rollen-, nicht namensbasiert). Scope bewusst **nur die compliance-verwalteten Sektionen** (channel/datapath/security/configuration/bridge-vlan/vlan99/ip-address/route) — **nicht** die ganze Config: defconf-Boilerplate (ipv4/ipv6-Firewall, `/disk`, `/tool mac-server`) und die VAP-Zeilen bleiben draussen, weil deren MACs gerätevergeben und in Nautobot nicht modelliert sind. Die früheren „Ausnahmen" fallen aus den Daten heraus: IOT hat `bands:["2g"]` → landet automatisch nur auf `wifi2`; VLAN1/VLAN99 stehen als literale Zeilen ausserhalb der Schleife (**VLAN99 auf `bridge`, NICHT auf die Radios** — sonst Mgmt over-the-air). + - 🔑 **Formatregel gelernt: gegen den echten `/export` schreiben, nicht gegen die `.rsc`.** RouterOS sortiert Attribute **alphabetisch** und schreibt `ft-over-ds` nur bei `ft=yes`; die handgesetzte `.rsc` hat abweichendes Padding/Reihenfolge. Referenz-Export: `routeros/state-backups/ap1-gc-backup-scrubbed-20260813.cfg`. **RouterOS exportiert CRLF**, Jinja rendert LF — GC normalisiert das korrekt (die schon vorher grüne `system-identity`-Compliance war der Beleg). + - ✅ **Abnahme:** ap1 **Intended == Geräte-Ist, 0 Drift** (33 Zeilen, zeilenweise identisch); ap2 unterscheidet sich in **exakt den 4 erwarteten Werten** (identity, 5180↔5745, 2412↔2462, `.99.11`↔`.99.12`). + - ✅ **Mitgenommen:** WG-Privkey-Platzhalter im core-Zweig auf dieselbe Maske `"<>"` vereinheitlicht (sonst Dauer-Diff, sobald eine Feature auf `/interface wireguard` liegt); **DNS-Statics korrigiert** (s. Nummernplan unten). `routeros/push-gc-template.py` gehärtet: **Pre-Push-Secret-Guard** (verweigert den PUT bei unmaskierten Werten), `--dry-run` mit Diff, `-m` statt hartkodierter Commit-Message, `sha` optional (sonst 404 nach Repo-Neuanlage). +- ✅ **Schritt 2 DURCH (2026-08-13) — Adoption + Backup/Compliance grün, 20/20.** + - SecretsGroup **„RouterOS Nornir"** an `crs326-core` + `crs326-branch` gehängt (ap1/ap2 hatten sie schon) — einheitliches Admin-PW seit 2026-08-08 macht eine Gruppe für alle 4 ausreichend. + - **Datenmodell-Korrekturen vorgezogen (technisch Voraussetzung):** `crs326-core` Status *Planned* → **Active** (ist der produktive L3-Router), `crs326-branch` `primary_ip4` `.99.13` → **`.99.2`** (live verifiziert). + - ✅ **Backup über alle 4 erfolgreich**, Sichtprüfung: **0 unmaskierte Secrets** (ap1/ap2 je 6 PSKs maskiert, core WG-Privkey maskiert, branch secret-frei). + - ✅ **4 neue ComplianceFeatures** (`wifi-channel`, `wifi-datapath`, `wifi-security`, `wifi-configuration`, alle `config_ordered=false`) + die bestehende `system-identity` → **20/20 compliant** über alle 4 Geräte. + - ⚠️ **Bewusst noch KEINE Features für `bridge-vlan`/`ip-address`/`ip-route`:** ComplianceRules gelten pro *Platform*, nicht pro Rolle — und der **`crs326-core`-Zweig ist in Slash-Form** geschrieben (`/ip/address add …`), was den Export (Space-Form, `/ip address add …`) **nicht** matcht → das gäbe permanenten Falsch-Drift auf dem Core. **Follow-up: core-Zweig auf Export-Form umstellen**, dann diese Features nachziehen. Die wifi-Features sind unkritisch, weil core/branch keine WLAN-Hardware haben (beide Seiten leer = compliant). +- ✅ **Follow-up DURCH (2026-08-13) — `crs326-core`-Zweig auf `/export`-Form.** Der Zweig war als *staged Cutover-Skript* geschrieben (Slash-Form `/ip/address`, plus `disabled=yes`-Flags aus der Vorbereitungszeit) und matchte den Export nicht → er hätte platform-weite ComplianceRules dauerhaft rot gefärbt. Jetzt Space-Form + alphabetische Attribute, `disabled=yes` entfernt (core ist seit 2026-08-08 produktiv), VLAN-Tabelle als Jinja-Konstante, GW-IPs weiter datengetrieben. **Ergebnis: 68 von 69 Zeilen byte-identisch zum Gerät.** Zusätzlich **Switch-Zweig** für `crs326-branch` (Mgmt-Layer) ergänzt — vorher fiel branch auf „nur `/system identity`" durch und meldete legitime Config als Drift. **9 weitere ComplianceFeatures** (interface-list/-vlan/-wireguard, ip-address/-pool/-dhcp-server/-dns/-firewall-nat/-route); bei `ip-dhcp-server` bewusst **drei spezifische Präfixe** statt `/ip dhcp-server`, sonst fingen die 54 dynamischen `lease`-Zeilen mit. +- ✅ **Schritt 3 DURCH (2026-08-13) — Deploy-Pipeline scharf, Pilot erfolgreich.** + - Image **`3.1.7-gc9`** auf der NAS gebaut (`/volume1/docker/nautobot-image`), Stack 203 retaggt. ⚠️ **Falle:** ein bereits vorhandenes `gc7` trug dieselben Flags; ein Build ohne geänderte Quelldatei zieht den **Docker-Cache** und liefert eine identische Image-ID — immer gegen die Flags im *fertigen* Image verifizieren. `scp` auf die NAS schlug fehl („Connection closed"), Übertragung per `ssh 'cat > datei'`. + - **Jobs sind in Nautobot zusätzlich einzeln freizuschalten** (`enabled=true`): die Plugin-Flags machen sie nur verfügbar. + - 🔴 **Kernbefund: `napalm-ros` ist ein READ-ONLY-Treiber.** Der Default-Dispatcher wählt NAPALM (weil `Platform.network_driver_mappings` `napalm: ros` enthält) → Backup (`get_config`) geht, **`merge_config` scheitert grundsätzlich**, mit leerer Fehlermeldung (`E1015 … ``). **Fix:** in den Golden-Config-Settings (nicht in den Nornir-Settings!) `custom_dispatcher: {"mikrotik_routeros": "nornir_nautobot.plugins.tasks.dispatcher.mikrotik_routeros.NetmikoMikrotikRouteros"}` — dieser Dispatcher pusht via `netmiko_send_config`. Danach lief der Deploy sofort. + - ⚠️ **Gescheiterter Deploy = sauberer Fehlschlag:** das Gerät blieb beim NAPALM-Versuch vollständig unverändert (kein Teilzustand). Zusätzlich meldet der Deploy-**Job** `SUCCESS`, obwohl der **Plan** auf `Failed` steht — wie beim Backup gilt: **nur der Objekt-Status zählt, nicht der Job-Status.** + - ✅ **Pilot-Deploy: die DNS-Statics auf dem Core.** Vorher `/system backup` (`core-pre-tp22-first-deploy-20260813.backup`). ⚠️ **`config_set` eines generierten Plans ist schreibgeschützt**, und `plan_type=missing` erzeugt **nur `add`** — das hätte Duplikate ergeben (`ap1.bf.lan` gleichzeitig auf `.99.3` und `.99.11`). Deshalb **manueller Plan mit Purge-vor-Add**. Ergebnis verifiziert: `ap1→.99.11`, `ap2→.99.12`, `branch→.99.2`, weiterhin 11 Einträge, keine Duplikate. **Compliance 51/56.** +- 📋 **Compliance-Backlog (5 verbleibende Drifts, alle ECHTE Altlasten — bewusst NICHT eigenmächtig entfernt, weil es potenzielle Rettungspfade sind):** ap1+ap2 `/ip dns static 192.168.88.1 router.lan` (Werks-defconf); `crs326-branch` `vlan10 "TEMP Mgmt VLAN10"` + `10.40.10.2` (Bring-up-Interim, CLAUDE.md-dokumentiert); `crs326-core` `10.40.41.211` (VLAN1-Bring-up-IP). Aufräumen gehört fachlich zu **TP19** (VLAN1-Retirement) und braucht eine bewusste Entscheidung. +- 🔒 **GOVERNANCE — verbindliche Deploy-Regeln (festgeschrieben 2026-08-13, VOR dem Scharfschalten von `enable_deploy`):** + 1. **Config Plans werden ausschliesslich feature-/manual-scoped erzeugt, NIEMALS als „full intended".** Ein Voll-Push schriebe wörtlich `passphrase="<>"` auf die APs (= alle WLAN-Clients raus, inkl. ~26 Shellys) bzw. `private-key="<>"` auf den Core (= WG-Hub tot, FM/DW-Spokes weg). **Jede Sektion, die `<>` enthält (`/interface wifi security`, `/interface wireguard`), ist vom Push generell ausgeschlossen.** + 2. **Das AP-Template wird NIE zeilenweise auf ein laufendes Gerät gepusht.** Die Provisionierung setzt zwischenzeitlich `vlan-filtering=no` → das reisst den vlan99-Mgmt-Pfad ab, die restlichen Zeilen kämen über dieselbe SSH-Session nie an (halb konfigurierter AP). Voll-Reprovisionierung ausschliesslich per Datei-Upload + `/import` (`routeros/.secrets/ap{1,2}-prov-base.rsc`). Verschärfend: `device-mode=home` blockt auf den hAPs `scheduler`/`fetch` → die übliche Auto-Revert-Absicherung steht dort **nicht** zur Verfügung; der VLAN1-untagged-Rettungspfad darf nie wegoptimiert werden. + 3. **Vor jedem Push ein `/system backup` auf dem Zielgerät.** Das GC-Backup ist wegen des Scrubbings **kein** vollwertiges Restore-Artefakt. - **Schritte:** (1) **AP-GC-Template**: `mikrotik_routeros.j2` um `role == "Wireless AP"`-Zweig erweitern (Bridge/vlan-filtering/ether1-Trunk/vlan99-Mgmt/6 SSIDs **ohne** PSK/L3) → APs komplett aus GC generierbar; (2) **Nornir/NAPALM-Adoption** aller 4 (SecretsGroup „RouterOS Nornir"; **einheitliches Admin-PW seit 2026-08-08 vereinfacht das**), Backup/Compliance-Job grün; (3) **`enable_plan`+`enable_deploy`** scharf — erst Dry-Run/Plan, dann kontrollierter Push; (4) **Secret-Handling** WG-Privkey (core) + WLAN-PSKs (bleiben aus dem Git-Template, via Nornir-Secrets injizieren); (5) Datenmodell-Nachträge: branch/core-Ports final, sw1 (außer Betrieb) + cudy1-Status. - **Nutzen:** Drift-Erkennung + reproduzierbarer Rebuild jedes Geräts aus dem Modell. - **Abh.:** TP21 ✅ (Plattform/Modell/Templates), TP20 ✅, einheitliches Admin-PW ✅. **Danach ist der kritische Pfad frei für TP5 (Deny-by-default) → TP6 (CAKE).** @@ -591,7 +624,7 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". --- ## Reihenfolge / Abhängigkeiten -> 🎯 **AKTUELLER KRITISCHER PFAD (Stand 2026-07-16, überschreibt die Reihenfolge unten): TP20 (Platform-Postgres) → TP21 (Nautobot) — Ziel: fertig vor CRS326-Produktivsetzung, spätestens Montag 2026-07-20.** TP18 (Garage-S3) und die Komodo-Abhängigkeit wurden am selben Tag als Blocker entfernt/entkoppelt (s. TP20), um diese Deadline zu halten — beide werden später nachgezogen, nicht mehr vorher abgewartet. **➡️ Stand-Update 2026-08-08:** TP20/TP21-Plattform steht, Core-Cutover (TP21.1) + WLAN-Rebuild (TP3) + VLAN20-Migration (TP14) sind durch. Neuer Rest-Pfad: **TP22 (Device-Adoption + Compliance-Push) → TP5 (Deny-by-default) → TP6 (CAKE, das Ziel).** Kleine Follow-ups davor: DoH-Cloudflare auf core, USV (TP15). Grund für den Nautobot-First-Ansatz: Governance-Entscheid „Config exklusiv über Nautobot" (s. oben) macht Nautobot zur Voraussetzung für praktisch jede weitere Router-/Switch-Änderung. Die Punkte 3-4 unten (Omada-PVID-Fix etc.) sind **obsolet** (User-Entscheid 2026-07-22 — Omada spielt seit TP15-Korrektur keine Rolle mehr, nie wieder aufgreifen) — ganze Liste braucht mal eine Aufräum-Session, hier nur der aktuelle Fokus vorangestellt. +> 🎯 **AKTUELLER KRITISCHER PFAD (Stand 2026-07-16, überschreibt die Reihenfolge unten): TP20 (Platform-Postgres) → TP21 (Nautobot) — Ziel: fertig vor CRS326-Produktivsetzung, spätestens Montag 2026-07-20.** TP18 (Garage-S3) und die Komodo-Abhängigkeit wurden am selben Tag als Blocker entfernt/entkoppelt (s. TP20), um diese Deadline zu halten — beide werden später nachgezogen, nicht mehr vorher abgewartet. **➡️ Stand-Update 2026-08-08:** TP20/TP21-Plattform steht, Core-Cutover (TP21.1) + WLAN-Rebuild (TP3) + VLAN20-Migration (TP14) sind durch. Neuer Rest-Pfad: **TP22 (Device-Adoption + Compliance-Push) → TP5 (Deny-by-default) → TP6 (CAKE, das Ziel).** Kleine Follow-ups davor: DoH-Cloudflare auf core, USV (TP15). **➡️ Stand-Update 2026-08-13 (User-Entscheid): TP22 ist jetzt aktiv das nächste Arbeitspaket** — TP17 (Shelly-Migration) ist parallel dazu vollständig abgeschlossen (alle Shellys auf VLAN30, User-bestätigt). Grund für den Nautobot-First-Ansatz: Governance-Entscheid „Config exklusiv über Nautobot" (s. oben) macht Nautobot zur Voraussetzung für praktisch jede weitere Router-/Switch-Änderung. Die Punkte 3-4 unten (Omada-PVID-Fix etc.) sind **obsolet** (User-Entscheid 2026-07-22 — Omada spielt seit TP15-Korrektur keine Rolle mehr, nie wieder aufgreifen) — ganze Liste braucht mal eine Aufräum-Session, hier nur der aktuelle Fokus vorangestellt. > ✅ **TP16 (CF-Tunnel Round-Robin/macvlan-Host-Isolation → reziprokes 3-Tunnel-HA)** KERN UMGESETZT 2026-07-10 (#1033 strukturell weg). Offen nur Access-Strang (WARP/`vw`) = user-gated. As-Built `docs/cf-tunnel-mapping.md §10`. 1. ~~**TP1 (GE8) → TP2 (Omada Controller).**~~ ✅ erledigt 2026-06-15. 2. ~~**TP4 (Router-Cutover L3-Infra + WAN-Edge).**~~ ✅ erledigt 2026-06-15 (L3) + 2026-07-01 (Edge-Cutover, FB physisch weg). diff --git a/nautobot/ingest-ap-configcontext.py b/nautobot/ingest-ap-configcontext.py new file mode 100644 index 0000000..a83d153 --- /dev/null +++ b/nautobot/ingest-ap-configcontext.py @@ -0,0 +1,203 @@ +#!/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 + "mgmt": {"vlan": 99, "gateway": "10.40.99.1"}, + "native_vlan": 1, + # Liste (nicht Dict): Reihenfolge ist garantiert und diffbar. + "radios": [ + {"key": "5g", "iface": "wifi1", "band": "5ghz-ax", "width": "20/40/80mhz", "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 = { + "ap1": {"5g": 36, "2g": 1}, + "ap2": {"5g": 149, "2g": 11}, +} + +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 + want = {"ap": {"channels": ch}} + if d.get("local_config_context_data") == want: + print(f" = {dev} 5g=ch{ch['5g']} 2g=ch{ch['2g']} (unveraendert)") + elif DRY: + print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']} (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']}") + + if DRY: + print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.") + + +if __name__ == "__main__": + main() diff --git a/nautobot/ingest-gc-scrubbing.py b/nautobot/ingest-gc-scrubbing.py new file mode 100644 index 0000000..0200e53 --- /dev/null +++ b/nautobot/ingest-gc-scrubbing.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +r"""BF-Netzwerk -> Nautobot Golden Config: Backup-Scrubbing (TP22 Schritt 0, 2026-08-13). + +HINTERGRUND / WARUM DAS EXISTIERT +--------------------------------- +Der GC-Backup-Job schreibt den rohen RouterOS-`/export` ins Gitea-Repo `bf-golden-config`. +Ohne ConfigReplace-Regeln landen dort Secrets im KLARTEXT. Genau das ist am 2026-07-22 +passiert: `backups/ap1.cfg` enthielt den (bis heute live genutzten) WG-Hub-private-key und +6 WLAN-PSKs. Diese Regeln maskieren solche Werte, BEVOR sie das Repo erreichen. + + -> MUSS scharf sein, bevor der Backup-Job fuer crs326-core/-branch/ap2 laeuft, + sonst leakt der Core-Backup denselben WG-Key ein zweites Mal. + +REGEX-DESIGN (gegen echten ap1-Export kalibriert, 2026-08-13) +------------------------------------------------------------- +RouterOS quotet Werte nur, wenn noetig -> im ap1-Export standen 4 von 6 `passphrase=` +UNQUOTIERT und 2 quotiert (mit `\\$`-Escapes). Beide Formen muessen fallen, deshalb: + + (feld=)("(?:[^"\\]|\\.)*"|\S+) + +Die quotierte Alternative steht ZWINGEND VORNE: `\\S+` ist greedy und wuerde bei einem +PSK mit Leerzeichen (`passphrase="my psk"`) nur `"my` fassen und den Rest exponieren. +Python-`re` bevorzugt die linke Alternative -> quotiert zuerst. + +Der Replace normalisiert IMMER auf die quotierte Form, damit die Maske unabhaengig von +der Ursprungsform byte-identisch ist. Das ist Voraussetzung dafuer, dass das +Jinja-Template denselben String emittieren kann (sonst Dauer-Diff in der Compliance). + + export NAUTOBOT_TOKEN=... + python3 nautobot/ingest-gc-scrubbing.py # Dry-Run + python3 nautobot/ingest-gc-scrubbing.py --apply # schreiben +""" +import json, os, re, 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 + +# Der Masken-String. Muss identisch im Jinja-Template stehen (TP22 Schritt 1). +MASK = '"<>"' + +# Wert-Matcher: quotiert (mit Escape-Support) ODER unquotiert. Quotiert MUSS zuerst. +VAL = r'("(?:[^"\\]|\\.)*"|\S+)' + +# Platform-Name, auf den die Regeln gelten. +PLATFORM = "RouterOS" + +# (name, feld-regex, beschreibung) +# Namen numerisch praefixiert: GC ordnet nach (platform, name), die Reihenfolge ist +# damit deterministisch und nachvollziehbar. +RULES = [ + ( + "10-wifi-passphrase", + r"(passphrase=)" + VAL, + "WLAN-PSKs (/interface wifi security). Im ap1-Export 6x, davon 4 unquotiert.", + ), + ( + "20-wireguard-private-key", + r"(private-key=)" + VAL, + "WireGuard-private-key (wg-hub auf crs326-core). Der kritischste Wert im Fleet.", + ), + ( + "30-preshared-key", + r"((?:wpa2?-)?pre-?shared-key=)" + VAL, + "WG-preshared-key / wpa-pre-shared-key. Defensiv - im ap1-Export nicht vorhanden.", + ), + ( + "40-password", + r"(password=)" + VAL, + "Generisch password= (faengt auch authentication-/encryption-password=). Defensiv.", + ), + ( + "50-secret", + r"(\bsecret=)" + VAL, + "RADIUS-/IPsec-/PPP-secret=. \\b verhindert Treffer auf secret-artige Praefixe. Defensiv.", + ), +] + +# Selbsttest-Faelle: (eingabe, erwartete ausgabe) - laufen bei JEDEM Aufruf, auch Dry-Run. +SELFTEST = [ + ("passphrase=jgp4ap3cwcnz", 'passphrase="<>"'), + (r'passphrase="74c9L283\$669"', 'passphrase="<>"'), + ('passphrase="psk mit space"', 'passphrase="<>"'), + ( + "add name=sec-main ft=yes passphrase=abc123 disabled=no", + 'add name=sec-main ft=yes passphrase="<>" disabled=no', + ), + ('private-key="9AFG+aGpk3/gnzka46oVvFfcU="', 'private-key="<>"'), + # Public-Keys duerfen NICHT maskiert werden (sie stehen legitim im Template): + ('public-key="dzRY0lnnUygijChWe5wXZmXPeNcbdALpqBptA3fyiVE="', None), + # Nicht-Secrets duerfen nicht angefasst werden: + ("name=dp-main vlan-id=10", None), +] + + +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 selftest(): + """Beweist die Regexe lokal, bevor irgendetwas nach Nautobot geht.""" + fails = [] + for src, want in SELFTEST: + got = src + for _, rx, _ in RULES: + got = re.sub(rx, r"\1" + MASK, got) + expected = src if want is None else want + status = "ok " if got == expected else "FAIL" + if got != expected: + fails.append((src, expected, got)) + # Werte nie ausgeben - nur ob die Maske sitzt. + shown = re.sub(r"=(\"[^\"]*\"|\S+)", "=…", src) + print(f" [{status}] {shown}") + return fails + + +def main(): + print("== Regex-Selbsttest ==") + fails = selftest() + if fails: + print("\n!! Selbsttest fehlgeschlagen - NICHTS geschrieben:") + for src, exp, got in fails: + print(f" input={src!r}\n want ={exp!r}\n got ={got!r}") + sys.exit(1) + print(" alle Faelle ok\n") + + if not TOKEN: + print("!! NAUTOBOT_TOKEN nicht gesetzt") + sys.exit(1) + + plat = find("/dcim/platforms", name=PLATFORM) + if not plat: + print(f"!! Platform {PLATFORM!r} nicht gefunden") + sys.exit(1) + print(f"== ConfigReplace-Regeln (Platform {PLATFORM}) ==") + + for name, rx, desc in RULES: + payload = { + "name": name, + "platform": plat["id"], + "regex": rx, + "replace": r"\1" + MASK, + "description": desc, + } + obj = find("/plugins/golden-config/config-replace", name=name) + if obj: + delta = {k: v for k, v in payload.items() if k in ("regex", "replace", "description") and obj.get(k) != v} + if not delta: + print(f" = {name}") + elif DRY: + print(f" ~ {name} {list(delta)} (dry-run)") + else: + req("PATCH", f"/plugins/golden-config/config-replace/{obj['id']}/", delta) + print(f" ~ {name} {list(delta)}") + elif DRY: + print(f" + {name} (dry-run)") + else: + req("POST", "/plugins/golden-config/config-replace/", payload) + print(f" + {name}") + + print("\nMaske: " + MASK + " <- muss identisch im Jinja-Template stehen (TP22 Schritt 1)") + if DRY: + print("DRY-RUN - nichts geschrieben. Mit --apply ausfuehren.") + + +if __name__ == "__main__": + main() diff --git a/nautobot/mikrotik_routeros.j2 b/nautobot/mikrotik_routeros.j2 index 2fb2ca7..6ccac6f 100644 --- a/nautobot/mikrotik_routeros.j2 +++ b/nautobot/mikrotik_routeros.j2 @@ -1,126 +1,189 @@ # Nautobot Golden Config -- intended RouterOS config for {{ obj.name }} -# Generated from Nautobot device data + hardcoded TP15 L3 staged values. -# Scope: identity for all MikroTik devices; full staged L3 cutover config for crs326-core. +# +# FORMAT-REGEL (TP22, 2026-08-13): Zeilen sind im RouterOS-**/export-Format** geschrieben +# (Space-Form `/ip address add ...`, Attribute ALPHABETISCH), nicht in der Slash-Form der +# Provisionierungs-Skripte. Golden Config vergleicht Backup (= echter /export) und Intended +# textuell -- jede Abweichung in Reihenfolge, Quoting oder Pfadform ist ein Falsch-Drift. +# Referenz-Exporte: routeros/state-backups/*-gc-backup-scrubbed-*.cfg +# +# Secrets tragen die Maske "<>" der ConfigReplace-Backup-Regeln. +# 🔴 Zeilen mit <> duerfen NIE per Deploy gepusht werden. /system identity set name={{ obj.name }} {% if obj.name == "crs326-core" %} +{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#} +{%- set vlans = [ + {"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"}, + {"id": 20, "name": "INFRA", "pool": "10.40.20.100-10.40.20.200", "lease": "1d"}, + {"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"}, + {"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"}, + {"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"}, + {"id": 90, "name": "GUEST", "pool": "10.40.90.100-10.40.90.200", "lease": "12h"}, + {"id": 99, "name": "MGMT", "pool": "10.40.99.100-10.40.99.200", "lease": "1d"} +] %} # ===================================================================================== -# TP15 — crs326-core L3 staged config (ap1 -> core). NICHT automatisch aktivieren. -# Alles Betriebsumlenkende wird disabled=yes angelegt; Cutover aktiviert in einem Rutsch. +# crs326-core — PRODUKTIVER L3-Router (seit Cutover 2026-08-08, TP21.1). +# Die frueheren `disabled=yes`-Staging-Flags sind ENTFERNT: sie stammten aus der +# Vorbereitung des Cutovers und beschreiben nicht mehr den Sollzustand. # ===================================================================================== # [1] INTERFACE-LISTS -/interface/list add name=WAN comment="TP15" -/interface/list add name=LAN comment="TP15" -/interface/list/member add list=WAN interface=vlan100 -/interface/list/member add list=LAN interface=vlan10 -/interface/list/member add list=LAN interface=vlan20 -/interface/list/member add list=LAN interface=vlan30 -/interface/list/member add list=LAN interface=vlan40 -/interface/list/member add list=LAN interface=vlan50 -/interface/list/member add list=LAN interface=vlan90 -/interface/list/member add list=LAN interface=vlan99 +/interface list add comment=TP15 name=WAN +/interface list add comment=TP15 name=LAN +/interface list member add interface=vlan100 list=WAN +{% for v in vlans %} +/interface list member add interface=vlan{{ v.id }} list=LAN +{% endfor %} -# [2] GW-IPs je VLAN — STAGED (disabled=yes) -/ip/address +# [2] VLAN-SVIs auf der Bridge (vlan99 traegt bewusst keinen comment) +{% for v in vlans %} +/interface vlan add {% if v.id != 99 %}comment={{ v.name }} {% endif %}interface=bridge name=vlan{{ v.id }} vlan-id={{ v.id }} +{% endfor %} +/interface vlan add comment=WAN interface=bridge name=vlan100 vlan-id=100 + +# [3] GW-IPs je VLAN — datengetrieben aus Nautobot (IP am SVI-Interface gebunden). +# comment = Interface-Description; network wird aus der Host-IP abgeleitet. {% for iface in obj.interfaces.all() %} -{% if iface.name.startswith("vlan") %} -{% for ip in iface.ip_addresses.all() %} -{% if ip.host == "10.40." + iface.name|replace("vlan", "") + ".1" %} -add address={{ ip.address }} interface={{ iface.name }} comment="{{ iface.description }}" disabled=yes +{% if iface.name.startswith("vlan") %} +{% for ip in iface.ip_addresses.all() %} +/ip address add address={{ ip.address }} comment="{{ iface.description }}" interface={{ iface.name }} network={{ ip.host.split(".")[:3] | join(".") }}.0 +{% endfor %} +{% endif %} +{% endfor %} + +# [4] WAN — DHCP-Client auf vlan100 (Liwest/Arris) +/ip dhcp-client add comment="WAN Arris DHCP (Liwest) via vlan100" interface=vlan100 use-peer-dns=no use-peer-ntp=no + +# [5] DHCP-Server je VLAN +{% for v in vlans %} +/ip pool add name=pool-v{{ v.id }} ranges={{ v.pool }} +{% endfor %} +{% for v in vlans %} +/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }} +{% endfor %} +{% for v in vlans %} +/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.{{ v.id }}.1 domain=bf.lan gateway=10.40.{{ v.id }}.1 +{% endfor %} +/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101 + +# [6] DNS — Upstream aktuell PLAIN 1.1.1.1. DoH-Cloudflare-Gateway ist offener TODO +# (core hat kein CA-Bundle -> verify-doh-cert nicht moeglich), s. TP21.1. +/ip dns set allow-remote-requests=yes mdns-repeat-ifaces=vlan10,vlan20,vlan40,vlan30,vlan90 servers=1.1.1.1 + +# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich): +# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12 +/ip dns static add address=10.40.99.1 comment="core = GW/Router" name=router.lan type=A +/ip dns static add address=10.40.99.1 comment="crs326-core (L3)" name=core.bf.lan type=A +/ip dns static add address=10.40.99.2 comment=crs326-branch name=branch.bf.lan type=A +/ip dns static add address=10.40.99.11 comment="ap1 = reiner AP" name=ap1.bf.lan type=A +/ip dns static add address=10.40.99.12 comment="ap2 = reiner AP" name=ap2.bf.lan type=A +/ip dns static add address=10.40.20.167 name=macmini.bf.lan type=A +/ip dns static add address=10.40.20.100 name=pve.bf.lan type=A +/ip dns static add address=10.40.20.173 name=pve-mcp.bf.lan type=A +/ip dns static add address=10.40.20.10 name=nas.bf.lan type=A +/ip dns static add address=10.40.20.126 name=docker.bf.lan type=A +/ip dns static add address=10.40.20.20 name=platform-pg.bf.lan type=A + +# [7] NAT +/ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16 +/ip firewall nat add action=masquerade chain=srcnat comment="defconf: masquerade" ipsec-policy=out,none out-interface-list=WAN + +# [8] ROUTEN zu den WG-Spokes (Default-Route kommt dynamisch vom WAN-DHCP-Client) +/ip route add comment="TP10: FM spoke -> wg-hub" dst-address=10.101.0.0/24 gateway=wg-hub +/ip route add comment="TP10: DW spoke -> wg-hub" dst-address=10.95.10.0/24 gateway=wg-hub + +# [9] WG-HUB (TP10 Multi-Site). Der echte Privkey liegt in routeros/ap1-wg-hub.privkey. +/interface wireguard add comment="TP10 Multi-Site VPN Hub (FM/DW spokes)" listen-port=51821 mtu=1412 name=wg-hub private-key="<>" +/interface wireguard peers add allowed-address=10.101.0.0/24 comment=fm endpoint-address=abxhutfgsid9snmy.myfritz.net endpoint-port=52476 interface=wg-hub name=fm public-key="dzRY0lnnUygijChWe5wXZmXPeNcbdALpqBptA3fyiVE=" +/interface wireguard peers add allowed-address=10.95.10.0/24 comment=dw interface=wg-hub name=dw public-key="FqYZLztQ/qME5VDie5QLGH++5299WJiE1dPpQnPbzUw=" +{% elif obj.role.name == "Switch" %} +# ===================================================================================== +# L2-Switch (crs326-branch) — Sollzustand des Mgmt-Layers. +# Bewusst NICHT emittiert: die TEMP-Bring-up-Objekte (vlan10 "TEMP Mgmt VLAN10" + +# 10.40.10.2). Die tauchen dadurch als Compliance-`extra` auf — genau richtig, denn sie +# sind Altlast aus dem Bring-up und gehoeren entfernt (VLAN1/VLAN10-Interim, TP19). +# ===================================================================================== +/interface vlan add comment=MGMT interface=bridge name=vlan99 vlan-id=99 +/ip address add address={{ obj.primary_ip4.address }} comment=TP21-mgmt-target interface=vlan99 network={{ obj.primary_ip4.host.split(".")[:3] | join(".") }}.0 +/ip dns set servers=10.40.99.1 +/ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1 +{% elif obj.role.name == "Wireless AP" %} +{% set ap = obj.get_config_context()["ap"] %} +{% set mgmt_host = obj.primary_ip4.host %} +{% set uplink = ap.uplink %} +# ===================================================================================== +# TP22 — Standalone-AP (kein CAPsMAN). Gerendert aus ConfigContext "ap-common" +# + Device.local_config_context_data (Kanaele) + DCIM (Name, primary_ip4). +# +# SCOPE: nur die COMPLIANCE-VERWALTETEN Sektionen, bewusst NICHT die ganze Config. +# Golden Config vergleicht per ComplianceRule.match_config sektionsweise -> defconf- +# Boilerplate (ipv4/ipv6-Firewall, /disk, /tool mac-server) und die VAP-Zeilen +# (/interface wifi add ... mac-address=...) bleiben draussen: deren MACs sind +# geraetevergeben und in Nautobot nicht modelliert. +# +# 🔴 NIE als Ganzes auf ein laufendes Geraet pushen: die Provisionierung setzt +# zwischenzeitlich vlan-filtering=no, was den vlan99-Mgmt-Pfad abreisst -> der Rest +# der Zeilen kaeme ueber dieselbe SSH-Session nie an. Voll-Reprovisionierung +# ausschliesslich per Datei-Upload + /import (siehe routeros/.secrets/ap*-prov-base.rsc). +# +# Attributreihenfolge = alphabetisch, exakt wie RouterOS /export sie schreibt. +# Referenz: routeros/state-backups/ap1-gc-backup-scrubbed-20260813.cfg +# ===================================================================================== + +# [1] CHANNELS — Frequenz aus der Kanalnummer gerechnet (eine Quelle der Wahrheit) +{% for r in ap.radios %} +{% set ch = ap.channels[r.key] %} +{% set freq = (5000 + 5 * ch) if r.key == "5g" else (2407 + 5 * ch) %} +/interface wifi channel add band={{ r.band }} frequency={{ freq }} name=ch-{{ r.key }} width={{ r.width }} +{% endfor %} + +# [2] DATAPATH — je Zone ein VLAN-Tag. OHNE datapath egressiert die SSID ungetaggt +# in VLAN1 (das war das TP3.1-Leck) -> jede Zone MUSS hier auftauchen. +{% for z in ap.zones %} +/interface wifi datapath add bridge={{ ap.bridge }} name=dp-{{ z.name }} vlan-id={{ z.vlan }} +{% endfor %} + +# [3] SECURITY — Passphrasen tragen die Backup-Maske aus TP22 Schritt 0. +# ft-over-ds schreibt RouterOS nur, wenn ft=yes. +{% for z in ap.zones %} +/interface wifi security add authentication-types={{ z.auth }} ft={{ z.ft }}{% if z.ft == "yes" %} ft-over-ds={{ z.ft_over_ds }}{% endif %} name=sec-{{ z.name }} passphrase="<>" +{% endfor %} + +# [4] CONFIGURATION — main sitzt auf den Master-Radios und bringt den Channel mit +# (je Band eine configuration); alle anderen Zonen sind Slave-VAPs ohne channel=. +{% for z in ap.zones %} +{% if z.master %} +{% for r in ap.radios %} +{% if r.key in z.bands %} +/interface wifi configuration add channel=ch-{{ r.key }} country={{ ap.country }} datapath=dp-{{ z.name }} mode=ap name=cfg-{{ z.name }}-{{ r.key }} security=sec-{{ z.name }} ssid={{ z.ssid }} {% endif %} {% endfor %} +{% else %} +/interface wifi configuration add country={{ ap.country }} datapath=dp-{{ z.name }} mode=ap name=cfg-{{ z.name }} security=sec-{{ z.name }} ssid={{ z.ssid }} {% endif %} {% endfor %} -# [3] WAN DHCP-Client (Liwest/Arris) — STAGED -/ip/dhcp-client add interface=vlan100 use-peer-dns=no add-default-route=yes \ - comment="TP15 WAN Liwest (Arris)" disabled=yes +# [5] BRIDGE-VLAN — Nur Master-Radios sind Bridge-Ports (Slave-VAPs forwarden darueber), +# deshalb muessen wifi1/wifi2 tagged-Member je Client-VLAN sein. Die frueheren +# "Ausnahmen" fallen hier aus den Daten heraus: IOT hat bands=["2g"] -> nur wifi2. +/interface bridge vlan add bridge={{ ap.bridge }} untagged={{ ap.bridge }},{{ uplink }} vlan-ids={{ ap.native_vlan }} +{% for z in ap.zones %} +{% set members = [uplink] + (ap.radios | selectattr("key", "in", z.bands) | map(attribute="iface") | list) %} +/interface bridge vlan add bridge={{ ap.bridge }} tagged={{ members | join(",") }} vlan-ids={{ z.vlan }} +{% endfor %} +# MGMT liegt auf der Bridge, NICHT auf den Radios - sonst waere Management over-the-air. +/interface bridge vlan add bridge={{ ap.bridge }} tagged={{ uplink }},{{ ap.bridge }} vlan-ids={{ ap.mgmt.vlan }} -# [4] DHCP-Server je VLAN -/ip/pool add name=pool-v10 ranges=10.40.10.100-10.40.10.200 -/ip/pool add name=pool-v20 ranges=10.40.20.100-10.40.20.200 -/ip/pool add name=pool-v30 ranges=10.40.30.100-10.40.30.250 -/ip/pool add name=pool-v40 ranges=10.40.40.100-10.40.40.200 -/ip/pool add name=pool-v50 ranges=10.40.50.100-10.40.50.200 -/ip/pool add name=pool-v90 ranges=10.40.90.100-10.40.90.200 -/ip/pool add name=pool-v99 ranges=10.40.99.100-10.40.99.200 - -/ip/dhcp-server/network add address=10.40.10.0/24 gateway=10.40.10.1 dns-server=10.40.10.1 domain=bf.lan -/ip/dhcp-server/network add address=10.40.20.0/24 gateway=10.40.20.1 dns-server=10.40.20.1 domain=bf.lan -/ip/dhcp-server/network add address=10.40.30.0/24 gateway=10.40.30.1 dns-server=10.40.30.1 domain=bf.lan -/ip/dhcp-server/network add address=10.40.40.0/24 gateway=10.40.40.1 dns-server=10.40.40.1 domain=bf.lan -/ip/dhcp-server/network add address=10.40.50.0/24 gateway=10.40.50.1 dns-server=10.40.50.1 domain=bf.lan -/ip/dhcp-server/network add address=10.40.90.0/24 gateway=10.40.90.1 dns-server=10.40.90.1 domain=bf.lan -/ip/dhcp-server/network add address=10.40.99.0/24 gateway=10.40.99.1 dns-server=10.40.99.1 domain=bf.lan - -/ip/dhcp-server add name=dhcp-v10 interface=vlan10 address-pool=pool-v10 lease-time=1d add-dns-entries-suffix=lan comment=MAIN disabled=yes -/ip/dhcp-server add name=dhcp-v20 interface=vlan20 address-pool=pool-v20 lease-time=1d add-dns-entries-suffix=lan comment=INFRA disabled=yes -/ip/dhcp-server add name=dhcp-v30 interface=vlan30 address-pool=pool-v30 lease-time=1d add-dns-entries-suffix=lan comment=IOT disabled=yes -/ip/dhcp-server add name=dhcp-v40 interface=vlan40 address-pool=pool-v40 lease-time=1d add-dns-entries-suffix=lan comment=MEDIA disabled=yes -/ip/dhcp-server add name=dhcp-v50 interface=vlan50 address-pool=pool-v50 lease-time=1d add-dns-entries-suffix=lan comment=UNTRUSTED disabled=yes -/ip/dhcp-server add name=dhcp-v90 interface=vlan90 address-pool=pool-v90 lease-time=12h add-dns-entries-suffix=lan comment=GUEST disabled=yes -/ip/dhcp-server add name=dhcp-v99 interface=vlan99 address-pool=pool-v99 lease-time=1d add-dns-entries-suffix=lan comment=MGMT disabled=yes - -# [5] DNS -/ip/dns set allow-remote-requests=yes servers=1.1.1.1 \ - use-doh-server=https://px35ynlol3.cloudflare-gateway.com/dns-query verify-doh-cert=yes \ - mdns-repeat-ifaces=vlan10,vlan20,vlan40,vlan30,vlan90 - -/ip/dns/static add name=router.lan address=10.40.99.1 comment="core = GW/Router" -/ip/dns/static add name=ap1.bf.lan address=10.40.99.3 comment="ap1 = reiner AP" -/ip/dns/static add name=ap2.bf.lan address=10.40.99.2 comment="ap2 = reiner AP" -/ip/dns/static add name=core.bf.lan address=10.40.99.1 comment="crs326-core (L3)" -/ip/dns/static add name=branch.bf.lan address=10.40.99.12 comment="crs326-branch (nach Renumber .13->.12)" -/ip/dns/static add name=macmini.bf.lan address=10.40.20.167 -/ip/dns/static add name=pve.bf.lan address=10.40.20.100 -/ip/dns/static add name=pve-mcp.bf.lan address=10.40.20.173 -/ip/dns/static add name=nas.bf.lan address=10.40.20.10 -/ip/dns/static add name=docker.bf.lan address=10.40.20.126 -/ip/dns/static add name=platform-pg.bf.lan address=10.40.20.20 - -# [6] NAT -/ip/firewall/nat add chain=srcnat dst-address=10.40.0.0/16 action=accept \ - comment="TP4: no-NAT inter-VLAN (vor masquerade)" -/ip/firewall/nat add chain=srcnat out-interface-list=WAN action=masquerade \ - comment="defconf: masquerade WAN" - -# [7] FIREWALL FILTER -/ip/firewall/filter add chain=forward action=accept connection-state=established,related,untracked comment="defconf: accept est,rel,untracked" -/ip/firewall/filter add chain=forward action=fasttrack-connection connection-state=established,related comment="defconf: fasttrack" -/ip/firewall/filter add chain=forward action=drop connection-state=invalid comment="defconf: drop invalid" -/ip/firewall/filter add chain=forward action=drop connection-nat-state=!dstnat in-interface-list=WAN comment="defconf: drop WAN !dstnat" -/ip/firewall/filter add chain=forward action=accept in-interface=wg-hub comment="TP10: allow from WG spokes (FM/DW)" -/ip/firewall/filter add chain=forward action=accept out-interface=wg-hub comment="TP10: allow to WG spokes (FM/DW)" -/ip/firewall/filter add chain=forward action=accept in-interface=vlan10 out-interface=vlan40 comment="TP5: MAIN->MEDIA (AirPlay/Sonos)" -/ip/firewall/filter add chain=forward action=accept in-interface=vlan40 out-interface=vlan10 comment="TP5: MEDIA->MAIN" -/ip/firewall/filter add chain=forward action=accept in-interface=vlan20 out-interface=vlan40 comment="TP5: INFRA->MEDIA (HA<->Sonos)" -/ip/firewall/filter add chain=forward action=accept in-interface=vlan40 out-interface=vlan20 comment="TP5: MEDIA->INFRA" -/ip/firewall/filter add chain=forward action=accept in-interface=vlan90 out-interface=vlan40 comment="TP5: GUEST->MEDIA (Sonos/Cast)" -/ip/firewall/filter add chain=forward action=accept in-interface=vlan40 out-interface=vlan90 comment="TP5: MEDIA->GUEST" - -/ip/firewall/filter add chain=input action=accept connection-state=established,related,untracked comment="TP10-seal-est" -/ip/firewall/filter add chain=input action=drop connection-state=invalid comment="TP10-seal-inv" -/ip/firewall/filter add chain=input action=accept protocol=udp dst-port=51821 comment="TP10: WG hub listen" -/ip/firewall/filter add chain=input action=accept protocol=udp dst-port=68 in-interface-list=WAN comment="TP10-seal-dhcp" -/ip/firewall/filter add chain=input action=accept protocol=icmp comment="TP10-seal-icmp" -/ip/firewall/filter add chain=input action=accept in-interface-list=LAN comment="TP10-seal-lan" -/ip/firewall/filter add chain=input action=drop in-interface-list=WAN comment="TP10-seal-wandrop" -/ip/firewall/filter add chain=input action=accept protocol=udp dst-port=5353 in-interface=vlan10 comment="TP5: mDNS in vlan10" -/ip/firewall/filter add chain=input action=accept protocol=udp dst-port=5353 in-interface=vlan40 comment="TP5: mDNS in vlan40" -/ip/firewall/filter add chain=input action=accept protocol=udp dst-port=5353 in-interface=vlan20 comment="TP5: mDNS in vlan20" -/ip/firewall/filter add chain=input action=accept protocol=udp dst-port=5353 in-interface=vlan30 comment="TP5: mDNS in vlan30" -/ip/firewall/filter add chain=input action=accept protocol=udp dst-port=5353 in-interface=vlan90 comment="TP5: mDNS in vlan90" - -# [8] WG-HUB (TP10 Multi-Site) — private-key wird im Cutover-Fenster gesetzt -/interface/wireguard add name=wg-hub mtu=1412 listen-port=51821 private-key="<<< AP1_WG_HUB_PRIVKEY >>>" \ - comment="TP10 Multi-Site VPN Hub (FM/DW spokes)" disabled=yes -/interface/wireguard/peers add interface=wg-hub name=fm allowed-address=10.101.0.0/24 \ - public-key="dzRY0lnnUygijChWe5wXZmXPeNcbdALpqBptA3fyiVE=" \ - endpoint-address=abxhutfgsid9snmy.myfritz.net endpoint-port=52476 -/interface/wireguard/peers add interface=wg-hub name=dw allowed-address=10.95.10.0/24 \ - public-key="FqYZLztQ/qME5VDie5QLGH++5299WJiE1dPpQnPbzUw=" -/ip/route add dst-address=10.101.0.0/24 gateway=wg-hub comment="TP10: FM spoke -> wg-hub" -/ip/route add dst-address=10.95.10.0/24 gateway=wg-hub comment="TP10: DW spoke -> wg-hub" +# [6] MGMT-LAYER +# interface-list + dns hier, damit die platform-weiten ComplianceFeatures +# (gelten pro Platform, nicht pro Rolle) auf APs nicht ins Leere laufen. +/interface list add comment=defconf name=WAN +/interface list add comment=defconf name=LAN +/interface list member add comment=defconf interface={{ ap.bridge }} list=LAN +/interface list member add interface=vlan{{ ap.mgmt.vlan }} list=LAN +/ip dns set allow-remote-requests=yes servers={{ ap.mgmt.gateway }} +/interface vlan add interface={{ ap.bridge }} name=vlan{{ ap.mgmt.vlan }} vlan-id={{ ap.mgmt.vlan }} +/ip address add address={{ obj.primary_ip4.address }} interface=vlan{{ ap.mgmt.vlan }} network={{ mgmt_host.split(".")[:3] | join(".") }}.0 +/ip route add comment="mgmt default via core" dst-address=0.0.0.0/0 gateway={{ ap.mgmt.gateway }} {% endif %} diff --git a/routeros/push-gc-template.py b/routeros/push-gc-template.py index 0e8d10b..f5f088b 100644 --- a/routeros/push-gc-template.py +++ b/routeros/push-gc-template.py @@ -1,58 +1,132 @@ #!/usr/bin/env python3 -"""Push nautobot/mikrotik_routeros.j2 to the bf-golden-config Gitea repo. +r"""Push nautobot/mikrotik_routeros.j2 to the bf-golden-config Gitea repo. -Reads Gitea token from ~/.secrets/credentials.env (GITEA_ACCESS_TOKEN). +Reads the Gitea token from ~/.secrets/credentials.env (GITEA_ACCESS_TOKEN). + + python3 routeros/push-gc-template.py --dry-run # Diff gegen Remote zeigen + python3 routeros/push-gc-template.py -m "TP22: AP-Zweig" # pushen + +SECRET-GUARD (TP22, 2026-08-13) +------------------------------- +Vor jedem PUT wird der Template-Inhalt gegen dieselben Feld-Regexe geprueft, die die +Nautobot-ConfigReplace-Regeln fuers Backup-Scrubbing verwenden. Findet sich ein Wert, +der NICHT die Maske `"<>"` traegt, bricht das Skript ab. + +Grund: genau dieser Weg hat am 2026-07-22 den WG-Hub-private-key und 6 WLAN-PSKs ins +Gitea-Repo getragen (via Backup-Job, aber dieselbe Zielablage). Das Repo musste am +2026-08-13 neu angelegt werden. Der Guard kostet nichts und verhindert die Wiederholung. """ +import argparse import base64 +import difflib import json import pathlib import re +import subprocess +import sys +import urllib.error import urllib.request REPO = "https://gitea.emsenhuber.net/api/v1/repos/admin/bf-golden-config" FILE = "jinja_templates/mikrotik_routeros.j2" TOKEN_PATH = pathlib.Path.home() / ".secrets/credentials.env" +SRC = pathlib.Path(__file__).resolve().parent.parent / "nautobot" / "mikrotik_routeros.j2" + +MASK = '"<>"' +# Muss mit nautobot/ingest-gc-scrubbing.py:RULES synchron bleiben. +SECRET_FIELDS = ["passphrase", "private-key", "pre-shared-key", "wpa-pre-shared-key", "password", "secret"] +VAL = r'("(?:[^"\\]|\\.)*"|\S+)' def get_token(): - text = TOKEN_PATH.read_text() - m = re.search(r"^GITEA_ACCESS_TOKEN=(.+)$", text, re.MULTILINE) + m = re.search(r"^GITEA_ACCESS_TOKEN=(.+)$", TOKEN_PATH.read_text(), re.MULTILINE) if not m: raise SystemExit("GITEA_ACCESS_TOKEN not found") return m.group(1).strip() -def main(): - token = get_token() - src = pathlib.Path(__file__).parent.parent / "nautobot" / "mikrotik_routeros.j2" - content = src.read_text() +def scan_for_secrets(text): + """Gibt eine Liste von (zeilennr, feld) zurueck, deren Wert nicht maskiert ist.""" + bad = [] + for lineno, line in enumerate(text.splitlines(), 1): + # Kommentarzeilen sind Doku (nennen die Feldnamen absichtlich), nicht Config. + if line.lstrip().startswith("#"): + continue + for field in SECRET_FIELDS: + for m in re.finditer(rf"(?