diff --git a/app/Support/OpenWork.php b/app/Support/OpenWork.php
index c1efd29..e26fb65 100644
--- a/app/Support/OpenWork.php
+++ b/app/Support/OpenWork.php
@@ -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,
diff --git a/docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md b/docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md
index abd794d..bd29227 100644
--- a/docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md
+++ b/docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md
@@ -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
+> ()
-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.` 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 --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): `` ist die PEM-kodierte Zertifikatskette,
+`` 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..node.clupilot.com` → `_acme-challenge..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://.node.clupilot.com` im VPN
- erreichbar, ohne Port, ohne Warnung
+1. Neuen Host übernehmen → am Ende ist `https://.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):
-
-- Hetzner, API-Token erzeugen (nur Lesen/Schreiben, keine Ressourcentypen):
+- Hetzner Docs, Zonen-FAQ — Subzonen werden nicht unterstützt (NE-7597D):
+
+- Hetzner, API-Token erzeugen (projektweit, keine Ressourcentypen-Beschränkung):
-- Hetzner Status, Abschaltung der DNS-Konsole im Mai 2026:
+- Hetzner Status, Abschaltung der alten DNS-Konsole im Mai 2026:
-- Proxmox-Forum, `hetznercloud` in `libproxmox-acme-plugins` 1.7.1, bestätigt
- im April 2026:
-
-- Proxmox VE Wiki, Alias-Modus für DNS-01:
+- Proxmox VE, `pvenode(1)` — Syntax von `cert set`:
+
+- Proxmox VE Wiki, Zertifikatsverwaltung — Ablageort externer Zertifikate: