#!/usr/bin/env python3 """TP31 — Cloudflare gegen caddy/Caddyfile stellen (READ-ONLY). Beantwortet genau eine Frage: wo weichen CF-Ingress, CF-DNS und Access von dem ab, was der Caddyfile sagt? Schreibt nichts. Exit 1, wenn Drift gefunden wird. Token: CF_API_TOKEN + CF_ACCOUNT_ID aus ~/.secrets/credentials.env — erscheint nie im Output. python3 caddy/cf-sync.py # Bericht python3 caddy/cf-sync.py --quiet # nur Drift-Zeilen (fuer cron/CI) """ import json import os import re import sys import urllib.request ZONE = "emsenhuber.net" CADDY_IP = "10.40.20.40" HERE = os.path.dirname(os.path.abspath(__file__)) QUIET = "--quiet" in sys.argv def secrets(): env = {} with open(os.path.expanduser("~/.secrets/credentials.env")) as fh: for line in fh: line = line.strip() if line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) env[k.strip()] = v.strip().strip("'\"") return env ENV = secrets() TOK, ACCT = ENV.get("CF_API_TOKEN"), ENV.get("CF_ACCOUNT_ID") if not TOK or not ACCT: sys.exit("CF_API_TOKEN/CF_ACCOUNT_ID fehlen in ~/.secrets/credentials.env") def api(path): req = urllib.request.Request( "https://api.cloudflare.com/client/v4" + path, headers={"Authorization": "Bearer " + TOK}, ) with urllib.request.urlopen(req, timeout=30) as r: return json.load(r) def caddy_sites(): sites, site = {}, None with open(os.path.join(HERE, "Caddyfile"), encoding="utf-8") as fh: for line in fh: # Block-Header: ein oder mehrere Hostnamen (kommagetrennt) vor "{" m = re.match(r"^([a-z0-9.-]+(?:\s*,\s*[a-z0-9.-]+)*\.%s)\s*\{" % re.escape(ZONE), line) if m: site = m.group(1) continue if site: m2 = re.search(r"reverse_proxy\s+(\S+)", line) if m2: for host in site.split(","): sites[host.strip()] = m2.group(1) site = None return sites def main(): caddy = caddy_sites() tunnels, ingress = {}, {} for t in api(f"/accounts/{ACCT}/cfd_tunnel?is_deleted=false&per_page=50")["result"]: res = api(f"/accounts/{ACCT}/cfd_tunnel/{t['id']}/configurations").get("result") or {} rules = (res.get("config") or {}).get("ingress") or [] tunnels[t["id"]] = {"name": t["name"], "conns": len(t.get("connections") or [])} for r in rules: if r.get("hostname"): ingress[r["hostname"]] = (t["name"], r.get("service", "")) # Nur die BF-Tunnel gehoeren gegen den Caddyfile geprueft. fm.* und terpene-db # sind andere Standorte/Projekte -- sie stehen berechtigt NICHT in caddy/Caddyfile. fremd = {h: v for h, v in ingress.items() if not v[0].startswith("bf-")} ingress = {h: v for h, v in ingress.items() if v[0].startswith("bf-")} zid = api(f"/zones?name={ZONE}")["result"][0]["id"] dns = {} for r in api(f"/zones/{zid}/dns_records?per_page=500")["result"]: if r["type"] == "CNAME": dns[r["name"]] = r["content"] try: apps = {a.get("domain", ""): a.get("name", "") for a in api(f"/accounts/{ACCT}/access/apps?per_page=100")["result"]} except Exception as exc: # Token ohne Access:Read apps = {} if not QUIET: print(f"(Access-Apps nicht lesbar: {type(exc).__name__} — Token-Scope)") drift = [] # 1) Ingress gegen Caddyfile. # Zielbild (TP31): CF-Ingress zeigt auf Caddy (10.40.20.40), Caddy aufs Origin. # Zeigt CF direkt auf ein Origin (nicht Caddy), ist das die alte Architektur = Drift. for host, (tun, svc) in sorted(ingress.items()): if svc.startswith("ssh://"): continue if host not in caddy: drift.append(f"NUR-CF {host:<32} {tun}: {svc} (in Caddy nicht vorhanden)") elif not (svc.startswith(f"http://{CADDY_IP}") or svc.startswith(f"https://{CADDY_IP}")): drift.append(f"ABWEICHEND {host:<32} CF={svc} (zeigt nicht auf Caddy {CADDY_IP})") # Caddy-Sites ohne CF-Ingress: nur die suffixlosen Namen sind potenziell Drift. # *.bf.*-Namen sind bewusst intern (Split-Horizon) und brauchen keinen Ingress. for host, svc in sorted(caddy.items()): if host not in ingress and not host.endswith(".bf." + ZONE): drift.append(f"NUR-CADDY {host:<32} {svc} (kein CF-Ingress — intern oder bewusst)") # 2) CNAMEs, die auf einen Tunnel zeigen, aber keine Ingress-Regel mehr haben for name, content in sorted(dns.items()): if not content.endswith(".cfargotunnel.com"): continue tid = content.split(".")[0] if name not in ingress and name not in fremd: tn = tunnels.get(tid, {}).get("name", tid[:8]) drift.append(f"DNS-LEICHE {name:<32} CNAME -> {tn}, aber keine Ingress-Regel (Catch-all 404)") # 3) CNAMEs auf Namen, die es nicht mehr gibt for name, content in sorted(dns.items()): if content.endswith(ZONE) and not content.endswith(".cfargotunnel.com"): if content not in dns and content not in caddy: drift.append(f"DNS-ZIEL {name:<32} CNAME -> {content} (Ziel existiert nicht)") if not QUIET: print(f"Tunnel: " + ", ".join(f"{t['name']}({t['conns']} conns)" for t in tunnels.values())) if fremd: print("Fremde Standorte/Projekte (nicht geprueft): " + ", ".join(sorted(f"{h} [{v[0]}]" for h, v in fremd.items()))) print(f"Caddy-Sites: {len(caddy)} · CF-Ingress-Hosts (bf-*): {len(ingress)} · CNAMEs: {len(dns)}" + (f" · Access-Apps: {len(apps)}" if apps else "")) print() for host in sorted(apps): base = host.split("/")[0] if base.startswith("*."): # Wildcard-App deckt alles ab, kein Drift continue if base not in caddy and base.endswith(ZONE): drift.append(f"ACCESS {host:<32} Access-App ohne Caddy-Site ({apps[host]})") if drift: print(f"⚠️ {len(drift)} Abweichung(en):") for d in drift: print(" " + d) return 1 print("✅ CF und Caddyfile sind deckungsgleich.") return 0 if __name__ == "__main__": sys.exit(main())