Montag, Juli 27, 2026

Netbox - Technitium mit IPAM und DHCP Reservierungen aus Netbox synchronisieren

Nachtrag 30.07.26: Ich habe die letzten 2 Tage (bisher erfolglos) versucht bei dem NetBox LXC ein Update (über den community-scripts Mechanismus) durchzuführen. Man muss auf jeden Fall die beiden Plugins (Netbox-DHCP & Netbox-DNS) noch zusätzlich in die local_requirements.txt eintragen. Das alleine reicht aber noch nicht, weil das Updatescript scheinbar ein Backup zieht und den ganzen Stack komplett neu deployed. Dabei wird auch das manuell angelegten Verzeichnis /opt/netbox/logs nebst Inhalt entfernt. Man sollte das Netbox-LXC unbedingt regelmäßig backuppen, insbesondere wenn man versucht das Update irgendwie manuell durchzufühen. Ich gebe hier Bescheid sobald ich eine möglichst simple Lösung gefunden habe. Erledigt, siehe Folgeartikel. Schuld war ein falscher Pfad für die NetBox Log Datei.

Jetzt kommt endlich der Teil, bei dem die Einträge von NetBox an die DNS-Server synchronisiert werden sollen. So viel vorab… es funktioniert 😎 Das Blog erfüllt nebenbei einen weiteren praktischen Zweck. Wenn ich alleine nicht mehr weiterkomme und nicht zu viel Zeit mit Fehlersuche verplempern will, kann ich einfach eine KI meinen Blog lesen lassen und muss nicht erst mit mehreren Prompts umständlich erklären, was ich gerade tue. Die Zusatzinformationen und Fehler in den Artikeln sind dabei ebenfalls wichtig, damit die KI nicht die gleichen Fehler erneut macht. Dazu muss der Blog natürlich öffentlich einsehbar sein, sonst müsste ich immer alles erst per Copy & Paste übergeben. So spare ich enorm viel Zeit, und nur deswegen ging das NetBox Thema auch so schnell voran. Doch nun zurück zu Semaphore/NetBox/Technitium.

Den öffentlichen SSH-Schlüssel von Semaphore habe ich bereits in dem LXC des Technitium-Servers in meinem Heimnetz hinterlegt und die Verbindung über die Shell erfolgreich getestet. Im letzten Artikel habe ich ein Inventory-Playbook eingerichtet, mit dem alle in NetBox eingetragenen Geräte/virtuelle Instanzen mit SSH der Reihe nach durchprobiert werden. Mit dem Script kann ich zukünftig immer prüfen, welche Hosts den öffentlichen Schlüssel haben und eine Connection zustande kommt. Das andere Standort-Subnetz ist für das Semaphore-LXC aktuell noch nicht erreichbar. Das hole ich später mit dem Tailscale-Tunnel-Trick nach, der bereits für diesen Zweck vorbereitet ist. Ich habe mit Claude zwei Playbooks erstellt, die per cron (5 Minuten zeitversetzt) alle 15 Minuten aufgerufen werden. Das eine Playbook push-dhcp-standort-a sendet IP-Reservierungen, die im NetBox-DHCP-Plugin hinterlegt wurden, an Technitium. Bestehende Reservierungen werden dabei überschrieben. Das zweite Playbook push-dns-standort-a tut das Gleiche mit den DNS-Einträgen. Bei Technitium muss beim Punkt DHCP –> Scope das Updateverhalten für DNS-Einträge wie auf dem Screenshot eingestellt werden.

Bildschirmfoto_20260726_232934.png

Das wird zwar im Text unten erwähnt, aber die Einstellungen sind leicht zu übersehen (waren sie zumindest für mich). Ich habe Claude die nachfolgende Anleitung schreiben lassen. Er kann das besser als ich, und die Entwicklung der Playbooks war diesmal besonders zäh und mit vielen Sackgassen verbunden. Ich selbst hätte den Code jedenfalls nicht so elegant hinbekommen. Er ist sehr gut dokumentiert und nachvollziehbar. Ich werde das neue System NetBox/Semaphore/Technitium nun mal ein paar Wochen zu Hause laufen lassen und in der Zwischenzeit mit der Dokumentation weitermachen. Für alle, die das nachbauen wollen… es lohnt sich!

