Mittwoch, Oktober 28, 2026

Netbox - Neue Skripte für Semaphore (DNS & DHCP Sync)

Ich habe einen Raspberry Pi 4 mit LibreELEC bestückt und nutze ihn mit dem Plugin für Waipu u. a. zum Fernsehen. Bei heise.de gibt es derzeit auch einen Artikel zu dem Thema. Vorher hatte ich einen Chromecast, aber das geschwätzige Ding hat tausende (!) Nachrichten pro Tag nach Hause (Alphabet/Google) geschickt, und deswegen ist diese Lösung bei mir zu Hause wieder rausgeflogen und durch LibreELEC/Kodi ersetzt worden. Seit einiger Zeit friert das Bild bei Kodi/Waipu ein, und der Raspberry muss neu gestartet werden.

Mir ist aufgefallen, dass dieses Problem immer nur zu ganz bestimmten Zeiten aufgetreten ist, und daher vermute ich mein NetBox-/DNS-/DHCP-Konstrukt (das ich mit Claude zusammengeschustert habe) als Ursache. Aktuell ist das Skript ziemlich aggressiv, da z. B. die Reservierungslisten bei Technitium bei jedem Durchlauf immer komplett überschrieben werden. Ich muss auch immer einen Skriptdurchlauf bei Semaphore abwarten, bis meine Änderungen in NetBox nach Technitium repliziert werden. Sofern die Änderungen in NetBox genau während eines solchen Durchlaufs passieren, kann das theoretisch zu unvorhersehbaren Effekten bei Technitium führen. Außerdem wäre mir lieber, dass die Änderungen sofort übernommen werden, also eventbasiert und nicht zeitbasiert. Das Ganze muss auch selektiver werden: Ich möchte nicht immer alles neu nach Technitium schreiben, sondern nur die jeweiligen Änderungen.

Claude hat also auf mein Geheiß neue Skripte erstellt. Diese ersetzen die beiden alten Skripte für die Synchronisierung von DNS und DHCP nach Technitium. Als Info vorab: Die neue Lösung läuft bereits seit zwei Wochen, bisher hatte ich keine Probleme damit. Der Entwicklungsprozess lief über längere Zeit und mündete wie sooft in diversen Sackgassen. Ich habe Claude daher den Artikel fertig schreiben lassen, da mir sowohl der Nerv als auch die Zeit dazu gefehlt hat.

Das Ausgangssetup

NetBox ist hier die Source of Truth für IPAM, DNS (Plugin netbox-plugin-dns) und DHCP (Plugin netbox-plugin-dhcp). Ausgerollt wird nach Technitium DNS/DHCP per Ansible, orchestriert über Semaphore. Bisher liefen dafür drei Playbooks als Semaphore-Workflow hintereinander, alle 15 Minuten:

  • DNS: A-Records aus NetBox nach Technitium schreiben
  • DHCP: Reservierungen aus NetBox nach Technitium schreiben
  • Proxmox: VM-Einstellungen zwischen NetBox und Proxmox abgleichen

Zusätzlich holt ein NetBox-Custom-Script die dynamisch vergebenen Leases aus Technitium zurück ins IPAM.

Was wirklich schiefging

Die Vermutung ging in die richtige Richtung, die eigentliche Ursache lag aber an einer anderen Stelle als gedacht. Das DHCP-Playbook verglich bereits vor dem Schreiben – der Hauptstörer war das DNS-Playbook:

  • Tauziehen um dieselben DNS-Records: Der DHCP-Scope in Technitium hat einen Domain-Namen gesetzt. Damit legt Technitium für jeden Lease bzw. jede Reservierung selbst A- und PTR-Records an (zu erkennen am Kommentar “Via ‘Standort-A’ DHCP scope”). Das DNS-Playbook hat alle 15 Minuten ALLE Records mit overwrite=true neu geschrieben – und beim nächsten Lease-Renew hat der DHCP-Server sie wieder zurückgeschrieben. Ein Endlos-Tauziehen um genau die Records der Geräte mit DHCP-Reservierung. Der Raspberry gehört dazu.
  • Namen mit mehreren IPs: Durch overwrite=true hat jeder Schreibvorgang den vorherigen Record ersetzt. Bei Namen mit mehreren A-Records blieb nach jedem Lauf nur die zuletzt geschriebene IP übrig – die Namensauflösung sprang also im 15-Minuten-Takt.
  • Altlasten: Die alten Playbooks haben nie etwas gelöscht. In Technitium lagen dadurch verwaiste DNS-Records und sogar zwei IPs, die doppelt reserviert waren (alte Container, die längst ersetzt waren). Zwei Geräte mit derselben reservierten IP – ein klassischer Adresskonflikt.
  • Verschluckte Fehler: Mehrere Tasks liefen mit failed_when: false. Was schiefging, fiel schlicht nicht auf.

Ob das Einfrieren von Kodi genau daher kam, lässt sich nicht beweisen. Das Zeitmuster passt aber zum 15-Minuten-Takt, und seit der Umstellung ist es nicht mehr aufgetreten.

Die neue Architektur

Pro Datentyp gibt es jetzt genau eine Richtung:

  • Statische DNS-Records (Server, Router, Infrastruktur): NetBox → Technitium, eventbasiert plus Fallback alle 30 Minuten
  • DHCP-Reservierungen: NetBox → Technitium, eventbasiert plus Fallback alle 30 Minuten
  • DNS-Namen von DHCP-Clients: erzeugt Technitium selbst aus dem Hostnamen der Reservierung
  • Dynamische Leases: Technitium → NetBox (bestehendes Custom Script, Tag dhcp-dynamic)
  • Proxmox-Abgleich: eigener stündlicher Schedule, unabhängig von den anderen

