Files
bf-infra/routeros/push-gc-template.py
Christoph Emsenhuber 3666e1e7fe TP22: RouterOS-Fleet unter Nautobot Golden Config, Deploy scharf
Nimmt die 4 Live-Geräte (crs326-core/-branch, ap1, ap2) real unter Golden
Config: Backup -> Compliance -> intended -> kontrollierter Push. Damit greift
der Governance-Entscheid "Config exklusiv über Nautobot" operativ.

Secret-Leak geschlossen (Anlass, ungeplant gefunden)
  Es gab keine ConfigReplace-Regeln, deshalb hatte der Backup-Job den
  ap1-/export ungefiltert nach Gitea geschrieben: der live genutzte
  WG-Hub-private-key und 6 WLAN-PSKs lagen dort im Klartext.
  - nautobot/ingest-gc-scrubbing.py: 5 ConfigReplace-Regeln, Maske
    "<<REDACTED>>". RouterOS quotet nur bei Bedarf (4 von 6 PSKs standen
    unquotiert), daher Alternation mit der quotierten Variante zuerst --
    \S+ ist greedy und würde bei einem PSK mit Leerzeichen den Rest
    exponieren. Regex-Selbsttest läuft bei jedem Aufruf.
  - Gitea-Repo neu angelegt (History-Rewrite verworfen: Gitea hält
    unerreichbare Blobs bis zum server-seitigen gc). Verifiziert: 0
    unmaskierte Secrets über alle 4 Geräte. User-Entscheid: nicht rotieren.
  - push-gc-template.py: Pre-Push-Secret-Guard, --dry-run, -m, sha optional.

Template
  - AP-Zweig (role == "Wireless AP"), datengetrieben aus ConfigContext
    ap-common + local_config_context_data; ap1/ap2 differieren nur in 4
    Werten. RadioProfile verworfen (hängt an einem Controller, den es seit
    der CAPsMAN-Abschaffung nicht gibt).
  - crs326-core-Zweig von Slash- auf /export-Form (Space-Form, Attribute
    alphabetisch), stale disabled=yes aus der Cutover-Ära entfernt.
  - Switch-Zweig für crs326-branch.
  - Mgmt-Nummernplan korrigiert: ap2 und branch waren in den DNS-Statics
    vertauscht, ap1 zeigte auf ein nicht existierendes .99.3.
    Verbindlich: core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12.
  Ergebnis: ap1 intended == Geräte-Ist (0 Drift), core 68/69 Zeilen
  byte-identisch. 14 ComplianceRules, 51/56 compliant -- die 5 Restdrifts
  sind echte Altlasten (Rettungspfade, Aufräumen gehört zu TP19).

Deploy (nautobot_config.py, Image gc9)
  enable_plan/enable_deploy an. napalm-ros ist READ-ONLY: der Default-
  Dispatcher wählt NAPALM (Platform trägt napalm: ros), Backup geht,
  merge_config scheitert grundsätzlich mit leerer Fehlermeldung. Fix =
  custom_dispatcher auf NetmikoMikrotikRouteros, in den Golden-Config-
  Settings (nicht den Nornir-Settings). Pilot-Deploy: DNS-Statics auf dem
  Core korrigiert, per Purge-vor-Add, weil plan_type=missing nur add
  erzeugt und sonst Duplikate entstanden wären.
  Governance-Regeln in docs/teilprojekte.md festgeschrieben: Pläne nur
  feature-scoped (ein Voll-Push schriebe die Maske als PSK auf die APs),
  AP-Template nie zeilenweise auf ein laufendes Gerät (vlan-filtering=no
  reisst den Mgmt-Pfad ab).

CLAUDE.md: Power-Save-Fehldiagnose korrigiert
  Der P750W war nicht im Power-Save, sondern schlicht ausgeschaltet; das
  Ping-Symptom kam vom lokal mitlaufenden WARP-Client (cloudflared =
  QUIC-Proxy, kein ICMP). Die Merkregel "DHCP-geht-aber-Ping-nicht =
  ARP/Power-Save" ist damit widerlegt und entfernt; strukturell schliesst
  TP16.1 diese ICMP-Lücke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:16:21 +02:00

134 lines
5.1 KiB
Python

