Eén console, veel klanten

Als je Proxmox voor andere mensen beheert, is elke nieuwe node hetzelfde werk, telkens net iets anders gedaan. Elke klant heeft zijn eigen land, tijdzone, root-keys, schijfindeling, naamgeving van hosts en subscription key. Zit er één daarvan fout op een verse node, dan merk je dat de eerste keer dat iemand inlogt, of de eerste keer dat een audit vraagt waarom de klok een uur afwijkt.

Ik heb dit gebouwd voor een klant met precies dat probleem, en wat ze vroegen was verfrissend eenvoudig. Elke site wordt gebouwd met zijn eigen naamgeving, zijn eigen root-wachtwoord en zijn eigen SSH-keys. Niemand op de site doet iets slims: ze zetten een server in het rack, prikken hem in het juiste VLAN en zetten hem aan. Niemand verzamelt serienummers, en niemand schrijft een antwoordbestand per machine. En een server die zo wordt toegevoegd, komt in het bestaande cluster van de site terecht.

Wat ze wildenWat het hier levert
de eigen naamgeving, het wachtwoord en de keys van de siteéén voorbereid antwoord per site, gekozen door het token van die site
in het juiste VLAN prikken, aanzetten, weglopenDNS-discovery, iPXE, en Caddy die het token kiest op basis van het bronsubnet
geen serienummers, geen antwoordbestanden per machineantwoorden filteren alleen op het product; de site komt uit het netwerk
de nieuwe server treedt toe tot het bestaande clusterniet in dit stuk: PDM heeft geen first-boot-hook, zie verderop

Dit stuk behandelt de eerste drie: een kale server van aanzetten tot een geïnstalleerde Proxmox-node met de instellingen van de juiste site. Die helft speelt bij bijna iedereen met meer dan één site, MSP of niet, dus het is de moeite waard om die apart te hebben. Toetreden tot het cluster is een aparte klus, en de sectie over de ontbrekende first-boot-hook legt uit waarom PDM dat nog niet voor je kan doen.

Proxmox Datacenter Manager (PDM) 1.1 heeft het ontbrekende stuk toegevoegd1. Het kan nu optreden als antwoordserver voor de geautomatiseerde installer van Proxmox, zodat een kale machine opstart, doorgeeft wat hij is, een voorbereid antwoord terugkrijgt en zichzelf installeert zonder dat er iemand achter de console zit. Voor een managed service provider (MSP) die PDM al als beheerconsole draait, is dat de voor de hand liggende plek om de installatie-instellingen van elke klant te bewaren.

Wat je niet wilt, is een PXE-server per klant. Elk daarvan is een Trivial File Transfer Protocol (TFTP)-server, een DHCP-configuratie, een set bootbestanden en een ISO die je opnieuw moet bouwen telkens als Proxmox een point release uitbrengt. Tien klanten zijn er tien om bij te houden. Dat schaalt niet. Het doel hier is één PXE-omgeving, één boot-image, één DNS-record en één PDM, waarbij elke site toch zijn eigen antwoord krijgt.

Eén PXE-omgeving voor elke sitesite110.10.50.0/24site210.20.50.0/24site310.30.50.0/24Caddy op 443catch-token erinsitetoken eruit,gekozen op subnetPDMantwoordenper token8443 op loopbackEén boot-image, één DNS-record en één DHCP-dienst voor elke site
Het uiteindelijke ontwerp. Elke site boot hetzelfde image. Caddy bepaalt aan het subnet van welke site een request kwam en geeft PDM het token van die site, en PDM biedt alleen antwoorden aan die op dat token zijn ingesteld.

PDM 1.1 komt er zelf al grotendeels. Het schiet op twee punten tekort, en die wil je allebei kennen voordat je een uitrol eromheen plant: het kan een antwoord niet koppelen aan een site, en het kan een nieuwe node geen first-boot-script meegeven. Dit stuk bewijst beide uit de eigen broncode van PDM, werkt om het eerste heen, en bouwt het geheel op mijn lab-PDM met 1.1.9, met de proxy zo gehard dat de delen die geheimen bevatten dicht blijven.

Wat PDM 1.1 je geeft

De functie zit onder Remotes → Automated Installations en heeft drie delen: installaties, voorbereide antwoorden en authenticatietokens2. Een voorbereid antwoord is de PDM-versie van het answer.toml van de installer, ingevuld via een formulier in plaats van met de hand geschreven.

DeelWat het doetDetail dat ertoe doet
Voorbereide antwoordenland, toetsenbord, tijdzone, hash van het root-wachtwoord, SSH-keys, netwerk, schijfindeling, subscription key, actie na de installatievoor elk request vers gerenderd
Doelfiltersbepalen welke machine welk antwoord krijgtJSON Pointer in de systeeminformatie van de installer, glob voor de waarde3
TemplatingMiniJinja in de velden FQDN, admin-e-mail, IP, gateway en DNStellers zoals installation_nr gaan omhoog telkens als er een antwoord wordt geserveerd4
Authenticatietokensde installer stuurt Authorization: Bearer <name>:<secret>een ander tokentype dan de API-tokens van PDM; elk antwoord somt de tokens op die het accepteert2
Installatieséén record per request, met de volledige systeeminformatiegaat van in uitvoering naar voltooid wanneer de post-install-webhook terugbelt
RechtenSys.Audit om te kijken, Sys.Modify om te wijzigenallebei op /system/auto-installation4

De kant van de installer bestaat al een tijdje. Het commando proxmox-auto-install-assistant prepare-iso bouwt een ISO die zijn antwoord over HTTP ophaalt, en sinds de ISO van Proxmox VE 9.2 kan het ook de kernel, initrd, ISO en boot.ipxe maken die iPXE nodig heeft om het geheel over het netwerk te booten5.

De tellers zijn het deel waar een losse answer.toml niets tegenover heeft. Eén voorbereid antwoord nummert elke host die het bouwt, waar een statisch bestand per machine gekopieerd zou moeten worden. Dat is een echte stap vooruit, en het blijft een eerste versie. Het formulier is nog niet gelijkwaardig aan het losse antwoordbestand6, en één onderdeel ontbreekt helemaal: een first-boot-script bestaat in 1.1 in geen enkele vorm7. Dat gat, en de locatie waarop het niet kan filteren, zijn de volgende twee secties.

Het kan niet matchen op de site

PDM kiest een antwoord door filters te vergelijken met de systeeminformatie die de installer post. De vraag is dus wat er in dat document staat, want wat er niet in staat, kan niet gematcht worden.

