Montag, September 14, 2026

Netbox - Anpassung der Semaphore Playbooks nach Update von Netbox auf 4.7.0

Netbox/Semaphore usw. läuft bisher nur bei mir zuhause und wie sich aktuell zeigt, aus gutem Grund. Das letzte Netbox Update, genauer gesagt ein Update des DHCP Plugins hat beim Sprung auf Netbox 4.7.0 diverse Änderungen mit sich gebracht, die eines meiner Playbooks leider in die Knie gezwungen haben. Das DHCP Plugin von Peter Eckel ist weiterhin beta, weswegen ich das Setup mit Netbox/Semaphore auch weiterhin (noch) nicht in einem produktiven Umfeld einsetzen werde. Ich vermute die Änderungen vom 04.08.26 im Netbox DHCP Plugin sind der konkrete Grund.

Die Fehlermeldung bei Semaphore lautet (exemplarisch für das dashy LXC):

7:35:10 PM
ok: [dns-redacted] => (item=dashy) => {
7:35:10 PM
    "msg": "WARNUNG: dashy hat hw_address_id={'id': 7, 'url': 'https://192.168.2.10/api/dcim/mac-addresses/7/', 'display': 'BC:24:11:xx:xx:xx', 'mac_address': 'BC:24:11:xx:xx:xx', 'description': ''}, keine passende MAC in mac_lookup gefunden - wird uebersprungen"
7:35:10 PM
}

ich habe jedenfalls das dhcp-push skript an die neuen Gegebenheiten angepasst. –>