Keine Kette und kein Workflow mehr. Jedes Playbook ist für sich idempotent und schreibt nur, was tatsächlich abweicht. Ein normaler Lauf ohne Änderungen in NetBox schreibt: nichts.

Schritt 1: NetBox und Plugins aktualisieren

NetBox läuft jetzt auf 4.7.3, dazu netbox-plugin-dns 1.7.3 und netbox-plugin-dhcp 0.2.0. Die Plugins habe ich über die Oberfläche aktualisiert. Wichtig beim DNS-Plugin: Die Versionen 1.7.0 und 1.7.1 (sowie 1.6.0 und 1.5.12/1.5.13) enthielten einen Migrationsfehler, der verwaltete PTR-Records in aktiven Zonen gelöscht hat. Also direkt auf 1.7.3 gehen und vorher kurz den Bestand an PTR-Records in den Reverse-Zonen prüfen.

Schritt 2: Das neue DNS-Playbook (dns-sync-standort-a.yml)

Die Kernideen:

  • Ist-Zustand aus Technitium lesen, Soll-Zustand aus NetBox, Vergleich über den Schlüssel Name|IP.
  • Nur Records anfassen, die den Kommentar “Managed via NetBox” tragen. Manuelle Einträge und die Records des DHCP-Scopes bleiben unberührt.
  • IPs mit DHCP-Reservierung und IPs mit Tag dhcp-dynamic werden auf Soll- UND Ist-Seite komplett ignoriert. Deren Namen verwaltet Technitium selbst. Das beendet das Tauziehen.
  • Neue Records mit overwrite=false anlegen, danach überzählige gezielt löschen. So bleiben mehrere IPs pro Name erhalten, und es entsteht keine Lücke.
  • Schutzbremse: Liefert NetBox nichts oder würden mehr als zehn Records gelöscht, bricht der Lauf ab.
  • Zwei Modi: Event (nur die betroffenen Namen) und Reconcile (die ganze Zone als Fallback).

Ein Detail, das einen Testlauf gekostet hat: Der lesende Request an Technitium muss mit check_mode: false laufen. Sonst wird er im Ansible-Check-Mode übersprungen, und ohne Ist-Zustand sieht es so aus, als fehle alles.

- name: Zone aus Technitium lesen
  ansible.builtin.uri:
    url: "{{ technitium_url }}/api/zones/records/get?token={{ technitium_token }}&domain={{ zone_name }}&zone={{ zone_name }}&listZone=true"
    method: GET
    validate_certs: false
  register: tt_zone
  check_mode: false
  no_log: true
- name: Technitium-Antwort pruefen
  ansible.builtin.assert:
    that:
      - tt_zone.json is defined
      - tt_zone.json.status | default('') == 'ok'
    fail_msg: "Kein Ist-Zustand - Abbruch, kein Diff ohne Technitium-Antwort."

Schritt 3: Das neue DHCP-Playbook (dhcp-sync-standort-a.yml)

  • Vergleich pro MAC-Adresse: Weicht IP oder Hostname ab (Hostname ohne Beachtung der Groß-/Kleinschreibung), wird die Reservierung ersetzt. Kommentare fließen bewusst nicht in den Vergleich ein.
  • Technitium kann Reservierungen nicht ändern, nur entfernen und neu anlegen. Deshalb pro Eintrag Entfernen und sofort Neuanlegen – mit Rollback, falls das Neuanlegen scheitert. Die Lücke liegt so im Millisekundenbereich und betrifft nur genau diesen einen Eintrag.
  • Neu angelegte Reservierungen bekommen einen Marker “netbox” im Kommentar. Nur solche löscht der Fallback-Lauf als verwaist, manuell angelegte bleiben in Ruhe.
  • Erkennung von IP-Konflikten: Hält eine andere MAC-Adresse bereits die Soll-IP, wird das im Log gemeldet.

Das Ersetzen einer einzelnen Reservierung liegt in einer eigenen Task-Datei (tasks/dhcp-replace-one.yml), die per include_tasks pro Eintrag aufgerufen wird. Nur so laufen Entfernen und Neuanlegen wirklich direkt hintereinander.

Schritt 4: Sicher testen – dry_run als Survey-Variable

Die Dry-Run-Checkbox von Semaphore schaltet Ansible in den Check-Mode, und dabei werden uri-Tasks komplett übersprungen. Deshalb hat jedes Playbook eine eigene Variable dry_run (Standard: true). In Semaphore wird sie als Survey-Variable angelegt: Typ Enum, Werte true/false, Standard true, und zwar als Extra-Variable, nicht als Environment-Variable – sonst kommt sie im Playbook gar nicht an.

Der Ablauf pro Playbook:

  • Dry-Run starten und den Aktionsplan lesen
  • Altlasten bereinigen (siehe unten)
  • scharf laufen lassen
  • erneuter Dry-Run – jetzt müssen alle Listen leer sein

Achtung bei den CLI-Argumenten in Semaphore: Dort muss es [”-e”, “dry_run=false”] heißen. Ein bloßes dry_run=false wird als zweiter Playbook-Name interpretiert.

Schritt 5: Altlasten bereinigen

Der erste ehrliche Vergleich hat einiges ans Licht gebracht:

  • verwaiste DNS-Records umbenannter oder entfernter Geräte
  • zwei IPs, die in Technitium doppelt reserviert waren
  • Reservierungen, die nur in Technitium existierten und in NetBox fehlten
  • generische Hostnamen (iphone, galaxy-…) mit mehreren IPs

Die verwaisten Records hat der erste scharfe DNS-Lauf entfernt, den Rest habe ich in NetBox nachgetragen bzw. in Technitium gelöscht. Damit NetBox doppelte IPs künftig verhindert, an der VRF die Option “Enforce unique space” aktivieren.

Schritt 6: Schedules einrichten und den alten Workflow entfernen

