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.

Samstag, Juli 25, 2026

Netbox - Semaphore - ssh_keys & Inventory von Netbox abrufen

Ich habe den Artikel massiv von einer KI “glätten” lassen, da ich mich beim Mitschreiben mehrmals verzettelt habe und ich den ursprünglichen Verlauf weder meinem zukünftigen Ich noch irgendjemand anderem antun wollte. Ich kann nicht so schön schreiben.

Damit ich mit Ansible ein dynamisches Inventory von NetBox abrufen kann, brauche ich zunächst ein paar Bausteine.

Ich erzeuge zunächst einen neuen zufälligen API-Token ohne Schreibrechte in der GUI des NetBox-LXC. Dabei wird zunächst ein fehlender API Token Pepper bemängelt. Man kann einen zufälligen Wert z. B. mit

python3 -c "import secrets; print(secrets.token_hex(32))"

in der NetBox-Shell erzeugen. Den soeben erzeugten Zufallswert trage ich danach in der /opt/netbox/netbox/netbox/configuration.py bei API_TOKEN_PEPPERS ein.

Bildschirmfoto_20260725_111644.png

Verpixelt sieht viel besser aus als der rote Balken. 😎

Danach NetBox neu starten, um die Konfiguration mit den Änderungen neu einzulesen:

systemctl restart netbox netbox-rq

Der Token wird nur einmal angezeigt, also wandert er ebenfalls in meine KeePass-Datenbank (KeePass und die Synchronisierung einer Datenbankdatei über alle Endgeräte wäre bei Gelegenheit einen eigenen Artikel wert).

Als Nächstes lege ich ein neues Inventory an, das ich NetBox Dynamic nenne, Type ist zunächst Static YAML. Sobald man den Type ausgewählt hat, kann man in dem neuen Fenster diesen Inhalt eintragen:

plugin: netbox.netbox.nb_inventory
api_endpoint: https://<netbox-lxc-ip>
token: "<dein-token>"
validate_certs: false
config_context: false
group_by:
  - site
  - role
  - tags

Der zuvor erzeugte Token beginnt mit Bearer nbt_…. Der eigentliche Token startet erst ab nbt – “Bearer” und das Leerzeichen davor sind nicht Teil des Tokens.

Bildschirmfoto_20260725_113904.png

Credentials lasse ich vorerst auf None. Ansible greift grundsätzlich via SSH auf die Endgeräte zu. Damit man nicht unzählige Username/Passwort-Kombinationen (gefährlich) bei Ansible hinterlegen muss, wird der Zugriff über einen SSH-Key geregelt. Den öffentlichen Teil des Ansible-SSH-Schlüssels kann ich dann auf allen Endgeräten hinterlegen.

Bei dem neuen Semaphore-LXC waren noch keine SSH-Keys hinterlegt; ich erzeuge in der Shell von Semaphore einfach einen neuen:

ssh-keygen -t ed25519 -f /opt/semaphore/ansible-key -N "" -C "semaphore-ansible"

Den öffentlichen Schlüssel kann man sich anzeigen lassen mit

cat /opt/semaphore/ansible-key.pub

Den privaten Key (nicht den mit .pub am Ende!) hinterlege ich in der Semaphore-GUI im Key Store.

Bildschirmfoto_20260725_130618.png

Sobald das erledigt ist, kann ich im Inventory-Objekt, das ich vorher erstellt hatte, die Credentials von None auf den soeben neu hinterlegten SSH-Key umstellen.

Bildschirmfoto_20260725_130942.png

Den öffentlichen Schlüssel habe ich mir mit cat in der Shell anzeigen lassen und den Inhalt in die Zwischenablage kopiert.

Bildschirmfoto_20260725_131403.png

In dem NetBox-LXC lege ich die Datei ~/.ssh/authorized_keys an:

mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys

und befülle die Datei mit dem Inhalt aus der Zwischenablage. Anschließend die Rechte einschränken:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

Den Key teste ich gleich; dazu wechsle ich in die Shell des Semaphore-LXC und verbinde mich mit dem NetBox-LXC, wo ich soeben den Schlüssel hinterlegt habe:

ssh -i /opt/semaphore/ansible-key root@192.168.2.10

