import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll"; import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen"; import { widerrufAbschliessen, widerrufEinleiten, widerrufZielkontenEntfernen, } from "@/server/services/widerruf"; import type { Plan } from "@/worker/ablaeufe/ausrollen"; import { vorgangAusfuehren } from "@/worker/ausfuehren"; import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse"; import { HANDLER } from "@/worker/handler"; 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"; /** * Widerruf gegen echte Hosts (#024): Public Key aus authorized_keys von B, Private Key samt * config- und known_hosts-Eintrag von A, optional das Zielkonto deploy auf B. Danach scheitert * `ssh ` von A. Scheitert ein Host, bleibt der Schluessel im Zwischenzustand. */ 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; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true"; await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]); const admin = await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } }); AKTEUR.id = admin.id; }); async function dienstSchluessel(name: string) { const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`); return prisma.schluessel.create({ data: { id: neu.id, name, slug: name, typ: "ED25519", kategorie: "DIENST", publicKey: neu.publicKeyZeile, fingerprint: neu.fingerprint, kommentar: `sshkv:${name}`, ...neu.versiegelt, }, select: { id: true, fingerprint: true, publicKey: true }, }); } async function trockenlaufUndAusrollen(schluesselId: string) { const ziele = [ ...(await zieleVonVerwendungen({ schluesselId })), ...(await zieleVonPrivatVerwendungen({ schluesselId })), ]; const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id }); expect(trockenlauf.status).toBe("ERLEDIGT"); const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id }, angefordertVonId: AKTEUR.id, }); return { plan: trockenlauf.plan as unknown as Plan, ausrollen }; } /** Deployment-Zugang wie im Assistenten: deploy auf B, Private Key auf A, Alias zu-b. */ async function deploymentZugang() { const a = await eingerichteterHost(zielA, "ziel-a"); const b = await eingerichteterHost(zielB, "ziel-b"); const konto = await vorgangLaufen("ZIELKONTO_ANLEGEN", { hostId: b.id, eingabe: { benutzer: "deploy", modus: "KEINER", befehle: [] }, }); expect(konto.fehler).toBeNull(); const schluessel = await dienstSchluessel("deploy-a-b"); await privateVerwendungAnlegen( { schluesselId: schluessel.id, hostId: a.id, zielbenutzer: "root", pfad: "~/.ssh/id_ed25519_deploy", sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "deploy", port: 22 }, knownHostsFuer: [b.id], }, AKTEUR, ); await publicVerwendungAnlegen( { schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "deploy" }, AKTEUR, ); const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id); expect(ausrollen.fehler).toBeNull(); const vorher = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); expect(vorher.stdout.trim()).toBe("deploy"); return { a, b, schluessel }; } describe("Widerruf", () => { it("entfernt Public Key, Private Key, config, known_hosts und auf Wunsch das Zielkonto", async () => { const { schluessel } = await deploymentZugang(); const blob = schluessel.publicKey.split(" ")[1]!; await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true }); const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id); expect(plan.ziele.every((z) => !z.fehler)).toBe(true); expect(ausrollen.fehler).toBeNull(); expect((await befehlAlsRoot(zielB, "cat /home/deploy/.ssh/authorized_keys")).stdout).not.toContain(blob); const ssh = await befehlAlsRoot(zielA, "ls -a /root/.ssh; cat /root/.ssh/config /root/.ssh/known_hosts"); expect(ssh.stdout).not.toContain("id_ed25519_deploy"); expect(ssh.stdout).not.toContain("zu-b"); expect(ssh.stdout).not.toContain("ssh-ed25519"); const danach = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b id -un"); expect(danach.code).not.toBe(0); // Zielkonto: erst nach den Schluesseln, dann Abschluss. const konto = await prisma.zielkonto.findFirstOrThrow({ where: { benutzername: "deploy" } }); const vorgaenge = await widerrufZielkontenEntfernen([schluessel.id], [konto.id], AKTEUR); for (const v of vorgaenge) await vorgangAusfuehren(v.id, HANDLER); expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } })).fehler).toBeNull(); expect((await befehlAlsRoot(zielB, "id deploy")).code).not.toBe(0); const [ergebnis] = await widerrufAbschliessen([schluessel.id], AKTEUR); expect(ergebnis?.status).toBe("WIDERRUFEN"); const protokoll = await prisma.protokoll.findMany({ where: { fingerprint: schluessel.fingerprint } }); expect(protokoll.map((p) => p.aktion)).toEqual( expect.arrayContaining(["schluessel.widerruf.eingeleitet", "schluessel.widerrufen"]), ); expect(JSON.stringify(protokoll.map((p) => [p.details, p.diff]))).not.toContain("PRIVATE KEY"); }); it("bleibt im Zwischenzustand, wenn ein Host nicht erreichbar ist", async () => { const { b, schluessel } = await deploymentZugang(); await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true }); // B „faellt aus“: ein Port, auf dem niemand lauscht. await prisma.host.update({ where: { id: b.id }, data: { port: 1 } }); const { plan } = await trockenlaufUndAusrollen(schluessel.id); expect(plan.ziele.find((z) => z.hostName === "ziel-b")?.fehler).toBeTruthy(); expect(plan.ziele.find((z) => z.hostName === "ziel-a")?.fehler).toBeUndefined(); const [offen] = await widerrufAbschliessen([schluessel.id], AKTEUR); expect(offen?.status).toBe("OFFEN"); expect(offen?.offen.map((o) => o.ziel)).toEqual(["deploy@ziel-b:~/.ssh/authorized_keys"]); expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).status).toBe( "AKTIV", ); // Was ging, ist trotzdem weg: der Private Key auf A. expect((await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout).not.toContain("id_ed25519_deploy"); // B ist zurueck: ein neuer Dry-Run erledigt den Rest. await prisma.host.update({ where: { id: b.id }, data: { port: zielB.port } }); const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id); expect(ausrollen.fehler).toBeNull(); const [fertig] = await widerrufAbschliessen([schluessel.id], AKTEUR); expect(fertig?.status).toBe("WIDERRUFEN"); }); });