import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { prisma } from "@/lib/db"; import { verschluesseln } from "@/lib/geheimnisse"; import { bootstrapBefehl } from "@/lib/ssh/bootstrap-skript"; import { vorgangAusfuehren } from "@/worker/ausfuehren"; import { deploymentKeySicherstellen, masterKeyVergessen } from "@/worker/geheimnisse"; import { HANDLER } from "@/worker/handler"; import { hostVerbinden } from "@/worker/ssh/host"; import { befehlAusfuehren } from "@/worker/ssh/verbindung"; import { datenbankLeeren } from "../hilfen/db"; import { befehlAlsRoot, rootPasswort, testPasswort, zielB, zielBereit } from "./hilfen/ziele"; /** Bootstrap gegen ein echtes sshd (#010). Ziel B, damit #009 auf Ziel A ungestoert bleibt. */ let verzeichnis: string; beforeAll(async () => { await zielBereit(zielB); verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-")); const datei = path.join(verzeichnis, "master.key"); writeFileSync(datei, randomBytes(32), { mode: 0o600 }); process.env["SSHKV_MASTER_KEY_FILE"] = datei; masterKeyVergessen(); }); afterAll(() => rmSync(verzeichnis, { recursive: true, force: true })); beforeEach(async () => { await datenbankLeeren(); await deploymentKeySicherstellen(); // Jeder Test beginnt ohne Dienstbenutzer und mit leerer authorized_keys fuer root. await befehlAlsRoot( zielB, "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys*; true", ); }); async function hostMitVertrautemKey(anmeldeart: "DIENSTBENUTZER" | "ROOT" = "DIENSTBENUTZER") { const host = await prisma.host.create({ data: { name: "ziel-b", adresse: zielB.host, port: zielB.port, anmeldeart }, }); const abruf = await prisma.vorgang.create({ data: { art: "HOSTKEY_ABRUFEN", hostId: host.id } }); await vorgangAusfuehren(abruf.id, HANDLER); await prisma.hostSchluessel.updateMany({ where: { hostId: host.id }, data: { status: "VERTRAUT" } }); return host; } async function bootstrap(hostId: string, benutzer: string, passwort: string) { const vorgang = await prisma.vorgang.create({ data: { art: "BOOTSTRAP", hostId, eingabe: { modus: "passwort", benutzer } }, }); await vorgangAusfuehren(vorgang.id, HANDLER, verschluesseln(passwort)); return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } }); } describe("Bootstrap per Passwort", () => { it("legt Dienstbenutzer, sudoers und Deployment-Key an und meldet sich danach per Key an", async () => { const host = await hostMitVertrautemKey(); const vorgang = await bootstrap(host.id, "root", rootPasswort()); expect(vorgang.fehler).toBeNull(); expect(vorgang.status).toBe("ERLEDIGT"); const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } }); expect(aktuell.bootstrapAm).not.toBeNull(); expect(aktuell.sshdBefund).toMatchObject({ permitRootLogin: "yes", passwordAuthentication: "yes" }); const verbindung = await hostVerbinden(aktuell); expect((await befehlAusfuehren(verbindung, "id -un")).stdout.trim()).toBe("sshkv"); expect((await befehlAusfuehren(verbindung, "sudo -n id -un")).stdout.trim()).toBe("root"); const datei = await befehlAusfuehren( verbindung, "cat ~/.ssh/authorized_keys; stat -c %a ~/.ssh ~/.ssh/authorized_keys", ); verbindung.end(); expect(datei.stdout).toMatch( /^# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nssh-ed25519 \S+ sshkv-deployment@/, ); expect(datei.stdout).toContain("# END SSHKV MANAGED\n700\n600"); const sudoers = await befehlAlsRoot(zielB, "stat -c %a /etc/sudoers.d/sshkv"); expect(sudoers.stdout.trim()).toBe("440"); }); it("ist wiederholbar, ohne den Key doppelt einzutragen, und behaelt fremde Zeilen", async () => { const host = await hostMitVertrautemKey("ROOT"); await befehlAlsRoot( zielB, "mkdir -p /root/.ssh && echo 'ssh-ed25519 AAAAfremd admin@laptop' > /root/.ssh/authorized_keys", ); expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT"); expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT"); const datei = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout; expect(datei.match(/sshkv-deployment@/g)).toHaveLength(1); expect(datei.startsWith("ssh-ed25519 AAAAfremd admin@laptop\n")).toBe(true); const sicherungen = await befehlAlsRoot(zielB, "ls /root/.ssh | grep -c sshkv.bak"); expect(Number(sicherungen.stdout.trim())).toBe(1); }); it("geht auch ueber einen Benutzer mit sudo-Passwort", async () => { const host = await hostMitVertrautemKey(); const vorgang = await bootstrap(host.id, "test", testPasswort()); expect(vorgang.fehler).toBeNull(); }); it("speichert das Passwort nirgends", async () => { const host = await hostMitVertrautemKey(); const passwort = rootPasswort(); await bootstrap(host.id, "root", passwort); const alles = JSON.stringify( [await prisma.vorgang.findMany(), await prisma.protokoll.findMany(), await prisma.host.findMany()], (_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert), ); expect(alles).not.toContain(passwort); }); it("scheitert mit falschem Passwort verstaendlich", async () => { const host = await hostMitVertrautemKey(); const vorgang = await bootstrap(host.id, "root", "falsch"); expect(vorgang.status).toBe("FEHLGESCHLAGEN"); expect(vorgang.fehler).toMatch(/Anmeldung wurde abgelehnt/); }); it("folgt keinem Symlink im Home des Dienstbenutzers", async () => { const host = await hostMitVertrautemKey(); await befehlAlsRoot( zielB, "useradd -m -s /bin/sh sshkv && mkdir -p /home/sshkv/.ssh && ln -s /etc/shadow /home/sshkv/.ssh/authorized_keys && chown -R sshkv: /home/sshkv/.ssh", ); const vorher = (await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout; const vorgang = await bootstrap(host.id, "root", rootPasswort()); expect(vorgang.status).toBe("FEHLGESCHLAGEN"); expect((await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout).toBe(vorher); const kopie = await befehlAlsRoot( zielB, "cat /home/sshkv/.ssh/*.sshkv.* 2>/dev/null | grep -c root: || true", ); expect(kopie.stdout.trim()).toBe("0"); }); }); describe("Bootstrap per Befehl", () => { it("funktioniert mit dem angezeigten Befehl und anschliessender Pruefung", async () => { const host = await hostMitVertrautemKey(); const deployment = await prisma.schluessel.findFirstOrThrow({ where: { kategorie: "DEPLOYMENT" } }); const befehl = bootstrapBefehl({ benutzer: "sshkv", dienstbenutzerAnlegen: true, deploymentKey: deployment.publicKey, }); const ausgabe = await befehlAlsRoot(zielB, befehl); expect(ausgabe.stdout).toContain("SSHKV-BOOTSTRAP-OK"); const pruefung = await prisma.vorgang.create({ data: { art: "BOOTSTRAP", hostId: host.id, eingabe: { modus: "pruefen" } }, }); await vorgangAusfuehren(pruefung.id, HANDLER); const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: pruefung.id } }); expect(fertig.fehler).toBeNull(); expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).bootstrapAm).not.toBeNull(); }); });