TP23 Stufe 4: AGH CIDR-Gruppen+Policy auf .36/.37 + Sync/Exception-Tooling
- 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>
This commit is contained in:
113
adguard/agh-sync.py
Executable file
113
adguard/agh-sync.py
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/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)())
|
||||
Reference in New Issue
Block a user