Zertifikats-Spec auf Weg B umgeschrieben, Registereintrag gestrichen
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

Die Spec fuer die Host-Konsole (docs/superpowers/specs/2026-08-01-host-
konsole-zertifikat-design.md) war auf einer eigenen DNS-Zone und Proxmox'
Alias-Modus fuer DNS-01 gebaut. Hetzner kennt keine Subzonen (docs.hetzner.
com/networking/dns/faq/zones/, Artikel NE-7597D: "Subzones are not
supported") -- damit entfallen eigene Zone, CNAME je Host, Alias-Modus und
der pro Host verteilte DNS-Token ersatzlos. Der Grund bleibt in der Spec
stehen, statt geloescht zu werden, damit niemand denselben Weg in einem
halben Jahr erneut vorschlaegt.

Weg B, wie im Register vorgegeben: CluPilot stellt zentral aus, DNS-01
ueber den vorhandenen kontoweiten Hetzner-Token, Zertifikat per SSH
(RemoteShell::putFile + `pvenode cert set --force --restart`, geprueft
gegen die Proxmox-Dokumentation) auf den Host, Erneuerung als geplanter Job
auf der Bereitstellungs-Warteschlange (dieselbe Grenze wie SyncVpnPeers --
nur queue-provisioning erreicht einen Host ueber den Tunnel). Dazu ein
Vergleich mit der Kundeninstanz (ConfigureDnsAndTls, HTTP-01) und eine
genaue Bestandsaufnahme der Bereitschaftsseite: sie kennt heute kein
Zertifikat, weder fuer Hosts noch, trotz ersten Anscheins, uebertragbar
fuer die Plattform -- CertificateSweep/ProxyHost misst nur oeffentlich
erreichbare Namen und laeuft im falschen Container fuer einen Host-FQDN.

Im Code bestaetigt und in der Spec vermerkt: RrsetId::zone() ist heute fest
auf die Kundenzone verdrahtet, ein Host-FQDN liegt aber in der
Plattformzone -- das ist Bauarbeit, keine offene Entscheidung. Offen bleibt
nur, welches Werkzeug das ACME-Protokoll auf CluPilot-Seite spricht (keine
Bibliothek/kein Tool dafuer im Repo) und ob Plattform- und Kundenzone im
selben Hetzner-Projekt liegen -- beides als offene Fragen benannt, keine
davon blockiert den Rest des Ablaufs.

Registereintrag in OpenWork.php gestrichen: die Spec beschreibt keinen
toten Weg mehr, und genau das war der einzige Punkt, den der Eintrag
festhielt.

Getestet: php artisan test --filter=OpenWork, 8 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 15:31:28 +02:00
parent fd216be623
commit 5fe33553ad
2 changed files with 364 additions and 158 deletions

View File

@ -49,13 +49,6 @@ final class OpenWork
'missing' => 'Kostet 22,80 € im Monat und verspricht mehr gleichzeitige Bearbeiter und erweiterte Office-Funktionen. Es gibt keine Collabora-Konfiguration, die vom gebuchten Modul abhängt.',
'risk' => 'Wie oben: bezahlt und nicht erbracht. Die Paketsperre für Start ist inzwischen gesetzt, das Modul selbst tut weiterhin nichts.',
],
[
'title' => 'Die Zertifikats-Spec beschreibt einen Weg, den es nicht gibt',
'state' => self::PLANNED,
'where' => 'docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md',
'missing' => 'Die Spec ist auf eine eigene Zone und Proxmox\' Alias-Modus gebaut. Hetzner unterstützt keine Subzonen (Artikel NE-7597D), damit entfallen beide ersatzlos. Weg B steht fest: CluPilot stellt zentral aus, DNS-01 über den vorhandenen Token, Zertifikat per SSH auf den Host, Erneuerung als geplanter Lauf mit Restlaufzeit auf der Bereitschaftsseite. Die Spec ist noch nicht umgeschrieben.',
'risk' => 'Wer sie heute aufschlägt und loslegt, baut den toten Weg.',
],
[
'title' => 'Der Rettungssystem-Weg liegt still',
'state' => self::PARTIAL,

View File

@ -1,7 +1,10 @@
# Die Host-Oberfläche unter ihrem Namen, mit gültigem Zertifikat, ohne Port
**Stand:** Entwurf, 1. August 2026 — Punkt 1 beantwortet, Weg entschieden
**Stand:** Entwurf, 4. August 2026 — Weg B, ersetzt die Fassung vom 1. August
**Auslöser:** gemessen auf `fsn-01`, dem ersten echten Host
**Vorherige Fassung:** baute auf einer eigenen DNS-Zone und Proxmox' Alias-Modus
für DNS-01. Beides entfällt ersatzlos — siehe „Der Weg, der nicht geht" weiter
unten. Diese Fassung beschreibt den Ersatz, nicht eine Variante daneben.
---
@ -44,206 +47,416 @@ _acme-challenge.fsn-01.node.clupilot.com TXT "…"
Wer den setzen kann, besitzt den Namen. Der Host braucht keine öffentliche
Adresse, keinen offenen Port, und ein **A-Eintrag ist nicht nötig** — der Name
löst weiterhin nur im Tunnel auf `10.66.0.100` auf.
löst weiterhin nur im Tunnel auf `10.66.0.100` auf. Diese Begründung stand
schon in der ersten Fassung und ändert sich nicht — was sich ändert, ist
ausschließlich, **wer** den TXT-Eintrag setzt und **wie** das Zertifikat
danach auf den Host kommt.
Nachzutragen, weil die erste Fassung es stillschweigend voraussetzte:
`RegisterHostDns` trägt den Namen eines Hosts **nur** in die tunnelinterne
Auflösung ein (den `dns-hosts`-Ordner, den der `vpn-dns`-Container liest — s.
`App\Services\Dns\FileHostDnsDirectory`), ausdrücklich **nicht** in die
öffentliche Hetzner-Zone. Der Kopfkommentar dieses Schritts nennt den Grund:
ein öffentlicher Eintrag verriete jedem Scanner das interne Subnetz und
ungefähr, wie viele Hosts dahinterstehen. Für DNS-01 macht das nichts — der
einzige öffentliche Spuren, den ein Host hinterlässt, ist der TXT-Eintrag
während einer Ausstellung, und der ist danach wieder weg.
---
## Der Punkt, der eine Entscheidung brauchte — beantwortet am 1. August 2026
## Der Weg, der nicht geht
Die Frage war: **Lässt sich bei Hetzner ein DNS-Token beschränken?** Denn für
DNS-01 muss ein Token auf den Host, und ein kontoweiter Token auf jeder
Proxmox-Maschine wäre ein Schlüssel, der weiter reicht als die Maschine.
Die erste Fassung baute auf zwei Dingen: einer eigenen DNS-Zone
`acme.clupilot.com`, angelegt in einem separaten, sonst leeren
Hetzner-Cloud-Projekt, und Proxmox' eingebautem **Alias-Modus** für DNS-01
(`pvenode config set -acmedomain0 …,alias=…`). Der Host hätte einen eigenen,
auf dieses eine Projekt beschränkten Token bekommen und selbst mit Let's
Encrypt gesprochen.
**Ja — über Projekte, nicht über Zonen.** Und zwar erst seit kurzem.
**Hetzner kennt keine Subzonen.** Geprüft an der eigenen Dokumentation:
Die alte DNS-Konsole (`dns.hetzner.com`) kannte genau einen Tokentyp:
kontoweit, alle Zonen, keine Abstufung. Wäre das noch der Stand, hieße die
Antwort nein und dieser Entwurf sähe anders aus. Diese Konsole ist am
**20. Mai 2026 abgeschaltet** worden; `HttpHetznerDnsClient` fährt seit dem
Umbau vom 31. Juli auf `api.hetzner.cloud/v1`.
> „Subzones are not supported." — Hetzner Docs, Artikel NE-7597D
> (<https://docs.hetzner.com/networking/dns/faq/zones/>)
Im neuen Modell liegen DNS-Zonen **in Cloud-Projekten**, und Token sind
projektweit. Hetzner nennt das ausdrücklich als den vorgesehenen Weg:
Eine eigene Zone für `acme.clupilot.com` unterhalb von `clupilot.com` lässt
sich bei Hetzner nicht anlegen — der Versuch kommt mit einem Fehler zurück
(von Betroffenen berichtet als `422 invalid TLD`), nicht mit einer Zone. Ohne
diese Zone gibt es keinen Alias-Ziel-Namen, ohne den wiederum ergibt Proxmox'
Alias-Modus keinen Sinn, und ohne den Alias-Modus fällt der ganze Zweck des
separaten Projekts weg — er sollte ja genau verhindern, dass ein Host-Token
die eigentliche Zone lesen oder schreiben kann. Das Gerüst trägt sich nicht
mehr selbst, sobald der unterste Stein fehlt.
> „As API tokens are scoped per project, you can restrict access to specific
> zones by placing them in separate projects."
**Das gehört hier stehen, nicht gelöscht:** Der Gedanke — ein Token pro Host,
beschränkt auf eine Zone, die nichts Wertvolles enthält — war richtig gedacht
für ein Problem, das es so bei Hetzner nicht lösen lässt. Wer in einem halben
Jahr denselben Gedanken noch einmal hat, soll hier lesen können, woran er
das letzte Mal gescheitert ist, statt ihn noch einmal zu bauen.
### Der Haken, der bleibt
**Ein Projekt-Token ist kein DNS-Token.** Es hat genau zwei Achsen: Projekt und
Lesen/Schreiben. Ein Schreibtoken darf in seinem Projekt alles, was die
Cloud-API kann — auch Server anlegen. Eine Beschränkung auf einen Ressourcentyp
gibt es nicht.
Daraus folgt die Regel für das Projekt: **es enthält nichts außer der einen
Zone.** Keine Server, keine Netze, kein Speicher. Dann ist der Schaden eines
gestohlenen Tokens auf diese Zone begrenzt, plus Kosten in einem sonst leeren
Projekt.
### Und die Zone, die er erreicht, enthält nichts Wichtiges
Der Token wird nicht auf `node.clupilot.com` ausgestellt, sondern auf eine
eigene Zone `acme.clupilot.com`, die **nichts enthält als Challenge-Einträge**.
Proxmox kann das von Haus aus — Alias-Modus:
```
pvenode config set -acmedomain0 \
domain=fsn-01.node.clupilot.com,alias=fsn-01.acme.clupilot.com,plugin=hetznercloud
```
Dazu ein fester CNAME im öffentlichen DNS, einmal je Host:
```
_acme-challenge.fsn-01.node.clupilot.com CNAME _acme-challenge.fsn-01.acme.clupilot.com
```
Let's Encrypt folgt dem CNAME bei der Prüfung; der Host schreibt nur in die
Alias-Zone. **Ein kompromittierter Host erreicht `node.clupilot.com` damit gar
nicht mehr** — auch nicht lesend. Das ist der Unterschied zwischen „der
Schlüssel kann nur eine Zone ändern" und „die Zone, die er ändern kann, ist
wertlos".
Der CNAME kostet nichts: `RegisterHostDns` legt ohnehin Einträge für den Host
an, mit dem zentralen Token. Ein Eintrag mehr, an der Stelle, die es schon tut.
### Was damit vom Tisch ist
Weg 3 aus dem ursprünglichen Entwurf — CluPilot setzt den TXT selbst und
übernimmt die Erneuerung — wird **nicht** gebaut. Er wäre eine zweite Stelle,
die still ausfallen kann, und Zertifikate laufen nach 90 Tagen ab.
Mit der Zone fallen ersatzlos weg: das eigene ACME-Projekt, der CNAME je Host,
der Proxmox-Alias-Modus, der pro Host verteilte `hetznercloud`-DNS-Token, und
die Versionsprüfung auf `libproxmox-acme-plugins` ≥ 1.7.1 (die existierte nur,
weil dieses Plugin den Alias-Modus sprechen musste).
---
## Was einmal von Hand eingerichtet wird
## Weg B: zentral ausstellen, statt vor Ort
Nicht pro Host, nicht durch die Pipeline. Einmal, vom Betreiber, bevor der
Schritt überhaupt laufen kann:
Vom Betreiber entschieden. In einem Satz: **CluPilot stellt das Zertifikat
selbst aus** — über DNS-01, mit dem Token, der ohnehin schon zentral liegt —
**und liefert es per SSH an den Host aus.** Kein Token verlässt CluPilot,
kein Host spricht je mit Let's Encrypt oder mit der Hetzner-API.
1. Ein eigenes Hetzner-Cloud-Projekt, z. B. `clupilot-acme`. **Leer** — nur die
Zone darin.
2. Zone `acme.clupilot.com` in diesem Projekt anlegen und beim Registrar auf
Hetzners Nameserver delegieren.
3. API-Token für dieses Projekt, Lesen **und** Schreiben. Roher Wert, ohne
Anführungszeichen.
4. Der Token wird als **eigener Schlüssel** hinterlegt, nicht als der aus
`provisioning.dns.token` — der ist kontoweit und bleibt zentral. Zwei
Schlüssel, zwei Reichweiten; wer sie zusammenlegt, hebt die ganze
Entscheidung oben wieder auf.
Das ist nicht nur der Ausweg aus der toten Zonen-Idee — es ist der
einfachere Weg ohnehin. Was in der ersten Fassung als Sicherheitsgewinn
gedacht war (ein Token, der nur eine wertlose Zone erreicht, falls ein Host
kompromittiert wird), erreicht Weg B direkter: **gar kein Token auf dem
Host.** Ein kompromittierter Host hat nichts, was er gegen die Hetzner-API
verwenden könnte — weil dort nie etwas lag.
Fehlt einer dieser vier Punkte, muss der Schritt mit einer klaren Meldung
stehenbleiben und nicht auf den kontoweiten Token zurückfallen.
### Wer stellt aus, und womit weist er sich aus
---
CluPilot selbst, mit dem Token, der schon existiert:
`provisioning.dns.token` / `HETZNER_DNS_TOKEN`, im Tresor unter dem Schlüssel
`dns.token`. Das ist derselbe Token, den `App\Services\Dns\HttpHetznerDnsClient`
heute für die A-Einträge der Kundeninstanzen benutzt, und dessen
Schreibfähigkeit `App\Services\Dns\DnsTokenCheck` bereits nachweist — indem
sie einen echten TXT-Eintrag anlegt und wieder löscht. Das ist praktisch
genau die Bewegung, die eine DNS-01-Antwort auch macht: TXT anlegen, Let's
Encrypt prüfen lassen, TXT wieder entfernen.
## Die zwei Teile
**Ein Unterschied ist zu bauen, kein Unterschied ist zu entscheiden:**
`HttpHetznerDnsClient` und die Adressierung darunter
(`App\Services\Dns\RrsetId::zone()`) sind heute fest auf **eine** Zone
verdrahtet — `ProvisioningSettings::dnsZone()`, die **Kundenzone**
(`clupilot.cloud`). Ein Aufruf mit einem Namen aus einer anderen Zone wirft
eine `RuntimeException`, mit Absicht (s. `RrsetId::relativeName()` — genau
diese Falle hat schon einmal einen Kundennamen doppelt an die Zone
angehängt). Ein Host-FQDN liegt aber in der **Plattformzone**
(`provisioning.dns.platform_zone`, Vorgabe `clupilot.com`, abgeleitet aus
`APP_URL`) — einer anderen Zone als der, die der Client heute erreichen
kann. Bevor CluPilot einen TXT-Eintrag für `_acme-challenge.fsn-01.node`
schreiben kann, braucht es entweder eine zonenparametrisierte Fassung dieses
Clients oder einen zweiten, auf die Plattformzone gerichteten Weg. Das ist
Handwerk, keine offene Frage — der Token selbst ist, wie im Register
festgehalten, kontoweit und nicht auf eine Zone beschränkt (bestätigt am
31. Juli beim Umzug auf die Cloud-API: „Der Token des Betreibers ist bereits
ein Cloud-Token und antwortet mit 200" auf `GET /zones`, das mehr als eine
Zone auflistet).
### 1. Zertifikat — eingerichtet in Proxmox, nicht in CluPilot
**Ungeprüft, und das gehört als offene Frage stehen:** ob die Plattformzone
`clupilot.com` im selben Hetzner-Cloud-**Projekt** liegt wie die Kundenzone
`clupilot.cloud`, gegen die der Token heute schon schreibt. Token sind bei
Hetzner projektweit, nicht zonenweit (dieselbe Erkenntnis, an der die erste
Fassung ihre Entscheidung für Projekte statt Zonen festmachte). Liegen beide
Zonen im selben Projekt, reicht der vorhandene Token ohne Weiteres. Liegen
sie in getrennten Projekten, braucht die Plattformzone entweder einen
eigenen Token oder muss ins selbe Projekt wie die Kundenzone wandern. Das
lässt sich mit einem einzigen Schreibversuch klären — nach demselben Muster
wie `DnsTokenCheck`, nur gegen `clupilot.com` statt `clupilot.cloud` — und
gehört **vor** der ersten Implementierungsstunde erledigt, nicht danach.
Proxmox bringt ACME mit und kann DNS-01. Der Schritt richtet über SSH ein:
### Wie das Zertifikat entsteht
- ACME-Konto (Kontaktadresse aus den Einstellungen)
- DNS-Modul `hetznercloud` mit dem Token des ACME-Projekts, Variable
`HETZNER_TOKEN`
- Zertifikatsauftrag für den FQDN des Hosts, **im Alias-Modus** (siehe oben)
DNS-01 ist ein Protokoll, kein einzelner Aufruf: ACME-Konto (oder
Wiederverwendung eines bestehenden), Order für den FQDN des Hosts,
Autorisierung anfordern, den verlangten TXT-Wert unter
`_acme-challenge.<fqdn>` in die Plattformzone schreiben, Let's Encrypt zur
Prüfung auffordern, auf `valid` pollen, TXT-Eintrag wieder entfernen, CSR
einreichen, Zertifikat abholen.
**Warum in Proxmox und nicht in CluPilot:** Proxmox erneuert danach selbst,
über seinen täglichen Zeitgeber. Eine Erneuerung, die in CluPilot hinge, wäre
eine zweite Stelle, die still ausfallen kann — und Zertifikate laufen nach 90
Tagen ab. Das merkt man dann an einem Dienstag um sieben.
**Wer das auf CluPilot-Seite spricht, ist offen — und gehört als offene
Frage benannt, nicht stillschweigend entschieden.** Im Repo gibt es dafür
heute nichts: kein ACME-Paket in `composer.json`, kein `certbot`/`acme.sh`/
`lego` in `docker/` oder `deploy/`. Das einzige ACME-Wissen, das existiert,
steckt in Traefik (`deploy/bootstrap/lib/traefik.sh`), und das läuft **auf
jedem Host selbst**, spricht nur HTTP-01 für Kundennamen und kommt für
diesen Zweck nicht infrage — Weg B verlangt ausdrücklich, dass kein Host je
mit Let's Encrypt spricht. Zwei Richtungen liegen offen: eine gepflegte
PHP-ACME-Bibliothek mit DNS-01-Unterstützung im App-Container, oder ein
Werkzeug wie `certbot`/`acme.sh` dort, gesteuert über einen Hook, der in den
oben beschriebenen (noch zu bauenden) plattformzonenfähigen DNS-Schreibweg
ruft. Das ist keine Entscheidung, die den Betreiber braucht — es ist eine
gewöhnliche technische Abwägung, wie sie in diesem Projekt sonst beim Bauen
fällt, nicht beim Entwerfen. Sie blockiert diese Spezifikation nicht, weil
der Rest des Ablaufs — wo das Ergebnis herkommt, wo es hingeht, wer es prüft
— davon unabhängig steht.
**Das Modul heißt `hetznercloud`, nicht `hetzner`.** Das alte `hetzner` spricht
mit `dns.hetzner.com/api/v1` und ist damit tot. Ein Host, auf dem noch das alte
Modul konfiguriert ist, muss es losbekommen, bevor das neue greift.
### Wie das Zertifikat auf den Host kommt
**Vor allem anderen: Version prüfen.** Das Modul `hetznercloud` kam erst mit
`libproxmox-acme-plugins` **1.7.1**. Darunter existiert es nicht — und der
Schritt scheitert dann nicht beim Einrichten, sondern erst beim Ausstellen,
also spät und mit einer Meldung, die nicht danach aussieht. Deshalb steht die
Prüfung **an den Anfang** des Schritts, nicht ans Ende:
Über dieselbe Verbindung, mit der CluPilot den Host ohnehin schon erreicht.
Jeder Host-Schritt in der Übernahme-Pipeline meldet sich über
`App\Services\Ssh\RemoteShell`, mit dem Flottenschlüssel aus dem Tresor
(`ssh.private_key`) und gepinnt auf den Fingerabdruck, den
`EstablishSshTrust` beim ersten Kontakt festgehalten hat
(`host.ssh_host_key`) — genau das Muster in `HostStep::keyLogin()`. Für eine
Erneuerung nach der Übernahme gilt dieselbe Regel: über die
WireGuard-Adresse, sobald der Tunnel einmal bewiesen ist, was nach der
Übernahme immer der Fall ist.
Zertifikat und Schlüssel gehen über `RemoteShell::putFile()` auf den Host,
danach installiert ein einziger Proxmox-eigener Befehl beides und lädt
`pveproxy` neu:
```
dpkg-query -W -f='${Version}' libproxmox-acme-plugins
pvenode cert set <zertifikatsdatei> <schluesseldatei> --force --restart
```
- ≥ 1.7.1 → weiter
- darunter → aktualisieren; geht das nicht, **abbrechen mit klarer Meldung**.
Kein Nachrüsten der Datei von Hand nach `/usr/share/proxmox-acme/dnsapi/`
das überlebt kein Paket-Update und hinterlässt einen Host, der sich von den
anderen unterscheidet, ohne dass es jemand aufgeschrieben hat.
Geprüft gegen die Proxmox-eigene Dokumentation (`pvenode(1)`, Stand August
2026): `<certificates>` ist die PEM-kodierte Zertifikatskette,
`<key>` der PEM-kodierte, **unverschlüsselte** private Schlüssel — Proxmox
verlangt das ausdrücklich, ein Passwort auf der Schlüsseldatei lässt sich
nicht verwenden. `--force` überschreibt ein vorhandenes Zertifikat (egal ob
von Hand oder per ACME gesetzt), `--restart` lädt `pveproxy` im selben
Aufruf neu. Kein separates `systemctl reload`, kein ACME-Plugin, keine
Versionsprüfung wie bei `libproxmox-acme-plugins` — das ist ein Kernbefehl
von `pvenode`, keine Zusatzfunktion. Ziel auf der Platte ist
`/etc/pve/local/pveproxy-ssl.pem` und `…-ssl.key`, dieselbe Stelle, die die
erste Fassung schon für den ausgelesenen Proxy-Zustand nannte.
Nach dem Einrichten `pveproxy` und `pvedaemon` neu starten, sonst kennt die
Oberfläche das Modul nicht.
Das läuft im `queue-provisioning`-Container und nirgendwo sonst — er ist der
einzige, der im Namensraum von `wg0` steht; jeder Verbindungsversuch aus dem
`app`-Container läuft nachweislich in eine Zeitüberschreitung (so
dokumentiert an `vpn-gateway` im `docker-compose.yml`). Dieselbe Grenze gilt
für jede Wartung, die einen Host über die Tunneladresse erreichen muss —
`SyncVpnPeers` und `SyncMonitoringStatus` sind bereits nach diesem Muster
gebaut (`Schedule::job(...)`, ausgeführt auf der Bereitstellungs-Warteschlange,
nicht im `scheduler`-Container).
### 2. Port — ein Proxy, gebunden an den Tunnel
### Wer erneuert es, und wie oft
443 → 127.0.0.1:8006, **nur auf der Tunneladresse lauschend**, nicht auf der
öffentlichen. `deploy/bootstrap/lib/traefik.sh` liegt bereits im Repo.
Ein geplanter Lauf, kein manueller. Nach demselben Muster wie
`SyncVpnPeers`/`SyncMonitoringStatus`: ein Job, eingetragen in
`routes/console.php` über `Schedule::job(...)`, ausgeführt auf der
Bereitstellungs-Warteschlange, damit er den Tunnel benutzen kann. Anders als
`clupilot:check-certificates` (das nur öffentliche Plattformnamen misst und
deshalb im `scheduler`-Container laufen darf) braucht dieser Lauf SSH auf
jeden Host.
Das Zertifikat aus Teil 1 wird von Proxmox verwaltet; der Proxy liest es von
dort (`/etc/pve/local/pveproxy-ssl.pem`) oder reicht TLS unverändert durch.
Zwei Wege, einer davon ist zu wählen — durchreichen ist einfacher und hat
keine zweite Kopie des Schlüssels.
Idempotent geprüft, nicht blind ausgestellt: bevor irgendetwas passiert,
liest der Lauf die Restlaufzeit des Zertifikats, das gerade auf dem Host
liegt (per SSH, z. B. aus `pvenode cert info` oder direkt aus der
PEM-Datei). Bleibt genug Zeit, passiert nichts. Ein sinnvoller Schwellwert
ist derselbe, den `App\Models\ProxyHost::isExpiringSoon()` schon für die
Plattformnamen benutzt — dreißig Tage, mit derselben Begründung: bei
neunziger Laufzeit hätte eine funktionierende Erneuerung bei dreißig
verbleibenden Tagen längst gegriffen.
## Wo der Schritt hingehört
### Woran der Betreiber sieht, dass die Erneuerung läuft
Nach `RegisterHostDns` — der Name muss registriert sein, bevor ein Zertifikat
auf ihn ausgestellt wird, und der CNAME in die Alias-Zone entsteht dort. Vor
`SecureHostFirewall` ist nicht nötig (DNS-01 braucht nichts Eingehendes), aber
es schadet auch nicht.
**Heute steht dazu nichts auf der Bereitschaftsseite — für keinen der beiden
Zertifikatswege.** Das war zu prüfen, nicht anzunehmen, und das Ergebnis ist
eindeutig: keine der sechs `App\Support\Readiness\*Checks`-Klassen erwähnt
ein Zertifikat, `CertificateInspector` oder `ProxyHost`.
Was es **gibt**, ist ein anderer, bereits fertiger Mechanismus für eine
**andere** Frage: `App\Services\Proxy\CertificateSweep` misst täglich
(`clupilot:check-certificates`, `Schedule::command(...)->dailyAt('04:17')`)
die Zertifikate der **Plattform-eigenen** Namen (Portal, Konsole, Status,
Dateien — aus `App\Support\KnownHostnames`) und legt das Ergebnis in
`ProxyHost` ab, sichtbar auf einer eigenen Seite, `Admin\ProxyHosts` unter
`/admin/proxy-hosts`. Das ist **nicht** die Bereitschaftsseite, und es ist
auch technisch nicht auf einen Host übertragbar: `CertificateInspector`
misst von außen, mit einem einfachen TLS-Handschlag gegen Port 443 des
Namens — und ein Host-FQDN wie `fsn-01.node.clupilot.com` löst außerhalb des
Tunnels **gar nicht** auf. Der Lauf, der `clupilot:check-certificates`
ausführt, sitzt zudem im `scheduler`-Container, der den Tunnel nicht
erreicht. Diesen Mechanismus für Hosts wiederzuverwenden hieße, ihn an genau
der Stelle zu brechen, die ihn heute trägt.
Was für Weg B fehlt, ist deshalb neu und eigenständig, nicht eine Erweiterung
von `CertificateSweep`:
- Der Erneuerungslauf selbst kennt die Restlaufzeit schon — er hat sie gerade
per SSH gelesen. Sie muss **je Host** abgelegt werden (nach dem Vorbild von
`ProxyHost::certificate_expires_at`, aber an einer Stelle, die zum Host
gehört, nicht zu einem einzelnen globalen Zeitstempel wie bei den beiden
Heartbeats in `OperationChecks` — es gibt viele Hosts, nicht einen Dienst).
- Eine neue `Check`-Klasse, die dieselbe Bauweise wie
`App\Support\Readiness\SecurityChecks` und `OperationChecks` benutzt: eine
Warnung (nicht blockierend — ein einzelner Host mit knapper Restlaufzeit
darf keine ganze Installation als „nicht bereit" markieren), sichtbar,
sobald ein Host unter die Dreißig-Tage-Schwelle fällt.
- **Getrennt davon** — dieselbe Unterscheidung, die `OperationChecks` schon
für Zeitplaner und Warteschlangen-Arbeiter trifft —, ob der Lauf selbst
überhaupt noch stattfindet. Ein Job, der seit zwei Wochen nicht mehr lief,
sagt „alles grün", weil er die letzte gemessene Restlaufzeit einfach
stehen lässt. Das ist der gefährlichere der beiden Fehler, weil er sich
nicht von einem echten guten Zustand unterscheidet, ohne dass man das Datum
der letzten Messung mit ansieht.
## Vergleich mit der Kundeninstanz
`App\Provisioning\Steps\Customer\ConfigureDnsAndTls` löst ein ähnliches
Problem für eine Kundeninstanz — Hostname, Zertifikat, ohne dass der Kunde
etwas tun muss. Der Vergleich zeigt, was sich überträgt und was nicht:
| | Kundeninstanz | Host-Konsole (Weg B) |
|---|---|---|
| Herausforderer | HTTP-01, über Traefik auf dem Host | DNS-01, zentral von CluPilot |
| Warum dieser Weg | Kundenname liegt öffentlich auf Port 80/443 — HTTP-01 ist der einfachste Weg, der dort funktioniert | Host-Konsole hat **nie** einen offenen Port 80 — HTTP-01 geht nicht |
| Wer stellt aus | Traefik/lego, auf dem Host selbst | CluPilot zentral, nie der Host |
| Womit weist sich der Aussteller aus | nichts — der Port ist der Beweis | Hetzner-Token, kontoweit, nur bei CluPilot |
| Was bei Fehlschlag passiert | Lauf geht trotzdem weiter (`domain_cert_ok=false`, Ereignis vermerkt) — ein Kunde, der noch keinen A-Eintrag gesetzt hat, darf seine Cloud trotzdem bekommen | muss auffallen — die Konsole ist ein täglich gebrauchtes Werkzeug, kein Kundenversprechen mit Kulanzfrist |
| Erneuerung | Traefiks eigener ACME-Zeitgeber, pro Host | ein CluPilot-eigener geplanter Lauf, fleetweit |
Der auffälligste Unterschied ist der letzte in der Tabelle. Bei einer
Kundeninstanz ist ein fehlendes Zertifikat ein Zustand, den man anzeigt und
mit dem man wartet — es kostet niemanden etwas, solange der Kunde seinen
A-Eintrag noch nicht gesetzt hat. Bei der Host-Konsole gibt es diese
Kulanz nicht: ein abgelaufenes Zertifikat dort ist ein Betreiber, der seine
eigene Infrastruktur nicht mehr ohne Warnklick erreicht. Das ist der Grund,
warum Weg B eine sichtbare, geprüfte Restlaufzeit braucht und die
Kundeninstanz-Logik das nicht in gleicher Form tut.
## Was der Host braucht, was CluPilot braucht
**Der Host braucht:**
- Eine funktionierende WireGuard-Tunneladresse und einen bewiesenen
Handschlag (`wg_peer`-Breadcrumb) — beides existiert bereits vor diesem
Schritt, aus dem Rest der Übernahme.
- Den CluPilot-Flottenschlüssel als vertrauenswürdig (`EstablishSshTrust`,
bereits gebaut).
- Eine Proxmox-Version, deren `pvenode cert set --force --restart` sich wie
dokumentiert verhält. Nicht unabhängig gegen den echten Fuhrpark geprüft —
`hosts.pve_version` liegt bereits als Feld vor und eignet sich für eine
solche Prüfung, falls sich beim Bauen ein Unterschied zwischen den
eingesetzten PVE-Ständen zeigt.
- **Nichts**, was heute nicht schon da ist. Kein DNS-Token, kein
ACME-Plugin, kein CNAME.
**CluPilot braucht:**
- Den vorhandenen Hetzner-Token, mit Schreibzugriff auf die Plattformzone —
zu prüfen wie oben beschrieben.
- Eine plattformzonenfähige Fassung des DNS-Schreibwegs (heute fest auf die
Kundenzone verdrahtet, s. oben).
- Ein Werkzeug, das ACME/DNS-01 spricht — offen, s. oben, blockiert aber
nicht das Schreiben dieser Spezifikation.
- Einen geplanten Job auf der Bereitstellungs-Warteschlange, der Restlaufzeit
liest, bei Bedarf erneuert, das Ergebnis je Host ablegt.
- Eine neue Prüfung auf der Bereitschaftsseite, die dieses Ergebnis zeigt.
## Der Proxy — Port 443 ohne Port 8006
Dieser Teil der ersten Fassung bleibt inhaltlich stehen; Weg B vereinfacht
ihn sogar. 443 → 127.0.0.1:8006, **nur auf der Tunneladresse lauschend**,
über den Traefik-Dienst, den `deploy/bootstrap/lib/traefik.sh` bereits auf
jedem Host installiert (dort heute zuständig für die HTTP-01-Zertifikate der
Kundeninstanzen auf demselben Host).
Die erste Fassung ließ offen, ob dieser Traefik das Zertifikat aus
`/etc/pve/local/pveproxy-ssl.pem` selbst einliest oder TLS unverändert
durchreicht, mit der Empfehlung „durchreichen ist einfacher". Unter Weg B
ist die Frage entschieden, nicht mehr offen: `pvenode cert set` installiert
das Zertifikat **in Proxmox**, das ist der ganze Witz des Befehls — pveproxy
hält und liefert es. Es ein zweites Mal in Traefik einzulesen wäre eine
zweite Kopie desselben privaten Schlüssels, ohne Gegenwert. **Durchreichen
ist damit nicht mehr die einfachere von zwei Optionen, sondern die einzig
sinnvolle.**
## Wo der Schritt in der Pipeline hingehört
Unverändert gegenüber der ersten Fassung: nach `RegisterHostDns` — der Name
muss feststehen, bevor ein Zertifikat auf ihn ausgestellt wird. Vor
`SecureHostFirewall` ist weiterhin nicht nötig (DNS-01 braucht nichts
Eingehendes), schadet aber auch nicht.
```
… RegisterHostDns → SecureHostConsole → RegisterCapacity → SecureHostFirewall …
… RegisterHostDns → SecureHostConsole (neu, Weg B) → RegisterCapacity → SecureHostFirewall …
```
`RegisterHostDns` bekommt eine Aufgabe dazu: den festen CNAME
`_acme-challenge.<host>.node.clupilot.com``_acme-challenge.<host>.acme.clupilot.com`,
gesetzt mit dem **zentralen** Token. Er ist dauerhaft und wird nie wieder
angefasst.
Der neue Schritt ruft dieselbe Ausstellungslogik, die später auch der
geplante Erneuerungslauf benutzt — ein Weg, zwei Aufrufer, damit Ausstellung
bei der Übernahme und Erneuerung danach nicht auseinanderlaufen.
## Wiederholbarkeit
- `libproxmox-acme-plugins` < 1.7.1 abbrechen, bevor irgendetwas geändert wird
- Zertifikat vorhanden und gültig → nichts tun, `advance()`
- Zertifikat auf dem Host noch gültig und weit genug von der
Erneuerungsschwelle entfernt → nichts tun
- Proxy schon eingerichtet und läuft → nichts tun
- ACME-Modul schon mit demselben Token eingerichtet → nichts tun; bei anderem
Token überschreiben, nicht ein zweites danebenstellen
- CNAME existiert schon → nichts tun (`RegisterHostDns` ist ohnehin schon so
gebaut)
- Beides prüfen, nicht annehmen: `pvenode cert info` sagt, was wirklich liegt
- TXT-Eintrag für eine laufende Ausstellung existiert schon (z. B. nach
einem abgebrochenen Lauf) → ersetzen, nicht daneben anlegen — dieselbe
RRSet-Ersetzungslogik, die `HttpHetznerDnsClient` für Kundeneinträge schon
benutzt
- Beides prüfen, nicht annehmen: `pvenode cert info` sagt, was auf dem Host
wirklich liegt, unabhängig davon, was CluPilot zuletzt geschrieben zu
haben glaubt
Dieselbe Regel wie überall in dieser Pipeline — ein Wiederanlauf darf nichts
zerstören, was funktioniert.
## Wo es schiefgehen kann, und was dann passiert
Ein Zertifikat, das still abläuft, ist die teuerste Fehlerart hier — es
merkt niemand, bis der Betreiber selbst ausgesperrt ist. Deshalb:
- **Der DNS-Schreibversuch schlägt fehl** (Netzwerk, abgelaufener Token,
falsches Projekt). Der Lauf bricht diesen Host ab, ohne das
Vorgängerzertifikat anzurühren — ein Host mit einem noch gültigen, nur
nicht mehr ganz frischen Zertifikat ist besser als einer ohne. Das
Ereignis wird protokolliert, mit dem Hostnamen darin, nicht nur „DNS
fehlgeschlagen".
- **Let's Encrypt lehnt ab** (Rate-Limit, Kontoproblem). Gleiche Reaktion:
bestehendes Zertifikat bleibt, Fehlschlag wird sichtbar vermerkt statt
verschluckt.
- **SSH auf den Host schlägt fehl** (Host offline, Tunnel gerade nicht
bewiesen). Das neue Zertifikat existiert dann bei CluPilot, aber nicht auf
dem Host — der nächste Lauf versucht es erneut, mit demselben oder einem
neu ausgestellten Zertifikat, je nachdem wie weit die vorherige
Restlaufzeit inzwischen gesunken ist.
- **Der geplante Lauf selbst bleibt aus** (Warteschlangen-Arbeiter tot,
Deploy vergessen). Das ist der Fall, den eine reine
Restlaufzeit-Anzeige **nicht** fängt — ein Wert, der einfach stehen
bleibt, sieht aus wie ein gesunder. Deshalb gehört ein zweiter,
unabhängiger Blick auf „lief der Lauf kürzlich" dazu, nach demselben
Muster wie die beiden Heartbeats in `OperationChecks`.
- **Ein Zertifikat läuft trotzdem ab**, weil alles oben zusammen zu lange
nicht griff. Dann bricht der TLS-Handschlag auf der Konsole, mit einer
Warnung, die der Betreiber sieht — schlecht, aber sichtbar, und genau der
Zustand von heute, den dieser Entwurf beheben soll. Der Unterschied ist:
mit der Bereitschaftsprüfung hätte er es dreißig Tage vorher schon
gesehen.
## Was ausdrücklich nicht gebaut wird
- **Kein Token auf dem Host.** Weder ein kontoweiter noch ein
projektbeschränkter — das war der ganze Punkt der ersten Fassung, und Weg
B erreicht es ohne die Zone, die dafür nötig gewesen wäre.
- **Kein Proxmox-eigenes ACME.** Kein `-acmedomain0`, kein Alias-Modus, kein
`hetznercloud`-Plugin, keine Versionsprüfung darauf.
- **Keine eigene Zone, kein CNAME je Host.**
- **Kein Wildcard-Zertifikat.** Jeder Host bekommt sein eigenes, genau wie
jede Kundeninstanz ihr eigenes bekommt — dieselbe Begründung wie dort.
- **Keine Selbstbedienung für den Betreiber.** Eine Erneuerung von Hand
anzustoßen ist kein Ziel dieser Spezifikation; falls es später gebraucht
wird, ist es ein Knopf über demselben Lauf, kein eigener Weg.
## Abnahme
1. Neuen Host anlegen → am Ende ist `https://<name>.node.clupilot.com` im VPN
erreichbar, ohne Port, ohne Warnung
1. Neuen Host übernehmen → am Ende ist `https://<name>.node.clupilot.com`
im VPN erreichbar, ohne Port, ohne Warnung
2. Von außen (ohne VPN) ist derselbe Name **nicht** erreichbar
3. Im öffentlichen DNS existiert **kein A-Eintrag** für den Namen
3. Im öffentlichen DNS existiert **kein** A-Eintrag für den Namen, und nach
Abschluss der Ausstellung auch **kein** übrig gebliebener TXT-Eintrag
4. Wiederholung des Schritts stellt kein zweites Zertifikat aus
5. `pvenode cert info` zeigt ein Let's-Encrypt-Zertifikat mit Laufzeit
6. Der Token auf dem Host kann `node.clupilot.com` **nicht** lesen und nicht
schreiben — einmal von Hand nachgewiesen, mit genau diesem Token gegen die
Zone
7. Ein Host mit `libproxmox-acme-plugins` < 1.7.1 bricht den Schritt ab und
hinterlässt keine halbe ACME-Konfiguration
Punkt 2, 3 und 6 sind die wichtigen: Sie beweisen, dass der Weg das Zertifikat
gebracht hat, **ohne** die Maschine zu öffnen und **ohne** einen Schlüssel zu
verteilen, der weiter reicht als sie.
---
5. `pvenode cert info` auf dem Host zeigt ein Let's-Encrypt-Zertifikat mit
Laufzeit
6. Kein Prozess auf dem Host besitzt einen Hetzner-Token — einmal von Hand
nachgesehen (Dateisystem, Umgebung, Proxmox-Konfiguration)
7. Ein Zertifikat, dessen Restlaufzeit unter die Schwelle fällt, erscheint
als Warnung auf der Bereitschaftsseite, bevor es abläuft
8. Wird der geplante Lauf zwei Zyklen lang nicht ausgeführt, zeigt die
Bereitschaftsseite das ebenfalls an — unabhängig davon, wie lange das
zuletzt gemessene Zertifikat noch gültig wäre
## Belege
- Hetzner, DNS-Migration ins Console (Projekt-Scoping der Token):
<https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/features-and-differences/>
- Hetzner, API-Token erzeugen (nur Lesen/Schreiben, keine Ressourcentypen):
- Hetzner Docs, Zonen-FAQ — Subzonen werden nicht unterstützt (NE-7597D):
<https://docs.hetzner.com/networking/dns/faq/zones/>
- Hetzner, API-Token erzeugen (projektweit, keine Ressourcentypen-Beschränkung):
<https://docs.hetzner.com/cloud/api/getting-started/generating-api-token/>
- Hetzner Status, Abschaltung der DNS-Konsole im Mai 2026:
- Hetzner Status, Abschaltung der alten DNS-Konsole im Mai 2026:
<https://status.hetzner.com/incident/c2146c42-6dd2-4454-916a-19f07e0e5a44>
- Proxmox-Forum, `hetznercloud` in `libproxmox-acme-plugins` 1.7.1, bestätigt
im April 2026:
<https://forum.proxmox.com/threads/acme-plugin-hetzner-dns-console-is-moving-to-the-hetzner-console.174363/page-2>
- Proxmox VE Wiki, Alias-Modus für DNS-01:
- Proxmox VE, `pvenode(1)` — Syntax von `cert set`:
<https://pve.proxmox.com/pve-docs/pvenode.1.html>
- Proxmox VE Wiki, Zertifikatsverwaltung — Ablageort externer Zertifikate:
<https://pve.proxmox.com/wiki/Certificate_Management>