---
# push-dhcp-standort-a.yml
#
# Discovery-Phase: schreibt noch NICHTS in Technitium, liest nur
# Host-Reservations aus netbox-plugin-dhcp + Scope-Config aus Technitium.
#
# 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"
    technitium_scope_name: "StandortA"
    dry_run: true  # true = nur anzeigen, kein Schreibzugriff
  tasks:
    - name: Alle VRFs 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 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: Alle Host Reservations holen
      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 }}
    - name: Anzahl gefundener Reservations
      ansible.builtin.debug:
        msg: "{{ all_dhcp_reservations | length }} Host Reservations gesamt gefunden"
    # Fallback-Lookup fuer den Fall, dass hw_address wieder nur eine ID liefert
    # (Plugin < 0.1.10). Seit 0.1.10 ("Fixed nested serializers") liefert
    # hw_address bereits ein verschachteltes Objekt inkl. mac_address.
    - name: MAC-Address-Objekte holen (limit=0, fuer Lookup-Fallback)
      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: MAC-Adressen-Lookup-Tabelle bauen (id -> mac_address, Fallback)
      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'))) }}"
      when: mac_addresses_raw.json is defined and mac_addresses_raw.json.results is defined
    - name: 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
    # -----------------------------------------------------------------
    # Push-Vorbereitung (noch kein Schreibzugriff)
    # -----------------------------------------------------------------
    - name: Ziel-Reservierungen filtern (IP innerhalb 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
    # hw_address ist seit Plugin 0.1.10 ein verschachteltes Objekt mit
    # mac_address inline; nutze das direkt, mit Fallback auf mac_lookup.
    - name: Ziel-Reservierungen im Technitium-Format aufbauen
      ansible.builtin.set_fact:
        target_reserved_leases: "{{ target_reserved_leases | default([]) + [{
          'hostName': item.hostname,
          'address': item.ipv4_address.address | ansible.utils.ipaddr('address'),
          'hardwareAddress': (
            (item.hw_address.mac_address if (item.hw_address is mapping and item.hw_address.mac_address is defined)
             else 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 is mapping and item.hw_address.mac_address is defined)
         or (item.hw_address is not mapping and (item.hw_address | string) in mac_lookup))
    - name: Reservierungen ohne aufgeloeste MAC warnen (werden NICHT gepusht)
      ansible.builtin.debug:
        msg: "WARNUNG: {{ item.hostname }} hat hw_address={{ item.hw_address | default('NULL') }}, keine MAC aufloesbar - wird uebersprungen"
      loop: "{{ site_reservations | default([]) }}"
      loop_control:
        label: "{{ item.hostname }}"
      when: >-
        not (item.hw_address is defined and
             ((item.hw_address is mapping and item.hw_address.mac_address is defined)
              or (item.hw_address is not mapping and (item.hw_address | string) in mac_lookup)))
    - name: Anzahl push-bereiter Reservierungen
      ansible.builtin.debug:
        msg: "{{ target_reserved_leases | default([]) | length }} von {{ site_reservations | default([]) | length }} push-bereit"
    - name: "DRY RUN - geplante reservedLeases-Liste anzeigen"
      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 (neu / aendern / unveraendert)
      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
    - name: "DRY RUN - Details je geplanter Aktion"
      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 vor Aenderung (Remove-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 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)

und kommentartechnisch ein bisschen gestrafft. Ich bin wegen der Fragilität meines aktuellen Setups etwas unglücklich. Ich werde die nächsten Tage ein paar alternative Ansätze bei dem Skript testen, die hoffentlich etwas robuster sind. Bei Netbox sind beim letzten Update diverse neue Felder hinzugekommen, das muss ich mir aber zunächst in Ruhe ansehen.

Nachtrag: Dafür wollte ich keinen eigenen neuen Blogpost erstellen, aber ich habe grade gesehen, dass es in Semaphore UI scheinbar als neues Feature sogenannte Workflows gibt –>

Bildschirmfoto_20260914_215305.png

Das sieht einem Blatt bei NodeRed verdächtig ähnlich. Damit ließen sich anschaulich verschiedene Playbooks miteinander verknüpfen bzw. aneinanderreihen, ggf sogar verschiedene Wege je nach Ausgabe wählen. Das sieht jedenfalls äußerst spannend aus und könnte meinen bisherigen Ansatz ganz erheblich vereinfachen. Man könnte die einzelnen Arbeitsschritte in einzelne Skripte modularisieren und durch Aneinanderreihung miteinander verknüpfen. Das wäre wesentlich einfacher zu warten, als ein monolithisches Skript das mit der Zeit immer größer und fehleranfälliger wird.

Samstag, September 5, 2026

Netbox - Semaphore Housekeeping, nachdem LXC vollgelaufen ist

Semaphore konnte keine Tasks mehr ausführen.

11:00:08 AM
fatal: [localhost]: FAILED! => {"msg": "failed to transfer file to /root/.ansible/tmp/ansible-tmp-1788598808.745682-600055-252320871827258/AnsiballZ_uri.py: [Errno 28] No space left on device: b'/opt/semaphore/tmp/project_2/repository_1_template_7_home/.ansible/tmp/ansible-local-599946yh9hvxgy/tmp9rlk1asu' -> b'/root/.ansible/tmp/ansible-tmp-1788598808.745682-600055-252320871827258/AnsiballZ_uri.py'"}

Das LXC war schlicht vollgelaufen. Als Schuldigen habe ich /opt/semaphore/database.sqlite ausgemacht. Die Datenbank ist auf 1,2gb angewachsen. Jeder Durchlauf der Templates wird scheinbar in der Datenbank gespeichert. Notfallmäßig habe ich das Semaphore LXC zunächst um 4GB vergrößert. Um die Datenbank automatisch aufzuräumen, muss man die config.json von Semaphore etwas ergänzen.

{
  "sqlite": {
    "host": "/opt/semaphore/database.sqlite"
  },
  "dialect": "sqlite",
  "max_tasks_per_template": 30,
  "tmp_path": "/opt/semaphore/tmp",

die neue Zeile “max_tasks_per_template”: 30, regelt, das nur die letzten 30 Durchläufe aufbewahrt werden. Man muss den Dienst semaphore nach dem Ändern der Config neu starten

Nachtrag: Ich habe den Wert inzwischen auf 670 gesetzt, das entspricht bei meinen Logs derzeit in etwa 7 Tage. 30 ist viel zu wenig.

systemctl restart semaphore

Die GUI ist danach erst mal nicht mehr erreichbar. Via Proxmox Shell komme ich noch auf das LXC. Neben der database.sqlite ist eine neue Datei erschienen mit dem Namen database.sqlite-journal

Every 2.0s: ls -la                                                                                semaphore: Sat Sep  5 13:35:15 2026
total 1271488
drwxr-xr-x 4 root root       4096 Sep  5 13:15 .
drwxr-xr-x 3 root root       4096 Jul 14 00:46 ..
-rw------- 1 root root        411 Jul 25 12:59 ansible-key
-rw-r--r-- 1 root root         99 Jul 25 12:59 ansible-key.pub
-rw-r--r-- 1 root root        370 Sep  5 13:11 config.json
-rw-r--r-- 1 root root        338 Sep  5 13:06 config.json.bak
-rw-r--r-- 1 root root 1269415936 Sep  5 13:35 database.sqlite
-rw-r--r-- 1 root root   32554168 Sep  5 13:35 database.sqlite-journal
drwxr-xr-x 4 root root       4096 Aug 30 11:44 playbooks
drwxr-xr-x 3 root root       4096 Jul 24 21:21 tmp

Es müssen zahlreiche Einträge gelöscht werden und für ein eventuelles Recovery wird eine solche Sicherungsdatei angelegt. Sobald der Löschvorgang abgschlossen ist, verschwindet auch die database.sqlite-journal wieder. In meinem Fall hätte ich ohne Vergößerung des LXCs auch nichts löschen können, da die Recoverydatei ja auch Platz bekegt. Man muss also den o.g. Mechanismus direkt nach der Installation des Semaphore LXCs aktivieren, um das Semaphore LXC dauerhaft schlank zu halten. Der Löschprozess hat bei mir seeehr lange gedauert. Danach ist die database.sqlite immer noch 1,2gb groß, allerdings sind das nur leere Seiten. Endgültig los wird man die leeren Seiten erst durch einen Eingriff in sqlite. Dazu muss der o.g. Löschprozess zuerst abgeschlossen sein und der semaphore Dienst angehalten werden

systemctl stop semaphore
sqlite3 /opt/semaphore/database.sqlite "VACUUM;"
systemctl start semaphore

sqlite3 muss man evtl vorher noch über apt installieren.

Bildschirmfoto_20260906_085947.png

Wie man auf dem Screenshot sehen kann ist die Datei database.sqlite nun wesentlich kleiner als vorher.

Dienstag, September 1, 2026

Netbox - PVE Manipulation/Auslesen von Settings via Semaphore/Netbox

Manchmal möchte ich nach einem Reboot eines PVE Hosts nicht, dass die VMs und LXCs automatisch hochgefahren werden. Ich habe bisher vor so einem Reboot immer den Autostart Haken bei jedem einzelnen LXC und VM entfernen und später wieder setzen müssen.

Bildschirmfoto_20260902_131246.png

Das wollte ich unbedingt vereinfachen und gleichzeitig die jeweils aktuellen Settings der einzelnen VMs und LXCs nach Netbox synchronisieren. In diesem Fall dienen tatsächlich die jeweiligen PVE Hosts als “source of truth” für Netbox. Ich habe mir ausserdem das Leben etwas leichter gemacht, indem ich VSCodium (Opensource Alternative zu VSCode von M$) mit zwei praktischen Plugins nachgerüstet habe –> 1. Open Remote - SSH und 2. YAML. Open Remote - SSH macht entfernte Dateisysteme auf dem lokalen Rechner via ssh in VSCodium zugänglich und YAML hilft dabei keine Fehler z.B. bei den Leerzeichen in den Playbooks zu machen. Die Einrichtung erkläre ich hier nicht, das sollte jeder mit dem bereits vorhandenen Material selbst hinkriegen können. VSCodium kann nativ mit git commits umgehen, das macht das Anlegen und V.a. das Ändern von Playbooks wesentlich komfortabler.

An den PVE Hosts die ferngesteuert und abgefragt werden sollen, müssen noch API Token generiert werden. Bei mir zuhause sind das zwei PVE Hosts, jeweils standalone. Ich muss also zwei API Token generieren. Der Token wird nur einmal, direkt nach dem Erzeugen angezeigt, es empfiehlt sich daher den Token an einem sicheren Ort (Passwortmanager) zu speichern.

Bildschirmfoto_20260901_153712.png

Bei den Playbooks habe ich ein neues yml File angelegt mit folgendem Inhalt:

---
- name: NetBox VM-Config gegen PVE abgleichen (Standort_A)
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    netbox_api: "https://192.168.2.10"
    netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
    # ACHTUNG: Slug, steht bei Standort und ist nur in Bearbeitung sichtbnar
    target_site_slug: "standort-a"
        pve_nodes:
      - name: pve01
        api: "https://192.168.2.16:8006/api2/json"
        token_id: "{{ lookup('env', 'PVE01_TOKEN_ID') }}"
        token_secret: "{{ lookup('env', 'PVE01_TOKEN_SECRET') }}"
      - name: pve03
        api: "https://192.168.2.18:8006/api2/json"
        token_id: "{{ lookup('env', 'PVE03_TOKEN_ID') }}"
        token_secret: "{{ lookup('env', 'PVE03_TOKEN_SECRET') }}"
        dry_run: false
    start_on_boot_enabled_value: "on"
    start_on_boot_disabled_value: "off"
    start_on_boot_laststate_value: "last-state"
    resource_drift: []
    onboot_actions: []
    netbox_update_actions: []
  tasks:
    # -----------------------------------------------------------------
    # 1. NetBox-VMs der Ziel-Site holen und auf vmid pruefen
    # -----------------------------------------------------------------
    - name: NetBox-VMs der Ziel-Site holen
      ansible.builtin.set_fact:
        netbox_vms: >-
          {{ query('netbox.netbox.nb_lookup', 'virtual-machines',
                    api_endpoint=netbox_api,
                    token=netbox_token,
                    api_filter='site=' ~ target_site_slug,
                    validate_certs=false)
             | map(attribute='value')
             | list }}
    - name: Anzahl gefundener NetBox-VMs
      ansible.builtin.debug:
        msg: "{{ netbox_vms | length }} VMs in NetBox fuer Site '{{ target_site_slug }}' gefunden"
    - name: Nur VMs mit gepflegter vmid behalten
      ansible.builtin.set_fact:
        netbox_vms_ready: >-
          {{ netbox_vms
             | selectattr('custom_fields.vmid', 'defined')
             | selectattr('custom_fields.vmid', 'ne', None)
             | list }}
        netbox_vms_incomplete: >-
          {{ netbox_vms
             | rejectattr('custom_fields.vmid', 'defined')
             | list
             + netbox_vms | selectattr('custom_fields.vmid', 'defined')
             | selectattr('custom_fields.vmid', 'equalto', None) | list }}
    - name: "WARNUNG - VMs ohne vmid in NetBox (werden uebersprungen)"
      ansible.builtin.debug:
        msg: "'{{ item.name }}': vmid={{ item.custom_fields.vmid | default('FEHLT') }}"
      loop: "{{ netbox_vms_incomplete }}"
      loop_control:
        label: "{{ item.name }}"
    # -----------------------------------------------------------------
    # 2. PVE-Ressourcen von BEIDEN Standalone-Nodes holen, zusammenfuehren
    #    und nach vmid indexieren (liefert vmid, name, node, maxcpu,
    #    maxmem, maxdisk - reicht fuer node-Aufloesung UND den
    #    vCPU/RAM/Disk-Diff)
    # -----------------------------------------------------------------
    - name: Ressourcen-Stand von JEDEM PVE-Node einzeln holen (Standalone, kein Proxmox-Cluster)
      ansible.builtin.uri:
        url: "{{ item.api }}/cluster/resources?type=vm"
        method: GET
        headers:
          Authorization: "PVEAPIToken={{ item.token_id }}={{ item.token_secret }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ pve_nodes }}"
      loop_control:
        label: "{{ item.name }}"
      register: pve_resources_raw
      no_log: true
    - name: Ergebnisse aller Nodes zu einer Liste zusammenfuehren
      ansible.builtin.set_fact:
        pve_resources_all: >-
          {{ pve_resources_raw.results
             | map(attribute='json')
             | map(attribute='data')
             | flatten(levels=1) }}
    - name: PVE-Node-Name zu vollen Node-Infos (API-URL + Token) Lookup bauen
      ansible.builtin.set_fact:
        pve_node_lookup_helper: "{{ dict(pve_nodes | map(attribute='name') | list | zip(pve_nodes)) }}"
    - name: "PVE-Ressourcen (LXC UND QEMU) nach vmid indexieren (inkl. API-URL + Token + Typ des jeweiligen Nodes)"
      ansible.builtin.set_fact:
        pve_by_vmid: "{{ pve_by_vmid | default({}) | combine({ (item.vmid | string): (item | combine({
          'api': pve_node_lookup_helper[item.node].api,
          'token_id': pve_node_lookup_helper[item.node].token_id,
          'token_secret': pve_node_lookup_helper[item.node].token_secret
        })) }) }}"
      loop: "{{ pve_resources_all | selectattr('type', 'in', ['lxc', 'qemu']) | list }}"
      loop_control:
        label: "{{ item.name }}"
    - name: "WARNUNG - NetBox-vmid ohne Treffer in PVE (werden uebersprungen)"
      ansible.builtin.debug:
        msg: "'{{ item.name }}' (vmid={{ item.custom_fields.vmid }}) nicht in PVE gefunden"
      loop: "{{ netbox_vms_ready }}"
      loop_control:
        label: "{{ item.name }}"
      when: (item.custom_fields.vmid | string) not in pve_by_vmid
    - name: Nur tatsaechlich in PVE vorhandene VMs weiterverarbeiten
      ansible.builtin.set_fact:
        netbox_vms_matched: >-
          {{ netbox_vms_ready
             | selectattr('custom_fields.vmid', 'in', pve_by_vmid.keys() | map('int') | list)
             | list }}
    # -----------------------------------------------------------------
    # 3. vCPU/RAM/Disk-Drift anzeigen (NUR Anzeige, noch kein Push)
    # -----------------------------------------------------------------
    - name: Ressourcen-Drift bauen (vCPU/RAM/Disk, Diff-Anzeige only)
      ansible.builtin.set_fact:
        resource_drift: "{{ resource_drift | default([]) + [{
          'name': item.name,
          'netbox_id': item.id,
          'vmid': item.custom_fields.vmid,
          'node': pve_by_vmid[item.custom_fields.vmid | string].node,
          'netbox_vcpus': item.vcpus | default(None),
          'pve_vcpus': pve_by_vmid[item.custom_fields.vmid | string].maxcpu | int,
          'netbox_memory_mb': item.memory | default(None),
          'pve_memory_mb': (pve_by_vmid[item.custom_fields.vmid | string].maxmem | int / 1024 / 1024) | round(0, 'common') | int,
          'netbox_disk_mb': item.disk | default(None),
          'pve_disk_mb': (pve_by_vmid[item.custom_fields.vmid | string].maxdisk | int / 1024 / 1024) | round(0, 'common') | int
        }] }}"
      loop: "{{ netbox_vms_matched }}"
      loop_control:
        label: "{{ item.name }}"
    - name: "INFO - Ressourcen-Drift je VM (Anzeige; Reverse-Push PVE->NetBox folgt darunter)"
      ansible.builtin.debug:
        msg: >-
          {{ item.name }} (vmid={{ item.vmid }}, node={{ item.node }}):
          vCPU NetBox={{ item.netbox_vcpus }} PVE={{ item.pve_vcpus }}
          {{ '[ABWEICHUNG]' if item.netbox_vcpus | int != item.pve_vcpus else '' }} |
          RAM(MB) NetBox={{ item.netbox_memory_mb }} PVE={{ item.pve_memory_mb }}
          {{ '[ABWEICHUNG]' if item.netbox_memory_mb | int != item.pve_memory_mb else '' }} |
          Disk(MB) NetBox={{ item.netbox_disk_mb }} PVE={{ item.pve_disk_mb }}
          {{ '[ABWEICHUNG - SHRINK, wird NICHT gepusht]' if item.netbox_disk_mb | int < item.pve_disk_mb
             else ('[ABWEICHUNG - GROW]' if item.netbox_disk_mb | int > item.pve_disk_mb else '') }}
      loop: "{{ resource_drift }}"
      loop_control:
        label: "{{ item.name }}"
    - name: NetBox-Update-Aktionsplan bauen (PVE-Ist-Werte sollen nach NetBox uebernommen werden)
      ansible.builtin.set_fact:
        netbox_update_actions: "{{ netbox_update_actions | default([]) + [{
          'name': item.name,
          'netbox_id': item.netbox_id,
          'vmid': item.vmid,
          'target_vcpus': item.pve_vcpus,
          'target_memory': item.pve_memory_mb,
          'target_disk': item.pve_disk_mb,
          'any_change': (item.netbox_vcpus | int != item.pve_vcpus)
                         or (item.netbox_memory_mb | int != item.pve_memory_mb)
                         or (item.netbox_disk_mb | int != item.pve_disk_mb)
        }] }}"
      loop: "{{ resource_drift }}"
      loop_control:
        label: "{{ item.name }}"
    - name: "UEBERSICHT - NetBox-Objekte, die auf PVE-Ist-Werte aktualisiert werden"
      ansible.builtin.debug:
        msg: "'{{ item.name }}' (netbox_id={{ item.netbox_id }}): vCPU->{{ item.target_vcpus }} RAM->{{ item.target_memory }}MB Disk->{{ item.target_disk }}MB"
      loop: "{{ netbox_update_actions }}"
      loop_control:
        label: "{{ item.name }}"
      when: item.any_change
    - name: "PVE-Ist-Werte nach NetBox schreiben (NUR wenn dry_run=false, NUR bei Abweichung)"
      ansible.builtin.uri:
        url: "{{ netbox_api }}/api/virtualization/virtual-machines/{{ item.netbox_id }}/"
        method: PATCH
        headers:
          Authorization: "Token {{ netbox_token }}"
        body_format: json
        body:
          vcpus: "{{ item.target_vcpus }}"
          memory: "{{ item.target_memory }}"
          disk: "{{ item.target_disk }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ netbox_update_actions }}"
      loop_control:
        label: "{{ item.name }}"
      when:
        - not (dry_run | bool)
        - item.any_change
      register: netbox_update_result
      failed_when: false
    - name: Fehlgeschlagene NetBox-Updates auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.name }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ netbox_update_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.name }}"
      when:
        - item.item is defined
        - item.status is defined
        - item.status != 200
    # -----------------------------------------------------------------
    # 4. onboot / start_on_boot: vollstaendig implementiert, inkl. Push
    # -----------------------------------------------------------------
    - name: "'Letzter Status' VMs herausfiltern (werden bewusst nicht gepusht)"
      ansible.builtin.set_fact:
        netbox_vms_laststate: "{{ netbox_vms_matched | selectattr('start_on_boot.value', 'equalto', start_on_boot_laststate_value) | list }}"
        netbox_vms_pushable: "{{ netbox_vms_matched | rejectattr('start_on_boot.value', 'equalto', start_on_boot_laststate_value) | list }}"
    - name: "INFO - VMs mit 'Letzter Status' (nicht abbildbar in Proxmox, wird uebersprungen)"
      ansible.builtin.debug:
        msg: "'{{ item.name }}' (vmid={{ item.custom_fields.vmid }}) steht auf 'Letzter Status' - wird nicht angefasst"
      loop: "{{ netbox_vms_laststate }}"
      loop_control:
        label: "{{ item.name }}"
    - name: Aktuelle PVE-Config je gematchter, pushbarer VM/LXC holen (fuer onboot-Wert)
      ansible.builtin.uri:
        url: "{{ pve_by_vmid[item.custom_fields.vmid | string].api }}/nodes/{{ pve_by_vmid[item.custom_fields.vmid | string].node }}/{{ pve_by_vmid[item.custom_fields.vmid | string].type }}/{{ item.custom_fields.vmid }}/config"
        method: GET
        headers:
          Authorization: "PVEAPIToken={{ pve_by_vmid[item.custom_fields.vmid | string].token_id }}={{ pve_by_vmid[item.custom_fields.vmid | string].token_secret }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ netbox_vms_pushable }}"
      loop_control:
        label: "{{ item.name }}"
      register: pve_configs_raw
    - name: Aktionsplan bauen (Diff NetBox start_on_boot vs. PVE onboot)
      ansible.builtin.set_fact:
        onboot_actions: "{{ onboot_actions | default([]) + [{
          'name': item.item.name,
          'vmid': item.item.custom_fields.vmid,
          'node': pve_by_vmid[item.item.custom_fields.vmid | string].node,
          'type': pve_by_vmid[item.item.custom_fields.vmid | string].type,
          'api': pve_by_vmid[item.item.custom_fields.vmid | string].api,
          'token_id': pve_by_vmid[item.item.custom_fields.vmid | string].token_id,
          'token_secret': pve_by_vmid[item.item.custom_fields.vmid | string].token_secret,
          'netbox_start_on_boot': item.item.start_on_boot.value,
          'netbox_wants_enabled': (item.item.start_on_boot.value == start_on_boot_enabled_value),
          'pve_onboot': (item.json.data.onboot | default(0) | int == 1)
        }] }}"
      loop: "{{ pve_configs_raw.results }}"
      loop_control:
        label: "{{ item.item.name }}"
    - name: "DRY RUN / UEBERSICHT - Alle gematchten, pushbaren VMs mit onboot-Stand"
      ansible.builtin.debug:
        msg: >-
          {{ item.name }} (vmid={{ item.vmid }}, node={{ item.node }}):
          NetBox={{ item.netbox_start_on_boot }} ({{ 'on' if item.netbox_wants_enabled else 'off' }})
          PVE-onboot={{ item.pve_onboot }}
          {{ '[WIRD GEAENDERT]' if item.netbox_wants_enabled != item.pve_onboot else '[unveraendert]' }}
      loop: "{{ onboot_actions }}"
      loop_control:
        label: "{{ item.name }}"
    - name: onboot-Wert in PVE setzen (nur bei tatsaechlicher Abweichung, nur wenn dry_run=false)
      ansible.builtin.uri:
        url: "{{ item.api }}/nodes/{{ item.node }}/{{ item.type }}/{{ item.vmid }}/config"
        method: PUT
        headers:
          Authorization: "PVEAPIToken={{ item.token_id }}={{ item.token_secret }}"
        body_format: form-urlencoded
        body:
          onboot: "{{ 1 if item.netbox_wants_enabled else 0 }}"
        validate_certs: false
        status_code: [200]
      loop: "{{ onboot_actions }}"
      loop_control:
        label: "{{ item.name }}"
      when:
        - not (dry_run | bool)
        - item.netbox_wants_enabled != item.pve_onboot
      register: onboot_push_result
      failed_when: false
    - name: Fehlgeschlagene onboot-Updates auflisten
      ansible.builtin.debug:
        msg: "FEHLER bei {{ item.item.name }}: {{ item.msg | default('unbekannt') }}"
      loop: "{{ onboot_push_result.results | default([]) }}"
      loop_control:
        label: "{{ item.item.name }}"
      when:
        - item.item is defined
        - item.status is defined
        - item.status != 200

