Merge branch 'phase/011-test-ssh-container'
This commit is contained in:
@@ -27,6 +27,15 @@ SSHKV_OIDC_GROUP_ADMIN=sshkv-admins
|
|||||||
SSHKV_OIDC_GROUP_OPERATOR=sshkv-operators
|
SSHKV_OIDC_GROUP_OPERATOR=sshkv-operators
|
||||||
SSHKV_OIDC_GROUP_READER=sshkv-readers
|
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 -------------------------------------------------------------
|
# --- Notfall-Admin -------------------------------------------------------------
|
||||||
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
|
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
|
||||||
SSHKV_BREAKGLASS_USER=notfall
|
SSHKV_BREAKGLASS_USER=notfall
|
||||||
@@ -35,3 +44,14 @@ SSHKV_BREAKGLASS_PASSWORD=
|
|||||||
# --- Worker --------------------------------------------------------------------
|
# --- Worker --------------------------------------------------------------------
|
||||||
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
||||||
SSHKV_SYNC_CRON=30 2 * * *
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -1,21 +1,49 @@
|
|||||||
# #011 Test-SSH-Container und Integrationstests
|
# #011 Test-SSH-Container und Integrationstests
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** test
|
**Labels:** test
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0,25 h
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Debian-Container mit sshd für Integrationstests (Bootstrap, Host-Key, später Ausrollen).
|
Debian-Container mit sshd für Integrationstests (Bootstrap, Host-Key, später Ausrollen).
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Dockerfile `tests/ssh-ziel/` (Debian 13, sshd, sudo, Passwort für root/test)
|
- [x] 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)
|
- [x] Compose-Datei für Tests, zwei Ziele (für Host-zu-Host)
|
||||||
- [ ] Vitest-Projekt `integration`
|
- [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
|
## Akzeptanzkriterien
|
||||||
- [ ] `pnpm test:integration` läuft lokal
|
- [x] `pnpm test:integration` läuft lokal
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
"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:up": "docker compose -f docker-compose.dev.yml up -d --wait",
|
||||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user