Files
sshkeyvault/tests/integration/hilfen/ziele.ts
T
florian.wildeandClaude Opus 5.5 e89112a761 test: SSH-Testziele und Mock-OIDC fuer Integrationstests (#011)
Bootstrap, Host-Key-Pruefung und spaeteres Ausrollen brauchen echte
sshd-Gegenstellen. Zwei Debian-13-Container (ziel-a, ziel-b) in einem
gemeinsamen Netz bilden frische Hosts vor dem Bootstrap nach: root- und
Passwort-Login sind absichtlich offen, Host-Keys entstehen erst beim
Start, und `hostkeys-tauschen` erlaubt den Test "geaenderter Host-Key
wird abgelehnt" ohne Neuerstellen des Containers.

Die Testhilfen lesen dieselben Adress-Variablen wie die Compose-Datei
und lassen Tests mit Hinweis auf den Startbefehl fehlschlagen, statt
sie still zu ueberspringen - ein uebersprungener Integrationstest sieht
sonst wie ein bestandener aus.

Der Mock-OIDC-Anbieter ersetzt authentik in der Entwicklung (PLAN.md,
Entscheidung 7). Bei interaktivem Login uebernimmt er nur Claims aus dem
Formular, daher belegt eine eigene Login-Seite email, name und groups
vor und laesst die Gruppen frei waehlen.

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

186 lines
6.6 KiB
TypeScript
Raw 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.
/**
* 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<boolean> {
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<void> {
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<Befehlsergebnis> {
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<Befehlsergebnis> {
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<string> {
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<string> {
const { stdout } = await execFileAsync(
"docker",
["compose", "-f", COMPOSE_DATEI, "exec", "-T", ziel.dienst, "hostkeys-tauschen"],
{ cwd: PROJEKTWURZEL, timeout: 30_000 },
);
return stdout.trim();
}