dns-sync-standort-a        */30 * * * *
dhcp-sync-standort-a       15,45 * * * *
push-pve-config-standort-a 0 * * * *

Beim Löschen der alten Templates ist Semaphore eingefroren (100 % CPU, die Oberfläche hing). Der Grund: Semaphore löscht mit dem Template alle zugehörigen Läufe samt Logs. Bei 672 vorgehaltenen Läufen pro Template mit langen Logs waren das über 300 MB in der SQLite-Datenbank, und das dauert einfach. Nicht neu starten (dann rollt SQLite die Transaktion zurück, und alles beginnt von vorn), sondern abwarten und die WAL-Datei beobachten:

watch -n 5 'ls -la /opt/semaphore/database.sqlite*'

Die Datenbankdatei schrumpft danach nicht von selbst – das ist normales SQLite-Verhalten. Wer den Platz zurückhaben will:

systemctl stop semaphore
sqlite3 /opt/semaphore/database.sqlite "PRAGMA wal_checkpoint(TRUNCATE); VACUUM;"
systemctl start semaphore

Für die neuen Templates: max_tasks_per_template deutlich niedriger setzen (100 bis 200), und in der ansible.cfg display_skipped_hosts = False setzen. Das macht die Logs um ein Vielfaches kürzer.

Schritt 7: Eventbasiert über NetBox-Webhooks

NetBox ruft bei jeder Änderung die Semaphore-API auf und übergibt die ID des betroffenen Objekts. Dafür braucht es pro Playbook einen Webhook und eine Event-Rule (in NetBox unter Operations › Integrations).

Webhook für DNS: URL http://192.168.2.11:3000/api/project/2/tasks, Methode POST, Content-Type application/json, Header “Authorization: Bearer” plus Semaphore-API-Token. Body:

{"template_id": 9, "environment": "{"record_id": {{ data.id }}, "nb_event": "{{ event }}", "old_fqdn": "{{ snapshots.prechange.fqdn | default('') }}", "old_value": "{{ snapshots.prechange.value | default('') }}", "dry_run": "false"}"}

Event-Rule für DNS: Objekttyp NetBox DNS › Record, Ereignisse created/updated/deleted, Bedingung:

{"attr": "type", "value": "A"}

Webhook für DHCP (gleiche URL, gleicher Header), Body:

{"template_id": 10, "environment": "{"res_id": {{ data.id }}, "nb_event": "{{ event }}", "mac": "{{ data.hw_address.mac_address | default('') }}", "dry_run": "false"}"}

Event-Rule für DHCP: Objekttyp NetBox DHCP › Host Reservation, Ereignisse created/updated/deleted, keine Bedingung.

Vorher lohnt sich ein manueller Test der Semaphore-API:

curl -i -X POST http://192.168.2.11:3000/api/project/2/tasks -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d '{"template_id": 9, "environment": "{"record_id": 391, "nb_event": "updated", "dry_run": "true"}"}'

Erwartet wird HTTP 201. Ein 401 bedeutet: Token falsch. Bei mir war er beim Kopieren schlicht abgeschnitten.

Stolpersteine beim Testen der Events

  • Falsche ID: Die ID in der Geräte-URL (dcim/devices/…) ist nicht die ID des DNS-Records. Die steht in der URL unter plugins/netbox-dns/records/….
  • nb_lookup mit ID-Filter: Ein api_filter=’id=…’ führt im Lookup-Plugin netbox.netbox.nb_lookup intern zu einem direkten get(). Existiert die ID nicht, stürzt es mit “NoneType object is not iterable” ab. Lösung: alle Objekte holen und lokal per selectattr nach der ID filtern.
  • Verwaltete Records: Records, die das DNS-Plugin aus IPAM-Adressen erzeugt, sind gesperrt und lassen sich nicht direkt bearbeiten. Getestet wird deshalb über das IPAM: eine IP-Adresse mit DNS-Namen anlegen, prüfen, wieder löschen. Das Plugin aktualisiert dabei den Record – und genau das löst das Event aus.
  • Webhooks laufen über den netbox-rq-Worker. Passiert gar nichts, zuerst dort ins Log schauen.

Fazit

Aus drei verketteten Playbooks, die alle 15 Minuten den kompletten Bestand neu geschrieben haben, sind zwei vergleichsbasierte Syncs geworden. Sie reagieren sofort, wenn sich in NetBox etwas ändert, und schreiben nur genau diese Änderung – plus ein periodischer Fallback, falls einmal ein Event verloren geht. Die Bildaussetzer waren mit hoher Wahrscheinlichkeit kein WLAN- oder Kodi-Problem, sondern ein selbstgebautes Tauziehen zwischen Playbook und DHCP-Server. Und nebenbei hat der erste ehrliche Abgleich mehr Altlasten zutage gefördert, als mir lieb war.

Anhang: Die kompletten Playbooks

Zugangsdaten (NETBOX_TOKEN, TECHNITIUM_TOKEN) kommen als Umgebungsvariablen aus Semaphore. Hostnamen, Zone und VRF müssen an die eigene Umgebung angepasst werden. Die Hilfsdatei tasks/dhcp-replace-one.yml muss relativ zum DHCP-Playbook im Unterordner tasks liegen.

dns-sync-standort-a.yml