192.168.2.10 ist die IP des NetBox-LXC. Klappt 😁

Bildschirmfoto_20260725_132415.png

Ich erstelle ein Test-Playbook in der Shell von Semaphore und committe es in mein lokales Git-Repository:

cd /opt/semaphore/playbooks
cat > test-inventory.yml << 'EOF'
---
- hosts: all
  gather_facts: false
  tasks:
    - name: Zeige erkannten Host
      debug:
        msg: "Host: {{ inventory_hostname }} | IP: {{ ansible_host | default('n/a') }} | Groups: {{ group_names }}"
EOF
git add test-inventory.yml
git commit -m "Add inventory test playbook"

Ich lege ein neues Task Template an.

Bildschirmfoto_20260725_133647.png

Das funktioniert nicht.

Bildschirmfoto_20260725_134303.png

Semaphore meldet: “Failed to install inventory: file path must be inside secrets path”. Eine Internetsuche mit der Fehlermeldung liefert keine direkten Treffer, wohl aber ein passendes GitHub-Issue zum Semaphore-Projekt: Der Fehler tritt in Zusammenhang mit dem Inventory-Typ Static YAML auf, wenn Semaphore versucht, den Inventory-Inhalt intern als temporäre Datei zu schreiben, dabei aber eine interne Pfadvalidierung greift.

Der praktische Workaround: Inventory-Typ auf File umstellen. Statt den YAML-Inhalt direkt im Semaphore-Textfeld zu pflegen, lagere ich die Datei stattdessen ins Repo aus – das umgeht den Bug komplett, weil Semaphore dann nur eine echte Datei aus dem geklonten Repository referenziert, statt selbst etwas in einen validierten Pfad zu schreiben:

cd /opt/semaphore/playbooks
mkdir -p inventory
cat > inventory/netbox.yml << 'EOF'
plugin: netbox.netbox.nb_inventory
api_endpoint: https://<netbox-lxc-ip>
token: "<dein-token>"
validate_certs: false
config_context: false
group_by:
  - site
  - role
  - tags
EOF
git add inventory/netbox.yml
git commit -m "Add NetBox dynamic inventory as file-based inventory"

Im Inventory-Objekt selbst stelle ich den Type entsprechend auf File um und trage als Pfad inventory/netbox.yml ein.

Ein Punkt, den ich hier bewusst in Kauf nehme: Der Token landet damit im Klartext im Repository. Da es sich um ein rein lokales Repo ohne externen Push handelt, ist das Risiko für mich vertretbar – für eine sauberere Lösung würde sich später Ansible Vault anbieten.

Beim erneuten Ausführen taucht ein neuer, klar eingrenzbarer Fehler auf: Ansible kennt das Plugin netbox.netbox.nb_inventory schlicht noch nicht, weil es nicht aktiviert ist. Das lässt sich über eine ansible.cfg im Repo lösen:

cd /opt/semaphore/playbooks
cat > ansible.cfg << 'EOF'
[inventory]
enable_plugins = netbox.netbox.nb_inventory, auto, yaml, ini
EOF
git add ansible.cfg
git commit -m "Enable netbox.netbox.nb_inventory plugin"

Semaphore kennt selbst keine globalen Umgebungsvariablen, insofern ist der Weg über die ansible.cfg im Repo hier der richtige.

Beim erneuten Testen zeigt sich außerdem, dass die group_by-Werte im Plural stehen müssen (sites, device_roles statt site, role) – das verlangt die bei mir installierte Version der netbox.netbox-Collection (3.23.0) so:

plugin: netbox.netbox.nb_inventory
api_endpoint: https://192.168.2.10
token: "<dein-token>"
validate_certs: false
config_context: false
group_by:
  - sites
  - device_roles
  - tags

Damit läuft der Test endlich durch: Sämtliche rund 100 Devices aus beiden Standorten werden korrekt aus NetBox gezogen, sauber gruppiert nach Site, Geräterolle und Tags.

Ein Denkfehler ist mir dabei aber zunächst nicht aufgefallen: Das debug-Modul in meinem Test-Playbook läuft komplett lokal auf dem Semaphore-Controller – es baut gar keine SSH-Verbindung zum jeweiligen Zielhost auf, sondern gibt nur bereits aus dem Inventory bekannte Variablen aus. Der “ok”-Status für alle Hosts hat also nur bestätigt, dass das Inventory korrekt befüllt wurde – nicht, dass SSH-Zugriff funktioniert.

