Files
bf-infra/nautobot/ingest-core-firewall.py
Christoph Emsenhuber b52d6aa49e TP45 Schicht A: Nautobot .30/.31/.32/.34 -> .154/.155/.156/.158
Oktett-Klasse .150-.199 (App-Services). Nautobot gibt damit .30/.31 an
AGH frei. .157 ist gitea, deshalb nimmt redis .158.

- Datenmigration ueber neues nautobot/tp45-ip-move.py: Nautobot verbietet
  das Aendern einer IP nach dem Anlegen -> anlegen/primary_ip4 umhaengen/
  Altobjekt loeschen, in dieser Reihenfolge
- Stack-Redeploy (PUT /api/stacks/203), Caddy-Backend, Core-Literal *24
  ueber GC deployt (Plan 51a90e25) mit Wirksamkeitspruefung am Geraet
- caddy/deploy.sh: push_verified() ersetzt den push||push-Fallback.
  `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann, der
  Fallback griff deshalb nie und `caddy validate` pruefte die ALTE Datei
  -> gruene Abnahme bei unveraendertem Zustand (Symptom: 502 bei
  gesundem Backend)
- 39 Referenzen in 28 Dateien mitgezogen; lebende Doku nachgezogen
  (CLAUDE.md, ip-plan.md, cf-tunnel-mapping §0-Kopf, TP16.3/TP31-Prosa)
- GC-Compliance 140/140, 0 Drifts

Neue Funde (TP45-Prosa + CLAUDE.md):
- Die Core-Adressliste `nautobot` wird von GC nirgends gerendert -> ein
  Recovery von null erzeugt einen Core, in den Nautobot nicht mehr per
  SSH kommt. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
- Regel *24 (Literal) und *28 (Liste) sind Dubletten; der Literal steht
  bei 0 Paketen. Zusammenlegung bleibt offen.
- gc-manual-plan.py: /api/extras/job-log-entries/ liefert 404 (Nautobot
  3.x) -> bei gescheitertem Deploy fehlt die Fehlermeldung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 13:14:35 +02:00

140 lines
5.9 KiB
Python

#!/usr/bin/env python3
r"""`/ip firewall filter` von `crs326-core` als Daten nach Nautobot schreiben.
WARUM DATEN UND NICHT TEMPLATEZEILEN
------------------------------------
36 Firewallregeln fest ins Jinja zu schreiben hiesse: jede TP5-Etappe braucht
einen Template-Edit. Genau die Reibung, wegen der die Governance in Verruf
geraten ist. Hier landen die Regeln als **Liste im local_config_context_data**
des Core; das Template rendert sie nur noch. Eine neue Regel = ein Listeneintrag.
⚠️ **Das ist ein RE-BASELINE-Werkzeug, kein Sync.** Es liest den Ist-Stand vom
Geraet und erklaert ihn zum Soll. Blind ausgefuehrt macht es Drift-Erkennung
wertlos — es gaebe nie Drift, weil das Soll immer dem Ist nachlaeuft.
Darum: **nur nach einer bewusst durchgefuehrten, verifizierten Aenderung
laufen lassen**, nie als Cronjob und nie "zum Grunmachen" der Compliance.
Attributreihenfolge = alphabetisch, exakt wie RouterOS `/export` sie schreibt
(gleiche Konvention wie der Rest des Templates, s. dessen Kopfkommentar).
python3 nautobot/ingest-core-firewall.py # Dry-Run
python3 nautobot/ingest-core-firewall.py --apply
"""
import base64
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"
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"
# Felder, die RouterOS im Export NICHT schreibt (Laufzeit-/Interndaten).
# `hw-offload` gehoert dazu (2026-08-18 aufgeschlagen): die REST-API meldet es
# auf der defconf-fasttrack-Regel als true, `/ip firewall filter export` laesst
# es weg -- am Geraet gegengeprueft. Ohne diesen Eintrag meldet die Compliance
# ewige Falsch-Drift auf genau dieser einen Regel. Gleiche Klasse wie die
# `disabled=false`- und Boolean-Faelle weiter unten.
SKIP = {".id", "dynamic", "invalid", "bytes", "packets", "creation-time",
"hw-offload"}
def secret(key):
for line in SECRETS.read_text(errors="replace").splitlines():
line = line.strip().removeprefix("export ")
k, sep, v = line.partition("=")
if sep and k.strip() == key:
return v.strip().strip("'\"")
raise SystemExit(f"{key} nicht in {SECRETS}")
def nb(path, method="GET", payload=None):
tok = os.environ.get("NAUTOBOT_TOKEN") or sys.exit("NAUTOBOT_TOKEN nicht gesetzt")
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 {tok}", "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 device_rules(host, pw):
auth = base64.b64encode(f"admin:{pw}".encode()).decode()
req = urllib.request.Request(f"http://{host}/rest/ip/firewall/filter",
headers={"Authorization": f"Basic {auth}",
"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def is_true(val):
"""RouterOS-REST liefert Booleans als STRINGS ("true"/"false"). Ein
naives `if rule.get("dynamic")` ist damit fuer "false" wahr und filtert
alles weg — am 2026-08-17 genau so passiert (0 statt 36 Regeln)."""
return val is True or (isinstance(val, str) and val.lower() == "true")
def render(rule):
"""Ein REST-Objekt -> die `add ...`-Zeile, wie /export sie schreibt."""
parts = []
for key in sorted(rule):
if key in SKIP:
continue
val = rule[key]
# `/export` schreibt Default-Werte NICHT. REST liefert sie trotzdem mit.
# Konkret `disabled=false`: der Export laesst es weg, `disabled=yes` steht
# dagegen drin und ist semantisch wichtig. Ohne diese Zeile meldet die
# Compliance ewige Falsch-Drift auf genau einer Regel (2026-08-17 beim
# ersten Lauf der neuen Rule aufgeschlagen: 75/76).
if key == "disabled" and not is_true(val):
continue
# REST liefert Booleans als "true"/"false", /export schreibt "yes"/"no".
# Ohne diese Uebersetzung meldet die Compliance ewige Falsch-Drift auf
# `hw-offload` (2026-08-17 im Vergleich gegen den echten Export gefunden).
if isinstance(val, bool):
val = "yes" if val else "no"
elif isinstance(val, str) and val.lower() in ("true", "false"):
val = "yes" if val.lower() == "true" else "no"
val = str(val)
# RouterOS quotet NUR bei Leerzeichen. Nicht bei "!" (siehe
# `connection-nat-state=!dstnat`) und nicht bei Kommas
# (`dst-port=8123,1883,8883`) — beides steht unquoted im Export.
if " " in val:
val = f'"{val}"'
parts.append(f"{key}={val}")
return "add " + " ".join(parts)
def main():
apply_ = "--apply" in sys.argv
dev = nb(f"/api/dcim/devices/?name={DEVICE}&depth=1")["results"][0]
host = dev["primary_ip4"]["address"].split("/")[0]
rules = [r for r in device_rules(host, secret("CRS326_CORE_PASS"))
if not is_true(r.get("dynamic"))]
lines = [render(r) for r in rules]
print(f"== {DEVICE}: {len(lines)} statische Filterregeln ==")
for ln in lines:
print(" " + (ln[:150] + " ..." if len(ln) > 150 else ln))
ctx = dict(dev.get("local_config_context_data") or {})
if ctx.get("firewall_filter") == lines:
print("\n = unveraendert, nichts zu tun.")
return
ctx["firewall_filter"] = lines
if not apply_:
print(f"\nDRY-RUN — {len(lines)} Zeilen wuerden nach "
f"local_config_context_data.firewall_filter geschrieben. Mit --apply.")
return
nb(f"/api/dcim/devices/{dev['id']}/", "PATCH", {"local_config_context_data": ctx})
print(f"\n ~ geschrieben. Jetzt: gc-configplan.py --refresh-compliance")
if __name__ == "__main__":
main()