← Toutes les notes
Rangée de baies de datacenter éclairées en bleu, chacune portant un panneau lumineux avec les logos NetBox, Technitium, Kea et Proxmox
Note

Une seule source de vérité pour le DNS, le DHCP et l’IPAM

Un POC DDI auto-hébergé et reproductible où NetBox pilote Technitium DNS, Kea DHCP et le SDN Proxmox VE via des flows Windmill événementiels. Stack Compose et configuration complètes.

Tous les labs que j’ai administrés ont fini par attraper la même maladie : une adresse IP qui vit à quatre endroits à la fois. Un enregistrement DNS, une réservation DHCP, un VLAN sur l’hôte Proxmox, et un tableur qui a cessé d’être juste il y a des mois. Rien ne casse tant qu’on ne supprime pas une machine, et là trois de ces quatre endroits continuent de pointer dessus.

Cette note est le compte rendu complet de la solution retenue : une seule source de vérité, et tout le reste généré à partir d’elle. Le tout auto-hébergé, sans rien qui sorte de la baie. Ce qui suit suffit à reconstruire le POC depuis un dossier vide.

Les adresses, zones et noms d’hôtes sont inventés. Remplacez-les par les vôtres, et ne recopiez jamais les secrets donnés en exemple.

Sommaire

Architecture

  source de vérité             orchestrateur           état appliqué
 ┌────────────────────┐     ┌──────────────────┐     ┌────────────────────┐
 │      NetBox        │     │    Windmill      │ ──▶ │  Technitium DNS    │
 │                    │     │                  │     ├────────────────────┤
 │ préfixes [dhcp]    │ ──▶ │ flow : sync DNS  │ ──▶ │  Kea DHCP4         │
 │ IP (dns_name)      │     │ flow : sync DHCP │     ├────────────────────┤
 │ IP (mac_address)   │     │ flow : sync SDN  │ ──▶ │  SDN Proxmox VE    │
 │ VLANs              │     │                  │     │  (un VNet par VLAN)│
 └────────────────────┘     └──────────────────┘     └────────────────────┘
        ▲                     ▲              ▲
        │                     │              │
     humains        webhook au changement    réconciliation horaire
RôleCe qui le fait tournerPourquoi celui-là
IPAMNetBoxVrai modèle de données, API solide, changelog par objet
OrchestrationWindmillFlows en étapes Python versionnées, plus une UI
DNSTechnitiumAPI HTTP complète, gestion des zones saine, léger
DHCPKeaConfiguration JSON, rechargeable via un socket de contrôle
Réseau overlaySDN Proxmox VELes VLANs sont déjà dans NetBox, autant qu’ils s’alignent

Trois réseaux Docker, et le découpage compte : netbox-net porte NetBox avec son Postgres et son Redis, ddns-net porte le DNS, le DHCP et Proxmox, windmill-net porte Windmill et sa propre base. Windmill est le seul conteneur raccordé aux trois, donc le seul à pouvoir parler à la fois à la source de vérité et aux cibles.

Les ports 53 et 67 ne sont volontairement pas publiés sur l’hôte. Le résolveur et le serveur de baux sont joignables depuis ddns-net, et nulle part ailleurs. Cette seule décision élimine toute une classe de problèmes du type « pourquoi mon portable résout via le lab ».

Pourquoi Windmill, après deux tentatives qui ne l’étaient pas

Windmill est le troisième orchestrateur sur lequel ce POC a tourné. Les deux premiers n’étaient pas vraiment des erreurs, plutôt le chemin le plus court pour comprendre ce que le travail demandait réellement. Ils méritent d’être documentés.

Première tentative : un conteneur Python sur minuterie

La version d’origine était un dossier sync/ avec trois scripts (sync_dns.py, sync_dhcp.py, sync_proxmox.py) et un entrypoint qui les exécutait en boucle :

SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))

def job() -> None:
    run_sync("sync_dns")
    run_sync("sync_dhcp")
    run_sync("sync_proxmox")

