Ein Durchgang fuer den Kern-Anwendungsfall: Vorlage und Zweck, Host A, Host B mit Zielbenutzer, Zusammenfassung, dann Schluessel erzeugen, Benutzer anlegen, zuweisen, Dry-Run, ausrollen und Verbindung testen. Die Vorlage Deployment-Zugang fuellt deploy, Ablaufdatum, from= und Alias vor; bei root als Ziel warnt der Assistent ausdruecklich. Die Ausfuehrung ist wiederaufnehmbar: wird mittendrin eine frische Bestaetigung fuer das Zielkonto noetig, geschieht sie in einem neuen Tab, und nichts wird doppelt angelegt. Zielkonten laufen als eigene Vorgaenge (neue Migration) und merken sich, ob sshkv den Benutzer angelegt hat - nur dann loescht ein Widerruf ihn spaeter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent mit der Vorlage „Deployment-Zugang“ (#018–#020), gebauter Stand +
|
||||
* Worker: Benutzer deploy auf B anlegen, Private Key auf A, Public Key auf B, Dry-Run,
|
||||
* ausrollen, Verbindung testen.
|
||||
*/
|
||||
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||
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",
|
||||
};
|
||||
|
||||
test.setTimeout(240_000);
|
||||
|
||||
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",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function hostEinrichten(page: Page, name: string, port: string) {
|
||||
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 });
|
||||
}
|
||||
|
||||
test.beforeAll(() => {
|
||||
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);
|
||||
});
|
||||
|
||||
test("Deployment-Zugang mit dem Assistenten", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
|
||||
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 hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
|
||||
// Frische Bestaetigung vorab (Benutzer anlegen verlangt sie); der Mock braucht auth_time.
|
||||
await page.goto("/bestaetigen?ziel=%2Fassistent");
|
||||
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: "Host-zu-Host-Assistent" })).toBeVisible();
|
||||
|
||||
// 1. Vorlage & Zweck
|
||||
await page.getByLabel("Vorlage").selectOption({ label: "Deployment-Zugang" });
|
||||
await page.getByLabel("Zweck").fill(`zz-e2e Claude Code Deployment ${kennung}`);
|
||||
await expect(page.getByLabel("Ablaufdatum (optional)")).not.toHaveValue("");
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 2. Quelle
|
||||
await page.getByLabel("Host A").selectOption({ label: hostA });
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 3. Ziel
|
||||
await page.getByLabel("Host B").selectOption({ label: hostB });
|
||||
await expect(page.getByLabel("Benutzer auf B")).toHaveValue("deploy");
|
||||
await expect(page.getByLabel("Einschränkungen in authorized_keys")).toHaveValue('from="127.0.0.1"');
|
||||
await expect(page.getByLabel("Alias auf A")).toHaveValue(`deploy-${hostB}`);
|
||||
// Im Docker-Netz kommt A nicht von 127.0.0.1 und erreicht B unter seinem Dienstnamen.
|
||||
await page.getByLabel("Einschränkungen in authorized_keys").fill("no-agent-forwarding");
|
||||
await page.getByLabel("HostName (aus Sicht von A)").fill("ziel-b");
|
||||
await page.getByLabel("Port").fill("22");
|
||||
await page.getByText("Fingerprint von B ist bestätigt").click();
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/assistent-ziel-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 4. Pruefen und vorbereiten
|
||||
await expect(page.getByText(`„deploy“ auf ${hostB} anlegen`)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Vorbereiten" }).click();
|
||||
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 90_000 });
|
||||
|
||||
// 5. Dry-Run, ausrollen, testen
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByText(`deploy@${hostB}:/home/deploy/.ssh/authorized_keys`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Verbindung von A nach B testen" }).click();
|
||||
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/assistent-fertig-desktop.png", fullPage: true });
|
||||
expect(aufZiel("ziel-a", `ssh -o BatchMode=yes deploy-${hostB} id -un`).trim()).toBe("deploy");
|
||||
|
||||
for (const name of [hostA, hostB]) {
|
||||
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$/);
|
||||
}
|
||||
});
|
||||
@@ -21,7 +21,14 @@ export function masterKeyAnlegen(): () => void {
|
||||
}
|
||||
|
||||
export async function vorgangLaufen(
|
||||
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
|
||||
art:
|
||||
| "HOSTKEY_ABRUFEN"
|
||||
| "BOOTSTRAP"
|
||||
| "TROCKENLAUF"
|
||||
| "AUSROLLEN"
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
@@ -118,3 +118,29 @@ describe("Zielkonto", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zielkonto als Vorgang", () => {
|
||||
it("legt an, merkt sich die Herkunft und entfernt den Benutzer wieder", async () => {
|
||||
const { prisma } = await import("@/lib/db");
|
||||
const { vorgangLaufen } = await import("./hilfen/host");
|
||||
const host = await eingerichteterHost(zielB, "ziel-b");
|
||||
const anlegen = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
|
||||
hostId: host.id,
|
||||
eingabe: { benutzer: "deploy", modus: "VOLL", befehle: [] },
|
||||
});
|
||||
expect(anlegen.fehler).toBeNull();
|
||||
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } });
|
||||
expect(konto).toMatchObject({ benutzername: "deploy", vonSshkvAngelegt: true, sudoModus: "VOLL" });
|
||||
expect(konto.sudoersInhalt).toContain("deploy ALL=(ALL:ALL) NOPASSWD: ALL");
|
||||
|
||||
const entfernen = await vorgangLaufen("ZIELKONTO_ENTFERNEN", {
|
||||
hostId: host.id,
|
||||
eingabe: { benutzer: "deploy" },
|
||||
});
|
||||
expect(entfernen.fehler).toBeNull();
|
||||
expect((await befehlAlsRoot(zielB, "id deploy 2>&1")).stdout).toMatch(/no such user/);
|
||||
expect(
|
||||
(await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } })).entferntAm,
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user