Files
bf-infra/nautobot/gc-configplan.py
Christoph Emsenhuber 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

820 lines
39 KiB
Python

#!/usr/bin/env python3
"""BF-Netzwerk -> Nautobot Golden Config: manuelle ConfigPlans anlegen und deployen.
WOFUER
------
Bis 2026-08-16 wurden alle 17 ConfigPlans dieses Projekts ad hoc per curl gebaut. Das
hat zweimal wehgetan (s. GOVERNANCE unten), deshalb hier einmal sauber als Skript.
Ein "manual" ConfigPlan ist der einzige Weg, etwas vom Geraet zu ENTFERNEN:
`plan_type=missing` erzeugt ausschliesslich `add`-Zeilen, `intended` waere ein
Voll-Push (verboten, s. Regel 1). Alles, was Drift vom Typ `extra` beseitigt, ist
also zwangslaeufig ein manueller Plan.
GOVERNANCE (docs/teilprojekte.md TP22 -- verbindlich, nicht optional)
--------------------------------------------------------------------
1. Plans NUR feature-/manual-scoped, NIEMALS "full intended". Ein Voll-Push
schriebe woertlich `passphrase="<<REDACTED>>"` auf die APs (= alle WLAN-Clients
raus) bzw. `private-key="<<REDACTED>>"` auf den Core (= WG-Hub tot).
-> Dieses Skript weigert sich, ein config_set mit `<<REDACTED>>` zu senden.
2. Das AP-Template wird NIE zeilenweise auf ein laufendes Geraet gepusht
(setzt zwischendurch vlan-filtering=no -> reisst den Mgmt-Pfad ab).
3. Vor jedem Push ein `/system backup` auf dem Zielgeraet.
-> Dieses Skript prueft das nicht, es kann nur daran erinnern. Machen.
ZWEI TEUER BEZAHLTE LEHREN, DIE HIER FEST EINGEBAUT SIND
--------------------------------------------------------
* Nautobot-REST sortiert Sammelabfragen ALPHABETISCH. Am 2026-08-13 wurden
4 Device-UUIDs positional zugeordnet -> der Deploy ging an ap1 statt an den Core.
Schadlos nur, weil RouterOS die Kommandos ablehnte. -> `resolve_device()` fragt
IMMER genau einen Namen ab und besteht auf genau einem Treffer; nach dem Anlegen
wird `plan.device.display` gegen den Zielnamen assertet.
* Job-Status ist KEIN Wirksamkeitsnachweis. Der Deploy-Job meldet `SUCCESS`, waehrend
der Plan auf `Failed` steht -- und selbst ein `Completed`-Plan beweist nur, dass
Netmiko die Zeilen abgesetzt hat, nicht dass das Geraet sie angenommen hat.
-> Dieses Skript liest am Ende den PLAN-Status und sagt explizit, dass die
Gegenprobe am Geraet noch aussteht.
WEITERE ROUTEROS-FALLEN (Memory `ref_gc_push_gotchas_routeros`)
---------------------------------------------------------------
* Keine `print`-Zeilen in Config Plans -- `:log info` verwenden.
* Selbstprotokollierendes `:do { ... } on-error={ :log info "..." }` statt nackter
Kommandos: ein Netmiko-Fehlschlag hinterlaesst sonst Teil-Zustand (anders als
NAPALM, das atomar zurueckrollt).
* `napalm-ros` ist READ-ONLY. Der Push laeuft nur, weil in den Golden-Config-
Settings ein `custom_dispatcher` auf `NetmikoMikrotikRouteros` zeigt.
BENUTZUNG
---------
export NAUTOBOT_TOKEN=...
python3 nautobot/gc-configplan.py --job drift-cleanup # Dry-Run
python3 nautobot/gc-configplan.py --job drift-cleanup --apply # anlegen+deployen
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", "")
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
STATUS_NOT_APPROVED = "4d2d08bf-3bf2-4370-8c8b-248dff2618ab"
STATUS_APPROVED = "7869b4b9-542d-4ff7-a846-92a04943e75d"
JOB_DEPLOY_CONFIG_PLANS = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa"
JOB_GENERATE_CONFIG_PLANS = "f93bfa53-2332-4fa2-a6ab-5921c802fcee"
# Die Reihenfolge ist NICHT beliebig: die Compliance vergleicht die intended config
# gegen das letzte BACKUP, nicht gegen das Live-Geraet. Wer nach einer Geraetaenderung
# direkt "Compliance" laufen laesst, misst den Stand von vorgestern (TP22-Falle).
REFRESH_SEQUENCE = [
("Backup Configurations", "a48b8c7b-ad4b-4909-8344-1b4e758e75a8"),
("Generate Intended Configurations", "afe60af2-bcd1-4166-8976-1a78f49bc228"),
("Perform Configuration Compliance", "ed3bcb19-5c84-4ffd-a7ba-564703213997"),
]
MASK = "<<REDACTED>>"
# --------------------------------------------------------------------------- HTTP
def req(method, path, data=None):
url = path if path.startswith("http") else f"{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", f"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:
detail = e.read().decode()[:600]
print(f"!! {method} {url}\n payload={json.dumps(data)[:300]}\n -> {e.code} {detail}")
raise
# ------------------------------------------------------------------- Bausteine
def resolve_device(name):
"""Genau EIN Geraet per Name. Nie positional aus einer Sammelabfrage."""
q = urllib.parse.urlencode({"name": name})
res = req("GET", f"/dcim/devices/?{q}").get("results", [])
if len(res) != 1:
raise SystemExit(f"FEHLER: '{name}' liefert {len(res)} Treffer, erwartet genau 1")
dev = res[0]
if dev.get("name") != name:
raise SystemExit(f"FEHLER: API lieferte '{dev.get('name')}' statt '{name}'")
return dev["id"]
def guard_config_set(config_set, device_name):
"""Regel 1: nie eine Maske aufs Geraet schreiben."""
if MASK in config_set:
raise SystemExit(
f"ABBRUCH: config_set fuer {device_name} enthaelt {MASK}. "
"Das wuerde das echte Secret auf dem Geraet ueberschreiben (TP22-Regel 1)."
)
for line in config_set.splitlines():
if line.strip().startswith("print") or " print " in f" {line} ":
raise SystemExit(f"ABBRUCH: `print` in Config Plan ({device_name}) -- `:log info` verwenden.")
def create_plans(device_names, config_set, change_control_id, dry):
"""Plaene ueber den Job 'Generate Config Plans' anlegen.
WICHTIG: `POST /plugins/golden-config/config-plan/` ist **405 Method Not Allowed** --
das ViewSet ist read-only. ConfigPlans entstehen ausschliesslich ueber diesen Job.
Bei `plan_type=manual` landet der Inhalt des `commands`-TextVar als `config_set`
auf jedem ausgewaehlten Geraet (ein Plan pro Geraet).
"""
guard_config_set(config_set, ",".join(device_names))
dev_ids = [resolve_device(n) for n in device_names]
if dry:
print(f" [dry-run] Job 'Generate Config Plans' fuer {', '.join(device_names)} ({change_control_id})")
for line in config_set.strip().splitlines():
print(f" | {line}")
return []
run = req(
"POST",
f"/extras/jobs/{JOB_GENERATE_CONFIG_PLANS}/run/",
{
"data": {
"device": dev_ids,
"plan_type": "manual",
"commands": config_set,
"change_control_id": change_control_id,
}
},
)
jr = (run.get("job_result") or run).get("id")
wait_job(jr, "Generate Config Plans")
# Plaene ueber die change_control_id einsammeln -- nie positional aus einer
# 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 sorted(found, key=created):
name = (p.get("device") or {}).get("display")
by_name[name] = p["id"]
plans = []
for name in device_names:
pid = by_name.get(name)
if not pid:
raise SystemExit(f"ABBRUCH: kein Plan fuer '{name}' unter ccid '{change_control_id}' gefunden")
# Gegenprobe am Objekt selbst, nicht am Suchergebnis.
check = req("GET", f"/plugins/golden-config/config-plan/{pid}/?depth=1")
got = (check.get("device") or {}).get("display")
if got != name:
raise SystemExit(f"ABBRUCH: Plan {pid} haengt an '{got}', erwartet '{name}'. NICHT deployen.")
if check.get("config_set", "").strip() != config_set.strip():
raise SystemExit(f"ABBRUCH: config_set von Plan {pid} weicht ab -- nicht deployen.")
print(f" Plan {pid} -> '{name}' verifiziert (Device + config_set)")
plans.append((name, pid))
return plans
def wait_job(jr, label):
print(f" Job '{label}' gestartet, JobResult {jr}")
for _ in range(60):
time.sleep(5)
st = req("GET", f"/extras/job-results/{jr}/").get("status")
st = st.get("value") if isinstance(st, dict) else st
if str(st).upper() in ("SUCCESS", "FAILURE", "ERRORED", "REVOKED", "COMPLETED", "FAILED"):
print(f" Job-Status: {st} (!! kein Wirksamkeitsnachweis)")
return st
print(" !! Job-Timeout nach 5 Min -- Status manuell pruefen")
return None
def approve(plan_id):
req("PATCH", f"/plugins/golden-config/config-plan/{plan_id}/", {"status": STATUS_APPROVED})
def deploy(plan_ids):
"""Deploy-Job starten und auf das JobResult warten."""
run = req(
"POST",
f"/extras/jobs/{JOB_DEPLOY_CONFIG_PLANS}/run/",
{"data": {"config_plan": plan_ids}},
)
jr = (run.get("job_result") or run).get("id")
wait_job(jr, "Deploy Config Plans")
return jr
def refresh_compliance():
"""Backup -> Intended -> Compliance, strikt in dieser Reihenfolge, dann Bericht."""
for label, job_id in REFRESH_SEQUENCE:
print(f"\n-- {label}")
run = req("POST", f"/extras/jobs/{job_id}/run/", {"data": {}})
jr = (run.get("job_result") or run).get("id")
wait_job(jr, label)
print("\n== Compliance ==")
res = req("GET", "/plugins/golden-config/config-compliance/?limit=500&depth=1")
rows = res.get("results", [])
bad = [r for r in rows if not r.get("compliance")]
print(f" {len(rows) - len(bad)}/{len(rows)} compliant")
for r in bad:
dev = (r.get("device") or {}).get("display")
rule = r.get("rule")
rule = rule.get("display") if isinstance(rule, dict) else rule
print(f" !! {dev} | {rule}")
if r.get("extra"):
print(f" extra: {r['extra'].strip()[:200]}")
if r.get("missing"):
print(f" missing: {r['missing'].strip()[:200]}")
if not bad:
print(" keine Drifts.")
return bad
def plan_status(plan_id):
p = req("GET", f"/plugins/golden-config/config-plan/{plan_id}/?depth=1")
st = p.get("status")
return st.get("display") if isinstance(st, dict) else st
# ------------------------------------------------------------------------ Jobs
def job_drift_cleanup():
"""TP22-Compliance-Backlog: die 4 verbliebenen `extra`-Drifts entfernen.
ap1/ap2: Werks-`defconf` DNS-Static `router.lan -> 192.168.88.1`. Auf beiden APs
der EINZIGE statische Eintrag; die IP existiert im Netz nicht. Der Core hat sein
eigenes, korrektes `router.lan -> 10.40.99.1` (im Template, compliant).
crs326-branch: Bring-up-Rest `vlan10` + `10.40.10.2`. Reihenfolge Adresse ->
Interface. Beruehrt KEINEN Client-Traffic: `/interface bridge vlan` bleibt
unangetastet (`bridge` steht dort statisch als tagged-Member von VLAN10), es
faellt nur die L3-Praesenz des Geraets in VLAN10 weg. Die Deploy-Session selbst
laeuft ueber vlan99/10.40.99.2, also nicht ueber den abgebauten Pfad.
"""
ap_set = (
':do { /ip dns static remove [find address=192.168.88.1] } '
'on-error={ :log info "drift: ap dns-static remove hat geworfen" }\n'
':log info ("drift: rest-defconf-static=" . '
'[:tostr [:len [/ip dns static find address=192.168.88.1]]])'
)
branch_set = (
':do { /ip address remove [find address="10.40.10.2/24"] } '
'on-error={ :log info "drift: branch address remove hat geworfen" }\n'
':do { /interface vlan remove [find name="vlan10"] } '
'on-error={ :log info "drift: branch vlan10 remove hat geworfen" }\n'
':log info ("drift: branch rest-vlan10=" . '
'[:tostr [:len [/interface vlan find name="vlan10"]]] . " rest-addr=" . '
'[:tostr [:len [/ip address find address~"10.40.10."]]])'
)
# (Geraeteliste, commands, change_control_id) -- ap1/ap2 teilen sich das
# config_set, koennen also in einem Job-Lauf erzeugt werden.
return [
(["ap1", "ap2"], ap_set, "TP22-drift-aps-defconf-dns"),
(["crs326-branch"], branch_set, "TP22-drift-branch-temp-vlan10"),
]
def job_mgmt_acl():
"""TP16.1 (2026-08-17): Mgmt-Dienste + Address-ACL auf allen vier Geraeten.
WARUM: seit dem Cloudflare-Mesh kommt der Remote-Zugriff aus 100.96.0.0/12. Eine ACL,
die nur 10.40.0.0/16 kennt, laesst Ping durch und sperrt SSH/REST/MikroMCP aus -- genau
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.154) aus, also aus
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
SCHREIBWEISE: `/ip service set ssh ...` mit dem Dienstnamen, NICHT `[find name=ssh]` --
letzteres trifft auch den dynamischen Verbindungs-Eintrag (`D c ssh ... local <ip>`)
und quittiert mit `failure: this is configured elsewhere` (TP16.1-Befund).
"""
acl = "10.40.0.0/16,100.96.0.0/12"
def line(svc, attr):
return (
f":do {{ /ip service set {svc} {attr} }} "
f'on-error={{ :log info "mgmt-acl: {svc} hat geworfen" }}'
)
# Erst die Abschaltungen (Angriffsflaeche runter), dann die ACLs.
full_set = "\n".join(
[line(s, "disabled=yes") for s in ("ftp", "telnet", "api-ssl")]
+ [line(s, f"address={acl}") for s in ("ssh", "www", "winbox", "api")]
+ [
':log info ("mgmt-acl: ssh=" . [/ip service get [find name=ssh and !dynamic] address]'
' . " winbox=" . [/ip service get [find name=winbox and !dynamic] address]'
' . " telnet-disabled=" . [:tostr [/ip service get [find name=telnet and !dynamic] disabled]])'
]
)
# Der Core hat ftp/telnet/api-ssl bereits aus und ssh/www bereits korrekt --
# ihm fehlen nur winbox (war offen) und die Mesh-Range auf api.
core_set = "\n".join(
[line(s, f"address={acl}") for s in ("winbox", "api")]
+ [
':log info ("mgmt-acl: winbox=" . [/ip service get [find name=winbox and !dynamic] address]'
' . " api=" . [/ip service get [find name=api and !dynamic] address])'
]
)
return [
(["crs326-branch", "ap1", "ap2"], full_set, "TP16.1-mgmt-acl-mesh"),
(["crs326-core"], core_set, "TP16.1-mgmt-acl-mesh-core"),
]
def job_tp42_dhcp_domain():
"""TP42 (2026-09-02): DHCP-Search-Domain der 7 Netze emsenhuber.net -> bf.emsenhuber.net.
WARUM: das GC-Template verteilte `domain=emsenhuber.net` (bare), die internen A-Records leben
aber unter `bf.emsenhuber.net`. Folge: ein bare `macmini` loeste NICHT auf (nur der volle FQDN).
Zielschema-Entscheid (User 2026-09-02) = `bf.emsenhuber.net`. Das Template (Z.133) ist bereits
auf `bf.emsenhuber.net` gehoben; dieser Job zieht das laufende Geraet nach.
MODIFY, kein add: das Netz existiert -> `set [find]`, nicht die `add`-Zeile der intended config
(die wuerde `already have such address` werfen). `[find]` ohne Filter trifft alle 7 Netze.
KEIN LOCKOUT-RISIKO: `domain` ist eine DHCP-Option, sie beruehrt weder Routing noch Mgmt-Zugang;
Clients ziehen sie erst beim naechsten Lease-Renew (lease-time 1d).
"""
core_set = "\n".join([
':do { /ip dhcp-server network set [find] domain=bf.emsenhuber.net } '
'on-error={ :log info "tp42-dhcp: set geworfen" }',
':log info ("tp42-dhcp: bf-domains=" . [:tostr [:len [/ip dhcp-server network '
'find where domain="bf.emsenhuber.net"]]] . "/7")',
])
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
def job_tp38_oob():
"""TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall.
Zwei Geraete, zwei config_sets:
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
die laufende Nautobot-SSH-Session (10.40.20.154) matcht auf Regel 1 (established),
bevor Regel 8 (drop) greift.
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
waere wirkungslos + non-compliant).
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
(10.40.20.154), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
"""
bridge_set = (
':do { /interface bridge port set [find interface=ether23] pvid=99 '
'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } '
'on-error={ :log info "tp38: ether23 set geworfen" }'
)
vlan_set = (
':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } '
'on-error={ :log info "tp38: vlan99 untagged geworfen" }'
)
branch_fw = "\n".join([
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
'add action=drop chain=input comment="TP38: input deny-by-default"',
])
branch_set = "\n".join([
bridge_set,
vlan_set,
*[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()],
':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")',
])
# ⚠️ `add ... place-before=[find ...]` wird ueber Netmiko IGNORIERT (Regel landet am
# Ketten-Ende, also HINTER dem finalen drop = wirkungslos; beim Deploy 2026-09-15 so
# aufgeschlagen). Deshalb: erst add (appends), dann `move` vor die Log-Regel.
core_set = "\n".join([
bridge_set,
vlan_set,
'/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" '
'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp',
'/ip firewall filter move [find where comment="TP38: OOB->core mgmt"] '
'destination=[find where comment="TP5: input log-only"]',
':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])',
])
return [
(["crs326-branch"], branch_set, "TP38-oob-branch"),
(["crs326-core"], core_set, "TP38-oob-core"),
]
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,
}
# ------------------------------------------------------------------------ main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--job", choices=sorted(JOBS))
ap.add_argument("--apply", action="store_true", help="schreiben (sonst Dry-Run)")
ap.add_argument("--no-deploy", action="store_true", help="Plaene nur anlegen, nicht deployen")
ap.add_argument(
"--deploy-ids",
nargs="+",
metavar="PLAN_ID",
help="bereits angelegte Plaene gestaffelt deployen (Risiko-Reihenfolge selbst waehlen)",
)
ap.add_argument(
"--refresh-compliance",
action="store_true",
help="Backup -> Generate Intended -> Compliance (in dieser Reihenfolge) + Ergebnisbericht",
)
args = ap.parse_args()
if not TOKEN:
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt")
# Gestaffelter Deploy bereits angelegter Plaene -- fuer Faelle, in denen man
# das riskantere Geraet erst anfassen will, nachdem das harmlose verifiziert ist.
if args.deploy_ids:
for pid in args.deploy_ids:
p = req("GET", f"/plugins/golden-config/config-plan/{pid}/?depth=1")
print(f" {pid} -> {(p.get('device') or {}).get('display')} [{plan_status(pid)}]")
deploy(args.deploy_ids)
print("\n== Plan-Status (massgeblich, nicht der Job-Status) ==")
for pid in args.deploy_ids:
p = req("GET", f"/plugins/golden-config/config-plan/{pid}/?depth=1")
print(f" {(p.get('device') or {}).get('display'):15s} {plan_status(pid)}")
print("\nJetzt am GERAET gegenpruefen -- 'Completed' beweist nichts.")
return
if args.refresh_compliance:
refresh_compliance()
return
if not args.job:
raise SystemExit("--job, --deploy-ids oder --refresh-compliance angeben")
dry = not args.apply
print(f"== {args.job} == {'DRY-RUN' if dry else 'APPLY'}")
if not dry:
print(" Erinnerung Regel 3: `/system backup` auf allen Zielgeraeten vorhanden?")
plans = []
for device_names, config_set, ccid in JOBS[args.job]():
print(f"\n-- {', '.join(device_names)} ({ccid})")
plans.extend(create_plans(device_names, config_set, ccid, dry))
if dry or not plans:
print("\nDry-Run beendet -- nichts geschrieben." if dry else "\nKeine Plaene angelegt.")
return
for _, pid in plans:
approve(pid)
print(f"\n{len(plans)} Plaene auf 'Approved' gesetzt")
if args.no_deploy:
print("--no-deploy: Deploy uebersprungen.")
return
print("\n== Deploy ==")
deploy([pid for _, pid in plans])
print("\n== Plan-Status (massgeblich, nicht der Job-Status) ==")
for name, pid in plans:
print(f" {name:15s} {plan_status(pid)} ({pid})")
print(
"\nNOCH NICHT FERTIG:\n"
" 1. Am GERAET gegenpruefen (MikroMCP) -- Plan 'Completed' beweist nichts.\n"
" 2. Danach in dieser Reihenfolge: Backup -> Generate Intended -> Compliance.\n"
" Die Compliance vergleicht gegen das letzte BACKUP, nicht gegen das Geraet."
)
if __name__ == "__main__":
main()