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; }; 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(warnungen: string[], text: string, aufruf: () => Promise): Promise { 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; bauNetz?: boolean; jetzt?: () => Date }, ): Promise { 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("/cluster/status"), ); const clusterEintrag = clusterStatus?.find((e) => e.type === "cluster"); const nodeIps = new Map( (clusterStatus ?? []) .filter((e): e is Extract => e.type === "node") .map((e) => [e.name, e.ip ?? null]), ); const nodes = await client.get("/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(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(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>("/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("/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( (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), bauNetz: optionen.bauNetz, }), warnungen, }; }