TP22: RouterOS-Fleet unter Nautobot Golden Config, Deploy scharf

Nimmt die 4 Live-Geräte (crs326-core/-branch, ap1, ap2) real unter Golden
Config: Backup -> Compliance -> intended -> kontrollierter Push. Damit greift
der Governance-Entscheid "Config exklusiv über Nautobot" operativ.

Secret-Leak geschlossen (Anlass, ungeplant gefunden)
  Es gab keine ConfigReplace-Regeln, deshalb hatte der Backup-Job den
  ap1-/export ungefiltert nach Gitea geschrieben: der live genutzte
  WG-Hub-private-key und 6 WLAN-PSKs lagen dort im Klartext.
  - nautobot/ingest-gc-scrubbing.py: 5 ConfigReplace-Regeln, Maske
    "<<REDACTED>>". RouterOS quotet nur bei Bedarf (4 von 6 PSKs standen
    unquotiert), daher Alternation mit der quotierten Variante zuerst --
    \S+ ist greedy und würde bei einem PSK mit Leerzeichen den Rest
    exponieren. Regex-Selbsttest läuft bei jedem Aufruf.
  - Gitea-Repo neu angelegt (History-Rewrite verworfen: Gitea hält
    unerreichbare Blobs bis zum server-seitigen gc). Verifiziert: 0
    unmaskierte Secrets über alle 4 Geräte. User-Entscheid: nicht rotieren.
  - push-gc-template.py: Pre-Push-Secret-Guard, --dry-run, -m, sha optional.

Template
  - AP-Zweig (role == "Wireless AP"), datengetrieben aus ConfigContext
    ap-common + local_config_context_data; ap1/ap2 differieren nur in 4
    Werten. RadioProfile verworfen (hängt an einem Controller, den es seit
    der CAPsMAN-Abschaffung nicht gibt).
  - crs326-core-Zweig von Slash- auf /export-Form (Space-Form, Attribute
    alphabetisch), stale disabled=yes aus der Cutover-Ära entfernt.
  - Switch-Zweig für crs326-branch.
  - Mgmt-Nummernplan korrigiert: ap2 und branch waren in den DNS-Statics
    vertauscht, ap1 zeigte auf ein nicht existierendes .99.3.
    Verbindlich: core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12.
  Ergebnis: ap1 intended == Geräte-Ist (0 Drift), core 68/69 Zeilen
  byte-identisch. 14 ComplianceRules, 51/56 compliant -- die 5 Restdrifts
  sind echte Altlasten (Rettungspfade, Aufräumen gehört zu TP19).

Deploy (nautobot_config.py, Image gc9)
  enable_plan/enable_deploy an. napalm-ros ist READ-ONLY: der Default-
  Dispatcher wählt NAPALM (Platform trägt napalm: ros), Backup geht,
  merge_config scheitert grundsätzlich mit leerer Fehlermeldung. Fix =
  custom_dispatcher auf NetmikoMikrotikRouteros, in den Golden-Config-
  Settings (nicht den Nornir-Settings). Pilot-Deploy: DNS-Statics auf dem
  Core korrigiert, per Purge-vor-Add, weil plan_type=missing nur add
  erzeugt und sonst Duplikate entstanden wären.
  Governance-Regeln in docs/teilprojekte.md festgeschrieben: Pläne nur
  feature-scoped (ein Voll-Push schriebe die Maske als PSK auf die APs),
  AP-Template nie zeilenweise auf ein laufendes Gerät (vlan-filtering=no
  reisst den Mgmt-Pfad ab).

CLAUDE.md: Power-Save-Fehldiagnose korrigiert
  Der P750W war nicht im Power-Save, sondern schlicht ausgeschaltet; das
  Ping-Symptom kam vom lokal mitlaufenden WARP-Client (cloudflared =
  QUIC-Proxy, kein ICMP). Die Merkregel "DHCP-geht-aber-Ping-nicht =
  ARP/Power-Save" ist damit widerlegt und entfernt; strukturell schliesst
  TP16.1 diese ICMP-Lücke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 18:16:21 +02:00
parent 3304fa0ed9
commit 3666e1e7fe
7 changed files with 733 additions and 150 deletions

View File

