303 lines
9.7 KiB
Python
303 lines
9.7 KiB
Python
import requests
|
|
import os
|
|
import time
|
|
import sys
|
|
import signal
|
|
import urllib3
|
|
|
|
# Suppress insecure request warnings if SSL verification is disabled
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
|
|
def log(msg):
|
|
"""Ungepufferte Ausgabe für Docker Logs"""
|
|
print(msg, flush=True)
|
|
|
|
# --- KONFIGURATION ---
|
|
# Pflichtfelder - Abbruch wenn nicht gesetzt
|
|
NETBOX_URL = os.getenv("NETBOX_URL")
|
|
TOKEN = os.getenv("NETBOX_TOKEN")
|
|
|
|
if not NETBOX_URL or not TOKEN:
|
|
log("FATAL ERROR: Environment variables NETBOX_URL and NETBOX_TOKEN are required.")
|
|
log("Please provide them via -e flag or .env file.")
|
|
sys.exit(1)
|
|
|
|
# Optionale Konfiguration mit Defaults
|
|
ZONE_NAME = os.getenv("ZONE_NAME", "example.com")
|
|
REVERSE_ZONE_NAME = os.getenv("REVERSE_ZONE_NAME", "1.168.192.in-addr.arpa")
|
|
REFRESH_INTERVAL = int(os.getenv("REFRESH_INTERVAL", "600"))
|
|
OUTPUT_FILE_FWD = os.getenv("OUTPUT_FILE_FWD", "/zones/db.fwd")
|
|
OUTPUT_FILE_REV = os.getenv("OUTPUT_FILE_REV", "/zones/db.rev")
|
|
DEFAULT_TTL = os.getenv("DEFAULT_TTL", "3600")
|
|
HEALTH_FILE = os.getenv("HEALTH_FILE", "/tmp/healthy")
|
|
|
|
# SSL Verification
|
|
verify_ssl_env = os.getenv("NETBOX_SSL_VERIFY", "true").lower()
|
|
VERIFY_SSL = verify_ssl_env in ("true", "1", "yes")
|
|
|
|
# Zusätzlicher Filter für IP-Abfragen (z.B. "&vrf=internal" oder "&tag__n=external")
|
|
EXTRA_FILTER = os.getenv("NETBOX_EXTRA_FILTER", "")
|
|
|
|
# Fallback Konfiguration (wenn KEIN NS in NetBox gefunden wird)
|
|
FALLBACK_NS_HOSTNAME = os.getenv("FALLBACK_NS_HOSTNAME", "ns1")
|
|
FALLBACK_NS_IP = os.getenv("FALLBACK_NS_IP", "127.0.0.1")
|
|
|
|
HEADERS = {'Authorization': f'Token {TOKEN}', 'Accept': 'application/json'}
|
|
|
|
# Globale Steuerung
|
|
running = True
|
|
|
|
def signal_handler(signum, frame):
|
|
global running
|
|
log(f"Signal {signum} empfangen. Beende Graceful...")
|
|
running = False
|
|
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
|
|
def fetch_ipam_data():
|
|
"""
|
|
Holt alle aktiven IPs mit DNS-Namen aus NetBox.
|
|
Wirft eine Exception, wenn NetBox nicht erreichbar ist.
|
|
"""
|
|
url = f"{NETBOX_URL}/api/ipam/ip-addresses/?status=active&dns_name__n=&limit=0{EXTRA_FILTER}"
|
|
log(f"Abruf IPAM: {url}")
|
|
|
|
r = requests.get(url, headers=HEADERS, timeout=10, verify=VERIFY_SSL)
|
|
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, verify=VERIFY_SSL)
|
|
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 write_atomic(filepath, content):
|
|
"""Schreibt Datei atomar: Erst in .tmp, dann move/replace."""
|
|
tmp_path = filepath + ".tmp"
|
|
try:
|
|
# Verzeichnis sicherstellen
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
|
|
with open(tmp_path, "w") as f:
|
|
f.write(content)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp_path, filepath)
|
|
return True
|
|
except Exception as e:
|
|
log(f"ERROR beim Schreiben von {filepath}: {e}")
|
|
if os.path.exists(tmp_path):
|
|
try:
|
|
os.remove(tmp_path)
|
|
except:
|
|
pass
|
|
raise e
|
|
|
|
def generate_zone_file_fwd(ipam_data, plugin_records):
|
|
"""Erstellt die Forward-Zone"""
|
|
|
|
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
|
|
|
|
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("")
|
|
|
|
content = "\n".join(header) + "\n; --- Records ---\n" + "\n".join(all_records) + "\n"
|
|
|
|
write_atomic(OUTPUT_FILE_FWD, content)
|
|
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("")
|
|
|
|
content = "\n".join(header) + "\n; --- PTR Records ---\n" + "\n".join(ptr_records) + "\n"
|
|
|
|
write_atomic(OUTPUT_FILE_REV, content)
|
|
log(f"SUCCESS: Reverse Zone geschrieben ({len(ptr_records)} Records).")
|
|
|
|
def touch_health_file():
|
|
"""Aktualisiert die Health-Datei"""
|
|
try:
|
|
with open(HEALTH_FILE, 'w') as f:
|
|
f.write(str(time.time()))
|
|
except Exception as e:
|
|
log(f"WARN: Konnte Health-File nicht schreiben: {e}")
|
|
|
|
if __name__ == "__main__":
|
|
if not os.path.exists(os.path.dirname(OUTPUT_FILE_FWD)):
|
|
log(f"FATAL: Verzeichnis {os.path.dirname(OUTPUT_FILE_FWD)} fehlt!")
|
|
time.sleep(5)
|
|
sys.exit(1)
|
|
|
|
log(f"--- CoreDNS Sync startet ---")
|
|
log(f"NetBox URL: {NETBOX_URL}")
|
|
log(f"Zone: {ZONE_NAME}")
|
|
log(f"SSL Verify: {VERIFY_SSL}")
|
|
|
|
error_counter = 0
|
|
MAX_ERRORS = 3
|
|
|
|
while running:
|
|
log(f"\n--- Sync Start: {time.ctime()} ---")
|
|
|
|
try:
|
|
ipam_data = fetch_ipam_data()
|
|
plugin_records = fetch_plugin_records()
|
|
|
|
generate_zone_file_fwd(ipam_data, plugin_records)
|
|
generate_zone_file_rev(ipam_data, plugin_records)
|
|
|
|
# Reset Errors bei Erfolg
|
|
error_counter = 0
|
|
touch_health_file()
|
|
|
|
except Exception as e:
|
|
error_counter += 1
|
|
log(f"CRITICAL ERROR ({error_counter}/{MAX_ERRORS}): Sync abgebrochen! Grund: {e}")
|
|
|
|
if error_counter >= MAX_ERRORS:
|
|
log("Too many errors. Exiting to allow Docker restart.")
|
|
sys.exit(1)
|
|
|
|
# Smart Sleep: Check 'running' flag frequently
|
|
sleep_steps = 10
|
|
step_duration = REFRESH_INTERVAL / sleep_steps
|
|
|
|
for _ in range(sleep_steps):
|
|
if not running: break
|
|
time.sleep(step_duration)
|
|
|
|
log("Exiting...")
|