feat(bootstrap): Deployment-Key per Einmal-Passwort oder Befehl (#010)
Ein einziges POSIX-Skript fuer beide Wege: der Worker fuehrt es nach einer einmaligen Passwortanmeldung aus, und genau dasselbe Skript steht als Befehl zum Kopieren bereit. So gibt es keine zwei Wahrheiten darueber, was ein Bootstrap auf dem Host aendert. Das Passwort reist nur verschluesselt im Job und steht nie in Vorgang, Protokoll oder Log. Dateien im Home des Zielbenutzers liest und schreibt das Skript als dieser Benutzer: ein Symlink auf /etc/shadow fuehrt zum Abbruch statt zur Kopie (Integrationstest). sudoers wird erst nach visudo -c aktiv. Am Ende beweist eine Anmeldung per Deployment-Key, dass sshkv ohne Passwort weiterarbeiten kann. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bootstrapBefehl, bootstrapSkript, sshdBefundLesen } from "@/lib/ssh/bootstrap-skript";
|
||||
import { shellQuote } from "@/lib/shell";
|
||||
|
||||
/** Das Bootstrap-Skript als Text (#010); ausgefuehrt wird es im Integrationstest. */
|
||||
const KEY =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl sshkv-deployment@x";
|
||||
|
||||
describe("Bootstrap-Skript", () => {
|
||||
it("quotiert Shell-Werte sicher", () => {
|
||||
expect(shellQuote("a'b")).toBe(`'a'\\''b'`);
|
||||
expect(shellQuote("$(id)")).toBe(`'$(id)'`);
|
||||
expect(() => shellQuote("a\0b")).toThrow();
|
||||
});
|
||||
|
||||
it("weist ungueltige Benutzer und kaputte Keys ab", () => {
|
||||
expect(() =>
|
||||
bootstrapSkript({ benutzer: "root;id", dienstbenutzerAnlegen: false, deploymentKey: KEY }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
bootstrapSkript({
|
||||
benutzer: "sshkv",
|
||||
dienstbenutzerAnlegen: true,
|
||||
deploymentKey: "ssh-ed25519 kaputt\nid",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("setzt den Key quotiert ein und prueft sudoers vor dem Aktivieren", () => {
|
||||
const skript = bootstrapSkript({ benutzer: "sshkv", dienstbenutzerAnlegen: true, deploymentKey: KEY });
|
||||
expect(skript).toContain(`KEY='${KEY}'`);
|
||||
expect(skript).toContain("visudo -cf");
|
||||
expect(skript.indexOf("visudo -cf")).toBeLessThan(skript.indexOf('mv "$NEU_SUDO" /etc/sudoers.d/sshkv'));
|
||||
expect(skript).toContain("runuser -u");
|
||||
});
|
||||
|
||||
it("ergibt einen Befehl mit Here-Document, das nichts expandiert", () => {
|
||||
const befehl = bootstrapBefehl({ benutzer: "root", dienstbenutzerAnlegen: false, deploymentKey: KEY });
|
||||
expect(befehl.startsWith("sudo sh <<'SSHKV_BOOTSTRAP'\n")).toBe(true);
|
||||
expect(befehl.trimEnd().endsWith("SSHKV_BOOTSTRAP")).toBe(true);
|
||||
});
|
||||
|
||||
it("liest die sshd-Einstellungen aus der Ausgabe", () => {
|
||||
expect(
|
||||
sshdBefundLesen(
|
||||
"SSHKV-SSHD:permitrootlogin without-password\nSSHKV-SSHD:passwordauthentication no\nanderes\nSSHKV-SSHD:authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2\n",
|
||||
),
|
||||
).toEqual({
|
||||
permitRootLogin: "without-password",
|
||||
passwordAuthentication: "no",
|
||||
authorizedKeysFile: ".ssh/authorized_keys .ssh/authorized_keys2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { bootstrapBefehl } from "@/lib/ssh/bootstrap-skript";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { deploymentKeySicherstellen, masterKeyVergessen } from "@/worker/geheimnisse";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
import { hostVerbinden } from "@/worker/ssh/host";
|
||||
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||
import { datenbankLeeren } from "../hilfen/db";
|
||||
import { befehlAlsRoot, rootPasswort, testPasswort, zielB, zielBereit } from "./hilfen/ziele";
|
||||
|
||||
/** Bootstrap gegen ein echtes sshd (#010). Ziel B, damit #009 auf Ziel A ungestoert bleibt. */
|
||||
|
||||
let verzeichnis: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await zielBereit(zielB);
|
||||
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();
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(verzeichnis, { recursive: true, force: true }));
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
await deploymentKeySicherstellen();
|
||||
// Jeder Test beginnt ohne Dienstbenutzer und mit leerer authorized_keys fuer root.
|
||||
await befehlAlsRoot(
|
||||
zielB,
|
||||
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys*; true",
|
||||
);
|
||||
});
|
||||
|
||||
async function hostMitVertrautemKey(anmeldeart: "DIENSTBENUTZER" | "ROOT" = "DIENSTBENUTZER") {
|
||||
const host = await prisma.host.create({
|
||||
data: { name: "ziel-b", adresse: zielB.host, port: zielB.port, anmeldeart },
|
||||
});
|
||||
const abruf = await prisma.vorgang.create({ data: { art: "HOSTKEY_ABRUFEN", hostId: host.id } });
|
||||
await vorgangAusfuehren(abruf.id, HANDLER);
|
||||
await prisma.hostSchluessel.updateMany({ where: { hostId: host.id }, data: { status: "VERTRAUT" } });
|
||||
return host;
|
||||
}
|
||||
|
||||
async function bootstrap(hostId: string, benutzer: string, passwort: string) {
|
||||
const vorgang = await prisma.vorgang.create({
|
||||
data: { art: "BOOTSTRAP", hostId, eingabe: { modus: "passwort", benutzer } },
|
||||
});
|
||||
await vorgangAusfuehren(vorgang.id, HANDLER, verschluesseln(passwort));
|
||||
return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } });
|
||||
}
|
||||
|
||||
describe("Bootstrap per Passwort", () => {
|
||||
it("legt Dienstbenutzer, sudoers und Deployment-Key an und meldet sich danach per Key an", async () => {
|
||||
const host = await hostMitVertrautemKey();
|
||||
const vorgang = await bootstrap(host.id, "root", rootPasswort());
|
||||
expect(vorgang.fehler).toBeNull();
|
||||
expect(vorgang.status).toBe("ERLEDIGT");
|
||||
|
||||
const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||
expect(aktuell.bootstrapAm).not.toBeNull();
|
||||
expect(aktuell.sshdBefund).toMatchObject({ permitRootLogin: "yes", passwordAuthentication: "yes" });
|
||||
|
||||
const verbindung = await hostVerbinden(aktuell);
|
||||
expect((await befehlAusfuehren(verbindung, "id -un")).stdout.trim()).toBe("sshkv");
|
||||
expect((await befehlAusfuehren(verbindung, "sudo -n id -un")).stdout.trim()).toBe("root");
|
||||
const datei = await befehlAusfuehren(
|
||||
verbindung,
|
||||
"cat ~/.ssh/authorized_keys; stat -c %a ~/.ssh ~/.ssh/authorized_keys",
|
||||
);
|
||||
verbindung.end();
|
||||
expect(datei.stdout).toMatch(
|
||||
/^# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nssh-ed25519 \S+ sshkv-deployment@/,
|
||||
);
|
||||
expect(datei.stdout).toContain("# END SSHKV MANAGED\n700\n600");
|
||||
|
||||
const sudoers = await befehlAlsRoot(zielB, "stat -c %a /etc/sudoers.d/sshkv");
|
||||
expect(sudoers.stdout.trim()).toBe("440");
|
||||
});
|
||||
|
||||
it("ist wiederholbar, ohne den Key doppelt einzutragen, und behaelt fremde Zeilen", async () => {
|
||||
const host = await hostMitVertrautemKey("ROOT");
|
||||
await befehlAlsRoot(
|
||||
zielB,
|
||||
"mkdir -p /root/.ssh && echo 'ssh-ed25519 AAAAfremd admin@laptop' > /root/.ssh/authorized_keys",
|
||||
);
|
||||
expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT");
|
||||
expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT");
|
||||
const datei = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||||
expect(datei.match(/sshkv-deployment@/g)).toHaveLength(1);
|
||||
expect(datei.startsWith("ssh-ed25519 AAAAfremd admin@laptop\n")).toBe(true);
|
||||
const sicherungen = await befehlAlsRoot(zielB, "ls /root/.ssh | grep -c sshkv.bak");
|
||||
expect(Number(sicherungen.stdout.trim())).toBe(1);
|
||||
});
|
||||
|
||||
it("geht auch ueber einen Benutzer mit sudo-Passwort", async () => {
|
||||
const host = await hostMitVertrautemKey();
|
||||
const vorgang = await bootstrap(host.id, "test", testPasswort());
|
||||
expect(vorgang.fehler).toBeNull();
|
||||
});
|
||||
|
||||
it("speichert das Passwort nirgends", async () => {
|
||||
const host = await hostMitVertrautemKey();
|
||||
const passwort = rootPasswort();
|
||||
await bootstrap(host.id, "root", passwort);
|
||||
const alles = JSON.stringify(
|
||||
[await prisma.vorgang.findMany(), await prisma.protokoll.findMany(), await prisma.host.findMany()],
|
||||
(_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert),
|
||||
);
|
||||
expect(alles).not.toContain(passwort);
|
||||
});
|
||||
|
||||
it("scheitert mit falschem Passwort verstaendlich", async () => {
|
||||
const host = await hostMitVertrautemKey();
|
||||
const vorgang = await bootstrap(host.id, "root", "falsch");
|
||||
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
|
||||
expect(vorgang.fehler).toMatch(/Anmeldung wurde abgelehnt/);
|
||||
});
|
||||
|
||||
it("folgt keinem Symlink im Home des Dienstbenutzers", async () => {
|
||||
const host = await hostMitVertrautemKey();
|
||||
await befehlAlsRoot(
|
||||
zielB,
|
||||
"useradd -m -s /bin/sh sshkv && mkdir -p /home/sshkv/.ssh && ln -s /etc/shadow /home/sshkv/.ssh/authorized_keys && chown -R sshkv: /home/sshkv/.ssh",
|
||||
);
|
||||
const vorher = (await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout;
|
||||
const vorgang = await bootstrap(host.id, "root", rootPasswort());
|
||||
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
|
||||
expect((await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout).toBe(vorher);
|
||||
const kopie = await befehlAlsRoot(
|
||||
zielB,
|
||||
"cat /home/sshkv/.ssh/*.sshkv.* 2>/dev/null | grep -c root: || true",
|
||||
);
|
||||
expect(kopie.stdout.trim()).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bootstrap per Befehl", () => {
|
||||
it("funktioniert mit dem angezeigten Befehl und anschliessender Pruefung", async () => {
|
||||
const host = await hostMitVertrautemKey();
|
||||
const deployment = await prisma.schluessel.findFirstOrThrow({ where: { kategorie: "DEPLOYMENT" } });
|
||||
const befehl = bootstrapBefehl({
|
||||
benutzer: "sshkv",
|
||||
dienstbenutzerAnlegen: true,
|
||||
deploymentKey: deployment.publicKey,
|
||||
});
|
||||
const ausgabe = await befehlAlsRoot(zielB, befehl);
|
||||
expect(ausgabe.stdout).toContain("SSHKV-BOOTSTRAP-OK");
|
||||
|
||||
const pruefung = await prisma.vorgang.create({
|
||||
data: { art: "BOOTSTRAP", hostId: host.id, eingabe: { modus: "pruefen" } },
|
||||
});
|
||||
await vorgangAusfuehren(pruefung.id, HANDLER);
|
||||
const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: pruefung.id } });
|
||||
expect(fertig.fehler).toBeNull();
|
||||
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).bootstrapAm).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user