NetBox ist bei mir schon länger die Quelle der Wahrheit für IPAM und DNS-Zonen (via netbox-plugin-dns) sowie für DHCP-Reservierungen (via netbox-plugin-dhcp). Was bisher gefehlt hat: der tatsächliche automatische Weg von NetBox zu Technitium. Änderungen in NetBox mussten von Hand in Technitium nachgezogen werden - genau das Gegenteil von “Single Source of Truth”.

Ziel dieses Artikels: zwei Ansible-Playbooks, orchestriert über Semaphore, die

- alle A-Records aus netbox-plugin-dns nach Technitium pushen (inklusive automatisch generiertem PTR-Eintrag),
- alle Host Reservations aus netbox-plugin-dhcp als DHCP-Reservierungen in Technitium anlegen bzw. aktualisieren.

Beide Scripts sind bewusst pro Standort getrennt (bei mir zwei Standorte, hier anonymisiert als “Standort A” und “Standort B” bezeichnet), weil jeder Standort seinen eigenen Technitium-Server mit eigenem DHCP-Scope und eigener DNS-Zone hat.

Architektur-Entscheidung: Zuordnung über VRF, nicht über DNS-Views

Ursprünglich dachte ich, ich könnte die Standort-Zuordnung über getrennte NetBox-DNS-Views lösen. Tatsächlich existiert bei mir aber nur eine einzige View - die Trennung der beiden Standort-Netze läuft stattdessen über zwei separate VRFs. Die Playbooks ermitteln daher die Zielmenge an Records/Reservierungen nicht über die View, sondern über IP-in-Prefix-Matching gegen die jeweilige Standort-VRF:

1. Alle Prefixe der Ziel-VRF aus NetBox holen
2. Für jeden Record/jede Reservierung prüfen, ob die zugehörige IP in einem dieser Prefixe liegt
3. Nur die Treffer werden an den jeweiligen Technitium-Server gepusht

Kleine Falle dabei: Der NetBox-API-Filter “vrf” für Prefixe ist ein reiner ID-Filter, kein Namensfilter - der Name muss vorher über einen separaten, ungefilterten Aufruf aller VRFs lokal herausgesucht werden (zusätzlich erschwert dadurch, dass der VRF-Name selbst ein Leerzeichen enthält, was beim serverseitigen Filtern Probleme macht).

Script 1: DNS-Push

Pusht alle A-Records der Ziel-VRF nach Technitium, mit automatischer PTR-Generierung. Bewusst ohne AAAA (aktuell kein IPv6 in NetBox erfasst) und ohne CNAME/MX/TXT (nur Adress-Records).

