#!/usr/bin/env python3 """ ComplianceRule `dhcp-lease` anlegen (idempotent). Die Luecke, die das schliesst ----------------------------- `nautobot/mikrotik_routeros.j2` rendert seit dem 2026-09-21 den Abschnitt `/ip dhcp-server lease` (TP45 Schicht B, aus `local_config_context_data.dhcp_leases`). Gerendert heisst aber noch nicht bewacht: ohne ComplianceRule erzeugt der Block **keine** Compliance-Eintraege, und die Gesamtzahl bliebe unauffaellig. Genau die Falle aus CLAUDE.md — "eine gleichbleibende Compliance-Zahl nach dem Hinzufuegen eines Geraets ist ein BEFUND, kein Erfolg". Konkret: von 54 live existierenden Reservierungen war vorher **keine einzige** drift-ueberwacht. Verschiebt jemand eine Adresse von Hand am Geraet, meldet Golden Config "compliant", weil die Sektion unter keiner Rule steht. Analogie: TP26 fuer `/ip firewall filter` (25 Regeln blind), `ingest-queue-rule.py` fuer `/queue`, `ingest-wifi-access-list-rule.py` fuer `/interface wifi access-list`, `ingest-logging-rule.py` fuer `/system logging`. export NAUTOBOT_TOKEN=... # aus docs/nas-vlan20/stacks/210-nautobot.yml python3 nautobot/ingest-dhcp-lease-rule.py # Dry-Run python3 nautobot/ingest-dhcp-lease-rule.py --apply """ import json import os import sys import urllib.error import urllib.parse import urllib.request BASE = "http://10.40.20.154:8080/api" TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") APPLY = "--apply" in sys.argv FEATURE = "dhcp-lease" # ⚠️ Nautobot begrenzt die Feature-Beschreibung auf 200 Zeichen (400 sonst). BESCHREIBUNG = ("TP45 Schicht B: DHCP-Reservierungen (/ip dhcp-server lease). Bis 2026-09-21 " "unter keiner Rule -- die 54 Reservierungen waren drift-blind. Gleicher " "blinder Fleck wie TP26 fuer /ip firewall filter.") # Praefix, nicht die ganze Sektion: `/ip dhcp-server lease` faengt nur die Reservierungen. # Ein blankes `/ip dhcp-server` wuerde die Server-, Network- und Option-Zeilen mitfangen, # die eigene Rules haben (bzw. haben sollen). MATCH = "/ip dhcp-server lease" PLATTFORM = "RouterOS" def req(method, path, data=None): url = path if path.startswith("http") else BASE + path body = json.dumps(data).encode() if data is not None else None r = urllib.request.Request(url, data=body, method=method) r.add_header("Authorization", "Token " + TOKEN) r.add_header("Content-Type", "application/json") r.add_header("Accept", "application/json") try: with urllib.request.urlopen(r, timeout=60) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: sys.exit("!! %s %s -> %s\n %s" % (method, url, e.code, e.read().decode()[:600])) def eins(pfad, **filter): q = urllib.parse.urlencode(filter) res = req("GET", "%s?%s" % (pfad, q)).get("results", []) if len(res) > 1: sys.exit("FEHLER: %s?%s liefert %d Treffer, erwartet 0 oder 1" % (pfad, q, len(res))) return res[0] if res else None def main(): if not TOKEN: sys.exit("NAUTOBOT_TOKEN fehlt.") print("Modus:", "APPLY" if APPLY else "DRY-RUN (nichts wird geschrieben)") plat = eins("/dcim/platforms/", name=PLATTFORM) if not plat: sys.exit("Plattform %r nicht gefunden." % PLATTFORM) print("Plattform : %s (%s)" % (plat["display"], plat["id"])) feat = eins("/plugins/golden-config/compliance-feature/", name=FEATURE) if feat: print("Feature : existiert bereits (%s)" % feat["id"]) else: print("Feature : anlegen -> %s" % FEATURE) if APPLY: feat = req("POST", "/plugins/golden-config/compliance-feature/", {"name": FEATURE, "slug": FEATURE, "description": BESCHREIBUNG}) print(" angelegt (%s)" % feat["id"]) if not feat: print("\nDry-Run: ohne Feature kann die Rule nicht geprueft werden. Mit --apply erneut.") return rule = eins("/plugins/golden-config/compliance-rule/", feature=feat["id"], platform=plat["id"]) if rule: print("Rule : existiert bereits (%s), match_config=%r, config_ordered=%s" % (rule["id"], rule.get("match_config"), rule.get("config_ordered"))) if rule.get("match_config") != MATCH: print(" ⚠️ match_config weicht ab -- erwartet %r" % MATCH) if rule.get("config_ordered"): print(" ⚠️ config_ordered=True -- erwartet False (s. u.)") return nutzlast = { "feature": feat["id"], "platform": plat["id"], "description": BESCHREIBUNG, # 🔴 False, NICHT True: die Reservierungs-Sektion steht am Geraet in # Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet), nicht numerisch. # Bei config_ordered=True waere jede Umsortierung durch RouterOS ein # Falsch-Drift. Die Reihenfolge ist hier auch semantisch bedeutungslos — # anders als bei /ip firewall filter, wo sie ueber Gewinnen/Verlieren entscheidet. "config_ordered": False, "config_type": "cli", "match_config": MATCH, "custom_compliance": False, "config_remediation": False, } print("Rule : anlegen -> %s auf %s, match_config=%r, config_ordered=False" % (FEATURE, PLATTFORM, MATCH)) if APPLY: r = req("POST", "/plugins/golden-config/compliance-rule/", nutzlast) print(" angelegt (%s)" % r["id"]) else: print(" Nutzlast: %s" % json.dumps(nutzlast)) if __name__ == "__main__": main()