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:
2026-10-12 09:13:56 +02:00
co-authored by Claude Opus 5.5
parent 224386106b
commit e89112a761
11 changed files with 614 additions and 6 deletions
+185
View File
@@ -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();
}
+63
View File
@@ -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");
});
});
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mock-OIDC – Anmeldung (nur Entwicklung)</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #111418;
color: #e6e8eb;
margin: 0;
padding: 2rem 1rem;
}
main {
max-width: 34rem;
margin: 0 auto;
}
h1 {
font-size: 1.3rem;
}
p.hinweis {
color: #f0b429;
font-size: 0.9rem;
}
label {
display: block;
margin: 1rem 0 0.3rem;
font-weight: 600;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
background: #1b2027;
color: inherit;
border: 1px solid #39414d;
border-radius: 6px;
padding: 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.9rem;
}
.rollen {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
button {
background: #2a3240;
color: inherit;
border: 1px solid #39414d;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
button[type="submit"] {
background: #2f6fde;
border-color: #2f6fde;
margin-top: 1rem;
width: 100%;
padding: 0.6rem;
}
</style>
</head>
<body>
<main>
<h1>Mock-OIDC-Anmeldung</h1>
<p class="hinweis">
Nur für die Entwicklung (#011). Benutzername wird zu <code>sub</code>, die Claims unten landen im
ID-Token und unter <code>/userinfo</code>.
</p>
<!-- Ohne action postet das Formular an die /authorize-URL samt Query zurück –
genau das erwartet mock-oauth2-server. -->
<form method="post">
<label for="username">Benutzername (sub)</label>
<input id="username" name="username" required value="admin" autocomplete="off" />
<div class="rollen">
<button type="button" data-rolle="admin">Admin</button>
<button type="button" data-rolle="operator">Operator</button>
<button type="button" data-rolle="reader">Reader</button>
<button type="button" data-rolle="ohne">Ohne Gruppe</button>
</div>
<label for="claims">Claims (JSON, frei änderbar)</label>
<textarea id="claims" name="claims" rows="12"></textarea>
<button type="submit">Anmelden</button>
</form>
</main>
<script>
// Der Mock übernimmt bei interaktivem Login nur die Claims aus diesem Formular
// (tokenCallbacks greifen dann nicht). Deshalb wird hier ein vollständiger
// Standardsatz vorbelegt; Gruppen lassen sich frei ändern.
const gruppen = {
admin: ["sshkv-admins"],
operator: ["sshkv-operators"],
reader: ["sshkv-readers"],
ohne: [],
};
const benutzer = document.getElementById("username");
const claims = document.getElementById("claims");
function fuellen(rolle) {
const name = benutzer.value || rolle;
claims.value = JSON.stringify(
{
email: name + "@sshkv.test",
email_verified: true,
name: name.charAt(0).toUpperCase() + name.slice(1) + " (Mock)",
preferred_username: name,
groups: gruppen[rolle],
},
null,
2,
);
}
document.querySelectorAll("button[data-rolle]").forEach((knopf) => {
knopf.addEventListener("click", () => {
if (knopf.dataset.rolle !== "ohne") benutzer.value = knopf.dataset.rolle;
fuellen(knopf.dataset.rolle);
});
});
fuellen("admin");
</script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
# SSH-Testziel fuer Integrationstests (#011). NUR fuer Tests und Entwicklung!
#
# Absichtlich offen konfiguriert (root-Login und Passwort-Anmeldung erlaubt): Das Ziel
# bildet einen frisch installierten Host vor dem Bootstrap nach, auf dem sshkv sich
# einmalig per Passwort anmeldet und dann seinen Deployment-Key hinterlegt.
FROM debian:trixie-slim
# openssh-client: Host-zu-Host-Tests (Ziel A ruft ssh/ssh-keyscan nach Ziel B auf).
# procps: pgrep/ps fuer Healthcheck und Fehlersuche.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openssh-server openssh-client sudo passwd procps \
&& rm -rf /var/lib/apt/lists/* \
# Host-Keys aus dem Paket-Postinst entfernen: Sie wuerden sonst im Image-Layer
# landen und jeder Container haette dieselben Keys. Erzeugt wird beim Start.
&& rm -f /etc/ssh/ssh_host_* \
&& mkdir -p /run/sshd \
&& useradd --create-home --shell /bin/bash --groups sudo test
# Passwoerter werden beim Start aus der Umgebung gesetzt, damit Compose sie ohne
# Neubau aendern kann; die Build-Args liefern nur die Standardwerte.
ARG ROOT_PASSWORT=sshkv-test
ARG TEST_PASSWORT=sshkv-test
ENV SSHKV_ROOT_PASSWORT=${ROOT_PASSWORT} \
SSHKV_TEST_PASSWORT=${TEST_PASSWORT}
COPY sshd-test.conf /etc/ssh/sshd_config.d/10-sshkv-test.conf
COPY --chmod=0755 einstieg.sh /usr/local/sbin/einstieg
COPY --chmod=0755 hostkeys-tauschen.sh /usr/local/bin/hostkeys-tauschen
EXPOSE 22
# Prueft, ob sshd wirklich einen Host-Key ausliefert – ein laufender Prozess allein
# heisst nach einem Neuladen noch nicht, dass er schon Verbindungen annimmt.
HEALTHCHECK --interval=2s --timeout=3s --start-period=5s --retries=15 \
CMD ssh-keyscan -T 2 -t ed25519 127.0.0.1 2>/dev/null | grep -q ssh-ed25519
ENTRYPOINT ["/usr/local/sbin/einstieg"]
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Einstieg des SSH-Testziels (#011).
set -eu
echo "root:${SSHKV_ROOT_PASSWORT}" | chpasswd
echo "test:${SSHKV_TEST_PASSWORT}" | chpasswd
# Host-Keys erst hier erzeugen: Jeder neu erstellte Container bekommt eigene Keys,
# so wie ein frisch installierter Host. ssh-keygen -A legt nur fehlende Keys an,
# ein blosser Neustart desselben Containers behaelt sie also.
ssh-keygen -A
# Vordergrund (-D) als PID 1, Log nach stderr (-e) – landet in `docker compose logs`.
exec /usr/sbin/sshd -D -e
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Tauscht die Host-Keys des laufenden Testziels (#011). Damit laesst sich pruefen, dass
# sshkv einen geaenderten Host-Key ablehnt, ohne den Container neu zu erstellen.
set -eu
alt="$(ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub | awk '{print $2}')"
rm -f /etc/ssh/ssh_host_*
ssh-keygen -A >/dev/null
neu="$(ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub | awk '{print $2}')"
# SIGHUP laesst sshd sich neu ausfuehren und dabei die Host-Keys neu lesen. sshd laeuft
# als PID 1 (siehe einstieg.sh); bestehende Sitzungen bleiben dabei erhalten.
kill -HUP 1
# Erst zurueckkehren, wenn sshd wirklich den neuen Key ausliefert – sonst sieht ein
# Test direkt danach je nach Timing noch den alten oder gar keinen Key.
i=0
while [ "$i" -lt 50 ]; do
aktuell="$(ssh-keyscan -T 1 -t ed25519 127.0.0.1 2>/dev/null | ssh-keygen -lf - 2>/dev/null | awk '{print $2}')"
if [ "$aktuell" = "$neu" ]; then
echo "Host-Keys getauscht: $alt -> $neu"
exit 0
fi
i=$((i + 1))
sleep 0.2
done
echo "sshd liefert nach dem Tausch nicht den neuen Host-Key aus (erwartet $neu)." >&2
exit 1
+7
View File
@@ -0,0 +1,7 @@
# Bootstrap-Testziel (#011): absichtlich offen, siehe Dockerfile. Nie auf echten Hosts!
PermitRootLogin yes
PasswordAuthentication yes
KbdInteractiveAuthentication no
UseDNS no
# Fehlermeldungen im Container-Log sollen auch abgelehnte Host-Key-/Auth-Versuche zeigen.
LogLevel VERBOSE