---
# push-dns-standort-a.yml
#
# Standortspezifisches Pendant: push-dns-standort-b.yml
#
# Pusht A-Records aus NetBox (netbox-plugin-dns) nach Technitium.
# Kein AAAA, da aktuell kein IPv6 in NetBox erfasst wird (spätere
# Nachrüstung möglich, dann selectattr-Filter unten erweitern).
# PTR wird nicht separat aus NetBox gepusht, sondern von Technitium
# selbst aus dem A-Record erzeugt (ptr=true, createPtrZone=true).
#
# Da nur EINE NetBox-DNS-View existiert, erfolgt die Standort-Zuordnung
# nicht über Views, sondern genau wie beim DHCP-Push (push-dhcp.yml)
# über IP-in-Prefix-Matching gegen die jeweilige Standort-VRF.
#
# Voraussetzungen:
#   - netbox.netbox.nb_inventory / nb_lookup Plugin aktiviert (ansible.cfg)
#   - Ansible-SSH-Key auf dem Ziel-Technitium-Host hinterlegt
#   - NETBOX_TOKEN und TECHNITIUM_TOKEN als Umgebungsvariablen in
#     Semaphore hinterlegt (Environment -> nicht im Repo!)
#
# Aufruf (Beispiel, nur gegen dns-standort-a testen):
#   ansible-playbook -i inventory/netbox.yml push-dns-standort-a.yml --limit dns-standort-a
- name: NetBox DNS Records nach Technitium pushen
  hosts: dns-standort-a           # Hostname exakt wie im NetBox-Inventory
  gather_facts: false
  vars:
    netbox_api: "https://192.168.2.10"
    netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
    # Technitium leitet HTTP (5380) per 307 auf HTTPS (53443) um.
    # Direkt HTTPS verwenden, sonst folgt der uri-Task dem Redirect
    # ggf. nicht zuverlässig. Zertifikat ist selbstsigniert -> validate_certs: false.
    technitium_url: "https://{{ ansible_host }}:53443"
    technitium_token: "{{ lookup('env', 'TECHNITIUM_TOKEN') }}"
    # Welche VRF ist diesem Host zugeordnet? Analog zur Scope-Zuordnung
    # in push-dhcp.yml. Bei Bedarf pro Host per host_vars setzen statt hier.
    target_vrf_name: "VRF Standort A"
    # Bestätigter Zonenname aus den Technitium-Logs (Saved zone file for
    # domain: standort-a.intern). Dient hier nur als Sanity-Check unten,
    # der eigentliche zone-Parameter im Push kommt weiterhin dynamisch
    # aus item.zone.name.
    expected_zone_name: "standort-a.intern"
    overwrite_existing: true
    # dry_run: true -> nur anzeigen, was gepusht WÜRDE, kein einziger
    # Request geht an Technitium. Für den ersten Test unbedingt so lassen:
    #   ansible-playbook ... push-dns.yml -e dry_run=true
    dry_run: true
  tasks:
    - name: Alle VRFs aus NetBox holen (ungefiltert, wegen Leerzeichen im Namen)
      ansible.builtin.set_fact:
        all_vrfs: >-
          {{ query('netbox.netbox.nb_lookup', 'vrfs',
                    api_endpoint=netbox_api,
                    token=netbox_token,
                    validate_certs=false)
             | map(attribute='value')
             | list }}
    - name: Ziel-VRF lokal per Namen herausfiltern (kein Server-Filter wegen Leerzeichen)
      ansible.builtin.set_fact:
        target_vrf: "{{ all_vrfs | selectattr('name', 'equalto', target_vrf_name) | first }}"
    - name: Prefixe der Ziel-VRF aus NetBox holen (Filter per vrf_id, nicht per Name)
      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: Alle DNS-Records aus netbox-plugin-dns holen (Status active)
      ansible.builtin.set_fact:
        all_dns_records: >-
          {{ query('netbox.netbox.nb_lookup', 'records',
                    api_endpoint=netbox_api,
                    token=netbox_token,
                    plugin='netbox-dns',
                    api_filter='status=active',
                    validate_certs=false)
             | map(attribute='value')
             | list }}
    - name: Auf A-Records reduzieren
      # Nur A, kein AAAA: IPv6 wird aktuell nicht in NetBox erfasst.
      # PTR wird nicht separat aus NetBox gepusht, sondern von Technitium
      # automatisch aus dem A-Record erzeugt (siehe ptr/createPtrZone unten).
      # CNAME/MX/TXT bleiben bewusst außen vor.
      ansible.builtin.set_fact:
        address_records: "{{ all_dns_records | selectattr('type', 'equalto', 'A') | list }}"
    - name: Records ermitteln, deren IP in einem Prefix der Ziel-VRF liegt
      ansible.builtin.set_fact:
        site_records: "{{ site_records | default([]) + [item] }}"
      loop: "{{ address_records }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      vars:
        record_ip: "{{ item.value | ansible.utils.ipaddr('address') }}"
      when: record_ip is ansible.utils.in_one_network target_prefixes
    - name: Anzahl der für diesen Host relevanten Records
      ansible.builtin.debug:
        msg: "{{ site_records | default([]) | length }} Records werden nach {{ inventory_hostname }} gepusht"
    - name: "DRY RUN - geplante Records im Detail anzeigen"
      ansible.builtin.debug:
        msg: "{{ item.fqdn | default(item.name) }} -> {{ item.value }} (Zone: {{ item.zone.name }}, TTL: {{ item.ttl | default(3600, true) }})"
      loop: "{{ site_records | default([]) }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      when: dry_run | bool
    - name: Warnen, falls Records eine abweichende Zone haben
      ansible.builtin.debug:
        msg: >-
          WARNUNG: {{ item.fqdn | default(item.name) }} hat Zone
          '{{ item.zone.name }}', erwartet wurde '{{ expected_zone_name }}'.
          Bitte in NetBox DNS prüfen, sonst schlägt der Push mit
          "zone not found" fehl.
      loop: "{{ site_records | default([]) }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      when: item.zone.name != expected_zone_name
    - name: Records auf Technitium anlegen bzw. aktualisieren
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/add"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          domain: "{{ item.fqdn | default(item.name) }}"
          zone: "{{ item.zone.name }}"
          type: "{{ item.type }}"
          ttl: "{{ item.ttl | default(3600, true) }}"
          overwrite: "{{ overwrite_existing | lower }}"
          ipAddress: "{{ item.value }}"
          ptr: "true"
          createPtrZone: "true"
        status_code: [200]
        validate_certs: false
      loop: "{{ site_records | default([]) }}"
      loop_control:
        label: "{{ item.fqdn | default(item.name) }}"
      no_log: true          # Token nicht in Semaphore-Logs schreiben
      register: push_result
      failed_when: >-
        push_result.json is defined and
        push_result.json.status is defined and
        push_result.json.status != 'ok'
      # Einzelne Fehler nicht den ganzen Lauf abbrechen lassen,
      # analog zur "graceful handling of per-reservation failures"
      # aus push-dhcp.yml:
      ignore_errors: true
      when: not (dry_run | bool)
    - name: Fehlgeschlagene Records auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.fqdn | default(item.item.name) }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ push_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.fqdn | default(item.item.name) }}"
      when:
        - not (dry_run | bool)
        - item.failed | default(false)

