Initial import from bf-network-r6.opencode — opencode migration

This commit is contained in:
2026-08-26 12:44:21 +02:00
commit 5ee0716dda
297 changed files with 72324 additions and 0 deletions

100
caddy/Caddyfile Normal file
View File

@@ -0,0 +1,100 @@
# TP31 — Reverse Proxy BF (Caddy), LXC 130 / 10.40.20.40
# SSoT: docs/teilprojekte.md → TP31. Deploy: pct push 130 caddy/Caddyfile /etc/caddy/Caddyfile
#
# Zertifikate ausschliesslich per DNS-01, damit auch rein INTERNE Namen gueltige
# Zertifikate bekommen, ohne dass von aussen irgendetwas erreichbar sein muss.
# Token: /etc/caddy/caddy.env (chmod 600, NICHT im Repo) — eng geschnitten auf
# Zone:DNS:Edit fuer emsenhuber.net. NICHT das account-weite CF_API_TOKEN.
{
email christoph@emsenhuber.com
acme_dns cloudflare {env.CF_DNS_API_TOKEN}
}
# --- Backends mit Selbstsignat: Caddy prueft deren Zertifikat nicht ---------
(selfsigned) {
transport http {
tls_insecure_skip_verify
}
}
# =========================================================================
# OEFFENTLICH (via Cloudflare Tunnel + Access; Clients ohne WARP)
# =========================================================================
vw.emsenhuber.net {
reverse_proxy http://10.40.20.127
}
hass.emsenhuber.net {
reverse_proxy http://10.40.20.105:8123
}
n8n.emsenhuber.net {
reverse_proxy http://10.40.20.167:5678
}
gitea.emsenhuber.net {
reverse_proxy http://10.40.20.157:3000
}
# =========================================================================
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
# =========================================================================
nautobot.emsenhuber.net {
reverse_proxy http://10.40.20.30:8080
}
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand
# noch auf VLAN1 (10.40.41.109); die Ingress-Zeile zeigte auf 10.40.20.109, das es nie gab.
# Wiederaufbau spaeter als eigener Punkt, dann nach TP32-Systematik.
ds-bf.emsenhuber.net {
reverse_proxy http://10.40.20.10:8080
}
ccu.emsenhuber.net {
reverse_proxy http://10.40.20.232
}
iobroker.emsenhuber.net {
reverse_proxy http://10.40.20.107:8081
}
z2m25.emsenhuber.net {
reverse_proxy http://10.40.20.106:8080
}
# --- MCP-Endpunkte: heute spricht ~/.claude.json diese IPs roh --------------
pve-mcp.emsenhuber.net {
reverse_proxy http://10.40.20.173:3000
}
ha-mcp.emsenhuber.net {
reverse_proxy http://10.40.20.105:9583
}
n8n-mcp.emsenhuber.net {
reverse_proxy http://10.40.20.167:3001
}
gitea-mcp.emsenhuber.net {
reverse_proxy http://10.40.20.167:3000
}
# --- HTTPS-Backends (Selbstsignat) -----------------------------------------
pve.emsenhuber.net {
reverse_proxy https://10.40.20.100:8006 {
import selfsigned
}
}
portainer.emsenhuber.net {
reverse_proxy https://10.40.20.126:9443 {
import selfsigned
}
}
pbs.emsenhuber.net {
reverse_proxy https://10.40.20.135:8007 {
import selfsigned
}
}

149
caddy/cf-sync.py Executable file
View 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())

33
caddy/deploy.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# TP31 — Caddyfile + DNS-Token in LXC 130 ausrollen.
#
# Das Token wird aus ~/.secrets/credentials.env gelesen und direkt in die
# Container-Env-Datei geschrieben. Es erscheint dabei NIE auf stdout und nie in
# einer Kommandozeile — dieselbe Regel wie bei `sshpass -e` (s. CLAUDE.md).
set -euo pipefail
PVE=${PVE:-root@10.40.20.100}
CTID=${CTID:-130}
HERE=$(cd "$(dirname "$0")" && pwd)
TOKEN=$(grep -m1 '^CF_DNS_API_TOKEN=' ~/.secrets/credentials.env 2>/dev/null | cut -d= -f2- || true)
if [ -z "${TOKEN}" ]; then
echo "FEHLER: CF_DNS_API_TOKEN fehlt in ~/.secrets/credentials.env" >&2
echo " Anlegen in Cloudflare: My Profile -> API Tokens -> Create Token," >&2
echo " Permission 'Zone : DNS : Edit', Zone Resources auf emsenhuber.net begrenzen." >&2
echo " NICHT das account-weite CF_API_TOKEN wiederverwenden." >&2
exit 1
fi
echo "-> Caddyfile"
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
ssh "${PVE}" "pct push ${CTID} /tmp/.caddyfile.\$\$ /etc/caddy/Caddyfile --perms 644 2>/dev/null || pct push ${CTID} /tmp/.caddyfile.$$ /etc/caddy/Caddyfile --perms 644; rm -f /tmp/.caddyfile.$$"
echo "-> Token (maskiert: Laenge ${#TOKEN})"
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$ && pct push ${CTID} /tmp/.cenv.$$ /etc/caddy/caddy.env --perms 600 && rm -f /tmp/.cenv.$$"
echo "-> validate"
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"
echo "-> reload/start"
ssh "${PVE}" "pct exec ${CTID} -- systemctl reload-or-restart caddy && sleep 3 && pct exec ${CTID} -- systemctl is-active caddy"