Eine Konsole, viele Kunden

Wenn du Proxmox für andere Leute betreust, ist jeder neue Node dieselbe Arbeit, jedes Mal ein bisschen anders. Jeder Kunde hat sein eigenes Land, seine Zeitzone, seine Root-Schlüssel, sein Disk-Layout, seine Hostnamen und seinen Subscription-Key. Erwischst du davon auf einem frischen Node eines falsch, merkst du es beim ersten Login oder dann, wenn ein Audit fragt, warum die Uhr eine Stunde danebenliegt.

Ich habe das für einen Kunden mit genau diesem Problem gebaut, und was er wollte, war erfrischend schlicht. Jeder Standort wird mit seinem eigenen Namensschema, seinem eigenen Root-Passwort und seinen eigenen SSH-Schlüsseln aufgebaut. Niemand vor Ort macht irgendetwas Schlaues: Server ins Rack, ins richtige VLAN stecken und einschalten. Niemand sammelt Seriennummern, und niemand schreibt eine Antwortdatei pro Maschine. Und ein so hinzugefügter Server landet im bestehenden Cluster des Standorts.

Was sie wolltenWas es hier liefert
Namensschema, Passwort und Schlüssel des Standortseine vorbereitete Antwort pro Standort, ausgewählt über das Token dieses Standorts
ins richtige VLAN stecken, einschalten, weggehenDNS-Discovery, iPXE und Caddy, der das Token anhand des Quell-Subnetzes wählt
keine Seriennummern, keine Antwortdateien pro MaschineAntworten filtern nur auf das Produkt; der Standort kommt aus dem Netz
der neue Server tritt dem bestehenden Cluster beinicht in diesem Beitrag: PDM hat keinen First-Boot-Hook, siehe unten

Dieser Beitrag behandelt die ersten drei: einen nackten Server vom Einschalten bis zu einem installierten Proxmox-Node mit den Einstellungen des richtigen Standorts zu bringen. Diese Hälfte kommt bei fast jedem mit mehr als einem Standort vor, MSP oder nicht, also lohnt sie sich für sich allein. Der Beitritt zum Cluster ist eine eigene Aufgabe, und der Abschnitt über den fehlenden First-Boot-Hook erklärt, warum PDM das noch nicht für dich erledigen kann.

Proxmox Datacenter Manager (PDM) 1.1 hat das fehlende Stück nachgeliefert1. Er kann jetzt als Antwortserver für den automatischen Installer von Proxmox dienen: Eine nackte Maschine bootet, meldet, was sie ist, bekommt eine vorbereitete Antwort zurück und installiert sich selbst, ohne dass jemand an der Konsole sitzt. Für einen Managed Service Provider (MSP), der PDM ohnehin als Management-Konsole betreibt, ist das der naheliegende Ort für die Installationseinstellungen jedes Kunden.

Was du nicht willst, ist ein PXE-Server pro Kunde. Jeder davon ist ein Trivial-File-Transfer-Protocol-Server (TFTP), eine DHCP-Konfiguration, ein Satz Boot-Dateien und ein ISO, das bei jedem Point-Release von Proxmox neu gebaut werden muss. Zehn Kunden heißt zehn davon aktuell halten. Das skaliert nicht. Das Ziel hier ist ein PXE-Bestand, ein Boot-Image, ein DNS-Eintrag und ein PDM, und trotzdem bekommt jeder Standort seine eigene Antwort.

Ein PXE-Bestand für jeden Standortsite110.10.50.0/24site210.20.50.0/24site310.30.50.0/24Caddy auf 443Catch-Token reinStandort-Token raus,nach Subnetz gewähltPDMAntworten begrenztper Token8443 auf LoopbackEin Boot-Image, ein DNS-Eintrag und ein DHCP-Dienst für jeden Standort
Das fertige Design. Jeder Standort bootet dasselbe Image. Caddy erkennt am Subnetz, von welchem Standort eine Anfrage kommt, und gibt PDM das Token dieses Standorts, und PDM bietet nur Antworten an, die auf dieses Token beschränkt sind.

PDM 1.1 schafft den größten Teil davon allein. An zwei Stellen reicht es nicht, und beide solltest du kennen, bevor du einen Rollout darum herum planst: Es gibt keine Möglichkeit, eine Antwort einem Standort zuzuordnen, und keine Möglichkeit, einem neuen Node ein First-Boot-Skript mitzugeben. Dieser Beitrag belegt beides aus dem eigenen Quellcode von PDM, umgeht das erste und baut das Ganze auf meinem Lab-PDM mit 1.1.9, mit einem gehärteten Proxy, damit die Teile, die Secrets halten, zu bleiben.

Was PDM 1.1 dir gibt

Die Funktion liegt unter Remotes → Automated Installations und hat drei Teile: Installationen, vorbereitete Antworten und Authentifizierungs-Tokens2. Eine vorbereitete Antwort ist die PDM-Version der answer.toml des Installers, ausgefüllt über ein Formular statt von Hand geschrieben.

TeilWas er tutDetail, auf das es ankommt
Vorbereitete AntwortenLand, Tastatur, Zeitzone, Hash des Root-Passworts, SSH-Schlüssel, Netzwerk, Disk-Layout, Subscription-Key, Aktion nach der Installationbei jeder Anfrage frisch gerendert
Zielfilterentscheiden, welche Maschine welche Antwort bekommtJSON Pointer in die Systeminformationen des Installers, Glob für den Wert3
TemplatingMiniJinja in den Feldern FQDN, Admin-E-Mail, IP, Gateway und DNSZähler wie installation_nr steigen bei jeder ausgelieferten Antwort4
Authentifizierungs-Tokensder Installer sendet Authorization: Bearer <name>:<secret>ein eigener Token-Typ, getrennt von den API-Tokens von PDM; jede Antwort listet die Tokens, die sie annimmt2
Installationenein Datensatz pro Anfrage, mit den vollständigen Systeminformationenwechselt von in Bearbeitung zu fertig, wenn der Post-Install-Webhook zurückruft
BerechtigungenSys.Audit zum Ansehen, Sys.Modify zum Ändernbeide auf /system/auto-installation4

Die Seite des Installers gibt es schon eine Weile. Der Befehl proxmox-auto-install-assistant prepare-iso baut ein ISO, das seine Antwort über HTTP holt, und seit dem ISO von Proxmox VE 9.2 kann er auch Kernel, Initrd, ISO und boot.ipxe erzeugen, die iPXE braucht, um das Ganze übers Netz zu booten5.

Die Zähler sind der Teil, für den eine eigenständige answer.toml nichts Vergleichbares hat. Eine vorbereitete Antwort nummeriert jeden Host, den sie baut, wo eine statische Datei pro Maschine kopiert werden müsste. Das ist ein echter Schritt nach vorn, und es ist trotzdem ein erster Wurf. Das Formular ist noch nicht gleichauf mit der eigenständigen Antwortdatei6, und ein Bereich fehlt ganz: Ein First-Boot-Skript gibt es in 1.1 in keiner Form7. Diese Lücke und der Standort, auf den es nicht filtern kann, sind die nächsten beiden Abschnitte.

Es kann nicht auf den Standort matchen