Stolpersteine beim DNS-Push

- Technitium leitet HTTP-Zugriffe (Port 5380) per 307-Redirect auf HTTPS (Port 53443) um. curl folgt Redirects standardmäßig nicht - ohne “-L” bzw. ohne direkten HTTPS-Aufruf bekommt man eine leere, aber fehlerfreie Antwort. Hat mich eine Weile gekostet, bis der Verbindungsaufbau per “-v” den 307 sichtbar gemacht hat.
- Der Delete-Endpoint für A-Records verlangt zwingend den Parameter “ipAddress” zusätzlich zu Domain/Zone/Typ - ohne den kommt “Parameter ‘ipAddress’ missing”.
- “ptr=true” und “createPtrZone=true” beim Anlegen eines A-Records erzeugen den passenden PTR-Eintrag automatisch mit - das taucht in der API-Antwort selbst aber nicht auf, erst ein separater Blick in die Reverse-Zone bestätigt es.
- Wichtig für den produktiven Betrieb: Technitiums DHCP-Server kann selbst automatisch DNS-Einträge für Leases pflegen (Option “Enable DNS Updates”). Die zusätzliche Option “Enable DNS Overwrite For Dynamic Lease” sollte deaktiviert bleiben - sonst könnte ein rein dynamischer Client theoretisch einen von NetBox gepushten, statischen A-Record überschreiben. Reservierte Leases respektieren ohnehin bestehende Records und würden im Konfliktfall nur eine Fehlermeldung loggen, statt etwas zu überschreiben.

Script 2: DHCP-Push

Pusht alle Host Reservations der Ziel-VRF als Technitium-DHCP-Reservierungen, ebenfalls pro Standort getrennt.

