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
+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,
};
}