
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.
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: 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 :
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_syncattrape, 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 dansdocker 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é :
{
"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 diffaffiche 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 # appliquersetup.py lit ces fichiers et les pousse dans Windmill.
| Critère | Conteneur cron | n8n | Windmill |
|---|---|---|---|
| Déclenchement | Intervalle fixe seul | Webhook + schedule + manuel | Webhook + schedule + manuel |
| Logique dans Git | Vrais fichiers .py | Chaînes JSON échappées | Vrais fichiers .py |
| Diff lisible en revue | 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 |
| RBAC | Aucun | Édition entreprise | Utilisateurs, groupes, dossiers |
| Simplicité de maintenance | Une image, aucun état | Une base, plus les workflows à réexporter | Une 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}.pyLa 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 :
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: 120sDeux 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 :
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-dhcp4parle DHCP et expose un socket de contrôle Unix.kea-ctrl-agentest 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 :
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 :
{
"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 :
{
"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 bootCré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 :
docker compose run --rm windmill-setupCette 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 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 |
Créer 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"}
}'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 :
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 :
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 bestL’é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, 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é :
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 :
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
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 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> :
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 :
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-setupLe 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 :
| 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. 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 :
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 +shortLe 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. 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 |
|---|---|
| PostgreSQL | Réplication en streaming avec bascule automatique (Patroni, ou un opérateur Kubernetes) |
| Redis | Sentinel, ou un équivalent managé, pour les bases de tâches et de cache |
| NetBox | Couche web sans état, N réplicas derrière un load balancer ; médias sur stockage partagé ou objet |
| rqworker | Adossé à une file, donc à scaler horizontalement ; c’est lui qui émet réellement les webhooks |
| Windmill | Serveur et workers séparés (DISABLE_SERVER, NUM_WORKERS), état dans son PostgreSQL répliqué |
| Technitium | Primaire caché écrit par la synchro, N secondaires servant les clients via AXFR/IXFR |
| Kea | Le 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 :
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éeLes 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 servirL’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 ──▶ appliquerL’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 :
| Changement | Ce que donne la charge utile | Ce qu’il faut écrire |
|---|---|---|
IP créée avec dns_name | postchange | Un enregistrement ajouté |
dns_name modifié | prechange + postchange | Une suppression, un ajout |
| IP supprimée | prechange seul | Un enregistrement supprimé |
| Champ sans rapport modifié | Les deux, identiques sur les champs utiles | Rien |
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-setde 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 premiumsubnet_cmdsethost_cmdspour 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.pyrecré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.