---
# dns-sync-standort-a.yml
# NetBox (netbox-plugin-dns) -> Technitium, nur A-Records in der VRF Standort-A.
# Ersetzt push-dns-standort-a.yml. Schreibt NUR Abweichungen.
#
# Modi (ein Playbook, ein Semaphore-Template):
#   Event     (NetBox-Webhook): -e record_id=123 -e nb_event=updated -e old_fqdn=... -e old_value=...
#             -> gleicht ALLE Records der betroffenen Namen ab (neu + alt), nicht nur einen.
#   Reconcile (Schedule, ohne Extra-Vars): gleicht die ganze Zone ab.
#
# Vergleich ueber Schluessel fqdn|ip. Neue Records mit overwrite=false (mehrere IPs
# pro Name bleiben erhalten), danach werden ueberzaehlige verwaltete Records geloescht.
# TTL-Aenderungen an bestehenden Records werden NICHT nachgezogen.
#
# WICHTIG: Nicht mit der Semaphore-Dry-Run-Checkbox (Check-Mode) testen,
# sondern mit der Variable dry_run=true (Default).
- name: NetBox DNS -> Technitium (event / reconcile)
  hosts: dns-standort-a
  gather_facts: false
  vars:
    netbox_api: "https://192.168.2.10"
    netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
    technitium_url: "https://{{ ansible_host }}:53443"
    technitium_token: "{{ lookup('env', 'TECHNITIUM_TOKEN') }}"
    target_vrf_name: "VRF Standort-A"
    zone_name: "standort-a.intern"
    # Identisch zum alten Playbook, damit bestehende Records als verwaltet erkannt werden.
    managed_comment: "Managed via NetBox (Ansible push-dns-standort-a)"
    dry_run: true
    max_deletes: 10
    record_id: ""
    nb_event: ""
    old_fqdn: ""
    old_value: ""
    mode: "{{ 'event' if (record_id | string | length > 0) else 'reconcile' }}"
  tasks:
    # ---------- NetBox: Prefixe + Soll-Records ----------
    - name: Ziel-VRF ermitteln
      ansible.builtin.set_fact:
        target_vrf: >-
          {{ query('netbox.netbox.nb_lookup', 'vrfs',
                    api_endpoint=netbox_api, token=netbox_token, validate_certs=false)
             | map(attribute='value') | selectattr('name', 'equalto', target_vrf_name) | first }}
    - name: Prefixe der Ziel-VRF holen
      ansible.builtin.set_fact:
        target_prefixes: >-
          {{ query('netbox.netbox.nb_lookup', 'prefixes',
                    api_endpoint=netbox_api, token=netbox_token,
                    api_filter='vrf_id=' ~ target_vrf.id, validate_certs=false)
             | map(attribute='value') | map(attribute='prefix') | list }}
    - name: Aktive A-Records aus NetBox holen
      ansible.builtin.set_fact:
        nb_a_records: >-
          {{ query('netbox.netbox.nb_lookup', 'records',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dns', api_filter='status=active type=A',
                    validate_certs=false)
             | map(attribute='value') | selectattr('type', 'equalto', 'A') | list }}
        desired_all: []
        current_all: []
        to_add: []
        to_delete: []
    # IPs mit DHCP-Reservierung: deren A/PTR erzeugt Technitiums DHCP-Scope selbst
    # (aus dem hostName der Reservierung). Diese IPs werden hier auf Soll- UND Ist-Seite
    # komplett ignoriert, sonst entsteht wieder ein Tauziehen um denselben Record.
    - name: IPs mit DHCP-Reservierung aus NetBox holen
      ansible.builtin.set_fact:
        dhcp_reserved_ips: >-
          {{ query('netbox.netbox.nb_lookup', 'hostreservations',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dhcp', validate_certs=false)
             | map(attribute='value')
             | selectattr('ipv4_address', 'mapping')
             | map(attribute='ipv4_address.address')
             | map('ansible.utils.ipaddr', 'address') | list }}
    # Dynamische Leases (von SyncDynamicLeases mit Tag dhcp-dynamic angelegt):
    # deren DNS macht Technitium ebenfalls selbst -> genauso ignorieren.
    - name: Dynamische Lease-IPs aus NetBox holen und zur Ignorier-Liste hinzufuegen
      ansible.builtin.set_fact:
        dhcp_dynamic_ips: >-
          {{ query('netbox.netbox.nb_lookup', 'ip-addresses',
                    api_endpoint=netbox_api, token=netbox_token,
                    api_filter='tag=dhcp-dynamic vrf_id=' ~ target_vrf.id,
                    validate_certs=false)
             | map(attribute='value') | map(attribute='address')
             | map('ansible.utils.ipaddr', 'address') | list }}
    - name: Ignorier-Liste zusammenfuehren
      ansible.builtin.set_fact:
        dhcp_reserved_ips: "{{ (dhcp_reserved_ips + dhcp_dynamic_ips) | unique }}"
    - name: Soll-Records normalisieren (nur Ziel-VRF)
      ansible.builtin.set_fact:
        desired_all: >-
          {{ desired_all + [{
               'fqdn': item.fqdn | lower | regex_replace('.$', ''),
               'ip': item.value,
               'ttl': item.ttl | default(3600, true),
               'zone': item.zone.name | default(zone_name)
             }] }}
      loop: "{{ nb_a_records }}"
      loop_control:
        label: "{{ item.fqdn }}"
      when:
        - item.value is ansible.utils.in_one_network(target_prefixes)
        - item.value not in dhcp_reserved_ips
    # ---------- Event: betroffene Namen bestimmen ----------
    - name: "[event] Record aus NetBox laden (auch inaktiv, um den Namen zu kennen)"
      ansible.builtin.set_fact:
        # Bewusst ohne id=-Filter: nb_lookup macht daraus ein get() und stuerzt bei
        # unbekannter ID mit "NoneType not iterable" ab. Stattdessen alle Records holen
        # und lokal nach ID filtern -> unbekannte ID ergibt einfach eine leere Liste.
        ev_rec: >-
          {{ query('netbox.netbox.nb_lookup', 'records',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dns', validate_certs=false)
             | map(attribute='value')
             | selectattr('id', 'equalto', record_id | int) | list }}
      when: mode == 'event' and nb_event != 'deleted'
    - name: "[event] Betroffene Namen (neu + alt)"
      ansible.builtin.set_fact:
        scope_fqdns: >-
          {{ ((ev_rec | default([])) | map(attribute='fqdn') | list + [old_fqdn])
             | map('lower') | map('regex_replace', '.$', '')
             | reject('equalto', '') | unique | list }}
      when: mode == 'event'
    # ---------- Technitium: Ist-Zustand (laeuft auch im Check-Mode) ----------
    - name: Zone aus Technitium lesen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/get?token={{ technitium_token }}&domain={{ zone_name }}&zone={{ zone_name }}&listZone=true"
        method: GET
        validate_certs: false
      register: tt_zone
      check_mode: false
      no_log: true
    - name: Technitium-Antwort pruefen
      ansible.builtin.assert:
        that:
          - tt_zone.json is defined
          - tt_zone.json.status | default('') == 'ok'
          - tt_zone.json.response.records is defined
        fail_msg: "Technitium-Zone {{ zone_name }} konnte nicht gelesen werden - Abbruch, kein Diff ohne Ist-Zustand."
    - name: Verwaltete A-Records aus Technitium normalisieren
      ansible.builtin.set_fact:
        current_all: "{{ current_all + [{'fqdn': item.name | lower, 'ip': item.rData.ipAddress}] }}"
      loop: >-
        {{ tt_zone.json.response.records
           | selectattr('type', 'equalto', 'A')
           | selectattr('comments', 'defined')
           | selectattr('comments', 'equalto', managed_comment) | list }}
      loop_control:
        label: "{{ item.name }}"
      when: item.rData.ipAddress not in dhcp_reserved_ips
    # ---------- Diff (Event: nur betroffene Namen, Reconcile: alles) ----------
    - name: Auf Geltungsbereich einschraenken
      ansible.builtin.set_fact:
        desired: "{{ desired_all | selectattr('fqdn', 'in', scope_fqdns) | list if mode == 'event' else desired_all }}"
        current: "{{ current_all | selectattr('fqdn', 'in', scope_fqdns) | list if mode == 'event' else current_all }}"
    - name: Schluessel bilden (fqdn|ip)
      ansible.builtin.set_fact:
        desired_keys: "{{ desired | map(attribute='fqdn') | zip(desired | map(attribute='ip')) | map('join', '|') | list }}"
        current_keys: "{{ current | map(attribute='fqdn') | zip(current | map(attribute='ip')) | map('join', '|') | list }}"
    - name: Fehlende Records vormerken
      ansible.builtin.set_fact:
        to_add: "{{ to_add + [item] }}"
      loop: "{{ desired }}"
      loop_control:
        label: "{{ item.fqdn }}|{{ item.ip }}"
      when: (item.fqdn ~ '|' ~ item.ip) not in current_keys
    - name: Ueberzaehlige verwaltete Records vormerken
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [item] }}"
      loop: "{{ current }}"
      loop_control:
        label: "{{ item.fqdn }}|{{ item.ip }}"
      when: (item.fqdn ~ '|' ~ item.ip) not in desired_keys
    - name: "[reconcile] Schutz: NetBox leer oder zu viele Loeschungen"
      ansible.builtin.assert:
        that:
          - desired | length > 0
          - to_delete | length <= (max_deletes | int)
        fail_msg: >-
          Abbruch: {{ desired | length }} Soll-Records, {{ to_delete | length }} Loeschungen geplant
          (max_deletes={{ max_deletes }}).
      when: mode == 'reconcile'
    - name: Geplante Aenderungen
      ansible.builtin.debug:
        msg:
          mode: "{{ mode }}"
          scope: "{{ scope_fqdns | default('alle') }}"
          ignoriert_dhcp: "{{ dhcp_reserved_ips | length }} (davon dynamisch: {{ dhcp_dynamic_ips | length }})"
          soll: "{{ desired | length }}"
          ist_verwaltet: "{{ current | length }}"
          add: "{{ to_add | map(attribute='fqdn') | zip(to_add | map(attribute='ip')) | map('join', ' -> ') | list }}"
          delete: "{{ to_delete | map(attribute='fqdn') | zip(to_delete | map(attribute='ip')) | map('join', ' -> ') | list }}"
          dry_run: "{{ dry_run }}"
    # ---------- Ausfuehren: erst anlegen, dann loeschen (keine Luecke) ----------
    - name: Records anlegen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/add"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          domain: "{{ item.fqdn }}"
          zone: "{{ item.zone }}"
          type: "A"
          ttl: "{{ item.ttl }}"
          overwrite: "false"
          ipAddress: "{{ item.ip }}"
          ptr: "true"
          createPtrZone: "true"
          comments: "{{ managed_comment }}"
        validate_certs: false
      loop: "{{ to_add }}"
      loop_control:
        label: "{{ item.fqdn }} -> {{ item.ip }}"
      register: add_result
      failed_when: (add_result.json.status | default('error')) != 'ok'
      ignore_errors: true
      no_log: true
      when: not (dry_run | bool)
    - name: Records loeschen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/delete"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          domain: "{{ item.fqdn }}"
          zone: "{{ zone_name }}"
          type: "A"
          ipAddress: "{{ item.ip }}"
        validate_certs: false
      loop: "{{ to_delete }}"
      loop_control:
        label: "{{ item.fqdn }} ({{ item.ip }})"
      register: del_result
      failed_when: (del_result.json.status | default('error')) != 'ok'
      ignore_errors: true
      no_log: true
      when: not (dry_run | bool)
    - name: Fehler auflisten
      ansible.builtin.debug:
        msg: "FEHLER {{ item.item.fqdn }} ({{ item.item.ip }}): {{ item.json.errorMessage | default(item.msg | default('?')) }}"
      loop: "{{ (add_result.results | default([])) + (del_result.results | default([])) }}"
      loop_control:
        label: "{{ item.item.fqdn | default('?') }}"
      when:
        - not (dry_run | bool)
        - item.failed | default(false)

