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 wollten | Was es hier liefert |
|---|---|
| Namensschema, Passwort und Schlüssel des Standorts | eine vorbereitete Antwort pro Standort, ausgewählt über das Token dieses Standorts |
| ins richtige VLAN stecken, einschalten, weggehen | DNS-Discovery, iPXE und Caddy, der das Token anhand des Quell-Subnetzes wählt |
| keine Seriennummern, keine Antwortdateien pro Maschine | Antworten filtern nur auf das Produkt; der Standort kommt aus dem Netz |
| der neue Server tritt dem bestehenden Cluster bei | nicht 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.
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.
| Teil | Was er tut | Detail, auf das es ankommt |
|---|---|---|
| Vorbereitete Antworten | Land, Tastatur, Zeitzone, Hash des Root-Passworts, SSH-Schlüssel, Netzwerk, Disk-Layout, Subscription-Key, Aktion nach der Installation | bei jeder Anfrage frisch gerendert |
| Zielfilter | entscheiden, welche Maschine welche Antwort bekommt | JSON Pointer in die Systeminformationen des Installers, Glob für den Wert3 |
| Templating | MiniJinja in den Feldern FQDN, Admin-E-Mail, IP, Gateway und DNS | Zähler wie installation_nr steigen bei jeder ausgelieferten Antwort4 |
| Authentifizierungs-Tokens | der 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 |
| Installationen | ein Datensatz pro Anfrage, mit den vollständigen Systeminformationen | wechselt von in Bearbeitung zu fertig, wenn der Post-Install-Webhook zurückruft |
| Berechtigungen | Sys.Audit zum Ansehen, Sys.Modify zum Ändern | beide 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üssel | Was er enthält | Könnte er den Standort nennen? |
|---|---|---|
product | fullname, product (pve, pbs, pmg), enable_btrfs | nein |
iso | release, isorelease | nein |
dmi | System, Baseboard und Chassis: Name, Seriennummer, UUID, SKU, Asset-Tag | nur wenn jemand den Standort ins Asset-Tag geschrieben hat |
network_interfaces | eine 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.
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_btrfsist 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.
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:
- 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. - 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.
- 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:
| Regel | Was der Code tut | Was es für dich bedeutet |
|---|---|---|
| Reihenfolge | eine URL im ISO gewinnt; sonst DHCP; jeder DHCP-Fehler fällt auf DNS zurück14 | das Image ohne --url bauen und nirgends Option 250 senden |
| Eintragsnamen | proxmox-auto-installer.<search domain> für die URL, proxmox-auto-installer-cert-fingerprint.<search domain> für den Fingerprint | eine Zone, zwei TXT-Einträge |
| Suchdomain | die erste search-Zeile von /etc/resolv.conf, an einem einzelnen Leerzeichen getrennt | genau eine Domain ausgeben; aus zweien wird ein ungültiger Name |
| Keine Domain | scheitert mit Could not find search domain in resolv.conf. | prüfen, ob DHCP-Option 15 oder 119 gesendet wird |
| TXT-Wert | dig txt +short, jedes " entfernt, dann getrimmt | ein in mehrere Strings aufgeteilter TXT-Wert hinterlässt ein Leerzeichen in der URL; einen String daraus machen |
| Fingerprint | nur aus DNS genommen, wenn auch die URL aus DNS kam und keiner eingebaut ist | den Zertifikats-Fingerprint von PDM veröffentlichen, das Caddy ebenfalls ausliefert |
| Token | gesendet als Authorization: Bearer <token>, im Klartext auf dem ISO gespeichert9 | deshalb 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:
| Feld | site1, Proxmox VE |
|---|---|
| Freigegebenes Token | site1-2026q4 |
| Zielfilter | /product/product=pve |
| Land, Tastatur, Zeitzone | gb, en-gb, Europe/London |
| Root-Zugang | SSH-Schlüssel, dazu ein über das Formular gesetztes Passwort, das PDM nur als Hash speichert und sendet4 |
| Netzwerk | DHCP, Interface-Namen festgelegt |
| FQDN | {{ product.product }}-site1-{{ installation_nr }}.example.doc |
| Disk | dein Layout, zum Beispiel ein ZFS-Mirror über zwei benannte Disks |
| Post-Installation-Webhook | Basis-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.
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:
| Zeile | Warum |
|---|---|
header Authorization "Bearer catch-all:not-a-secret" im Standort-Matcher | ohne 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_ip | der unmittelbare Peer; kein NAT und kein Load Balancer zwischen die Installer und Caddy stellen20 |
benannte handle-Blöcke | bleiben in der geschriebenen Reihenfolge und schließen sich gegenseitig aus, also hat das nackte handle { abort } das letzte Wort21 |
@mgmt | die 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//…|0600 | die Admin-API läuft standardmäßig auf TCP localhost:2019, ohne Authentifizierung22 |
protocols h1 h2 | Caddy lauscht standardmäßig für HTTP/3 auf UDP 44322; das schaltet es ab, statt einen Port offen zu lassen, den niemand öffnen wollte |
@hidden | file_server liefert jede Datei unter seinem Root aus, Dotfiles eingeschlossen, und hide ist ausdrücklich nicht als Sicherheitsgrenze dokumentiert23 |
Csrfpreventiontoken replace REDACTED | Caddy 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 Remotes | ja |
autoinst/tokens.shadow, jedes Installer-Token-Secret | ja |
auth/api.key, auth/csrf.key | ja |
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:
| Pfad | caddy |
|---|---|
auth/api.pem, auth/api.key | kann lesen |
auth/csrf.key, auth/authkey.key, auth/authkey.pub | kann nicht lesen |
remotes.shadow, remotes.cfg, autoinst/tokens.shadow, access/user.cfg | kann nicht lesen |
auth/ auflisten | verweigert |
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:
| Teil | Warum |
|---|---|
| beide Dateien beobachtet | der 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 |
ExecCondition | eine fehlgeschlagene Bedingung überspringt den Lauf, statt ihn scheitern zu lassen, also erreicht eine halb geschriebene Erneuerung Caddy nie25 |
PathChanged | lö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 Lauf | eine per Umbenennung eingesetzte Datei hat keine ACL, also konnte Caddy sie nicht lesen, bis das Skript wieder läuft |
try-reload-or-restart | lädt Caddy neu, wenn es läuft, und tut nichts, wenn nicht |
reload --force in der Unit von Caddy | ohne --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.
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üfung | Ergebnis |
|---|---|
| 8443 aus dem Management-Netz | keine Verbindung; der Drop-Zähler steigt |
| PDM-Weboberfläche über Caddy auf 443, aus dem Management | Login klappt, alles funktioniert |
| PDM-Weboberfläche von einer Standortadresse | abgebrochen |
| Antwort-POST von einem Standort mit dem Catch-Token | erreicht PDM mit dem Standort-Token: eine absichtlich nicht passende Probe bekam 400 no answer file found |
| dieselbe Probe direkt an PDM mit einem erfundenen Token | 401 |
Antwort-POST ohne das Catch-Token oder als /api2/extjs/… | abgebrochen |
Dateien, in denen die Secrets von PDM liegen, gelesen als caddy | keine lesbar |
| das Standort-Secret in Journal, Access-Log oder Daten von Caddy seit der Änderung | 0 |
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.
| DHCP-Server | Wie er die Schleife bricht |
|---|---|
| dnsmasq | Anfragen mit Option 175, die nur iPXE sendet, taggen und diesen die HTTP-URL geben31 |
| ISC dhcpd | if exists user-class and option user-class = "iPXE"32 |
| FortiGate | das 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.
Und das Ganze der Reihe nach, vom Einschalten bis zu einer Installation, die PDM als Finished markiert:
| Schritt | Was passiert | Wo du nachsiehst |
|---|---|---|
| 1 | die Firmware holt ipxe.efi über TFTP, iPXE holt boot.ipxe über HTTP | journalctl -u dnsmasq, das Log von Caddy |
| 2 | nach zehn Sekunden bootet das Menü den automatischen Eintrag; Kernel, Initrd und ISO werden geladen | vier GETs, einer davon etwa 1,6 GB |
| 3 | der Installer holt einen Lease, liest die Suchdomain, fragt beide TXT-Einträge ab | /tmp/fetch_answer.log auf dem Ziel |
| 4 | POST an Caddy, Token getauscht, PDM wählt eine Antwort | das Log von Caddy nennt das Token; PDM zeigt eine neue Installation |
| 5 | die Installation läuft | /tmp/auto_installer.log, /tmp/install-low-level-start-session.log |
| 6 | noch im Installer ruft der Webhook über Caddy zurück, dann startet die Maschine neu33 | die 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.
| Symptom | Wahrscheinliche Ursache |
|---|---|
Could not find search domain in resolv.conf. | DHCP hat keine Domain gesendet |
| eine TXT-Abfrage nach einem Namen mit Leerzeichen | mehr 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 Mitte | TXT-Wert in mehrere Strings aufgeteilt |
| das Log sagt, die URL kam von DHCP | Option 250 ist irgendwo im Netz |
| TLS-Fehler beim POST | der Fingerprint in DNS ist nicht das aktuelle Zertifikat von Caddy |
| Verbindung ohne Antwort geschlossen | Caddy hat abgebrochen: falsches Subnetz, kein Catch-Token oder eine Route, die keine der zwei ist |
400 no answer file found | das 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 Bearbeitung | die Basis-URL des Webhooks zeigt noch auf 8443 |
| iPXE lädt sich endlos selbst neu | der DHCP-Server unterscheidet iPXE nicht von der Firmware |
PXE-E32 TFTP-Timeout | next-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 entscheidet | Wo es liegt |
|---|---|
| welches ISO bootet | das Tag set:pbs an der DHCP-Reservierung |
| welche Antworten des Standorts gelten | das Quell-Subnetz, über den Token-Tausch von Caddy |
| welche Antwort innerhalb dieses Standorts | der 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.
| Kompromiss | Warum er zählt | Was zu tun ist |
|---|---|---|
| die Quelladresse ist das Tor | alles 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-Key | das VLAN isolieren, Port Security, nur Reservierungen, Token-Ablauf, Tokens zwischen Rollouts deaktivieren |
| das Catch-Token ist kein Geheimnis | es steckt in jedem Image und steht auf dieser Seite | es filtert nur verirrte Anfragen aus; das Subnetz und das echte Token in Caddy sind das Tor |
| Caddy kann den Schlüssel von PDM lesen | das Zertifikat von PDM auszuliefern heißt, dass der Proxy diesen einen privaten Schlüssel liest | eine 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 vertraut | ein gefälschter TXT-Eintrag schickt den Installer woandershin, und wenn auch der Fingerprint aus DNS kommt, kontrolliert der Fälscher beides | das Provisioning-DNS schützen oder den Fingerprint von PDM mit --cert-fingerprint einbauen und bei einer Änderung neu bauen |
| Boot-Dateien gehen über reines HTTP | sie können auf der Leitung gelesen oder ausgetauscht werden | das Netz isoliert halten; iPXE über HTTPS, wenn dein Build es kann |
| das Webhook-Token | 16 zufällige Bytes, einmal verwendbar, nach dem Rückruf gelöscht, aber ohne Zeitlimit4 | alte Installationsdatensätze aufräumen |
| der Subscription-Key | einmal aktiviert, nachdem das Netz steht; bei einem Fehler wird der Key gelöscht und die Unit scheitert, ohne Wiederholung34 | auf neuen Nodes systemctl --failed prüfen |
| PDM ist ein einzelner Punkt | er ist heute nicht hochverfügbar | ein Ausfall stoppt neue Installationen, keine laufenden Cluster |
| eine Management-NIC | die Antwort hat keine Optionen für Bond, VLAN oder Bridge9 | das 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.
Proxmox — Proxmox Datacenter Manager 1.1 press release — automatische Installationen, mit einem neuen Tab unter Remotes, der den Installationsfortschritt verfolgt. ↩︎
Proxmox Datacenter Manager documentation — Automated Installations — vorbereitete Antworten, Zielfilter, Templating und „a dedicated token mechanism, separate from the normal API tokens“. ↩︎ ↩︎
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. ↩︎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 PrivilegienSys.Audit/Sys.Modifyauf/system/auto-installation. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎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_sasund NVMe-JBOD. ↩︎ ↩︎ ↩︎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. ↩︎
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. ↩︎ ↩︎
proxmox,
proxmox-installer-types/src/lib.rs—SystemInfo: Produkt, ISO, DMI und Netzwerk-Interfaces. ↩︎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“. ↩︎ ↩︎ ↩︎ ↩︎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. ↩︎
proxmox-datacenter-manager,
debian/changelog— 1.1.1: „match prepared answers in the configuration’s stable order and let the first match win“. ↩︎proxmox,
proxmox-installer-types/src/answer.rs—AutoInstallerConfigträgtfirst_boot: Option<FirstBootHookInfo>. ↩︎pve-installer,
proxmox-auto-installer/src/bin/proxmox-auto-installer.rs— das First-Boot-Programm wird nur eingerichtet, wenn die Antwort einen Abschnittfirst_boothat. ↩︎pve-installer,
proxmox-fetch-answer/src/fetch_plugins/http.rs— zuerst die URL im ISO, dann DHCP, dann DNS; die TXT-Namen; das Parsen vonresolv.conf;dig txt +shortmit entfernten Anführungszeichen; der Bearer-Header. ↩︎ ↩︎pve-installer,
proxmox-auto-install-assistant/src/main.rs—--pxe-loaderimpliziert--pxe, das Ausgabeverzeichnis, das gzip-Initrd,/bootaus dem ISO entfernt, und die erzeugteboot.ipxe. ↩︎ ↩︎ ↩︎proxmox-datacenter-manager,
server/src/bin/proxmox-datacenter-api/main.rs—PDM_LISTEN_ADDRist die nicht spezifizierte IPv6-Adresse aufPDM_PORT, 8443, beim Build festgelegt. ↩︎Caddy releases — 2.11.1 hat CVE-2026-27587 behoben, einen Bypass des Path-Matchers über Escape-Sequenzen. ↩︎
Debian sources — caddy —
2.6.2-12+deb13u1in trixie,2.11.2-1~bpo13+1in trixie-backports. ↩︎Caddy —
request_header— „With no prefix, the field is set (overwritten).“ ↩︎Caddy — Request matchers —
remote_ipist der unmittelbare Peer; Pfad-Matches ignorieren Groß- und Kleinschreibung, sind URL-dekodiert und bereinigt, mit zusammengefassten doppelten Schrägstrichen. ↩︎ ↩︎Caddy —
handle— „only the first matchinghandleblock will be evaluated“. ↩︎Caddy — Global options — admin standardmäßig auf
localhost:2019;protocolsstandardmäßigh1 h2 h3. ↩︎ ↩︎Caddy —
file_server—hidesollte nicht als Sicherheitsgrenze behandelt werden. ↩︎Caddy —
log—Cookie,Set-Cookie,AuthorizationundProxy-Authorizationwerden alsREDACTEDprotokolliert. ↩︎systemd.service —
ExecCondition=— bei einem Exit-Code von 1 bis 254 „the remaining commands are skipped and the unit is not marked as failed“. ↩︎systemd.path —
PathChanged=— „activated if the file which was open for writing gets closed“. ↩︎caddyserver/dist,
init/caddy.service—ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile. ↩︎ ↩︎Caddy — Command line —
--environgibt vor dem Start die Umgebung aus. ↩︎Caddy — Caddyfile concepts, environment variables —
{$NAME}wird ersetzt, bevor die Caddyfile geparst wird. ↩︎caddyserver/caddy,
replacer.go— jeder Schlüssel, der mitenv.beginnt, wird anos.Getenvübergeben. ↩︎dnsmasq manual page —
dhcp-match,dhcp-boot,dhcp-hostmitset:<tag>,enable-tftp,tftp-secure,tftp-mtu,tftp-no-blocksizeundstatic-Ranges. ↩︎ ↩︎ ↩︎iPXE — Configuring ISC dhcpd —
if exists user-class and option user-class = "iPXE"gibt iPXE das Skript und der Firmware den Loader. ↩︎pve-installer,
unconfigured.sh—proxmox-post-hookläuft direkt nach dem erfolgreichenproxmox-auto-installer, vor dem Neustart. ↩︎pve-installer,
proxmox-first-boot/proxmox-first-boot-subscription— „always drop the staged key, regardless of whether activation succeeded“. ↩︎pdm-devel — “add auto-installer integration”, v4, 40 patches — die Serie, die die Funktion hinzugefügt hat, mit ihren Änderungsnotizen von v3 auf v4. ↩︎