From c9268f87920cee177faa1c405248c13bd3e04d5f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 21:40:20 +1000 Subject: [PATCH] feat(actions): scoped Proxmox power channel Co-Authored-By: Claude Opus 4.8 --- lib/actions/channels/proxmox.js | 16 ++++++++++++++++ tests/actions/proxmox.test.js | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 lib/actions/channels/proxmox.js create mode 100644 tests/actions/proxmox.test.js diff --git a/lib/actions/channels/proxmox.js b/lib/actions/channels/proxmox.js new file mode 100644 index 0000000..6be4aad --- /dev/null +++ b/lib/actions/channels/proxmox.js @@ -0,0 +1,16 @@ +// Proxmox guest power via a SCOPED PVEAPIToken (VM.PowerMgmt on whitelisted guests +// only). PVE enforces permissions server-side; this adapter never builds shell commands. +export async function powerGuest({ node, vmid, op, kindPath = 'lxc' }, { + apiUrl = process.env.PROXMOX_API_URL, + token = process.env.PROXMOX_API_TOKEN, + fetchImpl = fetch +} = {}) { + const url = `${apiUrl}/api2/json/nodes/${node}/${kindPath}/${vmid}/status/${op}`; + const res = await fetchImpl(url, { + method: 'POST', + headers: { Authorization: `PVEAPIToken=${token}` } + }); + if (!res.ok) throw new Error(`proxmox ${op} ${vmid} → ${res.status} ${await res.text?.() ?? ''}`); + const body = await res.json(); + return { ok: true, upid: body?.data ?? null }; +} diff --git a/tests/actions/proxmox.test.js b/tests/actions/proxmox.test.js new file mode 100644 index 0000000..ba5a8eb --- /dev/null +++ b/tests/actions/proxmox.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from 'vitest'; +import { powerGuest } from '../../lib/actions/channels/proxmox.js'; + +describe('proxmox channel', () => { + it('POSTs the scoped power op with the token header', async () => { + const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: 'UPID:...' }) })); + const out = await powerGuest({ node: 'z', vmid: 107, op: 'stop', kindPath: 'lxc' }, + { apiUrl: 'https://pve:8006', token: 'user@pve!void=secret', fetchImpl: fetchMock }); + expect(out.ok).toBe(true); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe('https://pve:8006/api2/json/nodes/z/lxc/107/status/stop'); + expect(opts.method).toBe('POST'); + expect(opts.headers.Authorization).toBe('PVEAPIToken=user@pve!void=secret'); + }); + it('throws on a non-ok response', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 403, text: async () => 'forbidden' })); + await expect(powerGuest({ node: 'z', vmid: 1, op: 'stop', kindPath: 'lxc' }, + { apiUrl: 'https://pve:8006', token: 't', fetchImpl: fetchMock })).rejects.toThrow(/403/); + }); +});