diff --git a/.env.example b/.env.example index 8073893..9c3f894 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,15 @@ SSHKV_OIDC_GROUP_ADMIN=sshkv-admins SSHKV_OIDC_GROUP_OPERATOR=sshkv-operators SSHKV_OIDC_GROUP_READER=sshkv-readers +# Entwicklung ohne authentik: Mock-OIDC aus docker-compose.test.yml (pnpm test:ziele:up). +# Der Issuer haengt am Host-Header – genau 127.0.0.1 verwenden, nicht localhost. +# Client-ID und -Secret prueft der Mock nicht; beliebige Werte funktionieren. +# Im Login-Formular sind Claims (email, name, groups …) vorbelegt und frei aenderbar. +# SSHKV_OIDC_ISSUER=http://127.0.0.1:8090/sshkv +# SSHKV_OIDC_CLIENT_ID=sshkv-dev +# SSHKV_OIDC_CLIENT_SECRET=sshkv-dev-geheim +# SSHKV_OIDC_NAME=Mock-OIDC + # --- Notfall-Admin ------------------------------------------------------------- # Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet. SSHKV_BREAKGLASS_USER=notfall @@ -35,3 +44,14 @@ SSHKV_BREAKGLASS_PASSWORD= # --- Worker -------------------------------------------------------------------- # Zeitplan fuer den Abgleich aller Hosts (cron) SSHKV_SYNC_CRON=30 2 * * * + +# --- Integrationstests (#011) --------------------------------------------------- +# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen +# fuer die Port-Bindung (host:port, nur 127.0.0.1). +SSHKV_TEST_ZIEL_A=127.0.0.1:2221 +SSHKV_TEST_ZIEL_B=127.0.0.1:2222 +# Passwoerter der Testziele fuer root und den Benutzer "test" +SSHKV_TEST_ROOT_PASSWORT=sshkv-test +SSHKV_TEST_TEST_PASSWORT=sshkv-test +# Mock-OIDC (Port-Bindung) +SSHKV_TEST_OIDC=127.0.0.1:8090 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..0d97b77 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,97 @@ +# Nur fuer Tests und Entwicklung (#011): zwei SSH-Testziele und ein Mock-OIDC-Anbieter. +# Diese Datei niemals im Betrieb verwenden – die Ziele erlauben root-Login per Passwort. +# +# pnpm test:ziele:up # bauen, starten, auf healthy warten +# pnpm test:integration +# pnpm test:ziele:down +# +# Alle Ports nur auf 127.0.0.1. Die Adressen kommen aus denselben Variablen, die auch +# die Testhilfen lesen (tests/integration/hilfen/ziele.ts), damit beide nie auseinanderlaufen. + +name: sshkv-test + +x-ssh-ziel: &ssh-ziel + # Kein gemeinsames `image:` – sonst bauen beide Dienste parallel unter demselben Tag + # und BuildKit bricht mit "already exists" ab. Der Build-Cache wird trotzdem geteilt. + build: + context: tests/ssh-ziel + restart: unless-stopped + networks: [ziel-netz] + environment: + SSHKV_ROOT_PASSWORT: ${SSHKV_TEST_ROOT_PASSWORT:-sshkv-test} + SSHKV_TEST_PASSWORT: ${SSHKV_TEST_TEST_PASSWORT:-sshkv-test} + +services: + ziel-a: + <<: *ssh-ziel + hostname: ziel-a + ports: + - "${SSHKV_TEST_ZIEL_A:-127.0.0.1:2221}:22" + + ziel-b: + <<: *ssh-ziel + hostname: ziel-b + ports: + - "${SSHKV_TEST_ZIEL_B:-127.0.0.1:2222}:22" + + # Ersatz fuer authentik in der Entwicklung (PLAN.md, Entscheidung 7). + # Issuer: http://127.0.0.1:8090/sshkv – der Mock leitet den Issuer aus dem Host-Header + # ab, "localhost:8090" ergaebe also einen anderen Issuer. Client-ID und -Secret prueft + # er nicht; jeder Wert wird angenommen. + mock-oidc: + image: ghcr.io/navikt/mock-oauth2-server:6.0.5 + restart: unless-stopped + ports: + - "${SSHKV_TEST_OIDC:-127.0.0.1:8090}:8080" + volumes: + - ./tests/mock-oidc/login.html:/mock/login.html:ro + environment: + SERVER_PORT: "8080" + # Bei interactiveLogin uebernimmt der Mock nur die Claims aus dem Login-Formular. + # Die eigene Login-Seite belegt sie deshalb vor (email, name, groups …) und + # erlaubt freie Gruppenwahl. tokenCallbacks gelten fuer nicht-interaktive Grants + # (z. B. client_credentials in Tests). + JSON_CONFIG: >- + { + "interactiveLogin": true, + "httpServer": "NettyWrapper", + "loginPagePath": "/mock/login.html", + "tokenCallbacks": [ + { + "issuerId": "sshkv", + "tokenExpiry": 3600, + "requestMappings": [ + { + "requestParam": "scope", + "match": "*", + "claims": { + "sub": "admin", + "email": "admin@sshkv.test", + "email_verified": true, + "name": "Admin (Mock)", + "preferred_username": "admin", + "groups": ["sshkv-admins"] + } + } + ] + } + ] + } + healthcheck: + test: + [ + "CMD", + "wget", + "-q", + "-O", + "/dev/null", + "http://127.0.0.1:8080/sshkv/.well-known/openid-configuration", + ] + interval: 3s + timeout: 3s + start_period: 10s + retries: 20 + +networks: + # Gemeinsames Netz: ziel-a erreicht ziel-b per Dienstname (Host-zu-Host-Tests). + ziel-netz: diff --git a/docs/issues/011-test-ssh-container.md b/docs/issues/011-test-ssh-container.md index 3e38869..ed6d500 100644 --- a/docs/issues/011-test-ssh-container.md +++ b/docs/issues/011-test-ssh-container.md @@ -1,21 +1,49 @@ # #011 Test-SSH-Container und Integrationstests -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** test -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0,25 h ## Ziel Debian-Container mit sshd für Integrationstests (Bootstrap, Host-Key, später Ausrollen). ## Aufgaben -- [ ] Dockerfile `tests/ssh-ziel/` (Debian 13, sshd, sudo, Passwort für root/test) -- [ ] Compose-Datei für Tests, zwei Ziele (für Host-zu-Host) -- [ ] Vitest-Projekt `integration` +- [x] Dockerfile `tests/ssh-ziel/` (Debian 13, sshd, sudo, Passwort für root/test) +- [x] Compose-Datei für Tests, zwei Ziele (für Host-zu-Host) +- [x] Vitest-Projekt `integration` +- [x] Mock-OIDC-Anbieter für die Entwicklung (`mock-oidc` in `docker-compose.test.yml`) +- [x] Hilfen `tests/integration/hilfen/ziele.ts` und Rauchtest `tests/integration/ziele.test.ts` ## Akzeptanzkriterien -- [ ] `pnpm test:integration` läuft lokal +- [x] `pnpm test:integration` läuft lokal ## Abhängigkeiten #001 ## Notizen / Entscheidungen +- **Start:** `pnpm test:ziele:up` (= `docker compose -f docker-compose.test.yml up -d --build --wait`), + Stopp: `pnpm test:ziele:down`. Projektname `sshkv-test`. +- **Ports** nur auf 127.0.0.1: `ziel-a` 2221, `ziel-b` 2222, `mock-oidc` 8090. Überschreibbar über + `SSHKV_TEST_ZIEL_A`/`SSHKV_TEST_ZIEL_B`/`SSHKV_TEST_OIDC` (Format `host:port`) – dieselben + Variablen lesen Compose (Port-Bindung) und die Testhilfen, damit beide nie auseinanderlaufen. +- **Testziel** (`debian:trixie-slim`): root und `test` (Gruppe `sudo`) mit Passwort + `sshkv-test` (ENV `SSHKV_TEST_ROOT_PASSWORT`/`SSHKV_TEST_TEST_PASSWORT`, wird beim Start per + `chpasswd` gesetzt). `PermitRootLogin yes` und `PasswordAuthentication yes` absichtlich – + das Ziel bildet einen Host vor dem Bootstrap nach. +- **Host-Keys** werden nicht ins Image gebacken, sondern beim Start mit `ssh-keygen -A` erzeugt: + jeder neu erstellte Container hat eigene Keys. `hostkeys-tauschen` im Container erzeugt neue + Keys, schickt sshd (PID 1) ein SIGHUP und wartet, bis `ssh-keyscan` den neuen Key sieht. +- **Fingerprint-Vergleich** handelt nur `ssh-ed25519` aus, sonst könnte der gezeigte Key-Typ + wechseln und der Vergleich flattern. +- **Nicht laufende Ziele** lassen die Tests mit Hinweis auf den Startbefehl fehlschlagen + (`zielBereit` wartet bis 15 s auf das SSH-Banner) – kein stilles Überspringen. +- Beide Ziele bauen ohne gemeinsames `image:`-Tag: Mit gemeinsamem Tag bricht der parallele + Build mit „already exists“ ab. +- **Mock-OIDC:** `ghcr.io/navikt/mock-oauth2-server:6.0.5` (exakt gepinnt). + Issuer `http://127.0.0.1:8090/sshkv` – der Mock leitet ihn aus dem Host-Header ab, mit + `localhost` entstünde ein anderer Issuer. Client-ID und -Secret werden nicht geprüft. + Bei `interactiveLogin` übernimmt der Mock **nur** die Claims aus dem Login-Formular + (`tokenCallbacks` greifen dann nicht). Deshalb liefert `tests/mock-oidc/login.html` eine + eigene Login-Seite (`loginPagePath`), die `email`, `email_verified`, `name`, + `preferred_username` und `groups: ["sshkv-admins"]` vorbelegt; Knöpfe für Admin/Operator/ + Reader/ohne Gruppe, das JSON bleibt frei änderbar. diff --git a/package.json b/package.json index b7ddb15..b612a47 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "test": "vitest run", "test:watch": "vitest", "test:integration": "vitest run --config vitest.integration.config.mts", + "test:ziele:up": "docker compose -f docker-compose.test.yml up -d --build --wait", + "test:ziele:down": "docker compose -f docker-compose.test.yml down", "db:up": "docker compose -f docker-compose.dev.yml up -d --wait", "db:down": "docker compose -f docker-compose.dev.yml down", "db:migrate": "prisma migrate dev", diff --git a/tests/integration/hilfen/ziele.ts b/tests/integration/hilfen/ziele.ts new file mode 100644 index 0000000..87e1f84 --- /dev/null +++ b/tests/integration/hilfen/ziele.ts @@ -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 { + 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(); +} diff --git a/tests/integration/ziele.test.ts b/tests/integration/ziele.test.ts new file mode 100644 index 0000000..a10def4 --- /dev/null +++ b/tests/integration/ziele.test.ts @@ -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"); + }); +}); diff --git a/tests/mock-oidc/login.html b/tests/mock-oidc/login.html new file mode 100644 index 0000000..5a7ec66 --- /dev/null +++ b/tests/mock-oidc/login.html @@ -0,0 +1,124 @@ + + + + + + Mock-OIDC – Anmeldung (nur Entwicklung) + + + +
+

