import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { authorizedKeysLesen } from "@/lib/ssh/authorized-keys"; import { DeploymentKeyFehler, instanzZiele, rotationsStand, umstellen, } from "@/server/services/deploymentkey"; import { anmeldebenutzerVon } from "@/server/services/soll"; import { deploymentKeySicherstellen, privateKeyLaden } from "@/worker/geheimnisse"; import { hostVerbinden } from "@/worker/ssh/host"; import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien"; import { datenbankLeeren } from "../hilfen/db"; import { warteschlangeStoppen } from "../hilfen/vorgang"; import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele"; /** * Rotation des Deployment-Keys gegen echte Hosts (#031): danach meldet sich sshkv nur noch mit * dem neuen Key an, der alte steht auf keinem Host mehr, und zu keinem Zeitpunkt war ein Host * ohne funktionierenden Key. */ const AKTEUR = { id: "", name: "Test-Admin" }; let aufraeumen: () => void; beforeAll(async () => { await Promise.all([zielBereit(zielA), zielBereit(zielB)]); aufraeumen = masterKeyAnlegen(); }); afterAll(async () => { aufraeumen(); await warteschlangeStoppen(); }); beforeEach(async () => { await datenbankLeeren(); await deploymentKeySicherstellen(); const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true"; await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]); AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id; }); const schritt = (s: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen") => vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id }); async function trockenlaufUndAusrollen() { const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele: await instanzZiele() }, angefordertVonId: AKTEUR.id, }); expect(trockenlauf.status).toBe("ERLEDIGT"); const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id }, angefordertVonId: AKTEUR.id, }); expect(ausrollen.fehler).toBeNull(); } type Host = Awaited>; async function fingerprintsAuf(host: Host): Promise { const verbindung = await hostVerbinden(host); try { const datei = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host)); return authorizedKeysLesen(datei.inhalt).zeilen.flatMap((z) => z.art === "schluessel" ? [z.fingerprint] : [], ); } finally { verbindung.end(); } } async function anmeldungKlappt(host: Host, schluesselId: string): Promise { const privateKey = await privateKeyLaden(schluesselId); try { const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey }); verbindung.end(); return true; } catch { return false; } finally { privateKey.fill(0); } } describe("Rotation des Deployment-Keys", () => { it("tauscht den Key auf allen Hosts ohne Unterbrechung aus", async () => { const a = await eingerichteterHost(zielA, "ziel-a"); const b = await eingerichteterHost(zielB, "ziel-b", "ROOT"); const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } }); expect((await schritt("erzeugen")).status).toBe("ERLEDIGT"); const neu = (await rotationsStand()).nachfolger!; expect(neu.vorgaengerId).toBe(alt.id); // Ein zweites Erzeugen waehrend der Rotation wird abgelehnt. expect((await schritt("erzeugen")).status).toBe("FEHLGESCHLAGEN"); // Vor dem Verteilen scheitert die Pruefung – und ohne erfolgreiche Pruefung kein Umstellen. await schritt("pruefen"); expect((await rotationsStand()).pruefung?.every((p) => !p.ok)).toBe(true); await expect(umstellen(AKTEUR)).rejects.toThrow(DeploymentKeyFehler); await trockenlaufUndAusrollen(); for (const host of [a, b]) { expect(await fingerprintsAuf(host)).toEqual(expect.arrayContaining([alt.fingerprint, neu.fingerprint])); } await schritt("pruefen"); expect((await rotationsStand()).pruefung?.map((p) => p.ok)).toEqual([true, true]); await umstellen(AKTEUR); const umgestellt = await rotationsStand(); expect(umgestellt.aktuell?.id).toBe(neu.id); expect(umgestellt.phase).toBe("aufraeumen"); // Abschliessen geht erst, wenn der alte Key ueberall entfernt ist. expect((await schritt("abschliessen")).fehler).toMatch(/steht noch auf/); await trockenlaufUndAusrollen(); for (const host of [a, b]) { const fingerprints = await fingerprintsAuf(host); expect(fingerprints).toContain(neu.fingerprint); expect(fingerprints).not.toContain(alt.fingerprint); expect(await anmeldungKlappt(host, alt.id)).toBe(false); expect(await anmeldungKlappt(host, neu.id)).toBe(true); } expect((await schritt("abschliessen")).status).toBe("ERLEDIGT"); const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } }); expect(verworfen.status).toBe("ROTIERT"); expect(verworfen.privatGeloeschtAm).not.toBeNull(); expect((await rotationsStand()).phase).toBe("bereit"); // Der Alltag geht mit dem neuen Key weiter. const abgleich = await vorgangLaufen("ABGLEICH", { hostId: a.id }); expect(abgleich.status).toBe("ERLEDIGT"); expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0); }); it("bricht vor dem Umstellen ab und laesst danach eine neue Rotation zu (#036)", async () => { const a = await eingerichteterHost(zielA, "ziel-a"); const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } }); expect((await schritt("erzeugen")).status).toBe("ERLEDIGT"); const neu = (await rotationsStand()).nachfolger!; await trockenlaufUndAusrollen(); expect(await fingerprintsAuf(a)).toContain(neu.fingerprint); expect((await schritt("abbrechen")).status).toBe("ERLEDIGT"); const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: neu.id } }); expect(verworfen).toMatchObject({ status: "WIDERRUFEN", privatVerschluesselt: null }); expect(await rotationsStand()).toMatchObject({ phase: "bereit", abgebrochen: { id: neu.id } }); // Ohne laufende Rotation gibt es nichts mehr abzubrechen. expect((await schritt("abbrechen")).status).toBe("FEHLGESCHLAGEN"); // Der Dry-Run nimmt den verworfenen Key wieder heraus; der alte arbeitet unveraendert. await trockenlaufUndAusrollen(); const fingerprints = await fingerprintsAuf(a); expect(fingerprints).toContain(alt.fingerprint); expect(fingerprints).not.toContain(neu.fingerprint); expect(await anmeldungKlappt(a, alt.id)).toBe(true); // Eine neue Rotation beginnt von vorn. expect((await schritt("erzeugen")).status).toBe("ERLEDIGT"); const zweiter = (await rotationsStand()).nachfolger!; expect(zweiter.id).not.toBe(neu.id); expect(zweiter.vorgaengerId).toBe(alt.id); }); it("laesst Hosts mit eigenem Deployment-Key aus", async () => { const a = await eingerichteterHost(zielA, "ziel-a"); const b = await eingerichteterHost(zielB, "ziel-b"); const eigener = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } }); // b bekommt (formal) einen eigenen Key – derselbe genuegt, es zaehlt die Zuordnung. await prisma.host.update({ where: { id: b.id }, data: { deploymentSchluesselId: eigener.id } }); expect((await instanzZiele()).map((z) => z.hostId)).toEqual([a.id]); }); });