Files
proxdeploy/src/server/services/server.ts
T
florian.wildeandClaude Opus 5.5 8dca30e254 fix(ssh): nur ed25519-Schlüssel speichern, die ssh2 lesen kann
ssh2.utils.generateKeyPairSync liefert in etwa 4 von 1000 Fällen einen privaten Schlüssel, an dem der eigene Parser scheitert. Ein so erzeugter Server-Schlüssel wäre dauerhaft unbrauchbar gewesen; in den Tests war es die Ursache des gelegentlich roten SSH-Tests.

Refs #014

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:39:54 +02:00

391 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Prisma, type Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import {
HostSchluesselBestaetigen,
ServerAendern,
ServerAnlegen,
ServerPruefen,
type BauNetz,
type ServerSicht,
} from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import type { Aufrufer } from "@/server/auth/zugang";
import { protokollieren } from "@/server/services/protokoll";
export function serverSicht(server: Server): ServerSicht {
return {
name: server.name,
titel: server.titel,
url: server.url,
tokenId: server.tokenId,
tlsPruefen: server.tlsPruefen,
fingerabdruck: server.fingerabdruck,
sshAktiv: server.sshAktiv,
sshBenutzer: server.sshBenutzer,
sshPort: server.sshPort,
sshOeffentlich: server.sshOeffentlich,
templateVmidVon: server.templateVmidVon,
templateVmidBis: server.templateVmidBis,
gastVmidVon: server.gastVmidVon,
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
erreichbar: server.erreichbar,
pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null,
letzterTest: (server.letzterTest as VerbindungsBericht | null) ?? null,
sshHostSchluessel: (server.sshHostSchluessel as Record<string, string> | null) ?? {},
};
}
/** Entschlüsselte Verbindungsdaten – nur für die Engine, nie in eine Antwort. */
export function verbindungAus(server: Server): ProxmoxVerbindung {
return {
url: server.url,
tokenId: server.tokenId,
tokenGeheimnis: entschluesseln(server.tokenGeheimnis),
tlsPruefen: server.tlsPruefen,
fingerabdruck: server.fingerabdruck,
};
}
function fingerabdruckOderFehler(eingabe: string | null): string | null {
if (!eingabe) return null;
const normal = fingerabdruckNormalisieren(eingabe);
if (!normal) {
throw new AnwendungsFehler(400, "ungueltig", "Der Fingerprint ist kein SHA-256-Wert (64 Hex-Zeichen).");
}
return normal;
}
export async function serverListe(): Promise<ServerSicht[]> {
return (await prisma.server.findMany({ orderBy: { name: "asc" } })).map(serverSicht);
}
export async function serverHolen(name: string): Promise<Server> {
const server = await prisma.server.findUnique({ where: { name } });
if (!server) throw nichtGefunden(`Den Server „${name}“`);
return server;
}
export async function serverAnlegen(
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<ServerSicht> {
const eingabe = ServerAnlegen.parse(roh);
if (await prisma.server.findUnique({ where: { name: eingabe.name } })) {
throw new AnwendungsFehler(409, "vergeben", `Den Namen „${eingabe.name}“ gibt es schon.`);
}
const server = await prisma.$transaction(async (tx) => {
const angelegt = await tx.server.create({
data: {
...eingabe,
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Server angelegt",
ziel: `server:${angelegt.name}`,
details: { url: angelegt.url, tokenId: angelegt.tokenId },
ip,
},
tx,
);
return angelegt;
});
return serverSicht(server);
}
export async function serverAendern(
name: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<ServerSicht> {
const bisher = await serverHolen(name);
const eingabe = ServerAendern.parse(roh);
if (eingabe.name !== bisher.name && (await prisma.server.findUnique({ where: { name: eingabe.name } }))) {
throw new AnwendungsFehler(409, "vergeben", `Den Namen „${eingabe.name}“ gibt es schon.`);
}
const { tokenGeheimnis, ...rest } = eingabe;
const daten = {
...rest,
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
};
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
// JSON-Felder (bauNetz) vergleichen nach Inhalt; Prisma.JsonNull steht für „leer“ wie null.
const vergleichbar = (wert: unknown) => JSON.stringify(wert === Prisma.JsonNull ? null : (wert ?? null));
const geaendert = Object.entries(daten)
.filter(
([feld, wert]) =>
feld === "tokenGeheimnis" ||
vergleichbar((bisher as Record<string, unknown>)[feld]) !== vergleichbar(wert),
)
.map(([feld]) => feld);
const server = await prisma.$transaction(async (tx) => {
const neu = await tx.server.update({ where: { id: bisher.id }, data: daten });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Server geändert",
ziel: `server:${neu.name}`,
details: { felder: geaendert },
ip,
},
tx,
);
return neu;
});
return serverSicht(server);
}
export async function serverLoeschen(name: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const server = await serverHolen(name);
await prisma.$transaction(async (tx) => {
await tx.server.delete({ where: { id: server.id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Server gelöscht",
ziel: `server:${server.name}`,
ip,
},
tx,
);
});
}
/**
* Erzeugt (oder erneuert) das SSH-Schlüsselpaar, mit dem ProxDeploy sich optional auf den Nodes
* anmeldet. ed25519 im OpenSSH-Format; der private Schlüssel liegt nur verschlüsselt vor.
*/
export async function sshSchluesselErzeugen(
name: string,
aufrufer: Aufrufer,
ip: string | null,
): Promise<ServerSicht> {
const server = await serverHolen(name);
const paar = ed25519Erzeugen(`proxdeploy@${server.name}`);
const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({
where: { id: server.id },
data: {
sshPrivat: verschluesseln(paar.privat),
sshOeffentlich: paar.oeffentlich,
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: server.sshOeffentlich ? "SSH-Schlüssel erneuert" : "SSH-Schlüssel erzeugt",
ziel: `server:${server.name}`,
ip,
},
tx,
);
return aktualisiert;
});
return serverSicht(neu);
}
export type PruefErgebnis = {
zertifikat: ZertifikatInfo | null;
/** Ob der angegebene Pin zum gesehenen Zertifikat passt (null: kein Pin angegeben). */
pinPasst: boolean | null;
verbindung:
| { ok: true; version: string; release: string }
| { ok: false; art: string; ursache: string; hinweis: string }
| null;
};
/**
* Prüft vor dem Speichern: Zertifikat holen (zum Bestätigen) und – wenn Token-Angaben da sind –
* einen ersten Aufruf von `/version`. Die vollständige Fähigkeitserkennung folgt in #003.
*/
export async function serverPruefen(roh: unknown): Promise<PruefErgebnis> {
const eingabe = ServerPruefen.parse(roh);
let zertifikat: ZertifikatInfo | null = null;
try {
zertifikat = await zertifikatHolen(eingabe.url);
} catch (fehler) {
if (fehler instanceof ProxmoxFehler) {
return {
zertifikat: null,
pinPasst: null,
verbindung: { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis },
};
}
throw fehler;
}
const pin = eingabe.fingerabdruck ? fingerabdruckNormalisieren(eingabe.fingerabdruck) : null;
const pinPasst = pin ? pin === zertifikat.fingerabdruck : null;
let geheimnis = eingabe.tokenGeheimnis;
if (!geheimnis && eingabe.name) {
const bestehend = await prisma.server.findUnique({ where: { name: eingabe.name } });
if (bestehend) geheimnis = entschluesseln(bestehend.tokenGeheimnis);
}
if (!eingabe.tokenId || !geheimnis || (!eingabe.tlsPruefen && !pin)) {
return { zertifikat, pinPasst, verbindung: null };
}
const client = new ProxmoxClient(
{
url: eingabe.url,
tokenId: eingabe.tokenId,
tokenGeheimnis: geheimnis,
tlsPruefen: eingabe.tlsPruefen,
fingerabdruck: pin,
},
{ zeitlimitMs: 10_000 },
);
try {
const version = await client.get<{ version: string; release: string }>("/version");
return {
zertifikat,
pinPasst,
verbindung: { ok: true, version: version.version, release: version.release },
};
} catch (fehler) {
if (fehler instanceof ProxmoxFehler) {
return {
zertifikat,
pinPasst,
verbindung: { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis },
};
}
throw fehler;
} finally {
await client.schliessen();
}
}
export type TestErgebnis =
{ ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
/**
* Der Verbindungstest (#003): erkundet den Server, speichert den Bericht und die Erreichbarkeit.
* Ein nicht erreichbarer Server ist kein Fehler der Anfrage, sondern ein Ergebnis.
*/
export async function serverTesten(
name: string,
aufrufer: Aufrufer,
ip: string | null,
): Promise<TestErgebnis> {
const server = await serverHolen(name);
const client = new ProxmoxClient(verbindungAus(server), { zeitlimitMs: 15_000 });
let ergebnis: TestErgebnis;
try {
const bericht = await erkunden(client, {
serverHost: new URL(server.url).hostname,
bauNetz: Boolean(server.bauNetz),
ssh: server.sshAktiv
? {
benutzer: server.sshBenutzer,
port: server.sshPort,
privatSchluessel: server.sshPrivat ? entschluesseln(server.sshPrivat) : null,
hostSchluessel: (server.sshHostSchluessel as Record<string, string> | null) ?? {},
}
: null,
});
ergebnis = { ok: true, bericht };
} catch (fehler) {
if (!(fehler instanceof ProxmoxFehler)) throw fehler;
ergebnis = { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis };
} finally {
await client.schliessen();
}
await prisma.$transaction(async (tx) => {
await tx.server.update({
where: { id: server.id },
data: {
erreichbar: ergebnis.ok,
geprueftAm: new Date(),
...(ergebnis.ok ? { pveVersion: ergebnis.bericht.version, letzterTest: ergebnis.bericht } : {}),
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Verbindung getestet",
ziel: `server:${server.name}`,
details: ergebnis.ok
? { version: ergebnis.bericht.version, fehlendeRechte: ergebnis.bericht.fehlendeRechte.length }
: { fehler: ergebnis.ursache },
ip,
},
tx,
);
});
return ergebnis;
}
/**
* Pinnt den SSH-Host-Key eines Nodes – nur einen, den der letzte Test tatsächlich gesehen hat.
* So kann niemand über die API einen beliebigen Schlüssel unterschieben.
*/
export async function hostSchluesselBestaetigen(
name: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<ServerSicht> {
const server = await serverHolen(name);
const eingabe = HostSchluesselBestaetigen.parse(roh);
const bericht = server.letzterTest as VerbindungsBericht | null;
const gesehen = bericht?.knoten.find((k) => k.name === eingabe.knoten)?.ssh?.hostSchluessel;
if (!gesehen || gesehen !== eingabe.hostSchluessel) {
throw new AnwendungsFehler(
409,
"nicht_gesehen",
"Dieser Host-Key wurde beim letzten Verbindungstest nicht gesehen.",
"Verbindung erneut testen und den dann angezeigten Host-Key bestätigen.",
);
}
const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({
where: { id: server.id },
data: {
sshHostSchluessel: {
...((server.sshHostSchluessel as Record<string, string> | null) ?? {}),
[eingabe.knoten]: eingabe.hostSchluessel,
},
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "SSH-Host-Key bestätigt",
ziel: `server:${server.name}`,
details: { knoten: eingabe.knoten, hostSchluessel: eingabe.hostSchluessel },
ip,
},
tx,
);
return aktualisiert;
});
return serverSicht(neu);
}