@@ -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). - **✅✅ 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). - **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. - **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`.

View File

@@ -41,16 +41,40 @@ PLUGINS_CONFIG = {
}, },
"nautobot_golden_config": { "nautobot_golden_config": {
# Backup + Intended-Config + Compliance enabled 2026-07-22 (TP21 follow-up # Backup + Intended-Config + Compliance enabled 2026-07-22 (TP21 follow-up
# session) once a Jinja template + Git repo target existed. Plan/Deploy # session) once a Jinja template + Git repo target existed.
# (device push) stay off on purpose -- this pipeline is pull/read-only #
# for now, pushing config is a deliberately separate future step. # 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="<<REDACTED>>"` to the
# APs (kicking every WLAN client) and `private-key="<<REDACTED>>"` 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_backup": True,
"enable_compliance": True, "enable_compliance": True,
"enable_intended": True, "enable_intended": True,
"enable_sotagg": True, "enable_sotagg": True,
"enable_plan": False, "enable_plan": True,
"enable_deploy": False, "enable_deploy": True,
"enable_postprocessing": False, "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_callables": [],
"postprocessing_subscribed": [], "postprocessing_subscribed": [],
"per_feature_bar": True, "per_feature_bar": True,

View File

@@ -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). - ✅ **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`. - 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` ### 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`
- ✅ **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.<octet>` 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). - ✅ **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.<octet>` 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*-<mac>`-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`). - 🔧 **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*-<mac>`-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). - ✅ **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). - ⚠️ **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). - ✅ **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. - ✅ **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) #### 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:<device-id>:<pw>"), 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. - **Was:** je Gerät `Shelly.SetAuth` (User `admin`, realm-individueller `ha1` = sha256("admin:<device-id>:<pw>"), 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. 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. 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. - **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). - **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 **`"<<REDACTED>>"`**. 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 `"<<REDACTED>>"` 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="<<REDACTED>>"` auf die APs (= alle WLAN-Clients raus, inkl. ~26 Shellys) bzw. `private-key="<<REDACTED>>"` auf den Core (= WG-Hub tot, FM/DW-Spokes weg). **Jede Sektion, die `<<REDACTED>>` 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. - **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. - **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).** - **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 ## 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`. > ✅ **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. 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). 2. ~~**TP4 (Router-Cutover L3-Infra + WAN-Edge).**~~ ✅ erledigt 2026-06-15 (L3) + 2026-07-01 (Edge-Cutover, FB physisch weg).

View File

@@ -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()

View File

@@ -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 = '"<<REDACTED>>"'
# 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="<<REDACTED>>"'),
(r'passphrase="74c9L283\$669"', 'passphrase="<<REDACTED>>"'),
('passphrase="psk mit space"', 'passphrase="<<REDACTED>>"'),
(
"add name=sec-main ft=yes passphrase=abc123 disabled=no",
'add name=sec-main ft=yes passphrase="<<REDACTED>>" disabled=no',
),
('private-key="9AFG+aGpk3/gnzka46oVvFfcU="', 'private-key="<<REDACTED>>"'),
# 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()

View File