SleutelWat erin staatKan het de site noemen?
productfullname, product (pve, pbs, pmg), enable_btrfsnee
isorelease, isoreleasenee
dmisysteem, baseboard en chassis: naam, serienummer, UUID, SKU, asset tagalleen als iemand de site in de asset tag heeft gezet
network_interfaceseen lijst van {link, mac}alleen via een inventaris die je zelf bijhoudt

Dat is alles8,9. Geen adres, geen hostnaam, geen DHCP-optie, niets over waar de machine ingeplugd zit.

De velden van de Desktop Management Interface (DMI) zien er veelbelovend uit tot je echte exemplaren leest. Elke installatie in mijn lab kwam terug met de systeemnaam Standard PC (Q35 + ICH9, 2009) en een leeg serienummer en lege asset tag. Echte servers vullen ze wel in, maar met wat de leverancier erin zette en niet met de naam van je klant, en matchen op serienummers of MAC-adressen werkt alleen als je een lijst bijhoudt van elk chassis dat je ooit gaat installeren, en dat is precies de administratie die dit had moeten wegnemen. Per host, ja. Per site, nee.

Op welk netwerk zit de machine dan? PDM komt het nooit te weten. De installer heeft zijn DHCP-lease al in handen als hij post, en het ene feit dat een MSP zeker weet over een nieuwe node, is precies het feit dat hij achterlaat. Matchen op het bronadres, IPv4 of IPv6, is een open featureverzoek, en ik heb het zelf ingediend als bug 826310.

Er zit een tweede beperking verstopt in de selectieregels, en mijn lab liep er recht in.

Hoe PDM een antwoord kiest1. Token geldig?ingeschakeld, niet verlopennee401 na ongeveer een halve secondeniet vastgelegd2. Antwoorden op dat tokenin bestandsvolgorde, niets anders3. Matchen alle filters?alleen strings, globs toegestaaneersteantwoord gerenderdelke teller erop gaat 1 omhooggeen4. Standaard op dit token?één standaard in heel PDMnee400: no answer file foundvastgelegd onder Installations
De selectie van PDM, uit de broncode. Het token wordt eerst gecontroleerd en bepaalt de reikwijdte van alles daarna, het standaardantwoord inbegrepen.

Het token wordt eerst gecontroleerd, en alleen antwoorden die op dat token zijn toegestaan, worden überhaupt bekeken, het standaardantwoord inbegrepen4. PDM staat in het hele systeem precies één standaardantwoord toe. Je kunt elke site dus geen eigen fallback geven met de standaardvlag. Mijn lab had een standaardantwoord met de naam fallback op een eigen token, en het is nog nooit één keer geserveerd, omdat niets dat token ooit verstuurt. Elke site heeft in plaats daarvan een vangnetfilter op zijn eigen token nodig, zoals /product/product=*, als laatste in het bestand.

Nog twee dingen uit dezelfde code die je in je hoofd wilt hebben als je debugt:

  • De eerste match wint, in bestandsvolgorde, en een tweede match logt alleen een waarschuwing. Dat is de regel sinds 1.1.111.
  • Een filter matcht alleen strings. /product/enable_btrfs is een boolean en matcht nooit iets, en een array heeft een index nodig, zoals in /network_interfaces/0/mac.

Op 1.1.9 krijgt een onbekend of verlopen token een 401 na een bewuste vertraging van ongeveer een halve seconde, en laat het geen record achter. Een goed token waar niets op matcht, krijgt een 400 met de body no answer file found, en die poging wordt vastgelegd, met de volledige systeeminformatie. Dat record is de snelste manier om de echte serienummers en MAC-adressen van een nieuw chassis te lezen voordat je er een filter voor schrijft.

Het kan geen first-boot-script draaien

De installer zelf ondersteunt een first-boot-hook. Een antwoord kan een sectie [first-boot] bevatten met source = "from-iso" of "from-url", een volgorde van before-network, network-online of fully-up, en optioneel een certificaatfingerprint9. prepare-iso --on-first-boot sluit voor het geval from-iso een script in de ISO in.

PDM stuurt die sectie nooit. Geen enkele keer. Het eigen antwoordtype van de installer heeft het veld, first_boot: Option<FirstBootHookInfo>12. Het voorbereide antwoord van PDM heeft er helemaal geen veld voor, en de code die daaruit het antwoord bouwt, schrijft onvoorwaardelijk first_boot: None4. Er staat niets in het formulier, niets in de API en niets in het configuratiebestand dat je met de hand zou kunnen toevoegen. Het is een open featureverzoek, bug 7699, ingediend in juni 2026 en bij het schrijven nog aan niemand toegewezen7. Bijna vijf maanden later heeft Proxmox er niet op gereageerd, en op 8263 ook niet. Dat zwijgen is het zorgwekkende deel. Iemand die de tijd neemt om op te schrijven hoe je product beter kan, verdient een antwoord, ook als dat antwoord nee is, en dat is gewone professionele beleefdheid. Des te meer als de mensen die schrijven betalende klanten en partners zijn, die al geld in het product steken voordat ze er ook nog hun tijd in steken.

En het script op de ISO redt je niet. De installer zet de first-boot-service alleen op if let Some(first_boot) = &answer.first_boot, en dat is het enige pad dat het ingesloten bestand ooit leest13. prepare-iso waarschuwt niet als je een script insluit waar geen enkel antwoord ooit om zal vragen. Het staat op de ISO en doet niets.

Het first-boot-gatScript op de ISOprepare-iso --on-first-bootAntwoord van PDMfirst_boot: None, altijdInstallerzet de first-boot-hook opalleen als het antwoord dit heeft:[first-boot]het script blijft op de ISOen draait nooit, zonder waarschuwing
Beide helften bereiken de installer, en de helft van PDM vraagt nooit om de hook. Het script op de ISO is dode ballast.

Dat weegt voor een MSP zwaarder dan voor wie dan ook. Een node uit de installer is een kaal systeem met een gehasht root-wachtwoord en wat SSH-keys. Het is geen PDM-remote, het zit niet in een cluster, en er staat niets op van je monitoring, repositories of storage. In mijn lab werden alle drie de nodes afgerond en belden ze terug, en de remote SITEA in PDM moest daarna nog steeds met de hand worden toegevoegd.

