Une console, plusieurs clients

Si vous gérez Proxmox pour le compte d’autres gens, chaque nouveau nœud est le même travail, fait à chaque fois un peu différemment. Chaque client a son propre pays, son fuseau horaire, ses clés root, sa disposition des disques, sa règle de nommage des hôtes et sa clé de souscription. Ratez l’un d’eux sur un nœud neuf et vous le découvrez la première fois que quelqu’un se connecte, ou la première fois qu’un audit demande pourquoi l’horloge a une heure de décalage.

J’ai construit cela pour un client qui avait exactement ce problème, et sa demande était d’une simplicité rafraîchissante. Chaque site s’installe avec son propre nommage, son propre mot de passe root et ses propres clés SSH. Personne sur le site ne fait quoi que ce soit de subtil : on monte un serveur dans la baie, on le branche sur le bon VLAN et on l’allume. Personne ne relève de numéros de série, et personne n’écrit de fichier de réponse par machine. Et un serveur ajouté de cette façon finit dans le cluster existant du site.

Ce qu’ils voulaientCe qui le fournit ici
le nommage, le mot de passe et les clés propres au siteune réponse préparée par site, choisie par le jeton de ce site
brancher sur le bon VLAN, allumer, repartirla découverte DNS, iPXE, et Caddy qui choisit le jeton d’après le sous-réseau source
pas de numéros de série, pas de fichier de réponse par machineles réponses ne filtrent que sur le produit ; le site vient du réseau
le nouveau serveur rejoint le cluster existantpas dans ce billet : PDM n’a pas de hook de premier démarrage, voir plus bas

Ce billet couvre les trois premiers points : amener un serveur nu de la mise sous tension à un nœud Proxmox installé avec les paramètres du bon site. C’est cette moitié-là qui se présente à presque tous ceux qui ont plus d’un site, MSP ou non, donc elle mérite d’exister seule. Rejoindre le cluster est un travail à part, et la section sur le hook de premier démarrage absent explique pourquoi PDM ne peut pas encore le faire pour vous.

Proxmox Datacenter Manager (PDM) 1.1 a ajouté la pièce qui manquait1. Il peut désormais servir de serveur de réponses pour l’installateur automatique de Proxmox : une machine nue démarre, envoie ce qu’elle est, reçoit une réponse préparée et s’installe toute seule, sans personne à la console. Pour un fournisseur de services managés (MSP) qui utilise déjà PDM comme console de gestion, c’est l’endroit tout désigné pour garder les paramètres d’installation de chaque client.

Ce que vous ne voulez pas, c’est un serveur PXE par client. Chacun d’eux est un serveur Trivial File Transfer Protocol (TFTP), une configuration DHCP, un jeu de fichiers de démarrage et une ISO à reconstruire chaque fois que Proxmox publie une version mineure. Dix clients, c’est dix de ces ensembles à tenir à jour. Ça ne passe pas à l’échelle. Le but ici est un seul parc PXE, une seule image de démarrage, un seul enregistrement DNS et un seul PDM, chaque site recevant malgré tout sa propre réponse.

Un seul parc PXE pour tous les sitessite110.10.50.0/24site210.20.50.0/24site310.30.50.0/24Caddy sur le 443jeton générique entrantjeton du site sortant,choisi par sous-réseauPDMréponses délimitéespar jeton8443 en boucle localeUne image de démarrage, un enregistrement DNS et un service DHCP pour tous les sites
Le résultat final. Tous les sites démarrent la même image. Caddy déduit de quel site vient une requête d’après son sous-réseau et remet à PDM le jeton de ce site, et PDM ne propose que les réponses associées à ce jeton.

PDM 1.1 fait l’essentiel du chemin tout seul. Il s’arrête court à deux endroits, et les deux méritent d’être connus avant de bâtir un déploiement autour : il n’a aucun moyen d’associer une réponse à un site, et aucun moyen de donner un script de premier démarrage à un nouveau nœud. Ce billet prouve les deux à partir du code source de PDM, contourne la première, et construit l’ensemble sur mon PDM de labo en 1.1.9, avec un proxy durci pour que les parties qui détiennent des secrets restent fermées.

Ce que PDM 1.1 vous donne

La fonction se trouve sous Remotes → Automated Installations et comporte trois parties : les installations, les réponses préparées et les jetons d’authentification2. Une réponse préparée est la version PDM du answer.toml de l’installateur, remplie via un formulaire au lieu d’être écrite à la main.

PartieCe qu’elle faitLe détail qui compte
Réponses préparéespays, clavier, fuseau horaire, hash du mot de passe root, clés SSH, réseau, disposition des disques, clé de souscription, action après installationgénérées à neuf pour chaque requête
Filtres de cibledécident quelle machine reçoit quelle réponseJSON Pointer dans les informations système de l’installateur, glob pour la valeur3
ModèlesMiniJinja dans les champs FQDN, e-mail de l’administrateur, IP, passerelle et DNSdes compteurs comme installation_nr augmentent à chaque réponse servie4
Jetons d’authentificationl’installateur envoie Authorization: Bearer <name>:<secret>un type de jeton distinct des jetons d’API de PDM ; chaque réponse liste les jetons qu’elle accepte2
Installationsun enregistrement par requête, avec toutes les informations systèmepasse de en cours à terminée quand le webhook de post-installation rappelle
PermissionsSys.Audit pour consulter, Sys.Modify pour modifierles deux sur /system/auto-installation4

Le côté installateur existe depuis un moment. La commande proxmox-auto-install-assistant prepare-iso construit une ISO qui va chercher sa réponse en HTTP, et depuis l’ISO de Proxmox VE 9.2 elle sait aussi produire le noyau, l’initrd, l’ISO et le boot.ipxe dont iPXE a besoin pour démarrer le tout par le réseau5.

Les compteurs sont la partie qui n’a aucun équivalent dans un answer.toml autonome. Une seule réponse préparée numérote chaque hôte qu’elle construit, là où un fichier statique devrait être copié pour chaque machine. C’est un vrai pas en avant, et cela reste une première version. Le formulaire n’est pas encore à parité avec le fichier de réponse autonome6, et un domaine manque entièrement : il n’y a aucun script de premier démarrage dans la 1.1, sous aucune forme7. Ce manque, et le site sur lequel il ne sait pas filtrer, font l’objet des deux sections suivantes.

Il ne sait pas filtrer sur le site

PDM choisit une réponse en comparant des filtres aux informations système que l’installateur envoie. La question est donc de savoir ce que contient ce document, car tout ce qui n’y figure pas ne peut pas servir de critère.

CléCe qu’elle contientPourrait-elle désigner le site ?
productfullname, product (pve, pbs, pmg), enable_btrfsnon
isorelease, isoreleasenon
dmisystème, carte mère et châssis : nom, numéro de série, UUID, SKU, étiquette d’inventaireseulement si quelqu’un a écrit le site dans l’étiquette d’inventaire
network_interfacesune liste de {link, mac}seulement via un inventaire que vous tenez vous-même

C’est tout8,9. Pas d’adresse, pas de nom d’hôte, pas d’option DHCP, rien sur l’endroit où la machine est branchée.

Les champs Desktop Management Interface (DMI) paraissent prometteurs jusqu’à ce qu’on en lise de vrais. Chacune de mes installations de labo est revenue avec le nom de système Standard PC (Q35 + ICH9, 2009), un numéro de série vide et une étiquette d’inventaire vide. Les vrais serveurs les remplissent, mais avec ce que le constructeur y a mis et non avec le nom de votre client, et filtrer sur les numéros de série ou les adresses MAC ne marche que si vous tenez la liste de chaque châssis que vous installerez jamais, ce qui est exactement la paperasse que tout cela devait supprimer. Par hôte, oui. Par site, non.

