#!/usr/bin/env python3 """ TP34 — Caddyfile aus Nautobot-Daten rendern. Liest alle ipam.Service-Objekte mit gesetztem CustomField `caddy_fqdn` aus Nautobot und rendert daraus ein Caddyfile. Optional: Diff gegen das eingecheckte Caddyfile oder deploy via pct push + caddy reload. Aufruf: python3 nautobot/tp34-caddy-render.py # rendert + diff python3 nautobot/tp34-caddy-render.py --deploy # rendert + diff + push + reload python3 nautobot/tp34-caddy-render.py --out /tmp/Caddyfile # nur rendern nach Datei """ import difflib import json import os import re import subprocess import sys import urllib.request import urllib.parse import urllib.error from pathlib import Path NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080") REPO = Path(__file__).resolve().parent.parent YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml" CADDYFILE = REPO / "caddy" / "Caddyfile" DEPLOY = "--deploy" in sys.argv OUT = None for i, a in enumerate(sys.argv): if a == "--out" and i + 1 < len(sys.argv): OUT = Path(sys.argv[i + 1]) def load_token() -> str: for line in YML.read_text().splitlines(): m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line) if m: return m.group(1) sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden") TOKEN = load_token() HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json", "Content-Type": "application/json"} def req(method: str, path: str, body=None): url = f"{NB}{path}" data = json.dumps(body).encode() if body is not None else None r = urllib.request.Request(url, data=data, headers=HDRS, method=method) try: with urllib.request.urlopen(r, timeout=20) as resp: return resp.status, json.loads(resp.read().decode() or "{}") except urllib.error.HTTPError as e: return e.code, json.loads(e.read().decode() or "{}") def fetch_caddy_services() -> list[dict]: """Alle ipam.Service-Objekte mit caddy_fqdn != None.""" page, out = 1, [] while True: q = urllib.parse.urlencode({"limit": 100, "offset": (page - 1) * 100, "depth": 1}) st, d = req("GET", f"/api/ipam/services/?{q}") results = d.get("results", []) if not results: break for svc in results: cf = svc.get("custom_fields", {}) if cf.get("caddy_fqdn"): out.append({ "fqdn": cf["caddy_fqdn"], "group": cf.get("caddy_group") or "internal", "vm": (svc.get("virtual_machine") or {}).get("name", "?"), "svc": svc.get("name", "?"), "ports": svc.get("ports", []), "protocol": svc.get("protocol", "tcp"), }) if not d.get("next"): break page += 1 return out def get_vm_ip(vm_name: str) -> str | None: """Primaere IP einer VM aus Nautobot.""" q = urllib.parse.urlencode({"name": vm_name, "depth": 1}) st, d = req("GET", f"/api/virtualization/virtual-machines/?{q}") for vm in d.get("results", []): pip = vm.get("primary_ip4") if isinstance(pip, dict): addr = pip.get("address", "") return addr.split("/")[0] if addr else None return None HEADER = """\ # TP34 — Caddyfile (aus Nautobot generiert) # SSoT: Nautobot ipam.Service + CustomFields caddy_fqdn / caddy_group # Manueller Deploy: pct push 130 caddy/Caddyfile /etc/caddy/Caddyfile # ssh root@10.40.20.40 "caddy reload --config /etc/caddy/Caddyfile" { \temail christoph@emsenhuber.com \tacme_dns cloudflare {env.CF_DNS_API_TOKEN} } # --- Backends mit Selbstsignat: Caddy prueft deren Zertifikat nicht --------- (selfsigned) { \ttransport http { \t\ttls_insecure_skip_verify \t} } """ GROUP_HEADERS = { "public": "# =========================================================================\n" "# OEFFENTLICH (via Cloudflare Tunnel + Access; Clients ohne WARP)\n" "# =========================================================================\n", "internal": "# =========================================================================\n" "# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)\n" "# =========================================================================\n", "mcp": "# --- MCP-Endpunkte: heute spricht ~/.claude.json diese IPs roh --------------\n", "https-backend": "# --- HTTPS-Backends (Selbstsignat) -----------------------------------------\n", } def render_caddyfile(services: list[dict], vm_ips: dict) -> str: groups: dict[str, list] = {"public": [], "internal": [], "mcp": [], "https-backend": []} for svc in sorted(services, key=lambda s: (s["group"], s["fqdn"])): groups.setdefault(svc["group"], []).append(svc) out = [HEADER] for group, label in [("public", None), ("internal", None), ("mcp", None), ("https-backend", None)]: svcs = groups.get(group, []) if not svcs: continue hdr = GROUP_HEADERS.get(group, f"# --- {group} ---\n") out.append(hdr) for svc in svcs: ip = vm_ips.get(svc["vm"], "MISSING") port = svc["ports"][0] if svc["ports"] else 80 scheme = "https" if group == "https-backend" else "http" backend = f"{scheme}://{ip}:{port}" out.append(f'{svc["fqdn"]} {{\n') if group == "https-backend": out.append(f"\treverse_proxy {backend} {{\n\t\timport selfsigned\n\t}}\n") else: out.append(f"\treverse_proxy {backend}\n") out.append("}\n\n") return "".join(out) def main(): print(f"== TP34 Caddy-Render @ {NB} ==") print("\n[0] Caddy-Services aus Nautobot laden...") services = fetch_caddy_services() print(f" {len(services)} Dienste mit caddy_fqdn gefunden") print("\n[1] VM-IPs holen...") vm_names = sorted({s["vm"] for s in services}) vm_ips = {} for name in vm_names: ip = get_vm_ip(name) vm_ips[name] = ip or "MISSING" status = ip or "! NICHT GEFUNDEN" print(f" {name}: {status}") print("\n[2] Caddyfile rendern...") rendered = render_caddyfile(services, vm_ips) if OUT: OUT.write_text(rendered) print(f" → geschrieben nach {OUT}") return print("\n[3] Diff gegen eingechecktes Caddyfile (caddy/Caddyfile)...") live = CADDYFILE.read_text() if CADDYFILE.exists() else "" diff = list(difflib.unified_diff( live.splitlines(keepends=True), rendered.splitlines(keepends=True), fromfile="caddy/Caddyfile (eingecheckt)", tofile="gerendert (Nautobot)", )) if not diff: print(" ✅ Kein Diff — Caddyfile ist in Sync mit Nautobot") else: print(f" ⚠️ {len(diff)} Diff-Zeilen:") for line in diff[:80]: print(" " + line, end="") if len(diff) > 80: print(f" … ({len(diff) - 80} weitere Zeilen)") if DEPLOY: if diff: print("\n[4] Deploy...") CADDYFILE.write_text(rendered) ret = subprocess.call( ["pct", "push", "130", str(CADDYFILE), "/etc/caddy/Caddyfile"]) if ret == 0: subprocess.call( ["ssh", "root@10.40.20.40", "caddy reload --config /etc/caddy/Caddyfile"]) else: print(" ! pct push fehlgeschlagen") else: print("\n[4] Deploy: nichts zu tun (kein Diff)") print("\n== fertig ==") if __name__ == "__main__": main()