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>
242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|