dhcp-sync-standort-a.yml

---
# dhcp-sync-standort-a.yml
# NetBox (netbox-plugin-dhcp, Host Reservations) -> Technitium-Scope "Standort-A".
# Ersetzt push-dhcp-standort-a.yml. Schreibt NUR Abweichungen.
#
# Modi (ein Playbook, ein Semaphore-Template):
#   Event     (NetBox-Webhook, pro Reservierung):
#             -e res_id=42 -e nb_event=updated -e mac=AA:BB:CC:DD:EE:FF
#   Reconcile (Semaphore-Schedule alle 30 min, ohne Extra-Vars): voller Diff.
#
# Aenderung einer bestehenden Reservierung: pro Eintrag Remove -> Add direkt
# hintereinander (tasks/dhcp-replace-one.yml); schlaegt das Add fehl, wird die
# alte Reservierung sofort wiederhergestellt.
#
# Von diesem Playbook angelegte Reservierungen tragen managed_marker im Kommentar.
# Nur solche werden im Reconcile als "verwaist" geloescht. Manuelle Reservierungen
# in Technitium bleiben unangetastet.
#
# Test: ansible-playbook -i inventory/netbox.yml dhcp-sync-standort-a.yml --limit dns-standort-a -e dry_run=true
- name: NetBox DHCP -> Technitium (event / reconcile)
  hosts: dns-standort-a
  gather_facts: false
  vars:
    netbox_api: "https://192.168.2.10"
    netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
    technitium_url: "https://{{ ansible_host }}:53443"
    technitium_token: "{{ lookup('env', 'TECHNITIUM_TOKEN') }}"
    target_vrf_name: "VRF Standort-A"
    technitium_scope_name: "Standort-A"
    managed_marker: "[netbox]"
    dry_run: true
    max_deletes: 5
    res_id: ""
    nb_event: ""
    mac: ""
    mode: "{{ 'event' if (res_id | string | length > 0) else 'reconcile' }}"
    mac_param: "{{ mac | replace(':', '-') | upper }}"
  tasks:
    # ---------- Prefixe der Ziel-VRF ----------
    - name: Ziel-VRF ermitteln
      ansible.builtin.set_fact:
        target_vrf: >-
          {{ query('netbox.netbox.nb_lookup', 'vrfs',
                    api_endpoint=netbox_api, token=netbox_token, validate_certs=false)
             | map(attribute='value') | selectattr('name', 'equalto', target_vrf_name) | first }}
    - name: Prefixe der Ziel-VRF holen
      ansible.builtin.set_fact:
        target_prefixes: >-
          {{ query('netbox.netbox.nb_lookup', 'prefixes',
                    api_endpoint=netbox_api, token=netbox_token,
                    api_filter='vrf_id=' ~ target_vrf.id, validate_certs=false)
             | map(attribute='value') | map(attribute='prefix') | list }}
    # ---------- Ist-Zustand Technitium ----------
    - name: Technitium-Scope lesen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/get?token={{ technitium_token }}&name={{ technitium_scope_name | urlencode }}"
        method: GET
        validate_certs: false
      register: tt_scope
      check_mode: false
      no_log: true
    - name: Technitium-Antwort pruefen
      ansible.builtin.assert:
        that:
          - tt_scope.json is defined
          - tt_scope.json.status | default('') == 'ok'
          - tt_scope.json.response is defined
        fail_msg: "Technitium-Scope {{ technitium_scope_name }} konnte nicht gelesen werden - Abbruch."
    - name: Ist-Reservierungen indexieren
      ansible.builtin.set_fact:
        current_leases: "{{ tt_scope.json.response.reservedLeases | default([]) }}"
        current_by_mac: >-
          {{ dict((tt_scope.json.response.reservedLeases | default([])) | map(attribute='hardwareAddress') | map('upper')
                  | zip(tt_scope.json.response.reservedLeases | default([]))) }}
        desired: []
        to_add: []
        to_change: []
        to_delete: []
        unmanaged_conflicts: []
    # ---------- Soll-Zustand NetBox ----------
    # Immer alle holen und lokal nach ID filtern (nb_lookup mit id=-Filter stuerzt
    # bei unbekannter ID ab).
    - name: Host Reservations aus NetBox holen
      ansible.builtin.set_fact:
        nb_res_all: >-
          {{ query('netbox.netbox.nb_lookup', 'hostreservations',
                    api_endpoint=netbox_api, token=netbox_token, plugin='netbox-dhcp',
                    validate_certs=false)
             | map(attribute='value') | list }}
    - name: Auf Geltungsbereich einschraenken (Event = nur res_id, Reconcile = alle)
      ansible.builtin.set_fact:
        nb_res: >-
          {{ [] if (mode == 'event' and nb_event == 'deleted')
             else (nb_res_all | selectattr('id', 'equalto', res_id | int) | list
                   if mode == 'event' else nb_res_all) }}
    - name: Soll-Reservierungen im Technitium-Format aufbauen (nur Ziel-VRF, nur mit MAC)
      ansible.builtin.set_fact:
        desired: >-
          {{ desired + [{
               'mac': item.hw_address.mac_address | replace(':', '-') | upper,
               'address': item.ipv4_address.address | ansible.utils.ipaddr('address'),
               'hostName': item.hostname,
               'comments': managed_marker ~ ((' ' ~ item.comments) if (item.comments | default('', true)) else '')
             }] }}
      loop: "{{ nb_res }}"
      loop_control:
        label: "{{ item.hostname | default(item.id) }}"
      when:
        - item.ipv4_address is mapping
        - item.ipv4_address.address is defined
        - item.hw_address is mapping
        - item.hw_address.mac_address is defined
        - (item.ipv4_address.address | ansible.utils.ipaddr('address')) is ansible.utils.in_one_network(target_prefixes)
    - name: Reservierungen ohne MAC/IP warnen (werden nicht gepusht)
      ansible.builtin.debug:
        msg: "WARNUNG: {{ item.hostname | default(item.id) }} hat keine aufloesbare MAC oder IP"
      loop: "{{ nb_res }}"
      loop_control:
        label: "{{ item.hostname | default(item.id) }}"
      when: not (item.hw_address is mapping and item.hw_address.mac_address is defined
                 and item.ipv4_address is mapping and item.ipv4_address.address is defined)
    - name: Hilfsindizes
      ansible.builtin.set_fact:
        desired_macs: "{{ desired | map(attribute='mac') | list }}"
        desired_mac_by_ip: "{{ dict(desired | map(attribute='address') | zip(desired | map(attribute='mac'))) }}"
    # ---------- Aktionsplan ----------
    - name: Neu
      ansible.builtin.set_fact:
        to_add: "{{ desired | rejectattr('mac', 'in', current_by_mac.keys() | list) | list }}"
    - name: Geaendert (IP oder Hostname weicht ab; Kommentar wird bewusst ignoriert)
      ansible.builtin.set_fact:
        to_change: "{{ to_change + [{'target': item, 'existing': current_by_mac[item.mac]}] }}"
      loop: "{{ desired }}"
      loop_control:
        label: "{{ item.hostName }}"
      when:
        - item.mac in current_by_mac
        - (current_by_mac[item.mac].address != item.address)
          or ((current_by_mac[item.mac].hostName | default('', true) | lower) != (item.hostName | lower))
    - name: IP-Konflikte (andere MAC haelt die Soll-IP) - verwaltete werden entfernt
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [item] if managed else to_delete }}"
        unmanaged_conflicts: "{{ unmanaged_conflicts if managed else unmanaged_conflicts + [item] }}"
      loop: "{{ current_leases }}"
      loop_control:
        label: "{{ item.hostName | default('') }} {{ item.address }}"
      vars:
        managed: "{{ (item.comments | default('', true)).startswith(managed_marker) }}"
      when:
        - item.address in desired_mac_by_ip
        - desired_mac_by_ip[item.address] != (item.hardwareAddress | upper)
    - name: "[event] Geloeschte / nicht mehr gueltige Reservierung entfernen"
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [current_by_mac[mac_param]] }}"
      when:
        - mode == 'event'
        - mac_param | length > 0
        - mac_param in current_by_mac
        - mac_param not in desired_macs
    - name: "[reconcile] Verwaiste verwaltete Reservierungen entfernen"
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [item] }}"
      loop: "{{ current_leases }}"
      loop_control:
        label: "{{ item.hostName | default('') }} {{ item.address }}"
      when:
        - mode == 'reconcile'
        - (item.comments | default('', true)).startswith(managed_marker)
        - (item.hardwareAddress | upper) not in desired_macs
    - name: Doppelte Loeschungen entfernen
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete | unique }}"
    - name: "[reconcile] Schutz: NetBox leer oder zu viele Loeschungen"
      ansible.builtin.assert:
        that:
          - desired | length > 0
          - to_delete | length <= (max_deletes | int)
        fail_msg: >-
          Abbruch: {{ desired | length }} Soll-Reservierungen, {{ to_delete | length }} Loeschungen geplant
          (max_deletes={{ max_deletes }}).
      when: mode == 'reconcile'
    - name: Aktionsplan
      ansible.builtin.debug:
        msg:
          mode: "{{ mode }}"
          neu: "{{ to_add | map(attribute='hostName') | list }}"
          aendern: "{{ to_change | map(attribute='target') | map(attribute='hostName') | list }}"
          loeschen: "{{ to_delete | map(attribute='hostName', default='?') | list }}"
          konflikt_unverwaltet: "{{ unmanaged_conflicts | map(attribute='address') | list }}"
          dry_run: "{{ dry_run }}"
    - name: Details zu Aenderungen (alt -> neu)
      ansible.builtin.debug:
        msg: "{{ item.target.mac }}: {{ item.existing.hostName | default('') }}/{{ item.existing.address }} -> {{ item.target.hostName }}/{{ item.target.address }}"
      loop: "{{ to_change }}"
      loop_control:
        label: "{{ item.target.hostName }}"
    - name: WARNUNG unverwaltete Konflikte (Soll-IP von manueller Reservierung belegt, Add wird fehlschlagen)
      ansible.builtin.debug:
        msg: "{{ item.address }} ist in Technitium manuell fuer {{ item.hardwareAddress }} reserviert"
      loop: "{{ unmanaged_conflicts }}"
      loop_control:
        label: "{{ item.address }}"
    # ---------- Ausfuehren: loeschen -> neu -> aendern ----------
    - name: Reservierungen loeschen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/removeReservedLease"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          name: "{{ technitium_scope_name }}"
          hardwareAddress: "{{ item.hardwareAddress }}"
        validate_certs: false
      loop: "{{ to_delete }}"
      loop_control:
        label: "{{ item.hostName | default('') }} {{ item.address }}"
      register: del_result
      failed_when: (del_result.json.status | default('error')) != 'ok'
      no_log: true
      when: not (dry_run | bool)
    - name: Neue Reservierungen anlegen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/addReservedLease"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          name: "{{ technitium_scope_name }}"
          hardwareAddress: "{{ item.mac }}"
          ipAddress: "{{ item.address }}"
          hostName: "{{ item.hostName }}"
          comments: "{{ item.comments }}"
        validate_certs: false
      loop: "{{ to_add }}"
      loop_control:
        label: "{{ item.hostName }}"
      register: add_result
      failed_when: (add_result.json.status | default('error')) != 'ok'
      ignore_errors: true
      no_log: true
      when: not (dry_run | bool)
    - name: Geaenderte Reservierungen ersetzen (pro Eintrag Remove -> Add, mit Rollback)
      ansible.builtin.include_tasks: tasks/dhcp-replace-one.yml
      loop: "{{ to_change }}"
      loop_control:
        loop_var: chg
        label: "{{ chg.target.hostName }}"
      when: not (dry_run | bool)

