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>
This commit is contained in:
132
nautobot/ingest-dhcp-lease-rule.py
Normal file
132
nautobot/ingest-dhcp-lease-rule.py
Normal 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()
|
||||||
@@ -138,6 +138,25 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
/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,
|
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
|
||||||
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
|
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
|
||||||
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
|
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
|
||||||
|
|||||||
266
nautobot/tp45-leases.py
Normal file
266
nautobot/tp45-leases.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
#!/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 move TP45-B2-v30 # Dry-Run, s. MOVES
|
||||||
|
python3 nautobot/tp45-leases.py move TP45-B2-v30 --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]
|
||||||
|
return ([l[1:] 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:
|
||||||
|
raise SystemExit("Soll ist NICHT leer (%d Zeilen) — `seed` ist ein Re-Baseline. "
|
||||||
|
"Fuer eine geplante Aenderung `move` benutzen." % len(soll))
|
||||||
|
new, when = backup_lines()
|
||||||
|
print("== seed aus Backup %s (%d Zeilen) ==" % (when, len(new)))
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user