Die 54 statischen Reservierungen des Cores standen in KEINEM Soll: das Template rendert kein einziges /ip dhcp-server lease, und die Sektion stand unter keiner ComplianceRule. Ein GC-Recovery von null haette einen Core ohne Reservierungen erzeugt, in dem jedes IoT-Geraet in den Pool faellt -- und weil Compliance nur prueft, was gerendert wird, waere die Zahl dabei gruen geblieben. Dieselbe Luecke wie die handgelaufene `nautobot`-Adressliste. - mikrotik_routeros.j2 [5b]: rendert local_config_context_data.dhcp_leases, gleiches Idiom wie firewall_filter. - tp45-leases.py: seed/move/check. Zeilen werden verbatim uebernommen und nur im address=-Token getauscht -- das erhaelt Quoting und die 14 client-id-Felder. Reihenfolge bleibt die Geraete-Reihenfolge (Einfuege-, nicht numerisch). - ingest-dhcp-lease-rule.py: Feature+Rule `dhcp-lease` auf RouterOS, config_ordered=False. Noch KEIN Geraetewrite -- der Ist-Stand wurde als Soll gesetzt (Re-Baseline). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
133 lines
5.5 KiB
Python
133 lines
5.5 KiB
Python
#!/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()
|