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=<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>
This commit is contained in:
@@ -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=<bare-IP>` 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=<bare-IP>` 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
135
nautobot/tp45-agh-firewall.py
Normal file
135
nautobot/tp45-agh-firewall.py
Normal file
@@ -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 <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()
|
||||
Reference in New Issue
Block a user