From e656dc6f1636cf8e6f2a6c6764a12fd8bf46bfb1 Mon Sep 17 00:00:00 2001 From: Gemini Bot Date: Tue, 9 Dec 2025 19:19:30 +0000 Subject: [PATCH] feat: Add healthcheck, graceful shutdown, and SSL verify option --- Dockerfile | 3 ++ README.md | 3 ++ sync.py | 81 +++++++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index fd9d5c7..ed91f6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,3 +20,6 @@ COPY sync.py /sync.py ENV PYTHONUNBUFFERED=1 CMD ["python", "/sync.py"] + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD [ $(date +%s) -lt $(($(stat -c %Y /tmp/healthy) + 60)) ] || exit 1 diff --git a/README.md b/README.md index 6844d9b..fde3d99 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Dieser Container automatisiert die Generierung von DNS-Zonendateien für CoreDNS * **Ausfallsicherer Betrieb:** Wenn NetBox nicht erreichbar ist oder Fehler zurückgibt, werden die bestehenden Zonendateien beibehalten. Das Schreiben erfolgt atomar, um Korruption bei Abstürzen zu verhindern. * **Dual-Zone-Unterstützung:** Generiert sowohl Forward- als auch Reverse-(PTR)-Zonen. * **Intelligenter Fallback:** Konfiguriert automatisch einen Fallback-Nameserver, falls in NetBox keine NS-Einträge definiert sind. +* **Healthcheck:** Überwacht den Sync-Status und ermöglicht Docker-Restarts bei anhaltenden Fehlern. ## Konfiguration @@ -27,10 +28,12 @@ Die Konfiguration erfolgt vollständig über Umgebungsvariablen. | Variable | Standardwert | Beschreibung | | :--- | :--- | :--- | | `REFRESH_INTERVAL` | `600` | Synchronisationsintervall in Sekunden. | +| `NETBOX_SSL_VERIFY` | `true` | SSL-Zertifikatsprüfung aktivieren (`true`/`false`). Bei selbstsignierten Zertifikaten auf `false` setzen. | | `OUTPUT_FILE_FWD` | `/zones/db.fwd` | Pfad im Container für die Forward-Zonendatei. | | `OUTPUT_FILE_REV` | `/zones/db.rev` | Pfad im Container für die Reverse-Zonendatei. | | `FALLBACK_NS_HOSTNAME`| `ns1` | Hostname, der als NS-Eintrag verwendet wird, falls keiner in NetBox existiert. | | `FALLBACK_NS_IP` | `127.0.0.1` | IP-Adresse für den Fallback-NS-Glue-Record. | +| `HEALTH_FILE` | `/tmp/healthy` | Pfad zur Statusdatei für den Docker Healthcheck. | ## Verwendung diff --git a/sync.py b/sync.py index aac5b3a..3cbfcc0 100644 --- a/sync.py +++ b/sync.py @@ -2,6 +2,11 @@ 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""" @@ -24,6 +29,11 @@ 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") # Fallback Konfiguration (wenn KEIN NS in NetBox gefunden wird) FALLBACK_NS_HOSTNAME = os.getenv("FALLBACK_NS_HOSTNAME", "ns1") @@ -31,6 +41,17 @@ 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. @@ -39,7 +60,7 @@ def fetch_ipam_data(): 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 = requests.get(url, headers=HEADERS, timeout=10, verify=VERIFY_SSL) r.raise_for_status() return r.json().get('results', []) @@ -52,7 +73,7 @@ def fetch_plugin_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 = requests.get(url, headers=HEADERS, timeout=10, verify=VERIFY_SSL) r.raise_for_status() data = r.json().get('results', []) @@ -163,11 +184,11 @@ def generate_zone_file_fwd(ipam_data, plugin_records): 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", + 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", "" ] @@ -203,11 +224,11 @@ def generate_zone_file_rev(ipam_data, plugin_records): 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", + 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", "" ] @@ -223,6 +244,14 @@ def generate_zone_file_rev(ipam_data, plugin_records): 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!") @@ -232,8 +261,12 @@ if __name__ == "__main__": log(f"--- CoreDNS Sync startet ---") log(f"NetBox URL: {NETBOX_URL}") log(f"Zone: {ZONE_NAME}") + log(f"SSL Verify: {VERIFY_SSL}") - while True: + error_counter = 0 + MAX_ERRORS = 3 + + while running: log(f"\n--- Sync Start: {time.ctime()} ---") try: @@ -242,9 +275,25 @@ if __name__ == "__main__": 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: - log(f"CRITICAL ERROR: Sync abgebrochen! Behalte alte Zone-Files. Grund: {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) - log(f"Schlafe {REFRESH_INTERVAL} Sekunden...") - time.sleep(REFRESH_INTERVAL) \ No newline at end of file + # 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...")