PDM wählt eine Antwort aus, indem es Filter mit den Systeminformationen vergleicht, die der Installer schickt. Die Frage ist also, was in diesem Dokument steht, denn was nicht drinsteht, kann auch nicht gematcht werden.

SchlüsselWas er enthältKönnte er den Standort nennen?
productfullname, product (pve, pbs, pmg), enable_btrfsnein
isorelease, isoreleasenein
dmiSystem, Baseboard und Chassis: Name, Seriennummer, UUID, SKU, Asset-Tagnur wenn jemand den Standort ins Asset-Tag geschrieben hat
network_interfaceseine Liste von {link, mac}nur über ein Inventar, das du selbst führst

Das ist alles8,9. Keine Adresse, kein Hostname, keine DHCP-Option, nichts darüber, wo die Kiste eingesteckt ist.

Die Felder des Desktop Management Interface (DMI) sehen vielversprechend aus, bis du echte liest. Jede meiner Lab-Installationen kam mit dem Systemnamen Standard PC (Q35 + ICH9, 2009) und leerer Seriennummer und leerem Asset-Tag zurück. Echte Server füllen sie aus, aber mit dem, was der Hersteller hineingeschrieben hat, und nicht mit dem Namen deines Kunden, und auf Seriennummern oder MAC-Adressen zu matchen funktioniert nur, wenn du eine Liste jedes Chassis führst, das du je installieren wirst, und genau diese Buchhaltung sollte ja wegfallen. Pro Host, ja. Pro Standort, nein.

Und an welchem Netz hängt die Kiste? Das erfährt PDM nie. Der Installer hat seinen DHCP-Lease in der Hand, wenn er seine Anfrage schickt, und die eine Tatsache, die ein MSP über einen neuen Node sicher weiß, ist genau die, die er zurücklässt. Das Matching auf die Quelladresse, IPv4 oder IPv6, ist ein offener Feature-Request, und ich habe ihn selbst als Bug 8263 eingereicht10.

In den Auswahlregeln steckt eine zweite Grenze, und mein Lab ist direkt hineingelaufen.

Wie PDM eine Antwort auswählt1. Token gültig?aktiviert, nicht abgelaufennein401 nach etwa einer halben Sekundenicht aufgezeichnet2. Antworten auf diesem Tokenin Dateireihenfolge, sonst nichts3. Alle Filter passen?nur Strings, Globs erlaubtersteAntwort gerendertjeder Zähler darauf steigt um 1keine4. Standard auf diesem Token?ein Standard in ganz PDMnein400: no answer file foundunter Installations aufgezeichnet
Die Auswahl von PDM, aus dem Quellcode. Das Token wird zuerst geprüft und begrenzt alles danach, die Standardantwort eingeschlossen.

Das Token wird zuerst geprüft, und nur Antworten, die für dieses Token freigegeben sind, kommen überhaupt in Betracht, auch die Standardantwort4. PDM erlaubt im ganzen System genau eine Standardantwort. Du kannst also nicht jedem Standort über das Standard-Flag einen eigenen Fallback geben. Mein Lab hatte eine Standardantwort namens fallback auf einem eigenen Token, und sie wurde kein einziges Mal ausgeliefert, weil nie etwas dieses Token sendet. Jeder Standort braucht stattdessen einen Auffangfilter auf seinem eigenen Token, etwa /product/product=*, als letzten Eintrag in der Datei.

Zwei weitere Dinge aus demselben Code solltest du beim Debuggen im Kopf haben:

  • Der erste Treffer gewinnt, in Dateireihenfolge, und ein zweiter Treffer erzeugt nur eine Warnung im Log. Das ist die Regel seit 1.1.111.
  • Ein Filter matcht nur Strings. /product/enable_btrfs ist ein Boolean und matcht nie etwas, und ein Array braucht einen Index, wie in /network_interfaces/0/mac.

Auf 1.1.9 bekommt ein unbekanntes oder abgelaufenes Token nach einer absichtlichen Verzögerung von etwa einer halben Sekunde eine 401 und hinterlässt keinen Datensatz. Ein gültiges Token ohne Treffer bekommt eine 400 mit dem Body no answer file found, und dieser Versuch wird aufgezeichnet, mit den vollständigen Systeminformationen. Dieser Datensatz ist der schnellste Weg, die echten Seriennummern und MAC-Adressen eines neuen Chassis zu lesen, bevor du einen Filter dafür schreibst.

Es kann kein First-Boot-Skript ausführen

Der Installer selbst unterstützt einen First-Boot-Hook. Eine Antwort kann einen Abschnitt [first-boot] mit source = "from-iso" oder "from-url" tragen, eine Reihenfolge von before-network, network-online oder fully-up und optional einen Zertifikats-Fingerprint9. prepare-iso --on-first-boot bettet für den Fall from-iso ein Skript ins ISO ein.

PDM schickt diesen Abschnitt nie. Kein einziges Mal. Der eigene Antworttyp des Installers hat das Feld, first_boot: Option<FirstBootHookInfo>12. Die vorbereitete Antwort von PDM hat überhaupt kein Feld dafür, und der Code, der daraus die Antwort baut, schreibt bedingungslos first_boot: None4. Es gibt nichts im Formular, nichts in der API und nichts in der Konfigurationsdatei, das du von Hand ergänzen könntest. Das ist ein offener Feature-Request, Bug 7699, eingereicht im Juni 2026 und beim Schreiben dieses Beitrags noch niemandem zugewiesen7. Fast fünf Monate später gibt es keine Antwort von Proxmox darauf, und auf 8263 auch nicht. Genau dieses Schweigen ist bedenklich. Wer sich die Zeit nimmt aufzuschreiben, wie dein Produkt besser werden könnte, hat eine Antwort verdient, auch wenn die Antwort Nein lautet, und das ist ganz normale professionelle Höflichkeit. Erst recht, wenn die Leute, die schreiben, zahlende Kunden und Partner sind, die schon Geld in das Produkt stecken, bevor sie auch noch ihre Zeit hineinstecken.

Und das Skript auf dem ISO rettet dich nicht. Der Installer richtet den First-Boot-Dienst nur unter der Bedingung if let Some(first_boot) = &answer.first_boot ein, und das ist der einzige Pfad, der die eingebettete Datei je liest13. prepare-iso warnt nicht, wenn du ein Skript einbettest, nach dem keine Antwort je fragen wird. Es liegt auf dem ISO und tut nichts.

Die First-Boot-LückeSkript auf dem ISOprepare-iso --on-first-bootAntwort von PDMfirst_boot: None, immerInstallerrichtet den First-Boot-Hook einnur wenn die Antwort[first-boot] hatdas Skript bleibt auf dem ISOund läuft nie, ohne Warnung
Beide Hälften erreichen den Installer, und die Hälfte von PDM fragt nie nach dem Hook. Das Skript auf dem ISO ist totes Gewicht.

Für einen MSP wiegt das schwerer als für jeden anderen. Ein Node aus dem Installer ist ein nacktes System mit einem gehashten Root-Passwort und ein paar SSH-Schlüsseln. Er ist kein PDM-Remote, er ist in keinem Cluster, und er hat nichts von deinem Monitoring, deinen Repositories oder deinem Storage. In meinem Lab haben alle drei Nodes fertig installiert und sich zurückgemeldet, und der Remote SITEA in PDM musste danach trotzdem von Hand hinzugefügt werden.

