feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning
Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es vor dem Speichern und bestätigt den SHA-256-Fingerprint. Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch Pin ist im Schema, im Client und per CHECK ausgeschlossen. Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock und im gebauten Stand auf Desktop und 390 px. Closes #002 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { utils as sshUtils } from "ssh2";
|
||||
import type { Server } from "@/generated/prisma/client";
|
||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||
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 { ServerAendern, ServerAnlegen, ServerPruefen, 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,
|
||||
erreichbar: server.erreichbar,
|
||||
pveVersion: server.pveVersion,
|
||||
geprueftAm: server.geprueftAm?.toISOString() ?? 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,
|
||||
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,
|
||||
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
|
||||
};
|
||||
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
|
||||
const geaendert = Object.entries(daten)
|
||||
.filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record<string, unknown>)[feld] !== 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 = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
||||
const neu = await prisma.$transaction(async (tx) => {
|
||||
const aktualisiert = await tx.server.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
sshPrivat: verschluesseln(paar.private),
|
||||
sshOeffentlich: paar.public.trim(),
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user