Die 7 DHCP-Pools starteten alle bei .100 und ueberlappten damit die beiden Oktett-Baender, die dieses TP gerade belegt: .100-.149 (Server-Hosts) und .150-.199 (App-Services). In VLAN20 ist das eine echte Kollisionsflaeche -- nautobot .154-.158, gitea .157, pve-mcp .173 und shelly-mcp .180 tragen ihre IP GERAETELOKAL und stehen in keinem Lease-Eintrag, der Core kann sie also nicht schuetzen und einem DHCP-Client .154 anbieten. Entscheid 2026-09-21: nur pool-v20 und pool-v30 werden verengt, v20 zuerst. VLAN10/40/50/90/99 bleiben bei .100-.200 -- dort gibt es kein Schema-Band, das etwas gewoenne, und VLAN40/VLAN10 haetten sonst 11 Sonos (.191-.201), den iPhone (.196) und den Mac (.82) mitgezogen. VLAN20 kostet die Verengung nichts: eine statische Reservierung (nas-bf .20.10, in .10x) und 0 dynamische Leases. pool-v30 bleibt bewusst unangetastet -- dort liegen 35 statische Reservierungen IM Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot .227, Twinkly .228, Withings .230/.231, lichterketten .236/.237, Nuki .241/.244, CCU .232). Erst Schicht B zieht die um, dann pool-v30. Nebenbei faellt damit die .30.250-Kollision: der Sonoff Dongle-M sitzt dort statisch, der Pool reicht heute bis .250. Neuer Job `tp45-pool-v20` (gc-configplan.py) mit gezaehltem Selektor: ein `find where`, das 0 trifft, sieht wie "nicht vorhanden" aus statt wie "kaputt" -- ein blankes `set [find where name=...]` liefe ins Leere und der Job meldete Erfolg. 🔴 Lehre aus dem ersten Fehlversuch: jedes config_set-Statement muss auf EINER Zeile klammer-vollstaendig sein. Mit `:if (...) do={` am Zeilenende geht RouterOS in den Fortsetzungs-Modus und zeigt einen anderen Prompt, der netmiko-Treiber verliert sein Muster und der Deploy stirbt mit `E1015: Pattern not detected: ...@crs326\-core\].*$|#.*$)` -- Plan auf Failed, ohne etwas geschrieben zu haben. Abnahme: Gerate-Read pool-v20 = 10.40.20.200-10.40.20.249 (total=50, used=0); DHCPOFFER-Probe auf vmbr0.20 -> 10.40.20.200 mit Domain Name Server 10.40.20.30, 10.40.20.31; Probe-Lease wieder entfernt; Compliance 140/140 ohne Drift. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
692 lines
33 KiB
Python
692 lines
33 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 json
|
|
import os
|
|
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")]
|
|
|
|
|
|
JOBS = {
|
|
"drift-cleanup": job_drift_cleanup,
|
|
"mgmt-acl": job_mgmt_acl,
|
|
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
|
"tp38-oob": job_tp38_oob,
|
|
"tp45-agh-fw": job_tp45_agh_fw,
|
|
"tp45-agh-dhcp": job_tp45_agh_dhcp,
|
|
"tp45-pool-v20": job_tp45_pool_v20,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------------ 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()
|