feat(rotation): gefuehrte Rotation mit Test vor dem Widerruf (#023)

Einen Schlüssel zu tauschen soll keinen Ausfall riskieren: Der Nachfolger
übernimmt alle aktiven Zuweisungen, wird ausgerollt und per
Verbindungstest geprüft – erst wenn alle Tests seit dem letzten Ausrollen
grün sind, entfernt der Widerruf aus #024 den alten Schlüssel, der dann
als ROTIERT endet.

Private Keys bekommen einen neuen Pfad (-rJJJJMMTT) bei gleichem Alias.
Damit ssh den neuen nimmt, schreibt privat.ts je Alias nur noch einen
config-Eintrag; die jüngste Zuweisung gewinnt. Der Worker setzt
vorgaengerId schon beim Erzeugen, so ist die Kette auch nach einem
Abbruch vollständig und die Schrittfolge wiederaufnehmbar.

Personen-Keys ohne Private Key in sshkv bekommen nur einen Hinweis:
dafür braucht es einen neuen Public Key von der Person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:30:39 +02:00
co-authored by Claude Opus 5.5
parent a2d4f8428a
commit f6ed66fc7e
17 changed files with 1368 additions and 11 deletions
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Button, Formularfehler, Karte } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import {
rotationAltWiderrufenAktion,
rotationStartenAktion,
rotationUebernehmenAktion,
} from "@/server/actions/rotation";
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
import type { OffeneEntfernung } from "@/server/services/widerruf";
/**
* Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom
* Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an
* derselben Stelle weiter.
*/
const SCHRITTE = [
"Neuer Schlüssel",
"Zuweisungen übernehmen",
"Dry-Run & Ausrollen",
"Verbindungstests",
"Alten widerrufen",
] as const;
export type RotationsTest = {
id: string;
ziel: string;
ausgerollt: boolean;
status: string | null;
fehler: string | null;
};
export type RotationsDaten = {
alt: { id: string; name: string };
neu: { id: string; name: string } | null;
schritt: 0 | 1 | 2 | 3 | 4 | 5;
neuOffen: number;
tests: RotationsTest[];
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
widerruf: OffeneEntfernung[] | null;
darfPrivat: boolean;
};
export function Rotation({ daten }: { daten: RotationsDaten }) {
const router = useRouter();
const [laeuft, setLaeuft] = useState<string>();
const [fehler, setFehler] = useState<string>();
const { alt, neu, schritt } = daten;
async function kette(arbeit: () => Promise<string | undefined>) {
setFehler(undefined);
try {
const meldung = await arbeit();
if (meldung) setFehler(meldung);
} catch (f) {
setFehler((f as Error).message);
} finally {
setLaeuft(undefined);
router.refresh();
}
}
const vorbereiten = () =>
kette(async () => {
if (!neu) {
setLaeuft("Neues Schlüsselpaar wird erzeugt …");
const antwort = await rotationStartenAktion(alt.id);
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
const ende = await warten(antwort.vorgangId);
if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen.";
}
setLaeuft("Zuweisungen werden übernommen …");
return (await rotationUebernehmenAktion(alt.id)).fehler;
});
const testen = () =>
kette(async () => {
for (const test of daten.tests) {
setLaeuft(`Teste ${test.ziel} …`);
const antwort = await verbindungTestenAktion(test.id);
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
await warten(antwort.vorgangId);
}
return undefined;
});
const altWiderrufen = () =>
kette(async () => {
setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …");
return (await rotationAltWiderrufenAktion(alt.id)).fehler;
});
const status = (
<>
<Formularfehler text={fehler} />
{laeuft && (
<p role="status" className="text-muted text-[14px]">
{laeuft}
</p>
)}
</>
);
return (
<div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
{schritt <= 1 && (
<Karte
titel={schritt === 0 ? "Neuen Schlüssel erzeugen" : "Zuweisungen übernehmen"}
untertitel="Noch ändert sich auf keinem Host etwas."
>
<ul className="text-muted mb-4 list-disc space-y-1 pl-5 text-[14px]">
<li>Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.</li>
<li>Public Key: dieselben Ziele und Einschränkungen wie bisher.</li>
<li>
Private Key: derselbe Host und Benutzer, neue Datei mit Suffix <code>-rJJJJMMTT</code>; der
config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei.
</li>
<li>Der alte Schlüssel bleibt gültig, bis der neue getestet ist.</li>
</ul>
{status}
<Button
variante="primaer"
icon={<KeyRound className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void vorbereiten()}
>
{neu ? "Zuweisungen übernehmen" : "Neuen Schlüssel erzeugen und Zuweisungen übernehmen"}
</Button>
</Karte>
)}
{schritt === 2 && neu && (
<Karte
titel="Neuen Schlüssel ausrollen"
untertitel="Dry-Run prüfen, dann anwenden – der alte Schlüssel bleibt vorerst daneben bestehen."
>
<AusrollenPanel auswahl={{ schluesselId: neu.id }} offen={daten.neuOffen} />
</Karte>
)}
{schritt === 3 && (
<Karte
titel="Verbindungen mit dem neuen Schlüssel testen"
untertitel="Erst wenn alle Tests grün sind, wird der alte Schlüssel entfernt."
>
<ul className="mb-4 flex flex-col gap-2">
{daten.tests.map((t) => (
<li key={t.id} className="flex flex-col gap-0.5 text-[14px]">
<span className="flex items-center gap-2 font-mono text-[13px] break-all">
{t.status === "ERLEDIGT" ? (
<CircleCheck className="text-ok size-4 shrink-0" aria-label="grün" />
) : t.status === "FEHLGESCHLAGEN" ? (
<CircleX className="text-danger size-4 shrink-0" aria-label="fehlgeschlagen" />
) : (
<PlugZap className="text-muted size-4 shrink-0" aria-label="noch nicht getestet" />
)}
{t.ziel}
</span>
{t.fehler && <span className="text-danger pl-6 text-[13px]">{t.fehler}</span>}
</li>
))}
</ul>
{status}
<Button
variante="primaer"
icon={<PlugZap className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void testen()}
>
Verbindungen testen
</Button>
</Karte>
)}
{schritt === 4 && !daten.widerruf && (
<Karte
titel="Alten Schlüssel entfernen"
untertitel={
daten.tests.length > 0
? "Alle Verbindungstests sind grün."
: "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen."
}
>
<p className="text-muted mb-4 text-[14px]">
Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run
folgt). Danach gilt er als rotiert.
</p>
{status}
<Button
variante="gefahr"
icon={<Ban className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void altWiderrufen()}
>
Alten Schlüssel widerrufen
</Button>
</Karte>
)}
{schritt === 4 && daten.widerruf && (
<Karte
titel="Alten Schlüssel entfernen"
untertitel="Der Dry-Run zeigt, was vom alten Schlüssel verschwindet."
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[alt.id]}
offen={daten.widerruf}
zielkonten={[]}
vorauswahl={[]}
ruecksprung={`/schluessel/${alt.id}/rotieren`}
darfPrivat={daten.darfPrivat}
ohneZielkonten
/>
</Karte>
)}
{schritt === 5 && neu && (
<Karte titel="Rotation abgeschlossen">
<p className="text-[14px]">
Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "}
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
{neu.name}
</Link>
.
</p>
</Karte>
)}
</div>
);
}