den “slug” sieht man am Standort wenn man auf Bearbeiten klickt. Da ich meinen Wohnrt nicht im Internet teilen möchte, habe ich die Felder verpixelt. In meinem Fall sind zwei PVE Hosts vorhanden und deren beiden IPs sind hart in das Skript kodiert. Wer das Skript verwenden möchte muss es an die eigene Situation anpassen.

Bildschirmfoto_20260901_161631.png

Aus Faulheitsgründen habe ich die vorher erzeugten PVE Token einfach in das Variablenfile der dns-push skripte dazugepackt. Man könnte das natürlich auch trennen, hat beides Vor- und Nachteile…

Bildschirmfoto_20260901_162918.png

Dem bereits vorhandenen Netbox Token habe ich nachträglich noch Scheibrechte eingeräumt (geht nachträglich über die GUI), sonst ließen sich keine Werte (vCPU, RAM etc.) aus PVE in Netbox schreiben. Zum Schluss noch das neue Playbook via Template in Semaphore einfügen.

Bildschirmfoto_20260901_163224.png

Die virtuellen Maschinen kennen bei Netbox bereits ein Feld für den Zustand nach Reboot

Bildschirmfoto_20260901_163659.png

Bei PVE wird als “Primary Key” zur Identifikation die sogenannte VMID verwendet. Dafür gibt es (noch) kein Feld bei Netbox. Nachdem man das neue Feld erzeugt hat, muss man es noch für jede virtuelle Instanz manuell befüllen. Nur LXCs und VMs die in Netbox manuell eine solche vmid zugewiesen bekommen haben, werden von obigen Skript auch berücksichtigt, der Rest wird ignoriert.