Für einen echten Verbindungstest tausche ich das debug-Modul gegen das eingebaute ping-Modul aus, das tatsächlich eine SSH-Verbindung aufbaut:

---
- hosts: all
  gather_facts: false
  tasks:
    - name: Teste SSH-Verbindung
      ansible.builtin.ping:

Nur die zwei Hosts, auf denen ich den Ansible-Key bereits manuell hinterlegt hatte (NetBox-LXC und einer meiner DNS-Server), laufen mit “ok” durch. Alle anderen rund 100 Hosts melden erwartungsgemäß UNREACHABLE, da dort noch kein Schlüssel verteilt ist. Semaphore wertet den Task deshalb insgesamt als fehlgeschlagen (Exit-Code 4, “mindestens ein Host unreachable”) – inhaltlich ist das aber genau das erwartete, korrekte Ergebnis.

Damit steht das dynamische Inventory: NetBox liefert live und korrekt gruppiert alle Hosts, Semaphore kann darauf zugreifen, und der SSH-Verbindungstest zeigt, wo noch Schlüssel fehlen. Als Nächstes steht die gezielte Verteilung des Ansible-SSH-Keys auf die tatsächlich relevanten Zielsysteme an. Dazu dann mehr in einem der nächsten Artikel.

Freitag, Juli 24, 2026

Netbox - Semaphore UI - lokales Playbook git Repo/Ansible Netbox Collection

Nun sind endlich alle Vorbereitungen abgeschlossen und demnächst wird es sich zeigen, ob es den ganzen Aufwand wert war. Ich habe die Anleitung von Semaphore zumindest teilweise gelesen 😉, aber wie das nun mal mit Anleitungen ist… langweilig. Ich probiere es einfach mal aus. Die Playbooks (auch die eigenen) kann man in einem Git-Repository ablegen. Das ist durchaus klug, weil Git eine eigene Versionierung u. v. m. mitbringt. Gerade bei Scripten/Code verliert man ohne Versionierung sehr schnell den Überblick über den Änderungsverlauf. Die Installation von Semaphore UI über die Community-Scripts habe ich bereits im ersten NetBox-Beitrag erwähnt. Ich werfe kurz einen Blick in das Installationsscript von Semaphore UI. Es werden bei der Installation von Semaphore via apt die Pakete ansible und git mitinstalliert, aber nicht weiter konfiguriert. Der Pfad zum Config-File steht ebenfalls in dem Installscript oder man sieht es bei systemd.

Bildschirmfoto_20260724_195337.png

In /opt/semaphore/config.json steht momentan nur das Allernötigste. Hier wäre die Seite zum Nachschlagen der (zahlreichen) möglichen Parameter (Docker ENV und Config-File-Variablen).
Für die Playbooks lege ich ein lokales Git-Repository an:

mkdir -p /opt/semaphore/playbooks
cd /opt/semaphore/playbooks
git init
Bildschirmfoto_20260724_202339.png

Für NetBox gibt es eine sogenannte Ansible-Collection, die die Kommunikation mit Ansible einfach möglich macht. Die lange Liste mit allen möglichen Befehlen, um NetBox zu beschreiben, ignoriere ich, interessant sind für mich momentan nur die Inventory- und Lookup-Plugins am Ende der Seite. In dem neuen Playbook-Verzeichnis jetzt ein neues Shellscript anlegen. Man muss python3-pip zuerst nachinstallieren, sonst funktioniert die Installation nicht.

apt update
apt install -y python3-pip
cd /opt/semaphore/playbooks
nano install-netbox-collection.sh

hier folgendes einfügen:

#!/bin/bash
set -e
ansible-galaxy collection install netbox.netbox --upgrade
pip3 install --upgrade pynetbox --break-system-packages

Durch –upgrade kann man das Script später erneut z. B. für Updates ausführen. Dann noch das Script ausführbar machen und in Git committen:

chmod +x install-netbox-collection.sh
git add install-netbox-collection.sh
git commit -m "Add netbox collection install script"