Mock-OIDC-Anmeldung

+

+ Nur für die Entwicklung (#011). Benutzername wird zu sub, die Claims unten landen im + ID-Token und unter /userinfo. +

+ +
+ + +
+ + + + +
+ + + +
+
+ + + diff --git a/tests/ssh-ziel/Dockerfile b/tests/ssh-ziel/Dockerfile new file mode 100644 index 0000000..6c46e4d --- /dev/null +++ b/tests/ssh-ziel/Dockerfile @@ -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"] diff --git a/tests/ssh-ziel/einstieg.sh b/tests/ssh-ziel/einstieg.sh new file mode 100644 index 0000000..6e4829e --- /dev/null +++ b/tests/ssh-ziel/einstieg.sh @@ -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 diff --git a/tests/ssh-ziel/hostkeys-tauschen.sh b/tests/ssh-ziel/hostkeys-tauschen.sh new file mode 100644 index 0000000..66d88ca --- /dev/null +++ b/tests/ssh-ziel/hostkeys-tauschen.sh @@ -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 diff --git a/tests/ssh-ziel/sshd-test.conf b/tests/ssh-ziel/sshd-test.conf new file mode 100644 index 0000000..232b5e4 --- /dev/null +++ b/tests/ssh-ziel/sshd-test.conf @@ -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