Files
bf-infra/nautobot/tp34-caddy-cf-seed.py
Christoph Emsenhuber b200401c5a TP34: 420.teilzeitladen.xyz korrigiert (war terpene.*) + eigener DNS-Token
- caddy/Caddyfile: Hostname 420.teilzeitladen.xyz (nicht terpene.*); eigener
  per-site tls-Block mit CF_DNS_API_TOKEN_TEILZEITLADEN, damit der enge
  emsenhuber.net-ACME-Token unangetastet bleibt (Least-Privilege)
- nautobot/tp34-caddy-cf-seed.py: veraltete terpene.*-Referenz im Kommentar korrigiert

Deploy erst nach Hinterlegen des teilzeitladen.xyz-Tokens in /etc/caddy/caddy.env
(Zone:DNS:Edit nur teilzeitladen.xyz).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 13:30:30 +02:00

210 lines
7.9 KiB
Python

#!/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"),
# 420.teilzeitladen.xyz: kein ipam.Service in TP37 P2 → hier als Hinweis, wird separat geseedet
# ("macmini", "terpene-worker", "420.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()