Mittwoch, Oktober 28, 2026

Netbox - Neue Skripte für Semaphore (DNS & DHCP Sync)

Ich habe einen Raspberry Pi 4 mit LibreELEC bestückt und nutze ihn mit dem Plugin für Waipu u. a. zum Fernsehen. Bei heise.de gibt es derzeit auch einen Artikel zu dem Thema. Vorher hatte ich einen Chromecast, aber das geschwätzige Ding hat tausende (!) Nachrichten pro Tag nach Hause (Alphabet/Google) geschickt, und deswegen ist diese Lösung bei mir zu Hause wieder rausgeflogen und durch LibreELEC/Kodi ersetzt worden. Seit einiger Zeit friert das Bild bei Kodi/Waipu ein, und der Raspberry muss neu gestartet werden.

Mir ist aufgefallen, dass dieses Problem immer nur zu ganz bestimmten Zeiten aufgetreten ist, und daher vermute ich mein NetBox-/DNS-/DHCP-Konstrukt (das ich mit Claude zusammengeschustert habe) als Ursache. Aktuell ist das Skript ziemlich aggressiv, da z. B. die Reservierungslisten bei Technitium bei jedem Durchlauf immer komplett überschrieben werden. Ich muss auch immer einen Skriptdurchlauf bei Semaphore abwarten, bis meine Änderungen in NetBox nach Technitium repliziert werden. Sofern die Änderungen in NetBox genau während eines solchen Durchlaufs passieren, kann das theoretisch zu unvorhersehbaren Effekten bei Technitium führen. Außerdem wäre mir lieber, dass die Änderungen sofort übernommen werden, also eventbasiert und nicht zeitbasiert. Das Ganze muss auch selektiver werden: Ich möchte nicht immer alles neu nach Technitium schreiben, sondern nur die jeweiligen Änderungen.

Claude hat also auf mein Geheiß neue Skripte erstellt. Diese ersetzen die beiden alten Skripte für die Synchronisierung von DNS und DHCP nach Technitium. Als Info vorab: Die neue Lösung läuft bereits seit zwei Wochen, bisher hatte ich keine Probleme damit. Der Entwicklungsprozess lief über längere Zeit und führte wie so oft in diverse Sackgassen. Ich habe Claude daher den Artikel fertig schreiben lassen, da mir sowohl der Nerv als auch die Zeit dazu gefehlt haben.

Das Ausgangssetup

NetBox ist hier die Source of Truth für IPAM, DNS (Plugin netbox-plugin-dns) und DHCP (Plugin netbox-plugin-dhcp). Ausgerollt wird nach Technitium DNS/DHCP per Ansible, orchestriert über Semaphore. Bisher liefen dafür drei Playbooks als Semaphore-Workflow hintereinander, alle 15 Minuten:

  • DNS: A-Records aus NetBox nach Technitium schreiben
  • DHCP: Reservierungen aus NetBox nach Technitium schreiben
  • Proxmox: VM-Einstellungen zwischen NetBox und Proxmox abgleichen

Zusätzlich holt ein NetBox-Custom-Script die dynamisch vergebenen Leases aus Technitium zurück ins IPAM.

Was wirklich schiefging

Die Vermutung ging in die richtige Richtung, die eigentliche Ursache lag aber an einer anderen Stelle als gedacht. Das DHCP-Playbook verglich bereits vor dem Schreiben – der Hauptstörer war das DNS-Playbook:

  • Tauziehen um dieselben DNS-Records: Der DHCP-Scope in Technitium hat einen Domain-Namen gesetzt. Damit legt Technitium für jeden Lease bzw. jede Reservierung selbst A- und PTR-Records an (zu erkennen am Kommentar “Via ‘Standort-A’ DHCP scope”). Das DNS-Playbook hat alle 15 Minuten ALLE Records mit overwrite=true neu geschrieben – und beim nächsten Lease-Renew hat der DHCP-Server sie wieder zurückgeschrieben. Ein Endlos-Tauziehen um genau die Records der Geräte mit DHCP-Reservierung. Der Raspberry gehört dazu.
  • Namen mit mehreren IPs: Durch overwrite=true hat jeder Schreibvorgang den vorherigen Record ersetzt. Bei Namen mit mehreren A-Records blieb nach jedem Lauf nur die zuletzt geschriebene IP übrig – die Namensauflösung sprang also im 15-Minuten-Takt.
  • Altlasten: Die alten Playbooks haben nie etwas gelöscht. In Technitium lagen dadurch verwaiste DNS-Records und sogar zwei IPs, die doppelt reserviert waren (alte Container, die längst ersetzt waren). Zwei Geräte mit derselben reservierten IP – ein klassischer Adresskonflikt.
  • Verschluckte Fehler: Mehrere Tasks liefen mit failed_when: false. Was schiefging, fiel schlicht nicht auf.

Ob das Einfrieren von Kodi genau daher kam, lässt sich nicht beweisen. Das Zeitmuster passt aber zum 15-Minuten-Takt, und seit der Umstellung ist es nicht mehr aufgetreten.

Die neue Architektur

Pro Datentyp gibt es jetzt genau eine Richtung:

  • Statische DNS-Records (Server, Router, Infrastruktur): NetBox → Technitium, eventbasiert plus Fallback alle 30 Minuten
  • DHCP-Reservierungen: NetBox → Technitium, eventbasiert plus Fallback alle 30 Minuten
  • DNS-Namen von DHCP-Clients: erzeugt Technitium selbst aus dem Hostnamen der Reservierung
  • Dynamische Leases: Technitium → NetBox (bestehendes Custom Script, Tag dhcp-dynamic)
  • Proxmox-Abgleich: eigener stündlicher Schedule, unabhängig von den anderen

Keine Kette und kein Workflow mehr. Jedes Playbook ist für sich idempotent und schreibt nur, was tatsächlich abweicht. Ein normaler Lauf ohne Änderungen in NetBox schreibt: nichts.

Schritt 1: NetBox und Plugins aktualisieren

NetBox läuft jetzt auf 4.7.3, dazu netbox-plugin-dns 1.7.3 und netbox-plugin-dhcp 0.2.0. Die Plugins habe ich über die Oberfläche aktualisiert. Wichtig beim DNS-Plugin: Die Versionen 1.7.0 und 1.7.1 (sowie 1.6.0 und 1.5.12/1.5.13) enthielten einen Migrationsfehler, der verwaltete PTR-Records in aktiven Zonen gelöscht hat. Also direkt auf 1.7.3 gehen und vorher kurz den Bestand an PTR-Records in den Reverse-Zonen prüfen.

Schritt 2: Das neue DNS-Playbook (dns-sync-standort-a.yml)

Die Kernideen:

  • Ist-Zustand aus Technitium lesen, Soll-Zustand aus NetBox, Vergleich über den Schlüssel Name|IP.
  • Nur Records anfassen, die den Kommentar “Managed via NetBox” tragen. Manuelle Einträge und die Records des DHCP-Scopes bleiben unberührt.
  • IPs mit DHCP-Reservierung und IPs mit Tag dhcp-dynamic werden auf Soll- UND Ist-Seite komplett ignoriert. Deren Namen verwaltet Technitium selbst. Das beendet das Tauziehen.
  • Neue Records mit overwrite=false anlegen, danach überzählige gezielt löschen. So bleiben mehrere IPs pro Name erhalten, und es entsteht keine Lücke.
  • Schutzbremse: Liefert NetBox nichts oder würden mehr als zehn Records gelöscht, bricht der Lauf ab.
  • Zwei Modi: Event (nur die betroffenen Namen) und Reconcile (die ganze Zone als Fallback).

Ein Detail, das einen Testlauf gekostet hat: Der lesende Request an Technitium muss mit check_mode: false laufen. Sonst wird er im Ansible-Check-Mode übersprungen, und ohne Ist-Zustand sieht es so aus, als fehle alles.

