6 Commits

Author SHA1 Message Date
b91bac1265 TP45 Schicht B2: VLAN30-Reservierungen ins Oktett-Schema
36 DHCP-Reservierungen wandern aus dem Pool-Fenster (.5, .8, .202-.244)
in die Schema-Baender .11-.14 / .151-.176 / .177-.196. Dazu die CCU-Leiche
.232 (status=waiting, last-seen=never; die CCU lebt in VLAN20) entfernt und
SONOFF-NSPanelPro von einer dynamischen Lease auf .249 zu einer statischen
Reservierung auf .12 gepinnt.

Neuer Job `tp45-v30-leases` in gc-configplan.py. Er laedt MOVES/REMOVE/ADDS
per importlib aus tp45-leases.py, statt sie zu kopieren -- die Tabelle dort
ist die SSoT des Umzugs, eine zweite Kopie liefe still auseinander.

Warum der Job zwei Selektor-Formen traegt: die erste Fassung schrieb
ueberall `[/ip dhcp-server lease [find where ...]]`. In `[:len ...]` ist das
Syntaxfehler (`expected command name (line 1 column 55)`, live gemessen) --
Golden Config meldete trotzdem Job SUCCESS und Plan Completed, das Geraet
blieb unveraendert. Aufgefallen ist es an der Zahl der Log-Zeilen: von 38
`:if`-Statements hat kein einziger Zweig geloggt. Richtig ist
`[/ip dhcp-server lease find where ...]` ohne Klammerpaar um `find`;
die Argument-Form `set [find where ...]` bleibt.

Abnahme am Geraet, nicht am Plan-Status: 39 Leases im Soll-Schema, .232 und
.249 geraeumt, NSPanelPro dynamic=false; Compliance 145/145, keine Drifts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 16:18:05 +02:00
b509a2325e TP45 Schicht B1 Fix: Befehlspraefix nicht doppelt rendern
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>
2026-09-21 14:59:14 +02:00
4d273117b6 TP45 Schicht B1: DHCP-Reservierungen ins GC-Soll (Renderer + ComplianceRule)
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>
2026-09-21 14:55:00 +02:00
92bd4aac85 TP45: pool-v20 auf .200-.249 verengt -- Schema-Baender .100-.199 freigegeben
Die 7 DHCP-Pools starteten alle bei .100 und ueberlappten damit die beiden
Oktett-Baender, die dieses TP gerade belegt: .100-.149 (Server-Hosts) und
.150-.199 (App-Services). In VLAN20 ist das eine echte Kollisionsflaeche --
nautobot .154-.158, gitea .157, pve-mcp .173 und shelly-mcp .180 tragen ihre
IP GERAETELOKAL und stehen in keinem Lease-Eintrag, der Core kann sie also
nicht schuetzen und einem DHCP-Client .154 anbieten.

Entscheid 2026-09-21: nur pool-v20 und pool-v30 werden verengt, v20 zuerst.
VLAN10/40/50/90/99 bleiben bei .100-.200 -- dort gibt es kein Schema-Band,
das etwas gewoenne, und VLAN40/VLAN10 haetten sonst 11 Sonos (.191-.201),
den iPhone (.196) und den Mac (.82) mitgezogen.

VLAN20 kostet die Verengung nichts: eine statische Reservierung (nas-bf
.20.10, in .10x) und 0 dynamische Leases.

pool-v30 bleibt bewusst unangetastet -- dort liegen 35 statische
Reservierungen IM Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot
.227, Twinkly .228, Withings .230/.231, lichterketten .236/.237, Nuki
.241/.244, CCU .232). Erst Schicht B zieht die um, dann pool-v30.
Nebenbei faellt damit die .30.250-Kollision: der Sonoff Dongle-M sitzt dort
statisch, der Pool reicht heute bis .250.

Neuer Job `tp45-pool-v20` (gc-configplan.py) mit gezaehltem Selektor: ein
`find where`, das 0 trifft, sieht wie "nicht vorhanden" aus statt wie
"kaputt" -- ein blankes `set [find where name=...]` liefe ins Leere und der
Job meldete Erfolg.

🔴 Lehre aus dem ersten Fehlversuch: jedes config_set-Statement muss auf EINER
Zeile klammer-vollstaendig sein. Mit `:if (...) do={` am Zeilenende geht
RouterOS in den Fortsetzungs-Modus und zeigt einen anderen Prompt, der
netmiko-Treiber verliert sein Muster und der Deploy stirbt mit
`E1015: Pattern not detected: ...@crs326\-core\].*$|#.*$)` -- Plan auf
Failed, ohne etwas geschrieben zu haben.