---
# push-dhcp-standort-a.yml
#
# ACHTUNG: Discovery-Phase / Vorstufe.
# Dieses Playbook schreibt noch NICHTS in Technitium. Es liest nur:
#   1. Die rohen Host-Reservation-Objekte aus netbox-plugin-dhcp
#   2. Die aktuelle Scope-Konfiguration (inkl. reservedLeases) aus Technitium
# ...und gibt beides im Klartext aus, damit wir die echten Feldnamen sehen,
# bevor wir die Add/Update-Logik bauen. Grund: Für netbox-plugin-dhcp und
# Technitiums DHCP-Reservation-API gibt es keine verlässliche Feld-Doku
# (beim DNS-Push haben abweichende Feldnamen/Verhalten mehrfach zu Fehlern
# geführt - das vermeiden wir diesmal, indem wir zuerst nur beobachten).
#
# Aufruf:
#   ansible-playbook -i inventory/netbox.yml push-dhcp-standort-a.yml --limit dns-standort-a
- name: DHCP-Reservierungen NetBox <-> Technitium - Discovery
  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"
    # Name des DHCP-Scopes in Technitium fuer diesen Standort.
    # Aus den bisherigen Logs bekannt: Scope heisst "StandortA".
    technitium_scope_name: "StandortA"
    # dry_run: true -> nur anzeigen was gepusht wuerde, kein Schreibzugriff.
    dry_run: true
  tasks:
    - name: Alle VRFs aus NetBox holen (ungefiltert, wegen Leerzeichen im Namen)
      ansible.builtin.set_fact:
        all_vrfs: >-
          {{ query('netbox.netbox.nb_lookup', 'vrfs',
                    api_endpoint=netbox_api,
                    token=netbox_token,
                    validate_certs=false)
             | map(attribute='value')
             | list }}
    - name: Ziel-VRF lokal per Namen herausfiltern
      ansible.builtin.set_fact:
        target_vrf: "{{ all_vrfs | selectattr('name', 'equalto', target_vrf_name) | first }}"
    - name: Prefixe der Ziel-VRF aus NetBox holen (Filter per vrf_id)
      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: "DISCOVERY - Alle Host Reservations aus netbox-plugin-dhcp holen (ungefiltert)"
      ansible.builtin.set_fact:
        all_dhcp_reservations: >-
          {{ query('netbox.netbox.nb_lookup', 'hostreservations',
                    api_endpoint=netbox_api,
                    token=netbox_token,
                    plugin='netbox-dhcp',
                    validate_certs=false)
             | map(attribute='value')
             | list }}
      # Endpoint-Name per curl gegen /api/plugins/netbox-dhcp/ verifiziert:
      # "hostreservations" (ohne Bindestrich).
    - name: "DISCOVERY - Anzahl gefundener Reservations gesamt"
      ansible.builtin.debug:
        msg: "{{ all_dhcp_reservations | length }} Host Reservations gesamt in NetBox gefunden"
    - name: "DISCOVERY - Erste 3 Reservations, nur relevante Felder"
      ansible.builtin.debug:
        msg: >-
          id={{ item.id }}
          hostname={{ item.hostname }}
          ipv4={{ item.ipv4_address.address | default('KEINE IP VERKNUEPFT') }}
          hw_address_id={{ item.hw_address | default('NULL') }}
          subnet_id={{ item.subnet | default('NULL') }}
      loop: "{{ all_dhcp_reservations[:3] }}"
      loop_control:
        label: "{{ item.hostname }}"
    - name: "MAC-Address-Objekte holen (alle, limit=0 - wird fuer die echte Lookup-Tabelle gebraucht, nicht nur Vorschau)"
      ansible.builtin.uri:
        url: "{{ netbox_api }}/api/dcim/mac-addresses/?limit=0"
        method: GET
        headers:
          Authorization: "Token {{ netbox_token }}"
        validate_certs: false
        status_code: [200]
      register: mac_addresses_raw
      ignore_errors: true
    - name: "DISCOVERY - Rohes Ergebnis des MAC-Address-Calls anzeigen (Status/Fehler)"
      ansible.builtin.debug:
        msg: "status={{ mac_addresses_raw.status | default('KEIN STATUS') }} failed={{ mac_addresses_raw.failed | default('?') }} msg={{ mac_addresses_raw.msg | default('kein msg-Feld') }}"
    - name: "DISCOVERY - Erste 5 MAC-Address-Objekte anzeigen"
      ansible.builtin.debug:
        msg: "id={{ item.id }} mac_address={{ item.mac_address | default('?') }}"
      loop: "{{ mac_addresses_raw.json.results }}"
      loop_control:
        label: "{{ item.id }}"
      when: mac_addresses_raw.json is defined and mac_addresses_raw.json.results is defined
    - name: "DISCOVERY - Aktuelle Technitium-Scope-Konfiguration holen (inkl. reservedLeases)"
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/get"
        method: GET
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          name: "{{ technitium_scope_name }}"
        validate_certs: false
        status_code: [200]
      register: technitium_scope_raw
      no_log: true
    - name: "DISCOVERY - Technitium-Scope-Antwort im Rohformat anzeigen"
      ansible.builtin.debug:
        var: technitium_scope_raw.json
    # -----------------------------------------------------------------
    # Ab hier: eigentliche Push-Vorbereitung (noch kein Schreibzugriff)
    # -----------------------------------------------------------------
    - name: Ziel-Reservierungen aus NetBox filtern (IP innerhalb der Ziel-VRF-Prefixe)
      ansible.builtin.set_fact:
        site_reservations: "{{ site_reservations | default([]) + [item] }}"
      loop: "{{ all_dhcp_reservations }}"
      loop_control:
        label: "{{ item.hostname }}"
      vars:
        record_ip: "{{ item.ipv4_address.address | ansible.utils.ipaddr('address') }}"
      when:
        - item.ipv4_address is defined
        - item.ipv4_address.address is defined
        - record_ip is ansible.utils.in_one_network target_prefixes
    - name: MAC-Adressen-Lookup-Tabelle bauen (id -> mac_address)
      ansible.builtin.set_fact:
        mac_lookup: "{{ dict(mac_addresses_raw.json.results | map(attribute='id') | map('string') | zip(mac_addresses_raw.json.results | map(attribute='mac_address'))) }}"
    - name: "Ziel-Reservierungen im Technitium-Format aufbauen (MAC-Format Doppelpunkt zu Bindestrich)"
      ansible.builtin.set_fact:
        target_reserved_leases: "{{ target_reserved_leases | default([]) + [{
          'hostName': item.hostname,
          'address': item.ipv4_address.address | ansible.utils.ipaddr('address'),
          'hardwareAddress': mac_lookup[item.hw_address | string] | default('UNBEKANNT') | replace(':', '-') | upper,
          'comments': item.comments | default(None)
        }] }}"
      loop: "{{ site_reservations | default([]) }}"
      loop_control:
        label: "{{ item.hostname }}"
      when: item.hw_address is defined and (item.hw_address | string) in mac_lookup
    - name: Reservierungen ohne aufgeloeste MAC-Adresse warnen (werden NICHT gepusht)
      ansible.builtin.debug:
        msg: "WARNUNG: {{ item.hostname }} hat hw_address_id={{ item.hw_address | default('NULL') }}, keine passende MAC in mac_lookup gefunden - wird uebersprungen"
      loop: "{{ site_reservations | default([]) }}"
      loop_control:
        label: "{{ item.hostname }}"
      when: not (item.hw_address is defined and (item.hw_address | string) in mac_lookup)
    - name: "Anzahl der fuer diesen Host aufbereiteten Reservierungen"
      ansible.builtin.debug:
        msg: "{{ target_reserved_leases | default([]) | length }} von {{ site_reservations | default([]) | length }} gefilterten NetBox-Reservierungen sind push-bereit"
    - name: "DRY RUN - Neue vollstaendige reservedLeases-Liste anzeigen (das wuerde an /api/dhcp/scopes/set gesendet)"
      ansible.builtin.debug:
        var: target_reserved_leases
      when: dry_run | bool
    - name: "Bestehende Technitium-Reservierungen nach hardwareAddress indexieren"
      ansible.builtin.set_fact:
        existing_leases_by_mac: "{{ dict(technitium_scope_raw.json.response.reservedLeases | map(attribute='hardwareAddress') | zip(technitium_scope_raw.json.response.reservedLeases)) }}"
    - name: "Aktionsplan bauen: pro NetBox-Reservierung neu/aendern/unveraendert bestimmen"
      ansible.builtin.set_fact:
        lease_actions: "{{ lease_actions | default([]) + [{
          'mac': item.hardwareAddress,
          'target': item,
          'existing': existing_leases_by_mac.get(item.hardwareAddress),
          'action': ('unveraendert'
                     if (existing_leases_by_mac.get(item.hardwareAddress) is not none and
                         existing_leases_by_mac[item.hardwareAddress].address == item.address and
                         existing_leases_by_mac[item.hardwareAddress].hostName == item.hostName)
                     else ('aendern' if existing_leases_by_mac.get(item.hardwareAddress) is not none else 'neu'))
        }] }}"
      loop: "{{ target_reserved_leases | default([]) }}"
      loop_control:
        label: "{{ item.hostName }}"
        loop_var: item
    - name: "Zusammenfassung des Aktionsplans"
      ansible.builtin.debug:
        msg: >-
          {{ lease_actions | selectattr('action', 'equalto', 'neu') | list | length }} neu,
          {{ lease_actions | selectattr('action', 'equalto', 'aendern') | list | length }} aendern,
          {{ lease_actions | selectattr('action', 'equalto', 'unveraendert') | list | length }} unveraendert
          (von {{ lease_actions | length }} NetBox-Reservierungen fuer diesen Host)
    - name: "DRY RUN - Details je geplanter Aktion anzeigen"
      ansible.builtin.debug:
        msg: >-
          [{{ item.action | upper }}] {{ item.target.hostName }}
          ({{ item.mac }}) -> {{ item.target.address }}
          {{ '(vorher: ' ~ item.existing.hostName ~ ' -> ' ~ item.existing.address ~ ')' if item.action == 'aendern' else '' }}
      loop: "{{ lease_actions | rejectattr('action', 'equalto', 'unveraendert') | list }}"
      loop_control:
        label: "{{ item.target.hostName }}"
      when: dry_run | bool
    - name: "Bestehende Reservierung entfernen, falls Aenderung ansteht (Remove-Schritt vor Add)"
      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.mac }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ lease_actions | selectattr('action', 'equalto', 'aendern') | list }}"
      loop_control:
        label: "{{ item.target.hostName }}"
      no_log: true
      register: remove_result
      when: not (dry_run | bool)
    - name: "Reservierung anlegen (neu ODER nach vorherigem Remove bei Aenderung)"
      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.target.address }}"
          hostName: "{{ item.target.hostName }}"
          comments: "{{ item.target.comments | default('') }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ lease_actions | rejectattr('action', 'equalto', 'unveraendert') | list }}"
      loop_control:
        label: "{{ item.target.hostName }}"
      no_log: true
      register: add_result
      ignore_errors: true
      when: not (dry_run | bool)
    - name: "Fehlgeschlagene Add-Aktionen auflisten"
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.target.hostName }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ add_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.target.hostName }}"
      when:
        - not (dry_run | bool)
        - item.failed | default(false)

