Files
bf-infra/nautobot/gc-configplan.py

418 lines
18 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
BASE = "http://10.40.20.30: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", [])
by_name = {}
for p in found:
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.30) 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"),
]
JOBS = {"drift-cleanup": job_drift_cleanup, "mgmt-acl": job_mgmt_acl}
# ------------------------------------------------------------------------ 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()