- name: Zone aus Technitium lesen
  ansible.builtin.uri:
    url: "{{ technitium_url }}/api/zones/records/get?token={{ technitium_token }}&domain={{ zone_name }}&zone={{ zone_name }}&listZone=true"
    method: GET
    validate_certs: false
  register: tt_zone
  check_mode: false
  no_log: true
- name: Technitium-Antwort pruefen
  ansible.builtin.assert:
    that:
      - tt_zone.json is defined
      - tt_zone.json.status | default('') == 'ok'
    fail_msg: "Kein Ist-Zustand - Abbruch, kein Diff ohne Technitium-Antwort."

Schritt 3: Das neue DHCP-Playbook (dhcp-sync-standort-a.yml)

  • Vergleich pro MAC-Adresse: Weicht IP oder Hostname ab (Hostname ohne Beachtung der Groß-/Kleinschreibung), wird die Reservierung ersetzt. Kommentare fließen bewusst nicht in den Vergleich ein.
  • Technitium kann Reservierungen nicht ändern, nur entfernen und neu anlegen. Deshalb pro Eintrag Entfernen und sofort Neuanlegen – mit Rollback, falls das Neuanlegen scheitert. Die Lücke liegt so im Millisekundenbereich und betrifft nur genau diesen einen Eintrag.
  • Neu angelegte Reservierungen bekommen einen Marker “netbox” im Kommentar. Nur solche löscht der Fallback-Lauf als verwaist, manuell angelegte bleiben in Ruhe.
  • Erkennung von IP-Konflikten: Hält eine andere MAC-Adresse bereits die Soll-IP, wird das im Log gemeldet.

Das Ersetzen einer einzelnen Reservierung liegt in einer eigenen Task-Datei (tasks/dhcp-replace-one.yml), die per include_tasks pro Eintrag aufgerufen wird. Nur so laufen Entfernen und Neuanlegen wirklich direkt hintereinander.

Schritt 4: Sicher testen – dry_run als Survey-Variable

Die Dry-Run-Checkbox von Semaphore schaltet Ansible in den Check-Mode, und dabei werden uri-Tasks komplett übersprungen. Deshalb hat jedes Playbook eine eigene Variable dry_run (Standard: true). In Semaphore wird sie als Survey-Variable angelegt: Typ Enum, Werte true/false, Standard true, und zwar als Extra-Variable, nicht als Environment-Variable – sonst kommt sie im Playbook gar nicht an.

Der Ablauf pro Playbook:

  • Dry-Run starten und den Aktionsplan lesen
  • Altlasten bereinigen (siehe unten)
  • scharf laufen lassen
  • erneuter Dry-Run – jetzt müssen alle Listen leer sein

Achtung bei den CLI-Argumenten in Semaphore: Dort muss es [”-e”, “dry_run=false”] heißen. Ein bloßes dry_run=false wird als zweiter Playbook-Name interpretiert.

Schritt 5: Altlasten bereinigen

Der erste ehrliche Vergleich hat einiges ans Licht gebracht:

  • verwaiste DNS-Records umbenannter oder entfernter Geräte
  • zwei IPs, die in Technitium doppelt reserviert waren
  • Reservierungen, die nur in Technitium existierten und in NetBox fehlten
  • generische Hostnamen (iphone, galaxy-…) mit mehreren IPs

Die verwaisten Records hat der erste scharfe DNS-Lauf entfernt, den Rest habe ich in NetBox nachgetragen bzw. in Technitium gelöscht. Damit NetBox doppelte IPs künftig verhindert, an der VRF die Option “Enforce unique space” aktivieren.

Schritt 6: Schedules einrichten und den alten Workflow entfernen

dns-sync-standort-a        */30 * * * *
dhcp-sync-standort-a       15,45 * * * *
push-pve-config-standort-a 0 * * * *

Beim Löschen der alten Templates ist Semaphore eingefroren (100 % CPU, die Oberfläche hing). Der Grund: Semaphore löscht mit dem Template alle zugehörigen Läufe samt Logs. Bei 672 vorgehaltenen Läufen pro Template mit langen Logs waren das über 300 MB in der SQLite-Datenbank, und das dauert einfach. Nicht neu starten (dann rollt SQLite die Transaktion zurück, und alles beginnt von vorn), sondern abwarten und die WAL-Datei beobachten:

watch -n 5 'ls -la /opt/semaphore/database.sqlite*'

Die Datenbankdatei schrumpft danach nicht von selbst – das ist normales SQLite-Verhalten. Wer den Platz zurückhaben will:

systemctl stop semaphore
sqlite3 /opt/semaphore/database.sqlite "PRAGMA wal_checkpoint(TRUNCATE); VACUUM;"
systemctl start semaphore

Für die neuen Templates: max_tasks_per_template deutlich niedriger setzen (100 bis 200), und in der ansible.cfg display_skipped_hosts = False setzen. Das macht die Logs um ein Vielfaches kürzer.

Schritt 7: Eventbasiert über NetBox-Webhooks

NetBox ruft bei jeder Änderung die Semaphore-API auf und übergibt die ID des betroffenen Objekts. Dafür braucht es pro Playbook einen Webhook und eine Event-Rule (in NetBox unter Operations › Integrations).

Webhook für DNS: URL http://192.168.2.11:3000/api/project/2/tasks, Methode POST, Content-Type application/json, Header “Authorization: Bearer” plus Semaphore-API-Token. Body:

{"template_id": 9, "environment": "{"record_id": {{ data.id }}, "nb_event": "{{ event }}", "old_fqdn": "{{ snapshots.prechange.fqdn | default('') }}", "old_value": "{{ snapshots.prechange.value | default('') }}", "dry_run": "false"}"}

Event-Rule für DNS: Objekttyp NetBox DNS › Record, Ereignisse created/updated/deleted, Bedingung:

{"attr": "type", "value": "A"}

Webhook für DHCP (gleiche URL, gleicher Header), Body:

{"template_id": 10, "environment": "{"res_id": {{ data.id }}, "nb_event": "{{ event }}", "mac": "{{ data.hw_address.mac_address | default('') }}", "dry_run": "false"}"}

Event-Rule für DHCP: Objekttyp NetBox DHCP › Host Reservation, Ereignisse created/updated/deleted, keine Bedingung.

Vorher lohnt sich ein manueller Test der Semaphore-API:

curl -i -X POST http://192.168.2.11:3000/api/project/2/tasks -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d '{"template_id": 9, "environment": "{"record_id": 391, "nb_event": "updated", "dry_run": "true"}"}'

Erwartet wird HTTP 201. Ein 401 bedeutet: Token falsch. Bei mir war er beim Kopieren schlicht abgeschnitten.

Stolpersteine beim Testen der Events

  • Falsche ID: Die ID in der Geräte-URL (dcim/devices/…) ist nicht die ID des DNS-Records. Die steht in der URL unter plugins/netbox-dns/records/….
  • nb_lookup mit ID-Filter: Ein api_filter=’id=…’ führt im Lookup-Plugin netbox.netbox.nb_lookup intern zu einem direkten get(). Existiert die ID nicht, stürzt es mit “NoneType object is not iterable” ab. Lösung: alle Objekte holen und lokal per selectattr nach der ID filtern.
  • Verwaltete Records: Records, die das DNS-Plugin aus IPAM-Adressen erzeugt, sind gesperrt und lassen sich nicht direkt bearbeiten. Getestet wird deshalb über das IPAM: eine IP-Adresse mit DNS-Namen anlegen, prüfen, wieder löschen. Das Plugin aktualisiert dabei den Record – und genau das löst das Event aus.
  • Webhooks laufen über den netbox-rq-Worker. Passiert gar nichts, zuerst dort ins Log schauen.

Fazit

Aus drei verketteten Playbooks, die alle 15 Minuten den kompletten Bestand neu geschrieben haben, sind zwei vergleichsbasierte Syncs geworden. Sie reagieren sofort, wenn sich in NetBox etwas ändert, und schreiben nur genau diese Änderung – plus ein periodischer Fallback, falls einmal ein Event verloren geht. Die Bildaussetzer waren mit hoher Wahrscheinlichkeit kein WLAN- oder Kodi-Problem, sondern ein selbstgebautes Tauziehen zwischen Playbook und DHCP-Server. Und nebenbei hat der erste ehrliche Abgleich mehr Altlasten zutage gefördert, als mir lieb war.

