feat(ausrollen): Public Keys mit Trockenlauf und Backup ausrollen (#016)
Zuweisungen von Public Keys an Hosts oder Hostgruppen, ein Trockenlauf mit Diff je Ziel und das Ausrollen genau dieses Plans. Hat sich die Datei oder das Soll seit dem Trockenlauf geaendert, bricht das Ziel ab: es wird nie etwas anderes geschrieben, als jemand gesehen hat. Auf dem Host geschieht alles als Zielbenutzer, mit Sicherung, Temp- Datei, mv und 0700/0600; der Hash wird dort erneut geprueft. Der Deployment-Key gehoert immer zum Soll des Anmeldebenutzers. Integrationstests gegen echtes sshd, die Oberflaechenbausteine folgen mit der Schluesselseite aus #012. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { schluesselpaarErzeugen } from "@/lib/ssh/openssh";
|
||||
import { publicVerwendungAnlegen, publicVerwendungEntfernen } from "@/server/services/verwendungen";
|
||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||
import type { Plan } from "@/worker/ablaeufe/ausrollen";
|
||||
import { datenbankLeeren } from "../hilfen/db";
|
||||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||||
import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele";
|
||||
|
||||
/** Public Keys ausrollen gegen ein echtes sshd (#016). */
|
||||
|
||||
const AKTEUR = { id: "test", name: "Test" };
|
||||
let aufraeumen: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
await zielBereit(zielA);
|
||||
aufraeumen = masterKeyAnlegen();
|
||||
});
|
||||
afterAll(() => aufraeumen());
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
async function schluesselAnlegen(name: string) {
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: `sshkv:${name}` });
|
||||
return prisma.schluessel.create({
|
||||
data: {
|
||||
name,
|
||||
slug: name,
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: paar.publicKeyZeile,
|
||||
fingerprint: paar.fingerprint,
|
||||
kommentar: `sshkv:${name}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function trockenlauf(schluesselId: string) {
|
||||
const vorgang = await vorgangLaufen("TROCKENLAUF", {
|
||||
eingabe: {
|
||||
ziele: (
|
||||
await prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId },
|
||||
include: { hostgruppe: { include: { mitglieder: true } } },
|
||||
})
|
||||
).flatMap((v) =>
|
||||
v.hostId
|
||||
? [{ hostId: v.hostId, zielbenutzer: v.zielbenutzer }]
|
||||
: (v.hostgruppe?.mitglieder.map((m) => ({ hostId: m.hostId, zielbenutzer: v.zielbenutzer })) ?? []),
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(vorgang.status).toBe("ERLEDIGT");
|
||||
return { id: vorgang.id, plan: vorgang.plan as unknown as Plan };
|
||||
}
|
||||
|
||||
const datei = async (pfad: string) => (await befehlAlsRoot(zielA, `cat ${pfad} 2>/dev/null || true`)).stdout;
|
||||
|
||||
describe("Public Key ausrollen", () => {
|
||||
it("zeigt im Trockenlauf die Aenderung, schreibt erst beim Ausrollen", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && printf '# von Hand\\n' > /root/.ssh/authorized_keys");
|
||||
const schluessel = await schluesselAnlegen("backup");
|
||||
const verwendungId = await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: schluessel.id,
|
||||
hostId: host.id,
|
||||
zielbenutzer: "root",
|
||||
optionen: 'restrict,from="10.0.0.12"',
|
||||
},
|
||||
AKTEUR,
|
||||
);
|
||||
|
||||
const { id, plan } = await trockenlauf(schluessel.id);
|
||||
expect(plan.ziele).toHaveLength(1);
|
||||
expect(plan.ziele[0]).toMatchObject({
|
||||
zielbenutzer: "root",
|
||||
geaendert: true,
|
||||
pfad: "/root/.ssh/authorized_keys",
|
||||
});
|
||||
expect(plan.ziele[0]!.diff).toContainEqual({
|
||||
art: "hinzu",
|
||||
text: `restrict,from="10.0.0.12" ${schluessel.publicKey}`,
|
||||
});
|
||||
expect(await datei("/root/.ssh/authorized_keys")).toBe("# von Hand\n");
|
||||
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
const inhalt = await datei("/root/.ssh/authorized_keys");
|
||||
expect(inhalt).toBe(
|
||||
`# von Hand\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nrestrict,from="10.0.0.12" ${schluessel.publicKey}\n# END SSHKV MANAGED\n`,
|
||||
);
|
||||
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, "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");
|
||||
|
||||
// Derselbe Trockenlauf nicht zweimal.
|
||||
const nochmal = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(nochmal.fehler).toMatch(/bereits ausgeführt/);
|
||||
});
|
||||
|
||||
it("bricht ab, wenn die Datei seit dem Trockenlauf geaendert wurde", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const schluessel = await schluesselAnlegen("backup");
|
||||
await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
|
||||
AKTEUR,
|
||||
);
|
||||
const { id } = await trockenlauf(schluessel.id);
|
||||
await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && echo '# dazwischen' >> /root/.ssh/authorized_keys");
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(ausrollen.status).toBe("FEHLGESCHLAGEN");
|
||||
expect(ausrollen.fehler).toMatch(/seit dem Trockenlauf geändert/);
|
||||
expect(await datei("/root/.ssh/authorized_keys")).toBe("# dazwischen\n");
|
||||
});
|
||||
|
||||
it("bricht ab, wenn sich die Zuweisungen seit dem Trockenlauf geaendert haben", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const a = await schluesselAnlegen("a");
|
||||
const b = await schluesselAnlegen("b");
|
||||
await publicVerwendungAnlegen({ schluesselId: a.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR);
|
||||
const { id } = await trockenlauf(a.id);
|
||||
await publicVerwendungAnlegen({ schluesselId: b.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR);
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(ausrollen.fehler).toMatch(/Zuweisungen .* geändert/);
|
||||
});
|
||||
|
||||
it("entfernt einen Schluessel wieder, und der Deployment-Key bleibt", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const schluessel = await schluesselAnlegen("weg");
|
||||
const id = await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "sshkv" },
|
||||
AKTEUR,
|
||||
);
|
||||
const erster = await trockenlauf(schluessel.id);
|
||||
// Im Block des Dienstbenutzers steht der Deployment-Key schon; nur der neue kommt hinzu.
|
||||
expect(erster.plan.ziele[0]!.diff.filter((z) => z.art !== "kontext")).toEqual([
|
||||
{ art: "hinzu", text: schluessel.publicKey },
|
||||
]);
|
||||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: erster.id } });
|
||||
expect(await datei("/home/sshkv/.ssh/authorized_keys")).toContain(schluessel.publicKey);
|
||||
|
||||
await publicVerwendungEntfernen(id, AKTEUR);
|
||||
const zweiter = await trockenlauf(schluessel.id);
|
||||
expect(zweiter.plan.ziele[0]!.diff).toContainEqual({ art: "entfernt", text: schluessel.publicKey });
|
||||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: zweiter.id } });
|
||||
const inhalt = await datei("/home/sshkv/.ssh/authorized_keys");
|
||||
expect(inhalt).not.toContain(schluessel.publicKey);
|
||||
expect(inhalt).toContain("sshkv-deployment@");
|
||||
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id } })).zustand).toBe("ENTFERNT");
|
||||
});
|
||||
|
||||
it("rollt ueber eine Hostgruppe fuer einen gewoehnlichen Benutzer aus", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const gruppe = await prisma.hostgruppe.create({
|
||||
data: { name: "Test", mitglieder: { create: { hostId: host.id } } },
|
||||
});
|
||||
const schluessel = await schluesselAnlegen("gruppe");
|
||||
await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostgruppeId: gruppe.id, zielbenutzer: "test" },
|
||||
AKTEUR,
|
||||
);
|
||||
const { id } = await trockenlauf(schluessel.id);
|
||||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(await datei("/home/test/.ssh/authorized_keys")).toContain(schluessel.publicKey);
|
||||
const besitz = await befehlAlsRoot(
|
||||
zielA,
|
||||
"stat -c '%U %a' /home/test/.ssh /home/test/.ssh/authorized_keys",
|
||||
);
|
||||
expect(besitz.stdout).toBe("test 700\ntest 600\n");
|
||||
});
|
||||
|
||||
it("meldet einen fehlenden Benutzer im Trockenlauf je Ziel", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const schluessel = await schluesselAnlegen("niemand");
|
||||
await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "gibtsnicht" },
|
||||
AKTEUR,
|
||||
);
|
||||
const { plan } = await trockenlauf(schluessel.id);
|
||||
expect(plan.ziele[0]!.fehler).toMatch(/gibt es auf ziel-a nicht/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
import { rootPasswort, type Ziel } from "./ziele";
|
||||
|
||||
/** Gemeinsame Vorbereitung fuer Integrationstests: Master-Key, Host mit Bootstrap (#016). */
|
||||
|
||||
export function masterKeyAnlegen(): () => void {
|
||||
const verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||
const datei = path.join(verzeichnis, "master.key");
|
||||
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||
masterKeyVergessen();
|
||||
return () => rmSync(verzeichnis, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function vorgangLaufen(
|
||||
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
geheim?: string;
|
||||
angefordertVonId?: string;
|
||||
},
|
||||
) {
|
||||
const vorgang = await prisma.vorgang.create({
|
||||
data: {
|
||||
art,
|
||||
hostId: daten.hostId ?? null,
|
||||
eingabe: daten.eingabe ?? {},
|
||||
angefordertVonId: daten.angefordertVonId ?? null,
|
||||
},
|
||||
});
|
||||
await vorgangAusfuehren(vorgang.id, HANDLER, daten.geheim);
|
||||
return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } });
|
||||
}
|
||||
|
||||
/** Host anlegen, Host-Key vertrauen, Bootstrap per root-Passwort. */
|
||||
export async function eingerichteterHost(
|
||||
ziel: Ziel,
|
||||
name: string,
|
||||
anmeldeart: "DIENSTBENUTZER" | "ROOT" = "DIENSTBENUTZER",
|
||||
) {
|
||||
const host = await prisma.host.create({ data: { name, adresse: ziel.host, port: ziel.port, anmeldeart } });
|
||||
await vorgangLaufen("HOSTKEY_ABRUFEN", { hostId: host.id });
|
||||
await prisma.hostSchluessel.updateMany({ where: { hostId: host.id }, data: { status: "VERTRAUT" } });
|
||||
const bootstrap = await vorgangLaufen("BOOTSTRAP", {
|
||||
hostId: host.id,
|
||||
eingabe: { modus: "passwort", benutzer: "root" },
|
||||
geheim: verschluesseln(rootPasswort()),
|
||||
});
|
||||
if (bootstrap.status !== "ERLEDIGT") throw new Error(`Bootstrap fehlgeschlagen: ${bootstrap.fehler}`);
|
||||
return prisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||
}
|
||||
Reference in New Issue
Block a user