Abnahme: Gerate-Read pool-v20 = 10.40.20.200-10.40.20.249 (total=50,
used=0); DHCPOFFER-Probe auf vmbr0.20 -> 10.40.20.200 mit
Domain Name Server 10.40.20.30, 10.40.20.31; Probe-Lease wieder entfernt;
Compliance 140/140 ohne Drift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 14:39:40 +02:00
974ecbc855 TP45 Schicht A: DHCP-Soll liefert AGH .30/.31 statt .36/.37
Die vier Core-Forward-Regeln fuer den Resolver waren nachgezogen, die
DHCP-Antwort an die Clients noch nicht: /ip dhcp-server network trug
weiterhin dns-server=10.40.20.36,10.40.20.37. Ein Client haette nach dem
AGH-Umzug den Resolver unter der alten Adresse gesucht.

Template geaendert, nach bf-golden-config gepusht (Git-DataSource ist die
Wahrheit, nicht die lokale Datei), Git-Sync, am Core gesetzt (alle 7 Netze),
per DHCPOFFER-Probe auf vmbr0.20 bestaetigt (Domain Name Server:
10.40.20.30, 10.40.20.31), Compliance 140/140 ohne Drift.

Randbefund (nicht in diesem Commit): die 7 DHCP-Pools starten live bei .100
und ueberlappen die Schema-Baender .100-.199 — pool-v30 reicht sogar bis
.250, wo statisch der Sonoff Dongle-M sitzt. Entscheid 2026-09-21: nur
pool-v20 und pool-v30 werden auf .200-.249 verengt, v20 zuerst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 14:34:49 +02:00
139d7c029d TP45 Schicht A: AGH-DNS-Freigaben .30/.31 im GC-Soll + am Core nachgezogen
- nautobot/tp45-agh-firewall.py (neu): pflegt die vier `TP5-E2: LAN->AGH dns*`-
  Regeln im `local_config_context_data.firewall_filter` (add/remove, Dry-Run
  default, Unified Diff). Bewusst NICHT `ingest-core-firewall.py` -- das ist ein
  RE-BASELINE-Werkzeug und wuerde die Aenderung mit sich selbst absegnen.
- gc-configplan.py: Jobs `tp45-agh-fw` (add + move vor die Log-Regel; `caddy`
  dahinter, damit der AGH-Block zusammenbleibt) und `tp45-agh-dhcp`.

Fix in `create_plans`: unter derselben `change_control_id` liegen nach einem
zweiten Lauf zwei Plaene. Die Auswahl folgte der REST-Reihenfolge und traf den
ALTEN -> der config_set-Guard brach ab, obwohl der neue Plan korrekt erzeugt
war. Jetzt gewinnt der juengste; die alten bleiben Historie.

Fund: `find where dst-address=<bare-IP>` matcht in RouterOS nie (nur `~` als
Regex). Die Zeile stand als "Verifikation" in
docs/transaktionen/TP23-agh-firewall.commands.rsc:7 und hat dort seit dem
2026-08-25 immer `0` gemeldet, ohne je etwas zu pruefen. Gleiche Klasse wie
`nc -z` (09-01): eine Pruefung, die 0 meldet, sieht wie "fehlt" aus, nicht wie
"kaputt".

Abnahme: Reihenfolge am Geraet (vier neue Regeln vor dem Drop), Compliance
140/140 / 0 Drifts mit `RouterOS - ip-firewall-filter` (ordered, frisch
bewertet, `actual` traegt die neuen Zeilen), `dig` vom VLAN10-Client gegen
.30/.31/.36/.37 alle beantwortet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 13:27:23 +02:00
5 changed files with 874 additions and 3 deletions

View File