Stolpersteine beim DHCP-Push

Hier gab es deutlich mehr zu entdecken als beim DNS-Teil, weil weder für netbox-plugin-dhcp noch für Technitiums DHCP-Reservation-API eine verlässliche Feld-Dokumentation existiert:

- Der Plugin-API-Endpoint für Host Reservations heißt “hostreservations” (ohne Bindestrich), nicht “host-reservations”, wie der Modellname vermuten lässt.
- Das Feld “hw_address” in einer Host Reservation ist nur eine ID-Referenz - keine MAC-Adresse im Klartext. Die eigentliche MAC-Adresse liegt seit neueren NetBox-Versionen als eigenständiges Core-Objekt unter “/api/dcim/mac-addresses/” und muss separat aufgelöst werden. Das “nb_lookup”-Lookup-Plugin kennt diesen (noch recht neuen) Objekttyp nicht, ein direkter REST-Aufruf per “uri”-Modul war nötig. Falle Nummer zwei hier: ohne “limit=0” liefert die API standardmäßig nur eine kleine Seite zurück - beim ersten Testlauf wurden dadurch nur 3 von 57 MAC-Adressen aufgelöst.
- MAC-Adressen-Format unterscheidet sich zwischen den Systemen: NetBox liefert Doppelpunkt-getrennt (z. B. “AA:BB:CC:DD:EE:FF”), Technitium erwartet Bindestrich-getrennt und in Großbuchstaben (”AA-BB-CC-DD-EE-FF”). Ein einfacher “replace”-Filter im Playbook erledigt die Umwandlung.
- Für das Schreiben gibt es zwei völlig unterschiedliche Technitium-Endpoints, mit unterschiedlichem Risiko: “/api/dhcp/scopes/set” erwartet (vermutlich) die komplette Scope-Konfiguration inklusive der gesamten reservierten Lease-Liste auf einmal - ein Fehler dort hätte den kompletten Scope durcheinanderbringen können, und ein Community-Issue zu genau diesem Endpoint warnte ausdrücklich vor Datenverlust. Stattdessen gibt es die viel sichereren Einzel-Endpoints “/api/dhcp/scopes/addReservedLease” und “/api/dhcp/scopes/removeReservedLease”, die jeweils nur eine einzelne Reservierung anlegen bzw. entfernen.
- “addReservedLease” ist ein reines Add, kein Upsert: Bei bereits existierender MAC-Adresse kommt ein klarer Fehler (”A reserved lease with same hardware address already exists”), es wird nichts stillschweigend überschrieben. Für Änderungen an bestehenden Reservierungen braucht es daher das Muster Remove-dann-Add.

