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 –>
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.
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.
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
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
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.
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.
Bei den Playbooks habe ich ein neues yml File angelegt mit folgendem Inhalt:
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.
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…
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.
Die virtuellen Maschinen kennen bei Netbox bereits ein Feld für den Zustand nach Reboot
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.
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.
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.
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
(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.
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.
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.