Merge branch 'phase/032-nacharbeiten'

This commit is contained in:
2026-10-12 17:56:33 +02:00
27 changed files with 789 additions and 113 deletions
+81 -28
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { rotationsDatum } from "@/lib/rotation";
import {
rotationAbbrechen,
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
@@ -55,38 +56,44 @@ async function trockenlaufUndAusrollen(schluesselId: string) {
expect(ausrollen.fehler).toBeNull();
}
/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
async function alterSchluesselAusgerollt(aId: string, bId: string) {
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: aId,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [bId],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
return alt;
}
describe("Rotation", () => {
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
const alt = await alterSchluesselAusgerollt(a.id, b.id);
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
@@ -123,4 +130,50 @@ describe("Rotation", () => {
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
});
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const alt = await alterSchluesselAusgerollt(a.id, b.id);
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: alt.id },
select: { id: true, publicKey: true },
});
await trockenlaufUndAusrollen(neu.id);
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
`IdentityFile ~/.ssh/${neuerPfad}`,
);
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
await trockenlaufUndAusrollen(neu.id);
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
expect(dateien).toContain("id_backup");
expect(dateien).not.toContain(neuerPfad);
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
const zustaende = [
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
].map((v) => v.zustand);
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect((await rotationsStand(alt.id)).abgebrochen).toBe(true);
});
});