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 { 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 { 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 { 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] }, }); }