Erster Lauf nach dem Einbau ergab 144/145 -- und die eine Drift war mein Fehler, nicht der des Geraets: backup_lines() strippte mit l[1:] nur das fuehrende '/', nicht den ganzen Befehl. Das gerenderte Soll enthielt dadurch `/ip dhcp-server lease ip dhcp-server lease add ...`. Gefunden hat das die Compliance-Zahl selbst. Genau deshalb ist der NENNER die Abnahme und nicht der Zaehler: 140 -> 145 beweist, dass die neue Rule auf allen fuenf Geraeten auswertet; 145/145 beweist, dass das Soll stimmt. Ausserdem: seed --force fuer einen bewussten Neuschnitt (der Guard gegen versehentliches Re-Baseline bleibt). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
13 KiB
Python
277 lines
13 KiB
Python
#!/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()
|