
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ôle | Ce qui le fait tourner | Pourquoi celui-là |
|---|---|---|
| IPAM | NetBox | Vrai modèle de données, API solide, changelog par objet |
| Orchestration | Windmill | Flows en étapes Python versionnées, plus une UI |
| DNS | Technitium | API HTTP complète, gestion des zones saine, léger |
| DHCP | Kea | Configuration JSON, rechargeable via un socket de contrôle |
| Réseau overlay | SDN Proxmox VE | Les 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_syncattrape, 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 dansdocker 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
printet 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 diffcomme 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ère | Conteneur cron | n8n | Windmill |
|---|---|---|---|
| Déclenchement | Intervalle fixe seul | Webhook + schedule | Webhook + schedule + manuel |
| Logique dans Git | Vrais fichiers .py | Chaînes JSON échappées | Vrais fichiers .py |
| Diff relisable | Oui | Non | Oui |
| Résultats par étape | Non | Oui | Oui |
| Historique des runs | docker logs | Oui | Oui |
| Secrets | Variables d’env | Substitution de marqueurs | Variables 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 NetBox | Valeur | Tag | Devient |
|---|---|---|---|
| Préfixe | 10.60.20.0/24 | dhcp | Subnet Kea, option routeur .1 |
| IP range | .100 à .200 | dhcp | Pool dynamique dans ce subnet |
| Adresse IP | 10.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 :
| Flow | Types d’objets NetBox | Schedule |
|---|---|---|
u/admin/sync_dns | ipam.ipaddress | 0 0 * * * * |
u/admin/sync_dhcp | ipam.prefix, ipam.iprange, ipam.ipaddress | 0 0 * * * * |
u/admin/sync_proxmox | ipam.vlan | 0 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ération | Requêtes HTTP | Atomique |
|---|---|---|
| Lecture NetBox | Paginée, 200 objets par requête | - |
| Upsert DNS | 1 par enregistrement (overwrite=true) | Non |
| Suppression DNS | 1 par enregistrement | Non |
| Sync DHCP | 1 config-get + 1 config-set | Oui |
| Sync Proxmox SDN | 1 GET zones + 1 GET vnets + N écritures | Non |
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.