From 76dffc4c0d0377e24033d1c9d48dbedcfdb20d3b Mon Sep 17 00:00:00 2001 From: Gemini Agent Date: Thu, 4 Dec 2025 05:19:36 +0000 Subject: [PATCH] Initial commit --- .gitignore | 4 + Dockerfile | 22 +++++ README.md | 75 +++++++++++++++++ sync.py | 242 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 sync.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eb8597 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e3ad3fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-alpine + +# Standard-Environment-Variablen (können beim Start überschrieben werden) +ENV NETBOX_URL="http://172.30.242.99" \ + NETBOX_TOKEN="0b740520aeef964cfd2ca34b82a472a271d51649" \ + ZONE_NAME="klenzel.net" \ + REVERSE_ZONE_NAME="172.in-addr.arpa" \ + REFRESH_INTERVAL=600 \ + OUTPUT_FILE_FWD="/zones/db.klenzel.net" \ + OUTPUT_FILE_REV="/zones/db.reverse.arpa" \ + DEFAULT_TTL=3600 \ + FALLBACK_NS_HOSTNAME="fks-01-cl-cdns" \ + FALLBACK_NS_IP="172.25.16.152" + +RUN pip install requests + +COPY sync.py /sync.py + +# Logs ungepuffert ausgeben +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "/sync.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..53a45da --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# CoreDNS NetBox Sync + +Dieser Container synchronisiert DNS-Records aus [NetBox](https://github.com/netbox-community/netbox) in lokale Zonefiles, die von [CoreDNS](https://coredns.io/) (oder BIND) genutzt werden können. + +## Features + +- **Automatische Synchronisation:** Holt alle 10 Minuten (konfigurierbar) Daten aus NetBox. +- **Forward & Reverse Zones:** Erstellt A/AAAA und PTR Records. +- **Ausfallsicherheit:** Wenn NetBox nicht erreichbar ist oder die API Fehler wirft, werden **keine** leeren Dateien geschrieben. Die alten Zonefiles bleiben erhalten, um `NXDOMAIN` Antworten zu verhindern. +- **Fallback NS:** Konfigurierbarer Fallback-Nameserver, falls in NetBox keine NS-Records für die Zone definiert sind. + +## Installation & Nutzung + +### 1. Image bauen + +```bash +docker build -t local/dns-sync . +``` + +### 2. Container starten + +Da die Standardwerte im Dockerfile hinterlegt sind, reicht dein bisheriger Befehl völlig aus, solange sich an der Konfiguration nichts geändert hat: + +```bash +docker run -d \ + --name klzDNS-worker \ + --restart unless-stopped \ + --net=container:klzDNS-coredns \ + -v klzDNS-data:/zones \ + local/dns-sync +``` + +### 3. Konfiguration anpassen (Optional) + +Möchtest du Werte ändern (z.B. URL, Token oder Interval), kannst du diese als Umgebungsvariablen (`-e`) übergeben: + +```bash +docker run -d \ + --name klzDNS-worker \ + --restart unless-stopped \ + --net=container:klzDNS-coredns \ + -v klzDNS-data:/zones \ + -e NETBOX_URL="http://deine-netbox-url" \ + -e NETBOX_TOKEN="dein-neuer-token" \ + -e REFRESH_INTERVAL=300 \ + local/dns-sync +``` + +Alternativ kannst du eine `.env` Datei erstellen: + +```ini +# .env Datei +NETBOX_URL=http://192.168.1.50 +REFRESH_INTERVAL=60 +``` + +Und diese einbinden: + +```bash +docker run -d ... --env-file .env local/dns-sync +``` + +## Verfügbare Variablen + +| Variable | Standardwert | Beschreibung | +| :--- | :--- | :--- | +| `NETBOX_URL` | `http://172.30.242.99` | URL zur NetBox Instanz | +| `NETBOX_TOKEN` | `0b74...` | API Token (Read-Only reicht) | +| `ZONE_NAME` | `klenzel.net` | Die zu verwaltende DNS-Zone | +| `REVERSE_ZONE_NAME` | `172.in-addr.arpa` | Reverse Lookup Zone | +| `REFRESH_INTERVAL` | `600` | Sync-Intervall in Sekunden | +| `OUTPUT_FILE_FWD` | `/zones/db.klenzel.net` | Pfad zur Forward Zone im Container | +| `OUTPUT_FILE_REV` | `/zones/db.reverse.arpa` | Pfad zur Reverse Zone im Container | +| `FALLBACK_NS_HOSTNAME`| `fks-01-cl-cdns` | Hostname des NS, falls keiner in NetBox definiert ist | +| `FALLBACK_NS_IP` | `172.25.16.152` | IP des Fallback NS (für Glue Record) | diff --git a/sync.py b/sync.py new file mode 100644 index 0000000..1c7749a --- /dev/null +++ b/sync.py @@ -0,0 +1,242 @@ +import requests +import os +import time +import sys + +# --- KONFIGURATION (Via Environment Variables) --- +# Falls keine ENV-Vars gesetzt sind, werden die alten Hardcoded-Werte als Fallback genutzt. +NETBOX_URL = os.getenv("NETBOX_URL", "http://172.30.242.99") +TOKEN = os.getenv("NETBOX_TOKEN", "0b740520aeef964cfd2ca34b82a472a271d51649") + +ZONE_NAME = os.getenv("ZONE_NAME", "klenzel.net") +REVERSE_ZONE_NAME = os.getenv("REVERSE_ZONE_NAME", "172.in-addr.arpa") + +# Interval in Sekunden +REFRESH_INTERVAL = int(os.getenv("REFRESH_INTERVAL", "600")) + +OUTPUT_FILE_FWD = os.getenv("OUTPUT_FILE_FWD", "/zones/db.klenzel.net") +OUTPUT_FILE_REV = os.getenv("OUTPUT_FILE_REV", "/zones/db.reverse.arpa") +DEFAULT_TTL = os.getenv("DEFAULT_TTL", "3600") + +# Fallback Konfiguration (wenn KEIN NS in NetBox gefunden wird) +FALLBACK_NS_HOSTNAME = os.getenv("FALLBACK_NS_HOSTNAME", "fks-01-cl-cdns") +FALLBACK_NS_IP = os.getenv("FALLBACK_NS_IP", "172.25.16.152") +# --------------------- + +HEADERS = {'Authorization': f'Token {TOKEN}', 'Accept': 'application/json'} + +def log(msg): + """Ungepufferte Ausgabe für Docker Logs""" + print(msg, flush=True) + +def fetch_ipam_data(): + """ + Holt alle aktiven IPs mit DNS-Namen aus NetBox. + Wirft eine Exception, wenn NetBox nicht erreichbar ist (kein try/except hier!). + """ + url = f"{NETBOX_URL}/api/ipam/ip-addresses/?status=active&dns_name__n=&limit=0" + log(f"Abruf IPAM: {url}") + + r = requests.get(url, headers=HEADERS, timeout=10) + r.raise_for_status() + return r.json().get('results', []) + +def fetch_plugin_records(): + """ + Holt Records aus dem NetBox DNS Plugin. + Wirft eine Exception bei Fehlern. + """ + records = [] + url = f"{NETBOX_URL}/api/plugins/netbox-dns/records/?zone__name={ZONE_NAME}&limit=0" + log(f"Abruf DNS Plugin: {url}") + + r = requests.get(url, headers=HEADERS, timeout=10) + r.raise_for_status() + data = r.json().get('results', []) + + for rec in data: + name = rec.get('name', '@') + rtype = rec.get('type', 'A') + value = rec.get('value', '') + + # SOA ignorieren (bauen wir selbst) + if rtype == 'SOA': continue + + # TTL Fix (None abfangen) + raw_ttl = rec.get('ttl') + ttl = raw_ttl if raw_ttl is not None else DEFAULT_TTL + + # FQDN Fix (Punkt am Ende erzwingen) + if rtype in ['CNAME', 'NS', 'MX', 'PTR'] and not value.endswith('.'): + value += '.' + + # String für Zonefile bauen (mit Tabs!) + records.append(f"{name}\t{ttl}\tIN\t{rtype}\t{value}") + + return records + +def get_ns_config(plugin_records, all_records): + """ + Entscheidet intelligent über Nameserver und Glue-Records. + """ + ns_entries = [r for r in plugin_records if "\tNS\t" in r] + extra_header_lines = [] + + if ns_entries: + # FALL A: NetBox hat NS Records -> Nimm den ersten als Primary SOA + first_ns_target = ns_entries[0].split('\t')[-1] + primary_ns = first_ns_target + log(f"Info: Nutze existierenden NS aus NetBox als Primary: {primary_ns}") + else: + # FALL B: Keine NS Records -> Fallback auf Container + primary_ns = f"{FALLBACK_NS_HOSTNAME}.{ZONE_NAME}." + + # 1. Den NS-Record selbst hinzufügen + extra_header_lines.append(f"@\tIN\tNS\t{primary_ns}") + + # 2. Dubletten-Check für Glue-Record (A-Record) + glue_already_exists = False + search_pattern = f"{FALLBACK_NS_HOSTNAME}\t" + + for record in all_records: + if record.startswith(search_pattern) and "\tA\t" in record: + glue_already_exists = True + break + + if glue_already_exists: + log(f"Info: Fallback-NS '{FALLBACK_NS_HOSTNAME}' existiert bereits. Kein doppelter Glue-Record.") + else: + extra_header_lines.append(f"{FALLBACK_NS_HOSTNAME}\t{DEFAULT_TTL}\tIN\tA\t{FALLBACK_NS_IP}") + log(f"Info: Erzeuge Glue-Record für: {FALLBACK_NS_HOSTNAME} -> {FALLBACK_NS_IP}") + + return primary_ns, extra_header_lines + +def generate_zone_file_fwd(ipam_data, plugin_records): + """Erstellt die Forward-Zone""" + + # Daten aufbereiten + ipam_records = [] + for ip in ipam_data: + dns_name = ip.get('dns_name', '') + address = ip.get('address', '').split('/')[0] + + if dns_name.endswith(f".{ZONE_NAME}"): + short_name = dns_name.replace(f".{ZONE_NAME}", "") + if short_name == "": short_name = "@" + + if ":" in address: + rtype = "AAAA" + else: + rtype = "A" + + ipam_records.append(f"{short_name}\t{DEFAULT_TTL}\tIN\t{rtype}\t{address}") + + all_records = plugin_records + ipam_records + + # Header-Logik + primary_ns, extra_headers = get_ns_config(plugin_records, all_records) + + serial = int(time.time()) + header = [ + f"$ORIGIN {ZONE_NAME}.", + f"$TTL {DEFAULT_TTL}", + f"@\tIN\tSOA\t{primary_ns}\tadmin.{ZONE_NAME}. (", + f"\t\t{serial}\t; Serial", + "\t\t7200\t\t; Refresh", + "\t\t3600\t\t; Retry", + "\t\t1209600\t\t; Expire", + f"\t\t{DEFAULT_TTL} )\t; Negative Cache TTL", + "" + ] + + header.extend(extra_headers) + header.append("") + + # Schreiben + with open(OUTPUT_FILE_FWD, "w") as f: + f.write("\n".join(header)) + f.write("\n; --- Records ---\n") + f.write("\n".join(all_records)) + f.write("\n") + log(f"SUCCESS: Forward Zone geschrieben ({len(all_records)} Records).") + +def generate_zone_file_rev(ipam_data, plugin_records): + """Erstellt die Reverse-Zone""" + + primary_ns, extra_headers = get_ns_config(plugin_records, []) + + ptr_records = [] + for ip in ipam_data: + dns_name = ip.get('dns_name', '') + address = ip.get('address', '').split('/')[0] + + if dns_name.endswith(f".{ZONE_NAME}"): + parts = address.split('.') + if len(parts) != 4: continue + + ptr_part = f"{parts[3]}.{parts[2]}.{parts[1]}" + fqdn = dns_name if dns_name.endswith('.') else f"{dns_name}." + + ptr_records.append(f"{ptr_part}\t{DEFAULT_TTL}\tIN\tPTR\t{fqdn}") + + serial = int(time.time()) + header = [ + f"$ORIGIN {REVERSE_ZONE_NAME}.", + f"$TTL {DEFAULT_TTL}", + f"@\tIN\tSOA\t{primary_ns}\tadmin.{ZONE_NAME}. (", + f"\t\t{serial}\t; Serial", + "\t\t7200\t\t; Refresh", + "\t\t3600\t\t; Retry", + "\t\t1209600\t\t; Expire", + f"\t\t{DEFAULT_TTL} )\t; Negative Cache TTL", + "" + ] + + if extra_headers: + for line in extra_headers: + if "\tNS\t" in line: + header.append(line) + + header.append("") + + with open(OUTPUT_FILE_REV, "w") as f: + f.write("\n".join(header)) + f.write("\n; --- PTR Records ---\n") + f.write("\n".join(ptr_records)) + f.write("\n") + log(f"SUCCESS: Reverse Zone geschrieben ({len(ptr_records)} Records).") + +if __name__ == "__main__": + # Kurzer Check ob Zielordner existiert + if not os.path.exists(os.path.dirname(OUTPUT_FILE_FWD)): + log(f"FATAL: Verzeichnis {os.path.dirname(OUTPUT_FILE_FWD)} fehlt!") + time.sleep(30) + exit(1) + + log(f"--- CoreDNS Sync startet ---") + log(f"NetBox URL: {NETBOX_URL}") + log(f"Zone: {ZONE_NAME}") + + while True: + log(f"\n--- Sync Start: {time.ctime()} ---") + + try: + # Schritt 1: Daten holen (wirft Exception bei Fehler) + # Wir holen BEIDE Datensätze, bevor wir irgendetwas schreiben. + # Wenn einer fehlschlägt, bricht der ganze Block ab. + ipam_data = fetch_ipam_data() + plugin_records = fetch_plugin_records() + + # Schritt 2: Zonen schreiben + # Wird nur erreicht, wenn fetch_ipam_data UND fetch_plugin_records erfolgreich waren + generate_zone_file_fwd(ipam_data, plugin_records) + generate_zone_file_rev(ipam_data, plugin_records) + + except Exception as e: + # Sicherheits-Logik: Bei JEDEM Fehler (Netzwerk, HTTP 500, Parse-Error) + # fangen wir hier ab und schreiben KEINE Dateien. + # Die alten Dateien bleiben erhalten -> CoreDNS liefert weiter die alten Daten (kein NXDOMAIN). + log(f"CRITICAL ERROR: Sync abgebrochen! Behalte alte Zone-Files. Grund: {e}") + + log(f"Schlafe {REFRESH_INTERVAL} Sekunden...") + time.sleep(REFRESH_INTERVAL) \ No newline at end of file