feat(server): Verbindungstest mit Fähigkeitserkennung

Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die
effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per
API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API
erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH.

Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste
in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys
werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt
werden kann nur ein Schlüssel, den der letzte Test gesehen hat.

Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im
Web-Subset von JetBrains Mono.

Refs #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:44:27 +02:00
co-authored by Claude Opus 5.5
parent 48e2a5cb8f
commit 86b650e20a
26 changed files with 2094 additions and 18 deletions
+119 -1
View File
@@ -1,11 +1,18 @@
import { utils as sshUtils } from "ssh2";
import type { Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
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 {
HostSchluesselBestaetigen,
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";
@@ -28,6 +35,8 @@ export function serverSicht(server: Server): ServerSicht {
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) ?? {},
};
}
@@ -259,3 +268,112 @@ export async function serverPruefen(roh: unknown): Promise<PruefErgebnis> {
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,
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);
}