Womit auch immer du einen Node fertig machst, ob Ansible oder etwas anderes, es muss von außen angestoßen werden: vom Post-Installation-Webhook, von einem Job, der die Liste der Installationen beobachtet, oder von einem Menschen. Ich lasse es dabei. In diesem Beitrag geht es darum, mit einem PXE-Bestand jeden Standort zu bedienen, und die First-Boot-Lücke ist eine Grenze, um die du herum planst, keine, die ich hier überkleben werde.

Das Token ist der Standort

PDM sieht den Standort nicht, aber es begrenzt Antworten per Token, und ein Token ist nur ein Header. Wer den Header setzt, bestimmt, welche Antworten der Installer bekommen kann. Das ist der ganze Trick.

Also stell etwas vor PDM, das weiß, woher die Anfrage kam:

  1. Das Boot-Image trägt ein Catch-Token, catch-all:not-a-secret, das in jedem Image gleich ist und genau hier veröffentlicht wird. Es ist kein PDM-Token und öffnet allein nichts.
  2. Ein Caddy-Reverse-Proxy vor PDM schaut sich bei einer Antwortanfrage mit dem Catch-Token das Quell-Subnetz an und ersetzt den Header durch das echte Token dieses Standorts.
  3. PDM sieht site2-2026q4:<secret> und bietet nur die Antworten von site2 an.

Das Secret kommt nie in die Nähe des Boot-Images, also gibt ein verlorener USB-Stick oder ein kopiertes ISO niemandem irgendetwas. Das Token eines Standorts zu rotieren ist eine Änderung an Caddy, kein Neubau des Images. Und ein neuer Standort ist eine Zeile Caddy-Konfiguration und ein neues Token in PDM.

Das verlässt sich darauf, dass die Quelladresse ehrlich ist, und das ist eine Eigenschaft deines Netzes, nicht des Proxys. Dieser Kompromiss und die anderen stehen weiter unten unter „Was noch zu entscheiden ist“.

Wie der Installer PDM ohne eingebaute URL findet

Ein Image für jeden Standort funktioniert nur, wenn das Image auch die Adresse von PDM nicht enthält. Der Installer sucht seine Antwort-URL an drei Stellen, in dieser Reihenfolge14:

Wo der Installer seine Antwort-URL findet1. URL ins ISO eingebaut?prepare-iso --urljaverwendet, DHCP und DNS nie gefragtein Image pro PDM: hier vermiedennein2. DHCP-Option 250?aus dem Lease des Installers gelesenjaverwendet, mit Option 251 für denFingerprint: hier nicht gesendetnein, oder ein DHCP-Fehler3. DNS-TXT-AbfrageSuchdomain aus resolv.confproxmox-auto-installer.example.docURL und Fingerprint, beide aus DNS
Die Reihenfolge, der der Installer folgt. Lass die URL aus dem Image weg und sende keine Option 250, dann landet jeder Standort bei DNS.
RegelWas der Code tutWas es für dich bedeutet
Reihenfolgeeine URL im ISO gewinnt; sonst DHCP; jeder DHCP-Fehler fällt auf DNS zurück14das Image ohne --url bauen und nirgends Option 250 senden
Eintragsnamenproxmox-auto-installer.<search domain> für die URL, proxmox-auto-installer-cert-fingerprint.<search domain> für den Fingerprinteine Zone, zwei TXT-Einträge
Suchdomaindie erste search-Zeile von /etc/resolv.conf, an einem einzelnen Leerzeichen getrenntgenau eine Domain ausgeben; aus zweien wird ein ungültiger Name
Keine Domainscheitert mit Could not find search domain in resolv.conf.prüfen, ob DHCP-Option 15 oder 119 gesendet wird
TXT-Wertdig txt +short, jedes " entfernt, dann getrimmtein in mehrere Strings aufgeteilter TXT-Wert hinterlässt ein Leerzeichen in der URL; einen String daraus machen
Fingerprintnur aus DNS genommen, wenn auch die URL aus DNS kam und keiner eingebaut istden Zertifikats-Fingerprint von PDM veröffentlichen, das Caddy ebenfalls ausliefert
Tokengesendet als Authorization: Bearer <token>, im Klartext auf dem ISO gespeichert9deshalb trägt das Image nur das Catch-Token

Das Zertifikat, das der Installer prüft, ist das, welches auf 443 antwortet. In diesem Design liefert Caddy dort das eigene Zertifikat von PDM aus, also sieht ein Client auf 443 dasselbe Zertifikat wie PDM auf 8443, und es gibt einen Fingerprint zu veröffentlichen.

Der Aufbau: PDM, Boot-Dateien und DNS

Die Beispiele verwenden die Domain example.doc und drei Standorte, 10.10.50.0/24, 10.20.50.0/24 und 10.30.50.0/24. .doc ist keine echte Top-Level-Domain, also setz deine eigene ein. PDM, Caddy, dnsmasq und die Boot-Dateien liegen alle auf einem Host, pdm.example.doc unter 10.10.50.10, und die entfernten Standorte erreichen ihn über ein DHCP-Relay auf ihren Gateways. Das Management liegt auf 10.99.0.0/24 und ist damit das einzige Netz, das die Weboberfläche von PDM je zu sehen bekommt.

Tokens und Antworten in PDM

Unter Remotes → Automated Installations → Authentication Tokens legst du ein Token pro Standort an und gibst ihm ein Ablaufdatum, das zu deinem Rollout-Fenster passt. Der Name ist nicht geheim, und du wirst ihn in Logs sehen, also lass ihn etwas sagen: site1-2026q4. Kopier das Secret, wenn es angezeigt wird.

Dann legst du eine vorbereitete Antwort pro Standort und pro Produkt an, freigegeben nur für das Token dieses Standorts:

Feldsite1, Proxmox VE
Freigegebenes Tokensite1-2026q4
Zielfilter/product/product=pve
Land, Tastatur, Zeitzonegb, en-gb, Europe/London
Root-ZugangSSH-Schlüssel, dazu ein über das Formular gesetztes Passwort, das PDM nur als Hash speichert und sendet4
NetzwerkDHCP, Interface-Namen festgelegt
FQDN{{ product.product }}-site1-{{ installation_nr }}.example.doc
Diskdein Layout, zum Beispiel ein ZFS-Mirror über zwei benannte Disks
Post-Installation-WebhookBasis-URL https://pdm.example.doc, mit dem Zertifikats-Fingerprint von PDM

Der Zähler steigt bei jeder ausgelieferten Antwort, Wiederholungen eingeschlossen, also können Namen Nummern überspringen. Der Zähler in meinem Lab steht nach drei fertigen Installationen bei 4. Wenn du vorhersagbare Namen brauchst, nimm FQDN from DHCP und leg den Hostnamen stattdessen in die DHCP-Reservierung.

Die Basis-URL des Webhooks ist die eine Einstellung, die sich für dieses Design ändern muss. Es liegt nahe, sie auf PDM selbst an Port 8443 zeigen zu lassen, so hatte es mein Lab, und 8443 wird gleich geschlossen. Lass sie auf Caddy zeigen.

