← 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
NotePar Kilian Toulliou29 min de lecture

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 Docker Compose et configuration complètes.

Tous les labs que j’administre attrapent la même maladie. Une adresse IP finit par vivre à quatre endroits : 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. Là, trois de ces quatre endroits continuent de pointer dessus.

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 à tout reconstruire depuis un dossier vide.

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 : NetBox, son Postgres et son Redis.
  • ddns-net : le DNS, le DHCP et Proxmox.
  • windmill-net : Windmill et sa propre base.

Windmill est le seul conteneur raccordé aux trois. C’est donc le seul à pouvoir joindre à la fois la source de vérité et les cibles.

Les ports 53 et 67 ne sont pas publiés sur l’hôte, volontairement. Le résolveur et le serveur de baux répondent dans ddns-net et nulle part ailleurs. Cette seule décision élimine toute une classe de problèmes du type « pourquoi l’hôte Docker 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 des erreurs. C’était le chemin le plus court pour comprendre ce que le travail demandait.

Première tentative : un conteneur Python sur Cron

Un dossier sync/, trois scripts, et un entrypoint qui bouclait dessus :

python
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. Ça fonctionnait. 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é depuis six heures ? Il a quand même interrogé tous les endpoints 360 fois. Et un seul intervalle devait servir trois travaux très différents.
  • Les erreurs défilent. run_sync attrape, journalise et continue. Ce comportement est le bon : 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. « La synchro DHCP a-t-elle réussi à 14h03, et qu’a-t-elle poussé ? » reste sans réponse.

Le reste en découle : rien à lancer à la demande, et lire, calculer et appliquer tiennent en un seul appel. Déboguer voulait dire ajouter un print et attendre 60 secondes de plus.

Deuxième tentative : n8n

