#!/usr/bin/env python3 """ agh-sync.py — AdGuard-Home-Paar agh-bf1 (.36) / agh-bf2 (.37) synchron halten. TP23 Stufe 2/4: die beiden AGH-Instanzen (pve LXC 110/111) haben KEIN natives HA → Config-Sync ist Betriebsaufwand. Dieses Werkzeug ist der Sync. SSoT = adguard/AdGuardHome.yaml (Passwort-Hashes REDIGIERT — keine Secrets im Git). Wahrheit im Datenpfad = die beiden Live-Instanzen; die Invariante ist .36 == .37 (AGH normalisiert die YAML beim Restart, daher NICHT gegen die Repo-Datei vergleichen, sondern die beiden Live-Configs gegeneinander). check pull .36 + .37, Passwörter redigiert diffen → meldet Drift (exit 1 bei Drift) push Repo-SSoT nehmen, echte users-Hashes aus .36 (live) reinjizieren, validieren, auf beide pushen, GESTAFFELT neu starten (110 → prüfen → 111), .36==.37 + API prüfen Kein Direkt-`sshpass -p` (Leak). SSH-Key zu root@pve vorausgesetzt. """ import subprocess, sys, tempfile, base64, os, re, pathlib, urllib.request PVE = "root@10.40.20.100" INST = {"110": "10.40.20.36", "111": "10.40.20.37"} # ct-id -> ip PRIMARY = "110" # Reihenfolge: primary zuerst SVC = "AdGuardHome.service" CFG = "/opt/AdGuardHome/AdGuardHome.yaml" REPO = pathlib.Path(__file__).resolve().parent SSOT = REPO / "AdGuardHome.yaml" PW_RE = re.compile(r"^( password: ).*$") def sh(*args, inp=None): return subprocess.run(args, input=inp, capture_output=True, text=True) def pull(ct): r = sh("ssh", "-o", "ConnectTimeout=8", PVE, f"pct exec {ct} -- cat {CFG}") if r.returncode: sys.exit(f"pull {ct} fehlgeschlagen: {r.stderr.strip()}") return r.stdout def redact(text): return "\n".join(PW_RE.sub(r"\1''", l) for l in text.splitlines()) def live_users_hashes(ct): """echte password-Zeilen (in Reihenfolge) aus der Live-Config von ct.""" out, inusers = [], False for l in pull(ct).splitlines(): if l.startswith("users:"): inusers = True; continue if inusers and l and not l.startswith((" ", "\t")): break if inusers: m = PW_RE.match(l) if m: out.append(l) return out def cmd_check(): a, b = redact(pull("110")), redact(pull("111")) if a == b: print("✅ .36 == .37 (synchron)") return 0 print("🔴 DRIFT zwischen .36 und .37:") import difflib for line in difflib.unified_diff(a.splitlines(), b.splitlines(), ".36", ".37", lineterm=""): print(line) return 1 def cmd_push(): if not SSOT.exists(): sys.exit(f"SSoT fehlt: {SSOT}") ssot = SSOT.read_text() # echte Hashes aus dem primary reinjizieren (SSoT trägt nur Redaktions-Platzhalter) hashes = live_users_hashes(PRIMARY) red = [i for i, l in enumerate(ssot.splitlines()) if PW_RE.match(l)] if len(red) != len(hashes): sys.exit(f"users-Anzahl SSoT({len(red)}) != live({len(hashes)}) — manuell prüfen") lines = ssot.splitlines() for idx, real in zip(red, hashes): lines[idx] = real merged = "\n".join(lines) + "\n" # validieren try: import yaml doc = yaml.safe_load(merged) assert doc["users"] and doc["clients"]["persistent"] except Exception as e: sys.exit(f"merge invalide: {e}") b64 = base64.b64encode(merged.encode()).decode() order = [PRIMARY] + [c for c in INST if c != PRIMARY] for ct in order: print(f"→ push + restart {ct} ({INST[ct]}) …") r = sh("ssh", "-o", "ConnectTimeout=10", PVE, f"pct exec {ct} -- sh -c 'base64 -d > {CFG}.new && " f"cp -a {CFG} {CFG}.presync && mv {CFG}.new {CFG} && " f"systemctl restart {SVC} && sleep 2 && systemctl is-active {SVC}'", inp=b64) if r.returncode or "active" not in r.stdout: sys.exit(f"push {ct} fehlgeschlagen: {r.stdout} {r.stderr}") print(f" {ct} active") print(cmd_check_msg()) return cmd_check() def cmd_check_msg(): return "— Verifikation:" if __name__ == "__main__": cmd = sys.argv[1] if len(sys.argv) > 1 else "check" sys.exit({"check": cmd_check, "push": cmd_push}.get(cmd, cmd_check)())