Dus wat je ook gebruikt om een node af te maken, of dat nu Ansible is of iets anders, het moet van buitenaf worden aangestoten: vanuit de webhook na de installatie, vanuit een job die de lijst Installations in de gaten houdt, of door een mens. Ik laat het daarbij. Dit stuk gaat erover hoe één PXE-omgeving elke site bedient, en het first-boot-gat is een beperking om omheen te plannen, niet een die ik hier ga wegpoetsen.

Het token is de site

PDM ziet de site niet, maar het beperkt antwoorden wel per token, en een token is gewoon een header. Wie de header zet, kiest welke set antwoorden de installer kan krijgen. Dat is de hele truc.

Zet dus iets voor PDM dat weet waar het request vandaan kwam:

  1. Het boot-image bevat een catch-token, catch-all:not-a-secret, dat in elk image hetzelfde is en hier gewoon gepubliceerd staat. Het is geen PDM-token en opent op zichzelf niets.
  2. Een Caddy-reverse-proxy voor PDM kijkt naar het bronsubnet van een antwoordrequest met het catch-token, en vervangt de header door het echte token van die site.
  3. PDM ziet site2-2026q4:<secret> en biedt alleen de antwoorden van site2 aan.

Het secret komt nooit in de buurt van het boot-image, dus een verloren USB-stick of een gekopieerde ISO levert niemand iets op. Het token van een site roteren is een wijziging in Caddy, geen nieuw image. En een site toevoegen is één regel Caddy-configuratie en één nieuw token in PDM.

Dit leunt erop dat het bronadres eerlijk is, en dat is een eigenschap van je netwerk, niet van de proxy. Die afweging en de andere staan hieronder onder “Wat er nog te beslissen valt”.

Hoe de installer PDM vindt zonder ingebakken URL

Eén image voor elke site werkt alleen als het image ook het adres van PDM niet bevat. De installer zoekt zijn antwoord-URL op drie plekken, in volgorde14:

Waar de installer zijn antwoord-URL vindt1. URL ingebakken in de ISO?prepare-iso --urljagebruikt, DHCP en DNS nooit gevraagdéén image per PDM: hier vermedennee2. DHCP-optie 250?gelezen uit de lease van de installerjagebruikt, met optie 251 voor defingerprint: hier niet verstuurdnee, of een DHCP-fout3. DNS-TXT-lookupzoekdomein uit resolv.confproxmox-auto-installer.example.docURL en fingerprint, allebei uit DNS
De volgorde die de installer aanhoudt. Laat de URL uit het image weg en stuur geen optie 250, en elke site valt door naar DNS.
RegelWat de code doetWat het voor jou betekent
Volgordeeen URL in de ISO wint; anders DHCP; elke DHCP-fout valt terug op DNS14bouw het image zonder --url, en stuur nergens optie 250
Recordnamenproxmox-auto-installer.<search domain> voor de URL, proxmox-auto-installer-cert-fingerprint.<search domain> voor de fingerprintéén zone, twee TXT-records
Zoekdomeinde eerste regel search van /etc/resolv.conf, gesplitst op één spatiegeef precies één domein uit; twee worden één ongeldige naam
Geen domeinfaalt met Could not find search domain in resolv.conf.controleer dat DHCP-optie 15 of 119 wordt meegestuurd
TXT-waardedig txt +short, elke " verwijderd, daarna getrimdeen TXT-waarde die in meerdere strings is gesplitst, laat een spatie in de URL achter; houd het één string
Fingerprintalleen uit DNS gehaald als de URL ook uit DNS kwam en er geen is ingebakkenpubliceer de certificaatfingerprint van PDM, die Caddy ook serveert
Tokenverstuurd als Authorization: Bearer <token>, in platte tekst opgeslagen op de ISO9en daarom bevat het image alleen het catch-token

Het certificaat dat de installer controleert, is het certificaat dat op 443 antwoordt. In dit ontwerp serveert Caddy daar het eigen certificaat van PDM, zodat een client op 443 hetzelfde certificaat ziet als PDM op 8443 heeft, en er één fingerprint te publiceren is.

Bouwen: PDM, bootbestanden en DNS

De voorbeelden gebruiken het domein example.doc en drie sites, 10.10.50.0/24, 10.20.50.0/24 en 10.30.50.0/24. .doc is geen echt topleveldomein, dus vervang het door je eigen domein. PDM, Caddy, dnsmasq en de bootbestanden staan allemaal op één host, pdm.example.doc op 10.10.50.10, en de andere sites bereiken die via een DHCP-relay op hun gateways. Beheer zit op 10.99.0.0/24 en is, als zodanig, het enige netwerk dat de web-UI van PDM ooit te zien krijgt.

Tokens en antwoorden in PDM

Voeg onder Remotes → Automated Installations → Authentication Tokens één token per site toe en geef het een vervaldatum die past bij je uitrolperiode. De naam is geen geheim en je ziet hem terug in logs, dus laat hem iets zeggen: site1-2026q4. Kopieer het secret op het moment dat het getoond wordt.

Voeg daarna per site per product een voorbereid antwoord toe, alleen toegestaan op het token van die site:

Veldsite1, Proxmox VE
Toegestaan tokensite1-2026q4
Doelfilter/product/product=pve
Land, toetsenbord, tijdzonegb, en-gb, Europe/London
Root-toegangSSH-keys, plus een wachtwoord ingesteld via het formulier, dat PDM alleen als hash opslaat en verstuurt4
NetwerkDHCP, interfacenamen vastgezet
FQDN{{ product.product }}-site1-{{ installation_nr }}.example.doc
Schijfje eigen indeling, bijvoorbeeld een ZFS-mirror over twee benoemde schijven
Webhook na de installatiebasis-URL https://pdm.example.doc, met de certificaatfingerprint van PDM

De teller gaat omhoog telkens als er een antwoord wordt geserveerd, herhaalpogingen inbegrepen, dus namen kunnen nummers overslaan. De teller in mijn lab staat op 4 na drie voltooide installaties. Wil je voorspelbare namen, gebruik dan FQDN from DHCP en zet de hostnaam op de DHCP-reservering.

De basis-URL van de webhook is de enige instelling die voor dit ontwerp moet veranderen. Het ligt voor de hand om hem naar PDM zelf op poort 8443 te laten wijzen, zo had mijn lab het staan, en 8443 gaat zo dicht. Laat hem naar Caddy wijzen.

De bootbestanden

Op een willekeurige Proxmox VE- of Debian-host met de Proxmox-repository:

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/

