#!/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 python3 nautobot/tp45-leases.py revert # Dry-Run, s. REVERT (nur Shellys) python3 nautobot/tp45-leases.py revert --apply # Ruecknahme 2026-09-21 python3 nautobot/tp45-leases.py restore # Dry-Run: ALLES zurueck auf vor B2 python3 nautobot/tp45-leases.py restore --apply # Vollruecknahme 2026-09-21 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. } # --------------------------------------------------------------------------- # RUECKNAHME 2026-09-21 (Christoph-Direktive, sofort): NUR die Shellys. # # Die 26 Shelly-Reservierungen gehen auf ihre ALTEN Adressen zurueck. Grund: # Home Assistant hat sie mit den alten Adressen eingebunden -- der Umzug haette # die HA-Integrationen zerrissen. Die uebrigen 10 Umzuege (NukiBridge, P750W, # DTUBI, NukiSmartlock, SwitchBot, Twinkly, Lichterketten, Withings) BLEIBEN. # # Bewusst als eigene Menge und nicht als Bereichs-Ableitung: welche MAC eine # Shelly ist, steht nur im Kommentar der Soll-Zeile, nicht im MOVES-Key. # --------------------------------------------------------------------------- SHELLY = { "E4:B0:63:D7:C7:A4", "28:37:2F:36:F4:7C", "E4:B0:63:E4:EF:7C", "E4:B0:63:E4:0F:30", "D8:85:AC:0C:5C:C8", "D0:CF:13:D8:18:FC", "D0:CF:13:D9:94:E8", "E4:B0:63:FA:FD:E0", "E8:F6:0A:61:88:08", "E4:B0:63:FC:57:E4", "E4:B0:63:FA:6C:00", "48:F6:EE:C8:E6:AC", "48:F6:EE:DD:6A:F4", "E4:B0:63:FC:59:48", "E4:B0:63:FA:D5:34", "10:20:BA:FA:F5:40", "10:20:BA:FB:6D:64", "48:F6:EE:CC:16:E4", "10:20:BA:FB:70:FC", "98:A3:16:7C:5F:EC", "20:6E:F1:01:F9:F8", "A0:85:E3:C8:65:34", "AC:15:18:7B:62:20", "14:33:5C:D8:2D:7C", "98:A3:16:79:38:50", "98:A3:16:7A:A3:54", } if len(SHELLY) != 26: raise SystemExit("SHELLY hat %d MACs statt 26 -- Tabelle beschaedigt." % len(SHELLY)) # Schluessel = MAC, Wert = (aktuelle Soll-Adresse, Adresse davor) -- also die # Umkehrung von MOVES, aber nur fuer den Shelly-Block. REVERT = {mac: (soll, ist) for mac, (ist, soll) in MOVES.items() if mac in SHELLY} if len(REVERT) != 26: raise SystemExit("REVERT hat %d Eintraege statt 26 -- MOVES/SHELLY passen nicht zusammen." % len(REVERT)) # 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"} # Die Zeile, mit der B2 die CCU-Leiche entfernt hat -- wortgleich der Zustand # VOR B2, Quelle `routeros/tp21-core-dhcp-from-ap1.rsc` Z. 70 (dort ohne # `disabled`). Fuer den vollstaendigen Rueckweg wird sie wieder angelegt. RESTORE_ADD = ("add address=10.40.30.232 comment=pve-homematic-raspi-ccu " "mac-address=BC:24:11:8E:0A:57 server=dhcp-v30") # 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", "revert", "restore", "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 "")) elif cmd == "restore": if not soll: raise SystemExit("Soll ist leer β€” nichts wiederherzustellen.") drop = {mac_of(a) for a in ADDS} new, seen, moved, dropped = [], set(), [], [] for line in soll: mac = mac_of(line) if mac in drop: dropped.append(mac) continue if mac in MOVES: ist_ip, soll_ip = MOVES[mac] cur = re.search(r"address=(\S+)", line).group(1) if cur != ist_ip: if cur != soll_ip: raise SystemExit("%s: Soll traegt weder %s noch %s β€” Soll, MOVES und " "Wirklichkeit auseinander. Zeile: %s" % (mac, ist_ip, soll_ip, line[:120])) line = set_address(line, ist_ip) moved.append(mac) seen.add(mac) new.append(line) fehlend = set(MOVES) - seen if fehlend: raise SystemExit("MOVES nennt MACs, die im Soll fehlen: %s" % ", ".join(sorted(fehlend))) for mac in sorted(REMOVE): if mac not in seen: new.append(RESTORE_ADD) moved.append(mac) print("== restore: %d Adressen zurueck, %d Zeilen entfernt, %d wieder angelegt " "(Soll jetzt %d Zeilen) ==" % (len(moved), len(dropped), len(REMOVE), len(new))) elif cmd == "revert": if not soll: raise SystemExit("Soll ist leer β€” es gibt nichts zurueckzunehmen.") new, seen = [], set() for line in soll: mac = mac_of(line) if mac in REVERT: neu_ip, alt_ip = REVERT[mac] if re.search(r"address=(\S+)", line).group(1) != neu_ip: raise SystemExit("%s: Soll traegt nicht %s β€” Soll und REVERT auseinander. " "Zeile: %s" % (mac, neu_ip, line[:120])) line = set_address(line, alt_ip) seen.add(mac) new.append(line) if seen != set(REVERT): raise SystemExit("REVERT nennt MACs, die im Soll fehlen: %s" % ", ".join(sorted(set(REVERT) - seen))) print("== revert: %d Shelly-Reservierungen zurueck auf die alten Adressen ==" % len(REVERT)) 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()