Files
coredns-netbox/sync.py

252 lines
8.1 KiB
Python

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", "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")
# 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'}
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 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".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".join(ptr_records) + "\n"
write_atomic(OUTPUT_FILE_REV, content)
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)