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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user