job()
schedule.every(SYNC_INTERVAL).seconds.do(job)

while True:
    schedule.run_pending()
    time.sleep(1)

Un SYNC_INTERVAL dans le .env, à 60 secondes par défaut, et un service sync dans Compose. Ça fonctionnait, et la logique de synchronisation qu’il contenait est pour l’essentiel celle qui tourne encore aujourd’hui. Ce qu’il ne savait pas faire, c’était m’informer :

  • Chaque exécution est un poll complet. Rien n’a changé dans NetBox depuis six heures ? Il a quand même interrogé tous les endpoints 360 fois. La latence face à un vrai changement est bornée par l’intervalle : on arbitre entre réactivité et charge inutile, avec un seul réglage pour trois travaux très différents.
  • Les erreurs défilent. run_sync attrape, journalise et continue. C’est le bon comportement (une cible cassée ne doit pas bloquer les deux autres), mais la seule trace est une ligne dans docker logs, perdue au redémarrage suivant. Impossible de répondre à « est-ce que la synchro DHCP a réussi à 14h03, et qu’a-t-elle poussé ? ».
  • Aucun moyen d’en lancer une à la demande. Déboguer voulait dire redémarrer le conteneur, ou attendre.
  • Lire, calculer et appliquer tiennent en un seul appel. Quand une exécution échouait, savoir si NetBox avait renvoyé n’importe quoi ou si Kea avait rejeté la configuration passait par des print et 60 secondes d’attente supplémentaires.

Deuxième tentative : n8n