Geen --url, want DNS levert die. --pxe-loader ipxe impliceert --pxe, en met --pxe moet de output een map zijn die al bestaat15. Je krijgt vier bestanden: vmlinuz, initrd.img omgepakt van zstd naar gzip omdat iPXE geen zstd-initrd kan laden, de voorbereide ISO met /boot eruit, en een boot.ipxe-menu dat na tien seconden standaard de geautomatiseerde installatie start en naar de andere bestanden verwijst met een relatieve naam15. Houd alle vier in één map.

Elk doel heeft genoeg geheugen nodig om de kernel, de initrd en de hele ISO vast te houden. De release notes van Proxmox VE 9.2 houden het op “at least 6 GiB of available memory on the host”5. Dezelfde notes noemen een bekend probleem met de kernel van de 9.2-ISO op megaraid_sas-controllers en sommige NVMe-drives in JBOD-modus (just a bunch of disks), met het advies om vanaf de 9.1-ISO te installeren en te upgraden5. Test je hardware voordat je het netwerkpad ermee vertrouwt.

De DNS-records

In de zone die past bij het ene zoekdomein dat DHCP uitdeelt:

$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"

De fingerprint is die van het eigen certificaat van PDM:

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

Controleer ze zoals de installer dat zal doen:

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

Caddy, de enige deur naar PDM

De API van PDM luistert op [::]:8443, en dat is een constante die in de binary is gecompileerd, geen instelling16. Je kunt hem niet aan de loopback binden. Dus wordt Caddy de enige weg naar binnen, en de firewall zorgt dat dat zo blijft.

De routes van Caddy voor PDMREQUESTWAT ER GEBEURTantwoord-POST, sitesubnet, catch-tokenherschreven naar het sitetokenpost-hook-POST, sitesubnetAuthorization verwijderd, doorgegevenweb-UI of API, beheersubnetonaangeroerd doorgegeven, PDM-loginal het andere, van overalafgebroken, bereikt PDM nooitpoort 8443, van buiten de machinegedropt door nftables
Elk request dat Caddy ziet, eindigt op een van vijf plekken. Alleen de eerste drie bereiken ooit PDM.

PDM heeft precies twee routes die werken zonder PDM-login: POST /api2/json/auto-install/answer met een installertoken, en POST …/installations/{uuid}/post-hook met het eenmalige token in de body4. Dat zijn de enige twee die een provisioningnetwerk ooit zou moeten bereiken.

Gebruik Caddy 2.11.1 of nieuwer. 2.11.1 repareerde een omzeiling van de path-matcher, CVE-2026-27587, en dat is precies het soort padfiltering waar dit op leunt17. Het eigen pakket van Debian 13 is 2.6.2, dus haal het uit trixie-backports, dat 2.11.2 heeft, of uit de eigen repository van Caddy18.

De 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
	}
}

De delen die beveiligingswerk doen, en waarom elk deel er staat:

RegelWaarom
header Authorization "Bearer catch-all:not-a-secret" in de site-matcherzonder deze regel krijgt elke POST vanaf een sitesubnet het echte token; een request moet van het juiste subnet komen en het catch-token meesturen
request_header Authorization …zet de header en overschrijft wat de client ook stuurde, zodat een vervalst token wordt vervangen en nooit doorgestuurd19
remote_ipde directe peer; zet geen NAT of load balancer tussen de installers en Caddy20
benoemde handle-blokkenblijven in de geschreven volgorde en sluiten elkaar uit, zodat de kale handle { abort } het laatste woord heeft21
@mgmtde volledige PDM-UI en API, onaangeroerd, zodat de eigen login, API-tokens en cookies van PDM gewoon werken; een sitesubnet krijgt er niets van
admin unix//…|0600de admin-API staat standaard op TCP localhost:2019, zonder authenticatie22
protocols h1 h2Caddy luistert standaard op UDP 443 voor HTTP/322; dit zet dat uit in plaats van een poort open te laten die niemand bedoelde te openen
@hiddenfile_server serveert elk bestand onder zijn root, dotfiles inbegrepen, en hide is gedocumenteerd als geen beveiligingsgrens23
Csrfpreventiontoken replace REDACTEDCaddy redigeert standaard Authorization en Cookie in zijn accesslog, maar niet de CSRF-header van PDM24

De path-matcher is vergevingsgezinder dan PDM, en dat is de goede kant op. Caddy schoont het pad op, decodeert procent-encoding, voegt herhaalde slashes samen en negeert hoofdletters voordat hij matcht20. Dus //answer, %61nswer en ANSWER tellen allemaal als de antwoordroute, en een variant /api2/extjs/… of een slash aan het eind matcht geen siteroute en valt door naar abort.

Twee keuzes daarin hebben uitleg nodig.

Caddy serveert het certificaat van PDM, rechtstreeks uit de bestanden van PDM. Caddy leest api.pem en api.key waar PDM ze bewaart, zodat de installers, de webhook en een browser allemaal het certificaat zien dat PDM op 8443 presenteert, en er geen tweede kopie is die uit de pas kan lopen. De voor de hand liggende manier om hem die toegang te geven, is de gebruiker caddy aan www-data toevoegen. In mijn lab heb ik gecontroleerd wat dat groepslidmaatschap werkelijk bereikt, door elk bestand als de gebruiker caddy te testen:

Bestand onder /etc/proxmox-datacenter-manager/Leesbaar voor caddy in www-data
remotes.shadow, de inloggegevens voor elke beheerde remoteja
autoinst/tokens.shadow, elk secret van elk installertokenja
auth/api.key, auth/csrf.keyja
access/user.cfg, subscriptions/ja

Niets in die lijst hoort ook maar in de buurt van een webproxy. Een bug in de proxy zou dan een compromittering zijn van elk cluster dat PDM beheert.

Dus blijft Caddy uit www-data en krijgt hij een ACL op precies de twee bestanden die hij nodig heeft, plus toestemming om door de twee mappen erboven te gaan zonder ze te mogen opsommen:

#!/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"

Getest als de gebruiker caddy in het lab, nadat het script had gedraaid:

Padcaddy
auth/api.pem, auth/api.keykan lezen
auth/csrf.key, auth/authkey.key, auth/authkey.pubkan niet lezen
remotes.shadow, remotes.cfg, autoinst/tokens.shadow, access/user.cfgkan niet lezen
auth/ opsommengeweigerd

