Initial import from bf-network-r6.opencode — opencode migration
This commit is contained in:
149
caddy/cf-sync.py
Executable file
149
caddy/cf-sync.py
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/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:
|
||||
m = re.match(r"^([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:
|
||||
sites[site] = 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
|
||||
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 caddy[host] != svc:
|
||||
drift.append(f"ABWEICHEND {host:<32} CF={svc} Caddy={caddy[host]}")
|
||||
for host, svc in sorted(caddy.items()):
|
||||
if host not in ingress:
|
||||
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())
|
||||
Reference in New Issue
Block a user