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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user