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
+144
View File
@@ -0,0 +1,144 @@
import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll";
import { anmeldebenutzerVon, deploymentNachfolger, type Ziel } from "@/server/services/soll";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Rotation des Instanz-Deployment-Keys (#031) – geführt in fünf Schritten:
*
* 1. erzeugen: Nachfolger anlegen (DEPLOYMENT, nicht Standard, vorgaengerId = alter).
* 2. verteilen: Dry-Run + Ausrollen auf alle Hosts mit Instanz-Key – der Nachfolger kommt
* **neben** den alten in den Block (soll.ts).
* 3. pruefen: je Host eine Anmeldung mit dem neuen Key.
* 4. umstellen: Nachfolger wird Standard, alter ROTIERT; danach Dry-Run + Ausrollen entfernt den
* alten aus allen Blöcken.
* 5. abschliessen: auf jedem Host nachsehen, dass der alte weg ist, dann seinen Private Key
* verwerfen.
*
* Zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da: umgestellt wird
* erst, wenn der neue Key überall geprüft ist (oder ein Admin einen Host bewusst übergeht –
* der braucht danach einen neuen Bootstrap).
*/
export class DeploymentKeyFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "DeploymentKeyFehler";
}
}
type Akteur = { readonly id: string; readonly name: string };
export type PruefErgebnis = { hostId: string; hostName: string; ok: boolean; fehler?: string };
/** Hosts, die den Instanz-Key benutzen (kein eigener, keiner über eine Gruppe). */
export async function instanzHosts() {
return prisma.host.findMany({
where: {
archiviertAm: null,
bootstrapAm: { not: null },
deploymentSchluesselId: null,
gruppen: { none: { hostgruppe: { deploymentSchluesselId: { not: null } } } },
},
select: { id: true, name: true, anmeldeart: true },
orderBy: { name: "asc" },
});
}
export async function instanzZiele(): Promise<Ziel[]> {
return (await instanzHosts()).map((h) => ({
hostId: h.id,
zielbenutzer: anmeldebenutzerVon(h),
art: "public",
}));
}
export async function rotationsStand() {
const aktuell = await prisma.schluessel.findFirst({
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
select: { id: true, fingerprint: true, angelegtAm: true, vorgaengerId: true },
});
const nachfolger = await deploymentNachfolger();
// Nach dem Umstellen: der alte ist ROTIERT, sein Private Key noch nicht verworfen.
const vorgaenger = aktuell?.vorgaengerId
? await prisma.schluessel.findFirst({
where: {
id: aktuell.vorgaengerId,
kategorie: "DEPLOYMENT",
status: "ROTIERT",
privatGeloeschtAm: null,
},
select: { id: true, fingerprint: true },
})
: null;
const letztePruefung = await prisma.vorgang.findFirst({
where: { art: "ROTATION", eingabe: { path: ["schritt"], equals: "pruefen" } },
orderBy: { angelegtAm: "desc" },
select: { id: true, status: true, ergebnis: true, beendetAm: true, angelegtAm: true },
});
const hosts = await instanzHosts();
const pruefung =
letztePruefung && nachfolger && letztePruefung.angelegtAm > nachfolger.angelegtAm
? ((letztePruefung.ergebnis as { hosts?: PruefErgebnis[] } | null)?.hosts ?? null)
: null;
const phase: "bereit" | "verteilen" | "aufraeumen" = nachfolger
? "verteilen"
: vorgaenger
? "aufraeumen"
: "bereit";
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung };
}
export async function rotationsSchrittAnfordern(
schritt: "erzeugen" | "pruefen" | "abschliessen",
akteur: Akteur,
): Promise<string> {
const stand = await rotationsStand();
if (schritt === "erzeugen" && stand.phase !== "bereit") {
throw new DeploymentKeyFehler("Es läuft bereits eine Rotation des Deployment-Keys.");
}
if (schritt === "pruefen" && !stand.nachfolger)
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
if (schritt === "abschliessen" && !stand.vorgaenger)
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
}
/**
* Schritt 4: Nachfolger wird Standard. Verlangt eine Prüfung nach dem Erzeugen, in der jeder
* Host den neuen Key angenommen hat – oder ausdrücklich übergangen wird.
*/
export async function umstellen(akteur: Akteur, uebergehen: readonly string[] = []): Promise<void> {
const stand = await rotationsStand();
if (!stand.nachfolger || !stand.aktuell)
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key.");
if (!stand.pruefung) throw new DeploymentKeyFehler("Erst den neuen Key auf allen Hosts prüfen.");
const geprueft = new Map(stand.pruefung.map((p) => [p.hostId, p]));
const offen = stand.hosts.filter((h) => !geprueft.get(h.id)?.ok && !uebergehen.includes(h.id));
if (offen.length) {
throw new DeploymentKeyFehler(
`Der neue Key ist noch nicht auf allen Hosts geprüft: ${offen.map((h) => h.name).join(", ")}.`,
);
}
const alt = stand.aktuell;
const neu = stand.nachfolger;
await prisma.$transaction([
// Erst den alten abloesen, dann den neuen setzen – der partielle Index erlaubt nur einen Standard.
prisma.schluessel.update({
where: { id: alt.id },
data: { instanzStandard: false, status: "ROTIERT" },
select: { id: true },
}),
prisma.schluessel.update({
where: { id: neu.id },
data: { instanzStandard: true },
select: { id: true },
}),
]);
await protokollieren({
akteur,
aktion: "deploymentkey.umgestellt",
ergebnis: "ERFOLG",
fingerprint: neu.fingerprint,
details: { alt: alt.fingerprint, uebergangen: [...uebergehen] },
});
}