Initial commit

This commit is contained in:
Gemini Agent
2025-12-04 05:19:36 +00:00
commit 76dffc4c0d
4 changed files with 343 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.env
.DS_Store

22
Dockerfile Normal file
View File

@@ -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"]

75
README.md Normal file
View File

@@ -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) |

242
sync.py Normal file
View File

@@ -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)