#!/usr/bin/env python3 """ TP34 — Caddy-CustomFields + Werte auf ipam.Service-Objekte setzen. Idempotenter Seeder (get-or-create) fuer zwei CustomFields auf ipam.Service: caddy_fqdn (text) — Caddy-Hostname (z.B. vw.emsenhuber.net) caddy_group (choice) — Kategorie: public / internal / mcp / https-backend Danach werden die Werte aus dem aktuellen Caddyfile (Ist-Stand 2026-09-07) auf die zugehoerigen ipam.Service-Objekte gesetzt. Vorbedingung: TP37 P2 muss gelaufen sein (ipam.Service-Objekte existieren). Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), Schluessel NAUTOBOT_SUPERUSER_API_TOKEN. Aufruf: python3 nautobot/tp34-caddy-cf-seed.py # Dry-Run (default) python3 nautobot/tp34-caddy-cf-seed.py --commit # schreibt nach Nautobot """ import json import os import re 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" COMMIT = "--commit" in sys.argv 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 find(endpoint: str, params: dict, match_key: str, match_val: str): q = urllib.parse.urlencode(dict(params, depth=0)) st, d = req("GET", f"/api/{endpoint}/?{q}") for row in d.get("results", []): if row.get(match_key) == match_val: return row return None # ---------------------------------------------------------------- CustomField-Definitionen # Beide CFs werden auf ContentType ipam.service gesetzt. CF_FQDN = { "key": "caddy_fqdn", "label": "Caddy FQDN", "type": "text", "description": "Caddy-Hostname fuer diesen Dienst (z.B. vw.emsenhuber.net)", "content_types": ["ipam.service"], "required": False, "default": None, } CF_GROUP = { "key": "caddy_group", "label": "Caddy Group", "type": "select", "description": "Caddy-Abschnitt (public/internal/mcp/https-backend)", "content_types": ["ipam.service"], "required": False, "default": None, "choices": [ {"value": "public", "display": "Oeffentlich (CF Tunnel)"}, {"value": "internal", "display": "Intern (Mesh/LAN)"}, {"value": "mcp", "display": "MCP-Endpunkt"}, {"value": "https-backend", "display": "HTTPS-Backend (Selbstsignat)"}, ], } def ensure_custom_field(cf_def: dict) -> bool: """Legt einen CustomField an wenn er nicht existiert. Gibt True zurueck wenn ok.""" key = cf_def["key"] existing = find("extras/custom-fields", {"key": key}, "key", key) if existing: print(f" = CF '{key}' existiert ({existing['id']})") return True if not COMMIT: print(f" + CF '{key}' [DRY-RUN]") return True payload = { "key": cf_def["key"], "label": cf_def["label"], "type": cf_def["type"], "description": cf_def.get("description", ""), "content_types": cf_def["content_types"], "required": cf_def.get("required", False), } if cf_def.get("choices"): payload["choices"] = cf_def["choices"] st, d = req("POST", "/api/extras/custom-fields/", payload) if st in (200, 201): print(f" + CF '{key}' angelegt ({d.get('id')})") return True print(f" ! CF '{key}' FEHLER {st}: {json.dumps(d)[:300]}") return False # ---------------------------------------------------------------- Caddyfile-Routen-Inventar # (vm_name, svc_name, fqdn, group) # Basiert auf caddy/Caddyfile Stand 2026-09-07 (terpene als Quick-Win schon drin). ROUTES = [ # Oeffentlich (via CF Tunnel) ("vaultwarden", "vaultwarden-http", "vw.emsenhuber.net", "public"), ("haos", "home-assistant", "hass.emsenhuber.net", "public"), ("n8n", "n8n-web", "n8n.emsenhuber.net", "public"), ("gitea", "gitea-http", "gitea.emsenhuber.net", "public"), # terpene: kein ipam.Service in TP37 P2 → hier als Hinweis, wird separat geseedet # ("macmini", "terpene-worker", "terpene.teilzeitladen.xyz", "public"), # Intern ("nautobot", "nautobot-web", "nautobot.bf.emsenhuber.net", "internal"), ("docker", "portainer", "portainer.bf.emsenhuber.net", "https-backend"), # ds-bf: kein eigener ipam.Service — NAS-DSM haengt am Port 8080 direkt # ("nautobot", "ds-bf", "ds-bf.emsenhuber.net", "internal"), # MCP-Endpunkte ("pve-mcp", "pve-mcp", "pve-mcp.bf.emsenhuber.net", "mcp"), # ha-mcp: kein Service in TP37 P2 (haos lauscht auf 9583, nicht als Service angelegt) ("n8n-mcp", "n8n-mcp", "n8n-mcp.bf.emsenhuber.net", "mcp"), ("gitea-mcp", "gitea-mcp", "gitea-mcp.bf.emsenhuber.net", "mcp"), # HTTPS-Backends (Selbstsignat) ("docker", "portainer", "portainer.bf.emsenhuber.net", "https-backend"), ] # Nur eindeutige Eintraege (portainer erscheint nur einmal) _seen = set() _unique_routes = [] for r in ROUTES: k = (r[0], r[1]) if k not in _seen: _seen.add(k) _unique_routes.append(r) ROUTES = _unique_routes def main(): print(f"== TP34 Caddy-CF-Seed ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==") print("\n[1] CustomFields anlegen") ensure_custom_field(CF_FQDN) ensure_custom_field(CF_GROUP) print(f"\n[2] ipam.Service-Objekte mit Caddy-CFs versehen ({len(ROUTES)} Routen)") n_ok = n_skip = 0 for vm_name, svc_name, fqdn, group in ROUTES: # Service-Objekt suchen svc = find("ipam/services", {"name": svc_name, "virtual_machine": vm_name}, "name", svc_name) if not svc: print(f" ! {vm_name}/{svc_name}: Service nicht gefunden — uebersprungen") n_skip += 1 continue svc_id = svc["id"] # Aktuelle CF-Werte pruefen cfs = svc.get("custom_fields", {}) current_fqdn = cfs.get("caddy_fqdn") current_group = cfs.get("caddy_group") if current_fqdn == fqdn and current_group == group: print(f" = {vm_name}/{svc_name}: '{fqdn}' ({group}) — unveraendert") n_ok += 1 continue if not COMMIT: print(f" + {vm_name}/{svc_name}: '{fqdn}' ({group}) [DRY-RUN]" + (f" (war: fqdn='{current_fqdn}', group='{current_group}')" if current_fqdn or current_group else "")) n_ok += 1 continue st, d = req("PATCH", f"/api/ipam/services/{svc_id}/", {"custom_fields": {"caddy_fqdn": fqdn, "caddy_group": group}}) if st == 200: print(f" + {vm_name}/{svc_name}: '{fqdn}' ({group}) gesetzt") n_ok += 1 else: print(f" ! {vm_name}/{svc_name}: FEHLER {st}: {json.dumps(d)[:300]}") n_skip += 1 print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) " f"| gesetzt={n_ok} uebersprungen={n_skip} ==") if __name__ == "__main__": main()