tasks/dhcp-replace-one.yml

---
# tasks/dhcp-replace-one.yml
# Ersetzt GENAU EINE Reservierung: Remove -> Add direkt hintereinander.
# Schlaegt das Add fehl, wird die alte Reservierung wiederhergestellt.
# Schlaegt auch der Rollback fehl, wird das deutlich im Log gemeldet.
# Erwartet loop_var "chg" mit chg.target (Soll) und chg.existing (Ist aus Technitium).
- name: "{{ chg.target.hostName }}: alte Reservierung entfernen"
  ansible.builtin.uri:
    url: "{{ technitium_url }}/api/dhcp/scopes/removeReservedLease"
    method: POST
    body_format: form-urlencoded
    body:
      token: "{{ technitium_token }}"
      name: "{{ technitium_scope_name }}"
      hardwareAddress: "{{ chg.existing.hardwareAddress }}"
    validate_certs: false
  register: rm_one
  failed_when: (rm_one.json.status | default('error')) != 'ok'
  no_log: true
- name: "{{ chg.target.hostName }}: neue Reservierung anlegen"
  ansible.builtin.uri:
    url: "{{ technitium_url }}/api/dhcp/scopes/addReservedLease"
    method: POST
    body_format: form-urlencoded
    body:
      token: "{{ technitium_token }}"
      name: "{{ technitium_scope_name }}"
      hardwareAddress: "{{ chg.target.mac }}"
      ipAddress: "{{ chg.target.address }}"
      hostName: "{{ chg.target.hostName }}"
      comments: "{{ chg.target.comments }}"
    validate_certs: false
  register: add_one
  failed_when: false
  no_log: true