#!/usr/bin/env python3
r"""Push nautobot/mikrotik_routeros.j2 to the bf-golden-config Gitea repo.
Reads the Gitea token from ~/.secrets/credentials.env (GITEA_ACCESS_TOKEN).
python3 routeros/push-gc-template.py --dry-run # Diff gegen Remote zeigen
python3 routeros/push-gc-template.py -m "TP22: AP-Zweig" # pushen
SECRET-GUARD (TP22, 2026-08-13)
-------------------------------
Vor jedem PUT wird der Template-Inhalt gegen dieselben Feld-Regexe geprueft, die die
Nautobot-ConfigReplace-Regeln fuers Backup-Scrubbing verwenden. Findet sich ein Wert,
der NICHT die Maske `"<<REDACTED>>"` traegt, bricht das Skript ab.
Grund: genau dieser Weg hat am 2026-07-22 den WG-Hub-private-key und 6 WLAN-PSKs ins
Gitea-Repo getragen (via Backup-Job, aber dieselbe Zielablage). Das Repo musste am
2026-08-13 neu angelegt werden. Der Guard kostet nichts und verhindert die Wiederholung.
"""
import argparse
import base64
import difflib
import json
import pathlib
import re
import subprocess
import sys
import urllib.error
import urllib.request
REPO = "https://gitea.emsenhuber.net/api/v1/repos/admin/bf-golden-config"
FILE = "jinja_templates/mikrotik_routeros.j2"
TOKEN_PATH = pathlib.Path.home() / ".secrets/credentials.env"
SRC = pathlib.Path(__file__).resolve().parent.parent / "nautobot" / "mikrotik_routeros.j2"
MASK = '"<<REDACTED>>"'
# Muss mit nautobot/ingest-gc-scrubbing.py:RULES synchron bleiben.
SECRET_FIELDS = ["passphrase", "private-key", "pre-shared-key", "wpa-pre-shared-key", "password", "secret"]
VAL = r'("(?:[^"\\]|\\.)*"|\S+)'
def get_token():
m = re.search(r"^GITEA_ACCESS_TOKEN=(.+)$", TOKEN_PATH.read_text(), re.MULTILINE)
if not m:
raise SystemExit("GITEA_ACCESS_TOKEN not found")
return m.group(1).strip()
def scan_for_secrets(text):
"""Gibt eine Liste von (zeilennr, feld) zurueck, deren Wert nicht maskiert ist."""
bad = []
for lineno, line in enumerate(text.splitlines(), 1):
# Kommentarzeilen sind Doku (nennen die Feldnamen absichtlich), nicht Config.
if line.lstrip().startswith("#"):
continue
for field in SECRET_FIELDS:
for m in re.finditer(rf"(?<![\w-]){re.escape(field)}=" + VAL, line):
if m.group(1) != MASK:
bad.append((lineno, field))
return bad
def api(path, method="GET", payload=None, token=""):
req = urllib.request.Request(
f"{REPO}/{path}",
data=json.dumps(payload).encode() if payload else None,
method=method,
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else {})
def main():
p = argparse.ArgumentParser()
p.add_argument("-m", "--message", help="Commit-Message (Default: letzter lokaler Commit-Titel)")
p.add_argument("--dry-run", action="store_true", help="nur Diff gegen Remote zeigen")
args = p.parse_args()
content = SRC.read_text()
# --- Secret-Guard: laeuft IMMER, auch im Dry-Run ---
bad = scan_for_secrets(content)
if bad:
print("!! ABBRUCH - unmaskierte Secrets im Template:")
for lineno, field in bad:
print(f" Zeile {lineno}: {field}= traegt nicht die Maske {MASK}")
sys.exit(1)
print(f" Secret-Guard: ok (alle Secret-Felder tragen {MASK})")
token = get_token()
# Remote-Stand holen (sha optional: nach einer Repo-Neuanlage existiert die Datei ggf. nicht)
sha, remote = None, ""
try:
_, meta = api(f"contents/{FILE}?ref=main", token=token)
sha = meta.get("sha")
remote = base64.b64decode(meta.get("content", "")).decode()
except urllib.error.HTTPError as e:
if e.code != 404:
raise
print(f" Remote: {FILE} existiert noch nicht - wird angelegt")
diff = list(difflib.unified_diff(
remote.splitlines(), content.splitlines(),
fromfile=f"remote/{FILE}", tofile=f"local/{SRC.name}", lineterm="", n=2))
if not diff:
print(" Kein Unterschied zum Remote - nichts zu tun.")
return
print(f" Diff: +{sum(1 for l in diff if l.startswith('+') and not l.startswith('+++'))}"
f" / -{sum(1 for l in diff if l.startswith('-') and not l.startswith('---'))} Zeilen")
if args.dry_run:
print("\n".join(diff))
print("\n DRY-RUN - nichts gepusht.")
return
msg = args.message
if not msg:
try:
msg = subprocess.run(["git", "log", "-1", "--pretty=%s"], cwd=SRC.parent.parent,
capture_output=True, text=True, check=True).stdout.strip()
except Exception:
msg = "Update mikrotik_routeros.j2"
payload = {"content": base64.b64encode(content.encode()).decode(), "message": msg, "branch": "main"}
if sha:
payload["sha"] = sha
status, res = api(f"contents/{FILE}", method="PUT" if sha else "POST", payload=payload, token=token)
print(f" HTTP {status} commit={res.get('commit', {}).get('sha', '?')[:8]} msg={msg!r}")
if __name__ == "__main__":
main()