Der Server kannte die Standards schon für kurze CLI-Befehle, aber nichts setzte sie. Jetzt im Server-Formular (Auswahl aus dem Verbindungstest) und als Vorbelegung im Assistenten. Closes #023 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
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 Standards,
|
||
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,
|
||
standards: (server.standards as Standards | 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,
|
||
standards: eingabe.standards ?? 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,
|
||
standards: rest.standards ?? 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);
|
||
}
|