Files
bf-infra/nautobot/gc-transaktion.py
Christoph Emsenhuber a0a650eb48 TP25 Fund E Hebel 1: ft-over-ds=no auf sec-main, Messung laeuft
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>
2026-08-31 08:19:31 +02:00

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()