Merge branch 'phase/017-private-key-ausrollen'

# Conflicts:
#	src/worker/handler.ts
This commit is contained in:
2026-10-12 10:54:28 +02:00
13 changed files with 1098 additions and 54 deletions
+4 -2
View File
@@ -24,7 +24,7 @@ beforeEach(async () => {
await deploymentKeySicherstellen();
await befehlAlsRoot(
zielA,
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys* /home/test/.ssh/authorized_keys*; true",
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /home/test/.ssh/authorized_keys*; rm -rf /root/.ssh; true",
);
});
@@ -101,7 +101,9 @@ describe("Public Key ausrollen", () => {
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: verwendungId } })).zustand).toBe(
"AUSGEROLLT",
);
expect((await befehlAlsRoot(zielA, "ls /root/.ssh | grep -c sshkv.bak")).stdout.trim()).toBe("1");
expect(
(await befehlAlsRoot(zielA, "ls /root/.ssh | grep -c authorized_keys.sshkv.bak")).stdout.trim(),
).toBe("1");
expect((await befehlAlsRoot(zielA, "stat -c %a /root/.ssh/authorized_keys")).stdout.trim()).toBe("600");
const eintrag = await prisma.protokoll.findFirst({ where: { aktion: "authorized_keys.geschrieben" } });
expect(eintrag?.diff).toContain("+ restrict");
+1 -1
View File
@@ -21,7 +21,7 @@ export function masterKeyAnlegen(): () => void {
}
export async function vorgangLaufen(
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN",
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
daten: {
hostId?: string;
eingabe?: object;
+182
View File
@@ -0,0 +1,182 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import {
publicVerwendungAnlegen,
privateVerwendungAnlegen,
privateVerwendungEntfernen,
} from "@/server/services/verwendungen";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import type { Plan } from "@/worker/ablaeufe/ausrollen";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "../hilfen/db";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
/**
* Kern-Anwendungsfall (#017): Schluesselpaar erzeugen, Private Key auf A, Public Key auf B –
* danach meldet sich A per `ssh <alias>` bei B an, ohne Rueckfrage zum Host-Key.
*/
const AKTEUR = { id: "test", name: "Test" };
let aufraeumen: () => void;
beforeAll(async () => {
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
aufraeumen = masterKeyAnlegen();
});
afterAll(() => aufraeumen());
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)]);
});
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,
},
});
}
async function trockenlaufUndAusrollen(schluesselId: string) {
const ziele = [
...(await zieleVonVerwendungen({ schluesselId })),
...(await zieleVonPrivatVerwendungen({ schluesselId })),
];
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele } });
expect(trockenlauf.status).toBe("ERLEDIGT");
const plan = trockenlauf.plan as unknown as Plan;
const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
return { plan, ausrollen };
}
describe("Host-zu-Host", () => {
it("A meldet sich nach dem Ausrollen per Alias bei B an", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const schluessel = await dienstSchluessel("backup-a-b");
await privateVerwendungAnlegen(
{
schluesselId: schluessel.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_ed25519_backup-a-b",
// Im Docker-Netz erreicht A das Ziel B unter seinem Dienstnamen auf Port 22.
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen(
{
schluesselId: schluessel.id,
hostId: b.id,
zielbenutzer: "root",
optionen: "no-pty,no-agent-forwarding",
},
AKTEUR,
);
const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
const privat = plan.ziele.find((z) => z.art === "privat")!;
expect(privat.dateien?.map((d) => [d.art, d.aktion])).toEqual([
["privateKey", "anlegen"],
["publicKey", "anlegen"],
["config", "anlegen"],
["knownHosts", "anlegen"],
]);
// Der Plan traegt keinen Schluesselinhalt.
expect(JSON.stringify(plan)).not.toContain("PRIVATE KEY");
expect(ausrollen.fehler).toBeNull();
const rechte = await befehlAlsRoot(
zielA,
"stat -c '%a' /root/.ssh/id_ed25519_backup-a-b /root/.ssh/id_ed25519_backup-a-b.pub /root/.ssh/config",
);
expect(rechte.stdout).toBe("600\n644\n600\n");
const test = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(test.stderr).not.toMatch(/Host key verification failed/);
expect(test.stdout.trim()).toBe("root");
expect(await prisma.privateVerwendung.count({ where: { zustand: "AUSGEROLLT" } })).toBe(1);
// Verbindungstest als Vorgang (#020).
const verwendung = await prisma.privateVerwendung.findFirstOrThrow();
const test2 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } });
expect(test2.fehler).toBeNull();
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).zuletztVerwendetAm,
).not.toBeNull();
// Ohne Public Key auf B scheitert der Test verstaendlich.
await befehlAlsRoot(zielB, "rm -f /root/.ssh/authorized_keys");
const test3 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } });
expect(test3.fehler).toMatch(/Permission denied/);
const protokoll = await prisma.protokoll.findFirst({ where: { aktion: "privatekey.ausgerollt" } });
expect(protokoll?.diff).toContain("anlegen /root/.ssh/id_ed25519_backup-a-b");
expect(JSON.stringify(protokoll?.details)).not.toContain("PRIVATE");
});
it("ueberschreibt keinen fremden Schluessel am Zielpfad", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const schluessel = await dienstSchluessel("fremd");
await befehlAlsRoot(
zielA,
"mkdir -p /root/.ssh && echo fremd > /root/.ssh/id_fremd && chmod 600 /root/.ssh/id_fremd",
);
await privateVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: a.id, zielbenutzer: "root", pfad: "~/.ssh/id_fremd" },
AKTEUR,
);
const { plan } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele[0]?.fehler).toMatch(/überschreibt keine fremden Schlüssel/);
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/id_fremd")).stdout).toBe("fremd\n");
});
it("entfernt Schluessel, config- und known_hosts-Eintrag beim Widerruf", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const schluessel = await dienstSchluessel("weg");
await befehlAlsRoot(
zielA,
"mkdir -p /root/.ssh && printf 'Host *\\n ServerAliveInterval 30\\n' > /root/.ssh/config",
);
const id = await privateVerwendungAnlegen(
{
schluesselId: schluessel.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_weg",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await trockenlaufUndAusrollen(schluessel.id);
expect((await befehlAlsRoot(zielA, "head -n 2 /root/.ssh/config")).stdout).toBe(
"# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nHost zu-b\n",
);
await privateVerwendungEntfernen(id, AKTEUR);
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const rest = await befehlAlsRoot(zielA, "ls /root/.ssh; cat /root/.ssh/config");
expect(rest.stdout).not.toContain("id_weg");
expect(rest.stdout).toContain("Host *\n ServerAliveInterval 30\n");
expect(rest.stdout).not.toContain("zu-b");
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/known_hosts")).stdout).not.toContain("ssh-ed25519");
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { knownHostsZeile, SshConfigFehler, sshConfigZeilen } from "@/lib/ssh/ssh-config";
import { TextblockFehler, textblockSetzen } from "@/lib/ssh/textblock";
/** Verwaltete Bloecke in ~/.ssh/config und known_hosts (#017). */
describe("Textblock", () => {
it("setzt den Block an den Anfang und laesst den Rest stehen", () => {
const vorher = "Host *\n ServerAliveInterval 30\n";
const ergebnis = textblockSetzen(vorher, ["Host a", " HostName b"], "anfang");
expect(ergebnis.inhalt).toBe(
"# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nHost a\n HostName b\n# END SSHKV MANAGED\n" +
vorher,
);
expect(textblockSetzen(ergebnis.inhalt, ["Host a", " HostName b"], "anfang").geaendert).toBe(false);
});
it("entfernt den Block bei leerem Soll", () => {
const mit = textblockSetzen("x\n", ["a"], "ende").inhalt;
expect(textblockSetzen(mit, [], "ende").inhalt).toBe("x\n");
expect(textblockSetzen("x\n", [], "ende").geaendert).toBe(false);
});
it("verweigert beschaedigte Bloecke und Zeilenumbrueche", () => {
expect(() => textblockSetzen("# BEGIN SSHKV MANAGED\n", ["a"])).toThrow(TextblockFehler);
expect(() => textblockSetzen("", ["a\nb"])).toThrow(TextblockFehler);
});
});
describe("ssh-config und known_hosts", () => {
it("schreibt einen Host-Eintrag mit IdentitiesOnly", () => {
expect(
sshConfigZeilen([
{
alias: "deploy-app01",
hostName: "10.0.0.5",
benutzer: "deploy",
port: 2222,
identityFile: "~/.ssh/id_ed25519_x",
},
]),
).toEqual([
"Host deploy-app01",
" HostName 10.0.0.5",
" User deploy",
" Port 2222",
" IdentityFile ~/.ssh/id_ed25519_x",
" IdentitiesOnly yes",
]);
});
it.each([
{ alias: "a b" },
{ alias: "a\nHost *" },
{ hostName: "x;id" },
{ benutzer: "Root" },
{ port: 0 },
{ identityFile: "~/../etc/passwd" },
])("weist ab: %o", (fehler) => {
const basis = { alias: "a", hostName: "h", benutzer: "u", port: 22, identityFile: "~/.ssh/k" };
expect(() => sshConfigZeilen([{ ...basis, ...fehler }])).toThrow(SshConfigFehler);
});
it("schreibt known_hosts mit Port in Klammern", () => {
expect(knownHostsZeile("ziel-b", 22, "ssh-ed25519", "AAAA")).toBe("ziel-b ssh-ed25519 AAAA");
expect(knownHostsZeile("10.0.0.5", 2222, "ssh-ed25519", "AAAA")).toBe("[10.0.0.5]:2222 ssh-ed25519 AAAA");
});
});