Anhang: Die kompletten Playbooks

Zugangsdaten (NETBOX_TOKEN, TECHNITIUM_TOKEN) kommen als Umgebungsvariablen aus Semaphore. Hostnamen, Zone und VRF müssen an die eigene Umgebung angepasst werden. Die Hilfsdatei tasks/dhcp-replace-one.yml muss relativ zum DHCP-Playbook im Unterordner tasks liegen.

dns-sync-standort-a.yml

---
# dns-sync-standort-a.yml
# NetBox (netbox-plugin-dns) -> Technitium, nur A-Records in der VRF Standort-A.
# Ersetzt push-dns-standort-a.yml. Schreibt NUR Abweichungen.
#
# Modi (ein Playbook, ein Semaphore-Template):
#   Event     (NetBox-Webhook): -e record_id=123 -e nb_event=updated -e old_fqdn=... -e old_value=...
#             -> gleicht ALLE Records der betroffenen Namen ab (neu + alt), nicht nur einen.
#   Reconcile (Schedule, ohne Extra-Vars): gleicht die ganze Zone ab.
#
# Vergleich ueber Schluessel fqdn|ip. Neue Records mit overwrite=false (mehrere IPs
# pro Name bleiben erhalten), danach werden ueberzaehlige verwaltete Records geloescht.
# TTL-Aenderungen an bestehenden Records werden NICHT nachgezogen.
#
# WICHTIG: Nicht mit der Semaphore-Dry-Run-Checkbox (Check-Mode) testen,
# sondern mit der Variable dry_run=true (Default).
- name: NetBox DNS -> Technitium (event / reconcile)
  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"
    zone_name: "standort-a.intern"
    # Identisch zum alten Playbook, damit bestehende Records als verwaltet erkannt werden.
    managed_comment: "Managed via NetBox (Ansible push-dns-standort-a)"
    dry_run: true
    max_deletes: 10
    record_id: ""
    nb_event: ""
    old_fqdn: ""
    old_value: ""
    mode: "{{ 'event' if (record_id | string | length > 0) else 'reconcile' }}"
  tasks:
    # ---------- NetBox: Prefixe + Soll-Records ----------
    - name: Ziel-VRF ermitteln
      ansible.builtin.set_fact:
        target_vrf: >-
          {{ query('netbox.netbox.nb_lookup', 'vrfs',
                    api_endpoint=netbox_api, token=netbox_token, validate_certs=false)
             | map(attribute='value') | 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: Aktive A-Records aus NetBox holen
      ansible.builtin.set_fact:
        nb_a_records: >-
          {{ query('netbox.netbox.nb_lookup', 'records',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dns', api_filter='status=active type=A',
                    validate_certs=false)
             | map(attribute='value') | selectattr('type', 'equalto', 'A') | list }}
        desired_all: []
        current_all: []
        to_add: []
        to_delete: []
    # IPs mit DHCP-Reservierung: deren A/PTR erzeugt Technitiums DHCP-Scope selbst
    # (aus dem hostName der Reservierung). Diese IPs werden hier auf Soll- UND Ist-Seite
    # komplett ignoriert, sonst entsteht wieder ein Tauziehen um denselben Record.
    - name: IPs mit DHCP-Reservierung aus NetBox holen
      ansible.builtin.set_fact:
        dhcp_reserved_ips: >-
          {{ query('netbox.netbox.nb_lookup', 'hostreservations',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dhcp', validate_certs=false)
             | map(attribute='value')
             | selectattr('ipv4_address', 'mapping')
             | map(attribute='ipv4_address.address')
             | map('ansible.utils.ipaddr', 'address') | list }}
    # Dynamische Leases (von SyncDynamicLeases mit Tag dhcp-dynamic angelegt):
    # deren DNS macht Technitium ebenfalls selbst -> genauso ignorieren.
    - name: Dynamische Lease-IPs aus NetBox holen und zur Ignorier-Liste hinzufuegen
      ansible.builtin.set_fact:
        dhcp_dynamic_ips: >-
          {{ query('netbox.netbox.nb_lookup', 'ip-addresses',
                    api_endpoint=netbox_api, token=netbox_token,
                    api_filter='tag=dhcp-dynamic vrf_id=' ~ target_vrf.id,
                    validate_certs=false)
             | map(attribute='value') | map(attribute='address')
             | map('ansible.utils.ipaddr', 'address') | list }}
    - name: Ignorier-Liste zusammenfuehren
      ansible.builtin.set_fact:
        dhcp_reserved_ips: "{{ (dhcp_reserved_ips + dhcp_dynamic_ips) | unique }}"
    - name: Soll-Records normalisieren (nur Ziel-VRF)
      ansible.builtin.set_fact:
        desired_all: >-
          {{ desired_all + [{
               'fqdn': item.fqdn | lower | regex_replace('[.]$', ''),
               'ip': item.value,
               'ttl': item.ttl | default(3600, true),
               'zone': item.zone.name | default(zone_name)
             }] }}
      loop: "{{ nb_a_records }}"
      loop_control:
        label: "{{ item.fqdn }}"
      when:
        - item.value is ansible.utils.in_one_network(target_prefixes)
        - item.value not in dhcp_reserved_ips
    # ---------- Event: betroffene Namen bestimmen ----------
    - name: "[event] Record aus NetBox laden (auch inaktiv, um den Namen zu kennen)"
      ansible.builtin.set_fact:
        # Bewusst ohne id=-Filter: nb_lookup macht daraus ein get() und stuerzt bei
        # unbekannter ID mit "NoneType not iterable" ab. Stattdessen alle Records holen
        # und lokal nach ID filtern -> unbekannte ID ergibt einfach eine leere Liste.
        ev_rec: >-
          {{ query('netbox.netbox.nb_lookup', 'records',
                    api_endpoint=netbox_api, token=netbox_token,
                    plugin='netbox-dns', validate_certs=false)
             | map(attribute='value')
             | selectattr('id', 'equalto', record_id | int) | list }}
      when: mode == 'event' and nb_event != 'deleted'
    - name: "[event] Betroffene Namen (neu + alt)"
      ansible.builtin.set_fact:
        scope_fqdns: >-
          {{ ((ev_rec | default([])) | map(attribute='fqdn') | list + [old_fqdn])
             | map('lower') | map('regex_replace', '[.]$', '')
             | reject('equalto', '') | unique | list }}
      when: mode == 'event'
    # ---------- Technitium: Ist-Zustand (laeuft auch im Check-Mode) ----------
    - name: Zone aus Technitium lesen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/get?token={{ technitium_token }}&domain={{ zone_name }}&zone={{ zone_name }}&listZone=true"
        method: GET
        validate_certs: false
      register: tt_zone
      check_mode: false
      no_log: true
    - name: Technitium-Antwort pruefen
      ansible.builtin.assert:
        that:
          - tt_zone.json is defined
          - tt_zone.json.status | default('') == 'ok'
          - tt_zone.json.response.records is defined
        fail_msg: "Technitium-Zone {{ zone_name }} konnte nicht gelesen werden - Abbruch, kein Diff ohne Ist-Zustand."
    - name: Verwaltete A-Records aus Technitium normalisieren
      ansible.builtin.set_fact:
        current_all: "{{ current_all + [{'fqdn': item.name | lower, 'ip': item.rData.ipAddress}] }}"
      loop: >-
        {{ tt_zone.json.response.records
           | selectattr('type', 'equalto', 'A')
           | selectattr('comments', 'defined')
           | selectattr('comments', 'equalto', managed_comment) | list }}
      loop_control:
        label: "{{ item.name }}"
      when: item.rData.ipAddress not in dhcp_reserved_ips
    # ---------- Diff (Event: nur betroffene Namen, Reconcile: alles) ----------
    - name: Auf Geltungsbereich einschraenken
      ansible.builtin.set_fact:
        desired: "{{ desired_all | selectattr('fqdn', 'in', scope_fqdns) | list if mode == 'event' else desired_all }}"
        current: "{{ current_all | selectattr('fqdn', 'in', scope_fqdns) | list if mode == 'event' else current_all }}"
    - name: Schluessel bilden (fqdn|ip)
      ansible.builtin.set_fact:
        desired_keys: "{{ desired | map(attribute='fqdn') | zip(desired | map(attribute='ip')) | map('join', '|') | list }}"
        current_keys: "{{ current | map(attribute='fqdn') | zip(current | map(attribute='ip')) | map('join', '|') | list }}"
    - name: Fehlende Records vormerken
      ansible.builtin.set_fact:
        to_add: "{{ to_add + [item] }}"
      loop: "{{ desired }}"
      loop_control:
        label: "{{ item.fqdn }}|{{ item.ip }}"
      when: (item.fqdn ~ '|' ~ item.ip) not in current_keys
    - name: Ueberzaehlige verwaltete Records vormerken
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [item] }}"
      loop: "{{ current }}"
      loop_control:
        label: "{{ item.fqdn }}|{{ item.ip }}"
      when: (item.fqdn ~ '|' ~ item.ip) not in desired_keys
    - name: "[reconcile] Schutz: NetBox leer oder zu viele Loeschungen"
      ansible.builtin.assert:
        that:
          - desired | length > 0
          - to_delete | length <= (max_deletes | int)
        fail_msg: >-
          Abbruch: {{ desired | length }} Soll-Records, {{ to_delete | length }} Loeschungen geplant
          (max_deletes={{ max_deletes }}).
      when: mode == 'reconcile'
    - name: Geplante Aenderungen
      ansible.builtin.debug:
        msg:
          mode: "{{ mode }}"
          scope: "{{ scope_fqdns | default('alle') }}"
          ignoriert_dhcp: "{{ dhcp_reserved_ips | length }} (davon dynamisch: {{ dhcp_dynamic_ips | length }})"
          soll: "{{ desired | length }}"
          ist_verwaltet: "{{ current | length }}"
          add: "{{ to_add | map(attribute='fqdn') | zip(to_add | map(attribute='ip')) | map('join', ' -> ') | list }}"
          delete: "{{ to_delete | map(attribute='fqdn') | zip(to_delete | map(attribute='ip')) | map('join', ' -> ') | list }}"
          dry_run: "{{ dry_run }}"
    # ---------- Ausfuehren: erst anlegen, dann loeschen (keine Luecke) ----------
    - name: Records anlegen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/add"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          domain: "{{ item.fqdn }}"
          zone: "{{ item.zone }}"
          type: "A"
          ttl: "{{ item.ttl }}"
          overwrite: "false"
          ipAddress: "{{ item.ip }}"
          ptr: "true"
          createPtrZone: "true"
          comments: "{{ managed_comment }}"
        validate_certs: false
      loop: "{{ to_add }}"
      loop_control:
        label: "{{ item.fqdn }} -> {{ item.ip }}"
      register: add_result
      failed_when: (add_result.json.status | default('error')) != 'ok'
      ignore_errors: true
      no_log: true
      when: not (dry_run | bool)
    - name: Records loeschen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/zones/records/delete"
        method: POST
        body_format: form-urlencoded
        body:
          token: "{{ technitium_token }}"
          domain: "{{ item.fqdn }}"
          zone: "{{ zone_name }}"
          type: "A"
          ipAddress: "{{ item.ip }}"
        validate_certs: false
      loop: "{{ to_delete }}"
      loop_control:
        label: "{{ item.fqdn }} ({{ item.ip }})"
      register: del_result
      failed_when: (del_result.json.status | default('error')) != 'ok'
      ignore_errors: true
      no_log: true
      when: not (dry_run | bool)
    - name: Fehler auflisten
      ansible.builtin.debug:
        msg: "FEHLER {{ item.item.fqdn }} ({{ item.item.ip }}): {{ item.json.errorMessage | default(item.msg | default('?')) }}"
      loop: "{{ (add_result.results | default([])) + (del_result.results | default([])) }}"
      loop_control:
        label: "{{ item.item.fqdn | default('?') }}"
      when:
        - not (dry_run | bool)
        - item.failed | default(false)

dhcp-sync-standort-a.yml

---
# dhcp-sync-standort-a.yml
# NetBox (netbox-plugin-dhcp, Host Reservations) -> Technitium-Scope "Standort-A".
# Ersetzt push-dhcp-standort-a.yml. Schreibt NUR Abweichungen.
#
# Modi (ein Playbook, ein Semaphore-Template):
#   Event     (NetBox-Webhook, pro Reservierung):
#             -e res_id=42 -e nb_event=updated -e mac=AA:BB:CC:DD:EE:FF
#   Reconcile (Semaphore-Schedule alle 30 min, ohne Extra-Vars): voller Diff.
#
# Aenderung einer bestehenden Reservierung: pro Eintrag Remove -> Add direkt
# hintereinander (tasks/dhcp-replace-one.yml); schlaegt das Add fehl, wird die
# alte Reservierung sofort wiederhergestellt.
#
# Von diesem Playbook angelegte Reservierungen tragen managed_marker im Kommentar.
# Nur solche werden im Reconcile als "verwaist" geloescht. Manuelle Reservierungen
# in Technitium bleiben unangetastet.
#
# Test: ansible-playbook -i inventory/netbox.yml dhcp-sync-standort-a.yml --limit dns-standort-a -e dry_run=true
- name: NetBox DHCP -> Technitium (event / reconcile)
  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: "Standort-A"
    managed_marker: "[netbox]"
    dry_run: true
    max_deletes: 5
    res_id: ""
    nb_event: ""
    mac: ""
    mode: "{{ 'event' if (res_id | string | length > 0) else 'reconcile' }}"
    mac_param: "{{ mac | replace(':', '-') | upper }}"
  tasks:
    # ---------- Prefixe der Ziel-VRF ----------
    - name: Ziel-VRF ermitteln
      ansible.builtin.set_fact:
        target_vrf: >-
          {{ query('netbox.netbox.nb_lookup', 'vrfs',
                    api_endpoint=netbox_api, token=netbox_token, validate_certs=false)
             | map(attribute='value') | 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 }}
    # ---------- Ist-Zustand Technitium ----------
    - name: Technitium-Scope lesen
      ansible.builtin.uri:
        url: "{{ technitium_url }}/api/dhcp/scopes/get?token={{ technitium_token }}&name={{ technitium_scope_name | urlencode }}"
        method: GET
        validate_certs: false
      register: tt_scope
      check_mode: false
      no_log: true
    - name: Technitium-Antwort pruefen
      ansible.builtin.assert:
        that:
          - tt_scope.json is defined
          - tt_scope.json.status | default('') == 'ok'
          - tt_scope.json.response is defined
        fail_msg: "Technitium-Scope {{ technitium_scope_name }} konnte nicht gelesen werden - Abbruch."
    - name: Ist-Reservierungen indexieren
      ansible.builtin.set_fact:
        current_leases: "{{ tt_scope.json.response.reservedLeases | default([]) }}"
        current_by_mac: >-
          {{ dict((tt_scope.json.response.reservedLeases | default([])) | map(attribute='hardwareAddress') | map('upper')
                  | zip(tt_scope.json.response.reservedLeases | default([]))) }}
        desired: []
        to_add: []
        to_change: []
        to_delete: []
        unmanaged_conflicts: []
    # ---------- Soll-Zustand NetBox ----------
    # Immer alle holen und lokal nach ID filtern (nb_lookup mit id=-Filter stuerzt
    # bei unbekannter ID ab).
    - name: Host Reservations aus NetBox holen
      ansible.builtin.set_fact:
        nb_res_all: >-
          {{ query('netbox.netbox.nb_lookup', 'hostreservations',
                    api_endpoint=netbox_api, token=netbox_token, plugin='netbox-dhcp',
                    validate_certs=false)
             | map(attribute='value') | list }}
    - name: Auf Geltungsbereich einschraenken (Event = nur res_id, Reconcile = alle)
      ansible.builtin.set_fact:
        nb_res: >-
          {{ [] if (mode == 'event' and nb_event == 'deleted')
             else (nb_res_all | selectattr('id', 'equalto', res_id | int) | list
                   if mode == 'event' else nb_res_all) }}
    - name: Soll-Reservierungen im Technitium-Format aufbauen (nur Ziel-VRF, nur mit MAC)
      ansible.builtin.set_fact:
        desired: >-
          {{ desired + [{
               'mac': item.hw_address.mac_address | replace(':', '-') | upper,
               'address': item.ipv4_address.address | ansible.utils.ipaddr('address'),
               'hostName': item.hostname,
               'comments': managed_marker ~ ((' ' ~ item.comments) if (item.comments | default('', true)) else '')
             }] }}
      loop: "{{ nb_res }}"
      loop_control:
        label: "{{ item.hostname | default(item.id) }}"
      when:
        - item.ipv4_address is mapping
        - item.ipv4_address.address is defined
        - item.hw_address is mapping
        - item.hw_address.mac_address is defined
        - (item.ipv4_address.address | ansible.utils.ipaddr('address')) is ansible.utils.in_one_network(target_prefixes)
    - name: Reservierungen ohne MAC/IP warnen (werden nicht gepusht)
      ansible.builtin.debug:
        msg: "WARNUNG: {{ item.hostname | default(item.id) }} hat keine aufloesbare MAC oder IP"
      loop: "{{ nb_res }}"
      loop_control:
        label: "{{ item.hostname | default(item.id) }}"
      when: not (item.hw_address is mapping and item.hw_address.mac_address is defined
                 and item.ipv4_address is mapping and item.ipv4_address.address is defined)
    - name: Hilfsindizes
      ansible.builtin.set_fact:
        desired_macs: "{{ desired | map(attribute='mac') | list }}"
        desired_mac_by_ip: "{{ dict(desired | map(attribute='address') | zip(desired | map(attribute='mac'))) }}"
    # ---------- Aktionsplan ----------
    - name: Neu
      ansible.builtin.set_fact:
        to_add: "{{ desired | rejectattr('mac', 'in', current_by_mac.keys() | list) | list }}"
    - name: Geaendert (IP oder Hostname weicht ab; Kommentar wird bewusst ignoriert)
      ansible.builtin.set_fact:
        to_change: "{{ to_change + [{'target': item, 'existing': current_by_mac[item.mac]}] }}"
      loop: "{{ desired }}"
      loop_control:
        label: "{{ item.hostName }}"
      when:
        - item.mac in current_by_mac
        - (current_by_mac[item.mac].address != item.address)
          or ((current_by_mac[item.mac].hostName | default('', true) | lower) != (item.hostName | lower))
    - name: IP-Konflikte (andere MAC haelt die Soll-IP) - verwaltete werden entfernt
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [item] if managed else to_delete }}"
        unmanaged_conflicts: "{{ unmanaged_conflicts if managed else unmanaged_conflicts + [item] }}"
      loop: "{{ current_leases }}"
      loop_control:
        label: "{{ item.hostName | default('') }} {{ item.address }}"
      vars:
        managed: "{{ (item.comments | default('', true)).startswith(managed_marker) }}"
      when:
        - item.address in desired_mac_by_ip
        - desired_mac_by_ip[item.address] != (item.hardwareAddress | upper)
    - name: "[event] Geloeschte / nicht mehr gueltige Reservierung entfernen"
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [current_by_mac[mac_param]] }}"
      when:
        - mode == 'event'
        - mac_param | length > 0
        - mac_param in current_by_mac
        - mac_param not in desired_macs
    - name: "[reconcile] Verwaiste verwaltete Reservierungen entfernen"
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete + [item] }}"
      loop: "{{ current_leases }}"
      loop_control:
        label: "{{ item.hostName | default('') }} {{ item.address }}"
      when:
        - mode == 'reconcile'
        - (item.comments | default('', true)).startswith(managed_marker)
        - (item.hardwareAddress | upper) not in desired_macs
    - name: Doppelte Loeschungen entfernen
      ansible.builtin.set_fact:
        to_delete: "{{ to_delete | unique }}"
    - name: "[reconcile] Schutz: NetBox leer oder zu viele Loeschungen"
      ansible.builtin.assert:
        that:
          - desired | length > 0
          - to_delete | length <= (max_deletes | int)
        fail_msg: >-
          Abbruch: {{ desired | length }} Soll-Reservierungen, {{ to_delete | length }} Loeschungen geplant
          (max_deletes={{ max_deletes }}).
      when: mode == 'reconcile'
    - name: Aktionsplan
      ansible.builtin.debug:
        msg:
          mode: "{{ mode }}"
          neu: "{{ to_add | map(attribute='hostName') | list }}"
          aendern: "{{ to_change | map(attribute='target') | map(attribute='hostName') | list }}"
          loeschen: "{{ to_delete | map(attribute='hostName', default='?') | list }}"
          konflikt_unverwaltet: "{{ unmanaged_conflicts | map(attribute='address') | list }}"
          dry_run: "{{ dry_run }}"
    - name: Details zu Aenderungen (alt -> neu)
      ansible.builtin.debug:
        msg: "{{ item.target.mac }}: {{ item.existing.hostName | default('') }}/{{ item.existing.address }} -> {{ item.target.hostName }}/{{ item.target.address }}"
      loop: "{{ to_change }}"
      loop_control:
        label: "{{ item.target.hostName }}"
    - name: WARNUNG unverwaltete Konflikte (Soll-IP von manueller Reservierung belegt, Add wird fehlschlagen)
      ansible.builtin.debug:
        msg: "{{ item.address }} ist in Technitium manuell fuer {{ item.hardwareAddress }} reserviert"
      loop: "{{ unmanaged_conflicts }}"
      loop_control:
        label: "{{ item.address }}"
    # ---------- Ausfuehren: loeschen -> neu -> aendern ----------
    - name: Reservierungen loeschen
      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.hardwareAddress }}"
        validate_certs: false
      loop: "{{ to_delete }}"
      loop_control:
        label: "{{ item.hostName | default('') }} {{ item.address }}"
      register: del_result
      failed_when: (del_result.json.status | default('error')) != 'ok'
      no_log: true
      when: not (dry_run | bool)
    - name: Neue Reservierungen anlegen
      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.address }}"
          hostName: "{{ item.hostName }}"
          comments: "{{ item.comments }}"
        validate_certs: false
      loop: "{{ to_add }}"
      loop_control:
        label: "{{ item.hostName }}"
      register: add_result
      failed_when: (add_result.json.status | default('error')) != 'ok'
      ignore_errors: true
      no_log: true
      when: not (dry_run | bool)
    - name: Geaenderte Reservierungen ersetzen (pro Eintrag Remove -> Add, mit Rollback)
      ansible.builtin.include_tasks: tasks/dhcp-replace-one.yml
      loop: "{{ to_change }}"
      loop_control:
        loop_var: chg
        label: "{{ chg.target.hostName }}"
      when: not (dry_run | bool)

