Files
florian.wildeandClaude Opus 5.5 edc38d0c27 feat(ausrollen): Private Key mit ssh-config und known_hosts (#017)
Der Kern-Anwendungsfall im Worker: Private Key auf Host A, Public Key
auf Host B, dazu ein Block in ~/.ssh/config von A und der vertraute
Host-Key von B in known_hosts - danach meldet sich A per Alias bei B
an, ohne Rueckfrage zum Host-Key (Integrationstest gegen zwei echte
sshd).

Gleiches Muster wie bei Public Keys: Plan je Datei mit Hashes, dann
genau dieser Plan. Der Inhalt eines Private Keys steht nie im Plan
oder Protokoll, eine fremde Datei am Zielpfad wird nie ueberschrieben,
und von einem alten Private Key bleibt keine Sicherungskopie liegen.
Der config-Block steht am Anfang, weil ssh den ersten Treffer nimmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:49:28 +02:00

198 lines
8.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 /home/test/.ssh/authorized_keys*; rm -rf /root/.ssh; 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 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");
// 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/);
});
});