J’ai ensuite déplacé la même logique dans n8n, piloté par les webhooks NetBox, avec les workflows versionnés dans le dépôt sous n8n/workflows/*.json et deux scripts de provisionnement. L’événementiel était le bon choix et il est resté. n8n comme infrastructure as code, non.

Le problème tient à ce à quoi ressemble réellement un fichier de workflow. Voici un nœud, tel que commité :

{
  "id": "22222222-2222-4222-8222-222222222222",
  "name": "Lire Netbox",
  "type": "n8n-nodes-base.code",
  "typeVersion": 2,
  "position": [550, 300],
  "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "const NETBOX_URL = 'http://netbox:8080';\nconst NETBOX_AUTH = '__NETBOX_AUTH__';\n\nasync function netboxGetAll(path) {\n  const items = [];\n  let url = `${NETBOX_URL}/api/${path}...
  }
}

Chaque ligne de code réelle vit dans un unique champ JSON de type chaîne, sauts de ligne échappés. Ce seul détail empoisonne tout le workflow :

  • Ce n’est pas relisable. Une modification d’un caractère dans la logique apparaît dans git diff comme une énorme ligne modifiée. La revue de code devient impossible, et repérer un changement accidentel aussi.
  • Aucun outil n’y accède. Pas de coloration syntaxique, pas de linter, pas de formateur, pas de vérification de types. L’éditeur voit une chaîne.
  • Le versioning stocke de l’état d’interface. "position": [550, 300], c’est l’emplacement de la boîte sur le canevas. Les identifiants de nœuds sont des UUID écrits à la main qui doivent rester stables entre deux déploiements, sinon le workflow est recréé au lieu d’être mis à jour. Déplacer une boîte dans le navigateur produit un diff.
  • Les identifiants se battent avec le format. __NETBOX_AUTH__ est un marqueur, remplacé au démarrage par un script de provisionnement, parce que le magasin de secrets n’est pas réellement déclaratif.

Éditer dans le navigateur puis exporter est agréable, jusqu’au moment où deux sources de vérité coexistent : on se retrouve à comparer du JSON généré à la main.

Ce que Windmill a changé

Windmill garde ce que n8n faisait bien (événementiel, un DAG qu’on peut regarder, des résultats par étape dans une UI) et abandonne ce qu’il faisait mal, parce qu’une étape de flow est un fichier Python normal sur le disque :

windmill/scripts/sync_dhcp/
├── lire_netbox.py        # lire
├── calculer_config.py    # calculer
└── appliquer_kea.py      # appliquer

setup.py lit ces fichiers et les pousse dans Windmill. Les conséquences sont toute la raison du changement :

CritèreConteneur cronn8nWindmill
DéclenchementIntervalle fixe seulWebhook + scheduleWebhook + schedule + manuel
Logique dans GitVrais fichiers .pyChaînes JSON échappéesVrais fichiers .py
Diff relisableOuiNonOui
Résultats par étapeNonOuiOui
Historique des runsdocker logsOuiOui
SecretsVariables d’envSubstitution de marqueursVariables secrètes typées

Concrètement : le code est linté et diffé comme n’importe quel Python, l’entrée, la sortie et la durée de chaque étape sont inspectables après coup, un flow peut être rejoué depuis l’UI pendant le débogage, et les secrets sont des variables Windmill plutôt que des chaînes interpolées dans le source au démarrage.

La réserve honnête, c’est que ce n’est pas gratuit. Windmill demande son propre PostgreSQL, soit une base de plus à faire tourner et à sauvegarder pour un lab. Si la synchro tenait en un seul script sans branchement et que personne d’autre que moi ne la regardait, le conteneur cron suffisait, et je n’essaierais de dissuader personne.

Arborescence du dépôt

.
├── compose.yaml
├── .env
├── config/
│   ├── kea/
│   │   ├── kea-dhcp4.conf          # subnet4 vide, rempli par la synchro
│   │   └── kea-ctrl-agent.conf     # socket Unix exposé en HTTP
│   ├── netbox/
│   │   └── configuration.py
│   └── proxmox/
│       └── init_sdn.py             # one-shot : token API + zone SDN
└── windmill/
    ├── setup.py                    # déploie variables, flows, schedules, webhooks
    └── scripts/
        ├── sync_dns/{lire_netbox,sync_technitium}.py
        ├── sync_dhcp/{lire_netbox,calculer_config,appliquer_kea}.py
        └── sync_proxmox/{lire_netbox_vlans,sync_proxmox_sdn}.py

Les étapes de flow sont de simples fichiers .py sur le disque. setup.py les lit et les pousse dans Windmill, ce qui veut dire que les flows sont versionnés dans Git plutôt qu’édités dans une interface web et perdus au redéploiement suivant.

La stack Compose

Le fichier complet est long, voici les parties qui portent une décision. D’abord NetBox et ses dépendances :

name: ddi-stack

networks:
  netbox-net:
  ddns-net:
  windmill-net:

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    networks: [netbox-net]
    environment:
      POSTGRES_DB: netbox
      POSTGRES_USER: netbox
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U netbox"]
      interval: 10s
      retries: 5

  netbox:
    image: netboxcommunity/netbox:latest
    restart: unless-stopped
    networks: [netbox-net, ddns-net]
    ports:
      - "8000:8080"
    environment:
      SECRET_KEY: ${NETBOX_SECRET_KEY}
      NETBOX_TOKEN_PEPPER: ${NETBOX_TOKEN_PEPPER}
      DB_HOST: postgres
      REDIS_HOST: redis
      REDIS_CACHE_HOST: redis
      REDIS_CACHE_DATABASE: "1"
    volumes:
      - ./config/netbox/configuration.py:/etc/netbox/config/configuration.py:ro
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:8080/login/ || exit 1"]
      interval: 30s
      retries: 10
      start_period: 120s

Deux choses à copier. start_period: 120s sur le healthcheck NetBox : le premier démarrage lance les migrations et prend plusieurs minutes, sans ça Compose déclare le conteneur unhealthy et tout ce qui en dépend abandonne. Et un service netbox-worker séparé, qui lance manage.py rqworker avec le même environnement : sans lui, NetBox met les webhooks sortants en file et ne les envoie jamais, ce qui ressemble exactement à « mes event rules ne marchent pas ».

Le DNS et le DHCP :

  technitium:
    image: technitium/dns-server:latest
    networks: [ddns-net]
    ports:
      - "5380:5380"          # UI web et API seulement, jamais le 53
    environment:
      DNS_SERVER_ADMIN_PASSWORD: ${TECHNITIUM_PASSWORD}
    volumes:
      - technitium-data:/etc/dns

  kea:
    image: jonasal/kea-dhcp4:2
    command: -c /etc/kea/kea-dhcp4.conf
    networks: [ddns-net]
    volumes:
      - kea-leases:/kea/leases
      - kea-sockets:/kea/sockets      # partagé avec le control agent
      - ./config/kea:/etc/kea
    healthcheck:
      test: ["CMD-SHELL", "test -S /kea/sockets/kea-dhcp4-ctrl.sock || exit 1"]
      interval: 15s
      start_period: 20s

  kea-ctrl-agent:
    image: jonasal/kea-ctrl-agent:2
    command: -c /etc/kea/kea-ctrl-agent.conf
    networks: [ddns-net]
    ports:
      - "8080:8000"
    volumes:
      - kea-sockets:/kea/sockets
      - ./config/kea:/etc/kea
    depends_on:
      kea: { condition: service_healthy }

Kea est découpé en deux conteneurs à dessein. kea-dhcp4 parle DHCP et expose un socket de contrôle Unix ; kea-ctrl-agent est la seule chose qui transforme ce socket en HTTP. Ils le partagent via le volume kea-sockets, et le healthcheck sur le fichier de socket est ce qui empêche l’agent de démarrer avant qu’il y ait quelque chose à qui parler.

Windmill et son job de setup :

  windmill:
    image: ghcr.io/windmill-labs/windmill:main
    networks: [windmill-net, netbox-net, ddns-net]   # le seul pont
    ports:
      - "8300:8000"
    environment:
      DATABASE_URL: postgresql://windmill:${WINDMILL_DB_PASSWORD}@windmill-db/windmill
      BASE_INTERNAL_URL: http://windmill:8000
      SUPERADMIN_SECRET: ${WINDMILL_SUPERADMIN_SECRET}
      NUM_WORKERS: "1"
    depends_on:
      windmill-db: { condition: service_healthy }

  windmill-setup:
    image: python:3.12-slim
    restart: on-failure                 # réessaie jusqu'à ce que Windmill réponde
    command: sh -c "pip install -q requests && python /setup.py"
    networks: [windmill-net, netbox-net, ddns-net]
    volumes:
      - ./windmill/scripts:/windmill-scripts:ro
      - ./windmill/setup.py:/setup.py:ro
    environment:
      WINDMILL_URL: http://windmill:8000
      NETBOX_URL: http://netbox:8080
      NETBOX_TOKEN: ${NETBOX_TOKEN}
      DNS_ALLOWED_ZONES: ${DNS_ALLOWED_ZONES:-}
      KEA_DNS_SERVERS: ${KEA_DNS_SERVERS:-9.9.9.9, 149.112.112.112}
      DHCP_TAG: ${DHCP_TAG:-dhcp}
    depends_on:
      windmill: { condition: service_healthy }
      netbox: { condition: service_healthy }

restart: on-failure sur le conteneur de setup est la façon économique de gérer l’ordonnancement : il sort en erreur tant que Windmill démarre, Compose le relance, et il finit par réussir. Aucune boucle d’attente à écrire.

Kea, configuré pour être écrasé

Le fichier de configuration Kea sur le disque est volontairement presque vide :

{
  "Dhcp4": {
    "interfaces-config": {
      "interfaces": ["*"],
      "dhcp-socket-type": "udp"
    },
    "control-socket": {
      "socket-type": "unix",
      "socket-name": "/kea/sockets/kea-dhcp4-ctrl.sock"
    },
    "lease-database": {
      "type": "memfile",
      "persist": true,
      "name": "/kea/leases/dhcp4.leases"
    },
    "hooks-libraries": [
      { "library": "/usr/local/lib/kea/hooks/libdhcp_lease_cmds.so" }
    ],
    "valid-lifetime": 4000,
    "subnet4": []
  }
}

"subnet4": [] est tout l’intérêt. Aucun subnet n’est jamais écrit à la main. La synchro possède ce tableau entièrement, et c’est ce qui fait que « supprimer le préfixe dans NetBox » retire réellement le subnet. libdhcp_lease_cmds.so est chargé pour rendre les commandes de baux disponibles sur le canal de contrôle, et le control agent se contente de faire le pont :

{
  "Control-agent": {
    "http-host": "0.0.0.0",
    "http-port": 8000,
    "control-sockets": {
      "dhcp4": {
        "socket-type": "unix",
        "socket-name": "/kea/sockets/kea-dhcp4-ctrl.sock"
      }
    }
  }
}

Démarrage

cp .env.example .env
# renseigner NETBOX_SECRET_KEY, NETBOX_TOKEN_PEPPER, POSTGRES_PASSWORD,
# TECHNITIUM_PASSWORD, WINDMILL_SUPERADMIN_SECRET, WINDMILL_PASSWORD

docker compose up -d
docker compose logs -f netbox      # migrations, 2 à 3 minutes au premier boot

Créer le super-utilisateur NetBox et un token API :

docker compose exec -e DJANGO_SUPERUSER_PASSWORD=a-changer netbox \
  /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py \
  createsuperuser --username admin --email admin@lab.example --noinput

docker compose exec netbox /opt/netbox/venv/bin/python \
  /opt/netbox/netbox/manage.py shell -c "
from users.models import Token
from django.contrib.auth import get_user_model
u = get_user_model().objects.get(username='admin')
print('NETBOX_TOKEN=' + Token.objects.create(user=u).key)
"

Placer ce token dans .env sous NETBOX_TOKEN, puis rejouer le job de setup. Il est idempotent, donc sans danger à tout moment :

docker compose run --rm windmill-setup

Cette seule commande crée le workspace Windmill, pousse les variables (secrètes et non secrètes), déploie les trois flows depuis windmill/scripts/, enregistre les schedules horaires et crée les event rules NetBox. Rien n’est cliqué dans une interface, et la relancer met à jour au lieu de dupliquer.

Déclarer les données dans NetBox

Quatre champs portent tout.

Créer le tag dhcp une fois (Customization → Tags), et le champ personnalisé mac_address sur IPAM > IP address (Customization → Custom Fields, type Text). Ensuite :

Objet NetBoxValeurTagDevient
Préfixe10.60.20.0/24dhcpSubnet Kea, option routeur .1
IP range.100 à .200dhcpPool dynamique dans ce subnet
Adresse IP10.60.20.10-Enregistrement A si dns_name rempli
Adresse IP+ mac_address-Réservation fixe dans Kea

Via l’API, création d’un hôte qui obtient à la fois un enregistrement et une réservation :

curl -s -X POST http://localhost:8000/api/ipam/ip-addresses/ \
  -H "Authorization: Token ${NETBOX_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
        "address": "10.60.20.10/24",
        "status": "active",
        "dns_name": "web01.lab.example",
        "custom_fields": {"mac_address": "aa:bb:cc:00:11:22"}
      }'

La passerelle par défaut n’est pas un champ que quelqu’un remplit : le flow la calcule comme la première adresse du réseau. Une valeur de moins à se tromper.

Les flows, étape par étape

Chaque flow est un DAG de petites étapes Python. Lire, calculer, appliquer. Quand une exécution échoue, on voit d’un coup d’œil si NetBox a renvoyé n’importe quoi, si la configuration a été mal construite, ou si la cible l’a rejetée.

Sync DNS : lire NetBox

La première étape est une lecture paginée, rien d’autre :

NETBOX_URL = "http://netbox:8080"

def netbox_get_all(path: str, auth: str) -> list:
    items, sep = [], "&" if "?" in path else "?"
    url = f"{NETBOX_URL}/api/{path}{sep}limit=200"
    while url:
        r = requests.get(url, headers={"Authorization": auth})
        r.raise_for_status()
        data = r.json()
        items.extend(data.get("results", []))
        url = data.get("next")
    return items

def main():
    auth = f"Token {wmill.get_variable('u/admin/netbox_token')}"
    desired = {}
    for ip in netbox_get_all("ipam/ip-addresses/", auth):
        if not ip.get("dns_name"):
            continue
        desired[ip["dns_name"].strip().rstrip(".")] = ip["address"].split("/")[0]
    return {"desired": desired, "dns_records": len(desired)}

Sync DNS : appliquer sur Technitium

La deuxième étape choisit la zone cible par correspondance du plus long suffixe, donc web01.dev.lab.example atterrit dans dev.lab.example si cette zone existe, et dans lab.example sinon :

def find_zone(fqdn: str, zones: set) -> str | None:
    best = None
    for z in zones:
        if fqdn == z or fqdn.endswith(f".{z}"):
            if best is None or len(z) > len(best):
                best = z
    return best

L’écriture est un upsert, donc rejouer la synchro ne change rien :

requests.get(f"{TECHNITIUM_URL}/api/zones/records/add", params={
    "token": token, "zone": zone, "domain": fqdn,
    "type": "A", "ipAddress": ip, "ttl": "300", "overwrite": "true",
}).raise_for_status()

Puis la passe de suppression : lister les enregistrements A existants, supprimer ceux que NetBox ne connaît plus. Elle ne s’exécute que dans les zones que le flow a le droit de toucher, contrôlées par u/admin/dns_allowed_zones. Laissez la variable vide et toutes les zones non internes existantes sont gérées ; renseignez-la, et la synchro ne sortira jamais de cette liste. Les zones internes de Technitium (localhost, in-addr.arpa) sont exclues automatiquement.

Sync DHCP : ignorer les exécutions qui ne changent rien

Le webhook DHCP écoute ipam.ipaddress, parce que les réservations vivent sur un champ personnalisé de l’IP plutôt que dans un modèle dédié. Il se déclenche donc aussi quand quelqu’un modifie dns_name, ce qui n’a rien à voir avec le DHCP. La première étape compare les snapshots avant et après, et s’arrête tôt :

def main(object_type: str = None, snapshots: dict = None):
    if object_type == "ipam.ipaddress":
        snapshots = snapshots or {}
        avant = (snapshots.get("prechange") or {}).get("custom_fields", {}).get("mac_address")
        apres = (snapshots.get("postchange") or {}).get("custom_fields", {}).get("mac_address")
        if avant == apres:
            return {"status": "skipped",
                    "reason": "ipaddress change irrelevant to DHCP"}
    ...

Les étapes suivantes portent skip_if: results.lire_netbox.status == 'skipped', donc une modification de dns_name ne déclenche plus un config-set inutile. Sur un schedule ou une exécution manuelle, object_type est vide et la synchro complète a lieu.

Sync DHCP : construire les subnets

for prefix in prefixes:
    net = ipaddress.ip_network(prefix["prefix"], strict=False)
    pools = [
        {"pool": f"{r['start_address'].split('/')[0]} - {r['end_address'].split('/')[0]}"}
        for r in ranges
        if ip_in_network(r["start_address"].split("/")[0], str(net))
    ]
    subnets.append({
        "id": prefix["id"],                       # id NetBox, stable entre les runs
        "subnet": str(net),
        "pools": pools,
        "option-data": [
            {"name": "routers", "data": str(net.network_address + 1)},
            {"name": "domain-name-servers", "data": kea_dns_servers},
        ],
        "reservations": [],
    })

Réutiliser l’id de l’objet NetBox comme id de subnet Kea est un petit détail payant : l’id est stable entre les exécutions, donc un subnet conserve son identité même quand le tableau est reconstruit de zéro.

Sync DHCP : appliquer de façon atomique

r = requests.post(KEA_URL, json={"command": "config-get",
                                 "service": ["dhcp4"], "arguments": {}})
dhcp = r.json()[0]["arguments"]["Dhcp4"]
dhcp["subnet4"] = subnets                      # la synchro possède ce tableau

r = requests.post(KEA_URL, json={"command": "config-set",
                                 "service": ["dhcp4"],
                                 "arguments": {"Dhcp4": dhcp}})
result = r.json()[0]
if result["result"] != 0:
    raise RuntimeError(f"Kea config-set failed: {result['text']}")

Lire toute la configuration en cours, remplacer un tableau, réécrire. Deux appels HTTP quel que soit le nombre de subnets, appliqués sans redémarrer le service. Les baux actifs survivent au rechargement, et c’est la propriété qui rend l’exécution horaire sans danger.

Sync Proxmox SDN

Les VLANs deviennent des VNets, un par VLAN, nommés vl<vid> :

for name, want in desired.items():
    if name not in existing:
        pve("POST", "/cluster/sdn/vnets",
            {"vnet": name, "zone": sdn_zone,
             "tag": want["vid"], "alias": want["alias"]})
    else:
        cur = existing[name]
        if cur.get("tag") != want["vid"] or (cur.get("alias") or "") != want["alias"]:
            pve("PUT", f"/cluster/sdn/vnets/{name}",
                {"tag": want["vid"], "alias": want["alias"]})

for name in existing:
    if name not in desired:
        pve("DELETE", f"/cluster/sdn/vnets/{name}")

if created + updated + removed > 0:
    pve("PUT", "/cluster/sdn", {})     # sans ça, rien n'est appliqué

Cette dernière ligne coûte un après-midi si on l’oublie. Proxmox met les changements SDN en attente et ne les applique qu’au PUT /cluster/sdn. Avant ça, l’API annonce tranquillement des VNets qu’aucune interface n’a jamais vus.

Proxmox demande aussi un token API plutôt que le mot de passe root, ce qui ajoute une étape de provisionnement, exécutée une seule fois :

docker compose run --rm proxmox-init
# crée root@pam!sync et la zone SDN, affiche la valeur du token
# la copier dans .env sous PROXMOX_TOKEN_VALUE, puis :
docker compose run --rm windmill-setup

Le token est stocké comme variable secrète Windmill sous la forme PVEAPIToken=user!name=value, donc aucun script ne détient jamais un mot de passe.

Déclencheurs : événementiel, avec un filet

setup.py enregistre trois event rules NetBox, chacune pointée sur un flow :

FlowTypes d’objets NetBoxSchedule
u/admin/sync_dnsipam.ipaddress0 0 * * * *
u/admin/sync_dhcpipam.prefix, ipam.iprange, ipam.ipaddress0 0 * * * *
u/admin/sync_proxmoxipam.vlan0 0 * * * *

Le code de setup détecte la version majeure de NetBox et enregistre soit des webhooks simples (3.x), soit des webhooks plus des event rules (4.x), donc la même stack fonctionne sur les deux.

Les webhooks sont le chemin rapide : un changement DNS est en place quelques secondes après la validation du formulaire. Le schedule horaire est le chemin honnête. Un webhook se perd, un conteneur redémarre, quelqu’un modifie la base directement. L’exécution planifiée est une réconciliation complète qui se moque de ce qui s’est passé entre-temps, parce qu’elle ne calcule jamais un diff à partir d’événements. Elle lit l’état désiré dans NetBox, lit l’état réel sur la cible, et fait ressembler le second au premier.

C’est la décision de conception qui mérite d’être copiée. Réconcilier contre la réalité, jamais contre un journal d’événements. Un système événementiel incapable de reconstruire son état de zéro dérivera, et vous vous en rendrez compte au pire moment.

Vérifier que ça marche

Le DNS, de bout en bout :

TOKEN=$(curl -sf "http://localhost:5380/api/user/login?user=admin&pass=${TECHNITIUM_PASSWORD}" | jq -r .token)

curl -sf "http://localhost:5380/api/zones/records/get?token=$TOKEN&zone=lab.example&domain=lab.example&listZone=true" \
  | jq '[.response.records[] | select(.type=="A") | {name, ip: .rData.ipAddress}]'

# résolution depuis ddns-net, là où le résolveur écoute réellement
docker run --rm --network ddi-stack_ddns-net nicolaka/netshoot \
  dig @technitium web01.lab.example A +short

Le DHCP :

curl -s -X POST http://localhost:8080 \
  -H "Content-Type: application/json" \
  -d '{"command":"config-get","service":["dhcp4"],"arguments":{}}' \
  | jq '[.[] | .arguments.Dhcp4.subnet4[]
         | {subnet, pools: [.pools[].pool],
            reservations: [.reservations[]["ip-address"]]}]'

curl -s -X POST http://localhost:8080 \
  -H "Content-Type: application/json" \
  -d '{"command":"lease4-get-all","service":["dhcp4"],"arguments":{"subnets":[1]}}' \
  | jq '.[] | .arguments.leases[]'

Forcer une synchro sans toucher à NetBox :

TOKEN=$(curl -s -X POST http://localhost:8300/api/auth/login \
  -H "Content-Type: application/json" \
  -d "{\"email\":\"${WINDMILL_USER}\",\"password\":\"${WINDMILL_PASSWORD}\"}")

curl -X POST "http://localhost:8300/api/w/ddi/jobs/run/f/u/admin/sync_dns" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Ce que ça coûte

OpérationRequêtes HTTPAtomique
Lecture NetBoxPaginée, 200 objets par requête-
Upsert DNS1 par enregistrement (overwrite=true)Non
Suppression DNS1 par enregistrementNon
Sync DHCP1 config-get + 1 config-setOui
Sync Proxmox SDN1 GET zones + 1 GET vnets + N écrituresNon

Environ 800 adresses réparties sur trois zones se synchronisent en moins d’une seconde. Ce n’est pas une prouesse de performance, c’est une conséquence de la forme : lire NetBox est paginé et peu coûteux, et le DHCP se réduit à deux appels quelle que soit la taille. Le DNS est la seule partie qui croît linéairement avec le nombre d’enregistrements, et à cette échelle le linéaire est gratuit.

Le chiffre qui compte vraiment est ailleurs. Supprimer une VM, c’est désormais une action : retirer l’IP dans NetBox. L’enregistrement disparaît, la réservation disparaît, et plus rien nulle part ne revendique cette adresse.

Ce que je me dirais avant de commencer

Décider dès le premier jour ce que l’automatisation a le droit de supprimer. Une synchro qui ne fait qu’ajouter n’est pas une synchro, c’est un import. Mais une synchro qui supprime tout ce qu’elle ne reconnaît pas finira par manger un enregistrement créé à la main et oublié. dns_allowed_zones existe exactement parce que la première version ne l’avait pas.

Rendre l’étape de setup idempotente. Workspace, variables, flows, schedules et webhooks sont tous créés par un script qu’on peut rejouer à volonté. Tout ce qui est provisionné à la main devient la seule chose que personne ne saura reconstruire.

Garder des étapes de flow petites et séparées. Lire, calculer, appliquer. Trois étapes disent d’un coup d’œil où l’exécution a échoué, une seule oblige à lire des logs.

Ne pas publier le DNS et le DHCP sur l’hôte. Ce sont des briques d’infrastructure pour la stack, pas des services pour le réseau sur lequel le portable se trouve. Les laisser non publiés s’est révélé être la décision de sécurité la plus simple de tout le projet.