Alors, sur quel réseau la machine est-elle branchée ? PDM ne le sait jamais. L’installateur a son bail DHCP en main quand il envoie sa requête, et le seul fait qu’un MSP connaît à coup sûr sur un nouveau nœud est justement celui qu’il laisse de côté. Le filtrage sur l’adresse source, IPv4 ou IPv6, est une demande de fonctionnalité ouverte, que j’ai moi-même déposée sous le numéro 826310.

Une deuxième limite se cache dans les règles de sélection, et mon labo est tombé en plein dedans.

Comment PDM choisit une réponse1. Jeton valide ?activé, non expirénon401 après environ une demi-secondenon enregistré2. Réponses sur ce jetondans l'ordre du fichier, rien d'autre3. Tous les filtres correspondent ?chaînes seulement, globs permisla premièreréponse généréechacun de ses compteurs augmente de 1aucune4. Défaut sur ce jeton ?un seul défaut dans tout PDMnon400 : no answer file foundenregistré sous Installations
La sélection de PDM, d’après le code source. Le jeton est vérifié en premier et délimite tout ce qui suit, réponse par défaut comprise.

Le jeton est vérifié en premier, et seules les réponses autorisées sur ce jeton sont prises en compte, y compris celle par défaut4. PDM n’autorise qu’une seule réponse par défaut pour tout le système. Vous ne pouvez donc pas donner à chaque site son propre repli avec l’indicateur par défaut. Mon labo avait une réponse par défaut appelée fallback sur son propre jeton, et elle n’a jamais été servie une seule fois, parce que rien n’envoie jamais ce jeton. Chaque site a plutôt besoin d’un filtre attrape-tout sur son propre jeton, comme /product/product=*, placé en dernier dans le fichier.

Deux autres points du même code sont bons à avoir en tête quand vous déboguez :

  • La première correspondance gagne, dans l’ordre du fichier, et une seconde correspondance ne produit qu’un avertissement dans le journal. C’est la règle depuis la 1.1.111.
  • Un filtre ne compare que des chaînes. /product/enable_btrfs est un booléen et ne correspond jamais à rien, et un tableau a besoin d’un indice, comme dans /network_interfaces/0/mac.

En 1.1.9, un jeton inconnu ou expiré reçoit un 401 après un délai volontaire d’environ une demi-seconde et ne laisse aucune trace. Un jeton valide sans aucune correspondance reçoit un 400 avec le corps no answer file found, et cette tentative est enregistrée, avec toutes les informations système. Cet enregistrement est le moyen le plus rapide de lire les vrais numéros de série et adresses MAC d’un nouveau châssis avant d’écrire un filtre pour lui.

Il ne sait pas lancer un script au premier démarrage

L’installateur lui-même prend en charge un hook de premier démarrage. Une réponse peut porter une section [first-boot] avec source = "from-iso" ou "from-url", un ordre parmi before-network, network-online ou fully-up, et une empreinte de certificat facultative9. prepare-iso --on-first-boot intègre un script dans l’ISO pour le cas from-iso.

PDM n’envoie jamais cette section. Pas une seule fois. Le type de réponse de l’installateur a bien le champ, first_boot: Option<FirstBootHookInfo>12. La réponse préparée de PDM n’a aucun champ pour cela, et le code qui construit la réponse à partir d’elle écrit first_boot: None sans condition4. Rien dans le formulaire, rien dans l’API, et rien dans le fichier de configuration que vous pourriez ajouter à la main. C’est une demande de fonctionnalité ouverte, le bug 7699, déposée en juin 2026 et toujours sans responsable au moment où j’écris7. Près de cinq mois plus tard, Proxmox n’y a pas répondu, pas plus qu’au 8263. C’est ce silence qui inquiète. Quelqu’un qui prend le temps d’écrire comment votre produit pourrait être meilleur mérite une réponse, même si cette réponse est non, et c’est une simple courtoisie professionnelle. À plus forte raison quand ceux qui écrivent sont des clients payants et des partenaires, qui mettent déjà de l’argent dans le produit avant d’y mettre aussi leur temps.

Et le script sur l’ISO ne vous sauve pas. L’installateur ne met en place le service de premier démarrage que if let Some(first_boot) = &answer.first_boot, et c’est le seul chemin qui lise jamais le fichier intégré13. prepare-iso n’émet aucun avertissement quand vous intégrez un script qu’aucune réponse ne demandera jamais. Il reste sur l’ISO et ne fait rien.

Le manque du premier démarrageScript sur l'ISOprepare-iso --on-first-bootRéponse de PDMfirst_boot: None, toujoursInstallateurmet en place le hook de premier démarrageseulement si la réponse a[first-boot]le script reste sur l'ISOet ne s'exécute jamais, sans avertissement
Les deux moitiés arrivent jusqu’à l’installateur, et celle qui vient de PDM ne demande jamais le hook. Le script sur l’ISO est un poids mort.

Cela pèse plus lourd pour un MSP que pour n’importe qui d’autre. Un nœud sorti de l’installateur est un système nu avec un mot de passe root haché et quelques clés SSH. Ce n’est pas un remote PDM, il n’est dans aucun cluster, et il n’a rien de votre supervision, de vos dépôts ni de votre stockage. Dans mon labo, les trois nœuds ont tous terminé et rappelé, et le remote SITEA dans PDM a quand même dû être ajouté à la main ensuite.

Donc, quel que soit l’outil qui finalise un nœud, Ansible ou autre chose, il doit être déclenché de l’extérieur : par le webhook de post-installation, par une tâche qui surveille la liste Installations, ou par une personne. Je m’arrête là. Ce billet traite de la façon de faire servir tous les sites par un seul parc PXE, et le manque de premier démarrage est une limite à prévoir dans vos plans, pas une que je vais masquer ici.

Le jeton, c’est le site

PDM ne voit pas le site, mais il délimite bien les réponses par jeton, et un jeton n’est qu’un en-tête. Celui qui pose l’en-tête choisit quel jeu de réponses l’installateur peut obtenir. Tout le tour est là.

Placez donc devant PDM quelque chose qui sait d’où vient la requête :

  1. L’image de démarrage porte un jeton générique, catch-all:not-a-secret, identique dans chaque image et publié ici même. Ce n’est pas un jeton PDM et il n’ouvre rien à lui seul.
  2. Un reverse proxy Caddy devant PDM regarde le sous-réseau source d’une requête de réponse qui porte le jeton générique, et remplace l’en-tête par le vrai jeton de ce site.
  3. PDM voit site2-2026q4:<secret> et ne propose que les réponses de site2.

Le secret n’approche jamais l’image de démarrage, si bien qu’une clé USB perdue ou une ISO copiée ne donne rien à personne. Renouveler le jeton d’un site est une modification de Caddy, pas une reconstruction d’image. Et ajouter un site, c’est une ligne de configuration Caddy et un nouveau jeton dans PDM.

Cela repose sur l’honnêteté de l’adresse source, qui est une propriété de votre réseau, pas du proxy. Ce compromis et les autres sont exposés plus bas, sous « Ce qui reste à décider ».

Comment l’installateur trouve PDM sans URL intégrée

Une seule image pour tous les sites ne fonctionne que si l’image ne porte pas non plus l’adresse de PDM. L’installateur cherche l’URL de sa réponse à trois endroits, dans l’ordre14 :

