/** * Proxmox VE adapter — uses the Proxmox VE REST API (api2/json). * Requires config: url, tokenId, tokenSecret; optional: insecure * * Auth: `Authorization: PVEAPIToken==` — see * https://pve.proxmox.com/wiki/Proxmox_VE_API#API_Tokens * * Endpoints verified against Proxmox's own published API tree * (https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js): GET /nodes, GET * /nodes/{node}/qemu, GET /nodes/{node}/lxc, and POST * /nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} (all * token-auth-eligible per that spec's "allowtoken" flag). * * Proxmox commonly runs with a self-signed certificate in homelab setups, so * (like the cPanel DNS adapter) this uses node:https directly rather than * fetch, to support an "insecure" opt-out of certificate verification. */ import * as https from "node:https"; import { withDiagLogging } from "../../services/diagLog.js"; export interface ProxmoxConfig { url: string; tokenId: string; tokenSecret: string; insecure?: boolean; } export type ProxmoxGuestType = "qemu" | "lxc"; export interface ProxmoxGuest { vmid: number; name: string; node: string; type: ProxmoxGuestType; status: string; // "running" | "stopped" cpu: number | null; maxmem: number | null; mem: number | null; uptime: number | null; } export interface ProxmoxGuestDetail { vmid: number; node: string; type: ProxmoxGuestType; name: string; status: string; cpuCores: number | null; cpuUsagePercent: number | null; memoryBytes: number | null; memUsedBytes: number | null; diskBytes: number | null; /** * Per-mount usage where Proxmox can actually see it: the LXC root * filesystem (host can see straight into it, no agent needed) or, for a * QEMU VM, whatever the QEMU guest agent reports from inside the guest. * Empty when neither is available (e.g. no guest agent) — diskBytes above * (allocated size) is still shown in that case, just not usage. */ disks: { mount: string; sizeBytes: number; usedBytes: number }[]; uptime: number | null; ipAddresses: string[]; /** QEMU only — false when the guest agent call failed (not installed/running). Always true for LXC (IPs/disk usage read directly, no agent needed). */ guestAgentAvailable: boolean; } export interface ProxmoxStorage { id: string; type: string; active: boolean; shared: boolean; totalBytes: number | null; usedBytes: number | null; availBytes: number | null; } export interface ProxmoxNodeStats { node: string; /** Set (with every other field null/empty) when this node's status/storage couldn't be fetched — e.g. the API token lacks Sys.Audit/Datastore.Audit. */ error: string | null; uptime: number | null; cpuUsagePercent: number | null; cpuCores: number | null; loadAverage: [number, number, number] | null; memTotalBytes: number | null; memUsedBytes: number | null; swapTotalBytes: number | null; swapUsedBytes: number | null; rootfsTotalBytes: number | null; rootfsUsedBytes: number | null; pveVersion: string | null; storages: ProxmoxStorage[]; } export interface ProxmoxAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; listGuests(): Promise; getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise; listNodeStats(): Promise; startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; /** Graceful shutdown (ACPI power event for a VM, SIGTERM-then-wait for a container) — unlike stopGuest, this asks the guest OS to shut itself down. */ shutdownGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; } function parseSizeToBytes(size: string): number | null { const match = size.match(/^(\d+(?:\.\d+)?)\s*([KMGT])?$/i); if (!match) return null; const value = parseFloat(match[1]); const unit = (match[2] ?? "").toUpperCase(); const multiplier = ({ "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 } as Record)[unit] ?? 1; return Math.round(value * multiplier); } function extractSizeParam(configValue: string): number | null { const match = configValue.match(/(?:^|,)size=([\d.]+[KMGT]?)/i); return match ? parseSizeToBytes(match[1]) : null; } function extractIpFromNetConfig(configValue: string): string | null { const match = configValue.match(/(?:^|,)ip=([^,]+)/i); if (!match) return null; const ip = match[1]; if (ip.toLowerCase() === "dhcp" || ip.toLowerCase() === "manual") return null; return ip.split("/")[0]; } interface RawResponse { status: number; text: () => string; } function request(url: string, insecure: boolean, options: { method?: string; headers?: Record } = {}): Promise { return new Promise((resolve, reject) => { const parsed = new URL(url); const req = https.request( { hostname: parsed.hostname, port: parsed.port || 8006, path: parsed.pathname + parsed.search, method: options.method || "GET", headers: options.headers || {}, rejectUnauthorized: !insecure, }, (res) => { let body = ""; res.setEncoding("utf8"); res.on("data", (chunk) => { body += chunk; }); res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body })); }, ); req.on("error", reject); req.end(); }); } export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { const insecure = config.insecure === true; function base() { return config.url.replace(/\/$/, ""); } function headers() { return { Authorization: `PVEAPIToken=${config.tokenId}=${config.tokenSecret}`, Accept: "application/json", }; } async function api(method: string, path: string): Promise { const res = await request(`${base()}/api2/json${path}`, insecure, { method, headers: headers() }); let data: any = null; try { data = res.text() ? JSON.parse(res.text()) : null; } catch { // non-JSON error page } if (res.status < 200 || res.status >= 300) { const message = data?.errors ? JSON.stringify(data.errors) : data?.message; throw new Error(message || `Proxmox API error: HTTP ${res.status}`); } return data?.data; } async function listNodes(): Promise { const data = await api("GET", "/nodes"); return (Array.isArray(data) ? data : []) .filter((n: any) => n.status === "online") .map((n: any) => n.node); } async function listGuestsForNode(node: string, type: ProxmoxGuestType): Promise { const data = await api("GET", `/nodes/${node}/${type}`); return (Array.isArray(data) ? data : []).map((g: any) => ({ vmid: g.vmid, name: g.name, node, type, status: g.status, cpu: g.cpu ?? null, maxmem: g.maxmem ?? null, mem: g.mem ?? null, uptime: g.uptime ?? null, })); } async function listGuests(): Promise { const nodes = await listNodes(); const perNode = await Promise.all( nodes.map(async (node) => { try { const [vms, containers] = await Promise.all([ listGuestsForNode(node, "qemu"), listGuestsForNode(node, "lxc"), ]); return [...vms, ...containers]; } catch { // one unreachable/offline node shouldn't take down the whole dashboard view return []; } }), ); return perNode.flat(); } async function getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise { const [config, status] = await Promise.all([ api("GET", `/nodes/${node}/${type}/${vmid}/config`), api("GET", `/nodes/${node}/${type}/${vmid}/status/current`), ]); let cpuCores: number | null = null; let diskBytes: number | null = null; const ipAddresses: string[] = []; const disks: { mount: string; sizeBytes: number; usedBytes: number }[] = []; let guestAgentAvailable = true; if (type === "lxc") { cpuCores = typeof config.cores === "number" ? config.cores : null; if (typeof config.rootfs === "string") { diskBytes = extractSizeParam(config.rootfs); } for (const key of Object.keys(config)) { if (/^net\d+$/.test(key) && typeof config[key] === "string") { const ip = extractIpFromNetConfig(config[key]); if (ip) ipAddresses.push(ip); } } // The host can see straight into an LXC's root filesystem — no agent // needed — but the API only exposes the root mount this way, not any // additional mount points configured on the container. if (typeof status.disk === "number" && typeof status.maxdisk === "number" && status.maxdisk > 0) { disks.push({ mount: "/", sizeBytes: status.maxdisk, usedBytes: status.disk }); } } else { const sockets = typeof config.sockets === "number" ? config.sockets : 1; cpuCores = typeof config.cores === "number" ? config.cores * sockets : null; let totalDisk = 0; let foundDisk = false; for (const key of Object.keys(config)) { if (/^(scsi|virtio|sata|ide)\d+$/.test(key) && typeof config[key] === "string") { const size = extractSizeParam(config[key]); if (size !== null) { totalDisk += size; foundDisk = true; } } } diskBytes = foundDisk ? totalDisk : null; try { const agentData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/network-get-interfaces`); const interfaces = agentData?.result ?? []; for (const iface of interfaces) { for (const addr of iface["ip-addresses"] ?? []) { if (addr["ip-address-type"] === "ipv4" && addr["ip-address"] !== "127.0.0.1") { ipAddresses.push(addr["ip-address"]); } } } } catch { guestAgentAvailable = false; } // Unlike LXC, the hypervisor can't see inside a QEMU disk image at // all — actual filesystem usage only exists if the guest agent // reports it from inside the guest, same availability caveat as the // network call above (a separate try/catch since one agent command // failing, e.g. on an older guest agent version, shouldn't hide IPs // the other command already got, or vice versa). try { const fsData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/get-fsinfo`); for (const fs of fsData?.result ?? []) { // Entries with no backing "disk" (tmpfs, proc, overlay, snap loop // mounts, ...) aren't real storage — skip them, same convention // widely used for this endpoint. if (!Array.isArray(fs.disk) || fs.disk.length === 0) continue; if (typeof fs["total-bytes"] !== "number" || typeof fs["used-bytes"] !== "number") continue; disks.push({ mount: fs.mountpoint ?? fs.name ?? "?", sizeBytes: fs["total-bytes"], usedBytes: fs["used-bytes"] }); } } catch { // Guest agent unavailable or too old to support get-fsinfo — leave // disks empty, diskBytes (allocated size) is still shown. } } const memoryBytes = typeof config.memory === "number" ? config.memory * 1024 * 1024 : null; return { vmid, node, type, name: config.name ?? `${type}/${vmid}`, status: status.status, cpuCores, cpuUsagePercent: typeof status.cpu === "number" ? status.cpu * 100 : null, memoryBytes, memUsedBytes: typeof status.mem === "number" ? status.mem : null, diskBytes, disks, uptime: typeof status.uptime === "number" ? status.uptime : null, ipAddresses, guestAgentAvailable: type === "qemu" ? guestAgentAvailable : true, }; } const emptyNodeStats = (node: string, error: string | null): ProxmoxNodeStats => ({ node, error, uptime: null, cpuUsagePercent: null, cpuCores: null, loadAverage: null, memTotalBytes: null, memUsedBytes: null, swapTotalBytes: null, swapUsedBytes: null, rootfsTotalBytes: null, rootfsUsedBytes: null, pveVersion: null, storages: [], }); function errorMessage(reason: unknown): string { return reason instanceof Error ? reason.message : String(reason); } async function getNodeStats(node: string): Promise { // Host status and storage usage need different ACL privileges // (Sys.Audit vs Datastore.Audit) — a token scoped only for VM/LXC // management (this integration's original scope) may have one but not // the other, so fetch them independently rather than losing both to // Promise.all's fail-fast behavior. const [statusResult, storageResult] = await Promise.allSettled([ api("GET", `/nodes/${node}/status`), api("GET", `/nodes/${node}/storage`), ]); const status = statusResult.status === "fulfilled" ? statusResult.value : null; const storages = storageResult.status === "fulfilled" ? storageResult.value : null; const errors: string[] = []; if (statusResult.status === "rejected") errors.push(`host stats: ${errorMessage(statusResult.reason)}`); if (storageResult.status === "rejected") errors.push(`storage: ${errorMessage(storageResult.reason)}`); const loadavgRaw = Array.isArray(status?.loadavg) ? status.loadavg.map((v: string) => Number(v)) : null; const loadAverage: [number, number, number] | null = loadavgRaw && loadavgRaw.length === 3 && loadavgRaw.every((n: number) => Number.isFinite(n)) ? (loadavgRaw as [number, number, number]) : null; return { node, error: errors.length > 0 ? errors.join("; ") : null, uptime: typeof status?.uptime === "number" ? status.uptime : null, cpuUsagePercent: typeof status?.cpu === "number" ? status.cpu * 100 : null, cpuCores: typeof status?.cpuinfo?.cpus === "number" ? status.cpuinfo.cpus : null, loadAverage, memTotalBytes: typeof status?.memory?.total === "number" ? status.memory.total : null, memUsedBytes: typeof status?.memory?.used === "number" ? status.memory.used : null, swapTotalBytes: typeof status?.swap?.total === "number" ? status.swap.total : null, swapUsedBytes: typeof status?.swap?.used === "number" ? status.swap.used : null, rootfsTotalBytes: typeof status?.rootfs?.total === "number" ? status.rootfs.total : null, rootfsUsedBytes: typeof status?.rootfs?.used === "number" ? status.rootfs.used : null, pveVersion: typeof status?.pveversion === "string" ? status.pveversion : null, storages: (Array.isArray(storages) ? storages : []).map((s: any) => ({ id: s.storage, type: s.type, active: !!s.active, shared: !!s.shared, totalBytes: typeof s.total === "number" ? s.total : null, usedBytes: typeof s.used === "number" ? s.used : null, availBytes: typeof s.avail === "number" ? s.avail : null, })), }; } async function listNodeStats(): Promise { const nodes = await listNodes(); return Promise.all( nodes.map(async (node) => { try { return await getNodeStats(node); } catch (err) { // Surface the failure on this node's card instead of silently // dropping it — that previously showed a misleading "no online // nodes" empty state even when nodes existed but stats couldn't // be fetched (e.g. missing ACL privileges on the API token). return emptyNodeStats(node, errorMessage(err)); } }), ); } async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise { await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`); } const startGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "start"); const stopGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "stop"); const restartGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "reboot"); const shutdownGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "shutdown"); async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { const start = Date.now(); try { await api("GET", "/nodes"); return { ok: true, latencyMs: Date.now() - start }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } return withDiagLogging("proxmox", { ping, listGuests, getGuestDetail, listNodeStats, startGuest, stopGuest, restartGuest, shutdownGuest, }); }