Bildschirmfoto_20260901_164407.png
Bildschirmfoto_20260902_095710-1.png

Nachdem ich das Skript ein paar mal getestet hatte, habe ich einen cronjob alle 10 Minuten dafür eingerichtet, das geht in Semaphore ja auch über die GUI. Ich kann jetzt zentral in Netbox das Verhalten aller virtuellen Instanzen nach einem Reboot des jeweiligen PVE Hosts steuern. Im gleichen Schritt werden die Werte für vCPU, RAM etc von den virtuellen Instanzen ausgelesen und in Netbox synchronisiert. Netbox tut sich etwas schwer mit der Umrechnung (Bei Netbox sind 1000MB ein GB und nicht 1024MB wie es richtig wäre), das stört mich aber nicht weiter. Der große Vorteil dabei ist nun, dass ich das Rebootverhalten nun auch massenhaft ändern kann und ich mich nicht mehr einzeln durchklicken muss.

Donnerstag, Juli 30, 2026

Netbox - Update des LXC

Mir ist da ein kleiner Fauxpas unterlaufen. Für mein zentrales Logging schreibe ich die NetBox-Logs in ein File, das ich dann via Grafana Alloy weiterverarbeite. Ich wollte alles schön zusammen lassen und habe das besagte Logfile dummerweise in /opt/netbox/logs/netbox.log geschrieben. Leider habe ich mir vorher das Community-Scripts-Update-Skript nicht angesehen. Es wird bei dem Skript zunächst ein Backup erstellt. Dann wird NetBox von Grund auf neu deployed, dabei geht das oben erwähnte Verzeichnis nebst der netbox.log verloren. Am Ende werden diverse Verzeichnisse/Dateien aus dem Backup zurückkopiert und NetBox anschließend neu gestartet. Ich schreibe daher das Logfile zukünftig einfach nach /var/log/netbox, das wird bei dem Updateprozess nicht angefasst und ist für Logs sinnvoller.

