From 067a7666e43edc4c9b418df99aeb27d2ac0164a6 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 2 Apr 2026 08:29:16 +0000 Subject: [PATCH] feat(proxmox): add Proxmox CLI auto-detection and interactive action setup improvements --- changelog.md | 8 + readme.md | 63 ++++-- ts/00_commitinfo_data.ts | 2 +- ts/actions/base-action.ts | 2 + ts/actions/proxmox-action.ts | 368 ++++++++++++++++++++++++++++------- ts/cli/action-handler.ts | 186 +++++++++++++++--- ts/cli/ups-handler.ts | 77 +++++--- ts/constants.ts | 3 + 8 files changed, 564 insertions(+), 145 deletions(-) diff --git a/changelog.md b/changelog.md index 417bc36..11a464f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-02 - 5.5.0 - feat(proxmox) +add Proxmox CLI auto-detection and interactive action setup improvements + +- Add Proxmox action support for CLI mode using qm/pct with automatic fallback to REST API mode +- Expose proxmoxMode configuration and update CLI wizards to auto-detect local Proxmox tools before prompting for API credentials +- Expand interactive action creation to support shutdown, webhook, script, and Proxmox actions with improved displayed details +- Update documentation to cover Proxmox CLI/API modes and clarify shutdown delay units in minutes + ## 2026-03-30 - 5.4.1 - fix(deps) bump tsdeno and net-snmp patch dependencies diff --git a/readme.md b/readme.md index 15065dc..04cf2ce 100644 --- a/readme.md +++ b/readme.md @@ -12,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon - **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT -- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown +- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown (auto-detects CLI tools — no API token needed on Proxmox hosts) - **👥 Group Management** — Organize UPS devices into groups with flexible operating modes - **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical - **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical @@ -250,10 +250,9 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi "type": "proxmox", "triggerMode": "onlyThresholds", "thresholds": { "battery": 30, "runtime": 15 }, - "proxmoxHost": "localhost", - "proxmoxPort": 8006, - "proxmoxTokenId": "root@pam!nupst", - "proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + "proxmoxMode": "auto", + "proxmoxExcludeIds": [], + "proxmoxForceStop": true }, { "type": "shutdown", @@ -364,7 +363,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in | `shutdown` | Graceful system shutdown with configurable delay | | `webhook` | HTTP POST/GET notification to external services | | `script` | Execute custom shell scripts from `/etc/nupst/` | -| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers via REST API | +| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers (CLI or API) | #### Common Fields @@ -396,7 +395,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in | Field | Description | Default | | --------------- | ---------------------------------- | ------- | -| `shutdownDelay` | Seconds to wait before shutdown | `5` | +| `shutdownDelay` | Minutes to wait before shutdown | `5` | #### Webhook Action @@ -438,11 +437,38 @@ Actions define automated responses to UPS conditions. They run **sequentially in Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down. +NUPST supports **two operation modes** for Proxmox: + +| Mode | Description | Requirements | +| ------ | -------------------------------------------------------------- | ------------------------- | +| `cli` | Uses `qm`/`pct` commands directly — **no API token needed** 🎉 | Running as root on Proxmox host | +| `api` | Uses Proxmox REST API via HTTPS | API token required | +| `auto` | Prefers CLI if available, falls back to API (default) | — | + +> 💡 **On a Proxmox host running as root** (the typical setup), NUPST auto-detects `qm` and `pct` CLI tools and uses them directly. No API token setup required! + +**CLI mode example** (simplest — auto-detected on Proxmox hosts): + ```json { "type": "proxmox", "thresholds": { "battery": 30, "runtime": 15 }, "triggerMode": "onlyThresholds", + "proxmoxMode": "auto", + "proxmoxExcludeIds": [100, 101], + "proxmoxStopTimeout": 120, + "proxmoxForceStop": true +} +``` + +**API mode example** (for remote Proxmox hosts or non-root setups): + +```json +{ + "type": "proxmox", + "thresholds": { "battery": 30, "runtime": 15 }, + "triggerMode": "onlyThresholds", + "proxmoxMode": "api", "proxmoxHost": "localhost", "proxmoxPort": 8006, "proxmoxTokenId": "root@pam!nupst", @@ -456,17 +482,18 @@ Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the h | Field | Description | Default | | --------------------- | ----------------------------------------------- | ------------- | -| `proxmoxHost` | Proxmox API host | `localhost` | -| `proxmoxPort` | Proxmox API port | `8006` | +| `proxmoxMode` | Operation mode | `auto` | +| `proxmoxHost` | Proxmox API host (API mode only) | `localhost` | +| `proxmoxPort` | Proxmox API port (API mode only) | `8006` | | `proxmoxNode` | Proxmox node name | Auto-detect via hostname | -| `proxmoxTokenId` | API token ID (e.g. `root@pam!nupst`) | Required | -| `proxmoxTokenSecret` | API token secret (UUID) | Required | +| `proxmoxTokenId` | API token ID (API mode only) | — | +| `proxmoxTokenSecret` | API token secret (API mode only) | — | | `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` | | `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` | | `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` | -| `proxmoxInsecure` | Skip TLS verification (self-signed certs) | `true` | +| `proxmoxInsecure` | Skip TLS verification (API mode only) | `true` | -**Setting up the API token on Proxmox:** +**Setting up the API token** (only needed for API mode): ```bash # Create token with full privileges (no privilege separation) @@ -631,7 +658,7 @@ Full SNMPv3 support with authentication and encryption: ### Network Security -- Connects only to UPS devices and optionally Proxmox on local network +- Connects only to UPS devices and optionally Proxmox on local network (CLI mode uses local tools — no network needed for VM shutdown) - HTTP API disabled by default; token-required when enabled - No external internet connections @@ -741,7 +768,13 @@ upsc ups@localhost # if NUT CLI is installed ### Proxmox VMs Not Shutting Down ```bash -# Verify API token works +# CLI mode: verify qm/pct are available and you're root +which qm pct +whoami # should be 'root' +qm list # should list VMs +pct list # should list containers + +# API mode: verify API token works curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \ https://localhost:8006/api2/json/nodes/$(hostname)/qemu diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 345b560..babb620 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/nupst', - version: '5.4.1', + version: '5.5.0', description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' } diff --git a/ts/actions/base-action.ts b/ts/actions/base-action.ts index ccf5bdd..b11b3cd 100644 --- a/ts/actions/base-action.ts +++ b/ts/actions/base-action.ts @@ -116,6 +116,8 @@ export interface IActionConfig { proxmoxForceStop?: boolean; /** Skip TLS verification for self-signed certificates (default: true) */ proxmoxInsecure?: boolean; + /** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */ + proxmoxMode?: 'auto' | 'api' | 'cli'; } /** diff --git a/ts/actions/proxmox-action.ts b/ts/actions/proxmox-action.ts index e9331bd..f811faa 100644 --- a/ts/actions/proxmox-action.ts +++ b/ts/actions/proxmox-action.ts @@ -1,14 +1,22 @@ +import * as fs from 'node:fs'; import * as os from 'node:os'; +import process from 'node:process'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { Action, type IActionContext } from './base-action.ts'; import { logger } from '../logger.ts'; import { PROXMOX, UI } from '../constants.ts'; +const execFileAsync = promisify(execFile); + /** * ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers * - * Uses the Proxmox REST API via HTTPS with API token authentication. - * Shuts down running QEMU VMs and LXC containers, waits for completion, - * and optionally force-stops any that don't respond. + * Supports two operation modes: + * - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host) + * - API mode: Uses the Proxmox REST API via HTTPS with API token authentication + * + * In 'auto' mode (default), CLI is preferred when available, falling back to API. * * This action should be placed BEFORE shutdown actions in the action chain * so that VMs are stopped before the host is shut down. @@ -16,6 +24,77 @@ import { PROXMOX, UI } from '../constants.ts'; export class ProxmoxAction extends Action { readonly type = 'proxmox'; + /** + * Check if Proxmox CLI tools (qm, pct) are available on the system + * Used by CLI wizards and by execute() for auto-detection + */ + static detectCliAvailability(): { + available: boolean; + qmPath: string | null; + pctPath: string | null; + isRoot: boolean; + } { + let qmPath: string | null = null; + let pctPath: string | null = null; + + for (const dir of PROXMOX.CLI_TOOL_PATHS) { + if (!qmPath) { + const p = `${dir}/qm`; + try { + if (fs.existsSync(p)) qmPath = p; + } catch (_e) { + // continue + } + } + if (!pctPath) { + const p = `${dir}/pct`; + try { + if (fs.existsSync(p)) pctPath = p; + } catch (_e) { + // continue + } + } + } + + const isRoot = !!(process.getuid && process.getuid() === 0); + + return { + available: qmPath !== null && pctPath !== null && isRoot, + qmPath, + pctPath, + isRoot, + }; + } + + /** + * Resolve the operation mode based on config and environment + */ + private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | { mode: 'api'; qmPath?: undefined; pctPath?: undefined } { + const configuredMode = this.config.proxmoxMode || 'auto'; + + if (configuredMode === 'api') { + return { mode: 'api' }; + } + + const detection = ProxmoxAction.detectCliAvailability(); + + if (configuredMode === 'cli') { + if (!detection.qmPath || !detection.pctPath) { + throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?'); + } + if (!detection.isRoot) { + throw new Error('CLI mode requires root access'); + } + return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath }; + } + + // Auto-detect + if (detection.available && detection.qmPath && detection.pctPath) { + return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath }; + } + return { mode: 'api' }; + } + /** * Execute the Proxmox shutdown action */ @@ -29,30 +108,21 @@ export class ProxmoxAction extends Action { return; } - const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; - const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; + const resolved = this.resolveMode(); const node = this.config.proxmoxNode || os.hostname(); - const tokenId = this.config.proxmoxTokenId; - const tokenSecret = this.config.proxmoxTokenSecret; const excludeIds = new Set(this.config.proxmoxExcludeIds || []); const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000; const forceStop = this.config.proxmoxForceStop !== false; // default true - const insecure = this.config.proxmoxInsecure !== false; // default true - - if (!tokenId || !tokenSecret) { - logger.error('Proxmox API token ID and secret are required'); - return; - } - - const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; - const headers: Record = { - 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`, - }; logger.log(''); logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning'); + logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`); logger.logBoxLine(`Node: ${node}`); - logger.logBoxLine(`API: ${host}:${port}`); + if (resolved.mode === 'api') { + const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; + const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; + logger.logBoxLine(`API: ${host}:${port}`); + } logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`); logger.logBoxLine(`Trigger: ${context.triggerReason}`); if (excludeIds.size > 0) { @@ -62,9 +132,34 @@ export class ProxmoxAction extends Action { logger.log(''); try { - // Collect running VMs and CTs - const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure); - const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure); + let runningVMs: Array<{ vmid: number; name: string }>; + let runningCTs: Array<{ vmid: number; name: string }>; + + if (resolved.mode === 'cli') { + runningVMs = await this.getRunningVMsCli(resolved.qmPath); + runningCTs = await this.getRunningCTsCli(resolved.pctPath); + } else { + // API mode - validate token + const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; + const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; + const tokenId = this.config.proxmoxTokenId; + const tokenSecret = this.config.proxmoxTokenSecret; + const insecure = this.config.proxmoxInsecure !== false; + + if (!tokenId || !tokenSecret) { + logger.error('Proxmox API token ID and secret are required for API mode'); + logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode'); + return; + } + + const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; + const headers: Record = { + 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`, + }; + + runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure); + runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, insecure); + } // Filter out excluded IDs const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid)); @@ -78,15 +173,33 @@ export class ProxmoxAction extends Action { logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`); - // Send shutdown commands to all VMs and CTs - for (const vm of vmsToStop) { - await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure); - logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); - } + // Send shutdown commands + if (resolved.mode === 'cli') { + for (const vm of vmsToStop) { + await this.shutdownVMCli(resolved.qmPath, vm.vmid); + logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); + } + for (const ct of ctsToStop) { + await this.shutdownCTCli(resolved.pctPath, ct.vmid); + logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); + } + } else { + const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; + const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; + const insecure = this.config.proxmoxInsecure !== false; + const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; + const headers: Record = { + 'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`, + }; - for (const ct of ctsToStop) { - await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure); - logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); + for (const vm of vmsToStop) { + await this.shutdownVMApi(baseUrl, node, vm.vmid, headers, insecure); + logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); + } + for (const ct of ctsToStop) { + await this.shutdownCTApi(baseUrl, node, ct.vmid, headers, insecure); + logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); + } } // Poll until all stopped or timeout @@ -95,23 +208,31 @@ export class ProxmoxAction extends Action { ...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })), ]; - const remaining = await this.waitForShutdown( - baseUrl, - node, - allIds, - headers, - insecure, - stopTimeout, - ); + const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout); if (remaining.length > 0 && forceStop) { logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`); for (const item of remaining) { try { - if (item.type === 'qemu') { - await this.stopVM(baseUrl, node, item.vmid, headers, insecure); + if (resolved.mode === 'cli') { + if (item.type === 'qemu') { + await this.stopVMCli(resolved.qmPath, item.vmid); + } else { + await this.stopCTCli(resolved.pctPath, item.vmid); + } } else { - await this.stopCT(baseUrl, node, item.vmid, headers, insecure); + const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; + const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; + const insecure = this.config.proxmoxInsecure !== false; + const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; + const headers: Record = { + 'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`, + }; + if (item.type === 'qemu') { + await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure); + } else { + await this.stopCTApi(baseUrl, node, item.vmid, headers, insecure); + } } logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`); } catch (error) { @@ -134,6 +255,110 @@ export class ProxmoxAction extends Action { } } + // ─── CLI-based methods ───────────────────────────────────────────── + + /** + * Get list of running QEMU VMs via qm list + */ + private async getRunningVMsCli( + qmPath: string, + ): Promise> { + try { + const { stdout } = await execFileAsync(qmPath, ['list']); + return this.parseQmList(stdout); + } catch (error) { + logger.error( + `Failed to list VMs via CLI: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } + + /** + * Get list of running LXC containers via pct list + */ + private async getRunningCTsCli( + pctPath: string, + ): Promise> { + try { + const { stdout } = await execFileAsync(pctPath, ['list']); + return this.parsePctList(stdout); + } catch (error) { + logger.error( + `Failed to list CTs via CLI: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } + + /** + * Parse qm list output + * Format: VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID + */ + private parseQmList(output: string): Array<{ vmid: number; name: string }> { + const results: Array<{ vmid: number; name: string }> = []; + const lines = output.trim().split('\n'); + + // Skip header line + for (let i = 1; i < lines.length; i++) { + const match = lines[i].match(/^\s*(\d+)\s+(\S+)\s+(running|stopped|paused)/); + if (match && match[3] === 'running') { + results.push({ vmid: parseInt(match[1], 10), name: match[2] }); + } + } + return results; + } + + /** + * Parse pct list output + * Format: VMID Status Lock Name + */ + private parsePctList(output: string): Array<{ vmid: number; name: string }> { + const results: Array<{ vmid: number; name: string }> = []; + const lines = output.trim().split('\n'); + + // Skip header line + for (let i = 1; i < lines.length; i++) { + const match = lines[i].match(/^\s*(\d+)\s+(running|stopped)\s+\S*\s*(.*)/); + if (match && match[2] === 'running') { + results.push({ vmid: parseInt(match[1], 10), name: match[3]?.trim() || '' }); + } + } + return results; + } + + private async shutdownVMCli(qmPath: string, vmid: number): Promise { + await execFileAsync(qmPath, ['shutdown', String(vmid)]); + } + + private async shutdownCTCli(pctPath: string, vmid: number): Promise { + await execFileAsync(pctPath, ['shutdown', String(vmid)]); + } + + private async stopVMCli(qmPath: string, vmid: number): Promise { + await execFileAsync(qmPath, ['stop', String(vmid)]); + } + + private async stopCTCli(pctPath: string, vmid: number): Promise { + await execFileAsync(pctPath, ['stop', String(vmid)]); + } + + /** + * Get VM/CT status via CLI + * Returns the status string (e.g., 'running', 'stopped') + */ + private async getStatusCli( + toolPath: string, + vmid: number, + ): Promise { + const { stdout } = await execFileAsync(toolPath, ['status', String(vmid)]); + // Output format: "status: running\n" + const status = stdout.trim().split(':')[1]?.trim() || 'unknown'; + return status; + } + + // ─── API-based methods ───────────────────────────────────────────── + /** * Make an API request to the Proxmox server */ @@ -173,9 +398,9 @@ export class ProxmoxAction extends Action { } /** - * Get list of running QEMU VMs + * Get list of running QEMU VMs via API */ - private async getRunningVMs( + private async getRunningVMsApi( baseUrl: string, node: string, headers: Record, @@ -201,9 +426,9 @@ export class ProxmoxAction extends Action { } /** - * Get list of running LXC containers + * Get list of running LXC containers via API */ - private async getRunningCTs( + private async getRunningCTsApi( baseUrl: string, node: string, headers: Record, @@ -228,10 +453,7 @@ export class ProxmoxAction extends Action { } } - /** - * Send graceful shutdown to a QEMU VM - */ - private async shutdownVM( + private async shutdownVMApi( baseUrl: string, node: string, vmid: number, @@ -246,10 +468,7 @@ export class ProxmoxAction extends Action { ); } - /** - * Send graceful shutdown to an LXC container - */ - private async shutdownCT( + private async shutdownCTApi( baseUrl: string, node: string, vmid: number, @@ -264,10 +483,7 @@ export class ProxmoxAction extends Action { ); } - /** - * Force-stop a QEMU VM - */ - private async stopVM( + private async stopVMApi( baseUrl: string, node: string, vmid: number, @@ -282,10 +498,7 @@ export class ProxmoxAction extends Action { ); } - /** - * Force-stop an LXC container - */ - private async stopCT( + private async stopCTApi( baseUrl: string, node: string, vmid: number, @@ -300,15 +513,15 @@ export class ProxmoxAction extends Action { ); } + // ─── Shared methods ──────────────────────────────────────────────── + /** * Wait for VMs/CTs to shut down, return any that are still running after timeout */ private async waitForShutdown( - baseUrl: string, - node: string, items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>, - headers: Record, - insecure: boolean, + resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string }, + node: string, timeout: number, ): Promise> { const startTime = Date.now(); @@ -323,12 +536,27 @@ export class ProxmoxAction extends Action { for (const item of remaining) { try { - const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`; - const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as { - data: { status: string }; - }; + let status: string; - if (response.data?.status === 'running') { + if (resolved.mode === 'cli') { + const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!; + status = await this.getStatusCli(toolPath, item.vmid); + } else { + const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; + const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; + const insecure = this.config.proxmoxInsecure !== false; + const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; + const headers: Record = { + 'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`, + }; + const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`; + const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as { + data: { status: string }; + }; + status = response.data?.status || 'unknown'; + } + + if (status === 'running') { stillRunning.push(item); } else { logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`); diff --git a/ts/cli/action-handler.ts b/ts/cli/action-handler.ts index 7e77616..45172d5 100644 --- a/ts/cli/action-handler.ts +++ b/ts/cli/action-handler.ts @@ -3,6 +3,7 @@ import { Nupst } from '../nupst.ts'; import { type ITableColumn, logger } from '../logger.ts'; import { symbols, theme } from '../colors.ts'; import type { IActionConfig } from '../actions/base-action.ts'; +import { ProxmoxAction } from '../actions/proxmox-action.ts'; import type { IGroupConfig, IUpsConfig } from '../daemon.ts'; import * as helpers from '../helpers/index.ts'; @@ -65,11 +66,146 @@ export class ActionHandler { logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`); logger.log(''); - // Action type (currently only shutdown is supported) - const type = 'shutdown'; - logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`); + // Action type selection + logger.log(` ${theme.dim('Action Type:')}`); + logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`); + logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`); + logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`); + logger.log(` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`); - // Battery threshold + const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `); + const typeValue = parseInt(typeInput, 10) || 1; + + const newAction: Partial = {}; + + if (typeValue === 1) { + // Shutdown action + newAction.type = 'shutdown'; + + const delayStr = await prompt( + ` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `, + ); + const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5; + if (isNaN(shutdownDelay) || shutdownDelay < 0) { + logger.error('Invalid shutdown delay. Must be >= 0.'); + process.exit(1); + } + newAction.shutdownDelay = shutdownDelay; + } else if (typeValue === 2) { + // Webhook action + newAction.type = 'webhook'; + + const url = await prompt(` ${theme.dim('Webhook URL:')} `); + if (!url.trim()) { + logger.error('Webhook URL is required.'); + process.exit(1); + } + newAction.webhookUrl = url.trim(); + + logger.log(''); + logger.log(` ${theme.dim('HTTP Method:')}`); + logger.log(` ${theme.dim('1)')} POST (JSON body)`); + logger.log(` ${theme.dim('2)')} GET (query parameters)`); + const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `); + newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST'; + + const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `); + const timeout = parseInt(timeoutInput, 10); + if (timeoutInput.trim() && !isNaN(timeout)) { + newAction.webhookTimeout = timeout * 1000; + } + } else if (typeValue === 3) { + // Script action + newAction.type = 'script'; + + const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `); + if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) { + logger.error('Script path must end with .sh.'); + process.exit(1); + } + newAction.scriptPath = scriptPath.trim(); + + const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `); + const timeout = parseInt(timeoutInput, 10); + if (timeoutInput.trim() && !isNaN(timeout)) { + newAction.scriptTimeout = timeout * 1000; + } + } else if (typeValue === 4) { + // Proxmox action + newAction.type = 'proxmox'; + + // Auto-detect CLI availability + const detection = ProxmoxAction.detectCliAvailability(); + + if (detection.available) { + logger.log(''); + logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.'); + logger.dim(` qm: ${detection.qmPath}`); + logger.dim(` pct: ${detection.pctPath}`); + newAction.proxmoxMode = 'cli'; + } else { + logger.log(''); + if (!detection.isRoot) { + logger.warn('Not running as root - CLI mode unavailable, using API mode.'); + } else { + logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.'); + } + logger.log(''); + logger.info('Proxmox API Settings:'); + logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0'); + + const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `); + newAction.proxmoxHost = pxHost.trim() || 'localhost'; + + const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `); + const pxPort = parseInt(pxPortInput, 10); + newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006; + + const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `); + if (pxNode.trim()) { + newAction.proxmoxNode = pxNode.trim(); + } + + const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `); + if (!tokenId.trim()) { + logger.error('Token ID is required for API mode.'); + process.exit(1); + } + newAction.proxmoxTokenId = tokenId.trim(); + + const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `); + if (!tokenSecret.trim()) { + logger.error('Token Secret is required for API mode.'); + process.exit(1); + } + newAction.proxmoxTokenSecret = tokenSecret.trim(); + + const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `); + newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n'; + newAction.proxmoxMode = 'api'; + } + + // Common Proxmox settings (both modes) + const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `); + if (excludeInput.trim()) { + newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)); + } + + const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `); + const stopTimeout = parseInt(timeoutInput, 10); + if (timeoutInput.trim() && !isNaN(stopTimeout)) { + newAction.proxmoxStopTimeout = stopTimeout; + } + + const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `); + newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n'; + } else { + logger.error('Invalid action type.'); + process.exit(1); + } + + // Battery threshold (all action types) + logger.log(''); const batteryStr = await prompt( ` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `, ); @@ -89,6 +225,8 @@ export class ActionHandler { process.exit(1); } + newAction.thresholds = { battery, runtime }; + // Trigger mode logger.log(''); logger.log(` ${theme.dim('Trigger mode:')}`); @@ -113,33 +251,13 @@ export class ActionHandler { '': 'onlyThresholds', // Default }; const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds'; - - // Shutdown delay - const delayStr = await prompt( - ` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `, - ); - const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5; - if (isNaN(shutdownDelay) || shutdownDelay < 0) { - logger.error('Invalid shutdown delay. Must be >= 0.'); - process.exit(1); - } - - // Create the action - const newAction: IActionConfig = { - type, - thresholds: { - battery, - runtime, - }, - triggerMode: triggerMode as IActionConfig['triggerMode'], - shutdownDelay, - }; + newAction.triggerMode = triggerMode as IActionConfig['triggerMode']; // Add to target (UPS or group) if (!target!.actions) { target!.actions = []; } - target!.actions.push(newAction); + target!.actions.push(newAction as IActionConfig); await this.nupst.getDaemon().saveConfig(config); @@ -350,11 +468,19 @@ export class ActionHandler { ]; const rows = target.actions.map((action, index) => { - let details = `${action.shutdownDelay || 5}s delay`; + let details = `${action.shutdownDelay || 5}min delay`; if (action.type === 'proxmox') { - const host = action.proxmoxHost || 'localhost'; - const port = action.proxmoxPort || 8006; - details = `${host}:${port}`; + const mode = action.proxmoxMode || 'auto'; + if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) { + details = 'CLI mode'; + } else { + const host = action.proxmoxHost || 'localhost'; + const port = action.proxmoxPort || 8006; + details = `API ${host}:${port}`; + } + if (action.proxmoxExcludeIds?.length) { + details += `, excl: ${action.proxmoxExcludeIds.join(',')}`; + } } else if (action.type === 'webhook') { details = action.webhookUrl || theme.dim('N/A'); } else if (action.type === 'script') { diff --git a/ts/cli/ups-handler.ts b/ts/cli/ups-handler.ts index 12e8fbc..8d504bb 100644 --- a/ts/cli/ups-handler.ts +++ b/ts/cli/ups-handler.ts @@ -9,6 +9,7 @@ import type { IUpsdConfig } from '../upsd/types.ts'; import type { TProtocol } from '../protocol/types.ts'; import type { INupstConfig, IUpsConfig } from '../daemon.ts'; import type { IActionConfig } from '../actions/base-action.ts'; +import { ProxmoxAction } from '../actions/proxmox-action.ts'; import { UPSD } from '../constants.ts'; /** @@ -1184,37 +1185,58 @@ export class UpsHandler { // Proxmox action action.type = 'proxmox'; - logger.log(''); - logger.info('Proxmox API Settings:'); - logger.dim('Requires a Proxmox API token. Create one with:'); - logger.dim(' pveum user token add root@pam nupst --privsep=0'); + // Auto-detect CLI availability + const detection = ProxmoxAction.detectCliAvailability(); - const pxHost = await prompt('Proxmox Host [localhost]: '); - action.proxmoxHost = pxHost.trim() || 'localhost'; + if (detection.available) { + logger.log(''); + logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.'); + logger.dim(` qm: ${detection.qmPath}`); + logger.dim(` pct: ${detection.pctPath}`); + action.proxmoxMode = 'cli'; + } else { + logger.log(''); + if (!detection.isRoot) { + logger.warn('Not running as root - CLI mode unavailable, using API mode.'); + } else { + logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.'); + } + logger.log(''); + logger.info('Proxmox API Settings:'); + logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0'); - const pxPortInput = await prompt('Proxmox API Port [8006]: '); - const pxPort = parseInt(pxPortInput, 10); - action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006; + const pxHost = await prompt('Proxmox Host [localhost]: '); + action.proxmoxHost = pxHost.trim() || 'localhost'; - const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): '); - if (pxNode.trim()) { - action.proxmoxNode = pxNode.trim(); + const pxPortInput = await prompt('Proxmox API Port [8006]: '); + const pxPort = parseInt(pxPortInput, 10); + action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006; + + const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): '); + if (pxNode.trim()) { + action.proxmoxNode = pxNode.trim(); + } + + const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): '); + if (!tokenId.trim()) { + logger.warn('Token ID is required for API mode, skipping'); + continue; + } + action.proxmoxTokenId = tokenId.trim(); + + const tokenSecret = await prompt('API Token Secret: '); + if (!tokenSecret.trim()) { + logger.warn('Token Secret is required for API mode, skipping'); + continue; + } + action.proxmoxTokenSecret = tokenSecret.trim(); + + const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): '); + action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n'; + action.proxmoxMode = 'api'; } - const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): '); - if (!tokenId.trim()) { - logger.warn('Token ID is required for Proxmox action, skipping'); - continue; - } - action.proxmoxTokenId = tokenId.trim(); - - const tokenSecret = await prompt('API Token Secret: '); - if (!tokenSecret.trim()) { - logger.warn('Token Secret is required for Proxmox action, skipping'); - continue; - } - action.proxmoxTokenSecret = tokenSecret.trim(); - + // Common Proxmox settings (both modes) const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): '); if (excludeInput.trim()) { action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)); @@ -1229,9 +1251,6 @@ export class UpsHandler { const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): '); action.proxmoxForceStop = forceInput.toLowerCase() !== 'n'; - const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): '); - action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n'; - logger.log(''); logger.info('Note: Place the Proxmox action BEFORE the shutdown action'); logger.dim('in the action chain so VMs shut down before the host.'); diff --git a/ts/constants.ts b/ts/constants.ts index 70cb5d2..579bda8 100644 --- a/ts/constants.ts +++ b/ts/constants.ts @@ -157,6 +157,9 @@ export const PROXMOX = { /** Proxmox API base path */ API_BASE: '/api2/json', + + /** Common paths to search for Proxmox CLI tools (qm, pct) */ + CLI_TOOL_PATHS: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'] as readonly string[], } as const; /**