@@ -1,126 +1,189 @@
# Nautobot Golden Config -- intended RouterOS config for {{ obj.name }} # 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 "<<REDACTED>>" der ConfigReplace-Backup-Regeln.
# 🔴 Zeilen mit <<REDACTED>> duerfen NIE per Deploy gepusht werden.
/system identity set name={{ obj.name }} /system identity set name={{ obj.name }}
{% if obj.name == "crs326-core" %} {% 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. # crs326-core — PRODUKTIVER L3-Router (seit Cutover 2026-08-08, TP21.1).
# Alles Betriebsumlenkende wird disabled=yes angelegt; Cutover aktiviert in einem Rutsch. # Die frueheren `disabled=yes`-Staging-Flags sind ENTFERNT: sie stammten aus der
# Vorbereitung des Cutovers und beschreiben nicht mehr den Sollzustand.
# ===================================================================================== # =====================================================================================
# [1] INTERFACE-LISTS # [1] INTERFACE-LISTS
/interface/list add name=WAN comment="TP15" /interface list add comment=TP15 name=WAN
/interface/list add name=LAN comment="TP15" /interface list add comment=TP15 name=LAN
/interface/list/member add list=WAN interface=vlan100 /interface list member add interface=vlan100 list=WAN
/interface/list/member add list=LAN interface=vlan10 {% for v in vlans %}
/interface/list/member add list=LAN interface=vlan20 /interface list member add interface=vlan{{ v.id }} list=LAN
/interface/list/member add list=LAN interface=vlan30 {% endfor %}
/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
# [2] GW-IPs je VLAN — STAGED (disabled=yes) # [2] VLAN-SVIs auf der Bridge (vlan99 traegt bewusst keinen comment)
/ip/address {% 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() %} {% for iface in obj.interfaces.all() %}
{% if iface.name.startswith("vlan") %} {% if iface.name.startswith("vlan") %}
{% for ip in iface.ip_addresses.all() %} {% for ip in iface.ip_addresses.all() %}
{% if ip.host == "10.40." + iface.name|replace("vlan", "") + ".1" %} /ip address add address={{ ip.address }} comment="{{ iface.description }}" interface={{ iface.name }} network={{ ip.host.split(".")[:3] | join(".") }}.0
add address={{ ip.address }} interface={{ iface.name }} comment="{{ iface.description }}" disabled=yes {% 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="<<REDACTED>>"
/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="<<REDACTED>>"
{% 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 %} {% endif %}
{% endfor %} {% 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 %} {% endif %}
{% endfor %} {% endfor %}
# [3] WAN DHCP-Client (Liwest/Arris) — STAGED # [5] BRIDGE-VLAN — Nur Master-Radios sind Bridge-Ports (Slave-VAPs forwarden darueber),
/ip/dhcp-client add interface=vlan100 use-peer-dns=no add-default-route=yes \ # deshalb muessen wifi1/wifi2 tagged-Member je Client-VLAN sein. Die frueheren
comment="TP15 WAN Liwest (Arris)" disabled=yes # "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 # [6] MGMT-LAYER
/ip/pool add name=pool-v10 ranges=10.40.10.100-10.40.10.200 # interface-list + dns hier, damit die platform-weiten ComplianceFeatures
/ip/pool add name=pool-v20 ranges=10.40.20.100-10.40.20.200 # (gelten pro Platform, nicht pro Rolle) auf APs nicht ins Leere laufen.
/ip/pool add name=pool-v30 ranges=10.40.30.100-10.40.30.250 /interface list add comment=defconf name=WAN
/ip/pool add name=pool-v40 ranges=10.40.40.100-10.40.40.200 /interface list add comment=defconf name=LAN
/ip/pool add name=pool-v50 ranges=10.40.50.100-10.40.50.200 /interface list member add comment=defconf interface={{ ap.bridge }} list=LAN
/ip/pool add name=pool-v90 ranges=10.40.90.100-10.40.90.200 /interface list member add interface=vlan{{ ap.mgmt.vlan }} list=LAN
/ip/pool add name=pool-v99 ranges=10.40.99.100-10.40.99.200 /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/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 address add address={{ obj.primary_ip4.address }} interface=vlan{{ ap.mgmt.vlan }} network={{ mgmt_host.split(".")[:3] | join(".") }}.0
/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 route add comment="mgmt default via core" dst-address=0.0.0.0/0 gateway={{ ap.mgmt.gateway }}
/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"
{% endif %} {% endif %}

View File

@@ -1,58 +1,132 @@
#!/usr/bin/env python3 #!/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 `"<<REDACTED>>"` 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 base64
import difflib
import json import json
import pathlib import pathlib
import re import re
import subprocess
import sys
import urllib.error
import urllib.request import urllib.request
REPO = "https://gitea.emsenhuber.net/api/v1/repos/admin/bf-golden-config" REPO = "https://gitea.emsenhuber.net/api/v1/repos/admin/bf-golden-config"
FILE = "jinja_templates/mikrotik_routeros.j2" FILE = "jinja_templates/mikrotik_routeros.j2"
TOKEN_PATH = pathlib.Path.home() / ".secrets/credentials.env" TOKEN_PATH = pathlib.Path.home() / ".secrets/credentials.env"
SRC = pathlib.Path(__file__).resolve().parent.parent / "nautobot" / "mikrotik_routeros.j2"
MASK = '"<<REDACTED>>"'
# 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(): def get_token():
text = TOKEN_PATH.read_text() m = re.search(r"^GITEA_ACCESS_TOKEN=(.+)$", TOKEN_PATH.read_text(), re.MULTILINE)
m = re.search(r"^GITEA_ACCESS_TOKEN=(.+)$", text, re.MULTILINE)
if not m: if not m:
raise SystemExit("GITEA_ACCESS_TOKEN not found") raise SystemExit("GITEA_ACCESS_TOKEN not found")
return m.group(1).strip() return m.group(1).strip()
def main(): def scan_for_secrets(text):
token = get_token() """Gibt eine Liste von (zeilennr, feld) zurueck, deren Wert nicht maskiert ist."""
src = pathlib.Path(__file__).parent.parent / "nautobot" / "mikrotik_routeros.j2" bad = []
content = src.read_text() 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"(?<![\w-]){re.escape(field)}=" + VAL, line):
if m.group(1) != MASK:
bad.append((lineno, field))
return bad
# fetch current SHA
def api(path, method="GET", payload=None, token=""):
req = urllib.request.Request( req = urllib.request.Request(
f"{REPO}/contents/{FILE}?ref=main", f"{REPO}/{path}",
headers={"Authorization": f"token {token}"}, data=json.dumps(payload).encode() if payload else None,
method=method,
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
) )
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req, timeout=30) as resp:
meta = json.loads(resp.read().decode()) raw = resp.read()
sha = meta["sha"] return resp.status, (json.loads(raw) if raw else {})
payload = {
"content": base64.b64encode(content.encode()).decode(), def main():
"sha": sha, p = argparse.ArgumentParser()
"message": "TP15: WG-HUB private key + ap1 cutover lease prep", p.add_argument("-m", "--message", help="Commit-Message (Default: letzter lokaler Commit-Titel)")
"branch": "main", p.add_argument("--dry-run", action="store_true", help="nur Diff gegen Remote zeigen")
} args = p.parse_args()
req2 = urllib.request.Request(
f"{REPO}/contents/{FILE}", content = SRC.read_text()
data=json.dumps(payload).encode(),
headers={ # --- Secret-Guard: laeuft IMMER, auch im Dry-Run ---
"Authorization": f"token {token}", bad = scan_for_secrets(content)
"Content-Type": "application/json", if bad:
}, print("!! ABBRUCH - unmaskierte Secrets im Template:")
method="PUT", for lineno, field in bad:
) print(f" Zeile {lineno}: {field}= traegt nicht die Maske {MASK}")
with urllib.request.urlopen(req2) as resp: sys.exit(1)
print(resp.status, json.loads(resp.read().decode()).get("commit", {}).get("id", "?")) print(f" Secret-Guard: ok (alle Secret-Felder tragen {MASK})")
token = get_token()
# Remote-Stand holen (sha optional: nach einer Repo-Neuanlage existiert die Datei ggf. nicht)
sha, remote = None, ""
try:
_, meta = api(f"contents/{FILE}?ref=main", token=token)
sha = meta.get("sha")
remote = base64.b64decode(meta.get("content", "")).decode()
except urllib.error.HTTPError as e:
if e.code != 404:
raise
print(f" Remote: {FILE} existiert noch nicht - wird angelegt")
diff = list(difflib.unified_diff(
remote.splitlines(), content.splitlines(),
fromfile=f"remote/{FILE}", tofile=f"local/{SRC.name}", lineterm="", n=2))
if not diff:
print(" Kein Unterschied zum Remote - nichts zu tun.")
return
print(f" Diff: +{sum(1 for l in diff if l.startswith('+') and not l.startswith('+++'))}"
f" / -{sum(1 for l in diff if l.startswith('-') and not l.startswith('---'))} Zeilen")
if args.dry_run:
print("\n".join(diff))
print("\n DRY-RUN - nichts gepusht.")
return
msg = args.message
if not msg:
try:
msg = subprocess.run(["git", "log", "-1", "--pretty=%s"], cwd=SRC.parent.parent,
capture_output=True, text=True, check=True).stdout.strip()
except Exception:
msg = "Update mikrotik_routeros.j2"
payload = {"content": base64.b64encode(content.encode()).decode(), "message": msg, "branch": "main"}
if sha:
payload["sha"] = sha
status, res = api(f"contents/{FILE}", method="PUT" if sha else "POST", payload=payload, token=token)
print(f" HTTP {status} commit={res.get('commit', {}).get('sha', '?')[:8]} msg={msg!r}")
if __name__ == "__main__": if __name__ == "__main__":