Files
bf-infra/nautobot/tp34-caddy-render.py
Christoph Emsenhuber 384f356290 TP34: terpene Quick-Win + Caddy-CF-Seeder + Renderer
- caddy/Caddyfile: terpene.teilzeitladen.xyz → 10.40.20.167:8890 (Quick-Win Schritt 0)
- nautobot/tp34-caddy-cf-seed.py: legt CustomFields caddy_fqdn/caddy_group auf
  ipam.Service an und setzt Werte fuer die 9 Routen mit vorhandenem Service-Objekt
- nautobot/tp34-caddy-render.py: rendert Caddyfile aus Nautobot, Diff gegen
  caddy/Caddyfile, optionaler Deploy via pct push + caddy reload

Deploy-Auflage fuer den terpene-Block:
  pct push 130 caddy/Caddyfile /etc/caddy/Caddyfile
  ssh root@10.40.20.40 "caddy reload --config /etc/caddy/Caddyfile"
(Plus CF-Tunnel-Route fuer terpene.teilzeitladen.xyz pruefen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 11:48:18 +02:00

217 lines
7.6 KiB
Python

#!/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()