Jetzt kann man das Script in der GUI von Semaphore einbinden und gleich ausführen. Das Script installiert/aktualisiert die oben erwähnte Ansible-NetBox-Collection.

Bildschirmfoto_20260724_212048.png
Bildschirmfoto_20260724_212756.png

Sollten weitere Collections benötigt werden (vermutlich PVE), kann ich die später einfach in dem Script nachtragen.

Netbox - Erweiterung des Tailscale Tunnels für Loki/Alloy

Die Logs laufen nun von allen PVEs (an beiden Standorten), der lokalen Netbox-Instanz und dem lokalen Technitium-Server bei Loki zusammen. Der Technitium-Server am anderen Standort kann jedoch seine Logs aktuell noch nicht an Loki (läuft in meinem Heimnetz) schicken. Bei den anderen Instanzen war dies kein Problem, denn Tailscale ist auf allen PVEs installiert und fungiert mit den beiden TS-Subnet-Routern als Transportschicht ins entfernte Subnetz.

Auf meinen Technitium-Servern möchte ich kein Tailscale installieren, daher bleibt mir nur der Trick mit dem Tailscale-Tunnel, diesmal jedoch in die andere Richtung. Das ist eh nicht verkehrt, den Weg zusätzlich einzurichten, ich kann damit jederzeit weiteren Geräten/LXCs/VMs eine Verbindung ins jeweils andere Subnet geben, ohne extra Tailscale installieren zu müssen. Ich habe auch bereits einen Weg gefunden, solche Routen in Netbox abzubilden, das werde ich aber erst angehen, sobald die Fernsteuerung per Ansible funktioniert.

Zunächst lege ich in den Tailscale-ACLs einen neuen Host namens loki an und weise ihm die tatsächliche LAN-IP-Adresse zu. Ich muss zusätzlich eine neue Action in den ACLs definieren. Die Änderungen in den ACLs sollten anhand der Screenshots selbsterklärend sein.

Bildschirmfoto_20260724_162249.png
Bildschirmfoto_20260724_162535.png

Nun trage ich die Route vom externen Technitium-Server (dns-giesing) in mein Heimnetz ein. Ich wechsele dazu in die Shell von dns-giesing und setze zunächst eine temporäre Route. Wenn es funktioniert, mache ich die Route persistent.

ip route add 192.168.2.0/24 via 192.168.3.67

Die IP-Adresse 192.168.3.67 gehört dem LXC mit Tailscale, das als Tailscale-Subnet-Router und Exit-Node im entfernten Subnetz fungiert. Mit

ip route show

kann man die aktuell gesetzte Route kontrollieren.

Bildschirmfoto_20260724_163737-1.png

Nur zum Spaß teste ich einen Ping bereits vor der iptables-Regel auf dem TS-Subnet-Router hier im Heimnetz und stelle entsetzt fest, dass der Ping bereits jetzt durchgegangen ist… Ich vermute, das liegt an den TS-eigenen Masquerading-, SNAT- und sonstigen Geschichten, die da im Hintergrund laufen. (Hier steht die Erklärung, SNAT war die richtige Vermutung) Ich setze auf dem TS-Subnet-Router in meinem Heimnetz die iptables-Regel mit Masquerading trotzdem. Die Verbindung sollte dann auch noch funktionieren, wenn ich irgendwann mal etwas an der globalen Tailscale-SNAT-Einstellung ändere.

iptables -t nat -A POSTROUTING -o eth0 -s 192.168.3.0/24 -j MASQUERADE

Der Ping geht nun vom entfernten Technitium-LXC zu meinem lokalen Loki-LXC.

Bildschirmfoto_20260724_171050.png

Optimal! Nun mache ich noch die Änderungen persistent, auf dem LXC im Heimnetz mit dem TS-Subnet-Router.

apt install iptables-persistent -y
netfilter-persistent save

und auf dns-giesing (Technitium) noch die Route fest in die /etc/network/interfaces eintragen:

auto lo
iface lo inet loopback
auto eth0
iface eth0 inet static
address 192.168.3.2/24
gateway 192.168.3.1
post-up ip route add 192.168.2.0/24 via 192.168.3.67