From 139d7c029d7147f36ec49b66ff5f10503c659d47 Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Mon, 21 Sep 2026 13:27:23 +0200 Subject: [PATCH] TP45 Schicht A: AGH-DNS-Freigaben .30/.31 im GC-Soll + am Core nachgezogen - nautobot/tp45-agh-firewall.py (neu): pflegt die vier `TP5-E2: LAN->AGH dns*`- Regeln im `local_config_context_data.firewall_filter` (add/remove, Dry-Run default, Unified Diff). Bewusst NICHT `ingest-core-firewall.py` -- das ist ein RE-BASELINE-Werkzeug und wuerde die Aenderung mit sich selbst absegnen. - gc-configplan.py: Jobs `tp45-agh-fw` (add + move vor die Log-Regel; `caddy` dahinter, damit der AGH-Block zusammenbleibt) und `tp45-agh-dhcp`. Fix in `create_plans`: unter derselben `change_control_id` liegen nach einem zweiten Lauf zwei Plaene. Die Auswahl folgte der REST-Reihenfolge und traf den ALTEN -> der config_set-Guard brach ab, obwohl der neue Plan korrekt erzeugt war. Jetzt gewinnt der juengste; die alten bleiben Historie. Fund: `find where dst-address=` matcht in RouterOS nie (nur `~` als Regex). Die Zeile stand als "Verifikation" in docs/transaktionen/TP23-agh-firewall.commands.rsc:7 und hat dort seit dem 2026-08-25 immer `0` gemeldet, ohne je etwas zu pruefen. Gleiche Klasse wie `nc -z` (09-01): eine Pruefung, die 0 meldet, sieht wie "fehlt" aus, nicht wie "kaputt". Abnahme: Reihenfolge am Geraet (vier neue Regeln vor dem Drop), Compliance 140/140 / 0 Drifts mit `RouterOS - ip-firewall-filter` (ordered, frisch bewertet, `actual` traegt die neuen Zeilen), `dig` vom VLAN10-Client gegen .30/.31/.36/.37 alle beantwortet. Co-Authored-By: Claude Sonnet 4.6 --- nautobot/gc-configplan.py | 132 ++++++++++++++++++++++++++++++++- nautobot/tp45-agh-firewall.py | 135 ++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 nautobot/tp45-agh-firewall.py diff --git a/nautobot/gc-configplan.py b/nautobot/gc-configplan.py index be005f5..e56d7ca 100644 --- a/nautobot/gc-configplan.py +++ b/nautobot/gc-configplan.py @@ -59,6 +59,7 @@ import time import urllib.error import urllib.parse import urllib.request +from datetime import datetime BASE = "http://10.40.20.154:8080/api" TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") @@ -160,8 +161,22 @@ def create_plans(device_names, config_set, change_control_id, dry): # Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch). q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1}) found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", []) + + # Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem + # Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch. + # 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch + # den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so + # passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen + # neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie. + def created(p): + raw = str(p.get("created") or "") + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError: + return datetime.min + by_name = {} - for p in found: + for p in sorted(found, key=created): name = (p.get("device") or {}).get("display") by_name[name] = p["id"] @@ -418,11 +433,126 @@ def job_tp38_oob(): ] +def job_tp45_agh_fw(): + """TP45 Schicht A (2026-09-21): DNS-Freigaben fuer die neuen Resolver-Adressen .30/.31. + + Der Resolver zieht von .36/.37 auf .30/.31 (TP32-Oktett-Klasse DNS `.3x`). Damit die + Umstellung ohne Ausfall geht, laufen beide Adressen kurz parallel — also braucht der + Core **beide** Freigaben. Diese hier ist ADDITIV: die vier .36/.37-Regeln bleiben + stehen und fallen erst beim Rueckbau (eigener Schritt, wenn die Zaehler stillstehen). + + Die vier Regeln sind ein Literal im Soll (`local_config_context_data.firewall_filter`, + gepflegt mit `nautobot/tp45-agh-firewall.py`) — der Job zieht nur das Geraet nach. + + 🔴 `place-before` wird ueber Netmiko IGNORIERT (Lehre aus TP38, 2026-09-15): die Regel + landet am Ketten-Ende, also HINTER dem finalen `drop` und waere wirkungslos, waehrend + der Plan trotzdem „Completed" meldet. Deshalb `add` (appends) und danach `move` vor + die Log-Regel — in der Reihenfolge, in der die Regeln stehen sollen (jedes `move` + schiebt seine Regel unmittelbar vor das Ziel, also hinter die zuvor verschobenen). + + 🔴 **Zweite Falle, am 2026-09-21 beim ersten Lauf aufgeschlagen:** nicht der `move` + war falsch, sondern der **Selektor** — `find where dst-address=` matcht nie + (Details und Tragweite in der Docstring von `sel()` unten). Der Plan meldete + trotzdem „Completed", die Regeln lagen hinter dem `drop`. Deshalb ist die Abnahme + hier **nicht** der Plan-Status, sondern die Position am Geraet. + + KEIN LOCKOUT-RISIKO: die Regeln erweitern nur `accept` in der forward-Kette; der + Mgmt-Pfad (Nautobot-SSH -> vlan99) ist von der Aenderung nicht beruehrt. Der Deploy + laeuft von 10.40.20.154, das auf Regel 22 (established) matcht. + """ + ips = ["10.40.20.30", "10.40.20.31"] + log = 'TP5: log-only, Vorstufe zum deny-by-default' + + def sel(cmt, ip): + """Selektor auf genau eine Regel. + + 🔴 **`dst-address=` matcht in `find where` NIE** — live am Core + gemessen (2026-09-21): `find where dst-address=10.40.20.30` → **0 Treffer**, + auch mit `/32`; `dst-address~"10.40.20.30"` (Regex) → 2. Deshalb steht hier + `~`, nicht `=`. + + ⚠️ Die Tragweite ist größer als dieser Job: **eine Prüfung, die 0 meldet, + sieht wie „nicht vorhanden" aus und nicht wie „kaputt".** Genau daran ist + der erste Lauf dieses Jobs gescheitert — der `add` lief (der Guard sah + „nicht vorhanden"), der `move` lief ins Leere, und der Plan meldete + trotzdem „Completed". Dieselbe Zeile stand als *Verifikation* in + `docs/transaktionen/TP23-agh-firewall.commands.rsc:7` und hat dort seit + dem 2026-08-25 immer `0` gemeldet, ohne je etwas zu prüfen. + Gleiche Klasse wie `nc -z` (2026-09-01) und das `sed`-Maskierungsmuster: + **erst den Selektor verdächtigen, dann das Gerät.** + """ + return f'comment="{cmt}" dst-address~"{ip}"' + + def rule(ip, proto, cmt): + return (f'/ip firewall filter add action=accept chain=forward comment="{cmt}" ' + f'dst-address={ip} dst-port=53 protocol={proto}') + + lines = [] + for ip in ips: + # Guard + move getrennt: nur der Guard ist idempotent (ein zweiter Lauf + # verdoppelt die Regeln nicht), der move läuft immer — er ist auch der + # Reparaturweg, wenn eine Regel schon existiert, aber falsch einsortiert ist. + for proto, cmt in (("udp", "TP5-E2: LAN->AGH dns"), ("tcp", "TP5-E2: LAN->AGH dns-tcp")): + lines.append(f':if ([:len [/ip firewall filter find where {sel(cmt, ip)}]] = 0) ' + f'do={{ {rule(ip, proto, cmt)} }}') + for ip in ips: + for cmt in ("TP5-E2: LAN->AGH dns", "TP5-E2: LAN->AGH dns-tcp"): + lines.append(f'/ip firewall filter move [find where {sel(cmt, ip)}] ' + f'destination=[find where comment="{log}"]') + # Der Anker ist bewusst die Log-Regel (Baseline, stabil) und nicht `caddy`: haengt + # sie unmittelbar vor der Log-Regel, landen die vier Regeln DAHINTER -- der AGH-Block + # bleibt so zusammen, wie das Soll ihn fuehrt. Waehlte man `caddy` als Ziel und die + # Regel fehlte, laegen die vier hinter dem `drop` = funktional kaputt; so bleibt im + # Fehlerfall nur eine Reihenfolge-Abweichung (Compliance-Drift, kein Ausfall). + lines.append(f'/ip firewall filter move [find where comment="TP31: AP-cloudflared -> caddy-bf1"] ' + f'destination=[find where comment="{log}"]') + # ⚠️ Diese Zeile meldet NUR Anwesenheit (je IP 2 Regeln erwartet), NICHT die + # Position -- sie kann also gruen aussehen, waehrend die Regeln hinter dem `drop` + # liegen. Genau daran ist der erste Lauf gescheitert. **Die Abnahme ist der + # Geraete-Read nach dem Deploy** (`print terse proplist=comment,packets`). + lines.append( + ':log info ("tp45-agh-fw: .30=" . ' + '[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.30"]]] . " .31=" . ' + '[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.31"]]] . " log=" . ' + f'[:tostr [:len [/ip firewall filter find where comment="{log}"]]])') + + return [(["crs326-core"], "\n".join(lines), "TP45-agh-firewall")] + + +def job_tp45_agh_dhcp(): + """TP45 Schicht A (2026-09-21): DHCP-DNS-Server der 7 Netze .36,.37 -> .30,.31. + + Der Core verteilt in ALLEN sieben Netzen `dns-server=10.40.20.36,10.40.20.37` + (live geprueft 2026-09-21). Das Template (Z.137) ist auf .30,.31 gehoben; dieser Job + zieht das laufende Geraet nach. + + MODIFY, kein add: das Netz existiert -> `set [find]`, nicht die `add`-Zeile der + intended config (die wuerfe `already have such address`). `[find]` ohne Filter + trifft alle 7 Netze. Gleiche Form wie `job_tp42_dhcp_domain`. + + ⚠️ **Reihenfolge zwingend:** erst `tp45-agh-fw` deployen. Sonst liefert DHCP Adressen + aus, die die Firewall noch nicht durchlaesst — unter deny-by-default faellt DNS aus + allen VLANs aus. + + KEIN LOCKOUT-RISIKO: eine DHCP-Option beruehrt weder Routing noch Mgmt-Zugang; + Clients ziehen sie erst beim naechsten Lease-Renew (lease-time 1d, GUEST 12h). + """ + core_set = "\n".join([ + ':do { /ip dhcp-server network set [find] dns-server=10.40.20.30,10.40.20.31 } ' + 'on-error={ :log info "tp45-agh-dhcp: set geworfen" }', + ':log info ("tp45-agh-dhcp: auf-neu=" . [:tostr [:len [/ip dhcp-server network ' + 'find where dns-server~"10.40.20.30"]]] . "/7")', + ]) + return [(["crs326-core"], core_set, "TP45-agh-dhcp")] + + JOBS = { "drift-cleanup": job_drift_cleanup, "mgmt-acl": job_mgmt_acl, "tp42-dhcp-domain": job_tp42_dhcp_domain, "tp38-oob": job_tp38_oob, + "tp45-agh-fw": job_tp45_agh_fw, + "tp45-agh-dhcp": job_tp45_agh_dhcp, } diff --git a/nautobot/tp45-agh-firewall.py b/nautobot/tp45-agh-firewall.py new file mode 100644 index 0000000..64eeae0 --- /dev/null +++ b/nautobot/tp45-agh-firewall.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +r"""TP45 Schicht A — die AGH-DNS-Freigaben im Core-`firewall_filter`-Soll pflegen. + +WAS HIER PASSIERT +----------------- +Die vier `TP5-E2: LAN->AGH dns*`-Regeln des Cores stehen als LITERALE im +`local_config_context_data.firewall_filter` (Nautobot) und werden vom GC-Template +nur gerendert. Zieht der Resolver um, muss das Soll mit — sonst meldet die +Compliance Drift, sobald das Geraet die neue Adresse traegt. + + add [...] zwei Regeln (udp+tcp) fuer jede IP, direkt hinter den + bestehenden AGH-Block einsortiert + remove [...] genau diese zwei Regeln wieder entfernen (= Rueckbau) + +WARUM KEIN `ingest-core-firewall.py` +------------------------------------ +Das ist ein RE-BASELINE-Werkzeug: es liest den Ist-Stand vom Geraet und erklaert +ihn zum Soll. Fuer eine *geplante* Aenderung ist es das falsche Werkzeug — es +wuerde die Aenderung mit sich selbst absegnen. Hier wird das Soll explizit +gesetzt, das Geraet zieht per `gc-configplan.py --job tp45-agh-fw` nach. + + python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 # Dry-Run + python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 --apply + +REIHENFOLGE IST SEMANTISCH — die ComplianceRule steht auf `config_ordered`. Neue +Regeln landen hinter der bestehenden `.37`-tcp-Zeile, also weiterhin VOR der +`TP5: log-only`-Zeile und damit vor dem finalen `drop`. +""" +import difflib +import json +import os +import pathlib +import sys +import urllib.request + +NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080") +DEVICE = "crs326-core" +COMMENT_UDP = "TP5-E2: LAN->AGH dns" +COMMENT_TCP = "TP5-E2: LAN->AGH dns-tcp" +ANCHOR = 'comment="TP5-E2: LAN->AGH dns-tcp" dst-address=' + + +def token(): + tok = os.environ.get("NAUTOBOT_TOKEN") + if tok: + return tok + # Fallback: aus dem (gitignored) Stack-File, Wert wird nie ausgegeben. + yml = (pathlib.Path(__file__).resolve().parent.parent + / "docs/nas-vlan20/stacks/210-nautobot.yml") + for line in yml.read_text(errors="replace").splitlines(): + key, sep, val = line.strip().lstrip("- ").partition("=") + if sep and key.strip() == "NAUTOBOT_SUPERUSER_API_TOKEN": + return val.strip().strip("'\"") + raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml") + + +def nb(path, method="GET", payload=None): + req = urllib.request.Request( + f"{NB}{path}", method=method, + data=json.dumps(payload).encode() if payload is not None else None, + headers={"Authorization": f"Token {token()}", "Content-Type": "application/json", + "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def rules_for(ip): + # Attributreihenfolge alphabetisch, exakt wie RouterOS /export sie schreibt — + # dieselbe Konvention wie ingest-core-firewall.py und das Template. + return [ + f'add action=accept chain=forward comment="{COMMENT_UDP}" ' + f'dst-address={ip} dst-port=53 protocol=udp', + f'add action=accept chain=forward comment="{COMMENT_TCP}" ' + f'dst-address={ip} dst-port=53 protocol=tcp', + ] + + +def is_agh(rule, ip): + return f"dst-address={ip}" in rule and "TP5-E2: LAN->AGH dns" in rule + + +def main(): + argv = sys.argv[1:] + if len(argv) < 3 or argv[0] not in ("add", "remove"): + raise SystemExit(__doc__.strip().splitlines()[-1]) + mode = argv[0] + ips = [a for a in argv[1:] if not a.startswith("--")] + apply_ = "--apply" in argv + + dev = nb(f"/api/dcim/devices/?name={DEVICE}")["results"][0] + ctx = dict(dev.get("local_config_context_data") or {}) + lines = list(ctx.get("firewall_filter") or []) + if not lines: + raise SystemExit("firewall_filter ist leer — falsches Geraet?") + + new = list(lines) + for ip in ips: + present = [l for l in new if is_agh(l, ip)] + if mode == "add": + if present: + print(f" {ip}: schon vorhanden ({len(present)} Regeln) — uebersprungen") + continue + # hinter die letzte bestehende AGH-Zeile einsortieren + idx = max((i for i, l in enumerate(new) if ANCHOR in l), default=None) + if idx is None: + raise SystemExit(f" kein AGH-Anker ({ANCHOR}...) im Soll gefunden") + new[idx + 1:idx + 1] = rules_for(ip) + print(f" {ip}: +2 Regeln nach Index {idx}") + else: + if len(present) != 2: + raise SystemExit(f" {ip}: {len(present)} statt 2 Regeln gefunden — Abbruch") + new = [l for l in new if not is_agh(l, ip)] + print(f" {ip}: -2 Regeln entfernt") + + if new == lines: + print("\n = unveraendert, nichts zu tun.") + return + + print(f"\n== Diff ({len(lines)} -> {len(new)} Zeilen) ==") + for d in difflib.unified_diff(lines, new, "IST", "SOLL", lineterm="", n=1): + print(" " + d[:170]) + + if not apply_: + print(f"\nDRY-RUN — mit --apply nach " + f"local_config_context_data.firewall_filter schreiben.") + return + ctx["firewall_filter"] = new + nb(f"/api/dcim/devices/{dev['id']}/", "PATCH", {"local_config_context_data": ctx}) + print("\n ~ geschrieben. Geraet zieht nach per: " + "python3 nautobot/gc-configplan.py --job tp45-agh-fw --apply") + + +if __name__ == "__main__": + main()