import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { publicVerwendungAnlegen, privateVerwendungAnlegen, privateVerwendungEntfernen, } from "@/server/services/verwendungen"; import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll"; import type { Plan } from "@/worker/ablaeufe/ausrollen"; import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse"; import { datenbankLeeren } from "../hilfen/db"; import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele"; /** * Kern-Anwendungsfall (#017): Schluesselpaar erzeugen, Private Key auf A, Public Key auf B – * danach meldet sich A per `ssh ` bei B an, ohne Rueckfrage zum Host-Key. */ const AKTEUR = { id: "test", name: "Test" }; let aufraeumen: () => void; beforeAll(async () => { await Promise.all([zielBereit(zielA), zielBereit(zielB)]); aufraeumen = masterKeyAnlegen(); }); afterAll(() => aufraeumen()); 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)]); }); 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, }, }); } async function trockenlaufUndAusrollen(schluesselId: string) { const ziele = [ ...(await zieleVonVerwendungen({ schluesselId })), ...(await zieleVonPrivatVerwendungen({ schluesselId })), ]; const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele } }); expect(trockenlauf.status).toBe("ERLEDIGT"); const plan = trockenlauf.plan as unknown as Plan; const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } }); return { plan, ausrollen }; } describe("Host-zu-Host", () => { it("A meldet sich nach dem Ausrollen per Alias bei B an", async () => { const a = await eingerichteterHost(zielA, "ziel-a"); const b = await eingerichteterHost(zielB, "ziel-b"); const schluessel = await dienstSchluessel("backup-a-b"); await privateVerwendungAnlegen( { schluesselId: schluessel.id, hostId: a.id, zielbenutzer: "root", pfad: "~/.ssh/id_ed25519_backup-a-b", // Im Docker-Netz erreicht A das Ziel B unter seinem Dienstnamen auf Port 22. sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, knownHostsFuer: [b.id], }, AKTEUR, ); await publicVerwendungAnlegen( { schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "root", optionen: "no-pty,no-agent-forwarding", }, AKTEUR, ); const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id); const privat = plan.ziele.find((z) => z.art === "privat")!; expect(privat.dateien?.map((d) => [d.art, d.aktion])).toEqual([ ["privateKey", "anlegen"], ["publicKey", "anlegen"], ["config", "anlegen"], ["knownHosts", "anlegen"], ]); // Der Plan traegt keinen Schluesselinhalt. expect(JSON.stringify(plan)).not.toContain("PRIVATE KEY"); expect(ausrollen.fehler).toBeNull(); const rechte = await befehlAlsRoot( zielA, "stat -c '%a' /root/.ssh/id_ed25519_backup-a-b /root/.ssh/id_ed25519_backup-a-b.pub /root/.ssh/config", ); expect(rechte.stdout).toBe("600\n644\n600\n"); const test = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); expect(test.stderr).not.toMatch(/Host key verification failed/); expect(test.stdout.trim()).toBe("root"); expect(await prisma.privateVerwendung.count({ where: { zustand: "AUSGEROLLT" } })).toBe(1); // Verbindungstest als Vorgang (#020). const verwendung = await prisma.privateVerwendung.findFirstOrThrow(); const test2 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); expect(test2.fehler).toBeNull(); expect( (await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).zuletztVerwendetAm, ).not.toBeNull(); // Ohne Public Key auf B scheitert der Test verstaendlich. await befehlAlsRoot(zielB, "rm -f /root/.ssh/authorized_keys"); const test3 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); expect(test3.fehler).toMatch(/Permission denied/); const protokoll = await prisma.protokoll.findFirst({ where: { aktion: "privatekey.ausgerollt" } }); expect(protokoll?.diff).toContain("anlegen /root/.ssh/id_ed25519_backup-a-b"); expect(JSON.stringify(protokoll?.details)).not.toContain("PRIVATE"); }); it("ueberschreibt keinen fremden Schluessel am Zielpfad", async () => { const a = await eingerichteterHost(zielA, "ziel-a"); const schluessel = await dienstSchluessel("fremd"); await befehlAlsRoot( zielA, "mkdir -p /root/.ssh && echo fremd > /root/.ssh/id_fremd && chmod 600 /root/.ssh/id_fremd", ); await privateVerwendungAnlegen( { schluesselId: schluessel.id, hostId: a.id, zielbenutzer: "root", pfad: "~/.ssh/id_fremd" }, AKTEUR, ); const { plan } = await trockenlaufUndAusrollen(schluessel.id); expect(plan.ziele[0]?.fehler).toMatch(/überschreibt keine fremden Schlüssel/); expect((await befehlAlsRoot(zielA, "cat /root/.ssh/id_fremd")).stdout).toBe("fremd\n"); }); it("entfernt Schluessel, config- und known_hosts-Eintrag beim Widerruf", async () => { const a = await eingerichteterHost(zielA, "ziel-a"); const b = await eingerichteterHost(zielB, "ziel-b"); const schluessel = await dienstSchluessel("weg"); await befehlAlsRoot( zielA, "mkdir -p /root/.ssh && printf 'Host *\\n ServerAliveInterval 30\\n' > /root/.ssh/config", ); const id = await privateVerwendungAnlegen( { schluesselId: schluessel.id, hostId: a.id, zielbenutzer: "root", pfad: "~/.ssh/id_weg", sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, knownHostsFuer: [b.id], }, AKTEUR, ); await trockenlaufUndAusrollen(schluessel.id); expect((await befehlAlsRoot(zielA, "head -n 2 /root/.ssh/config")).stdout).toBe( "# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nHost zu-b\n", ); await privateVerwendungEntfernen(id, AKTEUR); const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id); expect(ausrollen.fehler).toBeNull(); const rest = await befehlAlsRoot(zielA, "ls /root/.ssh; cat /root/.ssh/config"); expect(rest.stdout).not.toContain("id_weg"); expect(rest.stdout).toContain("Host *\n ServerAliveInterval 30\n"); expect(rest.stdout).not.toContain("zu-b"); expect((await befehlAlsRoot(zielA, "cat /root/.ssh/known_hosts")).stdout).not.toContain("ssh-ed25519"); }); });