- name: "{{ chg.target.hostName }}: ROLLBACK - alte Reservierung wiederherstellen"
  ansible.builtin.uri:
    url: "{{ technitium_url }}/api/dhcp/scopes/addReservedLease"
    method: POST
    body_format: form-urlencoded
    body:
      token: "{{ technitium_token }}"
      name: "{{ technitium_scope_name }}"
      hardwareAddress: "{{ chg.existing.hardwareAddress }}"
      ipAddress: "{{ chg.existing.address }}"
      hostName: "{{ chg.existing.hostName | default('') }}"
      comments: "{{ chg.existing.comments | default('') }}"
    validate_certs: false
  register: rb_one
  failed_when: false
  no_log: true
  when: (add_one.json.status | default('error')) != 'ok'
- name: "{{ chg.target.hostName }}: Fehler melden"
  ansible.builtin.fail:
    msg: >-
      Add fehlgeschlagen ({{ add_one.json.errorMessage | default('?') }}).
      {{ 'Alte Reservierung wiederhergestellt.'
         if (rb_one.json.status | default('error')) == 'ok'
         else 'ACHTUNG: Rollback ebenfalls fehlgeschlagen ('
              ~ (rb_one.json.errorMessage | default('?'))
              ~ ') - Reservierung fehlt in Technitium!' }}
  when: (add_one.json.status | default('error')) != 'ok'
  ignore_errors: true

