Files
bf-infra/nautobot/gc-configplan.py
Christoph Emsenhuber 1fddf5d798 TP10: Mesh-WARP-Quelle fuer FM/DW-Spokes auf BF-IP NATen
Spoke-FritzBoxen akzeptieren am WG-Peer nur Quell-IPs aus 10.40.0.0/16; WARP-Clients kommen ueber cf-mesh mit 100.96.0.0/12 und wurden verworfen. src-nat auf wg-hub-Egress legt die Quelle in den AllowedIPs-Raum.

Template (Soll) + idempotenter Job tp10-mesh-nat (manual Plan, nie full-intended). Compliance 145/145, end-to-end verifiziert (Mac/WARP -> 10.95.10.1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 15:56:44 +02:00

1055 lines
52 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, tag="tp45-v30"):
"""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 ("{tag}: {mac} -> {soll}") }} '
f'else={{ :log info ("{tag}: {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")]
def job_tp45_v30_revert():
"""RUECKNAHME 2026-09-21: die 26 Shelly-Reservierungen zurueck auf ihre ALTEN Adressen.
Christoph-Direktive, sofort: *„mach die shellys sofort rueckgaengig! das gibt
sonst ein riesen durcheinander mit home assistant"*. Grund: HA hat die Shellys
mit ihren alten Adressen eingebunden -- ein Umzug zerreisst die Integrationen.
🔴 **NUR die Shellys** — das war die erste, eng gefasste Ruecknahme. Die
uebrigen 10 Umzuege aus `job_tp45_v30_leases` (NukiBridge .11, P750W .13,
DTUBI .14, NukiSmartlock .177, SwitchBot .178, Twinkly .179, lichterketten
.190/.191, Withings .195/.196) und die beiden Sonderfaelle (CCU-Leiche
entfernt, NSPanelPro statisch auf .12) liess er unangetastet.
⚠️ **Noch am selben Tag ueberholt:** auf die weitergehende Direktive
(„wir sollten ALLE vlan30-aenderungen rueckgaengig machen" — Sperr-Risiko
Nuki) folgte `job_tp45_v30_restore`, der auch diese 10 plus beide
Sonderfaelle zurueckdreht. Dieser Job bleibt als der engere, zuerst
gelaufene Schritt bestehen.
Die Tabelle steht in `tp45-leases.py` als `REVERT` (abgeleitet aus `MOVES`,
gefiltert auf die 26 Shelly-MACs) -- NICHT hier, aus demselben Grund wie
`MOVES`: eine zweite Kopie laeuft still auseinander.
⚠️ **Reihenfolge:** erst `python3 nautobot/tp45-leases.py revert --apply`
(Soll zurueck), DANN diesen Job. Umgekehrt meldet GC sofort wieder Drift --
das Geraet waere richtig und das Soll falsch.
Abnahme: Geraete-Read `proplist=address,comment where server=dhcp-v30` -- die
26 Shellys muessen auf .202-.226/.233 stehen, die uebrigen 13 Eintraege
unveraendert. Danach `--refresh-compliance`, erwartet 145/145 und 0 Drifts.
"""
mod = _tp45_leases_module()
lines = [_lease_move(mac, alt, tag="tp45-v30-revert")
for mac, (_neu, alt) in sorted(mod.REVERT.items())]
# Abnahme-Zeile: die alten Baender muessen wieder voll sein, die neuen leer.
# Bewusst ein Bereichs-Paar statt aller 26 Adressen -- .202/.233 rahmen den
# alten Shelly-Block ein, .151/.176 den neuen.
lines.append(
':log info ("tp45-v30-revert: .151=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.151]]] . " .176=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.176]]] . " .202=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.202]]] . '
' " .233=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.233]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-v30-shelly-revert")]
def job_tp45_v30_restore():
"""VOLLRUECKNAHME 2026-09-21: alle VLAN30-Aenderungen aus B2 zuruecknehmen.
Christoph-Direktive: *„wir sollten alle vlan30-aenderungen rueckgaengig machen.
du koenntest mich auf diesem wege aus meiner wohnung aussperren."*
Was B2 am 2026-09-21 getan hat, und was hier einzeln zurueckgedreht wird:
1. **36 Reservierungs-Adressen** verschoben -> hier ALLE 36 zurueck auf ihre
`MOVES`-Ist-Adresse (idempotent: die 26 Shellys stehen schon dort, ein
zweites `set` auf dieselbe Adresse ist ein No-Op).
2. **CCU-Leiche entfernt** (`BC:24:11:8E:0A:57`, .30.232) -> wieder angelegt,
wortgleich der Stand vor B2. Ob die Leiche spaeter geloescht werden soll,
ist eine **eigene** Entscheidung, nicht Teil dieser Ruecknahme.
3. **SONOFF-NSPanelPro auf statisch .12 gepinnt** -> Reservierung entfernt;
vor B2 hatte es keine (reiner DHCP-Client auf .249). `remove` ist der
Weg, weil RouterOS 7.24 kein `make-dynamic` kennt (s. u.).
⚠️ **Reihenfolge:** erst `python3 nautobot/tp45-leases.py restore --apply`
(Soll zurueck), DANN diesen Job.
🔴 **Warum die Eile berechtigt war:** die Reservierung eines Geraets mit
**lokaler** IP zu aendern, bewegt das Geraet nicht — aber bei einem Geraet,
das **DHCP** nutzt, haette die naechste Erneuerung es auf die neue Adresse
gezogen, und IP-gebundene Einbindungen (Home Assistant: Nuki, Shelly, …)
waeren gerissen. Dass hier nichts passiert ist, war Glueck, kein Entwurf.
Abnahme: Geraete-Read der v30-Leases + ARP-Band-Zaehlung; danach
`--refresh-compliance`, erwartet 145/145 und 0 Drifts.
"""
mod = _tp45_leases_module()
lines = [_lease_move(mac, ist, tag="tp45-v30-restore")
for mac, (ist, _soll) in sorted(mod.MOVES.items())]
# NSPanelPro: die statische Reservierung ENTFERNEN -- vor B2 hatte es keine,
# es war ein reiner DHCP-Client (dynamische Lease auf .249).
#
# 🔴 `make-dynamic` gibt es in RouterOS 7.24 NICHT: `/ip dhcp-server lease
# make-dynamic` antwortet `bad command name make-dynamic` (live gemessen
# 2026-09-21). Der erste Versuch dieses Jobs ist daran gestorben und hat den
# Plan auf `Failed` gesetzt, OBWOHL alle uebrigen Statements gelandet sind —
# genau die Klasse „Completed/Failed beweist nichts, das Geraet ist die
# Wahrheit". `make-static` existiert (B2 hat es benutzt), das Gegenstueck
# fehlt; der Rueckweg ist `remove`.
#
# ⚠️ Das Panel haelt .12 noch bis zum Lease-Ablauf (~20 h, `status=bound`,
# `active-address=10.40.30.12`) und faellt danach in den Pool zurueck.
# Das ist eine von B2 erzeugte Uebergangserscheinung, keine neue Aenderung.
for add in mod.ADDS:
mac = re.search(r"mac-address=(\S+)", add).group(1)
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-restore: NSPanelPro-Reservierung entfernt") }} '
f'else={{ :log info ("tp45-v30-restore: NSPanelPro hat keine Reservierung") }}')
# CCU-Leiche wieder anlegen -- nur wenn sie fehlt.
for mac in sorted(mod.REMOVE):
find = f'find where mac-address={mac}'
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 0) do={{ '
f'/ip dhcp-server lease {mod.RESTORE_ADD}; '
f':log info ("tp45-v30-restore: CCU-Leiche wieder angelegt") }} '
f'else={{ :log info ("tp45-v30-restore: CCU-Leiche ist schon da") }}')
# Abnahme: die alten Baender muessen voll sein, die neuen leer.
lines.append(
':log info ("tp45-v30-restore: alt .202=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.202]]] . " .233=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.233]]] . " .244=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.244]]] . '
' " neu .11=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.11]]] . " .151=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .177=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.177]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-v30-restore")]
def _dns_static_lines():
"""Die `/ip dns static add`-Zeilen aus dem GC-Template, wortgetreu.
🔴 **Quelle ist das Template selbst, NICHT eine zweite Kopie.** Eine handgepflegte
Liste hier liefe beim ersten Template-Edit auseinander — still, weil beide Seiten
fuer sich plausibel aussehen; am Geraet faellt nur auf, dass ein Name fehlt.
Und die Kommentare muessen **zeichengleich** sein: Golden Config vergleicht
`comment=` mit, ein abweichender Text ist eine Drift.
Der `/ip dns static`-Block ist ein Literal im Template (im Gegensatz zu
`firewall_filter` und `dhcp_leases`, die als Daten aus
`local_config_context_data` kommen) — deshalb ist das Parsen hier zulaessig.
"""
text = (pathlib.Path(__file__).with_name("mikrotik_routeros.j2")).read_text()
return [ln.strip() for ln in text.splitlines()
if ln.strip().startswith("/ip dns static add ")]
def _dns_upsert(line):
"""Eine Template-Zeile -> idempotenter Upsert, EINZEILIG und klammer-vollstaendig.
`/ip dns static add` ist NICHT idempotent — ein zweiter Lauf legte den Namen ein
zweites Mal an. Deshalb: nur anlegen, wenn der Name fehlt, sonst in place setzen.
Der Selektor wird wie in `_lease_move` gezaehlt (`find where …` in `[:len …]`,
`[find where …]` als Argument) — die vertauschte Form ist ein Syntaxfehler, der
als `Completed` durchlaeuft und das Geraet unveraendert laesst.
"""
addr = re.search(r"address=(\S+)", line).group(1)
name = re.search(r"name=(\S+)", line).group(1)
cm = re.search(r'comment=("(?:[^"\\]|\\.)*"|\S+)', line)
comment = f" comment={cm.group(1)}" if cm else ""
return (f':if ([:len [/ip dns static find where name={name}]] = 0) do={{ '
f'{line} }} '
f'else={{ /ip dns static set [find where name={name}] address={addr}{comment} }}')
def job_tp45_c_dns():
"""TP45 Schicht C (2026-09-22): die internen Namen in den CORE — der FQDN-Fix.
Hintergrund: AGH wertet seine DNS-Rewrites nur im **Filter-Pfad** aus. Fuer
unfiltrierte Clients (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`,
`grp-media`, `grp-mgmt`) greifen sie nicht, und die fallen auf den conditional
forward `[/bf.emsenhuber.net/] -> 10.40.20.1` zurueck — den Core. Der hielt
aber nur 10 Statics; 12 Namen existierten nur als AGH-Rewrite und waren damit
**NXDOMAIN**. Vom Client gemessen 2026-09-22: alle 12 leer, `vw.emsenhuber.net`
lieferte die Cloudflare-Anycast-IP statt `.40` — und genau daher kam der
404 auf `/admin` (der CF-Weg setzt `CF-Connecting-IP`, der `@cf_admin`-Guard
antwortet darauf mit 404).
Dieser Job bringt die Statics auf das Geraet. Danach beantwortet der Core alle
internen Namen unabhaengig vom Filterstatus des Clients — und, weil die Namen
ueber Caddy laufen, mit gueltigem TLS.
⚠️ **Die 5 bare Namen (`vw`, `hass`, `n8n`, `gitea`, `ds-bf`) kommen bewusst mit
auf `.40`** (Christoph-Entscheid 2026-09-22): der lokale, header-freie Weg ist
die einzige Tuer zum Vault-Admin. ⚠️ Nebenwirkung: diese Namen werden damit fuer
**alle** VLANs aufloesbar, nicht nur fuer MAIN — MEDIA (`*A`) und GUEST
(`*B`+`*A`) erreichen `.40:443` ebenfalls. Einhegung ist ein eigener Schritt.
Rückweg: die Vorher-Werte stehen als `A1`/`A2`-Tabellen im Plan
(`~/.claude/plans/…`); vor dem Deploy liegt ein Backup auf dem Core.
"""
lines = [_dns_upsert(ln) for ln in _dns_static_lines()]
# Abnahme-Zeile: die Zahl der Statics plus drei Stichproben, die den Ausfall
# repraesentieren (ap3 gab es vorher nirgends, pve zeigt auf Caddy, vw muss
# `.40` sein — nicht die CF-Anycast-IP).
lines.append(
':do { :log info ("tp45-c-dns: statics=" . [:tostr [:len [/ip dns static find]]] '
'. " ap3=" . [:tostr [:len [/ip dns static find where name=ap3.bf.emsenhuber.net]]] '
'. " vw=" . [/ip dns static get [find where name=vw.emsenhuber.net] address] '
'. " pve=" . [/ip dns static get [find where name=pve.bf.emsenhuber.net] address]) } '
f'on-error={{ :log info "tp45-c-dns: Abnahme-Zeile geworfen" }}')
return [(["crs326-core"], "\n".join(lines), "TP45-c-dns")]
def job_tp10_mesh_nat():
"""TP10 (2026-10-03): Mesh-WARP-Verkehr Richtung FM/DW-Spokes auf eine BF-IP NATen.
WARUM: die Spoke-FritzBoxen akzeptieren am WG-Peer nur Quell-IPs aus 10.40.0.0/16
(AllowedIPs). WARP-Clients kommen ueber cf-mesh mit 100.96.0.0/12 -> die FritzBox
verwirft den Verkehr (bewiesen: ping src=100.96.0.6 -> 100% Loss, src=10.40.20.1
-> 0%). Eine src-nat auf dem wg-hub-Egress legt die Quelle in den AllowedIPs-Raum.
`action=src-nat` (NICHT masquerade): wg-hub hat keine Interface-IP, masquerade
haette keine Quelle. src-nat auf eine feste BF-IP (10.40.10.1) haelt zudem das
Connection-Tracking -- die Antwort an 10.40.10.1 wird im prerouting auf die
Mesh-IP zurueckuebersetzt und via cf-mesh geroutet.
KEIN LOCKOUT-RISIKO: reine srcnat-Ergaenzung; der no-NAT-accept (dst 10.40.0.0/16)
und der WAN-masquerade bleiben unberuehrt, die Regel matcht nur out-interface=wg-hub.
"""
cmt = "TP10: NAT mesh source to WG spokes (AllowedIPs)"
find = f'find where comment="{cmt}"'
add = (f'/ip firewall nat add action=src-nat chain=srcnat comment="{cmt}" '
f'out-interface=wg-hub src-address=100.96.0.0/12 to-addresses=10.40.10.1')
lines = [
f':if ([:len [/ip firewall nat {find}]] = 0) do={{ {add} }} '
f'else={{ :log info "tp10-mesh-nat: Regel existiert schon" }}',
f':log info ("tp10-mesh-nat: regeln=" . [:tostr [:len [/ip firewall nat {find}]]])',
]
return [(["crs326-core"], "\n".join(lines), "TP10-mesh-nat")]
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-c-dns": job_tp45_c_dns,
"tp45-pool-v20": job_tp45_pool_v20,
"tp45-v30-leases": job_tp45_v30_leases,
"tp45-v30-revert": job_tp45_v30_revert,
"tp45-v30-restore": job_tp45_v30_restore,
"tp10-mesh-nat": job_tp10_mesh_nat,
}
# ------------------------------------------------------------------------ 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()