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
+10 -1
View File
@@ -11,6 +11,7 @@ import { seiteSchuetzen } from "@/server/auth/zugang";
import { serverSicht } from "@/server/services/server";
import { ServerStatus } from "../server-status";
import { ServerAktionen } from "./server-aktionen";
import { Verbindungsbericht } from "./verbindungsbericht";
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
return { title: (await params).name };
@@ -67,6 +68,13 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</dl>
</Karte>
<Verbindungsbericht
name={server.name}
bericht={server.letzterTest}
geprueftAm={server.geprueftAm}
verwalten={verwalten}
/>
<Karte titel="SSH zu den Nodes (optional)">
{!server.sshAktiv ? (
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
@@ -80,7 +88,8 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</p>
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
<p className="text-muted">
Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003).
Host-Keys erscheinen nach dem Verbindungstest beim jeweiligen Node und werden erst nach
Bestätigung genutzt.
</p>
</div>
) : (
@@ -0,0 +1,320 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status, type StatusArt } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import type { Strategie } from "@/engine/proxmox/faehigkeiten";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { bytes, prozent, zeitpunkt } from "@/lib/formate";
type TestErgebnis =
{ ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
const WEG_STATUS: Record<Strategie["weg"], { art: StatusArt; text: string }> = {
api: { art: "erledigt", text: "per API" },
ssh: { art: "laeuft", text: "per SSH" },
nicht_moeglich: { art: "fehler", text: "nicht möglich" },
};
const VORGAENGE: Array<[keyof VerbindungsBericht["faehigkeiten"], string]> = [
["templateBau", "Template bauen"],
["vmProvisionierung", "VM provisionieren"],
["lxcProvisionierung", "LXC provisionieren"],
["snippets", "cloud-init-Snippets"],
["lxcFeaturesErweitert", "LXC-Features außer nesting"],
];
/**
* Ergebnis des Verbindungstests: Version, Nodes, Storages, Bridges, fehlende Rechte und – am
* wichtigsten – welcher Vorgang auf welchem Weg geht. Host-Keys lassen sich hier bestätigen.
*/
export function Verbindungsbericht({
name,
bericht: anfangsBericht,
geprueftAm,
verwalten,
}: {
name: string;
bericht: VerbindungsBericht | null;
geprueftAm: string | null;
verwalten: boolean;
}) {
const router = useRouter();
const [bericht, setBericht] = useState(anfangsBericht);
const [fehler, setFehler] = useState<{ ursache: string; hinweis?: string } | null>(null);
const [laeuft, setLaeuft] = useState(false);
const [zeit, setZeit] = useState(geprueftAm);
async function testen() {
setLaeuft(true);
setFehler(null);
try {
const ergebnis = await apiAufruf<TestErgebnis>("POST", `/api/v1/server/${name}/test`);
setZeit(new Date().toISOString());
if (ergebnis.ok) setBericht(ergebnis.bericht);
else setFehler({ ursache: ergebnis.ursache, hinweis: ergebnis.hinweis });
router.refresh();
} catch (ursache) {
setFehler({ ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler." });
} finally {
setLaeuft(false);
}
}
async function bestaetigen(knoten: string, hostSchluessel: string) {
try {
await apiAufruf("POST", `/api/v1/server/${name}/ssh/hostschluessel`, { knoten, hostSchluessel });
await testen();
} catch (ursache) {
setFehler({
ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.",
hinweis: ursache instanceof ApiFehler ? ursache.hinweis : undefined,
});
}
}
const pflichtFehlend = bericht?.fehlendeRechte.filter((r) => r.pflicht) ?? [];
const optionalFehlend = bericht?.fehlendeRechte.filter((r) => !r.pflicht) ?? [];
return (
<div className="flex flex-col gap-6">
<Karte
titel="Verbindungstest"
aktion={
<Knopf variante="sekundaer" onClick={testen} disabled={laeuft}>
{laeuft ? "Testet …" : "Verbindung testen"}
</Knopf>
}
>
<div aria-live="polite" className="flex flex-col gap-3 text-sm">
{laeuft ? (
<Status art="laeuft">Liest Version, Nodes, Storages, Netzwerk und Rechte …</Status>
) : null}
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1">{fehler.hinweis}</p> : null}
</div>
) : null}
{!bericht && !fehler && !laeuft ? <Status art="offen">Noch nicht getestet.</Status> : null}
{bericht ? (
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-[max-content_1fr]">
<dt className="text-muted">Proxmox VE</dt>
<dd className="font-mono">{bericht.version}</dd>
<dt className="text-muted">Cluster</dt>
<dd>
{bericht.cluster ? (
<>
<span className="font-mono">{bericht.cluster.name}</span> ·{" "}
{bericht.cluster.quorate ? "Quorum vorhanden" : "kein Quorum"}
</>
) : (
"Einzelhost"
)}
</dd>
<dt className="text-muted">Geprüft</dt>
<dd>{zeitpunkt(zeit)}</dd>
</dl>
) : null}
{bericht?.warnungen.map((warnung) => (
<Status key={warnung} art="laeuft">
{warnung}
</Status>
))}
</div>
</Karte>
{bericht ? (
<>
<Karte titel="Was geht auf diesem Server">
<ul className="flex flex-col gap-3 text-sm">
{VORGAENGE.map(([schluessel, titel]) => {
const strategie = bericht.faehigkeiten[schluessel];
const anzeige = WEG_STATUS[strategie.weg];
return (
<li key={schluessel} className="grid gap-1 sm:grid-cols-[14rem_8rem_1fr] sm:gap-3">
<span className="font-medium">{titel}</span>
<Status art={anzeige.art}>{anzeige.text}</Status>
<span className="text-muted">{strategie.grund}</span>
</li>
);
})}
</ul>
</Karte>
<Karte titel="Rechte des API-Tokens">
{pflichtFehlend.length === 0 ? (
<Status art="erledigt">Alle nötigen Rechte vorhanden.</Status>
) : (
<div className="flex flex-col gap-3 text-sm">
<Status art="fehler">
{pflichtFehlend.length === 1 ? "Ein Recht fehlt" : `${pflichtFehlend.length} Rechte fehlen`}{" "}
– die Rolle „ProxDeploy“ steht vollständig in docs/proxmox-rechte.md.
</Status>
<RechteListe rechte={pflichtFehlend} />
</div>
)}
{optionalFehlend.length > 0 ? (
<details className="mt-4 text-sm">
<summary className="text-muted cursor-pointer">
{optionalFehlend.length} optionale Rechte fehlen
</summary>
<div className="mt-2">
<RechteListe rechte={optionalFehlend} />
</div>
</details>
) : null}
</Karte>
{bericht.knoten.map((knoten) => (
<Karte
key={knoten.name}
titel={
<span className="font-mono">
{knoten.name}
{knoten.ip ? <span className="text-muted font-normal"> · {knoten.ip}</span> : null}
</span>
}
aktion={
knoten.online ? <Status art="erledigt">online</Status> : <Status art="fehler">offline</Status>
}
>
{knoten.online ? (
<div className="flex flex-col gap-5 text-sm">
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
<dt className="text-muted">Version</dt>
<dd className="font-mono">{knoten.version ?? "–"}</dd>
<dt className="text-muted">CPU</dt>
<dd>
{knoten.cpus ?? "–"} Kerne · {prozent(knoten.cpuAuslastung)}
</dd>
<dt className="text-muted">RAM</dt>
<dd>
{bytes(knoten.speicherBelegt)} / {bytes(knoten.speicherGesamt)}
</dd>
</dl>
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-left">
<caption className="text-muted mb-2 text-left text-xs font-medium tracking-wide uppercase">
Storages
</caption>
<thead className="text-muted">
<tr>
<th className="py-1 pr-4 font-medium">Name</th>
<th className="py-1 pr-4 font-medium">Typ</th>
<th className="py-1 pr-4 font-medium">Inhalte</th>
<th className="py-1 font-medium">Frei</th>
</tr>
</thead>
<tbody>
{knoten.storages.map((s) => (
<tr key={s.storage} className="border-linie border-t">
<td className="py-1.5 pr-4 font-mono">{s.storage}</td>
<td className="py-1.5 pr-4">{s.typ}</td>
<td className="text-muted py-1.5 pr-4">{s.inhalte.join(", ")}</td>
<td className="py-1.5">
{bytes(s.frei)} / {bytes(s.gesamt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-muted mb-2 text-xs font-medium tracking-wide uppercase">Bridges</p>
<ul className="flex flex-wrap gap-2">
{knoten.bridges.map((b) => (
<li key={b.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{b.name}</span>
<span className="text-muted">
{b.vlanBewusst ? " · VLAN-fähig" : ""}
{b.cidr ? ` · ${b.cidr}` : ""}
{b.kommentar ? ` · ${b.kommentar}` : ""}
</span>
</li>
))}
</ul>
</div>
{knoten.ssh ? (
<div className="flex flex-col gap-2">
<p className="text-muted text-xs font-medium tracking-wide uppercase">SSH</p>
{knoten.ssh.zustand === "ok" ? (
<Status art="erledigt">Verbunden als {knoten.ssh.host} · Host-Key bestätigt</Status>
) : knoten.ssh.zustand === "nicht_bestaetigt" ? (
<div className="flex flex-col gap-2">
<Status art="laeuft">
Host-Key noch nicht bestätigt – bitte mit dem Node vergleichen:
</Status>
<code className="bg-grund border-linie rounded-lg border px-3 py-2 font-mono text-xs break-all">
{knoten.ssh.hostSchluessel}
</code>
<p className="text-muted">
Auf dem Node:{" "}
<span className="font-mono">
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</span>
</p>
{verwalten && knoten.ssh.hostSchluessel ? (
<Knopf
variante="sekundaer"
className="self-start"
onClick={() => bestaetigen(knoten.name, knoten.ssh!.hostSchluessel!)}
>
Host-Key von {knoten.name} bestätigen
</Knopf>
) : null}
</div>
) : (
<Status art="fehler">{knoten.ssh.meldung ?? "SSH nicht möglich."}</Status>
)}
</div>
) : null}
</div>
) : (
<p className="text-muted text-sm">Node ist offline – keine Details.</p>
)}
</Karte>
))}
{bericht.vnets && bericht.vnets.length > 0 ? (
<Karte titel="SDN-VNets">
<ul className="flex flex-wrap gap-2 text-sm">
{bericht.vnets.map((v) => (
<li key={v.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{v.name}</span>
<span className="text-muted">
{" "}
· Zone {v.zone}
{v.tag ? ` · VLAN ${v.tag}` : ""}
{v.alias ? ` · ${v.alias}` : ""}
</span>
</li>
))}
</ul>
</Karte>
) : null}
</>
) : null}
</div>
);
}
function RechteListe({ rechte }: { rechte: VerbindungsBericht["fehlendeRechte"] }) {
return (
<ul className="flex flex-col gap-1.5">
{rechte.map((r) => (
<li key={`${r.recht}${r.pfad}`} className="grid gap-x-3 sm:grid-cols-[16rem_10rem_1fr]">
<span className="font-mono">{r.recht}</span>
<span className="text-muted font-mono">{r.pfad}</span>
<span className="text-muted">{r.wofuer}</span>
</li>
))}
</ul>
);
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { Status, StatusSymbol } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import type { ServerSicht } from "@/lib/schemas/server";
@@ -246,7 +246,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
{felder["fingerabdruck"] ? (
<p className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{felder["fingerabdruck"]}
</p>
) : null}
@@ -295,7 +295,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld>
</div>
<p className="text-muted text-sm">
Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“.
Welche Rechte das Token braucht, steht in der Doku „Proxmox-Rechte“ (docs/proxmox-rechte.md).
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { hostSchluesselBestaetigen } from "@/server/services/server";
type Kontext = { params: Promise<{ name: string }> };
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
return NextResponse.json(
await hostSchluesselBestaetigen(
(await params).name,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
),
);
});
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { serverTesten } from "@/server/services/server";
type Kontext = { params: Promise<{ name: string }> };
/** Verbindungstest: liest nur. Auch Operatoren dürfen ihn auslösen, um vor dem Ausrollen zu prüfen. */
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(
await serverTesten((await params).name, aufrufer, aufruferAdresse(anfrage.headers)),
);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { clsx } from "clsx";
import { StatusSymbol } from "@/components/status";
/**
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
@@ -30,7 +31,7 @@ export function Feld({
{children}
{fehler ? (
<p id={`${id}-fehler`} className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{fehler}
</p>
) : hilfe ? (
+31 -6
View File
@@ -1,11 +1,13 @@
import { clsx } from "clsx";
/**
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
* Dieselben Symbole wie in der CLI-Ausgabe.
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut). Die Symbole
* entsprechen ✓ ◐ ✗ ○ der CLI, sind hier aber gezeichnet: ◐ und ○ fehlen im Web-Subset von
* JetBrains Mono, der Browser fiele auf eine beliebige Schrift mit winzigem Glyph zurück.
*/
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
/** Die Zeichen der CLI – für Textausgaben (Terminal-Karte, Kopiertext). */
export const STATUS_SYMBOL: Record<StatusArt, string> = {
erledigt: "✓",
laeuft: "◐",
@@ -20,6 +22,31 @@ const STATUS_FARBE: Record<StatusArt, string> = {
offen: "text-muted",
};
export function StatusSymbol({ art, className }: { art: StatusArt; className?: string }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={clsx("inline-block h-[1em] w-[1em] shrink-0", className)}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{art === "erledigt" ? <path d="M3 8.5 6.5 12 13 4.5" /> : null}
{art === "fehler" ? <path d="M4 4 12 12M12 4 4 12" /> : null}
{art === "offen" ? <circle cx="8" cy="8" r="5.5" strokeWidth="1.6" /> : null}
{art === "laeuft" ? (
<>
<circle cx="8" cy="8" r="5.5" strokeWidth="1.6" />
<path d="M8 2.5a5.5 5.5 0 0 1 0 11Z" fill="currentColor" stroke="none" />
</>
) : null}
</svg>
);
}
export function Status({
art,
children,
@@ -30,10 +57,8 @@ export function Status({
className?: string;
}) {
return (
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
<span aria-hidden="true" className="font-mono">
{STATUS_SYMBOL[art]}
</span>
<span className={clsx("inline-flex items-baseline gap-1.5", STATUS_FARBE[art], className)}>
<StatusSymbol art={art} className="translate-y-[0.125em]" />
<span>{children}</span>
</span>
);
+319
View File
@@ -0,0 +1,319 @@
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import { pfad, type ProxmoxClient } from "./client";
import {
faehigkeitenAbleiten,
pfadFuer,
rechtePruefen,
versionLesen,
type Faehigkeiten,
type FehlendesRecht,
type PveVersion,
type RechteJePfad,
type SshZustand,
type StorageInfo,
} from "./faehigkeiten";
import { ProxmoxFehler } from "./fehler";
/**
* Der Verbindungstest: liest alles, was ProxDeploy über einen Server wissen muss, und leitet die
* Fähigkeiten ab. Nur lesende Aufrufe. Fehlt für einen Teil ein Recht, wird das als Warnung
* vermerkt statt den ganzen Test abzubrechen – der Bericht soll gerade dann helfen.
*/
export type BridgeInfo = {
name: string;
typ: string;
vlanBewusst: boolean;
aktiv: boolean;
cidr: string | null;
kommentar: string | null;
};
export type SshKnoten = {
zustand: SshZustand;
host: string;
hostSchluessel: string | null;
bestaetigt: boolean;
meldung: string | null;
};
export type KnotenInfo = {
name: string;
online: boolean;
ip: string | null;
version: string | null;
cpuAuslastung: number | null;
cpus: number | null;
speicherBelegt: number | null;
speicherGesamt: number | null;
storages: StorageInfo[];
bridges: BridgeInfo[];
ssh: SshKnoten | null;
};
export type VnetInfo = { name: string; zone: string; tag: number | null; alias: string | null };
export type VerbindungsBericht = {
geprueftAm: string;
version: string;
release: string;
cluster: { name: string; quorate: boolean } | null;
knoten: KnotenInfo[];
vnets: VnetInfo[] | null;
fehlendeRechte: FehlendesRecht[];
ssh: SshZustand;
faehigkeiten: Faehigkeiten;
warnungen: string[];
};
export type SshZugang = {
benutzer: string;
port: number;
privatSchluessel: string | null;
hostSchluessel: Record<string, string>;
};
type ClusterStatusEintrag =
| { type: "cluster"; name: string; quorate: number; nodes: number }
| { type: "node"; name: string; online: number; ip?: string; local?: number };
type NodeEintrag = {
node: string;
status: string;
cpu?: number;
maxcpu?: number;
mem?: number;
maxmem?: number;
};
type StorageEintrag = {
storage: string;
type: string;
content: string;
active?: number;
enabled?: number;
shared?: number;
avail?: number;
total?: number;
};
type NetzEintrag = {
iface: string;
type: string;
active?: number;
bridge_vlan_aware?: number;
cidr?: string;
comments?: string;
};
async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Promise<T>): Promise<T | null> {
try {
return await aufruf();
} catch (fehler) {
if (fehler instanceof ProxmoxFehler && (fehler.art === "recht" || fehler.art === "nicht_gefunden")) {
warnungen.push(`${text}: ${fehler.ursache}`);
return null;
}
throw fehler;
}
}
export async function erkunden(
client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
): Promise<VerbindungsBericht> {
const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version");
const pveVersion: PveVersion = versionLesen(version.version) ?? {
major: 0,
minor: 0,
patch: 0,
text: version.version,
};
if (pveVersion.major < 8) {
warnungen.push(`PVE ${version.version} wird nicht unterstützt – ProxDeploy braucht 8.0 oder neuer.`);
}
// Cluster und Nodes. /cluster/status braucht Sys.Audit; ohne bleibt die Node-Liste.
const clusterStatus = await versuchen(warnungen, "Cluster-Status", () =>
client.get<ClusterStatusEintrag[]>("/cluster/status"),
);
const clusterEintrag = clusterStatus?.find((e) => e.type === "cluster");
const nodeIps = new Map(
(clusterStatus ?? [])
.filter((e): e is Extract<ClusterStatusEintrag, { type: "node" }> => e.type === "node")
.map((e) => [e.name, e.ip ?? null]),
);
const nodes = await client.get<NodeEintrag[]>("/nodes");
const knoten: KnotenInfo[] = [];
for (const node of [...nodes].sort((a, b) => a.node.localeCompare(b.node))) {
const online = node.status === "online";
let storages: StorageInfo[] = [];
let bridges: BridgeInfo[] = [];
let nodeVersion: string | null = null;
if (online) {
const v = await versuchen(warnungen, `Version von ${node.node}`, () =>
client.get<{ version: string }>(pfad`/nodes/${node.node}/version`),
);
nodeVersion = v?.version ?? null;
storages = (
(await versuchen(warnungen, `Storages von ${node.node}`, () =>
client.get<StorageEintrag[]>(pfad`/nodes/${node.node}/storage`),
)) ?? []
)
.filter((s) => s.enabled !== 0)
.map((s) => ({
storage: s.storage,
typ: s.type,
inhalte: s.content.split(",").filter(Boolean),
aktiv: s.active === 1,
geteilt: s.shared === 1,
frei: s.avail ?? null,
gesamt: s.total ?? null,
}))
.sort((a, b) => a.storage.localeCompare(b.storage));
bridges = (
(await versuchen(warnungen, `Netzwerk von ${node.node}`, () =>
client.get<NetzEintrag[]>(pfad`/nodes/${node.node}/network`),
)) ?? []
)
.filter((n) => n.type === "bridge" || n.type === "OVSBridge")
.map((n) => ({
name: n.iface,
typ: n.type,
vlanBewusst: n.bridge_vlan_aware === 1 || n.type === "OVSBridge",
aktiv: n.active === 1,
cidr: n.cidr ?? null,
kommentar: n.comments?.trim() || null,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
knoten.push({
name: node.node,
online,
ip: nodeIps.get(node.node) ?? null,
version: nodeVersion,
cpuAuslastung: node.cpu ?? null,
cpus: node.maxcpu ?? null,
speicherBelegt: node.mem ?? null,
speicherGesamt: node.maxmem ?? null,
storages,
bridges,
ssh: null,
});
}
const vnetsRoh = await versuchen(warnungen, "SDN-VNets", () =>
client.get<Array<{ vnet: string; zone: string; tag?: number; alias?: string }>>("/cluster/sdn/vnets"),
);
const vnets =
vnetsRoh?.map((v) => ({ name: v.vnet, zone: v.zone, tag: v.tag ?? null, alias: v.alias ?? null })) ??
null;
// Rechte: effektiv je Pfad. Storage-Rechte nur für Storages, die Gäste aufnehmen können.
const gastStorages = [
...new Set(
knoten.flatMap((k) =>
k.storages
.filter((s) => s.inhalte.includes("images") || s.inhalte.includes("rootdir"))
.map((s) => s.storage),
),
),
];
const onlineNodes = knoten.filter((k) => k.online).map((k) => k.name);
const pfade = [
"/",
"/vms",
pfadFuer("sdn", {}),
...onlineNodes.map((node) => pfadFuer("node", { node })),
...gastStorages.map((storage) => pfadFuer("storage", { storage })),
];
const rechte: RechteJePfad = {};
for (const p of pfade) {
const antwort = await versuchen(warnungen, `Rechte auf ${p}`, () =>
client.get<RechteJePfad>("/access/permissions", { path: p }),
);
rechte[p] = antwort?.[p] ?? {};
}
const fehlendeRechte = rechtePruefen(pveVersion, rechte, { storages: gastStorages, nodes: onlineNodes });
// SSH je Node: nur wenn eingerichtet. Ohne gepinnten Host-Key wird erkundet, nicht genutzt.
let sshGesamt: SshZustand = "aus";
if (optionen.ssh) {
if (!optionen.ssh.privatSchluessel) {
sshGesamt = "kein_schluessel";
} else {
const zustaende: SshZustand[] = [];
for (const k of knoten.filter((k) => k.online)) {
// Einzelhost ohne Cluster-Status: die Adresse aus der Server-URL.
const host = k.ip ?? (knoten.length === 1 ? optionen.serverHost : k.name);
const pin = optionen.ssh.hostSchluessel[k.name] ?? null;
try {
const ergebnis = await sshAusfuehren(
{
host,
port: optionen.ssh.port,
benutzer: optionen.ssh.benutzer,
privatSchluessel: optionen.ssh.privatSchluessel,
hostSchluessel: pin,
},
"pveversion",
{ erkunden: !pin, zeitlimitMs: 15_000 },
);
const zustand: SshZustand = pin ? "ok" : "nicht_bestaetigt";
k.ssh = {
zustand,
host,
hostSchluessel: ergebnis.hostSchluessel,
bestaetigt: Boolean(pin),
meldung: ergebnis.stdout.trim() || null,
};
zustaende.push(zustand);
} catch (fehler) {
if (!(fehler instanceof SshFehler)) throw fehler;
k.ssh = {
zustand: "fehler",
host,
hostSchluessel: fehler.hostSchluessel ?? null,
bestaetigt: Boolean(pin),
meldung: `${fehler.ursache} ${fehler.hinweis}`,
};
zustaende.push("fehler");
}
}
sshGesamt = zustaende.includes("fehler")
? "fehler"
: zustaende.includes("nicht_bestaetigt")
? "nicht_bestaetigt"
: zustaende.length > 0
? "ok"
: "fehler";
}
}
// Im gemischten Cluster zählt die älteste Version.
const versionen = knoten.map((k) => (k.version ? versionLesen(k.version) : null)).filter((v) => v !== null);
const aelteste = versionen.reduce<PveVersion>(
(alt, v) => (v.major < alt.major || (v.major === alt.major && v.minor < alt.minor) ? v : alt),
pveVersion,
);
const alleStorages = knoten.flatMap((k) => k.storages);
return {
geprueftAm: (optionen.jetzt?.() ?? new Date()).toISOString(),
version: version.version,
release: version.release,
cluster: clusterEintrag ? { name: clusterEintrag.name, quorate: clusterEintrag.quorate === 1 } : null,
knoten,
vnets,
fehlendeRechte,
ssh: sshGesamt,
faehigkeiten: faehigkeitenAbleiten({
version: aelteste,
storages: alleStorages,
ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
}),
warnungen,
};
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Was geht auf diesem Server per API, was nur per SSH, was gar nicht – als reine Funktion aus
* den erkundeten Daten. Grundlage ist die Prüfung der Proxmox-API vom 2026-10-12
* (docs/architektur.md, Abschnitt 3).
*/
export type PveVersion = { major: number; minor: number; patch: number; text: string };
export function versionLesen(text: string): PveVersion | null {
const treffer = /^(\d+)\.(\d+)(?:[.-](\d+))?/.exec(text.trim());
if (!treffer) return null;
return {
major: Number(treffer[1]),
minor: Number(treffer[2]),
patch: Number(treffer[3] ?? 0),
text: text.trim(),
};
}
export function mindestens(version: PveVersion, major: number, minor: number): boolean {
return version.major > major || (version.major === major && version.minor >= minor);
}
// ── Rechte ──────────────────────────────────────────────────────────────────────────────────────
export type RechtBereich = "vms" | "storage" | "sdn" | "node" | "wurzel";
export type RechtAnforderung = {
recht: string;
bereich: RechtBereich;
/** Wofür – erscheint in der Oberfläche neben einem fehlenden Recht. */
wofuer: string;
pflicht: boolean;
};
const VM_RECHTE: Array<[string, string]> = [
["VM.Allocate", "VMs und Container anlegen und löschen"],
["VM.Clone", "Templates klonen"],
["VM.Audit", "Konfiguration lesen"],
["VM.Config.Disk", "Disks anlegen, importieren, vergrößern"],
["VM.Config.CDROM", "cloud-init-Laufwerk"],
["VM.Config.CPU", "CPU einstellen"],
["VM.Config.Memory", "RAM einstellen"],
["VM.Config.Network", "Netzwerk einstellen"],
["VM.Config.HWType", "Maschinentyp, BIOS, SCSI-Controller"],
["VM.Config.Options", "Name, Tags, Agent, Start bei Boot"],
["VM.Config.Cloudinit", "cloud-init (Benutzer, Keys, IP)"],
["VM.PowerMgmt", "Starten und Stoppen"],
];
/**
* Die Rolle `ProxDeploy` je Hauptversion. PVE 9 hat `VM.Monitor` gestrichen und den Guest-Agent
* in eigene Rechte aufgeteilt; `Sys.AccessNetwork` (für download-url) gibt es seit 8.2.
*/
export function benoetigteRechte(version: PveVersion): RechtAnforderung[] {
const liste: RechtAnforderung[] = VM_RECHTE.map(([recht, wofuer]) => ({
recht,
bereich: "vms",
wofuer,
pflicht: true,
}));
if (version.major >= 9) {
liste.push(
{
recht: "VM.GuestAgent.Audit",
bereich: "vms",
wofuer: "IP-Adressen über den Guest-Agent",
pflicht: true,
},
{
recht: "VM.GuestAgent.Unrestricted",
bereich: "vms",
wofuer: "Provisionierung über den Guest-Agent (exec, file-write)",
pflicht: true,
},
);
} else {
liste.push({ recht: "VM.Monitor", bereich: "vms", wofuer: "Guest-Agent (in PVE 8)", pflicht: true });
}
liste.push(
{
recht: "Datastore.AllocateSpace",
bereich: "storage",
wofuer: "Disks auf dem Storage anlegen",
pflicht: true,
},
{
recht: "Datastore.AllocateTemplate",
bereich: "storage",
wofuer: "Images und LXC-Templates herunterladen",
pflicht: true,
},
{ recht: "Datastore.Audit", bereich: "storage", wofuer: "Storage-Inhalt lesen", pflicht: true },
{ recht: "SDN.Use", bereich: "sdn", wofuer: "Bridges und VNets an Gäste hängen", pflicht: true },
{ recht: "Sys.Audit", bereich: "wurzel", wofuer: "Cluster-Status und Rechte lesen", pflicht: true },
);
if (mindestens(version, 8, 2)) {
liste.push({
recht: "Sys.AccessNetwork",
bereich: "node",
wofuer: "Images per download-url laden",
pflicht: true,
});
} else {
liste.push({
recht: "Sys.Modify",
bereich: "wurzel",
wofuer: "Images per download-url laden (vor PVE 8.2)",
pflicht: true,
});
}
liste.push(
{
recht: "Datastore.Allocate",
bereich: "storage",
wofuer: "Heruntergeladene Images beim Aufräumen löschen",
pflicht: false,
},
{ recht: "SDN.Audit", bereich: "sdn", wofuer: "VNets anzeigen", pflicht: false },
{ recht: "VM.Console", bereich: "vms", wofuer: "Konsole öffnen (Link aus ProxDeploy)", pflicht: false },
);
if (version.major >= 9) {
liste.push({
recht: "Sys.Modify",
bereich: "wurzel",
wofuer: "Privilegierte Container anlegen (seit PVE 9)",
pflicht: false,
});
}
return liste;
}
/** Effektive Rechte je ACL-Pfad, wie `GET /access/permissions?path=…` sie liefert. */
export type RechteJePfad = Record<string, Record<string, number | boolean>>;
export type FehlendesRecht = { recht: string; pfad: string; wofuer: string; pflicht: boolean };
export function pfadFuer(bereich: RechtBereich, kontext: { storage?: string; node?: string }): string {
switch (bereich) {
case "vms":
return "/vms";
case "storage":
return kontext.storage ? `/storage/${kontext.storage}` : "/storage";
case "sdn":
return "/sdn/zones/localnetwork";
case "node":
return kontext.node ? `/nodes/${kontext.node}` : "/nodes";
case "wurzel":
return "/";
}
}
/**
* Vergleicht die effektiven Rechte mit der Anforderung. Storage-Rechte werden je Storage geprüft,
* der Gäste aufnehmen kann – gilt ein Recht auf keinem davon, fehlt es.
*/
export function rechtePruefen(
version: PveVersion,
rechte: RechteJePfad,
kontext: { storages: string[]; nodes: string[] },
): FehlendesRecht[] {
const hat = (pfad: string, recht: string) => Boolean(rechte[pfad]?.[recht]);
const fehlend: FehlendesRecht[] = [];
for (const anforderung of benoetigteRechte(version)) {
let pfade: string[];
if (anforderung.bereich === "storage") {
pfade = kontext.storages.map((storage) => pfadFuer("storage", { storage }));
} else if (anforderung.bereich === "node") {
pfade = kontext.nodes.map((node) => pfadFuer("node", { node }));
} else {
pfade = [pfadFuer(anforderung.bereich, {})];
}
if (pfade.length === 0) pfade = [pfadFuer(anforderung.bereich, {})];
// Storage: es genügt, wenn ein Storage passt. Nodes: jeder Node braucht es.
const erfuellt =
anforderung.bereich === "storage"
? pfade.some((p) => hat(p, anforderung.recht))
: pfade.every((p) => hat(p, anforderung.recht));
if (!erfuellt) {
const ohne =
anforderung.bereich === "storage" ? pfade : pfade.filter((p) => !hat(p, anforderung.recht));
fehlend.push({
recht: anforderung.recht,
pfad: ohne.join(", "),
wofuer: anforderung.wofuer,
pflicht: anforderung.pflicht,
});
}
}
return fehlend;
}
// ── Strategien ──────────────────────────────────────────────────────────────────────────────────
export type StorageInfo = {
storage: string;
typ: string;
inhalte: string[];
aktiv: boolean;
geteilt: boolean;
frei: number | null;
gesamt: number | null;
};
export type SshZustand = "aus" | "kein_schluessel" | "nicht_bestaetigt" | "fehler" | "ok";
export type Weg = "api" | "ssh" | "nicht_moeglich";
export type Strategie = { weg: Weg; grund: string };
export type Faehigkeiten = {
templateBau: Strategie;
vmProvisionierung: Strategie;
lxcProvisionierung: Strategie & { ueber?: "node" | "gast" };
snippets: Strategie;
lxcFeaturesErweitert: Strategie;
};
/**
* Leitet je Vorgang den Weg ab. Grundsatz: API, wo es geht; SSH nur, wo Proxmox keine API hat
* oder die Version zu alt ist.
*/
export function faehigkeitenAbleiten(eingabe: {
version: PveVersion;
storages: StorageInfo[];
ssh: SshZustand;
rechteFehlen: boolean;
}): Faehigkeiten {
const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok";
const importSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("import"));
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie;
if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
templateBau = {
weg: "api",
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
};
} else if (sshOk) {
templateBau = {
weg: "ssh",
grund: mindestens(version, 8, 4)
? "Kein Storage mit Inhaltstyp „import“ – Download und Import laufen per SSH."
: `PVE ${version.text} kann Disk-Images erst ab 8.4 per API laden – Download und Import laufen per SSH.`,
};
} else {
templateBau = {
weg: "nicht_moeglich",
grund: mindestens(version, 8, 4)
? "Einem Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten."
: `PVE ${version.text} lädt Disk-Images erst ab 8.4 per API – SSH einrichten oder auf 8.4+ aktualisieren.`,
};
}
const snippets: Strategie =
sshOk && snippetSpeicher.length > 0
? { weg: "ssh", grund: `Snippets per SSH auf ${snippetSpeicher[0]!.storage}` }
: sshOk
? { weg: "nicht_moeglich", grund: "Kein Storage mit Inhaltstyp „Snippets“." }
: { weg: "nicht_moeglich", grund: "Proxmox hat keine API für Snippets – nur per SSH." };
return {
templateBau,
vmProvisionierung: { weg: "api", grund: "Über den Guest-Agent (im Template eingebaut)." },
lxcProvisionierung: sshOk
? { weg: "ssh", ueber: "node", grund: "pct exec über SSH zum Node." }
: {
weg: "ssh",
ueber: "gast",
grund:
"Proxmox hat keine Exec-API für Container – Profile laufen per SSH direkt in den Container (Netzweg nötig).",
},
snippets,
lxcFeaturesErweitert: sshOk
? { weg: "ssh", grund: "keyctl, fuse usw. per pct set über SSH." }
: {
weg: "nicht_moeglich",
grund: "Außer „nesting“ darf nur root@pam Container-Features setzen – per Token nicht.",
},
};
}
+2 -2
View File
@@ -117,10 +117,10 @@ export function rechtHinweis(meldung: string): string {
if (treffer) {
return (
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe README, Proxmox-Rechte)."
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe docs/proxmox-rechte.md)."
);
}
return "Rechte des API-Tokens prüfen (siehe README, Proxmox-Rechte).";
return "Rechte des API-Tokens prüfen (siehe docs/proxmox-rechte.md).";
}
/** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */
+145
View File
@@ -0,0 +1,145 @@
import { createHash } from "node:crypto";
import { Client } from "ssh2";
/**
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
* Pin bekannt, darf nur „erkundet“ werden (der Schlüssel wird gemeldet und muss bestätigt
* werden); für alles Schreibende ist ein Pin Pflicht.
*/
export type SshZiel = {
host: string;
port: number;
benutzer: string;
privatSchluessel: string;
/** „SHA256:…“ wie ssh-keygen -lf; null nur beim Erkunden. */
hostSchluessel: string | null;
};
export type SshErgebnis = { code: number | null; stdout: string; stderr: string; hostSchluessel: string };
export class SshFehler extends Error {
constructor(
readonly art: "verbindung" | "anmeldung" | "host_schluessel" | "zeitueberschreitung" | "befehl",
readonly ursache: string,
readonly hinweis: string,
readonly hostSchluessel?: string,
) {
super(ursache);
this.name = "SshFehler";
}
}
/** Fingerprint im Format von ssh-keygen: SHA256:<base64 ohne Padding>. */
export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
return `SHA256:${createHash("sha256").update(schluessel).digest("base64").replace(/=+$/, "")}`;
}
export function sshAusfuehren(
ziel: SshZiel,
befehl: string,
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
): Promise<SshErgebnis> {
if (!ziel.hostSchluessel && !optionen.erkunden) {
return Promise.reject(
new SshFehler(
"host_schluessel",
`Für ${ziel.host} ist noch kein Host-Key bestätigt.`,
"Verbindungstest ausführen und den angezeigten Host-Key bestätigen.",
),
);
}
const zeitlimit = optionen.zeitlimitMs ?? 60_000;
return new Promise((erfuellen, ablehnen) => {
const client = new Client();
let gesehen = "";
let erledigt = false;
const ende = (fehler: SshFehler | null, ergebnis?: SshErgebnis) => {
if (erledigt) return;
erledigt = true;
clearTimeout(uhr);
client.end();
if (fehler) ablehnen(fehler);
else erfuellen(ergebnis!);
};
const uhr = setTimeout(
() =>
ende(
new SshFehler(
"zeitueberschreitung",
`SSH zu ${ziel.host} hat nach ${Math.round(zeitlimit / 1000)} s nicht geantwortet.`,
"Erreichbarkeit von Port " + ziel.port + " prüfen.",
gesehen || undefined,
),
),
zeitlimit,
);
client
.on("ready", () => {
client.exec(befehl, (fehler, kanal) => {
if (fehler) {
ende(
new SshFehler(
"befehl",
`Befehl ließ sich nicht starten: ${fehler.message}`,
"SSH-Benutzer prüfen.",
gesehen,
),
);
return;
}
let stdout = "";
let stderr = "";
let code: number | null = null;
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
else kanal.end();
});
})
.on("error", (fehler: Error & { level?: string; code?: string }) => {
if (fehler.level === "client-authentication") {
ende(
new SshFehler(
"anmeldung",
`${ziel.benutzer}@${ziel.host} nimmt den Schlüssel von ProxDeploy nicht an.`,
"Öffentlichen Schlüssel aus den Server-Einstellungen in ~/.ssh/authorized_keys des Nodes eintragen.",
gesehen || undefined,
),
);
} else if (fehler.message.includes("Host denied")) {
ende(
new SshFehler(
"host_schluessel",
`Der Host-Key von ${ziel.host} hat sich geändert (gesehen ${gesehen}).`,
"Wurde der Node neu installiert? Dann den Host-Key neu bestätigen. Sonst nicht verbinden.",
gesehen,
),
);
} else {
ende(
new SshFehler(
"verbindung",
`SSH zu ${ziel.host}:${ziel.port} fehlgeschlagen: ${fehler.code ?? fehler.message}`,
"Läuft sshd? Ist der Port aus dem Netz von ProxDeploy erreichbar?",
gesehen || undefined,
),
);
}
})
.connect({
host: ziel.host,
port: ziel.port,
username: ziel.benutzer,
privateKey: ziel.privatSchluessel,
readyTimeout: Math.min(zeitlimit, 20_000),
hostVerifier: (schluessel: Buffer) => {
gesehen = hostSchluesselFingerabdruck(schluessel);
return ziel.hostSchluessel ? gesehen === ziel.hostSchluessel : Boolean(optionen.erkunden);
},
});
});
}
+37
View File
@@ -0,0 +1,37 @@
/** Formate für die Anzeige – deutsch, gleich in Web und (sinngemäß) CLI. */
export function bytes(wert: number | null | undefined): string {
if (wert === null || wert === undefined) return "–";
const einheiten = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
let zahl = wert;
let stufe = 0;
while (zahl >= 1024 && stufe < einheiten.length - 1) {
zahl /= 1024;
stufe += 1;
}
return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: zahl < 10 && stufe > 0 ? 1 : 0 })} ${einheiten[stufe]}`;
}
export function prozent(anteil: number | null | undefined): string {
if (anteil === null || anteil === undefined) return "–";
return `${Math.round(anteil * 100)} %`;
}
/** „vor 3 min“, „vor 2 h“, sonst Datum mit Uhrzeit. */
export function zeitpunkt(iso: string | null | undefined, jetzt = new Date()): string {
if (!iso) return "nie";
const datum = new Date(iso);
const sekunden = Math.round((jetzt.getTime() - datum.getTime()) / 1000);
if (sekunden < 60) return "gerade eben";
if (sekunden < 3600) return `vor ${Math.round(sekunden / 60)} min`;
if (sekunden < 86_400) return `vor ${Math.round(sekunden / 3600)} h`;
return datum.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
}
/** Dauer wie in der CLI: „18s“, „1m 12s“, „1h 03m“. */
export function dauer(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
}
+12
View File
@@ -1,3 +1,4 @@
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { z } from "@/lib/validierung";
/**
@@ -114,4 +115,15 @@ export type ServerSicht = {
erreichbar: boolean | null;
pveVersion: string | null;
geprueftAm: string | null;
letzterTest: VerbindungsBericht | null;
/** Gepinnte SSH-Host-Keys je Node – öffentlich, kein Geheimnis. */
sshHostSchluessel: Record<string, string>;
};
export const HostSchluesselBestaetigen = z.object({
knoten: z.string().trim().min(1),
hostSchluessel: z
.string()
.trim()
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/, "Kein SHA256-Fingerprint eines Host-Keys."),
});
+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);
}