Die Boot-Dateien

Auf einem beliebigen Proxmox-VE- oder Debian-Host mit dem 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/

Kein --url, weil DNS die URL liefert. --pxe-loader ipxe impliziert --pxe, und mit --pxe muss die Ausgabe ein bereits existierendes Verzeichnis sein15. Du bekommst vier Dateien: vmlinuz, initrd.img, von zstd auf gzip umkomprimiert, weil iPXE kein zstd-Initrd laden kann, das vorbereitete ISO ohne /boot und ein Menü boot.ipxe, das nach zehn Sekunden standardmäßig die automatische Installation startet und die anderen Dateien über relative Namen anspricht15. Halt alle vier in einem Verzeichnis.

Jedes Ziel braucht genug Arbeitsspeicher für Kernel, Initrd und das ganze ISO. Die Notizen zu Proxmox VE 9.2 nennen „at least 6 GiB of available memory on the host“5. Dieselben Notizen führen ein bekanntes Problem mit dem Kernel des 9.2-ISO auf megaraid_sas-Controllern und einigen NVMe-Laufwerken im JBOD-Modus (just a bunch of disks), mit dem Rat, vom 9.1-ISO zu installieren und dann zu aktualisieren5. Teste deine Hardware, bevor du ihr den Weg übers Netz anvertraust.

Die DNS-Einträge

In der Zone, die zu der einen Suchdomain passt, die DHCP ausgibt:

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

Der Fingerprint ist der des eigenen Zertifikats von PDM:

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

Prüf sie so, wie der Installer es tun wird:

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

Caddy, die einzige Tür zu PDM

Die API von PDM lauscht auf [::]:8443, und das ist eine in das Binary kompilierte Konstante, keine Einstellung16. Du kannst sie nicht an das Loopback binden. Also wird Caddy der einzige Weg hinein, und die Firewall stellt das sicher.

Die Routen von Caddy vor PDMANFRAGEWAS PASSIERTAntwort-POST, Standort-Subnetz, Catch-Tokenumgeschrieben auf das Token des StandortsPost-Hook-POST, Standort-SubnetzAuthorization entfernt, weitergereichtWeboberfläche oder API, Management-Subnetzunverändert weitergereicht, PDM-Loginalles andere, von überallabgebrochen, erreicht PDM niePort 8443, von außerhalb der Kistevon nftables verworfen
Jede Anfrage, die Caddy sieht, endet an einer von fünf Stellen. Nur die ersten drei erreichen je PDM.

PDM hat genau zwei Routen, die ohne PDM-Login funktionieren: POST /api2/json/auto-install/answer mit einem Installer-Token und POST …/installations/{uuid}/post-hook mit dem Einmal-Token im Body4. Das sind die einzigen zwei, die ein Provisioning-Netz je erreichen sollte.

Nimm Caddy 2.11.1 oder neuer. 2.11.1 hat einen Bypass des Path-Matchers behoben, CVE-2026-27587, und genau auf diese Art von Pfad-Sperre stützt sich das hier17. Das eigene Paket von Debian 13 ist 2.6.2, also nimm es aus trixie-backports, das 2.11.2 führt, oder aus dem eigenen Repository von Caddy18.

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

Die Teile, die Sicherheitsarbeit leisten, und warum jeder davon da ist:

ZeileWarum
header Authorization "Bearer catch-all:not-a-secret" im Standort-Matcherohne sie bekommt jeder POST aus einem Standort-Subnetz das echte Token; eine Anfrage muss aus dem richtigen Subnetz kommen und das Catch-Token tragen
request_header Authorization …setzt den Header und überschreibt, was der Client geschickt hat, also wird ein gefälschtes Token ersetzt, nie weitergereicht19
remote_ipder unmittelbare Peer; kein NAT und kein Load Balancer zwischen die Installer und Caddy stellen20
benannte handle-Blöckebleiben in der geschriebenen Reihenfolge und schließen sich gegenseitig aus, also hat das nackte handle { abort } das letzte Wort21
@mgmtdie volle PDM-Oberfläche und API, unverändert, also funktionieren Login, API-Tokens und Cookies von PDM wie gewohnt; ein Standort-Subnetz bekommt nichts davon
admin unix//…|0600die Admin-API läuft standardmäßig auf TCP localhost:2019, ohne Authentifizierung22
protocols h1 h2Caddy lauscht standardmäßig für HTTP/3 auf UDP 44322; das schaltet es ab, statt einen Port offen zu lassen, den niemand öffnen wollte
@hiddenfile_server liefert jede Datei unter seinem Root aus, Dotfiles eingeschlossen, und hide ist ausdrücklich nicht als Sicherheitsgrenze dokumentiert23
Csrfpreventiontoken replace REDACTEDCaddy schwärzt Authorization und Cookie standardmäßig im Access-Log, aber nicht den CSRF-Header von PDM24

Der Path-Matcher ist nachsichtiger als PDM, und das ist die richtige Richtung. Caddy bereinigt den Pfad, dekodiert Prozent-Kodierung, fasst doppelte Schrägstriche zusammen und ignoriert Groß- und Kleinschreibung, bevor es matcht20. Also zählen //answer, %61nswer und ANSWER alle als Antwortroute, und eine Variante /api2/extjs/… oder ein abschließender Schrägstrich matcht keine Standortroute und fällt bis zu abort durch.

Zwei Entscheidungen darin brauchen eine Erklärung.

Caddy liefert das Zertifikat von PDM aus, direkt aus den Dateien von PDM. Caddy liest api.pem und api.key dort, wo PDM sie ablegt, also sehen die Installer, der Webhook und ein Browser alle das Zertifikat, das PDM auf 8443 vorzeigt, und es gibt keine zweite Kopie, die auseinanderlaufen kann. Der naheliegende Weg, Caddy diesen Zugriff zu geben, ist, den Benutzer caddy zu www-data hinzuzufügen. Auf meinem Lab habe ich geprüft, was diese Gruppenmitgliedschaft tatsächlich erreicht, indem ich jede Datei als Benutzer caddy getestet habe:

Datei unter /etc/proxmox-datacenter-manager/Lesbar für caddy in www-data
remotes.shadow, die Zugangsdaten jedes verwalteten Remotesja
autoinst/tokens.shadow, jedes Installer-Token-Secretja
auth/api.key, auth/csrf.keyja
access/user.cfg, subscriptions/ja

Nichts auf dieser Liste hat in der Nähe eines Web-Proxys etwas verloren. Ein Bug im Proxy wäre dann eine Kompromittierung jedes Clusters, den PDM verwaltet.

Also bleibt Caddy aus www-data heraus und bekommt eine ACL auf genau die zwei Dateien, die es braucht, dazu die Erlaubnis, durch die zwei Verzeichnisse darüber hindurchzugehen, ohne sie aufzulisten:

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

Nach dem Ausführen auf dem Lab als Benutzer caddy getestet:

Pfadcaddy
auth/api.pem, auth/api.keykann lesen
auth/csrf.key, auth/authkey.key, auth/authkey.pubkann nicht lesen
remotes.shadow, remotes.cfg, autoinst/tokens.shadow, access/user.cfgkann nicht lesen
auth/ auflistenverweigert