tasks/dhcp-replace-one.yml

---
# tasks/dhcp-replace-one.yml
# Ersetzt GENAU EINE Reservierung: Remove -> Add direkt hintereinander.
# Schlaegt das Add fehl, wird die alte Reservierung wiederhergestellt.
# Schlaegt auch der Rollback fehl, wird das deutlich im Log gemeldet.
# Erwartet loop_var "chg" mit chg.target (Soll) und chg.existing (Ist aus Technitium).
- name: "{{ chg.target.hostName }}: alte Reservierung entfernen"
  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: "{{ chg.existing.hardwareAddress }}"
    validate_certs: false
  register: rm_one
  failed_when: (rm_one.json.status | default('error')) != 'ok'
  no_log: true
- name: "{{ chg.target.hostName }}: neue Reservierung anlegen"
  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: "{{ chg.target.mac }}"
      ipAddress: "{{ chg.target.address }}"
      hostName: "{{ chg.target.hostName }}"
      comments: "{{ chg.target.comments }}"
    validate_certs: false
  register: add_one
  failed_when: false
  no_log: true
- name: "{{ chg.target.hostName }}: ROLLBACK - alte Reservierung wiederherstellen"
  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: "{{ chg.existing.hardwareAddress }}"
      ipAddress: "{{ chg.existing.address }}"
      hostName: "{{ chg.existing.hostName | default('') }}"
      comments: "{{ chg.existing.comments | default('') }}"
    validate_certs: false
  register: rb_one
  failed_when: false
  no_log: true
  when: (add_one.json.status | default('error')) != 'ok'
