Files
sshkeyvault/tests/integration/host-zu-host.test.ts
T
florian.wildeandClaude Opus 5.5 61a52d4538 feat(ausrollen): Verbindungstest von Host A nach Host B (#020)
Auf A als Zielbenutzer ssh mit BatchMode und StrictHostKeyChecking:
keine Rueckfrage, keine stille Annahme eines Host-Keys. Der Test
beweist damit genau das, was ein Cronjob auf A spaeter braucht. Die
letzte Meldung von ssh landet in Vorgang und Protokoll; ein Erfolg
setzt die letzte Verwendung des Schluessels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:50:43 +02:00

183 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <alias>` 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");
});
});