Ein Hebel, sonst nichts. 2026-08-31 08:09 am Manager crs326-core: /interface wifi security set [find name="sec-main"] ft-over-ds=no /interface wifi capsman remote-cap provision [find] ft=yes bleibt -- FT laeuft weiter over-the-air, nur nicht mehr over-the-DS. Nimmt exakt die Aenderung vom 2026-08-17 zurueck. Abnahme (Lease, nicht Registration-Table): - Manager, alle vier aktiven Master-VAPs cap-wifi3/4/5/6: ft=true ft-over-ds=false - alle 3 CAPs state=Ok, alle VAPs running=yes AM CAP gelesen - iPhone nach 71 s zurueck auf dirtyvader-main, frische Lease 10.40.10.196, auth-type=ft-wpa3-psk, -54 dBm; 30 Clients gesamt zurueck - Log 08:13:20 zeigt sauberes "roamed to" zwischen zwei APs -> Roaming intakt - Geraete-Backup vorher: pre-tp25-funde-ftoverds.backup Governance: Transaktion TP25-fund-e-ft-over-ds OFFEN (Scope wifi-security, Ablauf 2026-09-03). Compliance steht deshalb ABSICHTLICH auf 134/135; der Diff ist exakt ft-over-ds=yes->no, kein Kollateral-Drift durch die Re-Provisionierung. Das GC-Template wird erst beim COMMIT nachgezogen -- vorher gruen machen wuerde den laufenden Versuch unsichtbar machen, und gc-transaktion.py verweigert den Commit ohnehin, solange der Scope driftet. 135/135 kommt nach der Messung zurueck. Messstand nach 25 min: weiter 93, letzter reauthenticating 07:34:43 (vor dem Eingriff). Logging nachweislich lebendig (593 wireless-Zeilen heute, beide SSIDs). Noch kein Ergebnis, nur ein sauberer Schnitt. Drei Funde: - cap-wifiN-Karte hat sich beim Re-Provisionieren VERSCHOBEN: ap2 war 1/2 -> jetzt 3/5, ap3 war 5/6 -> jetzt 4/6, ap1 hat 1/2 bekommen. Wer die 07:xx-Zeilen gegen die neuen liest, ordnet dem falschen AP zu. - Korrektur am Vormittagsbefund: main unterscheidet sich in ZWEI Attributen, nicht einem -- es ist auch die einzige SSID ohne wpa2-psk. Die Kontrollgruppe trennt FT und WPA3-only nicht. Wenn Hebel 1 und 2 beide nichts bewegen, ist authentication-types der naechste Kandidat, NICHT signal-range. - gc-transaktion.py rief sshpass -p auf (Passwort in argv, Klasse vom 2026-08-22). Auf SSHPASS-Env + -e umgestellt, weil das Skript laufen musste. Fuenf weitere Dateien unter routeros/ haben dasselbe Muster und sind bewusst NICHT angefasst (Stop-Bedingung; gehoert zu TP30). Entwarnung: /export auf 7.24 gibt Passphrasen gar nicht aus (live geprueft), die Transaktionsakte im Git ist sauber. Nebenbei: .gitignore hatte docs/nas-vlan20/stacks/ MIT Schraegstrich -- im Worktree liegt dort ein Symlink auf den Haupt-Checkout, der war damit nicht ignoriert. Doku an allen drei Stellen nachgezogen (Quick-Index, TP25-Abschnitt, Ist-Stand-Block) plus CLAUDE.md-Pflichtlektuere und neuer Handoff mit Entscheidungstabelle fuer 09-01. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
349 lines
15 KiB
Python
349 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
r"""Testaenderungen als Transaktion fuehren: begin / status / commit / rollback.
|
|
|
|
Das Problem, das dieses Skript loest
|
|
------------------------------------
|
|
Eine Aenderung, die am Geraet steht, aber nicht in der SoT, ist heute schlicht
|
|
**Drift** — und Drift sieht immer gleich aus, egal ob er ein Versehen ist oder ein
|
|
laufender Versuch. Wer testen will, hat also die Wahl zwischen "Compliance kaputt"
|
|
und "gar nicht testen". Beides ist falsch.
|
|
|
|
Die Uebersetzung von BEGIN/COMMIT/ROLLBACK auf diesen Stack:
|
|
|
|
BEGIN Snapshot des Geraets + Akte mit Geraet, Scope und ABLAUFDATUM.
|
|
Die Aenderung wird ganz normal per gc-manual-plan.py deployt und
|
|
ist am Geraet sofort wirksam — nur eben nicht in der SoT.
|
|
offen Compliance meldet den Drift weiter, aber `status` ordnet ihn zu:
|
|
"erwartet (offene Transaktion)" statt "unerklaert".
|
|
COMMIT Erst das Template/den Config-Context nachziehen, dann committen.
|
|
Das Skript verweigert den Commit, solange der Scope noch driftet —
|
|
denn dann waere die SoT gar nicht nachgezogen worden.
|
|
ROLLBACK Erzeugt aus dem Snapshot einen Rueckbau-Vorschlag fuer den Scope.
|
|
Bewusst NICHT automatisch gepusht: ein Rueckbau, den niemand
|
|
gelesen hat, ist genau die Sorte Automatik, die dieses Projekt
|
|
schon zweimal teuer bezahlt hat.
|
|
|
|
Drei Regeln, fest eingebaut
|
|
---------------------------
|
|
1. **Ablaufdatum ist Pflicht.** Eine offene Transaktion ohne Frist ist nichts
|
|
anderes als undokumentierter Drift, nur mit besserem Gewissen.
|
|
2. **Der Scope ist eine ComplianceRule**, kein Freibrief. `--scope bridge-vlan`
|
|
heisst: Abweichungen in `/interface bridge vlan` sind erwartet, alles andere
|
|
bleibt scharf.
|
|
3. **Eine offene Transaktion je Geraet.** Sonst ist beim Rollback nicht mehr
|
|
entscheidbar, wohin zurueck.
|
|
|
|
Aufruf
|
|
------
|
|
python3 nautobot/gc-transaktion.py begin --device crs326-core \
|
|
--scope bridge-vlan --change-id TEST-ether3 --expires 2026-08-28 \
|
|
--reason "ap1 testweise am Sideboard, Uplink auf ether3"
|
|
python3 nautobot/gc-transaktion.py status
|
|
python3 nautobot/gc-transaktion.py commit --change-id TEST-ether3
|
|
python3 nautobot/gc-transaktion.py rollback --change-id TEST-ether3
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.request
|
|
|
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
|
REPO = os.path.dirname(HIER)
|
|
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
|
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
|
|
|
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
|
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
|
IPS = {"crs326-core": "10.40.99.1", "crs326-branch": "10.40.99.2",
|
|
"ap1": "10.40.99.11", "ap2": "10.40.99.12"}
|
|
|
|
|
|
# --------------------------------------------------------------------------- Nautobot
|
|
def token():
|
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN=([A-Za-z0-9]+)", open(STACK).read())
|
|
if not m:
|
|
sys.exit("Token nicht in %s gefunden." % STACK)
|
|
return m.group(1)
|
|
|
|
|
|
def nb_get(pfad):
|
|
req = urllib.request.Request(NB + pfad, headers={
|
|
"Authorization": "Token " + token(), "Accept": "application/json"})
|
|
return json.load(urllib.request.urlopen(req, timeout=30))
|
|
|
|
|
|
def regeln():
|
|
"""ComplianceRule-Name -> match_config (die Abschnitte, die sie prueft)."""
|
|
d = nb_get("/api/plugins/golden-config/compliance-rule/?limit=100&depth=1")
|
|
out = {}
|
|
for r in d["results"]:
|
|
feat = r.get("feature") or {}
|
|
name = feat.get("name") or feat.get("display")
|
|
out[name] = [z.strip() for z in (r.get("match_config") or "").splitlines() if z.strip()]
|
|
return out
|
|
|
|
|
|
def compliance(device):
|
|
"""Feature -> compliant? fuer ein Geraet."""
|
|
d = nb_get("/api/plugins/golden-config/config-compliance/?limit=200&depth=1"
|
|
"&device=%s" % device)
|
|
# 🔴 Der Feature-Name steht bei depth=1 NICHT ausgeschrieben in rule.feature — dort
|
|
# kommt nur eine URL. Verwendbar ist rule.display ("RouterOS - bridge-vlan");
|
|
# der Teil nach dem letzten " - " ist der Regelname. Ohne das landete jeder Drift
|
|
# als "ausserhalb des Scopes" im Bericht, obwohl er genau der Scope war.
|
|
out = {}
|
|
for r in d["results"]:
|
|
rule = r.get("rule") or {}
|
|
anzeige = rule.get("display") if isinstance(rule, dict) else str(rule)
|
|
name = (anzeige or "").split(" - ")[-1].strip()
|
|
out[name] = bool(r.get("compliance"))
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------- Geraet
|
|
def geheim(device):
|
|
env = PW_ENV.get(device)
|
|
if not env:
|
|
sys.exit("Kein Passwort-Env fuer %r bekannt." % device)
|
|
wert = os.environ.get(env)
|
|
if wert:
|
|
return wert
|
|
pfad = os.path.expanduser("~/.secrets/credentials.env")
|
|
m = re.search(r"^%s=(.*)$" % env, open(pfad).read(), re.M)
|
|
if not m:
|
|
sys.exit("$%s weder im Environment noch in %s." % (env, pfad))
|
|
return m.group(1).strip().strip("\"'")
|
|
|
|
|
|
def export(device):
|
|
"""/export vom Geraet holen — der Snapshot, gegen den spaeter zurueckgebaut wird."""
|
|
ip = IPS.get(device) or sys.exit("Keine IP fuer %r." % device)
|
|
# 🔴 `sshpass -p` schreibt das Passwort in argv, wo es in `ps` und jedem
|
|
# argv-Trace steht — genau so ist am 2026-08-22 das Core-Passwort in ein
|
|
# Transkript geraten. `-e` haelt es in der Umgebung des Kindprozesses.
|
|
umgebung = dict(os.environ, SSHPASS=geheim(device))
|
|
p = subprocess.run(["sshpass", "-e", "ssh",
|
|
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
|
"admin@" + ip, "/export"],
|
|
capture_output=True, text=True, timeout=120, env=umgebung)
|
|
if p.returncode != 0:
|
|
sys.exit("Export fehlgeschlagen (rc=%d). %s" % (p.returncode, p.stderr[:200]))
|
|
return p.stdout
|
|
|
|
|
|
def abschnitt(text, praefixe):
|
|
"""Die Zeilen aus einem /export, die zu den match_config-Praefixen gehoeren.
|
|
|
|
RouterOS-Exporte schreiben die Sektion einmal als Kopfzeile und darunter die
|
|
Eintraege ohne Pfad — beides muss mit.
|
|
"""
|
|
raus, aktiv = [], False
|
|
for zeile in text.splitlines():
|
|
s = zeile.strip()
|
|
if s.startswith("/"):
|
|
aktiv = any(s.startswith(p) for p in praefixe)
|
|
if aktiv:
|
|
raus.append(s)
|
|
continue
|
|
if aktiv and s and not s.startswith("#"):
|
|
raus.append(s)
|
|
return raus
|
|
|
|
|
|
# --------------------------------------------------------------------------- Akten
|
|
def akte_pfad(change_id):
|
|
return os.path.join(AKTEN, "%s.json" % change_id)
|
|
|
|
|
|
def akten():
|
|
if not os.path.isdir(AKTEN):
|
|
return []
|
|
out = []
|
|
for n in sorted(os.listdir(AKTEN)):
|
|
if n.endswith(".json"):
|
|
out.append(json.load(open(os.path.join(AKTEN, n))))
|
|
return out
|
|
|
|
|
|
def schreibe(akte):
|
|
os.makedirs(AKTEN, exist_ok=True)
|
|
with open(akte_pfad(akte["change_id"]), "w") as fh:
|
|
json.dump(akte, fh, indent=2, ensure_ascii=False)
|
|
fh.write("\n")
|
|
|
|
|
|
def heute():
|
|
return datetime.date.today().isoformat()
|
|
|
|
|
|
# --------------------------------------------------------------------------- Befehle
|
|
def cmd_begin(a):
|
|
if os.path.exists(akte_pfad(a.change_id)):
|
|
sys.exit("Es gibt bereits eine Akte %s." % a.change_id)
|
|
offen = [t for t in akten() if t["state"] == "open" and t["device"] == a.device]
|
|
if offen:
|
|
sys.exit("Auf %s ist bereits %r offen — erst commit oder rollback.\n"
|
|
"(Regel 3: eine offene Transaktion je Geraet.)"
|
|
% (a.device, offen[0]["change_id"]))
|
|
verfuegbar = regeln()
|
|
if a.scope not in verfuegbar:
|
|
sys.exit("Scope %r ist keine ComplianceRule. Bekannt sind:\n %s"
|
|
% (a.scope, "\n ".join(sorted(verfuegbar))))
|
|
if a.expires <= heute():
|
|
sys.exit("--expires muss in der Zukunft liegen (Regel 1).")
|
|
|
|
os.makedirs(AKTEN, exist_ok=True)
|
|
snap = os.path.join(AKTEN, "%s.snapshot.rsc" % a.change_id)
|
|
# 🔴 NUR den Scope-Abschnitt sichern, nicht den ganzen /export. Der Vollexport
|
|
# enthaelt Firewall, Adressen, Benutzerliste und Skriptquellen — nichts davon
|
|
# wird fuer den Rueckbau gebraucht, und die Akten liegen im Git. Fuer den
|
|
# Ernstfall gibt es das binaere /system backup auf dem Geraet (--device-backup).
|
|
zeilen = abschnitt(export(a.device), verfuegbar[a.scope])
|
|
with open(snap, "w") as fh:
|
|
fh.write("# Snapshot des Scopes %r auf %s, %s (Transaktion %s)\n"
|
|
"# Bewusst NUR dieser Abschnitt — der Vollexport gehoert nicht ins Repo.\n\n"
|
|
% (a.scope, a.device, heute(), a.change_id))
|
|
fh.write("\n".join(zeilen) + "\n")
|
|
akte = {
|
|
"change_id": a.change_id, "device": a.device, "scope": a.scope,
|
|
"match_config": verfuegbar[a.scope], "reason": a.reason,
|
|
"started": heute(), "expires": a.expires, "state": "open",
|
|
"snapshot": os.path.relpath(snap, REPO),
|
|
"device_backup": a.device_backup or "",
|
|
}
|
|
schreibe(akte)
|
|
print("BEGIN %s auf %s — Scope %s, laeuft ab am %s"
|
|
% (a.change_id, a.device, a.scope, a.expires))
|
|
print(" Snapshot: %s" % akte["snapshot"])
|
|
print("\nJetzt die Aenderung wie gewohnt deployen:")
|
|
print(" python3 nautobot/gc-manual-plan.py --device %s --commands-file <datei.rsc> \\"
|
|
% a.device)
|
|
print(" --change-id %s --deploy --assert ..." % a.change_id)
|
|
|
|
|
|
def cmd_status(a):
|
|
ts = akten()
|
|
if not ts:
|
|
print("Keine Transaktionen.")
|
|
return
|
|
for t in ts:
|
|
marke = {"open": "OFFEN", "committed": "committed", "rolledback": "zurueckgebaut"}[t["state"]]
|
|
ueberfaellig = t["state"] == "open" and t["expires"] < heute()
|
|
print("%-34s %-12s %-14s Scope %s%s"
|
|
% (t["change_id"], marke, t["device"], t["scope"],
|
|
" ⚠️ ABGELAUFEN am " + t["expires"] if ueberfaellig else ""))
|
|
if t.get("reason"):
|
|
print(" %s" % t["reason"])
|
|
if t["state"] != "open":
|
|
continue
|
|
try:
|
|
c = compliance(t["device"])
|
|
except Exception as e:
|
|
print(" (Compliance nicht lesbar: %s)" % e)
|
|
continue
|
|
drifts = [f for f, ok in c.items() if not ok]
|
|
erwartet = [f for f in drifts if f == t["scope"]]
|
|
fremd = [f for f in drifts if f != t["scope"]]
|
|
print(" Drift im Scope: %s" % (", ".join(erwartet) or "keiner — SoT und Geraet gleich"))
|
|
if fremd:
|
|
print(" ⚠️ Drift AUSSERHALB des Scopes: %s" % ", ".join(fremd))
|
|
print(" Das deckt diese Transaktion NICHT ab.")
|
|
|
|
|
|
def cmd_commit(a):
|
|
pfad = akte_pfad(a.change_id)
|
|
if not os.path.exists(pfad):
|
|
sys.exit("Keine Akte %s." % a.change_id)
|
|
t = json.load(open(pfad))
|
|
if t["state"] != "open":
|
|
sys.exit("%s ist bereits %s." % (a.change_id, t["state"]))
|
|
c = compliance(t["device"])
|
|
if not c.get(t["scope"], True):
|
|
sys.exit("COMMIT verweigert: %s driftet auf %s noch.\n"
|
|
"Das heisst, die SoT ist nicht nachgezogen — erst Template/Config-Context\n"
|
|
"aendern, dann `gc-configplan.py --refresh-compliance`, dann committen."
|
|
% (t["scope"], t["device"]))
|
|
t["state"] = "committed"
|
|
t["closed"] = heute()
|
|
schreibe(t)
|
|
print("COMMIT %s — %s ist auf %s compliant, die Aenderung steht jetzt in der SoT."
|
|
% (a.change_id, t["scope"], t["device"]))
|
|
|
|
|
|
def cmd_rollback(a):
|
|
pfad = akte_pfad(a.change_id)
|
|
if not os.path.exists(pfad):
|
|
sys.exit("Keine Akte %s." % a.change_id)
|
|
t = json.load(open(pfad))
|
|
if t["state"] != "open":
|
|
sys.exit("%s ist bereits %s." % (a.change_id, t["state"]))
|
|
|
|
alt = abschnitt(open(os.path.join(REPO, t["snapshot"])).read(), t["match_config"])
|
|
neu = abschnitt(export(t["device"]), t["match_config"])
|
|
ziel = os.path.join(AKTEN, "%s.rollback.rsc" % a.change_id)
|
|
with open(ziel, "w") as fh:
|
|
fh.write("# Rueckbau-VORSCHLAG fuer %s (%s, Scope %s)\n"
|
|
"# Aus dem Snapshot vom %s. NICHT ungeprueft deployen:\n"
|
|
"# `add`-Zeilen legen neu an, statt zu aendern — was hier gebraucht wird,\n"
|
|
"# ist in der Regel ein `set [find ...]`. Zeilen entsprechend anpassen.\n\n"
|
|
% (a.change_id, t["device"], t["scope"], t["started"]))
|
|
fh.write("\n".join(alt) + "\n")
|
|
|
|
print("Snapshot (%d Zeilen) gegen Ist-Stand (%d Zeilen) im Scope %s:\n"
|
|
% (len(alt), len(neu), t["scope"]))
|
|
for z in sorted(set(neu) - set(alt)):
|
|
print(" JETZT NUR AM GERAET %s" % z)
|
|
for z in sorted(set(alt) - set(neu)):
|
|
print(" IM SNAPSHOT, FEHLT %s" % z)
|
|
print("\nVorschlag geschrieben: %s" % os.path.relpath(ziel, REPO))
|
|
print("Pruefen, anpassen, dann deployen:")
|
|
print(" python3 nautobot/gc-manual-plan.py --device %s --commands-file %s \\"
|
|
% (t["device"], os.path.relpath(ziel, REPO)))
|
|
print(" --change-id %s-rollback --deploy --assert ..." % a.change_id)
|
|
print("\nDanach die Akte schliessen: %s rollback --change-id %s --erledigt"
|
|
% (os.path.basename(__file__), a.change_id))
|
|
if a.erledigt:
|
|
t["state"] = "rolledback"
|
|
t["closed"] = heute()
|
|
schreibe(t)
|
|
print("\nAkte %s geschlossen (zurueckgebaut)." % a.change_id)
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
b = sub.add_parser("begin", help="Transaktion oeffnen (Snapshot + Akte)")
|
|
b.add_argument("--device", required=True)
|
|
b.add_argument("--scope", required=True, help="Name einer ComplianceRule, z.B. bridge-vlan")
|
|
b.add_argument("--change-id", required=True)
|
|
b.add_argument("--expires", required=True, help="YYYY-MM-DD, Pflicht")
|
|
b.add_argument("--reason", default="")
|
|
b.add_argument("--device-backup", default="", help="Name eines vorher angelegten /system backup")
|
|
b.set_defaults(func=cmd_begin)
|
|
|
|
s = sub.add_parser("status", help="offene Transaktionen und ihre Drifts")
|
|
s.set_defaults(func=cmd_status)
|
|
|
|
c = sub.add_parser("commit", help="Aenderung in die SoT uebernommen -> Akte schliessen")
|
|
c.add_argument("--change-id", required=True)
|
|
c.set_defaults(func=cmd_commit)
|
|
|
|
r = sub.add_parser("rollback", help="Rueckbau-Vorschlag aus dem Snapshot erzeugen")
|
|
r.add_argument("--change-id", required=True)
|
|
r.add_argument("--erledigt", action="store_true", help="Akte schliessen (nach dem Deploy)")
|
|
r.set_defaults(func=cmd_rollback)
|
|
|
|
a = p.parse_args()
|
|
a.func(a)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|