Der Haken ist die Erneuerung. PDM schreibt ein erneuertes Zertifikat in eine neue Datei und benennt sie an ihren Platz um, und eine neue Datei trägt die ACL der alten nicht. Also setzt systemd die ACL bei einer Erneuerung durch PDM wieder und lädt Caddy neu, ohne dass jemand eingreifen muss.

Eine Prüfung, dass Zertifikat und Schlüssel zusammengehören, weil eine Erneuerung sie nacheinander schreibt:

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

Eine Path-Unit, die beide Dateien beobachtet, und der Dienst, den sie startet:

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

Das Override weiter unten führt dasselbe ACL-Skript vor jedem Start von Caddy aus, also wird eine Erneuerung, während Caddy gestoppt war, nie verpasst.

Die Details, die es zum Laufen bringen, jedes davon geprüft:

TeilWarum
beide Dateien beobachtetder erste Schreibvorgang löst die Unit aus; die Bedingung überspringt sie, solange das Paar nicht passt, und der zweite Schreibvorgang löst sie mit einem passenden Paar erneut aus
ExecConditioneine fehlgeschlagene Bedingung überspringt den Lauf, statt ihn scheitern zu lassen, also erreicht eine halb geschriebene Erneuerung Caddy nie25
PathChangedlöst aus, wenn eine beobachtete Datei, die zum Schreiben geöffnet war, geschlossen wird26; auf dem Lab löste es auch aus, wenn eine neue Datei über die alte umbenannt wurde
das ACL-Skript bei jedem Laufeine per Umbenennung eingesetzte Datei hat keine ACL, also konnte Caddy sie nicht lesen, bis das Skript wieder läuft
try-reload-or-restartlädt Caddy neu, wenn es läuft, und tut nichts, wenn nicht
reload --force in der Unit von Caddyohne --force sieht Caddy dieselbe Konfiguration und liefert weiter das alte Zertifikat aus

Die letzte Zeile ist keine Vermutung. In einem Wegwerf-Container mit Caddy 2.11.7 habe ich die Zertifikatsdatei hinter einem laufenden Server getauscht: Ein einfaches caddy reload lieferte weiter das alte Zertifikat aus, und caddy reload --force lieferte das neue. Die paketierte Unit lädt schon mit --force neu27, also behalt diese Zeile, wenn du ExecReload überschreibst.

Auf dem Lab habe ich api.key und api.pem von PDM so ersetzt, wie es eine Erneuerung tut, indem ich neue Dateien geschrieben und über die alten umbenannt habe. Direkt nach dem Umbenennen hatte api.key keinen ACL-Eintrag für caddy. Innerhalb von vier Sekunden hatte die Path-Unit ausgelöst, die ACL war auf beiden Dateien zurück, und Caddy hatte an Ort und Stelle neu geladen, mit derselben Prozess-ID.

Token-Name und Secret bleiben getrennt. Der Name steht in der Caddyfile, wo er protokolliert wird. Das Secret steht in einer Umgebungsdatei, die nur root lesen kann, und Caddy liest es zur Laufzeit der Anfrage über {env.*}.

Protokollier, welches Token benutzt wurde, nie das Secret

Die erste Version davon in meinem Lab hatte ein Leck. Die paketierte Unit von Caddy startet es als caddy run --environ, und --environ gibt vor dem Start die gesamte Umgebung aus28,27. Jeder Neustart schrieb das Standort-Token im Klartext ins Journal: acht Zeilen davon, als ich gezählt habe. Nichts im Access-Log, nichts in den eigenen Logs von PDM, alles von diesem einen Flag.

Wohin das Token-Secret und der Token-Name gehenDAS SECRETpdm-secrets.envnur root, 0600Umgebung von Caddy{env.PDM_SECRET_SITE1}Authorization-Headernur der Loopback-Hop zu PDMDER NAMEimport-Zeile inder CaddyfileAccess-Log, jede Antwortanfrage"pdm_token":"site1-2026q4"NIE GESCHRIEBEN INdas Journal--environ entferntdie Admin-APIUnix-Socket, 0600einen Config-Dumpnur Platzhalter
Das Secret hat eine Aufgabe und einen Weg. Der Name ist das, was du protokollierst.

Also enthält die Secrets-Datei Secrets und sonst nichts:

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

und das Unit-Override streicht --environ und gibt dem Admin-Socket ein Zuhause:

# 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

Das + führt diese Zeilen als root aus, bevor Caddy als eigener Benutzer startet. Die Log-Zeilen zählen, sobald irgendetwas anderes /var/log/caddy anfasst: Ein von root rotiertes Log oder eine beim Testen von Hand angelegte Datei lässt Caddy sein eigenes Access-Log nicht mehr öffnen. Auf dem Lab habe ich pdm.log auf root:root gesetzt und Caddy neu gestartet; es kam als caddy:caddy zurück, und Caddy protokollierte keine Berechtigungsfehler.

Nach dem Bearbeiten neu starten statt neu laden, weil ein Reload die Umgebung nicht neu einliest. Nimm {env.NAME}, nie {$NAME}: Die zweite Form wird ersetzt, wenn die Caddyfile adaptiert wird, also landet das Secret in der JSON-Konfiguration, in der Autosave-Datei und in allem, was sie ausgibt29. Auch hier zählt der Admin-Socket. {env.*} löst alles in der Prozessumgebung auf, ohne Allow-List, also kann jeder, der über eine TCP-Admin-API eine Konfiguration laden kann, Caddy dazu bringen, ihm ein Secret vorzulesen30.

Jede Antwortzeile im Access-Log nennt jetzt Standort und Token, mit dem Header selbst geschwärzt. Aus dem Lab, wo beide siteA heißen:

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

Ich habe es geprüft, statt mich darauf zu verlassen. Auf einer Podman-Kopie dieser Konfiguration mit Dummy-Secrets und dann auf dem Lab habe ich Antworten über jeden Weg geschickt, der mir eingefallen ist: normal, mit eingeschaltetem Debug-Logging, mit abgeschaltetem Upstream für eine 502, mit einem Reload und einem Neustart. Dann habe ich jedes Secret im Journal, im Access-Log, in den Konfigurations- und Datenverzeichnissen von Caddy und im adaptierten JSON gezählt. Jede Zählung war null. Der Kontrolllauf, dieselbe Konfiguration mit --environ gestartet, gab das Secret zweimal aus.

Zwei Einschränkungen aus diesem Test. Mit debug protokolliert Caddy seine ganze adaptierte Konfiguration, also tauchen dort das Catch-Token und die Platzhalter {env.*} auf; die Werte dahinter nicht. Lass Debug in der Produktion trotzdem aus. Und wenn ein Secret schon durch ein Journal gegangen ist, wie meines, erzeug es in PDM neu und aktualisier die Datei; Log-Zeilen löschen ist nicht die Lösung.

Die Firewall: 8443 verlässt die Kiste nie

Mein Lab hatte überhaupt keine Firewall, also antwortete 8443 jedem Host im Provisioning-Netz, und jede Regel in der Caddyfile ließ sich umgehen. Mach es zu. nftables, mit Drop als Standard:

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

