Merge branch 'phase/009-host-key-pruefung'

This commit is contained in:
2026-10-12 09:51:59 +02:00
10 changed files with 800 additions and 3 deletions
+19
View File
@@ -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" } });
}
+93
View File
@@ -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<Antwort>;
beschriftung: string;
variante?: ButtonVariante;
icon?: ReactNode;
klein?: boolean;
}) {
const router = useRouter();
const [vorgangId, setVorgangId] = useState<string>();
const [status, setStatus] = useState<Status>();
const [fehler, setFehler] = useState<string>();
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 (
<div className="flex flex-col items-start gap-2">
<Button
variante={variante}
icon={icon}
klein={klein}
disabled={laeuft}
onClick={() =>
starten(async () => {
setFehler(undefined);
setStatus(undefined);
const antwort = await aktion();
if (antwort.fehler) setFehler(antwort.fehler);
else setVorgangId(antwort.vorgangId);
})
}
>
{laeuft ? "Läuft …" : beschriftung}
</Button>
<p role="status" className="text-[13px]">
{fehler && <span className="text-danger">{fehler}</span>}
{!fehler && status?.status === "FEHLGESCHLAGEN" && (
<span className="text-danger">{status.fehler}</span>
)}
{!fehler && status?.status === "ERLEDIGT" && <span className="text-ok">Erledigt.</span>}
</p>
</div>
);
}
+68
View File
@@ -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<VorgangsAntwort> {
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<VorgangsAntwort> {
return einreihen("HOSTKEY_ABRUFEN", hostId);
}
export async function erreichbarkeitPruefenAktion(hostId: string): Promise<VorgangsAntwort> {
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<typeof entscheidungSchema>,
): Promise<EntscheidungsAntwort> {
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;
}
}
+90
View File
@@ -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<void> {
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,
);
});
}
+52
View File
@@ -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;
}
};
+7 -3
View File
@@ -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,
};
+117
View File
@@ -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<Host, "anmeldeart">): string {
return host.anmeldeart === "ROOT" ? "root" : DIENSTBENUTZER;
}
/** Host-eigener Key, sonst der einer Gruppe, sonst der Instanz-Standard. */
export async function deploymentKeyIdFuer(
host: Pick<Host, "id" | "deploymentSchluesselId">,
): Promise<string> {
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<Client> {
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);
}
}
+240
View File
@@ -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<Host, "adresse" | "port">;
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<GesehenerHostKey> {
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<HostSchluessel, "algorithmus" | "fingerprint">[],
anmeldung: Anmeldung,
): Promise<Client> {
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<Befehlsergebnis> {
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();
});
});
}