Edit: Am 16.09.26 die neueste Version des DNS Push Playbooks ergänzt. s.u.
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.
Das Playbook für das pushen der DNS Einträge habe ich in der Zwischenzeit mehrmals überarbeitet, aktueller Stand wäre dieser hier:
---
# push-dns-site-a.yml
# Pusht A-Records aus NetBox (netbox-plugin-dns) nach Technitium und
# schreibt Comment + MAC-Adresse zurück auf das jeweilige NetBox
# IP-Address-Objekt (analog zu SyncDynamicLeases, nur für statisch
# gemanagte Records).
#
# Standort-Zuordnung erfolgt wie bei push-dhcp.yml über IP-in-Prefix-
# Matching gegen die Ziel-VRF, da nur eine NetBox-DNS-View existiert.
# Kein AAAA (IPv6 noch nicht in NetBox erfasst). Kein separater PTR-
# Push, Technitium erzeugt PTR selbst (ptr=true, createPtrZone=true).
#
# Voraussetzungen: netbox.netbox Collection, Ansible-SSH-Key auf dem
# Technitium-Host, NETBOX_TOKEN/TECHNITIUM_TOKEN als Env-Vars (z.B. in
# Semaphore als Environment-Variablen hinterlegt, nicht im Repo).
#
# Test (nur Anzeige): ansible-playbook -i inventory/netbox.yml push-dns-site-a.yml
# --limit dns-site-a -e dry_run=true
# Testlauf gegen einen einzelnen Record (schreibt echt, aber nur diesen einen):
# ansible-playbook -i inventory/netbox.yml push-dns-site-a.yml --limit dns-site-a
# -e dry_run=false -e test_fqdn=example-host.site-a.internal
- name: NetBox DNS Records nach Technitium pushen
hosts: dns-site-a
gather_facts: false
vars:
netbox_api: "https://netbox.internal.example"
netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
# Technitium leitet HTTP (5380) per 307 auf HTTPS (53443) um -> direkt HTTPS nutzen.
technitium_url: "https://{{ ansible_host }}:53443"
technitium_token: "{{ lookup('env', 'TECHNITIUM_TOKEN') }}"
target_vrf_name: "VRF Site-A"
expected_zone_name: "site-a.internal"
overwrite_existing: true
managed_comment: "Managed via NetBox (Ansible push-dns-site-a)"
dry_run: true
# Leer = alle Records verarbeiten. Für einen Testlauf gegen einen
# einzelnen Eintrag: -e test_fqdn=example-host.site-a.internal
test_fqdn: ""
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 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 aktiven DNS-Records aus netbox-plugin-dns holen
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
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: Auf einzelnen Test-Record einschränken, falls test_fqdn gesetzt
ansible.builtin.set_fact:
site_records: "{{ site_records | default([]) | selectattr('fqdn', 'equalto', test_fqdn) | list }}"
when: test_fqdn | length > 0
- name: Anzahl relevanter Records
ansible.builtin.debug:
msg: "{{ site_records | default([]) | length }} Records werden nach {{ inventory_hostname }} gepusht"
- name: "DRY RUN - geplante Records 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 bei abweichender Zone
ansible.builtin.debug:
msg: >-
WARNUNG: {{ item.fqdn | default(item.name) }} hat Zone '{{ item.zone.name }}',
erwartet '{{ expected_zone_name }}'. 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"
comments: "{{ managed_comment }}"
status_code: [200]
validate_certs: false
loop: "{{ site_records | default([]) }}"
loop_control:
label: "{{ item.fqdn | default(item.name) }}"
no_log: true
register: push_result
failed_when: >-
push_result.json is defined and push_result.json.status is defined
and push_result.json.status != 'ok'
ignore_errors: true
when: not (dry_run | bool)
# --- Rückschreiben nach NetBox: Comment + MAC auf dem IP-Address-Objekt ---
# Analog zu SyncDynamicLeases (dhcp_lease_mac), aber für statisch via
# NetBox gemanagte Records. MAC kommt aus dem nachgeladenen Interface-
# Objekt (primary_mac_address, seit NetBox 4.2), bleibt leer wenn die
# IP keinem Interface zugewiesen ist.
- name: Alle IP-Address-Objekte der Ziel-VRF aus NetBox holen (ein Bulk-Call statt einer pro Record)
ansible.builtin.set_fact:
all_vrf_ip_objects: >-
{{ query('netbox.netbox.nb_lookup', 'ip-addresses',
api_endpoint=netbox_api, token=netbox_token,
api_filter='vrf_id=' ~ target_vrf.id,
validate_certs=false)
| map(attribute='value') | list }}
when: not (dry_run | bool)
- name: IP-Objekte den gepushten Records lokal zuordnen (kein API-Call pro Record)
ansible.builtin.set_fact:
site_ip_objects: >-
{{ site_ip_objects | default([]) + (
all_vrf_ip_objects | default([])
| selectattr('address', 'defined')
| selectattr('address', 'search', '^' ~ (item.value | ansible.utils.ipaddr('address')) ~ '/')
| list
) }}
loop: "{{ site_records | default([]) }}"
loop_control:
label: "{{ item.fqdn | default(item.name) }}"
when: not (dry_run | bool)
- name: Volles Interface-Objekt nachladen (assigned_object liefert nur Kurzform ohne MAC)
ansible.builtin.uri:
url: "{{ item.assigned_object.url }}"
method: GET
headers:
Authorization: "Token {{ netbox_token }}"
status_code: [200]
validate_certs: false
loop: "{{ site_ip_objects | default([]) }}"
loop_control:
label: "{{ item.address }}"
when:
- not (dry_run | bool)
- item.assigned_object is defined and item.assigned_object is not none
register: interface_details
- name: MAC-Lookup-Dictionary aus nachgeladenen Interfaces bauen
ansible.builtin.set_fact:
mac_by_ip_id: >-
{{ mac_by_ip_id | default({}) | combine({
(result.item.id | string): (result.json.primary_mac_address.mac_address | default('', true))
}) }}
loop: "{{ interface_details.results | default([]) }}"
loop_control:
loop_var: result
label: "{{ result.item.address | default('?') }}"
when:
- not (dry_run | bool)
- result.skipped is not defined
- name: ip_mac_pairs für ALLE Records bauen (MAC aus Lookup, sonst leer)
ansible.builtin.set_fact:
ip_mac_pairs: >-
{{ ip_mac_pairs | default([]) + [{
'ip_id': item.id,
'mac': (mac_by_ip_id | default({})).get(item.id | string, '')
}] }}
loop: "{{ site_ip_objects | default([]) }}"
loop_control:
label: "{{ item.address }}"
when: not (dry_run | bool)
- name: Comment + MAC-Custom-Field auf dem NetBox IP-Address-Objekt setzen
ansible.builtin.uri:
url: "{{ netbox_api }}/api/ipam/ip-addresses/{{ item.ip_id }}/"
method: PATCH
headers:
Authorization: "Token {{ netbox_token }}"
Content-Type: "application/json"
body_format: json
body:
description: "{{ managed_comment }}"
custom_fields:
dhcp_lease_mac: "{{ item.mac | default(omit) }}"
status_code: [200]
validate_certs: false
loop: "{{ ip_mac_pairs | default([]) }}"
loop_control:
label: "{{ item.ip_id }}"
no_log: true
register: netbox_patch_result
failed_when: false
when: not (dry_run | bool)
- name: Fehlgeschlagene Technitium-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)
- name: Fehlgeschlagene NetBox-Rückschreib-Requests auflisten
ansible.builtin.debug:
msg: "FEHLER bei NetBox IP-Objekt id={{ item.item.ip_id }}: HTTP {{ item.status | default('?') }}"
loop: "{{ netbox_patch_result.results | default([]) }}"
loop_control:
label: "{{ item.item.ip_id }}"
when:
- not (dry_run | bool)
- item.status | default(0) != 200
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.
