Merge branch 'phase/009-host-key-pruefung'
This commit is contained in:
@@ -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" } });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user