Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.DS_Store
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
75
README.md
Normal 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
242
sync.py
Normal 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)
|
||||
Reference in New Issue
Block a user