diff --git a/caddy/cf-sync.py b/caddy/cf-sync.py new file mode 100755 index 0000000..dfef9b1 --- /dev/null +++ b/caddy/cf-sync.py @@ -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()) diff --git a/docs/handoff-20260825-agh-dns-cutover.md b/docs/handoff-20260825-agh-dns-cutover.md index 2490305..f8e0283 100644 --- a/docs/handoff-20260825-agh-dns-cutover.md +++ b/docs/handoff-20260825-agh-dns-cutover.md @@ -39,20 +39,26 @@ Auflösung selbst das Kaputte ist. **Nicht ans Ende einer langen Sitzung legen** ## 🔴 Was den Plan ändert — drei Funde vom 2026-08-25 (spät), alle live erhoben -### 1. Der Bestands-AdGuard ist ein **Archiv, keine Vorlage** +### 1. Die Rewrites sind **veraltet, nicht wertlos** — Ziele umbiegen statt löschen +> 🔴 **Korrektur (User, 2026-08-26):** „manche hosts leben auf vlan 20 weiter" — die erste Fassung +> hat die `10.40.41.x`-Einträge pauschal als tot bezeichnet. Falsch ist das **Ziel**, nicht der +> **Name**: `nas`, `macmini`, `appletv`, `tv`, `mfc` gibt es weiter, sie sind nur umgezogen +> (NAS `10.40.20.10`, macmini `10.40.20.167`, Brother MFC `10.40.10.6` — der steht auf **VLAN10**). +> ⇒ **Jeder Name einzeln bewerten: umbiegen, wenn der Host lebt; streichen, wenn nicht.** `adguard_bf1` (`10.40.20.16`, Docker in LXC 400) läuft seit 7 Tagen und hält **24 Rewrites** — davon ist der überwiegende Teil **tot**: -| Klasse | Anzahl | Beispiel | Warum tot | +| Klasse | Anzahl | Beispiel | Was damit zu tun ist | |---|---|---|---| -| Ziel im **toten VLAN1** | 8 | `nas.bf…→10.40.41.10`, `mfc.bf…→10.40.41.4`, `macmini.bf…→10.40.41.167` | VLAN1 ist seit 2026-08-18 leer (TP19) | -| Ziel **`nginx-bf.emsenhuber.net`** | 9 | `portainer`, `z2m25`, `nodered`, `grafana`, `vw`, `speedtest`, … | der NPM (LXC **128**) ist **heute** zerstört worden | -| **FritzBox-Reste** | 2 | `*.myfritz.net → 100.72.62.31` / `10.40.41.254` | FB6660 seit 2026-07-01 ausgebaut | -| FM-Standort | 3 | `nas.fm…→10.101.0.10` | anderer Standort, hier nicht anfassen | +| Ziel im toten VLAN1, **Host lebt** | 8 | `nas.bf…→10.40.41.10`, `mfc.bf…→10.40.41.4`, `macmini.bf…→10.40.41.167` | **umbiegen** auf die VLAN20/VLAN10-Adresse | +| Ziel **`nginx-bf.emsenhuber.net`** | 9 | `portainer`, `z2m25`, `nodered`, `grafana`, `vw`, `speedtest`, … | Ziel ersetzen durch **`10.40.20.40`** — Caddy tritt an die Stelle des NPM (LXC 128 ist zerstört) | +| **FritzBox-Reste** | 2 | `*.myfritz.net → 100.72.62.31` / `10.40.41.254` | streichen (FB6660 seit 2026-07-01 ausgebaut) | +| FM-Standort | 3 | `nas.fm…→10.101.0.10` | dort gepflegt, s. Fund 2 | -⇒ **Übernehmen: `upstream_dns`, `bootstrap_dns`, Filterlisten. NICHT die Rewrites.** -Die 16 Rewrites werden **aus `caddy/Caddyfile` neu erzeugt** (jeder Site-Name → `10.40.20.40`). -Die Altlasten oben sind eine **eigene, bewusste Entscheidung je Name** — nicht mitschleppen. +⇒ **Übernehmen: `upstream_dns`, `bootstrap_dns`, Filterlisten.** +Die 16 Service-Rewrites entstehen **neu aus `caddy/Caddyfile`** (jeder Site-Name → `10.40.20.40`). +Die Host-Rewrites oben werden **einzeln entschieden** — nicht blind mitschleppen, aber auch nicht +pauschal wegwerfen. Was dort **richtig** steht und mitmuss: ``` @@ -61,15 +67,42 @@ upstream_dns: - '[/bf.lan/]10.40.20.1' - '[/40.10.in-addr.arpa/]10.40.20.1' ``` -🔑 Die zweite Zeile ist **nicht optional**: der Core vergibt `domain=bf.lan` und legt aus jeder Lease +> ⚠️ **Cave (User, 2026-08-26): `emsenhuber.net` ist unsere Domain, nicht `bf.lan`.** +> `bf.lan` ist nur der **DHCP-interne Suffix**, den der Core heute vergibt (`domain=bf.lan` + +> `add-dns-entries-suffix=lan`) — er hat keine Namensautorität. **Offene Entscheidung für diese +> Session:** so lassen oder auf `bf.emsenhuber.net` heben (dann zieht der conditional upstream mit +> und die Lease-Namen liegen unter der echten Domain). Nicht nebenbei umstellen — es betrifft jeden +> Lease-Namen. + +🔑 Der conditional upstream ist **nicht optional**: der Core vergibt `domain=bf.lan` und legt aus jeder Lease einen `*.lan`-Eintrag an (`add-dns-entries-suffix=lan`). Ohne diesen conditional upstream sterben **alle** Lease-Namen. Ebenso bleibt **mDNS Sache des Core** (`mdns-repeat-ifaces` = vlan10/20/30/40/90). -### 2. `10.40.20.17` ist **nicht** „bf2" -Dort läuft **`adguard_fm2`** — die zweite Instanz des **FM**-Standorts, hier nur mitgehostet. Sie -beantwortet `gitea.emsenhuber.net` mit **`10.40.41.123`**, also ebenfalls ins tote VLAN1. -⇒ **Nicht als Vorlage, nicht als „vorhandene Redundanz" verbuchen.** BF hat heute **einen** AGH. -(Dass FM stale Rewrites hat, ist ein eigener Punkt — nicht in dieser Session.) +### 2. Die Redundanz **gibt es** — wechselseitig standortübergreifend (und sie ist überall stale) +> 🔴 **Korrektur (User, 2026-08-26):** „die redundanz gab es schon! und zwar wechselseitig +> standortübergreifend. bei fm klarerweise auf ds415+ gehostet." + +**Live gegengeprüft (2026-08-26, `nslookup` über den WG-Tunnel):** + +| Instanz | wo | antwortet auf 53 | `nas.bf.emsenhuber.net` → | +|---|---|---|---| +| `adguard_bf1` | BF, Docker in LXC 400 (`10.40.20.16`) | ✅ | BF-Rewrite, VLAN1-Ziel | +| `adguard_fm2` | **BF** (`10.40.20.17`), für FM | ✅ | — | +| FM-Resolver 1 | **FM**, DS415+ (`10.101.0.16`) | ✅ | **`10.40.41.10`** | +| FM-Resolver 2 | **FM**, DS415+ (`10.101.0.17`) | ✅ | **`10.40.41.10`** | + +⇒ Das Muster ist **reziprok**: jeder Standort hostet auch eine Instanz des anderen, spiegelbildlich +auf `.16`/`.17`. (`10.101.0.10` — die FM-NAS selbst — antwortet **nicht** auf 53.) + +🔑 **Die Konsequenz ist größer als „ein Paar bauen":** die BF-Namen liegen in **vier** Resolvern an +**zwei** Standorten, und **beide FM-Instanzen antworten heute mit der toten VLAN1-Adresse**. Ein +Rewrite-Satz, den nur das neue BF-Paar kennt, erzeugt **standortabhängig verschiedene Antworten** für +denselben Namen. Zum Auftrag gehört deshalb eine Festlegung: +- **entweder** den Rewrite-Satz als *eine* Quelle definieren und auf alle Instanzen ausrollen, +- **oder** — wartungsärmer und konsistent mit der SoT-Antwort für CF/Caddy (s. TP31) — festlegen, dass + **FM die BF-Namen nicht mehr selbst beantwortet**, sondern per conditional upstream nach BF fragt. +- ⚠️ **Nicht in dieser Session mitfixen, aber notieren:** FM zeigt auf tote Ziele. Das braucht ein + eigenes Fenster und den DS415+-Zugang. ### 3. Die Firewall-Vorratsregeln zeigen auf die **falsche** IP TP5 Etappe 2 hat vorgesorgt, aber hart auf den heutigen Container: @@ -82,8 +115,10 @@ TP5-E2: LAN->AGH dns-tcp accept tcp dst-address=10.40.20.16 dst-port=53 **`forward`-Fluss über VLAN-Grenzen** — und die Firewall ist seit 2026-08-18 deny-by-default. Ohne passende Regeln verlieren **IOT, MEDIA, UNTRUSTED, GUEST und MGMT** schlagartig die Auflösung, während VLAN20 (wo der Resolver steht) unauffällig weiterläuft. -⇒ **Die beiden Regeln auf die neuen AGH-Adressen erweitern, bevor irgendein DHCP angefasst wird** — -und zwar **über Nautobot Golden Config**, nicht ad hoc: Core-Config ist GC-only. +✅ **Entscheid (User, 2026-08-26): die Vorratsregel auf `10.40.20.16` wird GESTRICHEN** — „`.16` war +historisch, passt aber nicht mehr ins System." Also **nicht erweitern, sondern ersetzen**: die zwei +alten Zeilen raus, zwei neue auf das Paar `10.40.20.36/.37` rein — **über Nautobot Golden Config**, +nicht ad hoc (Core-Config ist GC-only), und **bevor** irgendein DHCP angefasst wird. ## Der Auftrag diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index a453fc6..5eb027c 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -1509,6 +1509,43 @@ Der `http:`-Block ist eingetragen (`use_x_forwarded_for: true` + `trusted_proxie ⚠️ **Preis, offen benannt:** mit Split-Horizon greift **Access innerhalb des Netzes nicht mehr**; dort tragen TP5-Segmentierung und die App-eigene Auth. +#### 🧭 Wo liegt die Source of Truth? — beantwortet 2026-08-26 +> **User-Frage:** „schaffen wir es mit überschaubarem aufwand cf und caddy synchron zu halten? wo +> liegt die sot?" + +**Antwort: gar nicht synchron halten — die Doppelpflege fällt mit Schritt 5/6 von selbst weg.** +Heute steht dieselbe Zuordnung *Name → Origin* an **zwei** Orten: im CF-Ingress (19 Regeln über drei +bf-Tunnel) und im `caddy/Caddyfile` (16 Sites). Der Vergleich am 08-26 zeigt: **sie stimmen fachlich +überein** — jeder gemeinsame Name zeigt auf **dasselbe** Origin. Es gibt also nichts zu versöhnen, +nur etwas abzuschaffen. + +Im Zielbild hat der Tunnel `bf-public` **eine einzige Catch-all-Regel auf `10.40.20.40`**. Damit hält +Cloudflare **keine** Host→Origin-Zuordnung mehr, und die SoT ist eindeutig: + +| Was | SoT | wo gepflegt | +|---|---|---| +| Name → Origin (alle Dienste) | **`caddy/Caddyfile`** (Git) | dieses Repo | +| Welcher Name ist öffentlich | **CNAME + Access-App** (4 Namen) | Cloudflare | +| Name → IP **intern** | **AdGuard-Rewrites** (TP23 Stufe 2) | AGH-Paar | + +⇒ Zu pflegen bleiben **vier** öffentliche Namen statt neunzehn Ingress-Regeln. Und die zwölf internen +Namen brauchen dann **gar keinen öffentlichen DNS-Eintrag mehr** — heute ist z. B. `pve.emsenhuber.net` +öffentlich auflösbar (hinter Access), danach ist es von außen schlicht unbekannt. **Das ist ein +Sicherheitsgewinn, kein Nebeneffekt.** + +🛠️ **Werkzeug dafür: [`caddy/cf-sync.py`](../caddy/cf-sync.py)** — read-only, liest CF-Ingress, Zonen-DNS +und Access-Apps und stellt sie gegen den Caddyfile; Exit 1 bei Drift (cron-/CI-tauglich). Erster Lauf +2026-08-26 fand **7 echte Abweichungen**, alle in Cloudflare, keine in Caddy: +- **`grafana` steht noch im Ingress** (`bf-nas → 10.40.20.109:3000`) — ein Origin, das es nie gab. +- **`nautobot` hat noch seinen CNAME**, aber seit TP16.3 keine Ingress-Regel ⇒ läuft in die Catch-all; + **das** ist die Quelle des 302, das man leicht für „lebt noch" hält. Dasselbe bei `gateway-dw`. +- **Drei `*.bf`-CNAMEs zeigen auf `nginx.bf`** — den heute zerstörten NPM (`phpmyadmin`, `speedtest`, + `z2m25`). +- Eine **Wildcard-Access-App `*.emsenhuber.net`** deckt die ganze Zone ab — sie ist der Grund, warum + jeder FQDN ein sauberes 302 liefert, auch wenn dahinter nichts läuft. +🔑 **Merkregel daraus:** *ein Eintrag ohne Gegenstück ist unsichtbar, solange etwas davor antwortet.* +Der Drift-Check muss deshalb **beide** Richtungen prüfen — Ingress ohne Site **und** CNAME ohne Ingress. + #### HA-Ausblick (nicht Teil der Umsetzung) **Es bleibt bei Caddy** — zustandslos bis auf den Zertifikatsspeicher, identisches Caddyfile je Instanz, versionierbar; NPM hätte eine Datenbank, die man mitreplizieren müsste.