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:
@@ -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`.
|
||||
|
||||
@@ -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="<<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_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,
|
||||
|
||||
@@ -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.<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).
|
||||
### 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.<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`).
|
||||
- ✅ **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:<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.
|
||||
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 **`"<<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.
|
||||
- **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).
|
||||
|
||||
203
nautobot/ingest-ap-configcontext.py
Normal file
203
nautobot/ingest-ap-configcontext.py
Normal 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()
|
||||
185
nautobot/ingest-gc-scrubbing.py
Normal file
185
nautobot/ingest-gc-scrubbing.py
Normal 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()
|
||||
@@ -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 "<<REDACTED>>" der ConfigReplace-Backup-Regeln.
|
||||
# 🔴 Zeilen mit <<REDACTED>> 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
|
||||
{% endif %}
|
||||
/ip address add address={{ ip.address }} comment="{{ iface.description }}" interface={{ iface.name }} network={{ ip.host.split(".")[:3] | join(".") }}.0
|
||||
{% endfor %}
|
||||
{% 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
|
||||
# [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
|
||||
|
||||
# [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
|
||||
# [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
|
||||
|
||||
/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
|
||||
# [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
|
||||
|
||||
/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
|
||||
# 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
|
||||
|
||||
# [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
|
||||
# [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
|
||||
|
||||
/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
|
||||
# [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
|
||||
|
||||
# [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"
|
||||
# [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
|
||||
# =====================================================================================
|
||||
|
||||
# [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"
|
||||
# [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 %}
|
||||
|
||||
/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"
|
||||
# [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 %}
|
||||
|
||||
# [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"
|
||||
# [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 %}
|
||||
{% 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 %}
|
||||
|
||||
# [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 }}
|
||||
|
||||
# [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 %}
|
||||
|
||||
@@ -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 `"<<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 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 = '"<<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():
|
||||
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"(?<![\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(
|
||||
f"{REPO}/contents/{FILE}?ref=main",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
f"{REPO}/{path}",
|
||||
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:
|
||||
meta = json.loads(resp.read().decode())
|
||||
sha = meta["sha"]
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
return resp.status, (json.loads(raw) if raw else {})
|
||||
|
||||
payload = {
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"sha": sha,
|
||||
"message": "TP15: WG-HUB private key + ap1 cutover lease prep",
|
||||
"branch": "main",
|
||||
}
|
||||
req2 = urllib.request.Request(
|
||||
f"{REPO}/contents/{FILE}",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="PUT",
|
||||
)
|
||||
with urllib.request.urlopen(req2) as resp:
|
||||
print(resp.status, json.loads(resp.read().decode()).get("commit", {}).get("id", "?"))
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("-m", "--message", help="Commit-Message (Default: letzter lokaler Commit-Titel)")
|
||||
p.add_argument("--dry-run", action="store_true", help="nur Diff gegen Remote zeigen")
|
||||
args = p.parse_args()
|
||||
|
||||
content = SRC.read_text()
|
||||
|
||||
# --- Secret-Guard: laeuft IMMER, auch im Dry-Run ---
|
||||
bad = scan_for_secrets(content)
|
||||
if bad:
|
||||
print("!! ABBRUCH - unmaskierte Secrets im Template:")
|
||||
for lineno, field in bad:
|
||||
print(f" Zeile {lineno}: {field}= traegt nicht die Maske {MASK}")
|
||||
sys.exit(1)
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user