Où l'installateur trouve l'URL de sa réponse1. URL intégrée à l'ISO ?prepare-iso --urlouiutilisée, DHCP et DNS jamais interrogésune image par PDM : évité icinon2. Option DHCP 250 ?lue dans le bail de l'installateurouiutilisée, avec l'option 251 pourl'empreinte : non envoyée icinon, ou toute erreur DHCP3. Requête DNS TXTdomaine de recherche de resolv.confproxmox-auto-installer.example.docURL et empreinte, toutes deux par le DNS
L’ordre que suit l’installateur. Ne mettez pas l’URL dans l’image et n’envoyez pas d’option 250, et chaque site retombe sur le DNS.
RègleCe que fait le codeCe que cela signifie pour vous
Ordreune URL dans l’ISO l’emporte ; sinon le DHCP ; tout échec du DHCP retombe sur le DNS14construisez l’image sans --url, et n’envoyez d’option 250 nulle part
Noms d’enregistrementproxmox-auto-installer.<search domain> pour l’URL, proxmox-auto-installer-cert-fingerprint.<search domain> pour l’empreinteune zone, deux enregistrements TXT
Domaine de recherchela première ligne search de /etc/resolv.conf, découpée sur une seule espacedistribuez exactement un domaine ; deux en font un seul nom invalide
Pas de domaineéchoue avec Could not find search domain in resolv.conf.vérifiez que l’option DHCP 15 ou 119 est bien envoyée
Valeur TXTdig txt +short, tous les " retirés, puis espaces de bord supprimésune valeur TXT découpée en plusieurs chaînes laisse une espace dans l’URL ; gardez une seule chaîne
Empreinteprise dans le DNS seulement si l’URL vient aussi du DNS et qu’aucune n’est intégréepubliez l’empreinte du certificat de PDM, que Caddy sert aussi
Jetonenvoyé comme Authorization: Bearer <token>, stocké en clair sur l’ISO9c’est pourquoi l’image ne porte que le jeton générique

Le certificat que vérifie l’installateur est celui qui répond sur le 443. Dans cette conception, Caddy y sert le propre certificat de PDM, donc un client voit sur le 443 le même certificat que PDM présente sur le 8443, et il n’y a qu’une seule empreinte à publier.

La construction : PDM, fichiers de démarrage et DNS

Les exemples utilisent le domaine example.doc et trois sites, 10.10.50.0/24, 10.20.50.0/24 et 10.30.50.0/24. .doc n’est pas un vrai domaine de premier niveau, remplacez-le par le vôtre. PDM, Caddy, dnsmasq et les fichiers de démarrage vivent tous sur un seul hôte, pdm.example.doc en 10.10.50.10, et les sites distants l’atteignent par un relais DHCP sur leurs passerelles. La gestion vit sur 10.99.0.0/24 et, à ce titre, c’est le seul réseau qui voie jamais l’interface web de PDM.

Jetons et réponses dans PDM

Sous Remotes → Automated Installations → Authentication Tokens, ajoutez un jeton par site et donnez-lui une expiration qui correspond à votre fenêtre de déploiement. Le nom n’est pas secret, et vous le verrez dans les journaux, alors faites-lui dire quelque chose : site1-2026q4. Copiez le secret quand il s’affiche.

Ajoutez ensuite une réponse préparée par site et par produit, autorisée sur le seul jeton de ce site :

Champsite1, Proxmox VE
Jeton autorisésite1-2026q4
Filtre de cible/product/product=pve
Pays, clavier, fuseau horairegb, en-gb, Europe/London
Accès rootclés SSH, plus un mot de passe défini dans le formulaire, que PDM stocke et n’envoie que sous forme de hash4
RéseauDHCP, noms d’interface figés
FQDN{{ product.product }}-site1-{{ installation_nr }}.example.doc
Disquevotre disposition, par exemple un miroir ZFS sur deux disques désignés par leur nom
Webhook de post-installationURL de base https://pdm.example.doc, avec l’empreinte du certificat de PDM

Le compteur augmente à chaque réponse servie, nouvelles tentatives comprises, donc les noms peuvent sauter des numéros. Le compteur de mon labo est à 4 après trois installations terminées. Si vous avez besoin de noms prévisibles, utilisez FQDN from DHCP et mettez le nom d’hôte sur la réservation DHCP.

L’URL de base du webhook est le seul réglage qu’il faut changer pour cette conception. Il est naturel de la pointer sur PDM lui-même, port 8443, c’est ainsi que mon labo l’avait, et le 8443 est sur le point d’être fermé. Pointez-la sur Caddy.

Les fichiers de démarrage

Sur n’importe quel hôte Proxmox VE ou Debian avec le dépôt Proxmox :

apt install proxmox-auto-install-assistant xorriso
mkdir -p /srv/www/pve-9.2
proxmox-auto-install-assistant prepare-iso proxmox-ve_9.2-1.iso \
  --fetch-from http \
  --answer-auth-token 'catch-all:not-a-secret' \
  --pxe-loader ipxe \
  --output /srv/www/pve-9.2/

Pas de --url, car le DNS la fournit. --pxe-loader ipxe implique --pxe, et avec --pxe la sortie doit être un répertoire qui existe déjà15. Vous obtenez quatre fichiers : vmlinuz, initrd.img recompressé de zstd en gzip parce qu’iPXE ne sait pas charger un initrd zstd, l’ISO préparée avec /boot retiré, et un menu boot.ipxe qui lance par défaut l’installation automatique au bout de dix secondes et désigne les autres fichiers par nom relatif15. Gardez les quatre dans un même répertoire.

Chaque cible a besoin d’assez de mémoire pour contenir le noyau, l’initrd et toute l’ISO. Les notes de Proxmox VE 9.2 l’expriment ainsi : « at least 6 GiB of available memory on the host »5. Les mêmes notes signalent un problème connu avec le noyau de l’ISO 9.2 sur les contrôleurs megaraid_sas et certains disques NVMe en mode JBOD (just a bunch of disks), avec le conseil d’installer depuis l’ISO 9.1 puis de mettre à niveau5. Testez votre matériel avant de lui confier le chemin réseau.

Les enregistrements DNS

Dans la zone qui correspond à l’unique domaine de recherche distribué par le DHCP :

$ORIGIN example.doc.
pdm                                      IN A    10.10.50.10
proxmox-auto-installer                   IN TXT  "https://pdm.example.doc/api2/json/auto-install/answer"
proxmox-auto-installer-cert-fingerprint  IN TXT  "63:C4:D6:...:85:07"

L’empreinte est celle du propre certificat de PDM :

openssl x509 -in /etc/proxmox-datacenter-manager/auth/api.pem -noout -fingerprint -sha256

Vérifiez-les comme le fera l’installateur :

dig txt +short proxmox-auto-installer.example.doc @10.10.50.10
# one quoted string, nothing split

Caddy, la seule porte d’entrée vers PDM

L’API de PDM écoute sur [::]:8443, et c’est une constante compilée dans le binaire, pas un réglage16. Vous ne pouvez pas la lier à la boucle locale. Caddy devient donc la seule entrée, et le pare-feu s’en assure.

Les routes de Caddy devant PDMREQUÊTECE QUI SE PASSEPOST de réponse, sous-réseau de site, jeton génériqueréécrit avec le jeton de ce sitePOST de post-hook, sous-réseau de siteAuthorization retiré, transmisinterface web ou API, sous-réseau de gestiontransmis intact, connexion PDMtout le reste, d'où que ce soitinterrompu, n'atteint jamais PDMport 8443, depuis l'extérieurrejeté par nftables
Chaque requête que voit Caddy finit à l’un de cinq endroits. Seuls les trois premiers atteignent PDM.

PDM a exactement deux routes qui fonctionnent sans connexion PDM : POST /api2/json/auto-install/answer avec un jeton d’installateur, et POST …/installations/{uuid}/post-hook avec le jeton à usage unique dans le corps4. Ce sont les deux seules qu’un réseau de provisionnement doit pouvoir atteindre.

Utilisez Caddy 2.11.1 ou plus récent. La 2.11.1 a corrigé un contournement du filtre de chemin, CVE-2026-27587, et c’est exactement le genre de filtrage par chemin sur lequel tout cela repose17. Le paquet de Debian 13 est en 2.6.2, prenez-le donc dans trixie-backports, qui porte la 2.11.2, ou dans le dépôt de Caddy lui-même18.

Le Caddyfile, /etc/caddy/Caddyfile :

{
	# the admin API on a socket only root can open, never on TCP
	admin unix//run/caddy/admin.sock|0600
	auto_https disable_redirects
	servers {
		protocols h1 h2
	}
}

