feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)
Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen. Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen, alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen. Der Nachfolger steht während der Rotation in Block und Pflichtliste, damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { anmelden, archivieren, aufZiel, hostEinrichten, PORT, zieleZuruecksetzen } from "./hilfen";
|
||||
|
||||
/**
|
||||
* Rotation des Deployment-Keys im Browser (#031), gebauter Stand + Worker: zwei frisch
|
||||
* eingerichtete Hosts, fuenf Schritte, danach meldet sich sshkv nur noch mit dem neuen Key an.
|
||||
*/
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test.beforeAll(() => zieleZuruecksetzen());
|
||||
|
||||
async function bestaetigen(page: Page) {
|
||||
await page.goto("/bestaetigen?ziel=%2Fdeployment-key");
|
||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
|
||||
}
|
||||
|
||||
test("Deployment-Key rotieren", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
|
||||
await anmelden(page);
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
const vorher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
|
||||
|
||||
await page.goto("/einstellungen");
|
||||
await page.getByRole("link", { name: "Deployment-Key rotieren" }).click();
|
||||
await bestaetigen(page);
|
||||
|
||||
await page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" }).click();
|
||||
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Schritt 2: verteilen – der neue Key kommt neben den alten.
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByRole("button", { name: /Änderung anwenden/ })).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/deployment-key-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
|
||||
|
||||
// Schritt 3 und 4: pruefen, umstellen.
|
||||
await page.getByRole("button", { name: "Neuen Key prüfen" }).click();
|
||||
const ergebnis = page.getByRole("list", { name: "Ergebnis der Prüfung" });
|
||||
await expect(ergebnis.getByText("angemeldet")).toHaveCount(2, { timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Auf neuen Key umstellen" }).click();
|
||||
|
||||
// Schritt 5: alten Key entfernen und abschliessen.
|
||||
await expect(page.getByRole("heading", { name: "5 · Alten Key entfernen" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Rotation abschließen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "1 · Neuen Key erzeugen" })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const nachher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
|
||||
expect(nachher.match(/sshkv-deployment/g)).toHaveLength(1);
|
||||
expect(nachher).not.toBe(vorher);
|
||||
|
||||
// Der Alltag geht weiter: Verbindung pruefen klappt mit dem neuen Key.
|
||||
await page.goto("/hosts");
|
||||
await page.getByRole("link", { name: hostA }).first().click();
|
||||
const pruefen = page.getByRole("button", { name: "Verbindung prüfen" });
|
||||
await pruefen.click();
|
||||
await expect(pruefen.locator("..").getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
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" | "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("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]);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ export async function vorgangLaufen(
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN"
|
||||
| "ABGLEICH",
|
||||
| "ABGLEICH"
|
||||
| "ROTATION",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
Reference in New Issue
Block a user