Het addertje is de vernieuwing. PDM schrijft een vernieuwd certificaat naar een nieuw bestand en hernoemt dat op zijn plek, en een nieuw bestand neemt de ACL van het oude niet over. Dus als PDM vernieuwt, zet systemd de ACL terug en herlaadt Caddy, zonder dat er iemand aan te pas komt.

Een controle dat het certificaat en de key bij elkaar horen, want een vernieuwing schrijft ze één voor één:

#!/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" ]

Een path-unit die beide bestanden in de gaten houdt, en de service die hij start:

# /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

De override verderop draait hetzelfde ACL-script voor elke start van Caddy, zodat een vernieuwing terwijl Caddy stillag nooit gemist wordt.

De details die het laten werken, elk gecontroleerd:

OnderdeelWaarom
beide bestanden bewaaktde eerste write start de unit; de conditie slaat hem over zolang het paar niet klopt, en de tweede write start hem opnieuw met een kloppend paar
ExecConditioneen mislukte conditie slaat de run over in plaats van hem te laten falen, zodat een half geschreven vernieuwing Caddy nooit bereikt25
PathChangedgaat af als een bewaakt bestand dat open stond om te schrijven, wordt gesloten26; in het lab ging hij ook af als een nieuw bestand over het oude werd hernoemd
het ACL-script bij elke runeen binnen-hernoemd bestand heeft geen ACL, dus Caddy kon het niet lezen tot het script opnieuw draait
try-reload-or-restartherlaadt Caddy als hij draait en doet niets als hij niet draait
reload --force in de unit van Caddyzonder --force ziet Caddy dezelfde config en blijft hij het oude certificaat serveren

Die laatste rij is geen gok. In een wegwerpcontainer met Caddy 2.11.7 heb ik het certificaatbestand achter een draaiende server verwisseld: een gewone caddy reload bleef het oude certificaat serveren, en caddy reload --force serveerde het nieuwe. De meegeleverde unit herlaadt al met --force27, dus houd die regel aan als je ExecReload overschrijft.

In het lab heb ik de api.key en api.pem van PDM vervangen zoals een vernieuwing dat doet, door nieuwe bestanden te schrijven en die over de oude te hernoemen. Direct na het hernoemen had api.key geen ACL-regel voor caddy. Binnen vier seconden was de path-unit afgegaan, stond de ACL weer op beide bestanden, en had Caddy op zijn plek herladen met hetzelfde proces-ID.

De tokennaam en het secret worden gescheiden gehouden. De naam gaat in de Caddyfile, waar hij gelogd wordt. Het secret gaat in een environmentbestand dat alleen root kan lezen, en Caddy leest het via {env.*} op het moment van het request.

Log welk token gebruikt werd, nooit het secret

De eerste versie hiervan in mijn lab lekte. De meegeleverde unit van Caddy start hem als caddy run --environ, en --environ print de hele environment voordat hij start28,27. Elke herstart zette het sitetoken in platte tekst in het journal: acht regels ervan op het moment dat ik ze telde. Niets in de accesslog, niets in de eigen logs van PDM, alles van die ene vlag.

Waar het tokensecret en de tokennaam heen gaanHET SECRETpdm-secrets.envalleen root, 0600environment van Caddy{env.PDM_SECRET_SITE1}Authorization-headeralleen de loopback-hop naar PDMDE NAAMimportregel inde Caddyfileaccesslog, elk antwoordrequest"pdm_token":"site1-2026q4"NOOIT GESCHREVEN NAARhet journal--environ verwijderdde admin-APIunix-socket, 0600een config-dumpalleen placeholder
Het secret heeft één taak en één pad. De naam is wat je logt.

Dus bevat het secretsbestand secrets en verder niets:

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

en de unit-override laat --environ vallen en geeft de admin-socket een plek:

# 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

De + draait die regels als root, voordat Caddy als zijn eigen gebruiker start. De logregels doen ertoe zodra iets anders aan /var/log/caddy komt: een log die door root is geroteerd, of een bestand dat tijdens het testen met de hand is aangemaakt, laat Caddy zonder toegang tot zijn eigen accesslog. In het lab heb ik pdm.log op root:root gezet en Caddy herstart; hij kwam terug als caddy:caddy en Caddy logde geen permissiefouten.

Herstart in plaats van herladen na het bewerken, want een reload leest de environment niet opnieuw in. Gebruik {env.NAME}, nooit {$NAME}: de tweede vorm wordt ingevuld wanneer de Caddyfile wordt geadapteerd, zodat het secret in de JSON-config belandt, in het autosave-bestand en in alles wat die dumpt29. Ook hier doet de admin-socket ertoe. {env.*} lost alles in de procesenvironment op zonder allowlist, dus iedereen die een config kan laden via een admin-API op TCP, kan Caddy een secret aan hem laten teruglezen30.

Elke antwoordregel in de accesslog noemt nu de site en het token, met de header zelf geredigeerd. Uit het lab, waar beide siteA heten:

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

Ik heb het gecontroleerd in plaats van erop te vertrouwen. Op een podman-kopie van deze config met dummy-secrets, en daarna in het lab, stuurde ik antwoorden via elk pad dat ik kon bedenken: normaal, debuglogging aan, upstream plat voor een 502, een reload en een herstart. Daarna telde ik elk secret in het journal, de accesslog, de config- en datamappen van Caddy en de geadapteerde JSON. Elke telling was nul. De controlerun, dezelfde config gestart met --environ, printte het secret twee keer.

Twee kanttekeningen bij die test. Met debug aan logt Caddy zijn hele geadapteerde config, dus het catch-token en de {env.*}-placeholders duiken daar op; de waarden erachter niet. Houd debug in productie hoe dan ook uit. En als een secret al door een journal is gegaan, zoals het mijne, genereer het dan opnieuw in PDM en werk het bestand bij; logregels verwijderen is niet de oplossing.

De firewall: 8443 verlaat de machine nooit

Mijn lab had helemaal geen firewall, dus 8443 antwoordde elke host op het provisioningnetwerk en elke regel in de Caddyfile was te omzeilen. Doe hem dicht. nftables, met drop als standaard:

#!/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;
	}
}

De verbindingen van PDM naar zijn remotes zijn uitgaand, dus 8443 inkomend dichtzetten verandert daar niets. Laad een nieuwe ruleset de eerste keer met een getimede rollback, zodat een fout je negentig seconden kost in plaats van een tocht naar de 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

In het lab, na dit alles:

