import requests import os import time import sys 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", "klenzel.net") REVERSE_ZONE_NAME = os.getenv("REVERSE_ZONE_NAME", "172.in-addr.arpa") 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 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" 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""" 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("") 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__": 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}") while True: 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) except Exception as e: log(f"CRITICAL ERROR: Sync abgebrochen! Behalte alte Zone-Files. Grund: {e}") log(f"Schlafe {REFRESH_INTERVAL} Sekunden...") time.sleep(REFRESH_INTERVAL)