- name: "{{ chg.target.hostName }}: Fehler melden"
  ansible.builtin.fail:
    msg: >-
      Add fehlgeschlagen ({{ add_one.json.errorMessage | default('?') }}).
      {{ 'Alte Reservierung wiederhergestellt.'
         if (rb_one.json.status | default('error')) == 'ok'
         else 'ACHTUNG: Rollback ebenfalls fehlgeschlagen ('
              ~ (rb_one.json.errorMessage | default('?'))
              ~ ') - Reservierung fehlt in Technitium!' }}
  when: (add_one.json.status | default('error')) != 'ok'
  ignore_errors: true

Danke, Claude, ich übernehme ab hier wieder. Ich habe den Artikel noch von Mistral prüfen lassen. Das Fazit von Mistral:
Fazit
Die Lösung ist architektonisch richtig und für ein Homelab/Small-Business-Setup überdurchschnittlich reif. Kernstärken: klare Ownership-Trennung über Marker und Ignorier-Listen (behebt die eigentliche Ursache, nicht das Symptom), echtes Diff-before-write mit Ist-Zustands-Assert, Event + Reconcile-Fallback, Rollback pro Reservierung, fail-safe Defaults. Der Code ist lesbar, gut kommentiert und die Design-Entscheidungen sind einschließlich ihrer Grenzen dokumentiert – das sieht man selten.
Handlungsbedarf in dieser Reihenfolge:
Token aus der URL (Basic-Auth) und item.msg aus den Fehlermeldungen entfernen → schließt den realistischen Leak-Pfad.
Fails aggregation: Fehler am Laufende zählen, Lauf failen lassen, DHCP-Playbook um „Fehler auflisten“ ergänzen.
Semaphore-Parallelität auf 1 (serialisiert Events und Reconcile).
Zertifikats-Validierung aktivieren und Webhook auf HTTPS – dann ist die Transportebene dicht.
NetBox-Token auf read-only reduzieren.
Kleinigkeiten: item.zone auch beim Delete, max_deletes auch im Event-Modus, MAC-Änderungs-Event um prechange.hw_address ergänzen, PTR-Leichen einmalig prüfen.
Mit Punkten 1–3 (unter zwei Stunden Arbeit) ist das Setup meines Erachtens auch für ein kleines produktives Umfeld vertretbar – die verbleibende Abhängigkeit vom Beta-Status von netbox-plugin-dhcp (deine eigene, berechtigte Einschränkung) einmal ausgenommen.
Soll ich dir die konkreten Patch-Snippets für Punkt 1–2 (Basic-Auth-Umstellung + Fehler-Aggregations-Task) ausformulieren?

