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: