TP26: /ip firewall filter ist drift-ueberwacht — 76/76, TP5 Etappe 3 entblockt
Bis heute gab es nur ip-firewall-nat. "72/72 compliant" sagte ueber die
Firewall gar nichts — und TP5 Etappe 3 wollte dort einen Deny-by-default
setzen, der dann als einzige Regel im Netz unbeobachtet gewesen waere.
Umsetzung datengetrieben statt als Templatezeilen: die 36 Regeln liegen in
local_config_context_data.firewall_filter, das Template rendert sie nur.
36 Regeln fest ins Jinja zu schreiben hiesse, dass jede TP5-Etappe einen
Template-Edit braucht — genau die Reibung, wegen der die Governance in
Verruf geraten ist. Neue Regel = ein Listeneintrag.
ingest-core-firewall.py ist ein RE-BASELINE-Werkzeug, kein Sync: es erklaert
den Ist-Stand zum Soll und macht blind ausgefuehrt die Drift-Erkennung
wertlos. Darum nur nach einer bewusst verifizierten Aenderung laufen lassen.
Drei Formatfallen beim Nachbau des /export-Formats, alle durch Vergleich mit
dem echten Geraete-Export bzw. durch die neue Rule selbst gefunden:
- REST liefert Booleans als STRINGS. `if rule.get("dynamic")` ist fuer
"false" wahr und filterte erst alle 36 Regeln weg (0 statt 36).
- REST sagt hw-offload=true, /export schreibt yes. Und "!" bzw. Kommas
werden NICHT gequotet (connection-nat-state=!dstnat, dst-port=8123,1883).
Quoting nur bei Leerzeichen.
- /export laesst Default-Werte weg: `disabled=no` steht nie drin,
`disabled=yes` dagegen schon. Das schlug beim ersten Lauf der neuen Rule
als 75/76 auf — die Rule hat ihren Zweck damit sofort erfuellt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
133
nautobot/ingest-core-firewall.py
Normal file
133
nautobot/ingest-core-firewall.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user