Das Playbook baut daraus einen Aktionsplan (neu / ändern / unverändert) und wendet für “ändern” automatisch Remove+Add an, für “neu” nur Add. Unveränderte Reservierungen werden komplett übersprungen - das macht wiederholte, geplante Läufe unkritisch.

Testablauf: erst lesen, dann vorsichtig schreiben

Für beide Scripts galt dieselbe Reihenfolge:

1. Reine Lese-Discovery gegen die echte NetBox-/Technitium-API, um tatsächliche Feldnamen und Antwortformate zu sehen, statt aus der (dünnen) Dokumentation zu raten.
2. Eine eingebaute “dry_run”-Variable im Playbook selbst, die den kompletten Schreib-Task überspringt und stattdessen nur anzeigt, was passieren würde.
3. Erst nach mehrfach sauberem Dry-Run-Durchlauf der scharfe Test - anfangs mit einem einzelnen harmlosen Testeintrag direkt per curl gegen die Technitium-API, um API-Format und Rechte ganz isoliert zu verifizieren, bevor überhaupt ein Ansible-Task etwas schreibt.

Eine Verwechslungsfalle in Semaphore selbst: Die eingebaute “Dry Run”-Checkbox beim Task-Start setzt Ansibles eigenen Check-Mode, der pauschal jedes Modul blockiert, das keinen Check-Mode unterstützt - darunter das “uri”-Modul, mit dem hier praktisch alles läuft. Das ist eine komplett andere Ebene als die selbstgebaute “dry_run”-Variable im Playbook. Für einen wirklich scharfen Lauf müssen beide Schalter passen: die Semaphore-Checkbox deaktiviert UND die Playbook-Variable auf “false”.

