Merge branch 'phase/023-024' (Rotation, Widerruf)
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum } from "@/lib/rotation";
|
||||
import {
|
||||
rotationAltWiderrufen,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
rotationsStand,
|
||||
} from "@/server/services/rotation";
|
||||
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
|
||||
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
|
||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||||
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "../hilfen/db";
|
||||
import { vorgangImWorker, warteschlangeStoppen } from "../hilfen/vorgang";
|
||||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||||
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
|
||||
|
||||
/**
|
||||
* Rotation gegen echte Hosts (#023): der neue Schluessel funktioniert (Verbindungstest gruen),
|
||||
* der alte ist danach von beiden Hosts verschwunden, und `ssh <alias>` von A geht weiter –
|
||||
* derselbe Alias zeigt jetzt auf die neue Datei.
|
||||
*/
|
||||
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;
|
||||
});
|
||||
|
||||
async function trockenlaufUndAusrollen(schluesselId: string) {
|
||||
const ziele = [
|
||||
...(await zieleVonVerwendungen({ schluesselId })),
|
||||
...(await zieleVonPrivatVerwendungen({ schluesselId })),
|
||||
];
|
||||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id });
|
||||
expect(trockenlauf.status).toBe("ERLEDIGT");
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", {
|
||||
eingabe: { trockenlaufId: trockenlauf.id },
|
||||
angefordertVonId: AKTEUR.id,
|
||||
});
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
|
||||
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 },
|
||||
});
|
||||
|
||||
// 3: ausrollen – beide Schluessel liegen jetzt nebeneinander, der Alias zeigt auf den neuen.
|
||||
await trockenlaufUndAusrollen(neu.id);
|
||||
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
|
||||
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
|
||||
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
|
||||
expect(config).toContain(`IdentityFile ~/.ssh/${neuerPfad}`);
|
||||
|
||||
// 4: Verbindungstest mit dem neuen Schluessel.
|
||||
const verwendung = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neu.id } });
|
||||
const test = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } });
|
||||
expect(test.fehler).toBeNull();
|
||||
expect((await rotationsStand(alt.id)).schritt).toBe(4);
|
||||
|
||||
// 5: alten widerrufen.
|
||||
await rotationAltWiderrufen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||
await trockenlaufUndAusrollen(alt.id);
|
||||
expect((await widerrufAbschliessen([alt.id], AKTEUR))[0]?.status).toBe("ROTIERT");
|
||||
|
||||
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||||
expect(authorized).not.toContain(alt.publicKey.split(" ")[1]!);
|
||||
expect(authorized).toContain(neu.publicKey.split(" ")[1]!);
|
||||
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
|
||||
expect(dateien).not.toContain("id_backup");
|
||||
expect(dateien).toContain(neuerPfad);
|
||||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||
expect(ssh.stdout.trim()).toBe("root");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
|
||||
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
|
||||
import {
|
||||
widerrufAbschliessen,
|
||||
widerrufEinleiten,
|
||||
widerrufZielkontenEntfernen,
|
||||
} from "@/server/services/widerruf";
|
||||
import type { Plan } from "@/worker/ablaeufe/ausrollen";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Widerruf gegen echte Hosts (#024): Public Key aus authorized_keys von B, Private Key samt
|
||||
* config- und known_hosts-Eintrag von A, optional das Zielkonto deploy auf B. Danach scheitert
|
||||
* `ssh <alias>` von A. Scheitert ein Host, bleibt der Schluessel im Zwischenzustand.
|
||||
*/
|
||||
|
||||
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; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
|
||||
const admin = await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } });
|
||||
AKTEUR.id = admin.id;
|
||||
});
|
||||
|
||||
async function dienstSchluessel(name: string) {
|
||||
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`);
|
||||
return prisma.schluessel.create({
|
||||
data: {
|
||||
id: neu.id,
|
||||
name,
|
||||
slug: name,
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: neu.publicKeyZeile,
|
||||
fingerprint: neu.fingerprint,
|
||||
kommentar: `sshkv:${name}`,
|
||||
...neu.versiegelt,
|
||||
},
|
||||
select: { id: true, fingerprint: true, publicKey: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function trockenlaufUndAusrollen(schluesselId: string) {
|
||||
const ziele = [
|
||||
...(await zieleVonVerwendungen({ schluesselId })),
|
||||
...(await zieleVonPrivatVerwendungen({ schluesselId })),
|
||||
];
|
||||
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id });
|
||||
expect(trockenlauf.status).toBe("ERLEDIGT");
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", {
|
||||
eingabe: { trockenlaufId: trockenlauf.id },
|
||||
angefordertVonId: AKTEUR.id,
|
||||
});
|
||||
return { plan: trockenlauf.plan as unknown as Plan, ausrollen };
|
||||
}
|
||||
|
||||
/** Deployment-Zugang wie im Assistenten: deploy auf B, Private Key auf A, Alias zu-b. */
|
||||
async function deploymentZugang() {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||
const konto = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
|
||||
hostId: b.id,
|
||||
eingabe: { benutzer: "deploy", modus: "KEINER", befehle: [] },
|
||||
});
|
||||
expect(konto.fehler).toBeNull();
|
||||
const schluessel = await dienstSchluessel("deploy-a-b");
|
||||
await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: schluessel.id,
|
||||
hostId: a.id,
|
||||
zielbenutzer: "root",
|
||||
pfad: "~/.ssh/id_ed25519_deploy",
|
||||
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "deploy", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
},
|
||||
AKTEUR,
|
||||
);
|
||||
await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "deploy" },
|
||||
AKTEUR,
|
||||
);
|
||||
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
const vorher = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||
expect(vorher.stdout.trim()).toBe("deploy");
|
||||
return { a, b, schluessel };
|
||||
}
|
||||
|
||||
describe("Widerruf", () => {
|
||||
it("entfernt Public Key, Private Key, config, known_hosts und auf Wunsch das Zielkonto", async () => {
|
||||
const { schluessel } = await deploymentZugang();
|
||||
const blob = schluessel.publicKey.split(" ")[1]!;
|
||||
|
||||
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
|
||||
const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
|
||||
expect(plan.ziele.every((z) => !z.fehler)).toBe(true);
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
|
||||
expect((await befehlAlsRoot(zielB, "cat /home/deploy/.ssh/authorized_keys")).stdout).not.toContain(blob);
|
||||
const ssh = await befehlAlsRoot(zielA, "ls -a /root/.ssh; cat /root/.ssh/config /root/.ssh/known_hosts");
|
||||
expect(ssh.stdout).not.toContain("id_ed25519_deploy");
|
||||
expect(ssh.stdout).not.toContain("zu-b");
|
||||
expect(ssh.stdout).not.toContain("ssh-ed25519");
|
||||
const danach = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b id -un");
|
||||
expect(danach.code).not.toBe(0);
|
||||
|
||||
// Zielkonto: erst nach den Schluesseln, dann Abschluss.
|
||||
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { benutzername: "deploy" } });
|
||||
const vorgaenge = await widerrufZielkontenEntfernen([schluessel.id], [konto.id], AKTEUR);
|
||||
for (const v of vorgaenge) await vorgangAusfuehren(v.id, HANDLER);
|
||||
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } })).fehler).toBeNull();
|
||||
expect((await befehlAlsRoot(zielB, "id deploy")).code).not.toBe(0);
|
||||
|
||||
const [ergebnis] = await widerrufAbschliessen([schluessel.id], AKTEUR);
|
||||
expect(ergebnis?.status).toBe("WIDERRUFEN");
|
||||
const protokoll = await prisma.protokoll.findMany({ where: { fingerprint: schluessel.fingerprint } });
|
||||
expect(protokoll.map((p) => p.aktion)).toEqual(
|
||||
expect.arrayContaining(["schluessel.widerruf.eingeleitet", "schluessel.widerrufen"]),
|
||||
);
|
||||
expect(JSON.stringify(protokoll.map((p) => [p.details, p.diff]))).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
it("bleibt im Zwischenzustand, wenn ein Host nicht erreichbar ist", async () => {
|
||||
const { b, schluessel } = await deploymentZugang();
|
||||
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
|
||||
|
||||
// B „faellt aus“: ein Port, auf dem niemand lauscht.
|
||||
await prisma.host.update({ where: { id: b.id }, data: { port: 1 } });
|
||||
const { plan } = await trockenlaufUndAusrollen(schluessel.id);
|
||||
expect(plan.ziele.find((z) => z.hostName === "ziel-b")?.fehler).toBeTruthy();
|
||||
expect(plan.ziele.find((z) => z.hostName === "ziel-a")?.fehler).toBeUndefined();
|
||||
|
||||
const [offen] = await widerrufAbschliessen([schluessel.id], AKTEUR);
|
||||
expect(offen?.status).toBe("OFFEN");
|
||||
expect(offen?.offen.map((o) => o.ziel)).toEqual(["deploy@ziel-b:~/.ssh/authorized_keys"]);
|
||||
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).status).toBe(
|
||||
"AKTIV",
|
||||
);
|
||||
// Was ging, ist trotzdem weg: der Private Key auf A.
|
||||
expect((await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout).not.toContain("id_ed25519_deploy");
|
||||
|
||||
// B ist zurueck: ein neuer Dry-Run erledigt den Rest.
|
||||
await prisma.host.update({ where: { id: b.id }, data: { port: zielB.port } });
|
||||
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
const [fertig] = await widerrufAbschliessen([schluessel.id], AKTEUR);
|
||||
expect(fertig?.status).toBe("WIDERRUFEN");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user