C1 - Der Core haelt jetzt 27 /ip dns static (vorher 10). Die 12 toten Namen loesen wieder auf: 9 Caddy-Frontends, tv.bf/mfc.bf, das bisher nirgends existierende ap3.bf, dazu pve.bf und pve-mcp.bf von der Host-IP auf .40 und die 5 bare Namen. Neuer GC-Job tp45-c-dns; er liest die Zeilen aus dem Template selbst und macht daraus einen idempotenten Upsert. C2 - AGH entdoppelt: upstream_dns nur noch 10.40.20.1, alle 26 Rewrites raus. Der Core ist damit die einzige Quelle. /admin ist lokal wieder erreichbar (header-frei 200, mit CF-Connecting-IP weiter 404 - der Guard bleibt zu). C3 - ds-bf-Ingress-Origin auf https://10.40.20.40:443 + SNI (CF-Write). Oeffentlich 200 in 0,24 s statt 000 nach 20 s. Abnahme: Geraete-Read (27 Statics), Compliance 145/145 / 0 Drifts, dig vom Mac fuer alle Namen, /admin lokal 200, ds-bf oeffentlich 200, Filter (doubleclick -> 0.0.0.0) intakt, vw/hass/n8n/gitea unveraendert. Offener Befund: der Core reicht AAAA weiter und liefert damit fuer die bare Namen die CF-IPv6. Heute wirkungslos (kein globales IPv6 im Netz), aber latent - bewusst dokumentiert statt geaendert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1026 lines
51 KiB
Python
1026 lines
51 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")]
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------------ 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()
|