Semaphore-Einrichtung

Ein paar Dinge, die in der aktuellen Semaphore-Version anders heißen oder liegen, als man erwarten würde:

- Umgebungsvariablen (NETBOX_TOKEN, TECHNITIUM_TOKEN) werden nicht unter “Environment” gepflegt, sondern unter “Variable Groups” - dort im Bereich “Environment Variables” als Name/Value-Paare, getrennt von den unverschlüsselten “Extra Variables”.
- Für den “dry_run”-Schalter lohnt sich eine Survey-Variable vom Typ “Enum” mit den beiden Werten “true”/”false”, Default “true”, und als verpflichtend markiert - so muss man sich bei jedem Lauf aktiv entscheiden, statt aus Gewohnheit durchzuklicken.
- Zeitgesteuerte, automatische Ausführung läuft über die eingebauten “Schedules” pro Task-Template (Cron-Syntax). Sinnvoll leicht versetzt für DNS- und DHCP-Push, damit sich die Läufe nicht überschneiden.

Neue Geräte in NetBox brauchen dabei keinen separaten Zwischenschritt - beide Playbooks fragen NetBox bei jedem Lauf live per API ab, es gibt keine zwischengespeicherte Geräteliste, die erst aktualisiert werden müsste.

Offene Punkte

- DNS-Pruning: Wird ein Gerät in NetBox komplett gelöscht (nicht nur die IP geändert), bleibt der zugehörige A-Record inklusive PTR aktuell noch als Karteileiche in Technitium stehen. Anders als beim DHCP-Teil (wo Add/Remove über die MAC-Adresse sauber greift) braucht das noch ein eigenes Aufräum-Script, das den Stand aus NetBox mit dem tatsächlichen Zoneninhalt abgleicht.

Update: Dieser Artikel wird ergänzt, sobald das DNS-Pruning-Script produktiv läuft.

RSS | ATOM


Kommentar hinzufügen

Die Felder Name und Kommentar sind Pflichtfelder.


BBCode Hilfe