diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index 1547354..1bcae0d 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -1125,11 +1125,10 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". (sie tun nichts — ein reiner AP hat keine `forward`-Kette zu schützen). Empfehlung: **abräumen**, sonst schreibt man Werksmüll als Absicht in die SoT — dasselbe Argument wie bei `telnet` in TP16.1. (b) **branch:** 0 Regeln ist der korrekte Sollzustand und muss im Template auch als *leer* gerendert werden. - - 🔴 **Folge 2 (und die ist gewichtiger): `/ip firewall filter` ist NICHT abgedeckt.** Die TP5-Zeile - „läuft über Nautobot GC (`ip-firewall-*`-Features aus TP22)" ist damit **irreführend — es existiert nur - NAT.** Die 11 Etappe-2-Regeln und der kommende Deny-by-default sind **nicht drift-überwacht**: - „72/72 compliant" sagt über die Firewall **gar nichts**. ➡️ **Das gehört VOR TP5 Etappe 3**, denn eine - Drop-Regel, die niemand überwacht, ist genau die, die still verschwindet. + - ✅ **Folge 2 — GESCHLOSSEN (2026-08-17, TP26): `/ip firewall filter` hat jetzt eine Compliance-Rule.** + Vorher gab es nur `ip-firewall-nat`; „72/72" sagte über die Firewall gar nichts. Jetzt **76/76** + (eine neue Rule × 4 Geräte). `config_ordered=true` — bei Firewallregeln ist die Reihenfolge semantisch. + **Damit ist TP5 Etappe 3 entblockt.** - ⚠️ **`steering=`/Channel-Änderungen re-applien alle VAPs und werfen jeden Client kurz raus** (TP3.3 am 2026-08-13 gemessen: ap1 25→2, ap2 7→14). **Nicht während eines Calls deployen.** `/system backup` je AP vorher. - **Abh.:** TP22 ✅ (GC-Deploy-Pipeline). Übernimmt den offenen Rest von **TP3.3**, verwandt **TP3.2** (Coverage-Doku @@ -1210,10 +1209,11 @@ TP5 E1 ✅ ──► TP5 E2 ✅ ──► [mehrtägiges stilles Fenster] ── 3. ⏳ **Wartezeit, kein Arbeitsschritt:** das Log muss **mehrere Tage** still bleiben. 70 Sekunden Stille sind keine Aussage — es gibt Geräte, die einmal pro Woche funken. **Das ist das Gate, nicht eine Meinung.** 4. 🔜 **TP5 Etappe 3 — Drop scharf schalten.** Vorbedingungen: das stille Fenster · D2 (iPhone `.30.201` - ins MAIN-SSID, Hands-on Christoph) · **und neu: eine Compliance-Rule auf `/ip firewall filter`.** - 🔴 **Am 2026-08-17 festgestellt: die Filter-Kette ist NICHT drift-überwacht** — es gibt nur - `ip-firewall-nat`. Eine Deny-by-default-Regel ohne Drift-Erkennung ist genau die, die still verschwindet - und niemandem auffällt, bis etwas nicht mehr geht. **Siehe TP25 Schritt 6.** + ins MAIN-SSID, Hands-on Christoph). ✅ **Die dritte Vorbedingung ist erfüllt:** die Filter-Kette ist + seit 2026-08-17 drift-überwacht (TP26, `ip-firewall-filter`, 76/76) — vorher wäre der Deny-by-default + die einzige Regel im Netz gewesen, die niemand überwacht. + ⚠️ **Nach dem Setzen des Drops `nautobot/ingest-core-firewall.py --apply` laufen lassen**, sonst meldet + die Compliance die neue Regel als Drift. Das ist Absicht: das Re-Baseline ist ein bewusster Akt. 🛟 Auto-Revert-Scheduler davor (auf dem Core möglich, `device-mode` erlaubt scheduler — auf den hAPs nicht). 5. 🔜 **TP5 Etappe 4 — UNTRUSTED/GUEST Internet-only + MGMT isolieren** (`seal-lan` verengen → schaltet zugleich die 5 toten mDNS-`input`-Regeln scharf). **Mit im Paket:** TP7.1 (SSDP-Reflector für die diff --git a/nautobot/ingest-core-firewall.py b/nautobot/ingest-core-firewall.py new file mode 100644 index 0000000..dd9d89a --- /dev/null +++ b/nautobot/ingest-core-firewall.py @@ -0,0 +1,133 @@ +#!/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.30:8080") +DEVICE = "crs326-core" +SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env" + +# Felder, die RouterOS im Export NICHT schreibt (Laufzeit-/Interndaten). +SKIP = {".id", "dynamic", "invalid", "bytes", "packets", "creation-time"} + + +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() diff --git a/nautobot/mikrotik_routeros.j2 b/nautobot/mikrotik_routeros.j2 index 9b1e718..3cd6f01 100644 --- a/nautobot/mikrotik_routeros.j2 +++ b/nautobot/mikrotik_routeros.j2 @@ -146,6 +146,17 @@ /ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16 /ip firewall nat add action=masquerade chain=srcnat comment="defconf: masquerade" ipsec-policy=out,none out-interface-list=WAN +# [7b] FIREWALL FILTER (TP26, 2026-08-17). Kommt als DATEN aus +# local_config_context_data.firewall_filter, nicht als Templatezeilen — +# 36 Regeln fest hier einzutragen hiesse, dass jede TP5-Etappe einen +# Template-Edit braucht. Befuellt/re-baselined von +# nautobot/ingest-core-firewall.py (bewusst manuell, nie automatisch: +# ein Soll, das dem Ist nachlaeuft, erkennt keine Drift mehr). +# Reihenfolge ist semantisch — die ComplianceRule steht auf config_ordered. +{% for line in obj.get_config_context().get("firewall_filter", []) %} +/ip firewall filter {{ line }} +{% endfor %} + # [8] ROUTEN zu den WG-Spokes (Default-Route kommt dynamisch vom WAN-DHCP-Client) /ip route add comment="TP10: FM spoke -> wg-hub" dst-address=10.101.0.0/24 gateway=wg-hub /ip route add comment="TP10: DW spoke -> wg-hub" dst-address=10.95.10.0/24 gateway=wg-hub