Eine begonnene Rotation ließ sich nicht zurücknehmen; nahm ein Host den neuen Key dauerhaft nicht an, blieb nur das Übergehen. Abbrechen verwirft jetzt den neuen Key samt Private Key, ein Dry-Run nimmt ihn wieder von den Hosts, und eine neue Rotation kann sofort starten. Nebenbei: Aktionen der Benutzerverwaltung (#035) bekommen im Audit-Log eine eigene Gruppe, statt den Filter „Anmeldung“ zu verwässern; der E2E-Test für lokale Konten hängt nicht mehr von der Reihenfolge ab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
7.6 KiB
TypeScript
177 lines
7.6 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||
import { prisma } from "@/lib/db";
|
||
import { authorizedKeysLesen } from "@/lib/ssh/authorized-keys";
|
||
import {
|
||
DeploymentKeyFehler,
|
||
instanzZiele,
|
||
rotationsStand,
|
||
umstellen,
|
||
} from "@/server/services/deploymentkey";
|
||
import { anmeldebenutzerVon } from "@/server/services/soll";
|
||
import { deploymentKeySicherstellen, privateKeyLaden } from "@/worker/geheimnisse";
|
||
import { hostVerbinden } from "@/worker/ssh/host";
|
||
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
|
||
import { datenbankLeeren } from "../hilfen/db";
|
||
import { warteschlangeStoppen } from "../hilfen/vorgang";
|
||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
|
||
|
||
/**
|
||
* Rotation des Deployment-Keys gegen echte Hosts (#031): danach meldet sich sshkv nur noch mit
|
||
* dem neuen Key an, der alte steht auf keinem Host mehr, und zu keinem Zeitpunkt war ein Host
|
||
* ohne funktionierenden Key.
|
||
*/
|
||
const AKTEUR = { id: "", name: "Test-Admin" };
|
||
let aufraeumen: () => void;
|
||
|
||
beforeAll(async () => {
|
||
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
|
||
aufraeumen = masterKeyAnlegen();
|
||
});
|
||
afterAll(async () => {
|
||
aufraeumen();
|
||
await warteschlangeStoppen();
|
||
});
|
||
|
||
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)]);
|
||
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
|
||
});
|
||
|
||
const schritt = (s: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen") =>
|
||
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
|
||
|
||
async function trockenlaufUndAusrollen() {
|
||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
|
||
eingabe: { ziele: await instanzZiele() },
|
||
angefordertVonId: AKTEUR.id,
|
||
});
|
||
expect(trockenlauf.status).toBe("ERLEDIGT");
|
||
const ausrollen = await vorgangLaufen("AUSROLLEN", {
|
||
eingabe: { trockenlaufId: trockenlauf.id },
|
||
angefordertVonId: AKTEUR.id,
|
||
});
|
||
expect(ausrollen.fehler).toBeNull();
|
||
}
|
||
|
||
type Host = Awaited<ReturnType<typeof eingerichteterHost>>;
|
||
|
||
async function fingerprintsAuf(host: Host): Promise<string[]> {
|
||
const verbindung = await hostVerbinden(host);
|
||
try {
|
||
const datei = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
|
||
return authorizedKeysLesen(datei.inhalt).zeilen.flatMap((z) =>
|
||
z.art === "schluessel" ? [z.fingerprint] : [],
|
||
);
|
||
} finally {
|
||
verbindung.end();
|
||
}
|
||
}
|
||
|
||
async function anmeldungKlappt(host: Host, schluesselId: string): Promise<boolean> {
|
||
const privateKey = await privateKeyLaden(schluesselId);
|
||
try {
|
||
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
|
||
verbindung.end();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
} finally {
|
||
privateKey.fill(0);
|
||
}
|
||
}
|
||
|
||
describe("Rotation des Deployment-Keys", () => {
|
||
it("tauscht den Key auf allen Hosts ohne Unterbrechung aus", async () => {
|
||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||
const b = await eingerichteterHost(zielB, "ziel-b", "ROOT");
|
||
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||
|
||
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||
const neu = (await rotationsStand()).nachfolger!;
|
||
expect(neu.vorgaengerId).toBe(alt.id);
|
||
// Ein zweites Erzeugen waehrend der Rotation wird abgelehnt.
|
||
expect((await schritt("erzeugen")).status).toBe("FEHLGESCHLAGEN");
|
||
|
||
// Vor dem Verteilen scheitert die Pruefung – und ohne erfolgreiche Pruefung kein Umstellen.
|
||
await schritt("pruefen");
|
||
expect((await rotationsStand()).pruefung?.every((p) => !p.ok)).toBe(true);
|
||
await expect(umstellen(AKTEUR)).rejects.toThrow(DeploymentKeyFehler);
|
||
|
||
await trockenlaufUndAusrollen();
|
||
for (const host of [a, b]) {
|
||
expect(await fingerprintsAuf(host)).toEqual(expect.arrayContaining([alt.fingerprint, neu.fingerprint]));
|
||
}
|
||
await schritt("pruefen");
|
||
expect((await rotationsStand()).pruefung?.map((p) => p.ok)).toEqual([true, true]);
|
||
|
||
await umstellen(AKTEUR);
|
||
const umgestellt = await rotationsStand();
|
||
expect(umgestellt.aktuell?.id).toBe(neu.id);
|
||
expect(umgestellt.phase).toBe("aufraeumen");
|
||
// Abschliessen geht erst, wenn der alte Key ueberall entfernt ist.
|
||
expect((await schritt("abschliessen")).fehler).toMatch(/steht noch auf/);
|
||
|
||
await trockenlaufUndAusrollen();
|
||
for (const host of [a, b]) {
|
||
const fingerprints = await fingerprintsAuf(host);
|
||
expect(fingerprints).toContain(neu.fingerprint);
|
||
expect(fingerprints).not.toContain(alt.fingerprint);
|
||
expect(await anmeldungKlappt(host, alt.id)).toBe(false);
|
||
expect(await anmeldungKlappt(host, neu.id)).toBe(true);
|
||
}
|
||
|
||
expect((await schritt("abschliessen")).status).toBe("ERLEDIGT");
|
||
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
|
||
expect(verworfen.status).toBe("ROTIERT");
|
||
expect(verworfen.privatGeloeschtAm).not.toBeNull();
|
||
expect((await rotationsStand()).phase).toBe("bereit");
|
||
|
||
// Der Alltag geht mit dem neuen Key weiter.
|
||
const abgleich = await vorgangLaufen("ABGLEICH", { hostId: a.id });
|
||
expect(abgleich.status).toBe("ERLEDIGT");
|
||
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
|
||
});
|
||
|
||
it("bricht vor dem Umstellen ab und laesst danach eine neue Rotation zu (#036)", async () => {
|
||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||
const neu = (await rotationsStand()).nachfolger!;
|
||
await trockenlaufUndAusrollen();
|
||
expect(await fingerprintsAuf(a)).toContain(neu.fingerprint);
|
||
|
||
expect((await schritt("abbrechen")).status).toBe("ERLEDIGT");
|
||
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: neu.id } });
|
||
expect(verworfen).toMatchObject({ status: "WIDERRUFEN", privatVerschluesselt: null });
|
||
expect(await rotationsStand()).toMatchObject({ phase: "bereit", abgebrochen: { id: neu.id } });
|
||
// Ohne laufende Rotation gibt es nichts mehr abzubrechen.
|
||
expect((await schritt("abbrechen")).status).toBe("FEHLGESCHLAGEN");
|
||
|
||
// Der Dry-Run nimmt den verworfenen Key wieder heraus; der alte arbeitet unveraendert.
|
||
await trockenlaufUndAusrollen();
|
||
const fingerprints = await fingerprintsAuf(a);
|
||
expect(fingerprints).toContain(alt.fingerprint);
|
||
expect(fingerprints).not.toContain(neu.fingerprint);
|
||
expect(await anmeldungKlappt(a, alt.id)).toBe(true);
|
||
|
||
// Eine neue Rotation beginnt von vorn.
|
||
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||
const zweiter = (await rotationsStand()).nachfolger!;
|
||
expect(zweiter.id).not.toBe(neu.id);
|
||
expect(zweiter.vorgaengerId).toBe(alt.id);
|
||
});
|
||
|
||
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
|
||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||
const eigener = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||
// b bekommt (formal) einen eigenen Key – derselbe genuegt, es zaehlt die Zuordnung.
|
||
await prisma.host.update({ where: { id: b.id }, data: { deploymentSchluesselId: eigener.id } });
|
||
expect((await instanzZiele()).map((z) => z.hostId)).toEqual([a.id]);
|
||
});
|
||
});
|