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>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user