Merge branch 'phase/010-bootstrap'
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