ControleResultaat
8443 vanaf het beheernetwerkgeen verbinding; de drop-teller loopt op
PDM-web-UI via Caddy op 443, vanaf beheerlogt in en werkt
PDM-web-UI vanaf een siteadresafgebroken
antwoord-POST vanaf een site met het catch-tokenbereikt PDM met het sitetoken: een bewust niet-matchende probe kreeg 400 no answer file found
dezelfde probe rechtstreeks naar PDM met een verzonnen token401
antwoord-POST zonder het catch-token, of als /api2/extjs/…afgebroken
bestanden waarin de secrets van PDM staan, gelezen als caddygeen enkele leesbaar
het sitesecret in het journal, de accesslog of de data van Caddy sinds de wijziging0

DHCP, iPXE en de lus

Serverfirmware kan zijn eerste bestand alleen over TFTP ophalen, dus TFTP serveert de kleine iPXE-loader en iPXE haalt al het andere over HTTP op. De valkuil is dat iPXE zijn eigen DHCP-request stuurt, en een server die daarop opnieuw ipxe.efi antwoordt, stuurt de machine eeuwig in een kringetje rond. De DHCP-server moet de firmware en iPXE uit elkaar kunnen houden.

Waarom een gewoon DHCP-antwoord rondlooptGewoon antwoord: het loopt rondfirmware vraagt om een bootbestandantwoord: ipxe.efiiPXE start en vraagt opnieuwzelfde antwoord: ipxe.efide server kan firmware niet vaniPXE onderscheiden, dus komt niet verderiPXE herkend: het werktfirmware vraagt om een bootbestandgeen iPXE: ipxe.efi over TFTPiPXE vraagt opnieuw, optie 175 gezetantwoord: de boot.ipxe-URL, over HTTPkernel, initrd en ISO, dan installeren
Twee keer hetzelfde request krijgt twee keer hetzelfde antwoord. Optie 175, die alleen iPXE stuurt, is hoe de server het tweede request van het eerste onderscheidt.
DHCP-serverHoe hij de lus doorbreekt
dnsmasqtag requests met optie 175, die alleen iPXE stuurt, en geef die de HTTP-URL31
ISC dhcpdif exists user-class and option user-class = "iPXE"32
FortiGatehet veld filename neemt één waarde, dus bouw een iPXE-loader met de URL erin en serveer die aan alles

dnsmasq, dat zijn eigen netwerk bedient en de andere twee via relays, met alleen reserveringen:

# /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 betekent geen pool: alleen gereserveerde machines krijgen een adres, en dat houdt vreemden van het installerpad af. Het adres van elke reservering moet binnen de eigen /24 van zijn site liggen, want dat adres gebruikt Caddy om het token te kiezen. Een node die in het verkeerde subnet is gereserveerd, krijgt het antwoord van de verkeerde site, of niets.

Reserveringen zijn een lijst met MAC-adressen, en dat is boekhouding per machine van precies het soort dat de klant niet wilde. Is inprikken en booten echt de eis, geef elk provisioning-VLAN dan in plaats daarvan een kleine dynamische range, bijvoorbeeld dhcp-range=set:site1,10.10.50.100,10.10.50.150,255.255.255.0,1h. De site komt nog steeds uit het subnet, dus Caddy kiest nog steeds het juiste token, en de FQDN-template met installation_nr geeft de node zijn naam. De prijs is de eerste rij van “Wat er nog te beslissen valt”: alles wat in dat VLAN wordt geprikt, kan nu het antwoord van de site ophalen, dus het VLAN zelf wordt de poort.

dnsmasq onderhandelt de TFTP-blokgrootte met de client. Laat je het met rust, dan begrenst het die op de MTU van zijn eigen interface31, en dat werkt tot het pad naar een site kleiner is dan de link waar dnsmasq op zit. Daarom legt de config het plafond vast als tftp-mtu=1500 in plaats van het over te nemen. Zo staat het getal voor de neus van wie een site achter een IPsec-tunnel beheert, of achter iets anders dat van het pakket afknabbelt, en die verlaagt het tot wat het pad echt draagt in plaats van het te ontdekken via een transfer die halverwege vastloopt. Als één NIC-ROM zich misdraagt zodra de optie überhaupt wordt toegekend, zet tftp-no-blocksize de onderhandeling uit.

Kopieer ipxe.efi uit het Debian-pakket ipxe naar /srv/tftp, met de gebruiker dnsmasq als eigenaar, want tftp-secure weigert al het andere. Kies bij voorkeur UEFI. Secure Boot heeft een gesigneerde iPXE nodig, dus richt dat in of zet Secure Boot uit voor de installatie en daarna weer aan.

Booten, kijken en wat er stukgaat

Test met één virtuele machine op een sitenetwerk voordat je aan hardware komt: UEFI-firmware, 8 GiB geheugen en een NIC op dat VLAN reproduceren de hele keten.

Wat er bij het booten gebeurtDoelserverUEFI-netwerkboot6 GiB of meer1. DHCP en TFTPeen lease, één zoekdomein, dan ipxe.efi2. HTTP op poort 80boot.ipxe, vmlinuz, initrd.img en de ISO, in het geheugen3. DNStwee TXT-records: de antwoord-URL en de fingerprint van PDM4. Caddy op 443, dan PDMnu het antwoord, na de reboot de webhook
Vier afzonderlijke gesprekken, elk met een eigen plek om te kijken als het misgaat.

En het geheel op volgorde, van aanzetten tot een installatie die PDM als Finished markeert:

Van aanzetten tot volledig geïnstalleerdDoeldnsmasq, :80Caddy :443PDMDHCP: een leaseTFTP: ipxe.efiiPXE vraagt opnieuwHTTP: kernel, initrd, ISOinstaller: één domeinDNS: twee TXT-recordsPOST, catch-tokensitetokenin uitvoeringhet gerenderde antwoord, via Caddyinstalleert op schijfwebhook, eenmalig tokenAuthorization verwijderdFinishedreboot; eerste bootactiveert de key
Elke stap, op volgorde. De webhook wordt vanuit de installer verstuurd vóór de reboot, dus Finished betekent geïnstalleerd, niet geboot; de subscription key wordt daarna geactiveerd, bij de eerste boot.
StapWat er gebeurtWaar je kijkt
1firmware haalt ipxe.efi over TFTP, iPXE haalt boot.ipxe over HTTPjournalctl -u dnsmasq, de log van Caddy
2na tien seconden boot het menu de geautomatiseerde optie; kernel, initrd en ISO worden gedownloadvier GET’s, waarvan één van ongeveer 1.6 GB
3de installer neemt een lease, leest het zoekdomein en vraagt beide TXT-records op/tmp/fetch_answer.log op het doel
4POST naar Caddy, token verwisseld, PDM kiest een antwoordde log van Caddy noemt het token; PDM toont een nieuwe installatie
5de installatie draait/tmp/auto_installer.log, /tmp/install-low-level-start-session.log
6nog in de installer belt de webhook terug via Caddy, daarna reboot de machine33de installatie gaat naar Finished met schijven, NIC’s en SSH-hostkeys erbij

Bij een fout valt de installer terug op een shell, tenzij het antwoord Reboot on Error zet, dus laat dat uit terwijl je test.

SymptoomWaarschijnlijke oorzaak
Could not find search domain in resolv.conf.DHCP stuurde geen domein
een TXT-query voor een naam met een spatie erinmeer dan één zoekdomein
Got empty response.record ontbreekt, verkeerde zone, of optie 6 wijst naar een andere DNS-server
een URL met een spatie in het middenTXT-waarde in meerdere strings gesplitst
de log zegt dat de URL uit DHCP kwamoptie 250 staat ergens op het netwerk
TLS-fout op de POSTde DNS-fingerprint is niet het huidige certificaat van Caddy
verbinding gesloten zonder antwoordCaddy brak hem af: verkeerd subnet, geen catch-token, of een route die niet een van de twee is
400 no answer file foundhet token werkte; geen filter matchte en er is geen standaardantwoord op dat token
het verkeerde antwoordoverlappende filters; de eerste in het bestand wint
status blijft in uitvoeringde basis-URL van de webhook wijst nog naar 8443
iPXE laadt zichzelf eindeloos opnieuwde DHCP-server houdt iPXE en firmware niet uit elkaar
PXE-E32 TFTP-timeoutnext-server verkeerd, of UDP 69 geblokkeerd

Proxmox Backup Server toevoegen

Niets aan het pad is specifiek voor Proxmox VE. Bouw een tweede set bootbestanden uit de ISO van PBS 4 met hetzelfde catch-token, in /srv/www/pbs-4/, en voeg per site één PBS-antwoord toe met het filter /product/product=pbs, toegestaan op het token van die site. De DNS-records, Caddy, de tokens en de firewall veranderen niet.

Voor een site met allebei moet iets beslissen welk product een machine krijgt, en een menu is het verkeerde ding om dat te laten beslissen. De choose van iPXE zonder --timeout wacht eeuwig aan de console op een toetsaanslag, en dat is het tegenovergestelde van unattended.

Laat de DHCP-reservering het beslissen. Je reserveert elke node al op MAC-adres, dus tag de PBS-nodes en stuur elke tag zijn eigen 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 op een reservering tagt die ene machine31, en tag:!pbs houdt de twee bootregels wederzijds uitsluitend, zodat er geen vraag is welke wint. De eigen boot.ipxe van elk product boot al na tien seconden de geautomatiseerde optie, zonder iemand aan het toetsenbord15.

Deze regels komen door dnsmasq --test op 2.91, de versie in mijn lab, en volgen de gedocumenteerde syntaxis van dhcp-host en tags. Ik heb nog geen PBS-node via deze regels geboot met PXE, dus probeer één getagde machine voordat je erop vertrouwt.

Vanaf daar is het hetzelfde pad als bij Proxmox VE. De installer van PBS post "product": "pbs", Caddy zet het token van de site erin, en PDM kiest het PBS-antwoord van die site. Nergens wordt iets met de hand gekozen, en welk product een node wordt, staat op één plek, de reservering, naast zijn adres.

Wat beslistWaar het staat
welke ISO bootde tag set:pbs op de DHCP-reservering
welke antwoorden van welke site geldenhet bronsubnet, via de tokenwissel van Caddy
welk antwoord binnen die sitehet filter /product/product=pbs in PDM

De installer legt alleen het besturingssysteem neer. De datastore, op storage los van de OS-schijf, is weer een taak voor wat de node afmaakt, en dat brengt ons terug bij de ontbrekende first-boot-hook.

Wat er nog te beslissen valt

Geen van deze punten houdt je tegen om dit ontwerp te gebruiken. Elk ervan heeft wel een eigenaar nodig.

AfwegingWaarom het ertoe doetWat je doet
het bronadres is de poortalles op een provisioning-VLAN, of wat daar een adres kan spoofen, kan het antwoord van die site ophalen: hash van het root-wachtwoord, SSH-keys, subscription keyisoleer het VLAN, port security, alleen reserveringen, vervaldatum op tokens, tokens uitzetten tussen uitrollen
het catch-token is geen geheimhet zit in elk image en staat op deze paginahet filtert alleen verdwaalde requests weg; het subnet en het echte token in Caddy zijn de poort
Caddy kan de key van PDM lezenhet certificaat van PDM serveren betekent dat de proxy die ene private key leesteen ACL op alleen api.pem en api.key, verder niets van PDM leesbaar, de host dichtgetimmerd; de key verlaat hem nooit
DNS wordt vertrouwdeen vervalst TXT-record stuurt de installer ergens anders heen, en als de fingerprint ook uit DNS komt, beheerst de vervalser allebeibescherm de provisioning-DNS, of bak de fingerprint van PDM in met --cert-fingerprint en bouw opnieuw als die verandert
bootbestanden gaan over gewone HTTPze kunnen onderweg gelezen of verwisseld wordenhoud het netwerk geïsoleerd; iPXE over HTTPS als je build dat ondersteunt
het webhooktoken16 willekeurige bytes, eenmalig, gewist na de callback, maar zonder tijdslimiet4ruim oude installatierecords op
de subscription keyéén keer geactiveerd nadat het netwerk op is; bij een fout wordt de key verwijderd en faalt de unit, zonder nieuwe poging34controleer systemctl --failed op nieuwe nodes
PDM is één enkel punthet is vandaag niet high-availableeen storing stopt nieuwe installaties, geen draaiende clusters
één beheer-NIChet antwoord heeft geen opties voor bond, VLAN of bridge9dat is werk na de installatie, met hetzelfde first-boot-probleem

Een eerste versie, degelijk gebouwd

Dit is een eerste release van de functie, en voor een eerste release is hij goed. Het antwoordformulier is verstandig, de selectieregels zijn helder zodra je ze leest, de webhook werkt, en de code is open, en dat is de enige reden dat alles hierboven bewezen kon worden in plaats van geraden. De patchserie ging vier rondes over de openbare mailinglijst voordat hij landde35, en dat zie je.

