#!/usr/bin/env python3 r"""TP45 Schicht B β€” die DHCP-Reservierungen des Cores ins Soll heben. DIE LUECKE, DIE DAS SCHLIESST ----------------------------- `nautobot/mikrotik_routeros.j2` rendert bis 2026-09-21 **kein einziges** `/ip dhcp-server lease`. Die 54 statischen Reservierungen des Cores stammen aus dem Einmal-Skript `routeros/tp21-core-dhcp-from-ap1.rsc` (Core-Cutover 2026-08-06) und stehen in **keinem** Soll. Folge: ein GC-Recovery von null erzeugt einen Core ohne Reservierungen β€” jedes IoT-Geraet faellt in den Pool, und weil Compliance nur prueft, was gerendert wird, bliebe die Zahl dabei gruen. Dieselbe Klasse wie die `nautobot`-Adressliste (s. CLAUDE.md): "was auf dem Geraet existiert, aber nicht im Template, ist eine Recovery-Luecke". Zusaetzlich war keine einzige Reservierung drift-ueberwacht. DAS SOLL -------- Die Zeilen liegen wie `firewall_filter` als LISTE von Strings in `local_config_context_data.dhcp_leases` auf `crs326-core` und werden vom Template mit `/ip dhcp-server lease {{ line }}` gerendert. πŸ”΄ **Zeilenform ist die RouterOS-`/export`-Form, alphabetisch, ohne fuehrenden Pfad** (genau wie `firewall_filter`): add address=10.40.30.244 comment=NukiBridge mac-address=24:62:AB:B1:63:F4 server=dhcp-v30 πŸ”΄ **Die REIHENFOLGE wird erhalten, nicht sortiert.** Am Geraet steht die Sektion in Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet) β€” `:export` gibt sie so aus, und das Backup wird textuell verglichen. Ein numerisch sortiertes Soll waere ein Dauer-Falsch-Drift. πŸ”΄ **Zeilen werden NICHT neu gebaut, sondern uebernommen und nur im `address=`-Token geaendert.** Damit bleiben Quoting (`comment="P750W / P750W"` vs `comment=NukiBridge`) und die 14 `client-id`-Felder automatisch korrekt β€” beides nachzubauen waere eine Fehlerquelle ohne Nutzen. WARUM DER LESEWEG UEBER NAUTOBOT GEHT ------------------------------------- Quelle fuer `seed` ist das GC-`backup_config` des Cores (taeglich 03:30 UTC bzw. per `gc-configplan.py --refresh-compliance`). Kein Geraete-Credential noetig. ⚠️ `seed` ist damit ein RE-BASELINE und darf **nur** laufen, solange der Ist-Stand der gewollte ist (Schicht B1). Fuer die geplante Aenderung gibt es `move`, das auf dem **bestehenden Soll** rechnet β€” ein Soll, das dem Ist nachlaeuft, erkennt keine Drift mehr (genau der Grund, warum `ingest-core-firewall.py` manuell bleibt). python3 nautobot/tp45-leases.py check # Soll vs. Backup-Diff python3 nautobot/tp45-leases.py seed # Dry-Run python3 nautobot/tp45-leases.py seed --apply # B1 python3 nautobot/tp45-leases.py seed --force --apply # Neuschnitt, ueberschreibt python3 nautobot/tp45-leases.py move # Dry-Run, s. MOVES python3 nautobot/tp45-leases.py move --apply # B2 Token: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), NAUTOBOT_SUPERUSER_API_TOKEN. Der Wert wird nie ausgegeben. """ import json import pathlib import re import sys import urllib.error import urllib.request NB = "http://10.40.20.154:8080" DEVICE = "crs326-core" KEY = "dhcp_leases" PREFIX = "/ip dhcp-server lease " REPO = pathlib.Path(__file__).resolve().parent.parent YML = REPO / "docs/nas-vlan20/stacks/210-nautobot.yml" # --------------------------------------------------------------------------- # Der Adressumzug (Schicht B2). Schluessel = MAC, Wert = (ist, soll). # Quelle der Soll-Spalte: docs/teilprojekte.md ### TP45 / der Plan. # Steht hier als Tabelle und nicht als Ableitung β€” der Umzug ist eine Absicht, # kein Rechenergebnis. # --------------------------------------------------------------------------- MOVES = { # fest verbaute Infrastruktur -> .10-.19 "24:62:AB:B1:63:F4": ("10.40.30.244", "10.40.30.11"), # NukiBridge "4C:EB:BD:E3:25:F2": ("10.40.30.5", "10.40.30.13"), # P750W (Etikettendrucker) "54:F2:9F:12:51:F0": ("10.40.30.8", "10.40.30.14"), # DTUBI-414392337823 # Shelly-Block .151-.176 (s1 -> .151 ... s26 -> .176) "E4:B0:63:D7:C7:A4": ("10.40.30.208", "10.40.30.151"), # shelly-s1 "28:37:2F:36:F4:7C": ("10.40.30.209", "10.40.30.152"), # shelly-s2 "E4:B0:63:E4:EF:7C": ("10.40.30.210", "10.40.30.153"), # shelly-s3 "E4:B0:63:E4:0F:30": ("10.40.30.211", "10.40.30.154"), # shelly-s4 "D8:85:AC:0C:5C:C8": ("10.40.30.212", "10.40.30.155"), # shelly-s5 "D0:CF:13:D8:18:FC": ("10.40.30.213", "10.40.30.156"), # shelly-s6 "D0:CF:13:D9:94:E8": ("10.40.30.214", "10.40.30.157"), # shelly-s7 "E4:B0:63:FA:FD:E0": ("10.40.30.215", "10.40.30.158"), # shelly-s8 "E8:F6:0A:61:88:08": ("10.40.30.216", "10.40.30.159"), # shelly-s9 "E4:B0:63:FC:57:E4": ("10.40.30.217", "10.40.30.160"), # shelly-s10 "E4:B0:63:FA:6C:00": ("10.40.30.218", "10.40.30.161"), # shelly-s11 "48:F6:EE:C8:E6:AC": ("10.40.30.219", "10.40.30.162"), # shelly-s12 "48:F6:EE:DD:6A:F4": ("10.40.30.220", "10.40.30.163"), # shelly-s13 "E4:B0:63:FC:59:48": ("10.40.30.221", "10.40.30.164"), # shelly-s14 "E4:B0:63:FA:D5:34": ("10.40.30.222", "10.40.30.165"), # shelly-s15 "10:20:BA:FA:F5:40": ("10.40.30.223", "10.40.30.166"), # shelly-s16 "10:20:BA:FB:6D:64": ("10.40.30.224", "10.40.30.167"), # shelly-s17 "48:F6:EE:CC:16:E4": ("10.40.30.225", "10.40.30.168"), # shelly-s18 "10:20:BA:FB:70:FC": ("10.40.30.226", "10.40.30.169"), # shelly-s19 "98:A3:16:7C:5F:EC": ("10.40.30.206", "10.40.30.170"), # shelly-s20 "20:6E:F1:01:F9:F8": ("10.40.30.207", "10.40.30.171"), # shelly-s21 "A0:85:E3:C8:65:34": ("10.40.30.202", "10.40.30.172"), # shelly-s22 "AC:15:18:7B:62:20": ("10.40.30.203", "10.40.30.173"), # shelly-s23 "14:33:5C:D8:2D:7C": ("10.40.30.204", "10.40.30.174"), # shelly-s24 "98:A3:16:79:38:50": ("10.40.30.205", "10.40.30.175"), # shelly-s25 "98:A3:16:7A:A3:54": ("10.40.30.233", "10.40.30.176"), # shelly-s26 (bad luefter) # App-Block .177-.199 "28:DB:A7:09:7B:54": ("10.40.30.241", "10.40.30.177"), # NukiSmartlock "B8:F8:62:09:F2:04": ("10.40.30.227", "10.40.30.178"), # SwitchBot-floor-lamp "98:F4:AB:2C:A7:88": ("10.40.30.228", "10.40.30.179"), # Twinkly-2CA789 "A4:F0:0F:5F:D0:EC": ("10.40.30.236", "10.40.30.190"), # lichterkette "24:DC:C3:9B:89:64": ("10.40.30.237", "10.40.30.191"), # lichterkette-mini "00:24:E4:40:2A:72": ("10.40.30.230", "10.40.30.195"), # Withings_waage "A4:7E:FA:18:2C:64": ("10.40.30.231", "10.40.30.196"), # Withings_blutdruck # .150 (Ender3V3KE) und .180 (espressif/Airfryer) bleiben, wo sie sind. } # CCU-Leiche: VLAN30-Reservierung `pve-homematic-raspi-ccu`, status=waiting, # last-seen=never. Die CCU ist live in VLAN20 (10.40.20.232, ARP+HTTP belegt, # Caddys ccu.bf proxyt dorthin) β€” dieser Eintrag ist ein Zombie aus dem # Core-Cutover. Er wird ENTFERNT, nicht umgezogen. REMOVE = {"BC:24:11:8E:0A:57"} # SONOFF-NSPanelPro hat KEINE Reservierung, nur eine dynamische Lease auf .249. # Wird in B2 per make-static + set address gepinnt. Zeile hier nur als Soll. ADDS = [ "add address=10.40.30.12 client-id=1:7c:88:99:6:7:ec comment=SONOFF-NSPanelPro " "mac-address=7C:88:99:06:07:EC server=dhcp-v30", ] def token(): for line in YML.read_text(errors="replace").splitlines(): m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line) if m: return m.group(1) raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml") def nb(path, method="GET", payload=None): req = urllib.request.Request( NB + path, method=method, data=json.dumps(payload).encode() if payload is not None else None, headers={"Authorization": "Token " + token(), "Content-Type": "application/json", "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=60) as r: raw = r.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: sys.exit("!! %s %s -> %s\n %s" % (method, path, e.code, e.read().decode()[:600])) def device(): return nb("/api/dcim/devices/?name=" + DEVICE)["results"][0] def backup_lines(): """Die `/ip dhcp-server lease`-Zeilen des Cores aus dem GC-Backup, in Geraete-Reihenfolge.""" rows = nb("/api/plugins/golden-config/golden-config/?depth=1&limit=0")["results"] for r in rows: dev = r.get("device") dev = dev.get("name") if isinstance(dev, dict) else dev if dev == DEVICE: cfg = r.get("backup_config") or "" when = (r.get("backup_last_success_date") or "?")[:19] # πŸ”΄ len(PREFIX), NICHT l[1:]: das Argument-Teil ist ohne den ganzen Befehl # zu speichern β€” genau wie firewall_filter, wo `add action=…` steht und # das Template `/ip firewall filter ` davorsetzt. Ein blosses l[1:] liess # `ip dhcp-server lease add …` stehen und erzeugte im gerenderten Soll # den Befehl DOPPELT (`/ip dhcp-server lease ip dhcp-server lease add …`). # Gefunden am 2026-09-21 an der ersten Compliance-Zahl nach dem Einbau: # 144/145 statt 145/145. return ([l[len(PREFIX):] for l in cfg.splitlines() if l.startswith(PREFIX)], when) raise SystemExit("kein GC-Eintrag fΓΌr " + DEVICE) def mac_of(line): m = re.search(r"\bmac-address=(\S+)", line) return m.group(1).upper() if m else "" def set_address(line, ip): new, n = re.subn(r"(?<=^add address=)\S+", ip, line) if n != 1: raise SystemExit("address-Token nicht eindeutig in: " + line) return new def show(old, new): print("== Diff (%d -> %d Zeilen) ==" % (len(old), len(new))) om, nm = {mac_of(l): l for l in old}, {mac_of(l): l for l in new} for mac in sorted(set(om) | set(nm)): a, b = om.get(mac), nm.get(mac) if a == b: continue if a is None: print(" + %s" % b[:150]) elif b is None: print(" - %s" % a[:150]) else: print(" ~ %s -> %s" % (re.search(r"address=(\S+)", a).group(1), re.search(r"address=(\S+)", b).group(1))) print(" %s" % b[:150]) def main(): argv = sys.argv[1:] if not argv or argv[0] not in ("seed", "move", "check"): raise SystemExit(__doc__.strip().splitlines()[-2].strip()) cmd, apply_ = argv[0], "--apply" in argv dev = device() ctx = dict(dev.get("local_config_context_data") or {}) soll = list(ctx.get(KEY) or []) if cmd == "check": ist, when = backup_lines() print("== check: %s / %s (Backup %s, %d Zeilen) ==" % (DEVICE, KEY, when, len(ist))) if not soll: print(" Soll ist LEER β€” Reservierungen stehen in keinem Soll.") return show(soll, ist) print(" (Diff oben: '-' = nur im Soll, '~' = Adresse weicht ab)") return if cmd == "seed": if soll and "--force" not in argv: raise SystemExit("Soll ist NICHT leer (%d Zeilen) β€” `seed` ist ein Re-Baseline. " "Fuer eine geplante Aenderung `move` benutzen; fuer einen " "bewussten Neuschnitt `--force`." % len(soll)) new, when = backup_lines() print("== seed aus Backup %s (%d Zeilen)%s ==" % (when, len(new), " [--force, ueberschreibt %d Zeilen]" % len(soll) if soll else "")) else: if not soll: raise SystemExit("Soll ist leer β€” erst `seed` laufen lassen (Schicht B1).") new, unknown = [], set() seen = set() for line in soll: mac = mac_of(line) if mac in REMOVE: seen.add(mac) continue if mac in MOVES: ist_ip, soll_ip = MOVES[mac] if re.search(r"address=(\S+)", line).group(1) != ist_ip: raise SystemExit("%s: Ist-Adresse im Soll ist nicht %s β€” Soll und MOVES " "auseinander. Zeile: %s" % (mac, ist_ip, line[:120])) line = set_address(line, soll_ip) seen.add(mac) new.append(line) for mac in list(MOVES) + list(REMOVE): if mac not in seen: unknown.add(mac) if unknown: raise SystemExit("MOVES/REMOVE nennen MACs, die im Soll fehlen: %s" % ", ".join(sorted(unknown))) for add in ADDS: if mac_of(add) not in {mac_of(l) for l in new}: new.append(add) print("== move: %d Adressen verschoben, %d entfernt, %d ergaenzt ==" % (len(MOVES), len(REMOVE), len(ADDS))) show(soll, new) if not apply_: print("\nDRY-RUN β€” mit --apply nach local_config_context_data.%s schreiben." % KEY) return ctx[KEY] = new nb("/api/dcim/devices/%s/" % dev["id"], "PATCH", {"local_config_context_data": ctx}) print("\n ~ %d Zeilen nach local_config_context_data.%s geschrieben." % (len(new), KEY)) print(" Geraet zieht nach per: python3 nautobot/gc-configplan.py --refresh-compliance") if __name__ == "__main__": main()