import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { schluesselpaarErzeugen } from "@/lib/ssh/openssh"; import { publicVerwendungAnlegen, publicVerwendungEntfernen } from "@/server/services/verwendungen"; import { deploymentKeySicherstellen } from "@/worker/geheimnisse"; import type { Plan } from "@/worker/ablaeufe/ausrollen"; import { datenbankLeeren } from "../hilfen/db"; import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele"; /** Public Keys ausrollen gegen ein echtes sshd (#016). */ const AKTEUR = { id: "test", name: "Test" }; let aufraeumen: () => void; beforeAll(async () => { await zielBereit(zielA); aufraeumen = masterKeyAnlegen(); }); afterAll(() => aufraeumen()); beforeEach(async () => { await datenbankLeeren(); await deploymentKeySicherstellen(); await befehlAlsRoot( zielA, "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /home/test/.ssh/authorized_keys*; rm -rf /root/.ssh; true", ); }); async function schluesselAnlegen(name: string) { const paar = await schluesselpaarErzeugen("ED25519", { kommentar: `sshkv:${name}` }); return prisma.schluessel.create({ data: { name, slug: name, typ: "ED25519", kategorie: "DIENST", publicKey: paar.publicKeyZeile, fingerprint: paar.fingerprint, kommentar: `sshkv:${name}`, }, }); } async function trockenlauf(schluesselId: string) { const vorgang = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele: ( await prisma.publicVerwendung.findMany({ where: { schluesselId }, include: { hostgruppe: { include: { mitglieder: true } } }, }) ).flatMap((v) => v.hostId ? [{ hostId: v.hostId, zielbenutzer: v.zielbenutzer }] : (v.hostgruppe?.mitglieder.map((m) => ({ hostId: m.hostId, zielbenutzer: v.zielbenutzer })) ?? []), ), }, }); expect(vorgang.status).toBe("ERLEDIGT"); return { id: vorgang.id, plan: vorgang.plan as unknown as Plan }; } const datei = async (pfad: string) => (await befehlAlsRoot(zielA, `cat ${pfad} 2>/dev/null || true`)).stdout; describe("Public Key ausrollen", () => { it("zeigt im Trockenlauf die Aenderung, schreibt erst beim Ausrollen", async () => { const host = await eingerichteterHost(zielA, "ziel-a"); await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && printf '# von Hand\\n' > /root/.ssh/authorized_keys"); const schluessel = await schluesselAnlegen("backup"); const verwendungId = await publicVerwendungAnlegen( { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", optionen: 'restrict,from="10.0.0.12"', }, AKTEUR, ); const { id, plan } = await trockenlauf(schluessel.id); expect(plan.ziele).toHaveLength(1); expect(plan.ziele[0]).toMatchObject({ zielbenutzer: "root", geaendert: true, pfad: "/root/.ssh/authorized_keys", }); expect(plan.ziele[0]!.diff).toContainEqual({ art: "hinzu", text: `restrict,from="10.0.0.12" ${schluessel.publicKey}`, }); expect(await datei("/root/.ssh/authorized_keys")).toBe("# von Hand\n"); const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); expect(ausrollen.fehler).toBeNull(); const inhalt = await datei("/root/.ssh/authorized_keys"); expect(inhalt).toBe( `# von Hand\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nrestrict,from="10.0.0.12" ${schluessel.publicKey}\n# END SSHKV MANAGED\n`, ); expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: verwendungId } })).zustand).toBe( "AUSGEROLLT", ); expect( (await befehlAlsRoot(zielA, "ls /root/.ssh | grep -c authorized_keys.sshkv.bak")).stdout.trim(), ).toBe("1"); expect((await befehlAlsRoot(zielA, "stat -c %a /root/.ssh/authorized_keys")).stdout.trim()).toBe("600"); const eintrag = await prisma.protokoll.findFirst({ where: { aktion: "authorized_keys.geschrieben" } }); expect(eintrag?.diff).toContain("+ restrict"); // Derselbe Trockenlauf nicht zweimal. const nochmal = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); expect(nochmal.fehler).toMatch(/bereits ausgeführt/); }); it("bricht ab, wenn die Datei seit dem Trockenlauf geaendert wurde", async () => { const host = await eingerichteterHost(zielA, "ziel-a"); const schluessel = await schluesselAnlegen("backup"); await publicVerwendungAnlegen( { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR, ); const { id } = await trockenlauf(schluessel.id); await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && echo '# dazwischen' >> /root/.ssh/authorized_keys"); const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); expect(ausrollen.status).toBe("FEHLGESCHLAGEN"); expect(ausrollen.fehler).toMatch(/seit dem Trockenlauf geändert/); expect(await datei("/root/.ssh/authorized_keys")).toBe("# dazwischen\n"); }); it("bricht ab, wenn sich die Zuweisungen seit dem Trockenlauf geaendert haben", async () => { const host = await eingerichteterHost(zielA, "ziel-a"); const a = await schluesselAnlegen("a"); const b = await schluesselAnlegen("b"); await publicVerwendungAnlegen({ schluesselId: a.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR); const { id } = await trockenlauf(a.id); await publicVerwendungAnlegen({ schluesselId: b.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR); const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); expect(ausrollen.fehler).toMatch(/Zuweisungen .* geändert/); }); it("entfernt einen Schluessel wieder, und der Deployment-Key bleibt", async () => { const host = await eingerichteterHost(zielA, "ziel-a"); const schluessel = await schluesselAnlegen("weg"); const id = await publicVerwendungAnlegen( { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "sshkv" }, AKTEUR, ); const erster = await trockenlauf(schluessel.id); // Im Block des Dienstbenutzers steht der Deployment-Key schon; nur der neue kommt hinzu. expect(erster.plan.ziele[0]!.diff.filter((z) => z.art !== "kontext")).toEqual([ { art: "hinzu", text: schluessel.publicKey }, ]); await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: erster.id } }); expect(await datei("/home/sshkv/.ssh/authorized_keys")).toContain(schluessel.publicKey); await publicVerwendungEntfernen(id, AKTEUR); const zweiter = await trockenlauf(schluessel.id); expect(zweiter.plan.ziele[0]!.diff).toContainEqual({ art: "entfernt", text: schluessel.publicKey }); await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: zweiter.id } }); const inhalt = await datei("/home/sshkv/.ssh/authorized_keys"); expect(inhalt).not.toContain(schluessel.publicKey); expect(inhalt).toContain("sshkv-deployment@"); expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id } })).zustand).toBe("ENTFERNT"); }); it("rollt ueber eine Hostgruppe fuer einen gewoehnlichen Benutzer aus", async () => { const host = await eingerichteterHost(zielA, "ziel-a"); const gruppe = await prisma.hostgruppe.create({ data: { name: "Test", mitglieder: { create: { hostId: host.id } } }, }); const schluessel = await schluesselAnlegen("gruppe"); await publicVerwendungAnlegen( { schluesselId: schluessel.id, hostgruppeId: gruppe.id, zielbenutzer: "test" }, AKTEUR, ); const { id } = await trockenlauf(schluessel.id); await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } }); expect(await datei("/home/test/.ssh/authorized_keys")).toContain(schluessel.publicKey); const besitz = await befehlAlsRoot( zielA, "stat -c '%U %a' /home/test/.ssh /home/test/.ssh/authorized_keys", ); expect(besitz.stdout).toBe("test 700\ntest 600\n"); }); it("meldet einen fehlenden Benutzer im Trockenlauf je Ziel", async () => { const host = await eingerichteterHost(zielA, "ziel-a"); const schluessel = await schluesselAnlegen("niemand"); await publicVerwendungAnlegen( { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "gibtsnicht" }, AKTEUR, ); const { plan } = await trockenlauf(schluessel.id); expect(plan.ziele[0]!.fehler).toMatch(/gibt es auf ziel-a nicht/); }); });