Compare commits

..

4 Commits

Author SHA1 Message Date
2adf1d5548 v5.5.0
All checks were successful
Release / build-and-release (push) Successful in 2m27s
2026-04-02 08:29:16 +00:00
067a7666e4 feat(proxmox): add Proxmox CLI auto-detection and interactive action setup improvements 2026-04-02 08:29:16 +00:00
0d863a1028 v5.4.1
All checks were successful
Release / build-and-release (push) Successful in 51s
2026-03-30 06:50:36 +00:00
c410a663b1 fix(deps): bump tsdeno and net-snmp patch dependencies 2026-03-30 06:50:36 +00:00
12 changed files with 600 additions and 175 deletions

View File

@@ -1,5 +1,19 @@
# 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
- update @git.zone/tsdeno from ^1.2.0 to ^1.3.1
- update net-snmp import from 3.26.0 to 3.26.1 in the SNMP manager
## 2026-03-30 - 5.4.0 - feat(snmp)
add configurable SNMP runtime units with v4.3 migration support

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.4.0",
"version": "5.5.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.4.0",
"version": "5.5.0",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [
"ups",
@@ -64,6 +64,6 @@
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"devDependencies": {
"@git.zone/tsdeno": "^1.2.0"
"@git.zone/tsdeno": "^1.3.1"
}
}

52
pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
'@git.zone/tsdeno':
specifier: ^1.2.0
version: 1.2.0
specifier: ^1.3.1
version: 1.3.1
packages:
@@ -39,8 +39,8 @@ packages:
'@design.estate/dees-element@2.2.3':
resolution: {integrity: sha512-MpAvJPrJDTDad8hUtdOzMgMFRE7n84O7INhvSlkTTLB3b84j8EKjwfUCMErGAo7Bq5zfw4LG7NnKhLYXXXjkXA==}
'@git.zone/tsdeno@1.2.0':
resolution: {integrity: sha512-Kx/9NchzKRoBhgj44V/ymF3rtspMAO+U+x7d3CFfBAyHCzVCrFtzIdVb5JULSTSR89d9a0L4VJVyVUoRdz/MUA==}
'@git.zone/tsdeno@1.3.1':
resolution: {integrity: sha512-1shZOSwMUfmonIe8OsX40EcQcS7/WFt+y+5+TQ7N6QmU+Hp6zjrfFvQwanr5Qcgh/E54WFNTfJk/PsJ26s6Oxw==}
hasBin: true
'@isaacs/cliui@9.0.0':
@@ -68,9 +68,6 @@ packages:
'@push.rocks/lik@6.3.1':
resolution: {integrity: sha512-UWDwGBaVx5yPtAFXqDDBtQZCzETUOA/7myQIXb+YBsuiIw4yQuhNZ23uY2ChQH2Zn6DLqdNSgQcYC0WywMZBNQ==}
'@push.rocks/npmextra@5.3.3':
resolution: {integrity: sha512-snLpSHwaQ5OXlZzF1KX/FY71W5LwajjBzor82Vue0smjEPnSeUPY5/JcVdMwtdprdJe13pc/EQQuIiL/zw4/yg==}
'@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
@@ -83,6 +80,9 @@ packages:
'@push.rocks/smartclickhouse@2.2.0':
resolution: {integrity: sha512-eTzKiREIPSzL1kPkVyD6vEbn+WV/DvQqDjP67VlhNlQGbRcemnJG/eLrUUR1ytmdIqnsZGEK6UYBgyj5nhzLNQ==}
'@push.rocks/smartconfig@6.1.0':
resolution: {integrity: sha512-B+xh63PhGAsSwuRyCKXr4PAjJ4HoVKhNysi67OGY6gGqGm6uopgEW1cvrUZ7T5ZSck9KlVx7ZTugbqm6dqBK1Q==}
'@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
@@ -1025,11 +1025,11 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdeno@1.2.0':
'@git.zone/tsdeno@1.3.1':
dependencies:
'@push.rocks/early': 4.0.4
'@push.rocks/npmextra': 5.3.3
'@push.rocks/smartcli': 4.0.20
'@push.rocks/smartconfig': 6.1.0
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartshell': 3.3.8
transitivePeerDependencies:
@@ -1070,23 +1070,6 @@ snapshots:
'@types/symbol-tree': 3.2.5
symbol-tree: 3.2.4
'@push.rocks/npmextra@5.3.3':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.4.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/qenv@6.1.3':
dependencies:
'@api.global/typedrequest': 3.3.0
@@ -1117,6 +1100,23 @@ snapshots:
'@push.rocks/smarturl': 3.1.0
'@push.rocks/webrequest': 4.0.5
'@push.rocks/smartconfig@6.1.0':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.4.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/smartdelay@3.0.5':
dependencies:
'@push.rocks/smartpromise': 4.2.3

View File

@@ -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

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.4.0',
version: '5.5.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}

View File

@@ -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';
}
/**

View File

@@ -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<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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<Array<{ vmid: number; name: string }>> {
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<Array<{ vmid: number; name: string }>> {
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<void> {
await execFileAsync(qmPath, ['shutdown', String(vmid)]);
}
private async shutdownCTCli(pctPath: string, vmid: number): Promise<void> {
await execFileAsync(pctPath, ['shutdown', String(vmid)]);
}
private async stopVMCli(qmPath: string, vmid: number): Promise<void> {
await execFileAsync(qmPath, ['stop', String(vmid)]);
}
private async stopCTCli(pctPath: string, vmid: number): Promise<void> {
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<string> {
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<string, string>,
@@ -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<string, string>,
@@ -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<string, string>,
insecure: boolean,
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
node: string,
timeout: number,
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
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<string, string> = {
'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`);

View File

@@ -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<IActionConfig> = {};
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') {

View File

@@ -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.');

View File

@@ -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;
/**

View File

@@ -1,4 +1,4 @@
import * as snmp from 'npm:net-snmp@3.26.0';
import * as snmp from 'npm:net-snmp@3.26.1';
import { Buffer } from 'node:buffer';
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.ts';