From f6283fd813253feb84f673d8a535b2fbdaba659d Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Mon, 31 Aug 2026 20:13:47 +0200 Subject: [PATCH] =?UTF-8?q?TP16.3=20=C2=A75:=20die=20sechs=20AP-Drifts=20i?= =?UTF-8?q?ns=20Soll,=2028.=20Regel=20interface-veth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit veth / ip address / ip firewall nat der drei cloudflared-Connectoren standen seit der Vor-Ort-Session per Direkt-Write am Geraet (Runbook §3.4), waehrend /ip address und /ip firewall unter GC-Drift-Kontrolle stehen: sechs extra-Drifts, Compliance 128/135. Sie sind inhaltlich richtig und sollen DAUERHAFT gelten -- deshalb ins Template und nicht in eine gc-transaktion (die ist fuer befristete Versuche). Kein Re-Baseline, kein Rueckbau, keine Zeile aufs Geraet: nur die SoT nachgezogen. Neu ist die 28. ComplianceRule interface-veth. Vorher gab es fuer /interface veth keine Regel -- das tragende Stueck des ganzen Aufbaus haette geloescht werden koennen, ohne dass irgendetwas meldet. Zwei Funde: (a) Der veth-Export traegt zwei geraetevergebene MACs (mac-address, container-mac-address), auf jedem AP anders. Eine nackte Regel haette damit Dauer-Drift auf allen drei APs erzeugt, also genau die immer-rote Zahl, die niemand mehr liest. Zwei eng auf die veth-Zeile verankerte ConfigReplace-Regeln normalisieren sie im Backup (Lookbehind haelt container-mac-address heraus, [^\n]*? haelt die Regel in der Zeile; Selbsttest mit Negativfaellen im Skript). Bewusst mit der vorhandenen Maske <> statt einer eigenen: sie ist im Repo zugleich die Push-Sperre (gc-configplan.py verweigert config_set damit) -- eine Zeile mac-address=<<...>> darf nie aufs Geraet. Kostet Backup-Treue: die veth-MACs stehen in bf-golden-config nicht mehr im Klartext. Vertretbar, RouterOS vergibt sie beim Neuanlegen ohnehin neu. Verworfene Alternative: MACs je Geraet modellieren -- widerspricht der Begruendung, die fuer die VAP-MACs schon im ConfigContext-Skript steht. (b) Der erste Compliance-Lauf meldete SUCCESS und war trotzdem unvollstaendig: 139 statt 140 Eintraege, crs326-core/interface-veth fehlte, im Job-Log "RENDER COMPLIANCE TASK GROUP failed: Broken pipe". Zweiter Lauf sauber. Merkregel im Runbook (Falle 16): Abnahme ist die ANZAHL der Eintraege (Regeln x Geraete), nicht die Konformitaetsquote. Stand danach: 139/140. Uebrig bleibt genau crs326-core/wifi-security -- die offene Transaktion TP25-fund-e-ft-over-ds einer laufenden Messung, die bleiben muss. Ausserdem: frischer Ingress-Snapshot von bf-public vor §5 und das geschaerfte §5.1-Erfolgskriterium (200 mit leerem Body + server: Caddy = Strecke bewiesen, 404 = Ingress-Regel fehlt) -- am Live-System gemessen, nicht angenommen. Co-Authored-By: Claude Opus 5 --- ...ublic-ingress-20260831-vor-paragraph5.json | 1 + docs/teilprojekte.md | 35 ++- docs/tp16.3-bf-public-ap-connector-runbook.md | 40 ++- nautobot/ingest-ap-configcontext.py | 19 ++ nautobot/ingest-veth-rule.py | 244 ++++++++++++++++++ nautobot/mikrotik_routeros.j2 | 28 ++ 6 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 docs/cf-backup/bf-public-ingress-20260831-vor-paragraph5.json create mode 100644 nautobot/ingest-veth-rule.py diff --git a/docs/cf-backup/bf-public-ingress-20260831-vor-paragraph5.json b/docs/cf-backup/bf-public-ingress-20260831-vor-paragraph5.json new file mode 100644 index 0000000..6d3941d --- /dev/null +++ b/docs/cf-backup/bf-public-ingress-20260831-vor-paragraph5.json @@ -0,0 +1 @@ +{"success":true,"errors":[],"messages":[],"result":{"tunnel_id":"48d3b0b6-7233-4781-a76a-1de4a5e957ce","version":3,"config":{"ingress":[{"service":"https://10.40.20.40:443","hostname":"vw.emsenhuber.net","originRequest":{"originServerName":"vw.emsenhuber.net"}},{"service":"https://10.40.20.40:443","hostname":"hass.emsenhuber.net","originRequest":{"originServerName":"hass.emsenhuber.net"}},{"service":"https://10.40.20.40:443","hostname":"n8n.emsenhuber.net","originRequest":{"originServerName":"n8n.emsenhuber.net"}},{"service":"https://10.40.20.40:443","hostname":"gitea.emsenhuber.net","originRequest":{"originServerName":"gitea.emsenhuber.net"}},{"service":"http_status:404"}],"warp-routing":{"enabled":false}},"source":"cloudflare","created_at":"2026-08-31T05:56:47.739644Z"}} \ No newline at end of file diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index 87956d0..4884b94 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -31,6 +31,23 @@ Mgmt (VLAN99, Ist 2026-08-16): core `.99.1` · branch `.99.2` · ap1 `.99.11` · **Compliance am 2026-08-31 nach vollem Refresh (Backup→Intended→Compliance) gelesen: 128/135.** *(Die Fassung vom 2026-08-16 stand bis zum 18. hier und war an vier Stellen überholt. Wer diesen Block liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur die TP-Abschnitte.)* +- 🆕 **TP16.3: die sechs AP-Drifts sind ins Soll gehoben (2026-08-31) — Compliance 139/140.** + `veth` / `ip address` / `ip firewall nat` der drei cloudflared-Connectoren stehen jetzt im + **AP-Template** (Block `[7]`) und im ConfigContext `ap-common` (`ap.container`), **nicht** in einer + `gc-transaktion`: ein Dauerzustand gehört ins Soll, eine Transaktion ist ein befristeter Versuch. + **Kein Re-Baseline, kein Rückbau, keine Zeile aufs Gerät** — nur die SoT nachgezogen. + 🆕 **28. ComplianceRule `interface-veth`** (vorher gab es für `/interface veth` keine ⇒ ein + gelöschtes veth hätte **nichts** gemeldet). 🔴 **Zwei Funde dabei:** (a) der veth-Export trägt + **zwei gerätevergebene MACs** — eine nackte Regel hätte Dauer-Drift auf allen drei APs erzeugt; + gelöst mit zwei eng auf die veth-Zeile verankerten `ConfigReplace`-Regeln (`60-`/`61-veth-*`, + `nautobot/ingest-veth-rule.py`), bewusst mit der vorhandenen Maske `<>`, weil die im + Repo zugleich die **Push-Sperre** ist. (b) **Der erste Compliance-Lauf meldete `SUCCESS` und war + trotzdem unvollständig** (139 statt 140 Einträge, `crs326-core`/`interface-veth` fehlte; + Job-Log: `RENDER COMPLIANCE TASK GROUP failed: Broken pipe`). Zweiter Lauf sauber. + ⚠️ **Merkregel: nach einem Refresh nicht nur die Konformitätsquote lesen, sondern die ANZAHL der + Einträge** — sie muss `Regeln × Geräte` sein. Eine fehlende Kombination sieht aus wie „die neue + Regel greift nicht" und ist in Wahrheit ein halb gelaufener Job. + *(Übrig bleibt genau `crs326-core`/`wifi-security` = offene Transaktion `TP25-fund-e-ft-over-ds`.)* - 🆕 **MCP-Bestand 2026-08-28 korrigiert:** **9 MCPs identisch** in `~/.claude.json` und `~/.config/opencode/opencode.jsonc` (vorher Asymmetrie 7 vs. 11 — `portainer-mcp`/`shelly-mcp` raus, `gitea-mcp`/`n8n-mcp` rein). **Mac mini = n8n-Host, NICHT zentraler MCP-Host** (Performance-Restriktion); zentraler MCP-Host = **DS923** (`nas-bf`, Portainer Endpoint 14). Inventur-Stand `mcp/INVENTORY.md` + Policy `docs/mcp-host-policy.md`. - 🆕 **MCP-Migration FQDN→IP (2026-08-29, TP35 Schicht 1):** `gitea-mcp` und `n8n-mcp` in `~/.config/opencode/opencode.jsonc` von CF-FQDN auf LAN-IP umgestellt — `gitea-mcp` → `http://10.40.20.167:3000/mcp` (macmini, **ohne** Bearer, intern), `n8n-mcp` → `http://10.40.20.167:3001/mcp` (mit `Bearer {env:N8N_MCP_BEARER}`). **Befund dabei:** der Handoff 1.2 nannte für gitea-mcp die falsche IP `.157:3000` (das ist die Gitea-Web-UI, `400 Invalid CSRF`); der echte gitea-mcp läuft auf `.167:3000` (`serverInfo: gitea-mcp 0.0.10`). `cloudflare`-MCP bleibt auf dem offiziellen FQDN (User-Direktive). - 🆕 **TP16.3 §2/§3 durchgezogen (2026-08-31, Vor-Ort):** je ein `cloudflared`-Container auf @@ -319,7 +336,7 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na > | TP14.4 | 🔜 | CF-Access-Token | — | > | TP16 | ✅ | CF-Tunnel HA wieder voll | 2026-08-24 | > | TP16.2 | 🔜 | Härtung bypass-Dienste | — | -> | TP16.3 | ⏳ | cloudflared-Reduktion (14 Hostnames geschnitten 08-30, F1 durch; §1–§3 durch 08-31, `bf-public` healthy/12 — offen: Flip §5, Abbau §6) | — | +> | TP16.3 | ⏳ | cloudflared-Reduktion (14 Hostnames geschnitten 08-30, F1 durch; §1–§3 durch 08-31, `bf-public` healthy/12; **Drift ins Soll 08-31 ⇒ 139/140** — offen: Flip §5, Abbau §6) | — | > | TP17.1 | ❌ | Shelly Gen4 → Zigbee | 2026-08-23 | > | TP17.3 | ✅ | Shelly-Regression behoben | 2026-08-23 | > | TP18 | 🔜 | Garage-Objektspeicher | — | @@ -636,7 +653,21 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na - 🟡 **MikroMCPs `export_config` liefert einen unvollständigen Export** (301 B gegen 6,2 KiB bei direktem `/export`) — ein Rollback-Artefakt, das existiert und trotzdem leer ist. Für die Backups dieser Session wurden die MCP-Exporte verworfen und `/export file=…` verwendet. -- 🔴 **Offen und entscheidungsbedürftig: Compliance steht auf 128/135, nicht 134/135.** §3.4 schreibt +- ✅ **ERLEDIGT 2026-08-31: die sechs Drifts sind ins Soll gehoben, Compliance 139/140.** + Entscheid Christoph: ins Template heben (nicht als Transaktion führen), und `/interface veth` + bekommt eine **eigene ComplianceRule** — vorher war es das tragende Stück ohne jede Überwachung. + Umgesetzt in `nautobot/mikrotik_routeros.j2` (Block `[7]`), `nautobot/ingest-ap-configcontext.py` + (`ap.container`) und neu `nautobot/ingest-veth-rule.py`. **Kein Geräte-Write.** + 🔴 **Der veth-Export trägt zwei gerätevergebene MACs** (`mac-address`, `container-mac-address`, + je AP verschieden). Ohne Vorbehandlung hätte die neue Regel auf **allen drei APs Dauer-Drift** + erzeugt — also genau die immer-rote Zahl, die niemand mehr liest. Zwei `ConfigReplace`-Regeln + normalisieren sie im Backup, eng auf die veth-Zeile verankert (`[^\n]*?` plus Lookbehind, damit + VAP-/Bridge-/Ethernet-MACs nicht mitgefangen werden; Selbsttest im Skript). + ⚠️ **Ehrlich dazugesagt:** das kostet Backup-Treue — die veth-MACs stehen in `bf-golden-config` + nicht mehr im Klartext. Vertretbar, weil RouterOS sie beim Neuanlegen ohnehin neu vergibt; was die + Regel beweist, ist die **Existenz** des veth mit der richtigen Adressierung. + *(Der ursprüngliche Befund, zur Nachvollziehbarkeit:)* +- 🟢 **War: Compliance stand auf 128/135, nicht 134/135.** §3.4 schreibt Direkt-Writes für `veth` / `ip address` / `nat` vor, während `/ip address` und `/ip firewall` unter GC-Drift-Kontrolle stehen — **Runbook und Governance widersprechen sich.** Folge: je AP zwei neue `extra`-Drifts (`ip-address` mit `172.17.0.1/24 interface=veth-cf`, `ip-firewall-nat` mit dem diff --git a/docs/tp16.3-bf-public-ap-connector-runbook.md b/docs/tp16.3-bf-public-ap-connector-runbook.md index d5689eb..2461f97 100644 --- a/docs/tp16.3-bf-public-ap-connector-runbook.md +++ b/docs/tp16.3-bf-public-ap-connector-runbook.md @@ -398,6 +398,10 @@ wie es war. Dann einfach neu absetzen. Das `masquerade` ist **Pflicht**, nicht Kosmetik — siehe §1.2: ohne es kommen die Pakete mit `172.17.0.2` am Core an, wo weder Route noch Firewall-Regel sie kennt. +✅ **Seit 2026-08-31 stehen diese drei Zeilen im SOLL** (AP-Template Block `[7]`, Werte aus dem +ConfigContext `ap-common` → `ap.container`). Wer sie hier direkt setzt, erzeugt daher **keine +Drift mehr** — aber auch keinen Bedarf: bei einem neuen AP rendert Golden Config sie mit. + ### 3.5 Image importieren und Container bauen ```bash @@ -503,7 +507,20 @@ curl -s -X POST -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: appli ``` Dazu **eine Ingress-Regel** für `test-tp163.emsenhuber.net` → `https://10.40.20.40:443` mit `originServerName: vw.emsenhuber.net` in `bf-public` ergänzen. Dann **von außerhalb des LAN, ohne -WARP** aufrufen. Erwartet: Access-Login, **keine Redirect-Schleife**. +WARP** aufrufen. + +🔑 **Das Erfolgskriterium ist praeziser, als es hier bis 2026-08-31 stand — und die frühere Fassung +war zweideutig.** Gemessen am 2026-08-31 gegen `10.40.20.40`: + +| Antwort | Bedeutung | +|---|---| +| Access-Login, danach **`200` mit LEEREM Body** und Header **`server: Caddy`** | ✅ **Strecke bewiesen.** Caddy hat geantwortet; es gibt für `test-tp163` nur kein Site-Block, und genau so antwortet Caddy auf einen unbekannten Host. | +| **`404`** | ❌ Die Ingress-Regel greift nicht — das ist der **Catch-all** von `bf-public` (`http_status:404`). | +| **`ERR_TOO_MANY_REDIRECTS`** | ❌ Blocker A ist zurück (Ingress auf `:80` oder `originServerName` fehlt). Sofort abbrechen. | + +⚠️ **Ohne diese Tabelle liest man das leere 200 als „kaputt" und das 404 als „Caddy sagt nichts" — +beide Male falsch herum.** Der zweite, unabhängige Beweis ist immer das Caddy-Log (§5.3). + ⇒ Erst wenn das steht, wird ein Produktivname angefasst. Danach Testname + Testregel wieder entfernen. ### 5.2 Flip je Name @@ -639,12 +656,23 @@ physischem Gang. Es schadet nicht — ohne laufenden Container ist es ein gesetz 12. 🔴 **`docker save` liefert seit Docker 25 OCI statt docker-v1** → `no config found in manifest`. 13. 🔴 **`/container/envs` nimmt `list=`, nicht `name=`.** 14. 🔴 **Der `device-mode`-Bestätigungs-Boot installiert keine Pakete** — Reihenfolge s. §3.3. -15. 🔴 **§3.4 kollidiert mit der GC-Governance:** `ip address` und `ip firewall nat` stehen unter - Drift-Kontrolle. Direkt gesetzt ⇒ **zwei `extra`-Drifts je AP**. Entweder vorher ins Soll heben - (AP-Template/ConfigContext) oder die Drift bewusst als offene Transaktion führen. -16. 🟡 **`A - AVAILABLE` in `/system package print` ist ein Download-Katalog**, kein Paket auf dem +15. ✅ **ERLEDIGT 2026-08-31 — §3.4 kollidierte mit der GC-Governance:** `ip address` und + `ip firewall nat` stehen unter Drift-Kontrolle, direkt gesetzt ergab das **zwei `extra`-Drifts + je AP**. Sie sind jetzt **ins Soll gehoben** (AP-Template Block `[7]` + ConfigContext + `ap.container`), **nicht** als Transaktion geführt: ein Dauerzustand gehört ins Template. + Dazu kam die **28. ComplianceRule `interface-veth`** — vorher war das veth das tragende Stück + **ohne jede Überwachung**. 🔴 **Falle darin:** der veth-Export trägt zwei gerätevergebene MACs; + ohne Normalisierung (`ConfigReplace` `60-`/`61-veth-*`) erzeugt die Regel Dauer-Drift auf allen + drei APs. Stand danach: **139/140**, übrig nur die offene TP25-Transaktion. +16. 🔴 **Ein Golden-Config-Job meldet `SUCCESS` und kann trotzdem halb durchgelaufen sein.** + Am 2026-08-31 fehlte nach dem ersten Compliance-Lauf genau **ein** Eintrag + (`crs326-core`/`interface-veth`), Job-Status `SUCCESS`, im Job-Log dagegen + `RENDER COMPLIANCE TASK GROUP failed: Broken pipe`. **Abnahme ist deshalb nicht die + Konformitätsquote, sondern die ANZAHL der Einträge = Regeln × Geräte.** 139 statt 140 sieht + aus wie „die neue Regel greift nicht" und ist ein halb gelaufener Job. Zweiter Lauf reichte. +17. 🟡 **`A - AVAILABLE` in `/system package print` ist ein Download-Katalog**, kein Paket auf dem Gerät — `enable` würde einen Internet-Fetch auslösen (auf ap1/ap2 gesperrt). `.npk` bleibt der Weg. -17. 🟡 **MikroMCPs `export_config` exportiert unvollständig** (301 B statt 6,2 KiB). Für Backups +18. 🟡 **MikroMCPs `export_config` exportiert unvollständig** (301 B statt 6,2 KiB). Für Backups `/export file=…` per SSH verwenden. Nach zwei Reboots je AP lohnt der Blick — die Tabelle zeigte beim CAPsMAN-Ausfall 14 h lang zufriedene Clients ohne Adresse. diff --git a/nautobot/ingest-ap-configcontext.py b/nautobot/ingest-ap-configcontext.py index 2b8fe56..15b4a78 100644 --- a/nautobot/ingest-ap-configcontext.py +++ b/nautobot/ingest-ap-configcontext.py @@ -58,6 +58,25 @@ AP_COMMON = { # local_config_context_data. (Nautobot merged Dicts tief, LISTEN ERSETZEND — # genau das ist hier gewollt.) "extra_ports": ["ether3", "ether4", "ether5"], + # Container-Netz des cloudflared-Connectors (TP16.3 §5, 2026-08-31). Bis dahin + # standen veth / ip address / nat per Direkt-Write am Geraet und erzeugten zwei + # extra-Drifts JE AP -- Runbook §3.4 und die GC-Governance widersprachen sich. + # Sie sind hier ins Soll gehoben, weil sie DAUERHAFT gelten sollen: ein + # Dauerzustand gehoert ins Template, nicht in eine gc-transaktion mit Ablaufdatum. + # 🔴 Das masquerade ist Pflicht, nicht Kosmetik: ohne es kommen die Pakete mit + # 172.17.0.2 am Core an, wo weder Route noch Firewall-Regel sie kennt -- die + # Freigabe vlan99->vlan20:443 aus Blocker B traegt sie dann nicht. + # Jeder Wert hier ist EIN Token der Export-Zeile, bewusst ausgeschrieben statt + # gerechnet: ein falscher join() faellt nicht auf, er wird zu Dauer-Drift. + "container": { + "veth": "veth-cf", + "veth_address": "172.17.0.2/24", # Container-Seite -> /interface veth + "gateway": "172.17.0.1", # AP-Seite ohne Prefix -> /interface veth + "ap_address": "172.17.0.1/24", # AP-Seite -> /ip address + "network": "172.17.0.0", # -> /ip address network= + "subnet": "172.17.0.0/24", # -> nat src-address= + "nat_comment": "TP31: cloudflared egress", + }, "mgmt": {"vlan": 99, "gateway": "10.40.99.1"}, "native_vlan": 1, # Liste (nicht Dict): Reihenfolge ist garantiert und diffbar. diff --git a/nautobot/ingest-veth-rule.py b/nautobot/ingest-veth-rule.py new file mode 100644 index 0000000..577c3d2 --- /dev/null +++ b/nautobot/ingest-veth-rule.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +r"""ComplianceRule `interface-veth` + zwei Normalisierungs-Regeln (TP16.3 §5, 2026-08-31). + +DIE LUECKE, DIE DAS SCHLIESST +----------------------------- +Seit dem 2026-08-31 traegt jeder AP ein `veth-cf` — das Container-Netz, an dem der +`cloudflared`-Connector fuer `bf-public` haengt. Es ist das tragende Stueck: ohne das +veth laeuft kein Container, und mit ihm haengt der oeffentliche Weg von vier Diensten. + +Fuer `/interface veth` gab es bis heute **keine ComplianceRule** — deshalb hat das veth +auch nie eine Drift erzeugt. Das ist genau die Sorte Ruhe, die nichts beweist: wer es +loescht, bekommt keine Meldung. Dieselbe blinde Stelle wie bei `/queue` (TP6), +`/ip firewall filter` (TP26) und `/system logging` (TP25 Fund E). + +WARUM DAS SKRIPT MEHR MACHT ALS EINE REGEL ANZULEGEN +---------------------------------------------------- +Der rohe Export traegt ZWEI geraetevergebene MAC-Adressen, auf jedem AP verschieden: + + /interface veth add address=172.17.0.2/24 container-mac-address=5E:85:94:E7:F9:51 \ + dhcp=no gateway=172.17.0.1 gateway6="" mac-address=5E:85:94:E7:F9:50 name=veth-cf + +Eine Regel ohne Vorbehandlung erzeugte damit auf allen drei APs **Dauer-Drift** — die +Regel wuerde also genau das produzieren, was sie verhindern soll: eine Zahl, die +immer rot ist und die deshalb niemand mehr liest. + +Zwei Auswege, und warum es dieser geworden ist: + (a) MACs je Geraet in Nautobot modellieren. Verworfen: `ingest-ap-configcontext.py` + begruendet ausfuehrlich, warum geraetevergebene MACs NICHT ins Modell gehoeren + (dieselbe Entscheidung wie bei den VAP-Interfaces, s. Template-Kommentar [1]). + Ein veth-Neubau vergibt ausserdem neue MACs -> das Modell waere sofort wieder alt. + (b) Die zwei Felder im Backup normalisieren, wie es fuer Secrets laengst geschieht. + -> dieser Weg. + +⚠️ EHRLICH DAZUGESAGT: die Maske kostet Backup-Treue. In `bf-golden-config` stehen die + veth-MACs danach nicht mehr im Klartext. Das ist vertretbar, weil RouterOS sie beim + Neuanlegen ohnehin neu vergibt — ein Restore braucht sie nicht. Was die Regel + beweist, ist die EXISTENZ des veth mit der richtigen Adressierung, nicht seine MAC. + +🔑 WARUM DIESELBE MASKE WIE FUER SECRETS (`<>`) UND KEINE EIGENE +-------------------------------------------------------------------------- +Hier ist sie kein Geheimnis-Hinweis, sondern ein **Push-Schutz**: `gc-configplan.py` +weigert sich, ein `config_set` mit `<>` zu senden (TP22-Regel 1). Eine +eigene Maske wie `<>` haette diesen Schutz NICHT — und eine gepushte Zeile +`mac-address=<>` waere Muell auf dem Geraet. Die Maske als "nicht pushbar"-Marke +zu lesen ist die allgemeinere und hier die richtige Bedeutung. + +Die Secrets-Regeln in `ingest-gc-scrubbing.py` bleiben davon **unberuehrt** — dieses +Skript legt nur zusaetzliche, eng verankerte Regeln an. Beide Skripte sind additiv und +raeumen nichts weg, was ihnen nicht gehoert. + +REGEX-VERANKERUNG +----------------- +Beide Muster beginnen mit `/interface veth add ` und benutzen `[^\n]*?` — sie koennen +die Zeile also nicht verlassen. `mac-address=` allein waere gefaehrlich (das Feld gibt +es auch an VAPs, Bridges und Ethernet-Ports); der Lookbehind `(?>" + +# (name, regex, beschreibung) - Namen numerisch praefixiert, GC ordnet nach (platform, name). +# 60/61 schliessen an die Secret-Regeln 10-50 aus ingest-gc-scrubbing.py an. +REPLACES = [ + ( + "60-veth-container-mac", + r"(/interface veth add [^\n]*?container-mac-address=)\S+", + "Geraetevergebene Container-MAC des veth (TP16.3). Kein Secret - Normalisierung, " + "damit die ComplianceRule interface-veth nicht auf allen APs Dauer-Drift meldet.", + ), + ( + "61-veth-mac", + r"(/interface veth add [^\n]*?(?> ' + 'dhcp=no gateway=172.17.0.1 gateway6="" mac-address=<> name=veth-cf') + +SELFTEST = [ + (AP1, AP1_SOLL), + # Bereits maskiert -> idempotent, kein zweiter Durchlauf-Schaden. + (AP1_SOLL, AP1_SOLL), + # MACs ausserhalb der veth-Zeile bleiben unangetastet - das ist der eigentliche Test. + ('/interface wifi add configuration=cfg-main mac-address=D0:EA:11:4D:40:E0 name=w5-iot', None), + ('/interface bridge set bridge admin-mac=04:F4:1C:B4:17:76 auto-mac=no', None), + ('/interface ethernet set ether1 mac-address=AA:BB:CC:DD:EE:FF', None), + # Zwei Zeilen: die Verankerung darf nicht ueber den Zeilenumbruch greifen. + ('/interface veth add address=172.17.0.2/24 name=veth-cf\n' + '/interface wifi add mac-address=D0:EA:11:4D:40:3A name=w2-iot', + 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 -> {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 anwenden(text): + for _, rx, _ in REPLACES: + text = re.sub(rx, r"\1" + MASK, text) + return text + + +def selftest(): + fails = [] + for src, want in SELFTEST: + got = anwenden(src) + erwartet = src if want is None else want + ok = got == erwartet + if not ok: + fails.append((src, erwartet, got)) + print(f" [{'ok ' if ok else 'FAIL'}] {src.splitlines()[0][:78]}") + return fails + + +def main(): + print("== Regex-Selbsttest (laeuft auch im Dry-Run) ==") + 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: + sys.exit("!! NAUTOBOT_TOKEN nicht gesetzt") + + plat = find("/dcim/platforms", name=PLATFORM) + if not plat: + sys.exit(f"!! Platform {PLATFORM!r} nicht gefunden") + + print(f"== ConfigReplace (Platform {PLATFORM}) ==") + for name, rx, desc in REPLACES: + nutzlast = { + "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 nutzlast.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/", nutzlast) + print(f" + {name}") + + print(f"\n== ComplianceFeature/-Rule {FEATURE!r} ==") + feat = find("/plugins/golden-config/compliance-feature", name=FEATURE) + if feat: + print(f" = Feature {FEATURE}") + elif DRY: + print(f" + Feature {FEATURE} (dry-run)") + feat = None + else: + feat = req("POST", "/plugins/golden-config/compliance-feature/", + {"name": FEATURE, "slug": FEATURE, + "description": "Container-Netz der AP-cloudflared-Connectoren (TP16.3)."}) + print(f" + Feature {FEATURE}") + + if feat: + rule = find("/plugins/golden-config/compliance-rule", + feature=feat["id"], platform=plat["id"]) + else: + rule = None + if rule: + print(f" = Rule {FEATURE} match_config={rule.get('match_config')!r}") + if rule.get("match_config") != MATCH: + print(f" ⚠️ weicht ab - erwartet {MATCH!r}") + elif DRY: + print(f" + Rule {FEATURE} match_config={MATCH!r} (dry-run)") + else: + req("POST", "/plugins/golden-config/compliance-rule/", { + "feature": feat["id"], + "platform": plat["id"], + "description": "Ohne diese Regel meldet ein geloeschtes veth-cf gar nichts (TP16.3).", + "config_ordered": False, + "config_type": "cli", + "match_config": MATCH, + }) + print(f" + Rule {FEATURE}") + + print("\n🔴 Danach ZWINGEND: python3 nautobot/gc-configplan.py --refresh-compliance") + print(" (Backup -> Intended -> Compliance). Die Check-Zahl MUSS steigen: 27x5=135 -> 28x5=140.") + print(" Bleibt sie bei 135, greift die Regel nicht - das ist ein Befund, kein Erfolg.") + if DRY: + print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.") + + +if __name__ == "__main__": + main() diff --git a/nautobot/mikrotik_routeros.j2 b/nautobot/mikrotik_routeros.j2 index 5f63222..2022d18 100644 --- a/nautobot/mikrotik_routeros.j2 +++ b/nautobot/mikrotik_routeros.j2 @@ -535,4 +535,32 @@ /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 }} + +{% if ap.container %} +# [7] CONTAINER-NETZ des cloudflared-Connectors (TP16.3 §5, ins Soll gehoben 2026-08-31) +# Bis hierher standen diese Zeilen per DIREKT-WRITE am Geraet (Runbook §3.4) und +# erzeugten je AP zwei extra-Drifts: Runbook und GC-Governance widersprachen sich, +# /ip address und /ip firewall stehen unter Drift-Kontrolle. Sie gehoeren ins Soll +# und NICHT in eine gc-transaktion: eine Transaktion ist ein befristeter Versuch, +# das hier ein Dauerzustand. +# +# 🔴 Das masquerade ist Pflicht, nicht Kosmetik. Der Container spricht aus 172.17.0.2; +# dem Core ist 172.17.0.0/24 unbekannt, es gibt weder Route noch Rueckweg. Erst +# hinter dem masquerade kommt der Verkehr als vlan99 an und wird von der Freigabe +# "TP31: AP-cloudflared -> caddy-bf1" (vlan99 -> vlan20:443) getragen. +# +# 🔑 <> steht hier NICHT fuer ein Geheimnis, sondern fuer zwei +# geraetevergebene MACs, die auf jedem AP anders lauten und in Nautobot bewusst +# nicht modelliert sind (gleiche Entscheidung wie bei den VAP-MACs, s. [1]). +# Die ConfigReplace-Regeln 60-/61-veth-* normalisieren sie im Backup +# (nautobot/ingest-veth-rule.py). Zweiter, willkommener Effekt: die Maske ist die +# Push-Sperre aus TP22-Regel 1 -- eine Zeile mac-address=<<...>> darf nie aufs Geraet. +# +# Ein kuenftiger AP ohne Connector setzt "container": {} per local_config_context_data; +# dann rendert dieser Block nichts und die Regel interface-veth vergleicht leer gegen leer. +{% set c = ap.container %} +/interface veth add address={{ c.veth_address }} container-mac-address=<> dhcp=no gateway={{ c.gateway }} gateway6="" mac-address=<> name={{ c.veth }} +/ip address add address={{ c.ap_address }} interface={{ c.veth }} network={{ c.network }} +/ip firewall nat add action=masquerade chain=srcnat comment="{{ c.nat_comment }}" src-address={{ c.subnet }} +{% endif %} {% endif %}