/** * Hilfen fuer Integrationstests gegen die SSH-Testziele aus docker-compose.test.yml (#011). * * Die Adressen kommen aus denselben Variablen wie in der Compose-Datei, damit ein * umgelegter Port nur an einer Stelle geaendert werden muss. */ import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { connect as tcpVerbinden } from "node:net"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { Client } from "ssh2"; const execFileAsync = promisify(execFile); const PROJEKTWURZEL = fileURLToPath(new URL("../../../", import.meta.url)); export const COMPOSE_DATEI = "docker-compose.test.yml"; export const STARTBEFEHL = `docker compose -f ${COMPOSE_DATEI} up -d --build --wait`; export interface Ziel { /** Compose-Dienstname, zugleich Hostname im Docker-Netz. */ dienst: "ziel-a" | "ziel-b"; host: string; port: number; } function adresseLesen(variable: string, standard: string): { host: string; port: number } { const wert = process.env[variable]?.trim() || standard; const trenner = wert.lastIndexOf(":"); const port = Number(wert.slice(trenner + 1)); if (trenner <= 0 || !Number.isInteger(port) || port <= 0) { throw new Error(`${variable}="${wert}" ist keine gültige Adresse (erwartet host:port).`); } return { host: wert.slice(0, trenner), port }; } export const zielA: Ziel = { dienst: "ziel-a", ...adresseLesen("SSHKV_TEST_ZIEL_A", "127.0.0.1:2221"), }; export const zielB: Ziel = { dienst: "ziel-b", ...adresseLesen("SSHKV_TEST_ZIEL_B", "127.0.0.1:2222"), }; export const rootPasswort = (): string => process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test"; export const testPasswort = (): string => process.env["SSHKV_TEST_TEST_PASSWORT"] || "sshkv-test"; const beschreibung = (ziel: Ziel) => `${ziel.dienst} (${ziel.host}:${ziel.port})`; /** Ein Versuch: verbindet per TCP und wartet auf das SSH-Banner. */ function bannerLesen(ziel: Ziel, zeitlimitMs: number): Promise { return new Promise((aufloesen) => { const sock = tcpVerbinden({ host: ziel.host, port: ziel.port }); const ende = (ok: boolean) => { sock.destroy(); aufloesen(ok); }; sock.setTimeout(zeitlimitMs, () => ende(false)); sock.once("error", () => ende(false)); // Ein offener Port allein reicht nicht: Docker nimmt Verbindungen schon an, bevor // sshd im Container lauscht. Erst das Banner zeigt, dass sshd wirklich antwortet. sock.once("data", (daten) => ende(daten.toString("latin1").startsWith("SSH-"))); }); } /** * Wartet, bis das Ziel ein SSH-Banner liefert. Schlaegt mit einem Hinweis auf den * Startbefehl fehl, statt Tests still zu ueberspringen – ein uebersprungener * Integrationstest sieht in CI sonst aus wie ein bestandener. */ export async function zielBereit(ziel: Ziel, gesamtMs = 15_000): Promise { const frist = Date.now() + gesamtMs; while (Date.now() < frist) { if (await bannerLesen(ziel, 2_000)) return; await new Promise((r) => setTimeout(r, 300)); } throw new Error( `SSH-Testziel ${beschreibung(ziel)} antwortet nicht. ` + `\`${STARTBEFEHL}\` vergessen? (oder pnpm test:ziele:up)`, ); } export interface Befehlsergebnis { stdout: string; stderr: string; /** Exit-Code; null, wenn der Befehl per Signal endete. */ code: number | null; } export interface Anmeldung { benutzer?: string; passwort?: string; } /** OpenSSH-Darstellung eines Host-Key-Fingerprints (`SHA256:…`, ohne Padding). */ export function fingerprintAus(schluessel: Buffer): string { return "SHA256:" + createHash("sha256").update(schluessel).digest("base64").replace(/=+$/, ""); } /** * Baut eine Verbindung auf und meldet den ed25519-Host-Key des Ziels. * * Testhilfe: Der Host-Key wird hier bewusst nicht gegen einen gespeicherten Wert * geprueft, sondern nur gemeldet – die Tests vergleichen selbst. Die strikte Pruefung * fuer echte Hosts lebt im Worker. */ function verbinden(ziel: Ziel, anmeldung: Anmeldung): Promise<{ client: Client; fingerprint: string }> { return new Promise((aufloesen, ablehnen) => { const client = new Client(); let fingerprint = ""; client .once("ready", () => aufloesen({ client, fingerprint })) .once("error", (fehler) => ablehnen(new Error(`SSH zu ${beschreibung(ziel)} fehlgeschlagen: ${fehler.message}`)), ) .connect({ host: ziel.host, port: ziel.port, username: anmeldung.benutzer ?? "root", password: anmeldung.passwort ?? rootPasswort(), readyTimeout: 10_000, // Nur ed25519 aushandeln: Sonst koennte der Server je nach Aushandlung mal den // ECDSA-, mal den ed25519-Key zeigen, und Fingerprint-Vergleiche wuerden flattern. algorithms: { serverHostKey: ["ssh-ed25519"] }, hostVerifier: (schluessel: Buffer) => { fingerprint = fingerprintAus(schluessel); return true; }, }); }); } function ausfuehren(client: Client, befehl: string): Promise { return new Promise((aufloesen, ablehnen) => { client.exec(befehl, (fehler, kanal) => { if (fehler) return ablehnen(fehler); const out: Buffer[] = []; const err: Buffer[] = []; kanal.on("data", (d: Buffer) => out.push(d)); kanal.stderr.on("data", (d: Buffer) => err.push(d)); kanal.once("close", (code: number | null) => aufloesen({ stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8"), code: code ?? null, }), ); }); }); } /** Fuehrt einen Befehl per Passwort-Login aus (Standard: root) und trennt danach. */ export async function befehlAlsRoot( ziel: Ziel, befehl: string, anmeldung: Anmeldung = {}, ): Promise { const { client } = await verbinden(ziel, anmeldung); try { return await ausfuehren(client, befehl); } finally { client.end(); } } /** Liest den aktuell ausgelieferten ed25519-Host-Key-Fingerprint (`SHA256:…`). */ export async function hostKeyFingerprint(ziel: Ziel): Promise { const { client, fingerprint } = await verbinden(ziel, {}); client.end(); return fingerprint; } /** * Tauscht die Host-Keys im laufenden Container (Skript `hostkeys-tauschen`). Kehrt erst * zurueck, wenn sshd die neuen Keys ausliefert – das Skript wartet selbst darauf. */ export async function hostKeysTauschen(ziel: Ziel): Promise { const { stdout } = await execFileAsync( "docker", ["compose", "-f", COMPOSE_DATEI, "exec", "-T", ziel.dienst, "hostkeys-tauschen"], { cwd: PROJEKTWURZEL, timeout: 30_000 }, ); return stdout.trim(); }