feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)

Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen.
Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per
Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen,
alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen.

Der Nachfolger steht während der Rotation in Block und Pflichtliste,
damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur
nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:27:09 +02:00
co-authored by Claude Opus 5.5
parent 75077f9101
commit cab6f45510
19 changed files with 892 additions and 20 deletions
+165
View File
@@ -0,0 +1,165 @@
import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Umstellen } from "@/components/deploymentkey/Umstellen";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Karte } from "@/components/ui/Karte";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { rotationsSchrittAktion } from "@/server/actions/deploymentkey";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { rotationsStand } from "@/server/services/deploymentkey";
export const metadata: Metadata = { title: "Deployment-Key rotieren" };
const SCHRITTE = ["Erzeugen", "Verteilen", "Prüfen", "Umstellen", "Aufräumen"] as const;
/**
* Rotation des Instanz-Deployment-Keys (#031). Der neue Key kommt erst neben den alten,
* wird geprueft und erst dann zum Standard; der alte verschwindet zuletzt.
*/
export default async function DeploymentKeySeite() {
const { benutzer } = await sitzungErforderlich("deploymentKeyVerwalten", "/deployment-key");
const stand = await rotationsStand();
const fehlgeschlagen = (stand.pruefung ?? []).filter((p) => !p.ok);
const aktuell =
stand.phase === "bereit"
? 0
: stand.phase === "aufraeumen"
? 4
: !stand.pruefung
? 1
: fehlgeschlagen.length
? 2
: 3;
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb
pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Deployment-Key" }]}
/>
}
titel="Deployment-Key rotieren"
untertitel="Ohne Unterbrechung: der neue Key steht erst neben dem alten, wird geprüft und löst ihn dann ab."
/>
<Schrittleiste schritte={SCHRITTE} aktuell={aktuell} beschriftung="Schritte der Rotation" />
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
<p className="text-accent text-[13.5px]">
Jeder Schritt verlangt eine frische Bestätigung Ihrer Anmeldung.{" "}
<Link href="/bestaetigen?ziel=%2Fdeployment-key" className="underline">
Jetzt bestätigen
</Link>
</p>
)}
<Karte titel="Aktueller Deployment-Key" untertitel={`Benutzt auf ${stand.hosts.length} Host(s)`}>
{stand.aktuell ? (
<MonoKopieren
wert={stand.aktuell.fingerprint}
beschriftung="Fingerprint des aktuellen Keys"
umbrechen
/>
) : (
<p className="text-accent text-[14px]">Noch kein Deployment-Key – läuft der Worker?</p>
)}
{stand.nachfolger && (
<div className="mt-4">
<p className="text-muted mb-1 text-[13px]">Neuer Key (noch nicht Standard)</p>
<MonoKopieren
wert={stand.nachfolger.fingerprint}
beschriftung="Fingerprint des neuen Keys"
umbrechen
/>
</div>
)}
</Karte>
{stand.phase === "bereit" && stand.aktuell && (
<Karte
titel="1 · Neuen Key erzeugen"
untertitel="Der Worker erzeugt ein neues ed25519-Paar. Noch ändert sich auf keinem Host etwas."
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "erzeugen")}
beschriftung="Neuen Deployment-Key erzeugen"
variante="primaer"
icon={<KeyRound className="size-4" />}
/>
</Karte>
)}
{stand.phase === "verteilen" && (
<>
<Karte
titel="2 · Neuen Key verteilen"
untertitel="Der neue Key kommt zusätzlich in den verwalteten Block – der alte bleibt vorerst."
>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
</Karte>
<Karte
titel="3 · Anmeldung mit dem neuen Key prüfen"
untertitel="sshkv meldet sich auf jedem Host einmal mit dem neuen Key an."
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "pruefen")}
beschriftung="Neuen Key prüfen"
icon={<ShieldCheck className="size-4" />}
/>
{stand.pruefung && (
<ul className="mt-4 flex flex-col gap-1.5" aria-label="Ergebnis der Prüfung">
{stand.pruefung.map((p) => (
<li key={p.hostId} className="flex flex-wrap items-center gap-2 text-[14px]">
<span className="font-mono">{p.hostName}</span>
{p.ok ? (
<Badge variante="ok">angemeldet</Badge>
) : (
<Badge variante="gefahr">fehlgeschlagen</Badge>
)}
{p.fehler && <span className="text-muted text-[13px]">{p.fehler}</span>}
</li>
))}
</ul>
)}
</Karte>
{stand.pruefung && (
<Karte
titel="4 · Umstellen"
untertitel="Ab jetzt benutzt sshkv den neuen Key; der alte gilt als rotiert."
>
<Umstellen fehlgeschlagen={fehlgeschlagen} />
</Karte>
)}
</>
)}
{stand.phase === "aufraeumen" && stand.vorgaenger && (
<Karte
titel="5 · Alten Key entfernen"
untertitel="Dry-Run und Ausrollen entfernen den alten Key aus allen Blöcken; danach wird sein Private Key verworfen."
>
<div className="flex flex-col gap-5">
<p className="text-muted text-[13px]">
Alter Key: <span className="font-mono">{stand.vorgaenger.fingerprint}</span>
</p>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
<div>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "abschliessen")}
beschriftung="Rotation abschließen"
variante="primaer"
icon={<CheckCheck className="size-4" />}
/>
</div>
</div>
</Karte>
)}
</div>
);
}
+16 -5
View File
@@ -1,5 +1,6 @@
import { Inbox } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
import { Badge } from "@/components/ui/Badge";
@@ -161,11 +162,21 @@ export default async function Einstellungen() {
</Info>
<Info titel="Deployment-Key">
{instanz.deploymentKey ? (
<MonoKopieren
wert={instanz.deploymentKey.fingerprint}
beschriftung="Fingerprint des Deployment-Keys"
umbrechen
/>
<>
<MonoKopieren
wert={instanz.deploymentKey.fingerprint}
beschriftung="Fingerprint des Deployment-Keys"
umbrechen
/>
{darf(benutzer.rolle, "deploymentKeyVerwalten") && (
<Link
href="/deployment-key"
className="text-accent mt-1 inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
>
Deployment-Key rotieren
</Link>
)}
</>
) : (
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
)}