Die Verbindungen von PDM zu seinen Remotes gehen nach außen, also ändert das Schließen von 8443 eingehend dort nichts. Lade einen neuen Regelsatz beim ersten Mal mit einem zeitgesteuerten Rollback, damit dich ein Fehler neunzig Sekunden kostet statt einen Gang zur Konsole:

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

Auf dem Lab, nach alledem:

PrüfungErgebnis
8443 aus dem Management-Netzkeine Verbindung; der Drop-Zähler steigt
PDM-Weboberfläche über Caddy auf 443, aus dem ManagementLogin klappt, alles funktioniert
PDM-Weboberfläche von einer Standortadresseabgebrochen
Antwort-POST von einem Standort mit dem Catch-Tokenerreicht PDM mit dem Standort-Token: eine absichtlich nicht passende Probe bekam 400 no answer file found
dieselbe Probe direkt an PDM mit einem erfundenen Token401
Antwort-POST ohne das Catch-Token oder als /api2/extjs/…abgebrochen
Dateien, in denen die Secrets von PDM liegen, gelesen als caddykeine lesbar
das Standort-Secret in Journal, Access-Log oder Daten von Caddy seit der Änderung0

DHCP, iPXE und die Schleife

Server-Firmware kann ihre erste Datei nur über TFTP holen, also liefert TFTP den kleinen iPXE-Loader aus, und iPXE holt alles andere über HTTP. Die Falle ist, dass iPXE eine eigene DHCP-Anfrage schickt, und ein Server, der darauf wieder mit ipxe.efi antwortet, schickt die Maschine für immer im Kreis. Der DHCP-Server muss Firmware und iPXE auseinanderhalten.

Warum eine einfache DHCP-Antwort im Kreis läuftEinfache Antwort: SchleifeFirmware fragt nach einer Boot-DateiAntwort: ipxe.efiiPXE startet und fragt erneutgleiche Antwort: ipxe.efider Server kann Firmware nichtvon iPXE unterscheiden, es geht nie weiteriPXE-bewusst: es klapptFirmware fragt nach einer Boot-Dateinicht iPXE: ipxe.efi über TFTPiPXE fragt erneut, Option 175 gesetztAntwort: die URL von boot.ipxe, über HTTPKernel, Initrd und ISO, dann Installation
Zweimal dieselbe Anfrage bekommt zweimal dieselbe Antwort. Option 175, die nur iPXE sendet, ist das, woran der Server die zweite Anfrage von der ersten unterscheidet.
DHCP-ServerWie er die Schleife bricht
dnsmasqAnfragen mit Option 175, die nur iPXE sendet, taggen und diesen die HTTP-URL geben31
ISC dhcpdif exists user-class and option user-class = "iPXE"32
FortiGatedas Feld filename nimmt nur einen Wert, also einen iPXE-Loader mit eingebetteter URL bauen und den an alle ausliefern

dnsmasq, das sein eigenes Netz und die anderen beiden über Relays bedient, nur mit Reservierungen:

# /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 heißt kein Pool: Nur reservierte Maschinen bekommen eine Adresse, und das hält Fremde vom Installationspfad fern. Die Adresse jeder Reservierung muss im /24 ihres eigenen Standorts liegen, weil Caddy genau an dieser Adresse das Token auswählt. Ein Node, der ins falsche Subnetz reserviert ist, bekommt die Antwort des falschen Standorts oder gar nichts.

Reservierungen sind eine Liste von MAC-Adressen, und das ist Buchhaltung pro Maschine, genau von der Art, die der Kunde nicht wollte. Wenn Einstecken und Booten wirklich die Anforderung ist, gib jedem Provisioning-VLAN stattdessen einen kleinen dynamischen Bereich, zum Beispiel dhcp-range=set:site1,10.10.50.100,10.10.50.150,255.255.255.0,1h. Der Standort kommt weiterhin aus dem Subnetz, also wählt Caddy weiterhin das richtige Token, und die FQDN-Vorlage mit installation_nr benennt den Node. Der Preis ist die erste Zeile von „Was noch zu entscheiden ist“: Alles, was in dieses VLAN gesteckt wird, kann jetzt die Antwort des Standorts abholen, also wird das VLAN selbst zum Tor.

dnsmasq handelt die TFTP-Blockgröße mit dem Client aus. Lässt du es in Ruhe, begrenzt es sie auf die MTU seines eigenen Interfaces31, und das funktioniert, bis der Weg zu einem Standort kleiner ist als der Link, an dem dnsmasq hängt. Also legt die Konfiguration die Obergrenze als tftp-mtu=1500 fest, statt sie zu erben. Damit steht die Zahl vor den Augen dessen, der einen Standort hinter einem IPsec-Tunnel oder irgendetwas anderem betreut, das vom Paket etwas abknapst, und er senkt sie auf das, was der Weg wirklich trägt, statt es an einer Übertragung zu merken, die mittendrin hängen bleibt. Wenn sich ein NIC-ROM schon daneben benimmt, sobald die Option überhaupt gewährt wird, schaltet tftp-no-blocksize die Aushandlung ab.

Kopier ipxe.efi aus dem Debian-Paket ipxe nach /srv/tftp, mit dem Benutzer dnsmasq als Eigentümer, weil tftp-secure alles andere verweigert. Nimm lieber UEFI. Secure Boot braucht ein signiertes iPXE, also richte das entweder ein oder schalte Secure Boot für die Installation aus und danach wieder ein.

Booten, beobachten und was kaputtgeht

Teste mit einer virtuellen Maschine in einem Standortnetz, bevor du Hardware anfasst: UEFI-Firmware, 8 GiB Arbeitsspeicher und eine NIC in diesem VLAN bilden die ganze Kette nach.

Was beim Booten passiertZielserverUEFI-Netzwerkboot6 GiB oder mehr1. DHCP und TFTPein Lease, eine Suchdomain, dann ipxe.efi2. HTTP auf Port 80boot.ipxe, vmlinuz, initrd.img und das ISO, in den Speicher3. DNSzwei TXT-Einträge: Antwort-URL und Fingerprint von PDM4. Caddy auf 443, dann PDMjetzt die Antwort, nach dem Neustart der Webhook
Vier getrennte Gespräche, jedes mit seiner eigenen Stelle zum Nachsehen, wenn es scheitert.

Und das Ganze der Reihe nach, vom Einschalten bis zu einer Installation, die PDM als Finished markiert:

Vom Einschalten bis fertig installiertZieldnsmasq, :80Caddy :443PDMDHCP: ein LeaseTFTP: ipxe.efiiPXE fragt erneutHTTP: Kernel, Initrd, ISOInstaller: eine DomainDNS: zwei TXT-EinträgePOST, Catch-TokenStandort-Tokenin Bearbeitungdie gerenderte Antwort, über Caddyinstalliert auf DiskWebhook, Einmal-TokenAuthorization entferntFinishedNeustart; erster Bootaktiviert den Key
Jeder Schritt, der Reihe nach. Der Webhook wird vom Installer vor dem Neustart gesendet, also heißt Finished installiert, nicht gebootet; der Subscription-Key wird danach aktiviert, beim ersten Boot.
SchrittWas passiertWo du nachsiehst
1die Firmware holt ipxe.efi über TFTP, iPXE holt boot.ipxe über HTTPjournalctl -u dnsmasq, das Log von Caddy
2nach zehn Sekunden bootet das Menü den automatischen Eintrag; Kernel, Initrd und ISO werden geladenvier GETs, einer davon etwa 1,6 GB
3der Installer holt einen Lease, liest die Suchdomain, fragt beide TXT-Einträge ab/tmp/fetch_answer.log auf dem Ziel
4POST an Caddy, Token getauscht, PDM wählt eine Antwortdas Log von Caddy nennt das Token; PDM zeigt eine neue Installation
5die Installation läuft/tmp/auto_installer.log, /tmp/install-low-level-start-session.log
6noch im Installer ruft der Webhook über Caddy zurück, dann startet die Maschine neu33die Installation wechselt zu Finished, mit Disks, NICs und SSH-Host-Keys

Bei einem Fehler landet der Installer in einer Shell, außer die Antwort setzt Reboot on Error, also lass das beim Testen aus.

SymptomWahrscheinliche Ursache
Could not find search domain in resolv.conf.DHCP hat keine Domain gesendet
eine TXT-Abfrage nach einem Namen mit Leerzeichenmehr als eine Suchdomain
Got empty response.Eintrag fehlt, falsche Zone, oder Option 6 zeigt auf einen anderen DNS-Server
eine URL mit einem Leerzeichen in der MitteTXT-Wert in mehrere Strings aufgeteilt
das Log sagt, die URL kam von DHCPOption 250 ist irgendwo im Netz
TLS-Fehler beim POSTder Fingerprint in DNS ist nicht das aktuelle Zertifikat von Caddy
Verbindung ohne Antwort geschlossenCaddy hat abgebrochen: falsches Subnetz, kein Catch-Token oder eine Route, die keine der zwei ist
400 no answer file founddas Token hat funktioniert; kein Filter hat gematcht und es gibt keine Standardantwort auf diesem Token
die falsche Antwortüberlappende Filter; der erste in der Datei gewinnt
Status bleibt in Bearbeitungdie Basis-URL des Webhooks zeigt noch auf 8443
iPXE lädt sich endlos selbst neuder DHCP-Server unterscheidet iPXE nicht von der Firmware
PXE-E32 TFTP-Timeoutnext-server falsch oder UDP 69 blockiert

Proxmox Backup Server hinzufügen

Nichts an diesem Weg ist spezifisch für Proxmox VE. Bau aus dem PBS-4-ISO mit demselben Catch-Token einen zweiten Satz Boot-Dateien nach /srv/www/pbs-4/ und leg pro Standort eine PBS-Antwort mit dem Filter /product/product=pbs an, freigegeben für das Token dieses Standorts. Die DNS-Einträge, Caddy, die Tokens und die Firewall ändern sich nicht.

Für einen Standort mit beidem muss etwas entscheiden, welches Produkt eine Maschine bekommt, und ein Menü ist das Falsche, um das zu entscheiden. choose von iPXE ohne --timeout wartet an der Konsole für immer auf einen Tastendruck, und das ist das Gegenteil von unbeaufsichtigt.

Lass stattdessen die DHCP-Reservierung entscheiden. Du reservierst ohnehin jeden Node per MAC-Adresse, also tagge die PBS-Nodes und schick jedem Tag seine eigene 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 an einer Reservierung taggt diese eine Maschine31, und tag:!pbs hält die zwei Boot-Zeilen gegenseitig exklusiv, also stellt sich die Frage nicht, welche gewinnt. Die eigene boot.ipxe jedes Produkts bootet ihren automatischen Eintrag schon nach zehn Sekunden, ohne dass jemand an der Tastatur sitzt15.

Diese Zeilen bestehen dnsmasq --test auf 2.91, der Version in meinem Lab, und folgen der dokumentierten Syntax für dhcp-host und Tags. Ich habe noch keinen PBS-Node darüber per PXE gebootet, also probier eine getaggte Maschine aus, bevor du dich darauf verlässt.

Ab da ist es derselbe Weg wie bei Proxmox VE. Der PBS-Installer schickt "product": "pbs", Caddy tauscht das Token des Standorts ein, und PDM wählt die PBS-Antwort dieses Standorts. An keiner Stelle wird etwas von Hand gewählt, und welches Produkt ein Node wird, steht an genau einer Stelle, in der Reservierung, neben seiner Adresse.

Was entscheidetWo es liegt
welches ISO bootetdas Tag set:pbs an der DHCP-Reservierung
welche Antworten des Standorts geltendas Quell-Subnetz, über den Token-Tausch von Caddy
welche Antwort innerhalb dieses Standortsder Filter /product/product=pbs in PDM

Der Installer legt nur das Betriebssystem an. Der Datastore, auf Storage getrennt von der OS-Disk, ist eine weitere Aufgabe für das, was den Node fertig macht, und das bringt uns zurück zum fehlenden First-Boot-Hook.

Was noch zu entscheiden ist

Nichts davon hält dich davon ab, dieses Design zu benutzen. Jeder Punkt braucht jemanden, dem er gehört.

KompromissWarum er zähltWas zu tun ist
die Quelladresse ist das Toralles in einem Provisioning-VLAN, oder alles, was dort eine Adresse fälschen kann, kann die Antwort dieses Standorts holen: Hash des Root-Passworts, SSH-Schlüssel, Subscription-Keydas VLAN isolieren, Port Security, nur Reservierungen, Token-Ablauf, Tokens zwischen Rollouts deaktivieren
das Catch-Token ist kein Geheimnises steckt in jedem Image und steht auf dieser Seitees filtert nur verirrte Anfragen aus; das Subnetz und das echte Token in Caddy sind das Tor
Caddy kann den Schlüssel von PDM lesendas Zertifikat von PDM auszuliefern heißt, dass der Proxy diesen einen privaten Schlüssel liesteine ACL nur auf api.pem und api.key, sonst nichts von PDM lesbar, der Host abgeschottet; der Schlüssel verlässt ihn nie
DNS wird vertrautein gefälschter TXT-Eintrag schickt den Installer woandershin, und wenn auch der Fingerprint aus DNS kommt, kontrolliert der Fälscher beidesdas Provisioning-DNS schützen oder den Fingerprint von PDM mit --cert-fingerprint einbauen und bei einer Änderung neu bauen
Boot-Dateien gehen über reines HTTPsie können auf der Leitung gelesen oder ausgetauscht werdendas Netz isoliert halten; iPXE über HTTPS, wenn dein Build es kann
das Webhook-Token16 zufällige Bytes, einmal verwendbar, nach dem Rückruf gelöscht, aber ohne Zeitlimit4alte Installationsdatensätze aufräumen
der Subscription-Keyeinmal aktiviert, nachdem das Netz steht; bei einem Fehler wird der Key gelöscht und die Unit scheitert, ohne Wiederholung34auf neuen Nodes systemctl --failed prüfen
PDM ist ein einzelner Punkter ist heute nicht hochverfügbarein Ausfall stoppt neue Installationen, keine laufenden Cluster
eine Management-NICdie Antwort hat keine Optionen für Bond, VLAN oder Bridge9das ist Arbeit nach der Installation, mit demselben First-Boot-Problem