Mistral hat grade beim Coden in den letzten Monaten enorm aufgeholt. Der Punkt mit dem Token in der URL ist in einem produktiven Umfeld durchaus problematisch. In den Semaphore-Logs taucht er dank no_log zwar nicht auf, wohl aber potenziell in Access-Logs von Technitium oder einem Proxy. Bei mir zu Hause im Homelab bin ich da momentan jedoch ganz entspannt. Ich werde das bei Gelegenheit fixen und die Lösung in einem neuen Artikel posten.

Freitag, Oktober 23, 2026

Lokale KI auf Aoostar WTR Max in Verbindung mit einer externen GPU via Aoostar AG03 Dockingstation (Oculink) TBC…

Nachtrag 29.10.26: Die Nvidiakarte am AG03 wird tatsächlich am WTR Max erkannt, wenn man ein Oculinkkabel verwendet 😁

Bevor ich mich weiter mit dem Thema Vane beschäftigen kann, muss zuerst die Hardware richtig funktionieren. Leider war der Start etwas holpriger als gedacht. Aktuell läuft Vane mit 24 GB (abgeknapst vom RAM), beschleunigt durch die iGPU des WTR Max. Das hat den entscheidenden Vorteil, dass ich auch größere Sprachmodelle in den RAM laden kann, da ich so deutlich mehr RAM der iGPU und damit ollama zuweisen kann. Nachteil ist das Tempo, denn der normale RAM ist wesentlich langsamer als der VRAM einer Grafikkarte. Die NVIDIA 1650 habe ich nur zu Bastelzwecken aufgehoben, sie hat lediglich 4 GB VRAM. Der VRAM der NVIDIA-Karte ist zwar doppelt so schnell wie der normale RAM, dieser Vorteil ist aber sofort dahin, sobald ein Sprachmodell geladen wird, das größer als 4 GB ist und neben dem VRAM auch in den normalen RAM ausgelagert werden muss. In diesem Fall rechnet die CPU die Teile des Modells, die nicht mehr in den VRAM passen, und das Gesamttempo sinkt Richtung CPU-only. Eine Kombination aus iGPU und NVIDIA-Karte unterstützt ollama dabei praktisch nicht. Für Modelle, die komplett in 4 GB passen, wäre die 1650 zwar schneller, für die größeren Modelle, um die es mir geht, ist die iGPU unterm Strich aber vermutlich die bessere Wahl als die Dockingstation mit der ollen 1650er. Ich wollte mit der alten Karte sowieso nur einen PoC machen, bevor ich mehr Geld in das Thema stecke. Ohne iGPU, also CPU-only, komme ich bei ollama derzeit auf ca. 10 Token/s, mit zugeschalteter iGPU auf ca. 30 bis 35 Token/s (gemessen mit qwen3:30b-a3b). Das ist aber leider immer noch quälend langsam für KI-Anwendungen. Gerade bei Vane bremst dabei weniger die eigentliche Textgenerierung als vielmehr das Einlesen der Suchergebnisse, also die Verarbeitung langer Prompts.

Sollte hier jemand mitlesen, warne ich außerdem dringend davor, die AG03 per USB4 am WTR Max betreiben zu wollen. In der Anleitung der AG03 wird explizit erwähnt, dass die Dockingstation nicht mit dem WTR Max kompatibel ist! Ich habe mir allerdings gedacht, dass die Anleitung auch explizit von Windows 11 und Thunderbolt 4/5 bzw. USB4 spricht. OCuLink ist dagegen ein sehr hardwarenahes Durchschleifen eines PCIe-Ports (man sollte daher auch keinesfalls im laufenden Betrieb das OCuLink-Kabel abziehen oder anschließen!). Ich gehe daher weiterhin davon aus, dass eine OCuLink-Verbindung die externe GPU in Proxmox bringen dürfte (als echtes PCIe-Device). Mit dem beigelegten USB4-Kabel an den beiden USB-C-Ports des WTR Max wird jedenfalls nur die Dockingstation erkannt, nicht aber die 1650er. Das bedeutet, dass die Dockingstation aktuell (und vermutlich auch in Zukunft) nicht via USB an das WTR Max angeschlossen werden kann. Das war aber ohnehin nie mein Plan, denn die Anbindung via OCuLink ist schneller und weniger fehleranfällig als mit einer Thunderbolt-Zwischenschicht. Leider liegt der Dockingstation kein OCuLink-Kabel bei, und ich habe schlicht übersehen, dass kein solches Kabel dabei ist. Ein passendes Kabel kostet ca. 25 € und kommt nicht mehr vor nächster Woche. Ich lasse den Artikel daher vorerst so, wie er ist, und ergänze ihn, sobald das Kabel da ist und ich ganz sicher weiß, dass man mit dieser Dockingstation eine eGPU an das WTR Max durchreichen kann.
Ich habe noch folgenden Eintrag bei den Q&A auf der Aoostar Homepage gefunden

