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:
1 parent
257ec3ec0a
commit
bc72b9e152
7 files changed
+454
-1
No files matched your search
@@ -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) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user