# PDM itself, on the loopback, trusted by its own certificate
(pdm_upstream) {
	reverse_proxy https://[::1]:8443 {
		transport http {
			tls_trust_pool file /etc/proxmox-datacenter-manager/auth/api.pem
			tls_server_name pdm.example.doc
		}
	}
	log_append pdm_status {http.reverse_proxy.status_code}
}

# one site: name, subnet, PDM token name, PDM token secret
(site) {
	@{args[0]} {
		method POST
		path /api2/json/auto-install/answer
		remote_ip {args[1]}
		header Authorization "Bearer catch-all:not-a-secret"
	}
	handle @{args[0]} {
		log_append site {args[0]}
		log_append pdm_token {args[2]}
		request_header Authorization "Bearer {args[2]}:{args[3]}"
		import pdm_upstream
	}
}

pdm.example.doc, 10.10.50.10 {
	tls /etc/proxmox-datacenter-manager/auth/api.pem /etc/proxmox-datacenter-manager/auth/api.key

	import site site1 10.10.50.0/24 site1-2026q4 {env.PDM_SECRET_SITE1}
	import site site2 10.20.50.0/24 site2-2026q4 {env.PDM_SECRET_SITE2}
	import site site3 10.30.50.0/24 site3-2026q4 {env.PDM_SECRET_SITE3}

	@post_hook {
		method POST
		path /api2/json/auto-install/installations/*/post-hook
		remote_ip 10.10.50.0/24 10.20.50.0/24 10.30.50.0/24
	}
	handle @post_hook {
		request_header -Authorization
		import pdm_upstream
	}

	# the PDM web UI and API, from the management network only
	@mgmt remote_ip 10.99.0.0/24
	handle @mgmt {
		import pdm_upstream
	}

	# anything else, from anywhere, never reaches PDM
	handle {
		abort
	}

	log {
		output file /var/log/caddy/pdm.log
		format filter {
			request>headers>Csrfpreventiontoken replace REDACTED
		}
	}
}

# boot files, to the sites and management only
http://:80 {
	@hidden path_regexp /\.
	handle @hidden {
		abort
	}
	@boot remote_ip 10.10.50.0/24 10.20.50.0/24 10.30.50.0/24 10.99.0.0/24
	handle @boot {
		root * /srv/www
		file_server
	}
	handle {
		abort
	}
}

Les parties qui font un travail de sécurité, et la raison de chacune :

LignePourquoi
header Authorization "Bearer catch-all:not-a-secret" dans le filtre du sitesans elle, n’importe quel POST venu d’un sous-réseau de site reçoit le vrai jeton ; une requête doit venir du bon sous-réseau et porter le jeton générique
request_header Authorization …pose l’en-tête en écrasant ce que le client a envoyé, donc un jeton forgé est remplacé, jamais transmis19
remote_iple pair immédiat ; ne mettez ni NAT ni répartiteur de charge entre les installateurs et Caddy20
blocs handle nommésgardés dans l’ordre d’écriture et mutuellement exclusifs, donc le handle { abort } nu a le dernier mot21
@mgmttoute l’interface et l’API de PDM, intactes, donc la connexion propre à PDM, ses jetons d’API et ses cookies fonctionnent normalement ; un sous-réseau de site n’en reçoit rien
admin unix//…|0600l’API d’administration écoute par défaut en TCP sur localhost:2019, sans authentification22
protocols h1 h2Caddy écoute par défaut en UDP 443 pour HTTP/322 ; ceci le désactive au lieu de laisser un port que personne n’a voulu ouvrir
@hiddenfile_server sert tous les fichiers sous sa racine, fichiers cachés compris, et hide est documenté comme n’étant pas une frontière de sécurité23
Csrfpreventiontoken replace REDACTEDCaddy masque Authorization et Cookie dans son journal d’accès par défaut, mais pas l’en-tête CSRF de PDM24

Le filtre de chemin est plus tolérant que PDM, et c’est le bon sens. Caddy nettoie le chemin, décode l’encodage pourcent, fusionne les barres obliques répétées et ignore la casse avant de comparer20. Donc //answer, %61nswer et ANSWER comptent tous comme la route de réponse, et une variante /api2/extjs/… ou une barre oblique finale ne correspond à aucune route de site et retombe sur abort.

Deux choix méritent une explication.

Caddy sert le certificat de PDM, directement depuis les fichiers de PDM. Caddy lit api.pem et api.key là où PDM les garde, donc les installateurs, le webhook et un navigateur voient tous le certificat que PDM présente sur le 8443, et il n’y a pas de seconde copie qui dérive. La façon évidente de lui donner cet accès est d’ajouter l’utilisateur caddy au groupe www-data. Sur mon labo, j’ai vérifié ce que cette appartenance au groupe atteint réellement, en testant chaque fichier en tant qu’utilisateur caddy :

Fichier sous /etc/proxmox-datacenter-manager/Lisible par caddy dans www-data
remotes.shadow, les identifiants de chaque remote géréoui
autoinst/tokens.shadow, tous les secrets des jetons d’installateuroui
auth/api.key, auth/csrf.keyoui
access/user.cfg, subscriptions/oui

Rien dans cette liste n’a sa place à proximité d’un proxy web. Un bogue dans le proxy deviendrait alors une compromission de chaque cluster géré par PDM.

Caddy reste donc hors de www-data et reçoit une ACL sur exactement les deux fichiers dont il a besoin, plus le droit de traverser les deux répertoires au-dessus sans pouvoir les lister :

#!/bin/sh
# /usr/local/sbin/pdm-cert-acl
# let Caddy read PDM's certificate and key in place, and nothing else of PDM's
set -e
D=/etc/proxmox-datacenter-manager; A=$D/auth
setfacl -m u:caddy:x "$D" "$A"
setfacl -m u:caddy:r "$A/api.pem" "$A/api.key"

Testé en tant qu’utilisateur caddy sur le labo après l’avoir lancé :

Chemincaddy
auth/api.pem, auth/api.keypeut lire
auth/csrf.key, auth/authkey.key, auth/authkey.pubne peut pas lire
remotes.shadow, remotes.cfg, autoinst/tokens.shadow, access/user.cfgne peut pas lire
lister auth/refusé

Le hic, c’est le renouvellement. PDM écrit un certificat renouvelé dans un nouveau fichier puis le renomme à sa place, et un nouveau fichier ne porte pas l’ACL de l’ancien. Donc, quand PDM renouvelle, systemd remet l’ACL et recharge Caddy, sans intervention de personne.

Une vérification que le certificat et la clé vont ensemble, parce qu’un renouvellement les écrit l’un après l’autre :

#!/bin/sh
# /usr/local/sbin/pdm-cert-pair-ok
# exit 0 only when PDM's certificate and key belong together
d=/etc/proxmox-datacenter-manager/auth
c=$(openssl x509 -noout -pubkey -in "$d/api.pem") || exit 1
k=$(openssl pkey -pubout -in "$d/api.key") || exit 1
[ "$c" = "$k" ]

Une unité path qui surveille les deux fichiers, et le service qu’elle lance :

# /etc/systemd/system/caddy-pdm-cert.path
[Unit]
Description=Watch the PDM certificate and key for renewal

[Path]
PathChanged=/etc/proxmox-datacenter-manager/auth/api.pem
PathChanged=/etc/proxmox-datacenter-manager/auth/api.key
Unit=caddy-pdm-cert.service

[Install]
WantedBy=multi-user.target

# /etc/systemd/system/caddy-pdm-cert.service
[Unit]
Description=Let Caddy read the renewed PDM certificate in place, then reload Caddy

[Service]
Type=oneshot
ExecCondition=/usr/local/sbin/pdm-cert-pair-ok
ExecStart=/usr/local/sbin/pdm-cert-acl
ExecStart=/usr/bin/systemctl try-reload-or-restart caddy.service
apt install acl
systemctl daemon-reload
systemctl enable --now caddy-pdm-cert.path

La surcharge plus bas lance le même script d’ACL avant chaque démarrage de Caddy, donc un renouvellement survenu pendant que Caddy était arrêté n’est jamais manqué.

Les détails qui font que ça marche, chacun vérifié :

ÉlémentPourquoi
les deux fichiers surveillésla première écriture déclenche l’unité ; la condition saute l’exécution tant que la paire ne correspond pas, et la seconde écriture la déclenche de nouveau avec une paire cohérente
ExecConditionune condition en échec saute l’exécution au lieu de la faire échouer, donc un renouvellement à moitié écrit n’atteint jamais Caddy25
PathChangedse déclenche quand un fichier surveillé qui était ouvert en écriture est fermé26 ; sur le labo, il s’est aussi déclenché quand un nouveau fichier a été renommé par-dessus l’ancien
le script d’ACL à chaque exécutionun fichier arrivé par renommage n’a pas d’ACL, donc Caddy ne pouvait pas le lire tant que le script n’avait pas tourné de nouveau
try-reload-or-restartrecharge Caddy s’il tourne et ne fait rien sinon
reload --force dans l’unité de Caddysans --force, Caddy voit la même configuration et continue de servir l’ancien certificat

Cette dernière ligne n’est pas une supposition. Dans un conteneur Caddy 2.11.7 jetable, j’ai remplacé le fichier de certificat derrière un serveur en marche : un simple caddy reload continuait de servir l’ancien certificat, et caddy reload --force servait le nouveau. L’unité du paquet recharge déjà avec --force27, gardez donc cette ligne si vous surchargez ExecReload.

Sur le labo, j’ai remplacé api.key et api.pem de PDM comme le fait un renouvellement, en écrivant de nouveaux fichiers puis en les renommant par-dessus les anciens. Juste après le renommage, api.key n’avait aucune entrée d’ACL pour caddy. En moins de quatre secondes, l’unité path s’était déclenchée, l’ACL était de retour sur les deux fichiers, et Caddy s’était rechargé sur place avec le même identifiant de processus.

Le nom du jeton et le secret sont tenus à part. Le nom va dans le Caddyfile, où il est journalisé. Le secret va dans un fichier d’environnement que seul root peut lire, et Caddy le lit via {env.*} au moment de la requête.

Journaliser quel jeton a servi, jamais le secret

La première version de mon labo fuyait. L’unité du paquet Caddy le lance en caddy run --environ, et --environ affiche tout l’environnement avant de démarrer28,27. Chaque redémarrage mettait le jeton du site dans le journal systemd en clair : huit lignes au moment où je les ai comptées. Rien dans le journal d’accès, rien dans les journaux de PDM, tout venait de ce seul paramètre.

Où vont le secret et le nom du jetonLE SECRETpdm-secrets.envroot seul, 0600l'environnement de Caddy{env.PDM_SECRET_SITE1}en-tête Authorizationsaut local vers PDM seulementLE NOMligne import dansle Caddyfilejournal d'accès, chaque requête de réponse"pdm_token":"site1-2026q4"JAMAIS ÉCRIT DANSle journal systemd--environ retirél'API d'adminsocket unix, 0600un vidage de configmarqueur seulement
Le secret a une seule tâche et un seul chemin. C’est le nom que vous journalisez.

Le fichier de secrets ne contient donc que des secrets :

install -m 0600 /dev/null /etc/caddy/pdm-secrets.env
printf 'PDM_SECRET_SITE1=%s\nPDM_SECRET_SITE2=%s\nPDM_SECRET_SITE3=%s\n' \
  '<secret1>' '<secret2>' '<secret3>' > /etc/caddy/pdm-secrets.env

et la surcharge de l’unité retire --environ et donne un emplacement au socket d’administration :

# systemctl edit caddy
[Service]
EnvironmentFile=/etc/caddy/pdm-secrets.env
RuntimeDirectory=caddy
RuntimeDirectoryMode=0700
# PDM's certificate and key, read where PDM keeps them
ExecStartPre=+/usr/local/sbin/pdm-cert-acl
# the access log directory and its files belong to Caddy
ExecStartPre=+/usr/bin/install -d -o caddy -g caddy -m 0750 /var/log/caddy
ExecStartPre=+/usr/bin/chown -R caddy:caddy /var/log/caddy
ExecStart=
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile
ExecReload=
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force

Le + exécute ces lignes en root, avant que Caddy ne démarre sous son propre utilisateur. Les lignes du journal comptent dès que quoi que ce soit d’autre touche /var/log/caddy : un journal tourné par root, ou un fichier créé à la main pendant les tests, laisse Caddy incapable d’ouvrir son propre journal d’accès. Sur le labo, j’ai mis pdm.log en root:root et redémarré Caddy ; il est revenu en caddy:caddy et Caddy n’a journalisé aucune erreur de permission.

Redémarrez plutôt que de recharger après l’avoir modifié, parce qu’un rechargement ne relit pas l’environnement. Utilisez {env.NAME}, jamais {$NAME} : la seconde forme est substituée au moment où le Caddyfile est adapté, donc le secret atterrit dans la configuration JSON, dans le fichier d’autosauvegarde et dans tout ce qui les vide à l’écran29. Le socket d’administration compte ici aussi. {env.*} résout n’importe quoi dans l’environnement du processus, sans liste d’autorisation, donc quiconque peut charger une configuration via une API d’administration en TCP peut faire relire un secret par Caddy à son intention30.

Chaque ligne de réponse du journal d’accès nomme désormais le site et le jeton, avec l’en-tête lui-même masqué. Depuis le labo, où les deux s’appellent siteA :

{"site":"siteA","pdm_token":"siteA","pdm_status":400,"status":400,
 "request":{"headers":{"Authorization":["REDACTED"]}}}

Je l’ai vérifié au lieu de m’y fier. Sur une copie podman de cette configuration avec des secrets factices, puis sur le labo, j’ai envoyé des réponses par tous les chemins auxquels j’ai pu penser : normal, journalisation de débogage active, amont coupé pour un 502, un rechargement et un redémarrage. Puis j’ai compté chaque secret dans le journal systemd, le journal d’accès, les répertoires de configuration et de données de Caddy et le JSON adapté. Chaque compte était à zéro. L’exécution témoin, la même configuration lancée avec --environ, a affiché le secret deux fois.

Deux réserves sur ce test. Avec debug actif, Caddy journalise toute sa configuration adaptée, donc le jeton générique et les marqueurs {env.*} y apparaissent ; les valeurs qu’ils recouvrent, non. Laissez de toute façon le débogage désactivé en production. Et si un secret est déjà passé par un journal, comme le mien, régénérez-le dans PDM et mettez à jour le fichier ; supprimer des lignes de journal n’est pas la solution.

Le pare-feu : le 8443 ne quitte jamais la machine

Mon labo n’avait aucun pare-feu, donc le 8443 répondait à tous les hôtes du réseau de provisionnement et chaque règle du Caddyfile pouvait être contournée. Fermez-le. nftables, avec une politique par défaut drop :

#!/usr/sbin/nft -f
flush ruleset

define SITES = { 10.10.50.0/24, 10.20.50.0/24, 10.30.50.0/24 }
define MGMT  = { 10.99.0.0/24 }

table inet filter {
	chain input {
		type filter hook input priority filter; policy drop;

		iifname "lo" accept
		ct state established,related accept
		ct state invalid drop
		meta l4proto { icmp, ipv6-icmp } accept

		# PDM's API listens on [::]:8443 and cannot be rebound
		tcp dport 8443 counter drop

		ip saddr $MGMT tcp dport { 22, 80, 443 } accept

		udp dport { 67, 69 } accept
		ip saddr $SITES meta l4proto { tcp, udp } th dport 53 accept
		ip saddr $SITES tcp dport { 80, 443 } accept
	}
	chain forward {
		type filter hook forward priority filter; policy drop;
	}
}

Les connexions de PDM vers ses remotes sont sortantes, donc fermer le 8443 en entrée n’y change rien. Chargez un nouveau jeu de règles avec un retour arrière minuté la première fois, pour qu’une erreur vous coûte quatre-vingt-dix secondes plutôt qu’un déplacement jusqu’à la console :

systemd-run --unit=nft-rollback --on-active=90 /usr/sbin/nft flush ruleset
nft -f /etc/nftables.conf
# still connected? then:
systemctl stop nft-rollback.timer && systemctl enable nftables

Sur le labo, une fois tout cela en place :

VérificationRésultat
le 8443 depuis le réseau de gestionpas de connexion ; le compteur de drop qui grimpe
l’interface web de PDM via Caddy sur le 443, depuis la gestionconnexion et fonctionnement normaux
l’interface web de PDM depuis une adresse de siteinterrompue
POST de réponse depuis un site avec le jeton génériqueatteint PDM avec le jeton du site : une sonde volontairement sans correspondance a reçu 400 no answer file found
la même sonde envoyée directement à PDM avec un jeton inventé401
POST de réponse sans le jeton générique, ou en /api2/extjs/…interrompu
fichiers où vivent les secrets de PDM, lus en tant que caddyaucun lisible
le secret du site dans le journal systemd, le journal d’accès ou les données de Caddy depuis le changement0

DHCP, iPXE et la boucle

Le firmware d’un serveur ne sait récupérer son premier fichier qu’en TFTP, donc TFTP sert le petit chargeur iPXE et iPXE récupère tout le reste en HTTP. Le piège, c’est qu’iPXE envoie sa propre requête DHCP, et un serveur qui lui répond encore ipxe.efi fait tourner la machine en rond pour toujours. Le serveur DHCP doit distinguer le firmware d’iPXE.

Pourquoi une réponse DHCP simple boucleRéponse simple : ça bouclele firmware demande un fichier de démarrageréponse : ipxe.efiiPXE démarre et redemandemême réponse : ipxe.efile serveur ne distingue pas le firmwared'iPXE, donc il n'avance jamaisQui reconnaît iPXE : ça marchele firmware demande un fichier de démarragepas iPXE : ipxe.efi en TFTPiPXE redemande, option 175 présenteréponse : l'URL de boot.ipxe, en HTTPnoyau, initrd et ISO, puis installation
La même requête deux fois reçoit la même réponse deux fois. L’option 175, que seul iPXE envoie, permet au serveur de distinguer la seconde requête de la première.
Serveur DHCPComment il casse la boucle
dnsmasqmarque les requêtes qui portent l’option 175, que seul iPXE envoie, et leur remet l’URL HTTP31
ISC dhcpdif exists user-class and option user-class = "iPXE"32
FortiGatele champ filename ne prend qu’une valeur, donc construisez un chargeur iPXE avec l’URL intégrée et servez-le à tout le monde

dnsmasq, qui sert son propre réseau et les deux autres via des relais, avec des réservations uniquement :

# /etc/dnsmasq.d/pxe.conf
local=/example.doc/
domain=example.doc
address=/pdm.example.doc/10.10.50.10
txt-record=proxmox-auto-installer.example.doc,"https://pdm.example.doc/api2/json/auto-install/answer"
txt-record=proxmox-auto-installer-cert-fingerprint.example.doc,"63:C4:D6:...:85:07"

dhcp-authoritative
dhcp-range=set:site1,10.10.50.0,static,255.255.255.0,1h
dhcp-range=set:site2,10.20.50.0,static,255.255.255.0,1h
dhcp-range=set:site3,10.30.50.0,static,255.255.255.0,1h
dhcp-option=tag:site1,option:router,10.10.50.1
dhcp-option=tag:site2,option:router,10.20.50.1
dhcp-option=tag:site3,option:router,10.30.50.1
dhcp-option=option:dns-server,10.10.50.10
dhcp-option=option:domain-search,example.doc

dhcp-match=set:ipxe,175
dhcp-match=set:efi64,option:client-arch,7
dhcp-match=set:efi64,option:client-arch,9
dhcp-boot=tag:efi64,tag:!ipxe,ipxe.efi
dhcp-boot=tag:ipxe,http://pdm.example.doc/pve-9.2/boot.ipxe
# no option 250 anywhere: DNS discovery depends on it

enable-tftp
tftp-root=/srv/tftp
tftp-secure
# TFTP block-size ceiling: lower it for an IPsec or other tunnel in the path
tftp-mtu=1500

# dhcp-host=<mac>,<ip inside that site's /24>,<hostname>
dhcp-host=aa:bb:cc:dd:ee:01,10.10.50.21,pve01
dhcp-host=aa:bb:cc:dd:ee:02,10.20.50.21,pve01

static veut dire pas de plage dynamique : seules les machines réservées reçoivent une adresse, ce qui tient les inconnus à l’écart du chemin d’installation. L’adresse de chaque réservation doit se trouver dans le /24 de son propre site, parce que c’est cette adresse que Caddy utilise pour choisir le jeton. Un nœud réservé dans le mauvais sous-réseau reçoit la réponse du mauvais site, ou rien.

Les réservations sont une liste d’adresses MAC, et c’est exactement le genre de comptabilité machine par machine dont le client ne voulait pas. Si brancher et démarrer est vraiment l’exigence, donnez plutôt à chaque VLAN de provisionnement une petite plage dynamique, par exemple dhcp-range=set:site1,10.10.50.100,10.10.50.150,255.255.255.0,1h. Le site vient toujours du sous-réseau, donc Caddy choisit toujours le bon jeton, et le modèle de FQDN avec installation_nr nomme le nœud. Le prix, c’est la première ligne de « Ce qui reste à décider » : tout ce qui est branché sur ce VLAN peut désormais récupérer la réponse du site, si bien que le VLAN lui-même devient la barrière.

dnsmasq négocie la taille de bloc TFTP avec le client. Livré à lui-même, il la plafonne à la MTU de sa propre interface31, ce qui marche jusqu’au jour où le chemin vers un site est plus petit que le lien sur lequel se trouve dnsmasq. La configuration fixe donc le plafond avec tftp-mtu=1500 au lieu d’en hériter. Cela met le chiffre sous les yeux de quiconque s’occupe d’un site derrière un tunnel IPsec ou n’importe quoi d’autre qui grignote le paquet, et il le baisse à ce que le chemin transporte vraiment, au lieu de le découvrir par un transfert qui cale en route. Si une ROM de carte réseau se comporte mal dès que l’option est accordée, tftp-no-blocksize coupe la négociation.

Copiez ipxe.efi depuis le paquet ipxe de Debian dans /srv/tftp, avec l’utilisateur dnsmasq comme propriétaire, parce que tftp-secure refuse tout le reste. Préférez l’UEFI. Secure Boot exige un iPXE signé, donc soit vous mettez cela en place, soit vous désactivez Secure Boot pendant l’installation et le réactivez ensuite.

Démarrer, surveiller, et ce qui casse

Testez avec une seule machine virtuelle sur un réseau de site avant de toucher au matériel : un firmware UEFI, 8 Gio de mémoire et une carte réseau sur ce VLAN reproduisent toute la chaîne.

Ce qui se passe au démarrageServeur cibledémarrage réseau UEFI6 Gio ou plus1. DHCP et TFTPun bail, un domaine de recherche, puis ipxe.efi2. HTTP sur le port 80boot.ipxe, vmlinuz, initrd.img et l'ISO, en mémoire3. DNSdeux enregistrements TXT : l'URL de réponse et l'empreinte de PDM4. Caddy sur le 443, puis PDMla réponse tout de suite, le webhook après le redémarrage
Quatre conversations séparées, chacune avec son propre endroit où regarder quand elle échoue.

Et le tout dans l’ordre, de la mise sous tension à une installation que PDM marque Finished :

De la mise sous tension à l'installation complèteCiblednsmasq, :80Caddy :443PDMDHCP : un bailTFTP : ipxe.efiiPXE redemandeHTTP : noyau, initrd, ISOinstallateur : un domaineDNS : deux TXTPOST, jeton génériquejeton du siteen coursla réponse générée, via Caddyinstalle sur disquewebhook, jeton uniqueAuthorization retiréFinishedredémarre ; 1er bootactive la clé
Chaque étape, dans l’ordre. Le webhook part de l’installateur avant le redémarrage, donc Finished veut dire installé, pas démarré ; la clé de souscription est activée ensuite, au premier démarrage.
ÉtapeCe qui se passeOù regarder
1le firmware reçoit ipxe.efi en TFTP, iPXE reçoit boot.ipxe en HTTPjournalctl -u dnsmasq, le journal de Caddy
2au bout de dix secondes, le menu lance l’entrée automatique ; noyau, initrd et ISO se téléchargentquatre GET, dont un d’environ 1,6 Go
3l’installateur prend un bail, lit le domaine de recherche, interroge les deux enregistrements TXT/tmp/fetch_answer.log sur la cible
4POST vers Caddy, jeton remplacé, PDM choisit une réponsele journal de Caddy nomme le jeton ; PDM affiche une nouvelle installation
5l’installation se déroule/tmp/auto_installer.log, /tmp/install-low-level-start-session.log
6toujours dans l’installateur, le webhook rappelle via Caddy, puis la machine redémarre33l’installation passe à Finished avec disques, cartes réseau et clés d’hôte SSH rattachés

En cas d’échec, l’installateur tombe sur un shell, sauf si la réponse active Reboot on Error, laissez donc ce réglage désactivé pendant les tests.

SymptômeCause probable
Could not find search domain in resolv.conf.le DHCP n’a envoyé aucun domaine
une requête TXT pour un nom contenant une espaceplus d’un domaine de recherche
Got empty response.enregistrement absent, mauvaise zone, ou l’option 6 pointe vers un autre serveur DNS
une URL avec une espace au milieuvaleur TXT découpée en plusieurs chaînes
le journal dit que l’URL vient du DHCPl’option 250 est présente quelque part sur le réseau
erreur TLS sur le POSTl’empreinte du DNS n’est pas celle du certificat actuel de Caddy
connexion fermée sans réponseCaddy l’a interrompue : mauvais sous-réseau, pas de jeton générique, ou une route qui n’est pas l’une des deux
400 no answer file foundle jeton a fonctionné ; aucun filtre n’a correspondu et il n’y a pas de réponse par défaut sur ce jeton
la mauvaise réponsedes filtres qui se chevauchent ; le premier du fichier gagne
le statut reste en coursl’URL de base du webhook pointe encore sur le 8443
iPXE se recharge sans finle serveur DHCP ne distingue pas iPXE du firmware
délai TFTP PXE-E32next-server erroné, ou UDP 69 bloqué

Ajouter Proxmox Backup Server

Rien dans ce chemin n’est propre à Proxmox VE. Construisez un second jeu de fichiers de démarrage à partir de l’ISO de PBS 4 avec le même jeton générique, dans /srv/www/pbs-4/, et ajoutez une réponse PBS par site avec le filtre /product/product=pbs, autorisée sur le jeton de ce site. Les enregistrements DNS, Caddy, les jetons et le pare-feu ne changent pas.

Pour un site qui a les deux, il faut que quelque chose décide quel produit reçoit une machine, et un menu n’est pas le bon outil pour le décider. Le choose d’iPXE sans --timeout attend à la console qu’on appuie sur une touche, indéfiniment, ce qui est tout le contraire d’une installation automatique.

Laissez plutôt la réservation DHCP décider. Vous réservez déjà chaque nœud par adresse MAC, alors marquez ceux qui seront des PBS et envoyez à chaque marque son propre boot.ipxe :

# /etc/dnsmasq.d/pxe.conf, replacing the single iPXE dhcp-boot line
dhcp-host=aa:bb:cc:dd:ee:01,10.10.50.21,pve01
dhcp-host=aa:bb:cc:dd:ee:31,set:pbs,10.10.50.31,pbs01

dhcp-boot=tag:ipxe,tag:!pbs,http://pdm.example.doc/pve-9.2/boot.ipxe
dhcp-boot=tag:ipxe,tag:pbs,http://pdm.example.doc/pbs-4/boot.ipxe

set:pbs sur une réservation marque cette seule machine31, et tag:!pbs rend les deux lignes de démarrage mutuellement exclusives, donc la question de savoir laquelle l’emporte ne se pose pas. Le boot.ipxe de chaque produit lance déjà son entrée automatique au bout de dix secondes sans personne au clavier15.

Ces lignes passent dnsmasq --test en 2.91, la version de mon labo, et suivent la syntaxe documentée de dhcp-host et des marques. Je n’ai pas encore démarré de nœud PBS en PXE à travers elles, alors essayez une machine marquée avant de vous y fier.

À partir de là, c’est le même chemin que pour Proxmox VE. L’installateur PBS envoie "product": "pbs", Caddy y substitue le jeton du site, et PDM choisit la réponse PBS de ce site. Rien n’est choisi à la main à aucun moment, et le produit que devient un nœud est écrit à un seul endroit, la réservation, à côté de son adresse.

Ce qui décideOù cela vit
quelle ISO démarrela marque set:pbs sur la réservation DHCP
quelles réponses de site s’appliquentle sous-réseau source, via la substitution de jeton dans Caddy
quelle réponse au sein de ce sitele filtre /product/product=pbs dans PDM

L’installateur ne pose que le système d’exploitation. Le datastore, sur un stockage séparé du disque système, est un autre travail pour ce qui finalise le nœud, ce qui nous ramène au hook de premier démarrage absent.

Ce qui reste à décider

Aucun de ces points ne vous empêche d’utiliser cette conception. Chacun a besoin de quelqu’un qui en soit responsable.

CompromisPourquoi c’est importantQue faire
l’adresse source est la barrièretout ce qui est sur un VLAN de provisionnement, ou capable d’y usurper une adresse, peut récupérer la réponse de ce site : hash du mot de passe root, clés SSH, clé de souscriptionisoler le VLAN, port security, réservations uniquement, expiration des jetons, désactiver les jetons entre deux déploiements
le jeton générique n’est pas un secretil est dans chaque image et sur cette pageil ne fait que filtrer les requêtes égarées ; la barrière, ce sont le sous-réseau et le vrai jeton détenu par Caddy
Caddy peut lire la clé de PDMservir le certificat de PDM implique que le proxy lise cette clé privée-làune ACL sur api.pem et api.key seulement, rien d’autre de PDM lisible, l’hôte verrouillé ; la clé ne le quitte jamais
le DNS est de confianceun enregistrement TXT forgé envoie l’installateur ailleurs, et si l’empreinte vient aussi du DNS, le faussaire contrôle les deuxprotéger le DNS de provisionnement, ou intégrer l’empreinte de PDM avec --cert-fingerprint et reconstruire quand elle change
les fichiers de démarrage passent en HTTP clairils peuvent être lus ou remplacés sur le filgarder le réseau isolé ; iPXE en HTTPS si votre build le permet
le jeton du webhook16 octets aléatoires, à usage unique, effacé après le rappel, mais sans limite de durée4purger les anciens enregistrements d’installation
la clé de souscriptionactivée une fois quand le réseau est monté ; en cas d’échec, la clé est supprimée et l’unité échoue, sans nouvelle tentative34vérifier systemctl --failed sur les nouveaux nœuds
PDM est un point uniqueil n’est pas hautement disponible aujourd’huiune panne arrête les nouvelles installations, pas les clusters en service
une seule carte réseau de gestionla réponse n’a pas d’options de bond, de VLAN ou de bridge9c’est du travail après installation, avec le même problème de premier démarrage

Une première version, sur laquelle on bâtit proprement

C’est une première version de la fonction, et pour une première version, elle est bonne. Le formulaire de réponse est sensé, les règles de sélection sont claires une fois qu’on les a lues, le webhook fonctionne, et le code est ouvert, ce qui est la seule raison pour laquelle tout ce qui précède a pu être prouvé plutôt que deviné. La série de correctifs est passée par quatre tours sur la liste de diffusion publique avant d’être intégrée35, et ça se voit.

Les manques sont précisément les deux choses qui transforment un serveur d’installation en système de provisionnement. Le site est l’unité dans laquelle pense un MSP, et l’installateur se tient dessus au moment où il pose sa question. Un hook de premier démarrage fait la différence entre un nœud installé et un nœud terminé. Les deux sont déjà à moitié là : l’installateur a le hook et PDM a le jeton. Je m’attends à ce que les deux arrivent, et quand ce sera le cas, le Caddy au milieu de cette conception deviendra plus simple au lieu de disparaître, parce qu’un proxy qui tient les secrets de PDM hors de portée et le 8443 hors du réseau vaut la peine d’être gardé de toute façon.

La raison de procéder ainsi en attendant est celle du début. Dix serveurs PXE, ce sont dix choses qui prennent du retard à dix vitesses différentes, et chacune n’est regardée que le jour où elle tombe en panne, justement le jour où vous en avez besoin. Un seul parc, tenu à jour une seule fois, où le site est déduit par le réseau et où les secrets sont gardés en un seul endroit, c’est moins de choses à porter. Moins de choses à porter, c’est moins de choses à rater, et c’est l’essentiel du métier.


  1. Proxmox — Proxmox Datacenter Manager 1.1 press release — les installations automatiques, avec un nouvel onglet sous Remotes qui suit la progression des installations. ↩︎

  2. Proxmox Datacenter Manager documentation — Automated Installations — réponses préparées, filtres de cible, modèles, et « a dedicated token mechanism, separate from the normal API tokens ». ↩︎ ↩︎

  3. proxmox-datacenter-manager, lib/pdm-api-types/src/auto_installer.rs — les clés des filtres de cible « are JSON Pointers as per [RFC6901], the values globs » ; une seule réponse par défaut. ↩︎

  4. proxmox-datacenter-manager, server/src/api/auto_installer/mod.rs — vérification du jeton avant la réponse par défaut, première correspondance dans l’ordre du fichier, first_boot: None, incrément des compteurs, le jeton de post-hook à usage unique, les deux routes accessibles sans connexion PDM, et les privilèges Sys.Audit/Sys.Modify sur /system/auto-installation. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  5. Proxmox VE Roadmap — Proxmox VE 9.2 — préparation PXE et iPXE, « at least 6 GiB of available memory on the host », et le problème connu megaraid_sas et NVMe en JBOD. ↩︎ ↩︎ ↩︎

  6. Proxmox Bugzilla 8078 — « prepared answer disk setup does not allow to specify multiple udev filters with same name », ouvert le 26 septembre 2026, statut NEW au 7 novembre 2026. ↩︎

  7. Proxmox Bugzilla 7699 — « Add on-first-boot parameter to the PDM remote installation », ouvert le 12 juin 2026, statut NEW au 7 novembre 2026. ↩︎ ↩︎

  8. proxmox, proxmox-installer-types/src/lib.rs — SystemInfo : produit, ISO, DMI et interfaces réseau. ↩︎

  9. Proxmox VE wiki — Automated Installation — les options [first-boot] et [network], les informations système envoyées, et le fait que le jeton d’authentification « is stored in plain text inside the prepared ISO ». ↩︎ ↩︎ ↩︎ ↩︎

  10. Proxmox Bugzilla 8263 — « [Feature Request] auto-installer: client source IP (or X-Forwarded-For) for answer selection and templating », ouvert le 31 octobre 2026, statut NEW au 7 novembre 2026. ↩︎

  11. proxmox-datacenter-manager, debian/changelog — 1.1.1 : « match prepared answers in the configuration’s stable order and let the first match win ». ↩︎

  12. proxmox, proxmox-installer-types/src/answer.rs — AutoInstallerConfig porte first_boot: Option<FirstBootHookInfo>. ↩︎

  13. pve-installer, proxmox-auto-installer/src/bin/proxmox-auto-installer.rs — l’exécutable de premier démarrage n’est mis en place que si la réponse a une section first_boot. ↩︎

  14. pve-installer, proxmox-fetch-answer/src/fetch_plugins/http.rs — URL de l’ISO d’abord, puis DHCP, puis DNS ; les noms TXT ; l’analyse de resolv.conf ; dig txt +short avec les guillemets retirés ; l’en-tête Bearer. ↩︎ ↩︎

  15. pve-installer, proxmox-auto-install-assistant/src/main.rs — --pxe-loader implique --pxe, le répertoire de sortie, l’initrd en gzip, /boot retiré de l’ISO, et le boot.ipxe généré. ↩︎ ↩︎ ↩︎

  16. proxmox-datacenter-manager, server/src/bin/proxmox-datacenter-api/main.rs — PDM_LISTEN_ADDR est l’adresse IPv6 non spécifiée sur PDM_PORT, 8443, fixée à la compilation. ↩︎

  17. Caddy releases — la 2.11.1 a corrigé CVE-2026-27587, un contournement du filtre de chemin par séquences d’échappement. ↩︎

  18. Debian sources — caddy — 2.6.2-12+deb13u1 dans trixie, 2.11.2-1~bpo13+1 dans trixie-backports. ↩︎

  19. Caddy — request_header — « With no prefix, the field is set (overwritten). » ↩︎

  20. Caddy — Request matchers — remote_ip est le pair immédiat ; les correspondances de chemin ignorent la casse, sont décodées, nettoyées, avec les barres obliques répétées fusionnées. ↩︎ ↩︎

  21. Caddy — handle — « only the first matching handle block will be evaluated ». ↩︎

  22. Caddy — Global options — admin vaut par défaut localhost:2019 ; protocols vaut par défaut h1 h2 h3. ↩︎ ↩︎

  23. Caddy — file_server — hide ne doit pas être traité comme une frontière de sécurité. ↩︎

  24. Caddy — log — Cookie, Set-Cookie, Authorization et Proxy-Authorization sont journalisés comme REDACTED. ↩︎

  25. systemd.service — ExecCondition= — pour un code de sortie de 1 à 254, « the remaining commands are skipped and the unit is not marked as failed ». ↩︎

  26. systemd.path — PathChanged= — « activated if the file which was open for writing gets closed ». ↩︎

  27. caddyserver/dist, init/caddy.service — ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile. ↩︎ ↩︎

  28. Caddy — Command line — --environ affiche l’environnement avant le démarrage. ↩︎

  29. Caddy — Caddyfile concepts, environment variables — {$NAME} est substitué avant l’analyse du Caddyfile. ↩︎

  30. caddyserver/caddy, replacer.go — toute clé commençant par env. est passée à os.Getenv. ↩︎

  31. dnsmasq manual page — dhcp-match, dhcp-boot, dhcp-host avec set:<tag>, enable-tftp, tftp-secure, tftp-mtu, tftp-no-blocksize et les plages static. ↩︎ ↩︎ ↩︎

  32. iPXE — Configuring ISC dhcpd — if exists user-class and option user-class = "iPXE" remet le script à iPXE et le chargeur au firmware. ↩︎

  33. pve-installer, unconfigured.sh — proxmox-post-hook s’exécute juste après la réussite de proxmox-auto-installer, avant le redémarrage. ↩︎

  34. pve-installer, proxmox-first-boot/proxmox-first-boot-subscription — « always drop the staged key, regardless of whether activation succeeded ». ↩︎

  35. pdm-devel — “add auto-installer integration”, v4, 40 patches — la série qui a ajouté la fonction, avec ses notes de changement de la v3 à la v4. ↩︎