feat(widerruf): Widerruf und Massenwiderruf mit Dry-Run (#024)

Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts
verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch
liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen
werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre
zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr
offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023).
Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite
nennt die offenen Ziele.

Zielkonten lassen sich optional mit entfernen, aber erst nach den
Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt.
Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen
Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen
im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie
automatisch.

Migration 20261012130000_widerruf_eingeleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:14:01 +02:00
co-authored by Claude Opus 5.5
parent 2dad8e5631
commit c6361ff3c6
26 changed files with 2334 additions and 91 deletions
+90
View File
@@ -0,0 +1,90 @@
import { execFileSync } from "node:child_process";
import { expect, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Gemeinsame Schritte fuer Widerruf (#024) und Rotation (#023): anmelden, Hosts einrichten und
* der Kern-Anwendungsfall aus kern-anwendungsfall.spec.ts als Ausgangslage.
*/
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
export const PORT = {
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
};
export function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
return execFileSync(
"docker",
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
{ encoding: "utf8" },
);
}
export function zieleZuruecksetzen(): void {
const reset =
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
aufZiel("ziel-a", reset);
aufZiel("ziel-b", reset);
}
export async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name);
await page.getByLabel("Adresse").fill("127.0.0.1");
await page.getByLabel("Port").fill(port);
await page.getByRole("button", { name: "Host anlegen" }).click();
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
await page.getByRole("button", { name: "Zugang einrichten" }).click();
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
}
export async function archivieren(page: Page, name: string): Promise<void> {
await page.goto("/hosts");
await page.getByRole("link", { name }).first().click();
await page.getByRole("button", { name: "Host archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
await expect(page).toHaveURL(/\/hosts$/);
}
/**
* Kern-Anwendungsfall: Schluessel erzeugen, Private Key auf A (Alias zu-b), Public Key auf B,
* Dry-Run, anwenden. Endet auf der Schluesselseite; liefert deren Adresse.
*/
export async function kernAufbauen(page: Page, zweck: string, hostA: string, hostB: string): Promise<string> {
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(zweck);
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Ausrollen" }).first().click();
const privat = page.getByRole("dialog", { name: "Private Key ausrollen" });
await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA });
await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB });
await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b");
await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b");
await privat.getByRole("spinbutton", { name: "Port" }).fill("22");
await privat.getByRole("button", { name: "Zuweisen" }).click();
await expect(privat).toBeHidden();
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: hostB });
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
await expect(oeffentlich).toBeHidden();
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.")).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
return page.url();
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import {
anmelden,
archivieren,
aufZiel,
hostEinrichten,
kernAufbauen,
PORT,
zieleZuruecksetzen,
} from "./hilfen";
/**
* Widerruf im Browser (#024), gebauter Stand + Worker: Ausgangslage ist der Kern-Anwendungsfall
* (Private Key auf A, Public Key auf B). „Widerrufen“ → Dialog → Dry-Run → anwenden → Status
* „widerrufen“; danach kommt A nicht mehr nach B.
*/
test.setTimeout(240_000);
test.beforeAll(() => zieleZuruecksetzen());
test("Widerruf mit einem Klick", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Widerruf ${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
// Dialog: zeigt genau, was passiert.
await page.getByRole("button", { name: "Widerrufen" }).click();
const dialog = page.getByRole("dialog", { name: `„${zweck}“ widerrufen?` });
await expect(dialog.getByText(`root@${hostB}`)).toBeVisible();
await expect(dialog.getByText(`root@${hostA}:~/.ssh/`)).toBeVisible();
await expect(dialog.getByText(/config-Eintrag „Host zu-b“/)).toBeVisible();
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-desktop.png" });
await dialog.getByRole("button", { name: "Widerruf vorbereiten" }).click();
// Zwischenzustand: noch nicht widerrufen, nichts mehr zuweisbar.
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
await expect(page.getByText("Widerruf unvollständig")).toBeVisible();
await expect(page.getByRole("button", { name: "Ausrollen" })).toHaveCount(0);
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-dry-run-desktop.png", fullPage: true });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByRole("heading", { name: "Widerrufen", exact: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByText("Widerruf unvollständig")).toHaveCount(0);
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toContain("id_ed25519");
expect(() => aufZiel("ziel-a", "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b true")).toThrow();
await archivieren(page, hostA);
await archivieren(page, hostB);
});