- A0: API-User 'automation' auf agh-bf1/.36 + agh-bf2/.37 (PW in ~/.secrets, TP30-Merkposten) - A: 8 clients.persistent (7 VLAN-CIDR-Gruppen + dns-bypass-exempt), Policy-Matrix (INFRA/MEDIA/MGMT filtering=aus, GUEST safe-search+parental an), live verifiziert - B: agh-sync.py (check/push), Repo-SSoT AdGuardHome.yaml mit REDIGIERTEN Hashes (Alt-Hash-Exposure im Git entfernt); Invariante .36==.37 (AGH normalisiert beim Restart) - C: agh-exception.py (allow-domain/unallow/unfilter/refilter/status) gegen beide Instanzen, end-to-end getestet; Contract in adguard/README.md - Stufe 6 (Bypass-Sperre) NICHT geschaltet; kein DHCP-/Firewall-Write Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
4.1 KiB
Python
Executable File
114 lines
4.1 KiB
Python
Executable File
#!/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'<redacted>'", 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)())
|