- 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=<bare-IP>` 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 <noreply@anthropic.com>
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
#!/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 <ip> [...] zwei Regeln (udp+tcp) fuer jede IP, direkt hinter den
|
|
bestehenden AGH-Block einsortiert
|
|
remove <ip> [...] 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()
|