mkdir -p /var/log/netbox
chown netbox:netbox /var/log/netbox

Sicherheitshalber kommt mein Logfile nochmal in ein separates Verzeichnis mit passender Ownership. Ich muss noch ein paar Änderungen im NetBox-LXC vornehmen. Die erste Änderung ist notwendig, damit in Zukunft die Updates sauber durchlaufen.
/opt/netbox/local_requirements

dulwich
netbox-plugin-dns
netbox-plugin-dhcp

(die beiden Plugins nachtragen)
/opt/netbox/netbox/netbox/configuration.py

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'normal': {'format': '%(asctime)s %(name)s %(levelname)s: %(message)s'},
    },
    'handlers': {
        'console': {
            'level': 'INFO',
            'class': 'logging.StreamHandler',
        },
        'file': {
            'level': 'INFO',
            'class': 'logging.handlers.WatchedFileHandler',
            'filename': '/var/log/netbox/netbox.log',
            'formatter': 'normal',
        },
    },
    'loggers': {
        'netbox': {'handlers': ['console', 'file'], 'level': 'INFO'},
        'django': {'handlers': ['console', 'file'], 'level': 'WARNING'},
    },
}

(bei filename den neuen Pfad eintragen)
und zuletzt noch Alloy anpassen /etc/alloy/config.alloy

loki.source.file "netbox" {
  targets = [
    {__path__ = "/var/log/netbox/netbox.log", job = "netbox", host = "netbox"},
  ]
  forward_to = [loki.write.central.receiver]
}
loki.write "central" {
  endpoint {
    url = "http://192.168.2.12:3100/loki/api/v1/push"
  }
}

(analog zur vorherigen Konfigurationsdatei den Pfad ändern)
Dann zunächst NetBox neu starten und kontrollieren, dass ein neues Logfile am neuen Platz angelegt wurde. Anschließend Alloy neu starten und in Grafana kontrollieren, dass die Logs von NetBox dort weiterhin ankommen. Ggf. ein neues Fantasiegerät erzeugen und wieder löschen, um neue Logeinträge zu generieren.
Nun klappt auch das Updaten des NetBox-LXCs via update-Kommando der Community-Scripts. Hier noch ein paar Screenshots vom Ergebnis.

Bildschirmfoto_20260730_154058.png
Bildschirmfoto_20260730_154514.png

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.