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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
befehlAlsRoot,
|
||||
hostKeyFingerprint,
|
||||
hostKeysTauschen,
|
||||
testPasswort,
|
||||
zielA,
|
||||
zielB,
|
||||
zielBereit,
|
||||
} from "./hilfen/ziele";
|
||||
|
||||
// Rauchtest fuer die Testumgebung selbst (#011): Spaetere Integrationstests (Bootstrap,
|
||||
// Host-Key-Pruefung, Ausrollen) verlassen sich darauf, dass diese Annahmen gelten.
|
||||
describe("SSH-Testziele", () => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
|
||||
});
|
||||
|
||||
it.each([zielA, zielB])("$dienst: Passwort-Login als root, whoami = root", async (ziel) => {
|
||||
const ergebnis = await befehlAlsRoot(ziel, "whoami");
|
||||
expect(ergebnis).toEqual({ stdout: "root\n", stderr: "", code: 0 });
|
||||
});
|
||||
|
||||
it("hat sudo installiert", async () => {
|
||||
const ergebnis = await befehlAlsRoot(zielA, "sudo -V");
|
||||
expect(ergebnis.code).toBe(0);
|
||||
expect(ergebnis.stdout).toMatch(/^Sudo version/m);
|
||||
});
|
||||
|
||||
it("erlaubt den Benutzer test per Passwort, mit sudo-Gruppe", async () => {
|
||||
const ergebnis = await befehlAlsRoot(zielB, "id -un; id -Gn", {
|
||||
benutzer: "test",
|
||||
passwort: testPasswort(),
|
||||
});
|
||||
expect(ergebnis.code).toBe(0);
|
||||
const [name, gruppen] = ergebnis.stdout.trim().split("\n");
|
||||
expect(name).toBe("test");
|
||||
expect(gruppen?.split(" ")).toContain("sudo");
|
||||
});
|
||||
|
||||
it("lehnt ein falsches Passwort ab", async () => {
|
||||
await expect(befehlAlsRoot(zielA, "true", { passwort: "falsch" })).rejects.toThrow(/authentication/i);
|
||||
});
|
||||
|
||||
it("Ziel A erreicht Ziel B über das Docker-Netz", async () => {
|
||||
const ergebnis = await befehlAlsRoot(zielA, "ssh-keyscan -T 5 -t ed25519 ziel-b");
|
||||
expect(ergebnis.code).toBe(0);
|
||||
expect(ergebnis.stdout).toMatch(/^ziel-b ssh-ed25519 AAAA\S+$/m);
|
||||
});
|
||||
|
||||
it("Host-Key-Tausch ändert den Fingerprint", async () => {
|
||||
const vorher = await hostKeyFingerprint(zielB);
|
||||
expect(vorher).toMatch(/^SHA256:[A-Za-z0-9+/]{43}$/);
|
||||
|
||||
await hostKeysTauschen(zielB);
|
||||
|
||||
const nachher = await hostKeyFingerprint(zielB);
|
||||
expect(nachher).toMatch(/^SHA256:[A-Za-z0-9+/]{43}$/);
|
||||
expect(nachher).not.toBe(vorher);
|
||||
// Der Container bleibt nach dem Tausch benutzbar.
|
||||
expect((await befehlAlsRoot(zielB, "whoami")).stdout).toBe("root\n");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user