Add Proxmox integration

Fifth live integration: VM/LXC status across every online node in the
cluster, with start/stop/restart actions — matching the "dashboard + basic
actions" depth from the plan. Adapter built against Proxmox VE's own
published API tree (pve.proxmox.com/pve-docs/api-viewer/apidoc.js — parsed
its ~4MB ExtJS tree structure directly since it's not plain JSON) plus
community-verified docs for the token auth header format, since the user's
instance isn't reachable from here.

server/src/integrations/proxmox/adapter.ts: GET /nodes for online nodes,
then GET /nodes/{node}/{qemu,lxc} per node in parallel (Promise.all) and
flattened, tagging each guest with its node and type; POST
/nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} for actions (all
confirmed token-auth-eligible via the spec's "allowtoken" flag). Uses
node:https directly (like the cPanel DNS adapter) rather than fetch, since
Proxmox commonly runs a self-signed certificate in homelab setups — added
an "Allow self-signed certificate" checkbox field for that. Auth is
`Authorization: PVEAPIToken=<tokenId>=<tokenSecret>`, a different shape
from the other three integrations' single bearer token, so the field
schema splits it into two fields (a non-secret token ID like
"root@pam!homelab-manager" and a secret token value).

Verified: full build passes. Unreachable from here, so ran a 16-check HTTP
test against the live server: role gating, credential non-leakage,
disabled-integration blocking, invalid-guest-type and non-numeric-vmid
rejection, checkbox-only config correctly still failing required-field
validation, and the wrong_type crash-safety check for this fifth adapter
type — server stays up throughout. Real node/VM data and the actual
start/stop/restart actions still need verification once this app can reach
the user's Proxmox cluster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 00:24:37 +02:00
1 parent 257ec3ec0a
commit bc72b9e152
7 files changed
+454 -1

No files matched your search

+6
View File
@@ -27,6 +27,12 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
{ key: "url", label: "Semaphore URL", secret: false, placeholder: "https://semaphore.example.lan" },
{ key: "token", label: "API token", secret: true, type: "password" },
],
proxmox: [
{ key: "url", label: "Proxmox URL", secret: false, placeholder: "https://pve.example.lan:8006" },
{ key: "tokenId", label: "API token ID", secret: false, placeholder: "root@pam!homelab-manager" },
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
+169
View File
@@ -0,0 +1,169 @@
/**
* Proxmox VE adapter — uses the Proxmox VE REST API (api2/json).
* Requires config: url, tokenId, tokenSecret; optional: insecure
*
* Auth: `Authorization: PVEAPIToken=<tokenId>=<tokenSecret>` — 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";
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 ProxmoxAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listGuests(): Promise<ProxmoxGuest[]>;
startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
}
interface RawResponse {
status: number;
text: () => string;
}
function request(url: string, insecure: boolean, options: { method?: string; headers?: Record<string, string> } = {}): Promise<RawResponse> {
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<any> {
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<string[]> {
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<ProxmoxGuest[]> {
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<ProxmoxGuest[]> {
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 statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
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");
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 { ping, listGuests, startGuest, stopGuest, restartGuest };
}
+3
View File
@@ -4,6 +4,7 @@ import { createTailscaleAdapter } from "./tailscale/adapter.js";
import { createGiteaAdapter } from "./gitea/adapter.js";
import { createDockhandAdapter } from "./dockhand/adapter.js";
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
import { createProxmoxAdapter } from "./proxmox/adapter.js";
export interface PingableAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
@@ -26,6 +27,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
return createDockhandAdapter(config as any);
case "semaphore":
return createSemaphoreAdapter(config as any);
case "proxmox":
return createProxmoxAdapter(config as any);
default:
throw new Error(`Integration type "${type}" is not implemented yet`);
}
+76
View File
@@ -18,6 +18,7 @@ import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const integrationsRouter = Router();
@@ -538,3 +539,78 @@ integrationsRouter.post(
}
}),
);
// ─── Proxmox ─────────────────────────────────────────────────────────────────
async function requireProxmoxAdapter(req: Request, res: Response) {
const id = Number(req.params.id);
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
res.status(404).json({ error: "not_found" });
return null;
}
if (loaded.integration.type !== "proxmox") {
res.status(400).json({ error: "wrong_type" });
return null;
}
if (!loaded.integration.enabled) {
res.status(400).json({ error: "integration_disabled" });
return null;
}
return { integration: loaded.integration, adapter: createProxmoxAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/proxmox/guests", asyncHandler(async (req, res) => {
const found = await requireProxmoxAdapter(req, res);
if (!found) return;
try {
const guests = await found.adapter.listGuests();
res.json({
guests,
summary: {
total: guests.length,
running: guests.filter((g) => g.status === "running").length,
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
const proxmoxActions = ["start", "stop", "restart"] as const;
for (const action of proxmoxActions) {
integrationsRouter.post(
`/:id/proxmox/nodes/:node/:type/:vmid/${action}`,
requireRole("operator"),
asyncHandler(async (req, res) => {
const found = await requireProxmoxAdapter(req, res);
if (!found) return;
const vmid = Number(req.params.vmid);
if (!Number.isInteger(vmid)) {
return res.status(400).json({ error: "invalid_vmid" });
}
const type = req.params.type;
if (type !== "qemu" && type !== "lxc") {
return res.status(400).json({ error: "invalid_type" });
}
try {
await found.adapter[`${action}Guest`](req.params.node, type, vmid);
await recordAudit({
actor: req.currentUser!,
category: "integration",
action: `${action}_guest`,
targetType: "proxmox_guest",
targetId: vmid,
detail: { integrationId: found.integration.id, node: req.params.node, guestType: type },
});
res.status(204).end();
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}),
);
}