De gaten zijn precies de twee dingen die van een installatieserver een provisioningsysteem maken. Een site is de eenheid waarin een MSP denkt, en de installer staat erop als hij de vraag stelt. Een first-boot-hook is het verschil tussen een node die geïnstalleerd is en een node die af is. Beide zijn al half aanwezig: de installer heeft de hook en PDM heeft het token. Ik verwacht dat ze allebei komen, en als dat gebeurt, wordt de Caddy in het midden van dit ontwerp eenvoudiger in plaats van te verdwijnen, want een proxy die de secrets van PDM buiten bereik houdt en 8443 van het netwerk, is hoe dan ook de moeite waard.

De reden om het in de tussentijd zo te doen, is die van bovenaan. Tien PXE-servers zijn tien dingen die elk in hun eigen tempo verouderen, en elk ervan wordt pas bekeken als het faalt op de dag dat je het nodig hebt. Eén omgeving, één keer bijgehouden, waarbij het netwerk de site bepaalt en de secrets op één plek liggen, is minder om te beheren. Minder om te beheren is minder om fout te doen, en dat is het grootste deel van het werk.


  1. Proxmox — persbericht Proxmox Datacenter Manager 1.1 — geautomatiseerde installaties, met een nieuw tabblad onder Remotes dat de voortgang van installaties bijhoudt. ↩︎

  2. Documentatie Proxmox Datacenter Manager — Automated Installations — voorbereide antwoorden, doelfilters, templating, en “a dedicated token mechanism, separate from the normal API tokens”. ↩︎ ↩︎

  3. proxmox-datacenter-manager, lib/pdm-api-types/src/auto_installer.rs — sleutels van doelfilters “are JSON Pointers as per [RFC6901], the values globs”; slechts één standaardantwoord. ↩︎

  4. proxmox-datacenter-manager, server/src/api/auto_installer/mod.rs — tokencontrole vóór het standaardantwoord, eerste match in bestandsvolgorde, first_boot: None, ophogen van de teller, het eenmalige post-hook-token, de twee routes die zonder PDM-login bereikbaar zijn, en de rechten Sys.Audit/Sys.Modify op /system/auto-installation. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  5. Proxmox VE Roadmap — Proxmox VE 9.2 — voorbereiding voor PXE en iPXE, “at least 6 GiB of available memory on the host”, en het bekende probleem met megaraid_sas en NVMe-JBOD. ↩︎ ↩︎ ↩︎

  6. Proxmox Bugzilla 8078 — “prepared answer disk setup does not allow to specify multiple udev filters with same name”, geopend op 26 september 2026, status NEW op 7 november 2026. ↩︎

  7. Proxmox Bugzilla 7699 — “Add on-first-boot parameter to the PDM remote installation”, geopend op 12 juni 2026, status NEW op 7 november 2026. ↩︎ ↩︎

  8. proxmox, proxmox-installer-types/src/lib.rs — SystemInfo: product, ISO, DMI en netwerkinterfaces. ↩︎

  9. Proxmox VE wiki — Automated Installation — de opties [first-boot] en [network], de systeeminformatie die wordt verstuurd, en dat het auth-token “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”, geopend op 31 oktober 2026, status NEW op 7 november 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 bevat first_boot: Option<FirstBootHookInfo>. ↩︎

  13. pve-installer, proxmox-auto-installer/src/bin/proxmox-auto-installer.rs — de first-boot-executable wordt alleen opgezet als het antwoord een sectie first_boot heeft. ↩︎

  14. pve-installer, proxmox-fetch-answer/src/fetch_plugins/http.rs — eerst de URL in de ISO, dan DHCP, dan DNS; de TXT-namen; het parsen van resolv.conf; dig txt +short met de aanhalingstekens eruit; de Bearer-header. ↩︎ ↩︎

  15. pve-installer, proxmox-auto-install-assistant/src/main.rs — --pxe-loader impliceert --pxe, de outputmap, de gzip-initrd, /boot verwijderd uit de ISO, en de gegenereerde boot.ipxe. ↩︎ ↩︎ ↩︎

  16. proxmox-datacenter-manager, server/src/bin/proxmox-datacenter-api/main.rs — PDM_LISTEN_ADDR is het ongespecificeerde IPv6-adres op PDM_PORT, 8443, vastgelegd bij het bouwen. ↩︎

  17. Caddy releases — 2.11.1 repareerde CVE-2026-27587, een omzeiling van de path-matcher via escape-reeksen. ↩︎

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

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

  20. Caddy — Request matchers — remote_ip is de directe peer; padmatches zijn hoofdletterongevoelig, URL-gedecodeerd, opgeschoond, met herhaalde slashes samengevoegd. ↩︎ ↩︎

  21. Caddy — handle — “only the first matching handle block will be evaluated”. ↩︎

  22. Caddy — Global options — admin staat standaard op localhost:2019; protocols staat standaard op h1 h2 h3. ↩︎ ↩︎

  23. Caddy — file_server — hide moet niet als beveiligingsgrens worden beschouwd. ↩︎

  24. Caddy — log — Cookie, Set-Cookie, Authorization en Proxy-Authorization worden gelogd als REDACTED. ↩︎

  25. systemd.service — ExecCondition= — bij een exitcode van 1 tot en met 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 print de environment voordat hij start. ↩︎

  29. Caddy — Caddyfile-concepten, environmentvariabelen — {$NAME} wordt ingevuld voordat de Caddyfile wordt geparsed. ↩︎

  30. caddyserver/caddy, replacer.go — elke sleutel die met env. begint, wordt aan os.Getenv doorgegeven. ↩︎

  31. manpagina van dnsmasq — dhcp-match, dhcp-boot, dhcp-host met set:<tag>, enable-tftp, tftp-secure, tftp-mtu, tftp-no-blocksize en static-ranges. ↩︎ ↩︎ ↩︎

  32. iPXE — Configuring ISC dhcpd — if exists user-class and option user-class = "iPXE" geeft iPXE het script en de firmware de loader. ↩︎

  33. pve-installer, unconfigured.sh — proxmox-post-hook draait direct nadat proxmox-auto-installer slaagt, vóór de reboot. ↩︎

  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 — de serie die de functie toevoegde, met de wijzigingsnotities van v3 naar v4. ↩︎