Ein erster Wurf, ordentlich darauf aufgebaut

Das ist ein erstes Release der Funktion, und für ein erstes Release ist es gut. Das Antwortformular ist vernünftig, die Auswahlregeln sind klar, sobald du sie gelesen hast, der Webhook funktioniert, und der Code ist offen, und nur deshalb ließ sich irgendetwas von alledem beweisen statt erraten. Die Patch-Serie ist vier Runden über die öffentliche Mailingliste gegangen, bevor sie gelandet ist35, und das merkt man.

Die Lücken sind genau die zwei Dinge, die aus einem Installationsserver ein Provisioning-System machen. Ein Standort ist die Einheit, in der ein MSP denkt, und der Installer steht darauf, wenn er fragt. Ein First-Boot-Hook ist der Unterschied zwischen einem Node, der installiert ist, und einem Node, der fertig ist. Beides ist schon halb da: Der Installer hat den Hook, und PDM hat das Token. Ich erwarte, dass beides kommt, und wenn es so weit ist, wird der Caddy in der Mitte dieses Designs einfacher, statt zu verschwinden, denn ein Proxy, der die Secrets von PDM außer Reichweite und 8443 vom Netz fernhält, lohnt sich sowieso.

Der Grund, es bis dahin so zu machen, ist der vom Anfang. Zehn PXE-Server sind zehn Dinge, die in zehn verschiedenen Geschwindigkeiten veralten, und jedes wird erst angeschaut, wenn es an dem Tag ausfällt, an dem du es brauchst. Ein Bestand, einmal aktuell gehalten, mit dem Standort vom Netz ermittelt und den Secrets an einem Ort, ist weniger, wofür du geradestehen musst. Weniger, wofür du geradestehen musst, ist weniger, was schiefgehen kann, und das ist der größte Teil der Arbeit.


  1. Proxmox — Proxmox Datacenter Manager 1.1 press release — automatische Installationen, mit einem neuen Tab unter Remotes, der den Installationsfortschritt verfolgt. ↩︎

  2. Proxmox Datacenter Manager documentation — Automated Installations — vorbereitete Antworten, Zielfilter, Templating und „a dedicated token mechanism, separate from the normal API tokens“. ↩︎ ↩︎

  3. proxmox-datacenter-manager, lib/pdm-api-types/src/auto_installer.rs — die Schlüssel der Zielfilter „are JSON Pointers as per [RFC6901], the values globs“; nur eine Standardantwort. ↩︎

  4. proxmox-datacenter-manager, server/src/api/auto_installer/mod.rs — Token-Prüfung vor der Standardantwort, erster Treffer in Dateireihenfolge, first_boot: None, Zähler-Inkremente, das Einmal-Token für den Post-Hook, die zwei Routen, die ohne PDM-Login erreichbar sind, und die Privilegien Sys.Audit/Sys.Modify auf /system/auto-installation. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  5. Proxmox VE Roadmap — Proxmox VE 9.2 — PXE- und iPXE-Vorbereitung, „at least 6 GiB of available memory on the host“, und das bekannte Problem mit megaraid_sas und NVMe-JBOD. ↩︎ ↩︎ ↩︎

  6. Proxmox Bugzilla 8078 — „prepared answer disk setup does not allow to specify multiple udev filters with same name“, eröffnet am 26. September 2026, Status NEW am 7. November 2026. ↩︎

  7. Proxmox Bugzilla 7699 — „Add on-first-boot parameter to the PDM remote installation“, eröffnet am 12. Juni 2026, Status NEW am 7. November 2026. ↩︎ ↩︎

  8. proxmox, proxmox-installer-types/src/lib.rs — SystemInfo: Produkt, ISO, DMI und Netzwerk-Interfaces. ↩︎

  9. Proxmox VE wiki — Automated Installation — die Optionen [first-boot] und [network], die gesendeten Systeminformationen, und dass das 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“, eröffnet am 31. Oktober 2026, Status NEW am 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 trägt first_boot: Option<FirstBootHookInfo>. ↩︎

  13. pve-installer, proxmox-auto-installer/src/bin/proxmox-auto-installer.rs — das First-Boot-Programm wird nur eingerichtet, wenn die Antwort einen Abschnitt first_boot hat. ↩︎

  14. pve-installer, proxmox-fetch-answer/src/fetch_plugins/http.rs — zuerst die URL im ISO, dann DHCP, dann DNS; die TXT-Namen; das Parsen von resolv.conf; dig txt +short mit entfernten Anführungszeichen; der Bearer-Header. ↩︎ ↩︎

  15. pve-installer, proxmox-auto-install-assistant/src/main.rs — --pxe-loader impliziert --pxe, das Ausgabeverzeichnis, das gzip-Initrd, /boot aus dem ISO entfernt, und die erzeugte boot.ipxe. ↩︎ ↩︎ ↩︎

  16. proxmox-datacenter-manager, server/src/bin/proxmox-datacenter-api/main.rs — PDM_LISTEN_ADDR ist die nicht spezifizierte IPv6-Adresse auf PDM_PORT, 8443, beim Build festgelegt. ↩︎

  17. Caddy releases — 2.11.1 hat CVE-2026-27587 behoben, einen Bypass des Path-Matchers über Escape-Sequenzen. ↩︎

  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 ist der unmittelbare Peer; Pfad-Matches ignorieren Groß- und Kleinschreibung, sind URL-dekodiert und bereinigt, mit zusammengefassten doppelten Schrägstrichen. ↩︎ ↩︎

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

  22. Caddy — Global options — admin standardmäßig auf localhost:2019; protocols standardmäßig h1 h2 h3. ↩︎ ↩︎

  23. Caddy — file_server — hide sollte nicht als Sicherheitsgrenze behandelt werden. ↩︎

  24. Caddy — log — Cookie, Set-Cookie, Authorization und Proxy-Authorization werden als REDACTED protokolliert. ↩︎

  25. systemd.service — ExecCondition= — bei einem Exit-Code von 1 bis 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 gibt vor dem Start die Umgebung aus. ↩︎

  29. Caddy — Caddyfile concepts, environment variables — {$NAME} wird ersetzt, bevor die Caddyfile geparst wird. ↩︎

  30. caddyserver/caddy, replacer.go — jeder Schlüssel, der mit env. beginnt, wird an os.Getenv übergeben. ↩︎

  31. dnsmasq manual page — dhcp-match, dhcp-boot, dhcp-host mit set:<tag>, enable-tftp, tftp-secure, tftp-mtu, tftp-no-blocksize und static-Ranges. ↩︎ ↩︎ ↩︎

  32. iPXE — Configuring ISC dhcpd — if exists user-class and option user-class = "iPXE" gibt iPXE das Skript und der Firmware den Loader. ↩︎

  33. pve-installer, unconfigured.sh — proxmox-post-hook läuft direkt nach dem erfolgreichen proxmox-auto-installer, vor dem Neustart. ↩︎

  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 — die Serie, die die Funktion hinzugefügt hat, mit ihren Änderungsnotizen von v3 auf v4. ↩︎