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 | 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 { return (await prisma.server.findMany({ orderBy: { name: "asc" } })).map(serverSicht); } export async function serverHolen(name: string): Promise { 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 { 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 { 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)[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 { 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 { 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 { 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 { 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 | 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 { 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 | 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); }