Danke Claude ich übernehme ab hier wieder…. Ich habe den Artikel noch von Mistral prüfen lassen. Das Fazit von Mistral:
Fazit
Die Lösung ist architektonisch richtig und für ein Homelab/Small-Business-Setup überdurchschnittlich reif. Kernstärken: klare Ownership-Trennung über Marker und Ignorier-Listen (behebt die eigentliche Ursache, nicht das Symptom), echtes Diff-before-write mit Ist-Zustands-Assert, Event + Reconcile-Fallback, Rollback pro Reservierung, fail-safe Defaults. Der Code ist lesbar, gut kommentiert und die Design-Entscheidungen sind einschließlich ihrer Grenzen dokumentiert – das sieht man selten.
Handlungsbedarf in dieser Reihenfolge:
Token aus der URL (Basic-Auth) und item.msg aus den Fehlermeldungen entfernen → schließt den realistischen Leak-Pfad.
Fails aggregation: Fehler am Laufende zählen, Lauf failen lassen, DHCP-Playbook um „Fehler auflisten“ ergänzen.
Semaphore-Parallelität auf 1 (serialisiert Events und Reconcile).
Zertifikats-Validierung aktivieren und Webhook auf HTTPS – dann ist die Transportebene dicht.
NetBox-Token auf read-only reduzieren.
Kleinigkeiten: item.zone auch beim Delete, max_deletes auch im Event-Modus, MAC-Änderungs-Event um prechange.hw_address ergänzen, PTR-Leichen einmalig prüfen.
Mit Punkten 1–3 (unter zwei Stunden Arbeit) ist das Setup meines Erachtens auch für ein kleines produktives Umfeld vertretbar – die verbleibende Abhängigkeit vom Beta-Status von netbox-plugin-dhcp (deine eigene, berechtigte Einschränkung) einmal ausgenommen.
Soll ich dir die konkreten Patch-Snippets für Punkt 1–2 (Basic-Auth-Umstellung + Fehler-Aggregations-Task) ausformulieren?

Mistral hat grade beim Coden in den letzten Monaten enorm aufgeholt. Der Punkt mit dem Token, das in den Logs landet, ist durchaus problematisch in einem produktiven Umfeld bei mir zu Hause in meinem Homelab bin ich da momentan jedoch ganz entspannt. Ich werde das mit dem Passwort bei Gelegenheit fixen und die Lösung in einem neuen Artikel posten.

RSS | ATOM


Kommentar hinzufügen

Die Felder Name und Kommentar sind Pflichtfelder.


BBCode Hilfe