@@ -52,13 +52,17 @@ BENUTZUNG
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
"""
import argparse
import importlib.util
import json
import os
import pathlib
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
@@ -160,8 +164,22 @@ def create_plans(device_names, config_set, change_control_id, dry):
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
# Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem
# Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch.
# 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch
# den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so
# passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen
# neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie.
def created(p):
raw = str(p.get("created") or "")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
return datetime.min
by_name = {}
for p in found:
for p in sorted(found, key=created):
name = (p.get("device") or {}).get("display")
by_name[name] = p["id"]
@@ -418,11 +436,302 @@ def job_tp38_oob():
]
def job_tp45_agh_fw():
"""TP45 Schicht A (2026-09-21): DNS-Freigaben fuer die neuen Resolver-Adressen .30/.31.
Der Resolver zieht von .36/.37 auf .30/.31 (TP32-Oktett-Klasse DNS `.3x`). Damit die
Umstellung ohne Ausfall geht, laufen beide Adressen kurz parallel — also braucht der
Core **beide** Freigaben. Diese hier ist ADDITIV: die vier .36/.37-Regeln bleiben
stehen und fallen erst beim Rueckbau (eigener Schritt, wenn die Zaehler stillstehen).
Die vier Regeln sind ein Literal im Soll (`local_config_context_data.firewall_filter`,
gepflegt mit `nautobot/tp45-agh-firewall.py`) — der Job zieht nur das Geraet nach.
🔴 `place-before` wird ueber Netmiko IGNORIERT (Lehre aus TP38, 2026-09-15): die Regel
landet am Ketten-Ende, also HINTER dem finalen `drop` und waere wirkungslos, waehrend
der Plan trotzdem „Completed" meldet. Deshalb `add` (appends) und danach `move` vor
die Log-Regel — in der Reihenfolge, in der die Regeln stehen sollen (jedes `move`
schiebt seine Regel unmittelbar vor das Ziel, also hinter die zuvor verschobenen).
🔴 **Zweite Falle, am 2026-09-21 beim ersten Lauf aufgeschlagen:** nicht der `move`
war falsch, sondern der **Selektor** — `find where dst-address=<bare-IP>` matcht nie
(Details und Tragweite in der Docstring von `sel()` unten). Der Plan meldete
trotzdem „Completed", die Regeln lagen hinter dem `drop`. Deshalb ist die Abnahme
hier **nicht** der Plan-Status, sondern die Position am Geraet.
KEIN LOCKOUT-RISIKO: die Regeln erweitern nur `accept` in der forward-Kette; der
Mgmt-Pfad (Nautobot-SSH -> vlan99) ist von der Aenderung nicht beruehrt. Der Deploy
laeuft von 10.40.20.154, das auf Regel 22 (established) matcht.
"""
ips = ["10.40.20.30", "10.40.20.31"]
log = 'TP5: log-only, Vorstufe zum deny-by-default'
def sel(cmt, ip):
"""Selektor auf genau eine Regel.
🔴 **`dst-address=<bare-IP>` matcht in `find where` NIE** — live am Core
gemessen (2026-09-21): `find where dst-address=10.40.20.30` → **0 Treffer**,
auch mit `/32`; `dst-address~"10.40.20.30"` (Regex) → 2. Deshalb steht hier
`~`, nicht `=`.
⚠️ Die Tragweite ist größer als dieser Job: **eine Prüfung, die 0 meldet,
sieht wie „nicht vorhanden" aus und nicht wie „kaputt".** Genau daran ist
der erste Lauf dieses Jobs gescheitert — der `add` lief (der Guard sah
„nicht vorhanden"), der `move` lief ins Leere, und der Plan meldete
trotzdem „Completed". Dieselbe Zeile stand als *Verifikation* in
`docs/transaktionen/TP23-agh-firewall.commands.rsc:7` und hat dort seit
dem 2026-08-25 immer `0` gemeldet, ohne je etwas zu prüfen.
Gleiche Klasse wie `nc -z` (2026-09-01) und das `sed`-Maskierungsmuster:
**erst den Selektor verdächtigen, dann das Gerät.**
"""
return f'comment="{cmt}" dst-address~"{ip}"'
def rule(ip, proto, cmt):
return (f'/ip firewall filter add action=accept chain=forward comment="{cmt}" '
f'dst-address={ip} dst-port=53 protocol={proto}')
lines = []
for ip in ips:
# Guard + move getrennt: nur der Guard ist idempotent (ein zweiter Lauf
# verdoppelt die Regeln nicht), der move läuft immer — er ist auch der
# Reparaturweg, wenn eine Regel schon existiert, aber falsch einsortiert ist.
for proto, cmt in (("udp", "TP5-E2: LAN->AGH dns"), ("tcp", "TP5-E2: LAN->AGH dns-tcp")):
lines.append(f':if ([:len [/ip firewall filter find where {sel(cmt, ip)}]] = 0) '
f'do={{ {rule(ip, proto, cmt)} }}')
for ip in ips:
for cmt in ("TP5-E2: LAN->AGH dns", "TP5-E2: LAN->AGH dns-tcp"):
lines.append(f'/ip firewall filter move [find where {sel(cmt, ip)}] '
f'destination=[find where comment="{log}"]')
# Der Anker ist bewusst die Log-Regel (Baseline, stabil) und nicht `caddy`: haengt
# sie unmittelbar vor der Log-Regel, landen die vier Regeln DAHINTER -- der AGH-Block
# bleibt so zusammen, wie das Soll ihn fuehrt. Waehlte man `caddy` als Ziel und die
# Regel fehlte, laegen die vier hinter dem `drop` = funktional kaputt; so bleibt im
# Fehlerfall nur eine Reihenfolge-Abweichung (Compliance-Drift, kein Ausfall).
lines.append(f'/ip firewall filter move [find where comment="TP31: AP-cloudflared -> caddy-bf1"] '
f'destination=[find where comment="{log}"]')
# ⚠️ Diese Zeile meldet NUR Anwesenheit (je IP 2 Regeln erwartet), NICHT die
# Position -- sie kann also gruen aussehen, waehrend die Regeln hinter dem `drop`
# liegen. Genau daran ist der erste Lauf gescheitert. **Die Abnahme ist der
# Geraete-Read nach dem Deploy** (`print terse proplist=comment,packets`).
lines.append(
':log info ("tp45-agh-fw: .30=" . '
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.30"]]] . " .31=" . '
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.31"]]] . " log=" . '
f'[:tostr [:len [/ip firewall filter find where comment="{log}"]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-agh-firewall")]
def job_tp45_agh_dhcp():
"""TP45 Schicht A (2026-09-21): DHCP-DNS-Server der 7 Netze .36,.37 -> .30,.31.
Der Core verteilt in ALLEN sieben Netzen `dns-server=10.40.20.36,10.40.20.37`
(live geprueft 2026-09-21). Das Template (Z.137) ist auf .30,.31 gehoben; dieser Job
zieht das laufende Geraet nach.
MODIFY, kein add: das Netz existiert -> `set [find]`, nicht die `add`-Zeile der
intended config (die wuerfe `already have such address`). `[find]` ohne Filter
trifft alle 7 Netze. Gleiche Form wie `job_tp42_dhcp_domain`.
⚠️ **Reihenfolge zwingend:** erst `tp45-agh-fw` deployen. Sonst liefert DHCP Adressen
aus, die die Firewall noch nicht durchlaesst — unter deny-by-default faellt DNS aus
allen VLANs aus.
KEIN LOCKOUT-RISIKO: eine DHCP-Option beruehrt weder Routing noch Mgmt-Zugang;
Clients ziehen sie erst beim naechsten Lease-Renew (lease-time 1d, GUEST 12h).
"""
core_set = "\n".join([
':do { /ip dhcp-server network set [find] dns-server=10.40.20.30,10.40.20.31 } '
'on-error={ :log info "tp45-agh-dhcp: set geworfen" }',
':log info ("tp45-agh-dhcp: auf-neu=" . [:tostr [:len [/ip dhcp-server network '
'find where dns-server~"10.40.20.30"]]] . "/7")',
])
return [(["crs326-core"], core_set, "TP45-agh-dhcp")]
def _pool_set(vlan, ranges):
"""Zeilen, die `pool-v<vlan>` auf `ranges` ziehen -- mit gezaehltem Selektor.
MODIFY, kein add: der Pool existiert, die `add`-Zeile der intended config
wuerfe `already have such name`. Gleiche Form wie `job_tp45_agh_dhcp`.
🔴 Der Selektor wird GEZAEHLT, nicht geglaubt (Lehre vom 2026-09-21): ein
`find where`, das 0 trifft, sieht wie „nicht vorhanden" aus und nicht wie
„kaputt" -- ein blankes `set [find where name=…]` liefe ins Leere und der
Job meldete trotzdem Erfolg (dieselbe Klasse wie `dst-address=<bare IP>`
in TP23-agh-firewall.commands.rsc:7, das seit dem 08-25 immer 0 meldete).
🔴🔴 **JEDES Statement muss auf EINER Zeile klammer-vollstaendig sein.**
Erste Fassung hatte `:if (...) do={` am Zeilenende -- RouterOS geht dann in
den Fortsetzungs-Modus und zeigt einen ANDEREN Prompt, der netmiko-Treiber
verliert sein Muster und der Deploy stirbt mit
`E1015: Pattern not detected: (redacted)@crs326\\-core\\].*$|#.*$)`.
Der Plan stand danach auf `Failed`, **ohne** etwas geschrieben zu haben.
Deshalb ist jede Zeile hier ein vollstaendiges `… do={ …; … } else={ … }`.
"""
pool = f"pool-v{vlan}"
return "\n".join([
f':if ([:len [/ip pool find where name="{pool}"]] = 1) do={{ '
f'/ip pool set [find where name="{pool}"] ranges={ranges}; '
f':log info ("tp45-pool: {pool} -> " . [/ip pool get [find where name="{pool}"] ranges]) }} '
f'else={{ :log info ("tp45-pool: ABBRUCH, {pool} nicht eindeutig") }}',
])
def job_tp45_pool_v20():
"""TP45 (2026-09-21): pool-v20 von .100-.200 auf .200-.249 verengen.
Damit werden die Schema-Baender .100-.149 (Server-Hosts) und .150-.199
(App-Services) in VLAN20 echt frei. VLAN20 traegt KEINE DHCP-Reservierung
im alten Fenster (live geprueft: nur `nas-bf .20.10` in .10x) und 0
dynamische Leases -- die Verengung kostet hier nichts.
🔴 Warum ueberhaupt: `nautobot .154-.158`, `gitea .157`, `pve-mcp .173` und
`shelly-mcp .180` tragen ihre IP GERAETELOKAL und stehen in KEINEM
Lease-Eintrag -- der Core kann sie nicht schuetzen und einem DHCP-Client
.154 anbieten. Die Verengung ist hier sofort wirksam.
⚠️ VLAN30 gehoert NICHT hierher: dort liegen 35 statische Reservierungen IM
Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot .227, Twinkly .228,
Withings .230/.231, lichterketten .236/.237, Nuki .241/.244, CCU .232).
Erst Schicht B zieht die um, DANN pool-v30.
"""
return [(["crs326-core"], _pool_set(20, "10.40.20.200-10.40.20.249"), "TP45-pool-v20")]
def _tp45_leases_module():
"""MOVES/REMOVE/ADDS aus `tp45-leases.py` laden.
🔴 **NICHT kopieren.** Diese Tabellen sind die SSoT des Umzugs. Eine zweite
Kopie hier liefe beim ersten Edit auseinander — und zwar still, weil beide
Seiten fuer sich plausibel aussehen; am Geraet faellt nur auf, dass eine
Adresse fehlt.
Der Dateiname traegt einen Bindestrich (Repo-Konvention fuer Skripte), ist
also nicht regulaer importierbar -> importlib ueber den Pfad. `__name__` ist
dann nicht `__main__`, das `main()`-Guard der Datei greift also nicht.
"""
spec = importlib.util.spec_from_file_location(
"tp45_leases", pathlib.Path(__file__).with_name("tp45-leases.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _lease_move(mac, soll):
"""Eine Umzugs-Zeile — EINZEILIG und klammer-vollstaendig (E1015-Lehre, s. _pool_set).
Der Selektor wird GEZAEHLT: ein `find where`, das 0 trifft, sieht wie
„nicht vorhanden" aus und nicht wie „kaputt", und ein blankes `set [find …]`
liefe ins Leere, waehrend der Job Erfolg meldet.
Absichtlich OHNE `address=<ist>` im Selektor: ein zweiter Lauf muss ein
No-Op sein, kein Fehler. Die Ist-Adresse wird stattdessen VOR dem Deploy
geprueft — `tp45-leases.py move` verlangt, dass das Soll genau `ist` traegt
und bricht sonst ab.
🔴🔴 **ZWEI VERSCHIEDENE Selektor-Formen, und das ist keine Kosmetik.**
Die erste Fassung schrieb ueberall die Argument-Form `[… [find where …]]`.
Die ist **Syntaxfehler**, sobald sie in `[:len …]` steht:
`expected command name (line 1 column 55)` (live gemessen 2026-09-21)
Der Deploy lief trotzdem als `Completed` durch, das Geraet blieb
**unveraendert**, und im Log stand nur die Schlusszeile — von den 38
`:if`-Statements hat **kein einziger** Zweig geloggt, also ist auch kein
`set` gelaufen.
* in `[:len …]` und direkt vor `find`: **`/ip dhcp-server lease find where …`**
(kein Klammerpaar um `find`) — Muster aus `_pool_set`/`job_tp45_agh_dhcp`
* als Argument einer Vollpfad-Kommandozeile: `set [find where …]`
— diese Form ist in `_pool_set` bewiesen und bleibt.
⚠️ **Merkregel:** `Completed` am Plan und `SUCCESS` am Job beweisen hier
nichts. Der Beweis ist die **Zahl der Log-Zeilen** (jeder Zweig loggt) oder
ein Geraete-Read. Beides gehoert zur Abnahme — genau wie beim
`pool-v20`-Deploy, der ohne Gegenprobe als gelungen gegolten haette.
"""
find = f'find where mac-address={mac}'
arg = f'[find where mac-address={mac}]'
return (f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
f'/ip dhcp-server lease set {arg} address={soll}; '
f':log info ("tp45-v30: {mac} -> {soll}") }} '
f'else={{ :log info ("tp45-v30: {mac} NICHT EINDEUTIG -- nicht angefasst") }}')
def job_tp45_v30_leases():
"""TP45 Schicht B2 (2026-09-21): die VLAN30-Reservierungen ins Oktett-Schema.
36 Reservierungen wandern aus dem DHCP-Pool-Fenster (.202-.244, .5, .8) in die
Schema-Baender: `.11-.14` fest verbaute Infrastruktur, `.151-.176` Shelly-Block,
`.177-.196` App-Services. Dazu zwei Sonderfaelle:
* `pve-homematic-raspi-ccu` (MAC BC:24:11:8E:0A:57) wird ENTFERNT — eine
Leiche aus dem Core-Cutover (`status=waiting`, `last-seen=never`). Die CCU
ist live in VLAN20 (.20.232, ARP + HTTP 200 belegt, Caddys `ccu.bf` proxyt
dorthin); die VLAN30-Reservierung ist ein Zombie.
* `SONOFF-NSPanelPro` hat GAR KEINE Reservierung, nur eine dynamische Lease
auf `.249`. Wird per `make-static` gepinnt und auf `.12` gezogen.
⚠️ **Reihenfolge:** dieser Job gehoert VOR `tp45-pool-v30`. Die Verengung auf
`.200-.249` setzt voraus, dass keine Reservierung mehr im Schema-Band liegt —
sonst zieht der Pool Adressen, die schon vergeben sind.
⚠️ **Kein Ausfall, kein Lockout:** ueber DHCP-Reservierungen umzuziehen heisst,
dass die Geraete beim naechsten Lease-Renew selbst nachziehen (lease-time 1d).
Nur zwei der 36 sind im Moment ueberhaupt `bound` (SwitchBot, DTUBI); alle
anderen stehen auf `last-seen=never`. Nichts wird hart umgeschaltet.
⚠️ **`.150` (Ender3V3KE) und `.180` (espressif/Airfryer) bleiben, wo sie sind** —
sie liegen bereits im App-Band und werden bewusst nicht bewegt.
Rueckweg: `MOVES` traegt Ist UND Soll je MAC, das inverse `set` ist also
mechanisch; zusaetzlich liegt vor dem Deploy ein binaeres Backup auf dem Core.
"""
mod = _tp45_leases_module()
lines = [_lease_move(mac, soll) for mac, (_ist, soll) in sorted(mod.MOVES.items())]
for mac in sorted(mod.REMOVE):
find = f'find where mac-address={mac}'
arg = f'[find where mac-address={mac}]'
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
f'/ip dhcp-server lease remove {arg}; '
f':log info ("tp45-v30: CCU-Leiche {mac} entfernt") }} '
f'else={{ :log info ("tp45-v30: CCU-Leiche schon weg") }}')
for add in mod.ADDS:
mac = re.search(r"mac-address=(\S+)", add).group(1)
ip = re.search(r"address=(\S+)", add).group(1)
cmt = re.search(r"comment=(\S+)", add).group(1)
find = f'find where mac-address={mac}'
arg = f'[find where mac-address={mac}]'
# make-static VOR set: auf einer dynamischen Lease ist `set address=` wirkungslos
# bzw. wird beim naechsten Renew ohnehin verworfen -- erst statisch, dann pinnen.
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
f'/ip dhcp-server lease make-static {arg}; '
f'/ip dhcp-server lease set {arg} address={ip} comment="{cmt}"; '
f':log info ("tp45-v30: NSPanelPro statisch auf {ip}") }} '
f'else={{ :log info ("tp45-v30: NSPanelPro hat keine Lease -- spaeter nachziehen") }}')
# Abnahme-Zeile. `address=` ist ein echter Eigenschaftsvergleich (kein Regex) --
# bewusst NICHT `~"10.40.30.11"`, das traefe auch .110-.119.
lines.append(
':log info ("tp45-v30: .11=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.11]]] . " .12=" . [:tostr [:len [/ip dhcp-server '
'lease find where address=10.40.30.12]]] . " .151=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .196=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.196]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-v30-leases")]
JOBS = {
"drift-cleanup": job_drift_cleanup,
"mgmt-acl": job_mgmt_acl,
"tp42-dhcp-domain": job_tp42_dhcp_domain,
"tp38-oob": job_tp38_oob,
"tp45-agh-fw": job_tp45_agh_fw,
"tp45-agh-dhcp": job_tp45_agh_dhcp,
"tp45-pool-v20": job_tp45_pool_v20,
"tp45-v30-leases": job_tp45_v30_leases,
}

View File

@@ -0,0 +1,132 @@
#!/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()

View File

@@ -36,7 +36,7 @@
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
{%- set vlans = [
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
{"id": 20, "name": "INFRA", "pool": "10.40.20.100-10.40.20.200", "lease": "1d"},
{"id": 20, "name": "INFRA", "pool": "10.40.20.200-10.40.20.249", "lease": "1d"},
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
@@ -134,10 +134,29 @@
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
{% endfor %}
{% for v in vlans %}
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.36,10.40.20.37 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.30,10.40.20.31 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
{% endfor %}
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
# [5b] DHCP-RESERVIERUNGEN — kommen als DATEN aus local_config_context_data.dhcp_leases,
# wie firewall_filter. Warum das noetig war: bis 2026-09-21 rendert dieses Template
# KEIN einziges `/ip dhcp-server lease`. Die 54 Reservierungen stammen aus dem
# Einmal-Skript routeros/tp21-core-dhcp-from-ap1.rsc (Core-Cutover 2026-08-06) und
# standen damit in KEINEM Soll — 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 Klasse wie die handgelaufene `nautobot`-Adressliste (s. CLAUDE.md).
#
# 🔴 Zeilen sind RouterOS-/export-Form, alphabetisch, OHNE fuehrenden Pfad — genau
# wie firewall_filter. Gefuellt und verschoben von nautobot/tp45-leases.py.
# 🔴 Die REIHENFOLGE wird NICHT sortiert: das Backup traegt die Sektion in
# Einfuege-Reihenfolge (.30.150 frueh, .30.5 spaet), ein numerisch sortiertes
# Soll waere ein Dauer-Falsch-Drift. Die ComplianceRule steht deshalb auf
# config_ordered=no.
{% for line in obj.get_config_context().get("dhcp_leases", []) %}
/ip dhcp-server lease {{ line }}
{% endfor %}
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
r"""TP45 Schicht A — die AGH-DNS-Freigaben im Core-`firewall_filter`-Soll pflegen.
WAS HIER PASSIERT
-----------------
Die vier `TP5-E2: LAN->AGH dns*`-Regeln des Cores stehen als LITERALE im
`local_config_context_data.firewall_filter` (Nautobot) und werden vom GC-Template
nur gerendert. Zieht der Resolver um, muss das Soll mit — sonst meldet die
Compliance Drift, sobald das Geraet die neue Adresse traegt.
add <ip> [...] zwei Regeln (udp+tcp) fuer jede IP, direkt hinter den
bestehenden AGH-Block einsortiert
remove <ip> [...] genau diese zwei Regeln wieder entfernen (= Rueckbau)
WARUM KEIN `ingest-core-firewall.py`
------------------------------------
Das ist ein RE-BASELINE-Werkzeug: es liest den Ist-Stand vom Geraet und erklaert
ihn zum Soll. Fuer eine *geplante* Aenderung ist es das falsche Werkzeug — es
wuerde die Aenderung mit sich selbst absegnen. Hier wird das Soll explizit
gesetzt, das Geraet zieht per `gc-configplan.py --job tp45-agh-fw` nach.
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 # Dry-Run
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 --apply
REIHENFOLGE IST SEMANTISCH — die ComplianceRule steht auf `config_ordered`. Neue
Regeln landen hinter der bestehenden `.37`-tcp-Zeile, also weiterhin VOR der
`TP5: log-only`-Zeile und damit vor dem finalen `drop`.
"""
import difflib
import json
import os
import pathlib
import sys
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
DEVICE = "crs326-core"
COMMENT_UDP = "TP5-E2: LAN->AGH dns"
COMMENT_TCP = "TP5-E2: LAN->AGH dns-tcp"
ANCHOR = 'comment="TP5-E2: LAN->AGH dns-tcp" dst-address='
def token():
tok = os.environ.get("NAUTOBOT_TOKEN")
if tok:
return tok
# Fallback: aus dem (gitignored) Stack-File, Wert wird nie ausgegeben.
yml = (pathlib.Path(__file__).resolve().parent.parent
/ "docs/nas-vlan20/stacks/210-nautobot.yml")
for line in yml.read_text(errors="replace").splitlines():
key, sep, val = line.strip().lstrip("- ").partition("=")
if sep and key.strip() == "NAUTOBOT_SUPERUSER_API_TOKEN":
return val.strip().strip("'\"")
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
def nb(path, method="GET", payload=None):
req = urllib.request.Request(
f"{NB}{path}", method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Token {token()}", "Content-Type": "application/json",
"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def rules_for(ip):
# Attributreihenfolge alphabetisch, exakt wie RouterOS /export sie schreibt —
# dieselbe Konvention wie ingest-core-firewall.py und das Template.
return [
f'add action=accept chain=forward comment="{COMMENT_UDP}" '
f'dst-address={ip} dst-port=53 protocol=udp',
f'add action=accept chain=forward comment="{COMMENT_TCP}" '
f'dst-address={ip} dst-port=53 protocol=tcp',
]
def is_agh(rule, ip):
return f"dst-address={ip}" in rule and "TP5-E2: LAN->AGH dns" in rule
def main():
argv = sys.argv[1:]
if len(argv) < 3 or argv[0] not in ("add", "remove"):
raise SystemExit(__doc__.strip().splitlines()[-1])
mode = argv[0]
ips = [a for a in argv[1:] if not a.startswith("--")]
apply_ = "--apply" in argv
dev = nb(f"/api/dcim/devices/?name={DEVICE}")["results"][0]
ctx = dict(dev.get("local_config_context_data") or {})
lines = list(ctx.get("firewall_filter") or [])
if not lines:
raise SystemExit("firewall_filter ist leer — falsches Geraet?")
new = list(lines)
for ip in ips:
present = [l for l in new if is_agh(l, ip)]
if mode == "add":
if present:
print(f" {ip}: schon vorhanden ({len(present)} Regeln) — uebersprungen")
continue
# hinter die letzte bestehende AGH-Zeile einsortieren
idx = max((i for i, l in enumerate(new) if ANCHOR in l), default=None)
if idx is None:
raise SystemExit(f" kein AGH-Anker ({ANCHOR}...) im Soll gefunden")
new[idx + 1:idx + 1] = rules_for(ip)
print(f" {ip}: +2 Regeln nach Index {idx}")
else:
if len(present) != 2:
raise SystemExit(f" {ip}: {len(present)} statt 2 Regeln gefunden — Abbruch")
new = [l for l in new if not is_agh(l, ip)]
print(f" {ip}: -2 Regeln entfernt")
if new == lines:
print("\n = unveraendert, nichts zu tun.")
return
print(f"\n== Diff ({len(lines)} -> {len(new)} Zeilen) ==")
for d in difflib.unified_diff(lines, new, "IST", "SOLL", lineterm="", n=1):
print(" " + d[:170])
if not apply_:
print(f"\nDRY-RUN — mit --apply nach "
f"local_config_context_data.firewall_filter schreiben.")
return
ctx["firewall_filter"] = new
nb(f"/api/dcim/devices/{dev['id']}/", "PATCH", {"local_config_context_data": ctx})
print("\n ~ geschrieben. Geraet zieht nach per: "
"python3 nautobot/gc-configplan.py --job tp45-agh-fw --apply")
if __name__ == "__main__":
main()

276
nautobot/tp45-leases.py Normal file
View File

@@ -0,0 +1,276 @@
#!/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()