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>
This commit is contained in:
@@ -1,58 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Push nautobot/mikrotik_routeros.j2 to the bf-golden-config Gitea repo.
|
||||
r"""Push nautobot/mikrotik_routeros.j2 to the bf-golden-config Gitea repo.
|
||||
|
||||
Reads Gitea token from ~/.secrets/credentials.env (GITEA_ACCESS_TOKEN).
|
||||
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():
|
||||
text = TOKEN_PATH.read_text()
|
||||
m = re.search(r"^GITEA_ACCESS_TOKEN=(.+)$", text, re.MULTILINE)
|
||||
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 main():
|
||||
token = get_token()
|
||||
src = pathlib.Path(__file__).parent.parent / "nautobot" / "mikrotik_routeros.j2"
|
||||
content = src.read_text()
|
||||
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
|
||||
|
||||
# fetch current SHA
|
||||
|
||||
def api(path, method="GET", payload=None, token=""):
|
||||
req = urllib.request.Request(
|
||||
f"{REPO}/contents/{FILE}?ref=main",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
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) as resp:
|
||||
meta = json.loads(resp.read().decode())
|
||||
sha = meta["sha"]
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
return resp.status, (json.loads(raw) if raw else {})
|
||||
|
||||
payload = {
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"sha": sha,
|
||||
"message": "TP15: WG-HUB private key + ap1 cutover lease prep",
|
||||
"branch": "main",
|
||||
}
|
||||
req2 = urllib.request.Request(
|
||||
f"{REPO}/contents/{FILE}",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="PUT",
|
||||
)
|
||||
with urllib.request.urlopen(req2) as resp:
|
||||
print(resp.status, json.loads(resp.read().decode()).get("commit", {}).get("id", "?"))
|
||||
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user