J’ai ensuite déplacé la même logique dans n8n, piloté par les webhooks NetBox. Les workflows sont partis dans le dépôt sous n8n/workflows/*.json.

L’événementiel était le bon choix, et il est resté. n8n comme infrastructure as code, non.

Regardez ce qu’est réellement un fichier de workflow. Voici un nœud, tel que commité :

json
{
  "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 est dans une seule chaîne JSON, sauts de ligne échappés. Ce seul détail empoisonne tout :

  • La revue de code devient impossible. Changez un caractère, git diff affiche une seule énorme ligne modifiée, et aucun outil n’atteint ce qu’il y a dedans : pas de coloration, pas de linter, pas de types. L’éditeur voit une chaîne.
  • Le dépôt versionne l’état de l’interface graphique. "position": [550, 300], c’est l’emplacement de la boîte sur le canevas. Déplacez-la dans le navigateur, vous obtenez un diff.

Éditer dans le navigateur est agréable. Jusqu’au moment où deux sources de vérité coexistent, et où l’on compare du JSON généré à la main.

Ce que Windmill a changé

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

windmill/scripts/sync_dhcp/
├── read_netbox.py        # lire
├── compute_config.py     # calculer
└── apply_kea.py          # appliquer

setup.py lit ces fichiers et les pousse dans Windmill.

CritèreConteneur cronn8nWindmill
DéclenchementIntervalle fixe seulWebhook + schedule + manuelWebhook + schedule + manuel
Logique dans GitVrais fichiers .pyChaînes JSON échappéesVrais fichiers .py
Diff lisible en revueOuiNonOui
Résultats par étapeNonOuiOui
Historique des runsdocker logsOuiOui
SecretsVariables d’envSubstitution de marqueursVariables secrètes typées
RBACAucunÉdition entrepriseUtilisateurs, groupes, dossiers
Simplicité de maintenanceUne image, aucun étatUne base, plus les workflows à réexporterUne base PostgreSQL dédiée

Ce que ça apporte :

  • Le code est linté et diffé comme n’importe quel Python.
  • L’entrée, la sortie et la durée de chaque étape survivent à l’exécution.
  • Un flow se rejoue depuis l’UI pendant le débogage.
  • Les secrets sont des variables, pas des chaînes interpolées dans le source au démarrage.

La réserve honnête : ce n’est pas gratuit. Windmill veut son propre PostgreSQL, soit une base de plus à faire tourner et à sauvegarder. Si votre synchro tient en un script sans branchement que personne d’autre ne lit, le conteneur cron suffisait. Je n’essaierai pas de vous en dissuader.

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/{read_netbox,sync_technitium}.py
        ├── sync_dhcp/{read_netbox,compute_config,apply_kea}.py
        └── sync_proxmox/{read_netbox_vlans,sync_proxmox_sdn}.py

La stack Docker Compose

Tout le POC tient dans un seul fichier compose.yaml. Il est long, voici les parties qui portent une décision. NetBox et ses dépendances d’abord :

yaml
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.

Le DNS et le DHCP :

yaml
  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 volontairement découpé en deux conteneurs :

  ┌──────────────┐   socket Unix    ┌────────────────┐   HTTP :8000
  │  kea-dhcp4   │◀────────────────▶│ kea-ctrl-agent │◀──────────────  la synchro
  │  parle DHCP  │  volume sockets  │  parle HTTP    │
  └──────────────┘                  └────────────────┘
  • 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.
  • Le healthcheck sur le fichier de socket empêche l’agent de démarrer avant qu’il y ait quelqu’un à qui parler.

Windmill et son job de setup :

yaml
  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 pendant que Windmill démarre, Compose le relance, il finit par réussir. Aucune boucle d’attente à écrire.

Kea, configuré pour être écrasé

La configuration Kea sur le disque est volontairement presque vide :

json
{
  "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 que les commandes de baux fonctionnent sur le canal de contrôle. L’agent se contente de faire le pont :

json
{
  "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

bash
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 :

bash
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 :

bash
docker compose run --rm windmill-setup

Cette seule commande fait tout :

  • 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,
  • crée les event rules NetBox.

Rien n’est cliqué dans une interface. C’est idempotent, donc 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

Créer un hôte qui obtient à la fois un enregistrement et une réservation :

bash
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"}
      }'

Personne ne renseigne la passerelle par défaut. Le flow la calcule comme la première adresse du réseau. Une valeur de moins à se tromper.

Les flows, étape par étape

C’est le cœur du projet, et il est plus modeste qu’il n’y paraît : faire dialoguer des API entre elles, sur déclenchement ou sur schedule. On ne réinvente aucune roue. On traduit des données d’une API vers une autre, et on s’assure que la traduction reste vraie.

Chaque flow est le même DAG en trois étapes. Cette forme, c’est toute l’histoire du débogage :

   déclencheur  (webhook │ schedule │ run manuel)
      │
      ▼
 ┌──────────────┐
 │ lire NetBox  │   échec ici ? la source n'a rien donné d'exploitable
 └──────┬───────┘
        ▼
 ┌──────────────┐
 │ calculer     │   échec ici ? j'ai construit la mauvaise configuration
 └──────┬───────┘
        ▼
 ┌──────────────┐
 │ appliquer    │   échec ici ? la cible a refusé
 └──────────────┘

Un coup d’œil à la boîte en rouge et vous savez où chercher. Ça vaut plus que ça n’en a l’air.

Sync DNS : lire NetBox

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

python
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 par correspondance du plus long suffixe. web01.dev.lab.example atterrit donc dans dev.lab.example si cette zone existe, et dans lab.example sinon :

python
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 :

python
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, définies par u/admin/dns_allowed_zones :

  • variable vide : toutes les zones non internes sont gérées ;
  • variable renseignée : la synchro ne sort jamais de cette liste ;
  • dans les deux cas, les zones internes de Technitium (localhost, in-addr.arpa) sont exclues automatiquement.

Conflits DNS : ce qui est géré, ce qui ne l’est pas

Deux situations ressemblent à un conflit. Une seule en est un.

Plusieurs dns_name sur la même IP : aucun problème. desired est indexé par FQDN, donc web01.lab.example et www.lab.example pointant tous deux sur 10.60.20.10 sont deux clés distinctes, et deux enregistrements A distincts. La passe de suppression travaille elle aussi par nom, donc retirer l’un ne touche pas l’autre. C’est le cas nominal, pas une tolérance.

Deux IP portant le même dns_name : l’une des deux disparaît en silence. C’est le même dictionnaire, vu de l’autre côté :

python
desired[fqdn] = ip["address"].split("/")[0]

La dernière IP lue écrase la précédente avant même que Technitium soit contacté. overwrite=true n’y est pour rien : l’appel add ne reçoit qu’une seule valeur pour ce nom, il ne remplace pas un enregistrement concurrent, il applique le seul qu’on lui a donné. NetBox trie ses adresses par adresse, donc en pratique la plus haute gagne, mais c’est une conséquence de l’ordre de lecture, pas une règle de résolution. Rien n’échoue, rien n’est signalé : dns_records compte les clés du dictionnaire, l’IP perdante n’apparaît donc nulle part dans le résumé de l’exécution.

Et rien ne surveille ça aujourd’hui. Pas d’alerte, pas de compteur de collisions. Un doublon de dns_name saisi par erreur dans NetBox passerait inaperçu, et le seul symptôme serait un hôte qui ne résout pas, remonté par quelqu’un plutôt que par le système. C’est une lacune repérée après coup, pas un compromis pesé au moment de la conception. L’endroit où la corriger est l’étape de lecture, qui a toute l’information nécessaire pour détecter la collision et échouer, ou au minimum la remonter. Ce n’est pas fait.

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 :

python
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.read_netbox.status == 'skipped'. Une modification de dns_name ne déclenche plus de 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

python
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

python
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 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. C’est ce qui rend l’exécution horaire sans danger.

Sync Proxmox SDN

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

python
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é

Proxmox veut aussi un token API plutôt que le mot de passe root. Une étape de provisionnement de plus, à exécuter une seule fois :

bash
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 secret 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. Il enregistre des webhooks simples en 3.x, des webhooks plus des event rules en 4.x, donc la même stack fonctionne sur les deux.

Deux déclencheurs, une seule destination :

  chemin rapide                          filet de sécurité
  ─────────────                          ─────────────────
  quelqu'un valide un formulaire         schedule horaire
  le webhook part en quelques secondes   rattrape ce qui s'est perdu
          │                                      │
          └──────────────┐        ┌──────────────┘
                         ▼        ▼
                 ┌───────────────────────────┐
                 │  la même réconciliation   │
                 ├───────────────────────────┤
                 │ désiré ← NetBox           │
                 │ réel   ← la cible         │
                 │ appliquer la différence   │
                 └───────────────────────────┘

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, parce que les choses tournent mal :

  • un webhook se perd,
  • un conteneur redémarre,
  • quelqu’un modifie la base directement.

L’exécution planifiée 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é, lit l’état réel, et fait correspondre le second au premier.

Vérifier que ça marche

Le DNS, de bout en bout :

bash
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 :

bash
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 :

bash
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. Le DHCP se réduit à deux appels quelle que soit la taille. Seul le DNS 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.

Passer en production ?

Tout ce qui précède est un POC : un exemplaire de chaque composant, un seul hôte, quelques centaines d’adresses. Cinq chantiers s’ouvrent dès qu’il faut survivre au redémarrage d’un nœud et tenir des dizaines de milliers d’adresses réparties sur plusieurs sites.

Rendre chaque composant hautement disponible

La bonne nouvelle d’abord : l’orchestrateur n’est pas dans le chemin de données. Si Windmill meurt, le DNS résout toujours et le DHCP distribue toujours des baux à partir du dernier état appliqué. Ce n’est pas une panne, c’est un gel.

Ce qui recadre le travail. Le plan de contrôle doit être récupérable. Le plan de données doit être redondant. Deux problèmes différents.

ComposantÀ quoi ressemble la HA
PostgreSQLRéplication en streaming avec bascule automatique (Patroni, ou un opérateur Kubernetes)
RedisSentinel, ou un équivalent managé, pour les bases de tâches et de cache
NetBoxCouche web sans état, N réplicas derrière un load balancer ; médias sur stockage partagé ou objet
rqworkerAdossé à une file, donc à scaler horizontalement ; c’est lui qui émet réellement les webhooks
WindmillServeur et workers séparés (DISABLE_SERVER, NUM_WORKERS), état dans son PostgreSQL répliqué
TechnitiumPrimaire caché écrit par la synchro, N secondaires servant les clients via AXFR/IXFR
KeaLe hook high_availability en hot-standby ou load-balancing, avec synchro des baux entre pairs

Deux pièges dans ce tableau.

Le worker NetBox n’est pas optionnel, et il s’oublie facilement au moment de scaler. Les webhooks sortants passent par sa file. Un pool sous-dimensionné ressemble juste à « la synchro est en retard », sans que rien ne paraisse cassé.

Kea en HA casse l’approche config-set. Poussez tout le tableau subnet4 sur un pair, l’autre est périmé. Poussez sur les deux, ce n’est pas atomique à l’échelle de la paire. À ce stade, la configuration cesse d’être un fichier qu’on pousse pour devenir une base que les deux serveurs lisent. La section sur la montée en charge arrive à la même conclusion par un autre chemin.

Découper par site, région et tier

NetBox fournit déjà les clés de partition : Region, Site Group, Site, Tenant. Autant les utiliser plutôt que d’inventer un schéma de tags.

Le côté lecture est un filtre de requête, c’est la moitié facile :

python
prefixes = netbox_get_all(f"ipam/prefixes/?tag={dhcp_tag}&site={site}", auth)

La moitié difficile, c’est tout ce qu’il y a autour. Un flow limité à un site a besoin :

  • de ses propres identifiants,
  • de sa propre liste blanche,
  • de ses propres cibles.

Une erreur dans une région ne doit pas pouvoir en atteindre une autre.

Dans Windmill, cela se traduit par les worker tags : épingler le job d’une région à des workers qui tournent dans cette région. Ça règle au passage l’accessibilité, quand le worker central n’a aucune route vers un réseau d’administration distant. Le rayon d’explosion devient une propriété de déploiement plutôt qu’un espoir.

Le tiering d’environnement (lab, préproduction, production) est un autre axe. Un seul NetBox avec un Tenant par tier fonctionne et préserve la source de vérité unique. Mais uniquement si les identifiants de la synchro diffèrent par tier : ce qui empêche un changement de lab d’atteindre la production doit être une autorisation, pas un filtre dans un fichier Python que n’importe qui peut modifier.

Plan de contrôle central, plan de données local

« Un seul endroit pour déclarer, plusieurs endroits pour servir » se découpe proprement par protocole, parce que le DNS et le DHCP n’ont pas le même rapport au réseau.

Le DNS se fédère nativement. Un primaire caché que seule la synchro écrit, et un secondaire sur chaque site :

         écrit             AXFR / IXFR + NOTIFY, signés en TSIG
  synchro ──────▶ primaire caché ──┬──▶ secondaire, site A ──▶ clients
                  (ne sert aucun   ├──▶ secondaire, site B ──▶ clients
                   client)         └──▶ secondaire, site C ──▶ clients
                                        aucune API d'écriture exposée

Les serveurs distants sont en lecture seule par construction, pas par politique : ils n’exposent aucune API d’écriture dont on pourrait abuser. Une coupure WAN dégrade vers le service de la dernière zone transférée, ce qui est presque toujours le bon mode de défaillance.

Le DHCP ne se fédère pas, parce qu’il est local au lien. Deux options honnêtes :

 A. relayer vers le centre
    clients ──▶ passerelle (ip helper-address) ══ WAN ══▶ Kea central
    WAN coupé ⇒ plus de nouveaux baux sur ce site

 B. un Kea par site, configuré depuis le centre
    clients ──▶ Kea local ◀══ config poussée (ou lue dans une base partagée)
    WAN coupé ⇒ le site continue de servir

L’option A, c’est une seule configuration à maintenir. Le coût : les clients existants ne tiennent que jusqu’à l’expiration de leur bail, ce qui transforme valid-lifetime en décision de disponibilité délibérée plutôt qu’en valeur par défaut.

L’option B survit à la perte du WAN, au prix de N serveurs à garder alignés.

La règle sous-jacente aux deux mérite d’être retenue : la synchro écrit dans exactement un endroit par service, tout le reste est un réplica. Deux écrivains, et vous avez reconstruit le problème que ce projet existe pour supprimer.

Synchroniser le changement, pas tout le parc

C’est ce que le POC sacrifie le plus visiblement. Chaque exécution lit toutes les IP, reconstruit l’état désiré et compare. À quelques centaines d’adresses, cela coûte moins d’une seconde. À quarante mille, à chaque modification, non.

 aujourd'hui   chaque édition ──▶ lire les 40 000 IP     ──▶ diff ──▶ appliquer
 voulu         une édition    ──▶ lire la charge utile   ──▶ 1 écriture
               horaire        ──▶ lire UNE zone/UN site  ──▶ diff ──▶ appliquer

L’ironie : NetBox envoie déjà ce qu’il faut, et le flow le jette. La charge utile de l’événement porte event, l’objet, et snapshots.prechange / snapshots.postchange. Aujourd’hui le flow DHCP ne s’en sert que pour décider s’il doit s’arrêter. Il pourrait s’en servir pour décider quoi écrire :

ChangementCe que donne la charge utileCe qu’il faut écrire
IP créée avec dns_namepostchangeUn enregistrement ajouté
dns_name modifiéprechange + postchangeUne suppression, un ajout
IP suppriméeprechange seulUn enregistrement supprimé
Champ sans rapport modifiéLes deux, identiques sur les champs utilesRien

La suppression est le cas qui contraint la conception. postchange est nul, donc l’ancienne valeur doit venir de prechange. Toute synchro incrémentale qui ignore les snapshots prechange laisse fuir des enregistrements périmés en silence.

Quatre choses doivent l’accompagner :

  • Garder une réconciliation, mais la limiter. Une synchro par objet dérive dès le premier webhook perdu. Gardez la passe complète, rendez-la partielle : une zone ou un site par exécution, en tourniquet. Ou filtrez sur ?last_updated__gte= avec un repère dans l’état Windmill, en gardant en tête qu’un filtre temporel ne voit pas les suppressions.
  • Fusionner les événements. Un import en masse de 5000 adresses ne doit pas donner 5000 exécutions. Regroupez sur une courte fenêtre, dédupliquez par id.
  • Verrouiller par clé. Beaucoup de petites exécutions concurrentes créent des courses qu’une grosse exécution sérielle n’avait jamais. Les limites de concurrence de Windmill, indexées par zone ou par subnet, sont le correctif économique. Et relisez l’objet plutôt que de faire confiance à une charge utile peut-être déjà périmée.
  • Changer le protocole de la cible, pas seulement le code. C’est le vrai plafond. Un config-set de tout le tableau est en O(parc), quelle que soit l’astuce du flow. Déplacez les réservations vers un backend d’hôtes MySQL ou PostgreSQL que Kea lit directement, et la poussée disparaît. ISC vend aussi les hooks premium subnet_cmds et host_cmds pour l’édition par objet. Pour le DNS, l’équivalent est la mise à jour dynamique RFC 2136, par nature enregistrement par enregistrement.

À grande échelle, la réponse n’est pas une synchro plus rapide. C’est une cible qui accepte un delta.

Les briques que le POC n’a pas

Rien d’original ici, et c’est bien le problème : ce sont trois manques standards, qu’il serait malhonnête de laisser deviner.

Sauvegarde. Il n’existe aucune stratégie de sauvegarde aujourd’hui.

  • Le PostgreSQL de NetBox est le seul élément irremplaçable. C’est la source de vérité, tout le reste en découle.
  • La configuration de Technitium et celle de Kea n’ont pas besoin d’être sauvegardées : une synchro les reconstruit intégralement. Le PostgreSQL de Windmill est entre les deux, setup.py recrée les flows et les schedules, seul l’historique des runs disparaît.
  • Les baux DHCP sont la vraie perte. Le memfile de Kea vit dans le volume kea-leases : volume perdu, les réservations reviennent à la synchro suivante puisqu’elles sortent de NetBox, mais les baux dynamiques du pool ne sont pas reconstructibles. Kea repart d’un pool qu’il croit libre alors que des clients détiennent encore leurs adresses, jusqu’à ce que le parc se renouvelle.

Secrets. Le .env est en clair sur l’hôte, injecté en variables d’environnement, relayé dans Windmill par setup.py. Les valeurs sensibles y atterrissent bien en variables is_secret, mais tout ce qui est en amont est lisible par quiconque a un shell sur la machine. C’est un compromis assumé pour un POC en homelab, où le .env et l’hôte ont la même surface. Passer à des Docker secrets, à SOPS pour chiffrer le fichier dans Git, ou à un Vault dont les valeurs sont lues au démarrage, c’est du branchement standard sur une stack Compose, pas une reconception.

Observabilité. Pour savoir qu’un flow a échoué, il faut ouvrir l’UI Windmill et regarder l’historique des runs. Personne n’est prévenu. Le schedule horaire limite les dégâts (l’exécution suivante rattrape un échec ponctuel), mais une panne durable, token expiré ou cible injoignable, reste invisible jusqu’à ce qu’elle se voie ailleurs. Le handler d’erreur de Windmill vers un webhook d’alerting, ou une scrutation périodique des runs échoués via son API, sont les deux réponses évidentes. Ni l’une ni l’autre ne demande de toucher aux flows.

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 parce que la première version n’avait aucune limite de ce genre.

Rendre l’étape de setup idempotente. Workspace, variables, flows, schedules, webhooks : 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 tourne le serveur qui héberge le POC. Les laisser non publiés a été la décision de sécurité la plus simple de tout le projet.

Cette décision est propre au POC en Docker Compose, mais le principe se transpose tel quel en production : isoler les services réseau dans un VLAN dédié, et n’exposer que ce qui doit l’être. Concrètement, un relais DHCP plutôt qu’un serveur par VLAN, du DoH (DNS over HTTPS) pour la résolution côté client, et de petits serveurs DNS par zone en lecture seule alimentés par AXFR. L’objectif est le même dans les deux cas : un fonctionnement standard, une configuration réseau réduite, et surtout pas tous les ports ouverts sur tous les VLANs.

Le motif ne s’arrête pas au DNS et au DHCP : chaque nouvelle cible est un flow lire, calculer, appliquer de plus. Des port groups vCenter, des configurations de switch que NetBox sait déjà rendre depuis ses config templates, des objets d’adresses de pare-feu (les objets, pas les règles, qui doivent rester sous revue humaine). Je n’en ai implémenté aucun, donc je n’en dirai rien de plus.

Une seule chose mérite d’être anticipée avant d’en arriver là : plus il y a de consommateurs, plus la qualité des données NetBox devient critique. Avec un seul, un dns_name malformé est un mauvais enregistrement. Avec six, il se propage partout d’un coup.

Partager