From 177ba100eb4ce8fc19a0120971a8a7ef75b581f5 Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Tue, 15 Sep 2026 19:23:59 +0200 Subject: [PATCH] TP38: OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall Template mikrotik_routeros.j2: ether23 auf core+branch als OOB-Port (pvid=99, admit-only-untagged-and-priority-tagged) + VLAN99 untagged=ether23. Branch bekommt einen deny-by-default-Input-Filter (8 Regeln, src-Rahmen spiegelt /ip service available-from), Core eine Regel "OOB->core mgmt". Neue Skripte: ingest-tp38-oob.py (seedet firewall_filter in beiden local_config_context_data) und der gc-configplan.py-Job tp38-oob (Deploy). ConfigPlans angelegt+approved, Deploy steht noch aus. Co-Authored-By: Claude Haiku 4.5 --- nautobot/gc-configplan.py | 62 ++++++++++++++++++++ nautobot/ingest-tp38-oob.py | 105 ++++++++++++++++++++++++++++++++++ nautobot/mikrotik_routeros.j2 | 24 ++++++-- 3 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 nautobot/ingest-tp38-oob.py diff --git a/nautobot/gc-configplan.py b/nautobot/gc-configplan.py index ceff2a2..55722fd 100644 --- a/nautobot/gc-configplan.py +++ b/nautobot/gc-configplan.py @@ -353,10 +353,72 @@ def job_tp42_dhcp_domain(): return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")] +def job_tp38_oob(): + """TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall. + + Zwei Geraete, zwei config_sets: + * branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln + (deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das + Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT: + die laufende Nautobot-SSH-Session (10.40.20.30) matcht auf Regel 1 (established), + bevor Regel 8 (drop) greift. + * core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit + place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und + waere wirkungslos + non-compliant). + + KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der + VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot + (10.40.20.30), das in jeder gesetzten Firewall-Freigabe enthalten bleibt. + """ + bridge_set = ( + ':do { /interface bridge port set [find interface=ether23] pvid=99 ' + 'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } ' + 'on-error={ :log info "tp38: ether23 set geworfen" }' + ) + vlan_set = ( + ':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } ' + 'on-error={ :log info "tp38: vlan99 untagged geworfen" }' + ) + + branch_fw = "\n".join([ + 'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked', + 'add action=drop chain=input comment="TP38: invalid" connection-state=invalid', + 'add action=accept chain=input comment="TP38: icmp" protocol=icmp', + 'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp', + 'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16', + 'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12', + 'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP', + 'add action=drop chain=input comment="TP38: input deny-by-default"', + ]) + branch_set = "\n".join([ + bridge_set, + vlan_set, + *[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()], + ':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]' + ' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")', + ]) + + core_set = "\n".join([ + bridge_set, + vlan_set, + '/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" ' + 'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp ' + 'place-before=[find where log-prefix=TP5-INPUT-WOULD-DROP]', + ':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]' + ' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])', + ]) + + return [ + (["crs326-branch"], branch_set, "TP38-oob-branch"), + (["crs326-core"], core_set, "TP38-oob-core"), + ] + + JOBS = { "drift-cleanup": job_drift_cleanup, "mgmt-acl": job_mgmt_acl, "tp42-dhcp-domain": job_tp42_dhcp_domain, + "tp38-oob": job_tp38_oob, } diff --git a/nautobot/ingest-tp38-oob.py b/nautobot/ingest-tp38-oob.py new file mode 100644 index 0000000..8c80d72 --- /dev/null +++ b/nautobot/ingest-tp38-oob.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +r"""TP38: OOB-Port + Input-Firewall als Daten nach Nautobot schreiben. + +Zwei getrennte Aenderungen, ein Skript: + +1. BRANCH (crs326-branch): `firewall_filter` im local_config_context_data ANLEGEN + (8 Input-Regeln, deny-by-default). Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12 + spiegelt bewusst das `available-from` der /ip service-Zeilen — kein neues + Fenster, nur stateful + deny-by-default + WOULD-DROP-Log. +2. CORE (crs326-core): EINE Regel "TP38: OOB->core mgmt" in die bestehende + firewall_filter-Liste EINFUEGEN (vor log/drop), damit der OOB-Port (untagged + ether23, VLAN99) auch den Core selbst verwalten kann. + +Attributreihenfolge = alphabetisch, exakt wie RouterOS `/export` sie schreibt +(gleiche Konvention wie ingest-core-firewall.py). + + python3 nautobot/ingest-tp38-oob.py # Dry-Run + python3 nautobot/ingest-tp38-oob.py --apply +""" +import json +import os +import sys +import urllib.request + +NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080") + +BRANCH_RULES = [ + 'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked', + 'add action=drop chain=input comment="TP38: invalid" connection-state=invalid', + 'add action=accept chain=input comment="TP38: icmp" protocol=icmp', + 'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp', + 'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16', + 'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12', + 'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP', + 'add action=drop chain=input comment="TP38: input deny-by-default"', +] + +CORE_NEW_RULE = 'add action=accept chain=input comment="TP38: OOB->core mgmt" dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp' + + +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 get_device(name): + return nb(f"/api/dcim/devices/?name={name}&depth=1")["results"][0] + + +def patch_device(dev, ctx, label): + dev_id = dev["id"] + nb(f"/api/dcim/devices/{dev_id}/", "PATCH", {"local_config_context_data": ctx}) + print(f" ~ {label} geschrieben.") + + +def main(): + apply_ = "--apply" in sys.argv + + # --- BRANCH --- + branch = get_device("crs326-branch") + bctx = dict(branch.get("local_config_context_data") or {}) + if bctx.get("firewall_filter") == BRANCH_RULES: + print("== branch: firewall_filter unveraendert ==") + else: + bctx["firewall_filter"] = BRANCH_RULES + print(f"== branch: {len(BRANCH_RULES)} Input-Regeln ==") + for ln in BRANCH_RULES: + print(" " + ln) + if apply_: + patch_device(branch, bctx, "branch firewall_filter") + + # --- CORE --- + core = get_device("crs326-core") + cctx = dict(core.get("local_config_context_data") or {}) + ff = list(cctx.get("firewall_filter") or []) + if CORE_NEW_RULE in ff: + print("\n== core: Regel schon vorhanden ==") + else: + # vor der Log-Regel einsortieren (die sitzt direkt vor dem finalen drop). + insert_at = len(ff) + for i, ln in enumerate(ff): + if "log-prefix=TP5-INPUT-WOULD-DROP" in ln: + insert_at = i + break + ff.insert(insert_at, CORE_NEW_RULE) + cctx["firewall_filter"] = ff + print(f"\n== core: fuege OOB-Regel an Position {insert_at} ein ==") + print(" " + CORE_NEW_RULE) + if apply_: + patch_device(core, cctx, "core firewall_filter") + + if not apply_: + print("\nDRY-RUN — mit --apply schreiben. Danach: " + "push-gc-template.py + gc-configplan.py --refresh-compliance") + + +if __name__ == "__main__": + main() diff --git a/nautobot/mikrotik_routeros.j2 b/nautobot/mikrotik_routeros.j2 index b3d1e77..bfa6ece 100644 --- a/nautobot/mikrotik_routeros.j2 +++ b/nautobot/mikrotik_routeros.j2 @@ -73,9 +73,11 @@ /interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30 /interface bridge port add bridge=bridge comment=defconf interface=ether15 /interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100 -{% for n in range(17, 25) %} +{% for n in range(17, 23) %} /interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} {% endfor %} +/interface bridge port add bridge=bridge comment="TP38-OOB-MGMT" frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99 +/interface bridge port add bridge=bridge comment=defconf interface=ether24 /interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1 /interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2 # ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4). @@ -94,7 +96,7 @@ /interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40 /interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50 /interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90 -/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge vlan-ids=99 +/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge untagged=ether23 vlan-ids=99 /interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100 # [1] INTERFACE-LISTS @@ -422,9 +424,20 @@ /system ntp client servers add address=10.40.99.1 /ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1 +# INPUT-FIREWALL (TP38, 2026-09-15) — deny-by-default fuer die Mgmt-Ebene (10.40.99.2). +# Der Branch ist L2-only: ALLE Mgmt-Anfragen kommen auf der einen vlan99-SVI an, egal ob +# VLAN10 (ueber den Core geroutet), cf-mesh (100.96.0.0/12) oder der OOB-Port (untagged +# ether23, 10.40.99.x). Deshalb matcht die Quelle per src-address, nicht per in-interface. +# Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12 spiegelt bewusst das `available-from` der +# /ip service-Zeilen — kein neues Fenster, nur stateful + deny-by-default + WOULD-DROP-Log. +{% for line in obj.get_config_context().get("firewall_filter", []) %} +/ip firewall filter {{ line }} +{% endfor %} + # L2-FABRIC — wie beim Core ein Literal-Block (Begruendung dort). -# ether9-23 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) — +# ether9-22 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) — # bewusst so abgebildet, damit eine spaetere Bereinigung als Drift sichtbar wird. +# ether23 ist seit TP38 der OOB-MGMT-Port (untagged VLAN99), s. unten. /interface bridge port add bridge=bridge comment=defconf interface=ether1 /interface bridge port add bridge=bridge comment=defconf interface=ether2 /interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10 @@ -433,9 +446,10 @@ /interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40 /interface bridge port add bridge=bridge comment=defconf interface=ether7 pvid=20 /interface bridge port add bridge=bridge comment=defconf interface=ether8 -{% for n in range(9, 24) %} +{% for n in range(9, 23) %} /interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20 {% endfor %} +/interface bridge port add bridge=bridge comment="TP38-OOB-MGMT" frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99 /interface bridge port add bridge=bridge comment=defconf interface=ether24 /interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus1 /interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2 @@ -445,7 +459,7 @@ /interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40 /interface bridge vlan add bridge=bridge comment=UNTRUSTED tagged=ether1,ether24,bridge vlan-ids=50 /interface bridge vlan add bridge=bridge comment=GUEST tagged=ether1,ether24,bridge vlan-ids=90 -/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge vlan-ids=99 +/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge untagged=ether23 vlan-ids=99 {% elif obj.role.name == "Wireless AP" %} {% set ap = obj.get_config_context()["ap"] %} {% set mgmt_host = obj.primary_ip4.host %}