Bildschirmfoto_20261023_232321.png

das bestärkt mich in meiner Vermutung, dass sich eine eGPU via OcuLink durchreichen lassen sollte ;) (Ja klappt, siehe folgende Screenshots)

Bildschirmfoto_20261029_211209.png
IMG_4455.jpg

Mittwoch, Oktober 21, 2026

Proxmox auf AOOSTAR WTR MAX mit externer GraKa über Oculink, z.B. für Internetrecherche mit lokaler KI

Info: Der Artikel wurde am bereits am 21.10.26 angelegt, aber erst am 23.10.26 veröffentlicht. Man kann das Datum bei Flatpress nachträglich leider nicht ohne Weiteres ändern.

Mein Voronprojekt muss leider noch etwas warten. Ich bin derzeit noch mit dem Inhaber von meltbro.de in Kontakt wegen einiger Zusätze, daher wurde mein Bausatz noch nicht versendet. Ich kann den Laden weiterhin nur sehr empfehlen, der Inhaber ist ein supernetter Typ, der sich speziell mit den Voron Druckern perfekt auskennt. Besser hätte ich es nicht treffen können. Er scheint nahezu alles alleine zu machen, daher sollte man etwas Geduld mitbringen. Das stört mich allerdings überhaupt nicht, dafür bekomme ich einen maßgeschneiderten “bleeding edge 3D-Drucker”. Der Tisch ist eh noch nicht ganz grade und mit dem Prusa kann ich ja weiterhin drucken, wenngleich im Schneckentempo (verglichen mit dem Voron).

IMG_4386.jpg

Parallel habe ich mir für meinen Betrieb ein weiteres NAS angeschafft. In diesem Fall ein AOOSTAR WTR MAX Ryzen 7 PRO. Es soll eine lokale KI hosten und als zusätzliches Backupziel für PBS dienen. Das Gerät verfügt über einen Oculink Port, für diesen habe ich zusätzlich eine AOOSTAR AG03 eGPU Dockstation bestellt. Mit einer potenten Grafikkarte sollte eine lokale KI (nicht SI und auch nicht Lake America) ganz gut darauf laufen. Als Software Unterbau soll wieder das gut eingespielte Team Proxmox/Truenas VM dienen. Um, meine lokale KI anzutreiben werde ich vorerst zu Testzwecken eine uralte Nvidia 1650 nutzen. Der RAM ist derzeit derart teuer, dass ich mich zähneknirschend für 2×24GB DDR5 entschieden habe, 2×32GB waren mir einfach viiiiel zu teuer. Als HDDs habe ich wieder 2 TB Hitachi bei Amazon genommen, das Stück für 79,- die werden per PCI Passthrough an die Truenas VM durchgereicht. Ich habe beim letzten NAS ebenfalls diese Platten gekauft und obwohl die Platten laut Aufkleber bereits viele Jahre alt sind, handelt es sich erneut um Lagerware, die noch keine Sekunde gelaufen ist. Für den Promox Host nehme ich eine 500 GB SSD NVME. Zusätzlich 2×1TB für den ZFS Mirror der die LXCs und VMs beherbergen wird.

IMG_4389.jpg

Über den physischen Zusammenbau gibt es nicht viel zu sagen, erwähnenswert ist jedoch, dass man beim Abheben des Bodenblechs unbedingt vorsichtig walten lassen sollte, damit man das Lüfterkabel nicht abreißt. Das wäre mit beinahe passiert.

IMG_4393.jpg

Im Bios habe ich TPM und Secureboot deaktiviert und die Option nach einem Stromausfall immer automatisch hochzufahren aktiviert. Die übrigen Einstellungen lasse ich alle auf “Auto”.

IMG_4406.jpg
IMG_4410.jpg

Da bei mir kein ECC RAM (ECC wird explizit unterstützt!) verbaut ist, habe ich sicherheitshalber vorab erfolgreich Memtest86 durchlaufen lassen. Der Speicher scheint in Ordnung zu sein.

IMG_4417.jpg

Die Proxmox Installation läuft problemlos durch, die 500 GB NVME habe ich (wie immer) mit ext4 partitioniert und aus den beiden 1 TB NVMEs habe ich über die Proxmox GUI einen ZFS Mirror für die LXCs und VMs erstellt. Vorher habe ich noch GPT bei den beiden NVMEs aktiviert, braucht man vermutlich nicht, ich mache das aber trotzdem so gut wie immer.
Jetzt kommt der coole Teil 😃 Bei den Community Scripts gibt es seit Kurzem ein Testing Paket namens Vane. Vane ist der Nachfolger von Perplexica, einer lokalen! KI Internetrecherche ähnlich wie bei Google und Co. Die Suche läuft im Hintergrund über searxng, eine Cloud KI oder eine lokale KI über ollama wertet die Suche aus und generiert eine passende Antwort. Das Paket ist derzeit als “testing” markiert und ich habe mich derzeit scheinbar als Einziger für Tests gemeldet. (Natürlich nicht, hätte mich auch gewundert. Die Kommunikation läuft bei GitHub zusammen nicht bei Discord) Mal schauen wie sich das noch entwickelt. Ich werde Vane in einem Proxmox Setup einen eigenen Artikel widmen, da das aus meiner Sicht der absolute Knaller ist 😎 Vorher muss ich das aber erst noch ausführlich testen und optimieren, Aktuell läuft Vane (genauer gesagt ollama) bei mir noch zu 100% über die CPU

Bildschirmfoto_20261023_111427.png

Der Screenshot ist kurz nach Installation aufgenommen, ollama hat aktuell 12 Kerne zugeteilt bekommen und 28GB RAM, andernfalls würde da nichts passieren. Die iGPU scheint (noch) nicht korrekt durchgereicht zu werden und DHL hat die GPU Dockingstation noch nicht geliefert (sollte hoffentlich heute ankommen). Eine einfache Suche, die den Internetzugriff testet, hat aber bereits funktioniert 😁

Bildschirmfoto_20261023_133202.png
Bildschirmfoto_20261023_133226.png

nachdem ich ein bisschen mit den Komponenten herumgespielt habe funktioniert nun auch die Abfrage über meine separate searxng Instanz (mehr zur Einrichtung folgt im Artikel zu Vane). Ich habe eine wiederkehrende Scherzfrage, mit der ich KI Recherchen gerne teste. Es geht um eine Anekdote über Helmut Kohl. Viele KIs in der Cloud sträuben/weigern sich darüber Auskunft zu erteilen oder scheitern sogar daran. Es ist übrigens kein Gerücht, die Geschichte wurde immerhin von Göran Persson, dem ehemaligen PM von Schweden kolportiert ;)

Bildschirmfoto_20261023_151155.png
Bildschirmfoto_20261023_151404.png

Sonntag, Oktober 11, 2026

DIY - Voron 2.4 Bestellung und Vorarbeiten für den Tisch

Ich habe den halben Samstag damit verplempert die Einzelteile des Voron bei einem Schraubenversender, Dold, Amazon etc. zusammenzuklicken. Vorab mein Fazit: Das lohnt sich kaum, aufgrund des erheblichen Zeitinvests für die Suche nach den Teilen! Es gibt diverse Onlineshops die Bausätze anbieten. Ich habe mich für einen Shop mit Sitz in Bonn entschieden. Dieser Shop betreibt auch einen Discordkanal und ich werde dort vermutlich sogar Hilfe erhalten, sollte dies notwendig werden. Die Preise sind außerdem, zumindest nach meiner Einschätzung, sehr fair, das Filament ist hier ebenfalls billiger als bei Prusa. Das Beste an dem Shop ist aber definitiv der Name 😉 –> Meltbro.de

