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>
183 lines
7.3 KiB
TypeScript
183 lines
7.3 KiB
TypeScript
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");
|
||
});
|
||
});
|