diff --git a/src/app/api/vorgaenge/[id]/route.ts b/src/app/api/vorgaenge/[id]/route.ts new file mode 100644 index 0000000..eed5687 --- /dev/null +++ b/src/app/api/vorgaenge/[id]/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { vorgangLesen } from "@/server/vorgaenge/vorgaenge"; + +/** + * Status eines Vorgangs fuer die Fortschrittsanzeige (#009). Liefert nur, was + * `vorgangLesen` freigibt – keine Job-Interna, keine Geheimnisse. + */ +export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) { + try { + await rechtErforderlich("lesen"); + } catch (fehler) { + if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 }); + throw fehler; + } + const vorgang = await vorgangLesen((await params).id); + if (!vorgang) return NextResponse.json({ fehler: "Unbekannter Vorgang." }, { status: 404 }); + return NextResponse.json(vorgang, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/src/components/vorgang/VorgangsKnopf.tsx b/src/components/vorgang/VorgangsKnopf.tsx new file mode 100644 index 0000000..a73421a --- /dev/null +++ b/src/components/vorgang/VorgangsKnopf.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState, useTransition, type ReactNode } from "react"; +import { Button, type ButtonVariante } from "@/components/ui/Button"; + +/** + * Startet einen Vorgang im Worker und zeigt seinen Fortschritt (#009). Fragt den Status + * jede Sekunde ab und laedt nach dem Ende die Seite neu, damit Server Components die neuen + * Daten zeigen. + */ +type Antwort = { readonly vorgangId?: string; readonly fehler?: string }; +type Status = { status: string; fehler: string | null }; + +const LAUFEND = new Set(["WARTEND", "LAEUFT"]); + +export function VorgangsKnopf({ + aktion, + beschriftung, + variante = "sekundaer", + icon, + klein, +}: { + aktion: () => Promise; + beschriftung: string; + variante?: ButtonVariante; + icon?: ReactNode; + klein?: boolean; +}) { + const router = useRouter(); + const [vorgangId, setVorgangId] = useState(); + const [status, setStatus] = useState(); + const [fehler, setFehler] = useState(); + const [startet, starten] = useTransition(); + const abbrechen = useRef(false); + + useEffect(() => { + if (!vorgangId) return; + abbrechen.current = false; + const abfragen = async () => { + while (!abbrechen.current) { + const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" }); + if (!antwort.ok) { + setFehler("Status des Vorgangs nicht abrufbar."); + return; + } + const neu = (await antwort.json()) as Status; + setStatus(neu); + if (!LAUFEND.has(neu.status)) { + router.refresh(); + return; + } + await new Promise((weiter) => setTimeout(weiter, 1000)); + } + }; + void abfragen(); + return () => { + abbrechen.current = true; + }; + }, [vorgangId, router]); + + const laeuft = + startet || (status !== undefined && LAUFEND.has(status.status)) || (vorgangId !== undefined && !status); + + return ( +
+ +

+ {fehler && {fehler}} + {!fehler && status?.status === "FEHLGESCHLAGEN" && ( + {status.fehler} + )} + {!fehler && status?.status === "ERLEDIGT" && Erledigt.} +

+
+ ); +} diff --git a/src/server/actions/hostkeys.ts b/src/server/actions/hostkeys.ts new file mode 100644 index 0000000..aa89851 --- /dev/null +++ b/src/server/actions/hostkeys.ts @@ -0,0 +1,68 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "@/lib/validierung"; +import { bestaetigungFrisch, rechtErforderlich, RechteFehler } from "@/server/auth/sitzungspflicht"; +import { HostKeyEntscheidungFehler, hostKeyEntscheiden } from "@/server/services/hostkeys"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; + +/** Aktionen rund um Host-Keys und Erreichbarkeit (#009). */ + +export type VorgangsAntwort = { readonly vorgangId?: string; readonly fehler?: string }; +export type EntscheidungsAntwort = { + readonly fehler?: string; + readonly bestaetigen?: string; + readonly erledigt?: boolean; +}; + +const hostIdSchema = z.string().min(1).max(64); + +async function einreihen( + art: "HOSTKEY_ABRUFEN" | "ERREICHBARKEIT", + hostId: string, +): Promise { + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const id = hostIdSchema.parse(hostId); + return { vorgangId: await vorgangEinreihen({ art, angefordertVonId: benutzer.id, hostId: id }) }; + } catch (fehler) { + if (fehler instanceof RechteFehler) return { fehler: fehler.message }; + throw fehler; + } +} + +export async function hostKeyAbrufenAktion(hostId: string): Promise { + return einreihen("HOSTKEY_ABRUFEN", hostId); +} + +export async function erreichbarkeitPruefenAktion(hostId: string): Promise { + return einreihen("ERREICHBARKEIT", hostId); +} + +const entscheidungSchema = z.object({ + hostId: hostIdSchema, + hostSchluesselId: z.string().min(1).max(64), + vertrauen: z.boolean(), + ersetzen: z.boolean().default(false), +}); + +export async function hostKeyEntscheidenAktion( + eingabe: z.input, +): Promise { + try { + const { benutzer } = await rechtErforderlich("hostKeyEntscheiden"); + const daten = entscheidungSchema.parse(eingabe); + // Einen vertrauten Key zu ersetzen ist der Moment eines moeglichen Angriffs – nur mit + // frischer Bestaetigung der eigenen Anmeldung. + if (daten.ersetzen && !bestaetigungFrisch(benutzer.bestaetigtAm)) { + return { bestaetigen: `/bestaetigen?ziel=${encodeURIComponent(`/hosts/${daten.hostId}`)}` }; + } + await hostKeyEntscheiden(daten, { id: benutzer.id, name: benutzer.name }); + revalidatePath(`/hosts/${daten.hostId}`); + return { erledigt: true }; + } catch (fehler) { + if (fehler instanceof RechteFehler || fehler instanceof HostKeyEntscheidungFehler) + return { fehler: fehler.message }; + throw fehler; + } +} diff --git a/src/server/services/hostkeys.ts b/src/server/services/hostkeys.ts new file mode 100644 index 0000000..6f6a11f --- /dev/null +++ b/src/server/services/hostkeys.ts @@ -0,0 +1,90 @@ +import { prisma } from "@/lib/db"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Entscheidung eines Menschen ueber einen Host-Key (#009, TOFU). + * + * Einen vertrauten Key zu ersetzen ist der Moment, in dem ein Angreifer mit einer + * abgefangenen Verbindung gewinnen wuerde. Deshalb verlangt das ein ausdrueckliches + * `ersetzen` (die Oberflaeche fragt nach und verlangt eine frische Bestaetigung). + */ +export class HostKeyEntscheidungFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "HostKeyEntscheidungFehler"; + } +} + +export type Entscheidung = { + readonly hostSchluesselId: string; + readonly vertrauen: boolean; + readonly ersetzen?: boolean; +}; + +export async function hostKeyEntscheiden( + eingabe: Entscheidung, + akteur: { readonly id: string; readonly name: string }, +): Promise { + await prisma.$transaction(async (tx) => { + const key = await tx.hostSchluessel.findUnique({ + where: { id: eingabe.hostSchluesselId }, + include: { host: true }, + }); + if (!key) throw new HostKeyEntscheidungFehler("Diesen Host-Key gibt es nicht."); + if (key.status !== "AUSSTEHEND") { + throw new HostKeyEntscheidungFehler("Über diesen Host-Key wurde bereits entschieden."); + } + + if (!eingabe.vertrauen) { + await tx.hostSchluessel.update({ + where: { id: key.id }, + data: { status: "ABGELEHNT", entschiedenVon: akteur.name, entschiedenAm: new Date() }, + }); + await protokollieren( + { + akteur, + aktion: "hostkey.abgelehnt", + ergebnis: "ERFOLG", + hostName: key.host.name, + fingerprint: key.fingerprint, + }, + tx, + ); + return; + } + + const bisher = await tx.hostSchluessel.findFirst({ + where: { hostId: key.hostId, algorithmus: key.algorithmus, status: "VERTRAUT" }, + }); + if (bisher && !eingabe.ersetzen) { + throw new HostKeyEntscheidungFehler( + `Für ${key.algorithmus} ist bereits ein anderer Host-Key vertraut. Ersetzen muss ausdrücklich bestätigt werden.`, + ); + } + if (bisher) { + await tx.hostSchluessel.update({ where: { id: bisher.id }, data: { status: "ERSETZT" } }); + } + await tx.hostSchluessel.update({ + where: { id: key.id }, + data: { status: "VERTRAUT", entschiedenVon: akteur.name, entschiedenAm: new Date() }, + }); + // Eine offene Abweichung zu genau diesem Key ist damit erledigt. + await tx.abweichung.updateMany({ + where: { hostId: key.hostId, art: "HOSTKEY_GEAENDERT", fingerprint: key.fingerprint, behobenAm: null }, + data: { behobenAm: new Date() }, + }); + await protokollieren( + { + akteur, + aktion: bisher ? "hostkey.ersetzt" : "hostkey.bestaetigt", + ergebnis: "ERFOLG", + hostName: key.host.name, + fingerprint: key.fingerprint, + details: bisher + ? { bisher: bisher.fingerprint, algorithmus: key.algorithmus } + : { algorithmus: key.algorithmus }, + }, + tx, + ); + }); +} diff --git a/src/worker/ablaeufe/hostkey.ts b/src/worker/ablaeufe/hostkey.ts new file mode 100644 index 0000000..af1b0ac --- /dev/null +++ b/src/worker/ablaeufe/hostkey.ts @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/db"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren"; +import { hostKeyFesthalten, hostVerbinden } from "@/worker/ssh/host"; +import { befehlAusfuehren, hostKeyAbrufen } from "@/worker/ssh/verbindung"; + +/** Vorgaenge HOSTKEY_ABRUFEN und ERREICHBARKEIT (#009). */ + +export async function hostLaden(hostId: string | null) { + if (!hostId) throw new VorgangsFehler("Der Vorgang nennt keinen Host."); + const host = await prisma.host.findUnique({ where: { id: hostId } }); + if (!host || host.archiviertAm) throw new VorgangsFehler("Der Host existiert nicht mehr."); + return host; +} + +export const hostKeyAbrufenHandler: Handler = async ({ vorgang }) => { + const host = await hostLaden(vorgang.hostId); + const gesehen = await hostKeyAbrufen(host); + const ergebnis = await hostKeyFesthalten(host.id, gesehen); + await protokollieren({ + akteur: "System", + aktion: "hostkey.abgerufen", + ergebnis: ergebnis === "geaendert" ? "FEHLER" : "ERFOLG", + hostName: host.name, + fingerprint: gesehen.fingerprint, + details: { algorithmus: gesehen.algorithmus, ergebnis }, + }); + return { ergebnis: { ...gesehen, ergebnis } }; +}; + +export const erreichbarkeitHandler: Handler = async ({ vorgang }) => { + const host = await hostLaden(vorgang.hostId); + try { + const verbindung = await hostVerbinden(host); + try { + const antwort = await befehlAusfuehren(verbindung, "id -un"); + await prisma.host.update({ + where: { id: host.id }, + data: { erreichbar: true, zuletztGeprueftAm: new Date() }, + }); + return { ergebnis: { erreichbar: true, benutzer: antwort.stdout.trim() } }; + } finally { + verbindung.end(); + } + } catch (fehler) { + await prisma.host.update({ + where: { id: host.id }, + data: { erreichbar: false, zuletztGeprueftAm: new Date() }, + }); + throw fehler; + } +}; diff --git a/src/worker/handler.ts b/src/worker/handler.ts index 1738165..462c042 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -1,7 +1,11 @@ import type { HandlerTabelle } from "@/worker/ausfuehren"; +import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; /** - * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Host-Key (#009), Bootstrap - * (#010), Ausrollen (#016), Abgleich (#021) … + * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Bootstrap (#010), + * Ausrollen (#016), Abgleich (#021) … */ -export const HANDLER: HandlerTabelle = {}; +export const HANDLER: HandlerTabelle = { + HOSTKEY_ABRUFEN: hostKeyAbrufenHandler, + ERREICHBARKEIT: erreichbarkeitHandler, +}; diff --git a/src/worker/ssh/host.ts b/src/worker/ssh/host.ts new file mode 100644 index 0000000..74091d3 --- /dev/null +++ b/src/worker/ssh/host.ts @@ -0,0 +1,117 @@ +import type { Client } from "ssh2"; +import type { Host } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler } from "@/worker/ausfuehren"; +import { privateKeyLaden } from "@/worker/geheimnisse"; +import { + HostKeyGeaendertFehler, + verbinden, + type Anmeldung, + type GesehenerHostKey, +} from "@/worker/ssh/verbindung"; + +/** + * Verbindungen zu verwalteten Hosts (#009): vertraute Host-Keys aus der Datenbank, + * Deployment-Key und Anmeldebenutzer nach `anmeldeart`. + */ + +/** Dienstbenutzer auf den Hosts (Freigabe Frage 1). */ +export const DIENSTBENUTZER = "sshkv"; + +export function anmeldebenutzer(host: Pick): string { + return host.anmeldeart === "ROOT" ? "root" : DIENSTBENUTZER; +} + +/** Host-eigener Key, sonst der einer Gruppe, sonst der Instanz-Standard. */ +export async function deploymentKeyIdFuer( + host: Pick, +): Promise { + if (host.deploymentSchluesselId) return host.deploymentSchluesselId; + const gruppe = await prisma.hostgruppeMitglied.findFirst({ + where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } }, + select: { hostgruppe: { select: { deploymentSchluesselId: true } } }, + }); + if (gruppe?.hostgruppe.deploymentSchluesselId) return gruppe.hostgruppe.deploymentSchluesselId; + const standard = await prisma.schluessel.findFirst({ + where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" }, + select: { id: true }, + }); + if (!standard) + throw new VorgangsFehler("Es gibt noch keinen Deployment-Key. Läuft der Worker vollständig?"); + return standard.id; +} + +/** + * Haelt einen gesehenen Host-Key fest. Ein abweichender Key wird nie vertraut, sondern als + * AUSSTEHEND angelegt, und eine Abweichung HOSTKEY_GEAENDERT geht auf. + */ +export async function hostKeyFesthalten( + hostId: string, + gesehen: GesehenerHostKey, +): Promise<"unveraendert" | "neu" | "geaendert"> { + const vertraut = await prisma.hostSchluessel.findFirst({ + where: { hostId, algorithmus: gesehen.algorithmus, status: "VERTRAUT" }, + }); + if (vertraut?.fingerprint === gesehen.fingerprint) { + await prisma.hostSchluessel.update({ where: { id: vertraut.id }, data: { zuletztGesehen: new Date() } }); + return "unveraendert"; + } + await prisma.hostSchluessel.upsert({ + where: { hostId_fingerprint: { hostId, fingerprint: gesehen.fingerprint } }, + create: { hostId, ...gesehen, status: "AUSSTEHEND" }, + update: { zuletztGesehen: new Date() }, + }); + if (!vertraut) return "neu"; + + const offen = await prisma.abweichung.findFirst({ + where: { hostId, art: "HOSTKEY_GEAENDERT", behobenAm: null, fingerprint: gesehen.fingerprint }, + }); + if (!offen) { + await prisma.abweichung.create({ + data: { + hostId, + art: "HOSTKEY_GEAENDERT", + fingerprint: gesehen.fingerprint, + details: { erwartet: vertraut.fingerprint, algorithmus: gesehen.algorithmus }, + }, + }); + await prisma.host.update({ where: { id: hostId }, data: { driftZustand: "DRIFT" } }); + } + return "geaendert"; +} + +/** + * Verbindet sich mit einem Host. Ein geaenderter Host-Key wird festgehalten und protokolliert, + * dann weitergeworfen – der Vorgang scheitert. + */ +export async function hostVerbinden(host: Host, anmeldung?: Anmeldung): Promise { + const vertraut = await prisma.hostSchluessel.findMany({ + where: { hostId: host.id, status: "VERTRAUT" }, + select: { algorithmus: true, fingerprint: true }, + }); + let zugang = anmeldung; + let privateKey: Buffer | undefined; + if (!zugang) { + privateKey = await privateKeyLaden(await deploymentKeyIdFuer(host)); + zugang = { benutzer: anmeldebenutzer(host), privateKey }; + } + try { + return await verbinden(host, vertraut, zugang); + } catch (fehler) { + if (fehler instanceof HostKeyGeaendertFehler) { + await hostKeyFesthalten(host.id, fehler.gesehen); + await protokollieren({ + akteur: "System", + aktion: "hostkey.abweichung", + ergebnis: "VERWEIGERT", + hostName: host.name, + fingerprint: fehler.gesehen.fingerprint, + details: { erwartet: fehler.erwartet }, + }); + } + throw fehler; + } finally { + privateKey?.fill(0); + } +} diff --git a/src/worker/ssh/verbindung.ts b/src/worker/ssh/verbindung.ts new file mode 100644 index 0000000..8f1f972 --- /dev/null +++ b/src/worker/ssh/verbindung.ts @@ -0,0 +1,240 @@ +import { Client, type ConnectConfig } from "ssh2"; +import type { Host, HostSchluessel } from "@/generated/prisma/client"; +import { fingerprintSha256 } from "@/lib/ssh/openssh"; +import { VorgangsFehler } from "@/worker/ausfuehren"; + +/** + * SSH-Verbindungen des Workers (#009) – ausschliesslich mit strikter Host-Key-Pruefung. + * + * Es gibt genau zwei Arten, einen Host-Key zu behandeln: + * - `abrufen`: Key nur ansehen und die Verbindung abbrechen, bevor irgendetwas + * uebertragen wird (TOFU, der Mensch entscheidet danach). + * - `pruefen`: nur ein vertrauter Key ist gut. Ein unbekannter oder geaenderter Key bricht + * die Verbindung ab, bevor Zugangsdaten gesendet werden. Es gibt keinen Codepfad, der + * einen Key automatisch annimmt (CLAUDE.md). + */ + +/** Wie lange ein Verbindungsaufbau dauern darf. */ +const VERBINDUNG_TIMEOUT_MS = 15_000; +/** Wie lange ein einzelner Befehl laufen darf. */ +const BEFEHL_TIMEOUT_MS = 120_000; +/** Mehr Ausgabe nehmen wir nicht an – authorized_keys-Dateien sind klein. */ +const AUSGABE_HOECHSTENS = 4 * 1024 * 1024; + +/** + * Bevorzugte Reihenfolge beim Abrufen. ed25519 zuerst: kurz, modern und auf jedem + * aktuellen Debian vorhanden. + */ +const ALGORITHMEN_REIHENFOLGE = [ + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "rsa-sha2-512", + "rsa-sha2-256", +] as const; + +type SignaturAlgorithmus = (typeof ALGORITHMEN_REIHENFOLGE)[number]; + +/** Der Typ im Key-Blob (`ssh-rsa`) und die Signaturverfahren dazu (`rsa-sha2-*`). */ +export function signaturAlgorithmenFuer(keyTyp: string): SignaturAlgorithmus[] { + if (keyTyp === "ssh-rsa") return ["rsa-sha2-512", "rsa-sha2-256"]; + return ALGORITHMEN_REIHENFOLGE.filter((algorithmus) => algorithmus === keyTyp); +} + +/** Liest den Typ (erstes SSH-String-Feld) aus einem Key-Blob. */ +export function keyTypAusBlob(blob: Buffer): string { + if (blob.length < 4) return ""; + const laenge = blob.readUInt32BE(0); + if (laenge <= 0 || laenge > 64 || blob.length < 4 + laenge) return ""; + return blob.subarray(4, 4 + laenge).toString("ascii"); +} + +export type GesehenerHostKey = { + readonly algorithmus: string; + readonly publicKey: string; + readonly fingerprint: string; +}; + +export function hostKeyBeschreiben(blob: Buffer): GesehenerHostKey { + return { + algorithmus: keyTypAusBlob(blob), + publicKey: blob.toString("base64"), + fingerprint: fingerprintSha256(blob), + }; +} + +/** Der Host praesentierte einen anderen Key als den vertrauten. */ +export class HostKeyGeaendertFehler extends VorgangsFehler { + constructor( + readonly gesehen: GesehenerHostKey, + readonly erwartet: string, + ) { + super( + `ACHTUNG: Der Host-Key hat sich geändert (erwartet ${erwartet}, gesehen ${gesehen.fingerprint}). ` + + "Die Verbindung wurde abgebrochen. Erst prüfen, ob der Host neu installiert wurde – sonst könnte " + + "jemand die Verbindung abfangen.", + ); + this.name = "HostKeyGeaendertFehler"; + } +} + +export class HostKeyUnbekanntFehler extends VorgangsFehler { + constructor() { + super( + "Für diesen Host ist noch kein Host-Key bestätigt. Bitte zuerst den Host-Key abrufen und bestätigen.", + ); + this.name = "HostKeyUnbekanntFehler"; + } +} + +export type Ziel = Pick; + +export type Anmeldung = + | { readonly benutzer: string; readonly privateKey: Buffer } + | { readonly benutzer: string; readonly passwort: string }; + +/** + * Holt den Host-Key, ohne sich anzumelden. Die Verbindung wird im hostVerifier abgebrochen – + * vor dem Austausch von Zugangsdaten. + */ +export function hostKeyAbrufen(ziel: Ziel): Promise { + return new Promise((erfuellen, ablehnen) => { + const client = new Client(); + let gesehen: GesehenerHostKey | undefined; + const fertig = (fehler?: unknown) => { + client.end(); + if (gesehen) erfuellen(gesehen); + else ablehnen(fehler instanceof VorgangsFehler ? fehler : verbindungsFehler(fehler, ziel)); + }; + client.on("error", fertig); + client.on("close", () => fertig()); + client.connect({ + host: ziel.adresse, + port: ziel.port, + username: "sshkv-hostkey-abruf", + readyTimeout: VERBINDUNG_TIMEOUT_MS, + algorithms: { serverHostKey: [...ALGORITHMEN_REIHENFOLGE] }, + hostVerifier: (blob: Buffer) => { + gesehen = hostKeyBeschreiben(blob); + // false bricht ab; mehr als den Key wollten wir nicht. + return false; + }, + }); + }); +} + +/** + * Baut eine Verbindung mit strikter Pruefung gegen die vertrauten Host-Keys auf. Nur die + * Algorithmen der vertrauten Keys werden angeboten – sonst koennte der Server einen Key eines + * anderen Typs zeigen, den wir nie gesehen haben. + */ +export function verbinden( + ziel: Ziel, + vertraut: readonly Pick[], + anmeldung: Anmeldung, +): Promise { + if (vertraut.length === 0) return Promise.reject(new HostKeyUnbekanntFehler()); + const angeboten = [...new Set(vertraut.flatMap((key) => signaturAlgorithmenFuer(key.algorithmus)))]; + + return new Promise((erfuellen, ablehnen) => { + const client = new Client(); + let abweichung: HostKeyGeaendertFehler | undefined; + client.on("ready", () => erfuellen(client)); + client.on("error", (fehler) => { + client.end(); + ablehnen(abweichung ?? verbindungsFehler(fehler, ziel)); + }); + + const konfiguration: ConnectConfig = { + host: ziel.adresse, + port: ziel.port, + username: anmeldung.benutzer, + readyTimeout: VERBINDUNG_TIMEOUT_MS, + algorithms: { serverHostKey: angeboten }, + hostVerifier: (blob: Buffer) => { + const gesehen = hostKeyBeschreiben(blob); + const passend = vertraut.find((key) => key.algorithmus === gesehen.algorithmus); + if (passend && passend.fingerprint === gesehen.fingerprint) return true; + abweichung = new HostKeyGeaendertFehler(gesehen, passend?.fingerprint ?? vertraut[0]!.fingerprint); + return false; + }, + // Nichts ausprobieren, was nicht ausdruecklich gewollt ist (kein Agent, keine Tastatur). + tryKeyboard: false, + ...("privateKey" in anmeldung + ? { privateKey: anmeldung.privateKey } + : { password: anmeldung.passwort }), + }; + client.connect(konfiguration); + }); +} + +function verbindungsFehler(fehler: unknown, ziel: Ziel): VorgangsFehler { + const meldung = fehler instanceof Error ? fehler.message : String(fehler); + const code = (fehler as { code?: string } | undefined)?.code; + if (code === "ECONNREFUSED") + return new VorgangsFehler(`${ziel.adresse}:${ziel.port} lehnt die Verbindung ab.`); + if (code === "ENOTFOUND" || code === "EAI_AGAIN") + return new VorgangsFehler(`${ziel.adresse} ist nicht auflösbar.`); + if (code === "EHOSTUNREACH" || code === "ENETUNREACH") + return new VorgangsFehler(`${ziel.adresse} ist nicht erreichbar.`); + if (/Timed out/i.test(meldung)) + return new VorgangsFehler(`Zeitüberschreitung beim Verbinden mit ${ziel.adresse}.`); + if (/All configured authentication methods failed/i.test(meldung)) + return new VorgangsFehler("Die Anmeldung wurde abgelehnt (Schlüssel bzw. Passwort nicht akzeptiert)."); + if (/no matching|handshake failed/i.test(meldung)) + return new VorgangsFehler( + "Kein gemeinsames Verfahren für den Host-Key – der Host bietet den vertrauten Key-Typ nicht an.", + ); + return new VorgangsFehler(`SSH-Verbindung zu ${ziel.adresse} fehlgeschlagen.`, { cause: fehler }); +} + +export type Befehlsergebnis = { readonly stdout: string; readonly stderr: string; readonly code: number }; + +/** + * Fuehrt einen Befehl aus. `stdin` wird geschrieben und geschlossen – so reisen Dateiinhalte + * (und Private Keys, #017) nur durch die SSH-Verbindung und nie als Datei auf dem sshkv-Server. + */ +export function befehlAusfuehren( + client: Client, + befehl: string, + stdin?: string | Buffer, +): Promise { + return new Promise((erfuellen, ablehnen) => { + client.exec(befehl, (fehler, strom) => { + if (fehler) + return ablehnen(new VorgangsFehler("Befehl konnte nicht gestartet werden.", { cause: fehler })); + const ausgabe: Buffer[] = []; + const fehlerAusgabe: Buffer[] = []; + let laenge = 0; + const zeitgeber = setTimeout(() => { + strom.close(); + ablehnen(new VorgangsFehler("Zeitüberschreitung bei einem Befehl auf dem Host.")); + }, BEFEHL_TIMEOUT_MS); + + const sammeln = (ziel: Buffer[]) => (daten: Buffer) => { + laenge += daten.length; + if (laenge > AUSGABE_HOECHSTENS) { + strom.close(); + return; + } + ziel.push(daten); + }; + strom.on("data", sammeln(ausgabe)); + strom.stderr.on("data", sammeln(fehlerAusgabe)); + strom.on("close", (code: number | null) => { + clearTimeout(zeitgeber); + if (laenge > AUSGABE_HOECHSTENS) { + return ablehnen(new VorgangsFehler("Der Host lieferte mehr Ausgabe als erwartet.")); + } + erfuellen({ + stdout: Buffer.concat(ausgabe).toString("utf8"), + stderr: Buffer.concat(fehlerAusgabe).toString("utf8"), + code: code ?? -1, + }); + }); + if (stdin !== undefined) strom.end(stdin); + else strom.end(); + }); + }); +} diff --git a/tests/integration/hostkey.test.ts b/tests/integration/hostkey.test.ts new file mode 100644 index 0000000..5107a68 --- /dev/null +++ b/tests/integration/hostkey.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { hostKeyEntscheiden } from "@/server/services/hostkeys"; +import { vorgangAusfuehren } from "@/worker/ausfuehren"; +import { masterKeyVergessen } from "@/worker/geheimnisse"; +import { HANDLER } from "@/worker/handler"; +import { hostVerbinden } from "@/worker/ssh/host"; +import { HostKeyGeaendertFehler, befehlAusfuehren } from "@/worker/ssh/verbindung"; +import { datenbankLeeren } from "../hilfen/db"; +import { hostKeyFingerprint, hostKeysTauschen, rootPasswort, zielA, zielBereit } from "./hilfen/ziele"; + +/** Host-Key-Pruefung gegen ein echtes sshd (#009). */ + +const AKTEUR = { id: "test", name: "Test" }; +let verzeichnis: string; + +beforeAll(async () => { + await zielBereit(zielA); + verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-")); + const datei = path.join(verzeichnis, "master.key"); + writeFileSync(datei, randomBytes(32), { mode: 0o600 }); + process.env["SSHKV_MASTER_KEY_FILE"] = datei; + masterKeyVergessen(); +}); + +afterAll(() => rmSync(verzeichnis, { recursive: true, force: true })); + +beforeEach(async () => { + await datenbankLeeren(); +}); + +async function hostAnlegen() { + return prisma.host.create({ + data: { name: "ziel-a", adresse: zielA.host, port: zielA.port, anmeldeart: "ROOT" }, + }); +} + +async function abrufen(hostId: string) { + const vorgang = await prisma.vorgang.create({ data: { art: "HOSTKEY_ABRUFEN", hostId } }); + await vorgangAusfuehren(vorgang.id, HANDLER); + return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } }); +} + +const passwort = () => ({ benutzer: "root", passwort: rootPasswort() }); + +describe("Host-Key (TOFU)", () => { + it("ruft den Key ab, ohne ihm zu vertrauen", async () => { + const host = await hostAnlegen(); + const vorgang = await abrufen(host.id); + expect(vorgang.status).toBe("ERLEDIGT"); + const keys = await prisma.hostSchluessel.findMany({ where: { hostId: host.id } }); + expect(keys).toHaveLength(1); + expect(keys[0]).toMatchObject({ status: "AUSSTEHEND", algorithmus: "ssh-ed25519" }); + expect(keys[0]?.fingerprint).toBe(await hostKeyFingerprint(zielA)); + // Ohne Bestaetigung keine Verbindung. + await expect(hostVerbinden(host, passwort())).rejects.toThrow(/noch kein Host-Key bestätigt/); + }); + + it("verbindet nach Bestaetigung und verweigert nach einem Host-Key-Wechsel", async () => { + const host = await hostAnlegen(); + await abrufen(host.id); + const [key] = await prisma.hostSchluessel.findMany({ where: { hostId: host.id } }); + await hostKeyEntscheiden({ hostSchluesselId: key!.id, vertrauen: true }, AKTEUR); + + const verbindung = await hostVerbinden(host, passwort()); + expect((await befehlAusfuehren(verbindung, "id -un")).stdout.trim()).toBe("root"); + verbindung.end(); + + await hostKeysTauschen(zielA); + await expect(hostVerbinden(host, passwort())).rejects.toBeInstanceOf(HostKeyGeaendertFehler); + + const abweichung = await prisma.abweichung.findFirst({ + where: { hostId: host.id, art: "HOSTKEY_GEAENDERT" }, + }); + expect(abweichung?.behobenAm).toBeNull(); + const neu = await prisma.hostSchluessel.findFirst({ where: { hostId: host.id, status: "AUSSTEHEND" } }); + expect(neu?.fingerprint).toBe(await hostKeyFingerprint(zielA)); + expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT"); + + // Ersetzen nur ausdruecklich. + await expect(hostKeyEntscheiden({ hostSchluesselId: neu!.id, vertrauen: true }, AKTEUR)).rejects.toThrow( + /ausdrücklich/, + ); + await hostKeyEntscheiden({ hostSchluesselId: neu!.id, vertrauen: true, ersetzen: true }, AKTEUR); + const wieder = await hostVerbinden(host, passwort()); + wieder.end(); + expect( + await prisma.abweichung.count({ + where: { hostId: host.id, art: "HOSTKEY_GEAENDERT", behobenAm: null }, + }), + ).toBe(0); + const aktionen = (await prisma.protokoll.findMany()).map((e) => e.aktion); + expect(aktionen).toEqual( + expect.arrayContaining([ + "hostkey.abgerufen", + "hostkey.bestaetigt", + "hostkey.abweichung", + "hostkey.ersetzt", + ]), + ); + }); + + it("meldet einen nicht erreichbaren Host verstaendlich", async () => { + const host = await prisma.host.create({ data: { name: "weg", adresse: "127.0.0.1", port: 1 } }); + const vorgang = await abrufen(host.id); + expect(vorgang.status).toBe("FEHLGESCHLAGEN"); + expect(vorgang.fehler).toMatch(/lehnt die Verbindung ab/); + }); +}); diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 665f586..119ef61 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -9,6 +9,7 @@ export default defineConfig({ test: { include: ["tests/integration/**/*.test.ts"], environment: "node", + globalSetup: ["tests/global-setup.ts"], setupFiles: ["tests/setup-env.ts"], fileParallelism: false, testTimeout: 60_000,