Aktuell steht mein Prusa Drucker auf einem Ikea Lack Tisch. Da ich immer älter werde und ich keine Lust habe jedes Mal auf die Knie zu gehen, werde ich heute einen höheren Tisch für die beiden Drucker bauen. Das Ergebnis poste ich dann bildlich hier. Die Tischplatte (Spanplatte), die ich verwenden werde steht aktuell auf 4 Metallfüßen und hängt daher maximal durch. Ich muss also für eine plane Fläche einen Rahmen aus Balken bauen und die Rahmenteile irgendwie miteinander verbinden. Hoffentlich lässt sich die Tischplatte so wieder zurückbiegen, darauf wäre genug Platz für beide Drucker, die dann zusätzlich in einer ergonomischeren Höhe wären.

Ich habe mich während des Bauprozesses des Rahmens, für eine Teilverschraubung der Balken entschieden. Ich bin eigentlich ein Fan von Leim und Holzdübeln, allerdings möchte ich den Rahmen zerlegbar haben, sonst kriege ich ihn später ggf. nicht mehr durch die Tür oder das Treppenhaus.

Natürlich habe ich beim Bauen vergessen die Klötzchen, die für die Verschraubung in den Rahmen eingeleimt werden müssen, ebenfalls abzurichten und zu hobeln. Ich musste das Abricht/Hobelsetup nochmal aufbauen, was leider sehr viel Zeit gefressen hat und so der komplette Nachmittag für das Projekt draufgegangen ist.

IMG_4349.jpg

Ablängen

IMG_4350.jpg

Grob zuschneiden

IMG_4351.jpg
IMG_4352.jpg

Mit dem “Finger Remover” an zwei Seiten rechteckig machen, dann die Abrichte in einen Dickenhobel umbauen und die anderen beiden Seiten bei allen Bauteilen auf die gleiche Höhe bringen. Da die Balken aus dem Baumarkt nur grade aussehen, habe ich beim Hobeln, wie immer, ziemlich viel Material verloren. Ursprünglich hatten die Balken 7,4 cm x 5,4 cm, am Ende bin ich bei 6,9 cm x 5,1 cm rausgekommen

IMG_4355.jpg

Für die Schrauben passende Klötzchen anleimen

IMG_4356.jpg
IMG_4357.jpg

Die Tischplatte hängt massiv durch, sowohl die Füße als auch die Platte sind von Ikea. Ich meine, dass das so vorgesehen war, in diesem Fall einfach nur Crap!

IMG_4358.jpg
IMG_4359.jpg
IMG_4360.jpg
IMG_4361.jpg

Irgendwann steht das Ding jedenfalls, das präzise Arbeiten hat sich gelohnt, wie man an den beiden Wasserwaagen sieht. Die Höhe erscheint mir ebenfalls sehr gut, mein Rücken wird es mir in Zukunft vermutlich danken.

IMG_4362.jpg

Ich habe allerlei Krempel als Gewicht auf die Plattenenden gelegt. Ich hoffe, dass dieser kleine Spin im Laufe der nächsten Tage wieder verschwindet. Ich würde gerne an diesem, Tisch den Voron aufbauen und ein permanentes Wegrollen der Schrauben wird mich vermutlich in den Wahnsinn treiben. Evtl. muss ich den Rückbiegeprozess mit ein paar Klemmen beschleunigen, jetzt lasse ich es erst mal so.

Donnerstag, Oktober 8, 2026

DIY Bau eines 3D Druckers - Voron 2.4 - Vorüberlegungen

Ich habe bereits seit einigen Jahren einen 3D-Drucker von Prusa. Der Drucker (MKS3+) gilt als robustes Arbeitspferd, ist aber technisch inzwischen etwas in die Jahre gekommen.

IMG_4347.jpg

Die Druckqualität und das Tempo ließen sich durch ein Upgrade mit hochauflösenden NEMA Steppermotoren noch etwas verbessern, aber grundsätzlich ist bei dieser Modellreihe irgendwann Schluss. Außerdem bleibt der Druckraum bei maximal 25 x 21 x 22 cm begrenzt, neue Motoren hin oder her. Obwohl ich Prusa als Unternehmen mit Sitz in Europa (nähe Prag, schöne Stadt, nebenbei bemerkt!) sehr schätze, ist mir das Highendsegment mit einem größeren Druckraum für meine Hobbyanwendungen einfach zu teuer. Ein fertiger Drucker aus China wäre zwar dtl. billiger als ein fertiger Drucker von Prusa, kommt aber wegen nachträglich eingeführtem Cloud(Klau)zwang und möglichem/wahrscheinlichem Diebstahl geistigen Eigentums nicht infrage. Drucker von Bambu Lab stechen hier besonders negativ hervor.

Zum Glück gibts da eine Opensource Alternative, namens Voron. Die Baupläne werden von Hobbyisten gepflegt und weiterentwickelt, die Einzel- und Ersatzteile sind überall gut verfügbar und werden es auch in Zukunft bleiben und der Preis ist bei so einem Eigenbau aus Standardkomponenten natürlich unschlagbar. Zusätzlich belegen Vorondrucker bei Tests regelmäßig Spitzenplätze und halten derzeit (Stand meines Wissens) auch den Geschwindigkeitsrekord beim FDM 3D-Druck. So schnell wie in dem Video (inzwischen schaffen manche Enthusiasten den Benchy in unter 1 Minute!!!) muss es gar nicht sein mir würde die 1/5-1/10 der Geschwindigkeit (je nach Projekt) bereits ausreichen. Bei so einem Drucker ist man natürlich auch zu 100% auf sich alleine gestellt, da es keine Firma gibt, deren Support man mit seinen Fragen terrorisieren könnte.

Es stehen mehrere Modelle bei Voron zur Auswahl und ich dachte mir, wenn ich den Aufwand schon betreibe, dann lasse ich mir natürlich das Deluxe Modell Voron 2.4 mit 35×35x35cm Bauraum raus ;) Praktischerweise gibt es auf der Homepage von Voron einen Konfigurator der, je nach Größe, eine passende Einkaufsliste (inklusive möglicher Bezugsquellen) generiert. Die Einkaufsvorschläge für die Komponenten sind für Deutschland meist nicht optimal, daher werde ich hier in einem der nächsten Beiträge u.a. festhalten, wo ich welche Komponenten gekauft habe. Ich stehe mit keinem der Läden in Kontakt oder werde hierfür bezahlt. Ich habe auch keine der Einzelteile umsonst bekommen. Es ist daher auch nicht unbedingt als Empfehlung zu verstehen, sondern dient primär mir als Gedächtnisstütze. Falls es manche Einzelteile woanders billiger gibt, wäre daher ein Hinweis sehr nett. Theoretisch gib es bei der Größe kein Limit, wie bereits der großartige Ivan Miranda gezeigt hat. Die KI Claude kalkuliert für das Projekt mit 35×35x35cm ca. 1100-1900 € und ich hoffe, dass ich mich eher im unteren Bereich bewegen werde.
Ich habe mir vor ein paar Jahren mal eine kleine CNC für einen Dremel gebaut. Das hat anfangs sogar funktioniert… allerdings habe ich beim Abrichten einer Holzplatte nicht bedacht, dass der Dremel vielleicht durch Überforderung zu brennen beginnen könnte.

IMG_4345.jpg

Der Dremel hat sich schließlich bei der Hälfte des Jobs mit einem Rauchwölkchen verabschiedet und ich habe das CNC-Gerät seitdem nicht mehr angefasst. Für eine größere Spindel ist das Ding einfach viel zu windig, für einen Laser nicht präzise genug und die meiste Freude hatte ich eh durch den Lerneffekt beim Zusammenbau. Die Reste kann ich nun gut für den Voron verwerten. Damit ich ein Andenken an den CNC Bau nach den Plänen von Nikodem Bartnik behalte, lasse ich das Foto in diesem Beitrag stehen.