feat(daemon): Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2

This commit is contained in:
2026-02-20 11:51:59 +00:00
parent 782c8c9555
commit 42b8eaf6d2
30 changed files with 2183 additions and 697 deletions

View File

@@ -5,8 +5,11 @@ import { type ITableColumn, logger } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
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 { UPSD } from '../constants.ts';
/**
* Thresholds configuration for CLI display
@@ -89,31 +92,46 @@ export class UpsHandler {
const upsId = helpers.shortId();
const name = await prompt('UPS Name: ');
// Select protocol
logger.log('');
logger.info('Communication Protocol:');
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt('Select protocol [1]: ');
const protocolChoice = parseInt(protocolInput, 10) || 1;
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
// Create a new UPS configuration object with defaults
const newUps = {
const newUps: Record<string, unknown> & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = {
id: upsId,
name: name || `UPS-${upsId}`,
snmp: {
protocol,
groups: [],
actions: [],
};
if (protocol === 'snmp') {
newUps.snmp = {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
upsModel: 'cyberpower' as TUpsModel,
},
thresholds: {
battery: 60,
runtime: 20,
},
groups: [],
actions: [],
};
// Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt);
};
// Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt);
} else {
newUps.upsd = {
host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
upsName: UPSD.DEFAULT_UPS_NAME,
timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
await this.gatherUpsdSettings(newUps.upsd, prompt);
}
// Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler();
@@ -132,10 +150,14 @@ export class UpsHandler {
// Save the configuration
await this.nupst.getDaemon().saveConfig(config as INupstConfig);
this.displayUpsConfigSummary(newUps);
this.displayUpsConfigSummary(newUps as unknown as IUpsConfig);
// Test the connection if requested
await this.optionallyTestConnection(newUps.snmp, prompt);
if (protocol === 'snmp' && newUps.snmp) {
await this.optionallyTestConnection(newUps.snmp as ISnmpConfig, prompt);
} else if (protocol === 'upsd' && newUps.upsd) {
await this.optionallyTestUpsdConnection(newUps.upsd, prompt);
}
// Check if service is running and restart it if needed
await this.restartServiceIfRunning();
@@ -232,11 +254,51 @@ export class UpsHandler {
upsToEdit.name = newName;
}
// Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Show current protocol and allow changing
const currentProtocol = upsToEdit.protocol || 'snmp';
logger.log('');
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt(`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `);
const protocolChoice = parseInt(protocolInput, 10);
if (protocolChoice === 2) {
upsToEdit.protocol = 'upsd';
} else if (protocolChoice === 1) {
upsToEdit.protocol = 'snmp';
}
// else keep current
// Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
const editProtocol = upsToEdit.protocol || 'snmp';
if (editProtocol === 'snmp') {
// Initialize SNMP config if switching from UPSD
if (!upsToEdit.snmp) {
upsToEdit.snmp = {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
upsModel: 'cyberpower' as TUpsModel,
};
}
// Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
} else {
// Initialize UPSD config if switching from SNMP
if (!upsToEdit.upsd) {
upsToEdit.upsd = {
host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
upsName: UPSD.DEFAULT_UPS_NAME,
timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
}
await this.gatherUpsdSettings(upsToEdit.upsd, prompt);
}
// Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler();
@@ -260,7 +322,11 @@ export class UpsHandler {
this.displayUpsConfigSummary(upsToEdit);
// Test the connection if requested
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
if (editProtocol === 'snmp' && upsToEdit.snmp) {
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
} else if (editProtocol === 'upsd' && upsToEdit.upsd) {
await this.optionallyTestUpsdConnection(upsToEdit.upsd, prompt);
}
// Check if service is running and restart it if needed
await this.restartServiceIfRunning();
@@ -397,17 +463,31 @@ export class UpsHandler {
}
// Prepare table data
const rows = config.upsDevices.map((ups) => ({
id: ups.id,
name: ups.name || '',
host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower',
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
}));
const rows = config.upsDevices.map((ups) => {
const protocol = ups.protocol || 'snmp';
let host = 'N/A';
let model = '';
if (protocol === 'upsd' && ups.upsd) {
host = `${ups.upsd.host}:${ups.upsd.port}`;
model = `NUT:${ups.upsd.upsName}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
model = ups.snmp.upsModel || 'cyberpower';
}
return {
id: ups.id,
name: ups.name || '',
protocol: protocol.toUpperCase(),
host,
model,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
};
});
const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
{ header: 'Name', key: 'name', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' },
@@ -482,58 +562,71 @@ export class UpsHandler {
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
const isUpsConfig = 'id' in config && 'name' in config;
// Get SNMP config and other values based on config type
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000;
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
const boxWidth = 45;
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
logger.logBoxLine(`UPS ID: ${upsId}`);
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
if (!snmpConfig) {
logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxEnd();
return;
}
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
const upsdConfig = (config as IUpsConfig).upsd!;
logger.logBoxLine('UPSD/NIS Settings:');
logger.logBoxLine(` Host: ${upsdConfig.host}`);
logger.logBoxLine(` Port: ${upsdConfig.port}`);
logger.logBoxLine(` UPS Name: ${upsdConfig.upsName}`);
logger.logBoxLine(` Timeout: ${upsdConfig.timeout / 1000} seconds`);
if (upsdConfig.username) {
logger.logBoxLine(` Auth: ${upsdConfig.username}`);
}
} else {
// SNMP display
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${snmpConfig.host}`);
logger.logBoxLine(` Port: ${snmpConfig.port}`);
logger.logBoxLine(` Version: ${snmpConfig.version}`);
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
logger.logBoxLine(` Community: ${snmpConfig.community}`);
} else if (snmpConfig.version === 3) {
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
logger.logBoxLine(` Username: ${snmpConfig.username}`);
// Show auth and privacy details based on security level
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
if (!snmpConfig) {
logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxEnd();
return;
}
if (snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${snmpConfig.host}`);
logger.logBoxLine(` Port: ${snmpConfig.port}`);
logger.logBoxLine(` Version: ${snmpConfig.version}`);
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
logger.logBoxLine(` Community: ${snmpConfig.community}`);
} else if (snmpConfig.version === 3) {
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
logger.logBoxLine(` Username: ${snmpConfig.username}`);
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
}
if (snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
}
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
}
// Show timeout value
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
}
// Show OIDs if custom model is selected
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
// Show group assignments if this is a UPS config
if (isUpsConfig) {
const groups = (config as IUpsConfig).groups;
@@ -555,25 +648,36 @@ export class UpsHandler {
const isUpsConfig = 'id' in config && 'name' in config;
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`);
try {
// Get SNMP config based on config type
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
let status: ISnmpUpsStatus;
if (!snmpConfig) {
throw new Error('SNMP configuration not found');
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
const upsdConfig = (config as IUpsConfig).upsd!;
const testConfig = {
...upsdConfig,
timeout: Math.min(upsdConfig.timeout, 10000),
};
status = await this.nupst.getUpsd().getUpsStatus(testConfig);
} else {
// SNMP protocol
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
if (!snmpConfig) {
throw new Error('SNMP configuration not found');
}
const testConfig: ISnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000),
};
status = await this.nupst.getSnmp().getUpsStatus(testConfig);
}
const testConfig: ISnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
const boxWidth = 45;
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
logger.logBoxLine('UPS Status:');
@@ -872,6 +976,97 @@ export class UpsHandler {
}
}
/**
* Gather UPSD/NIS connection settings
* @param upsdConfig UPSD configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherUpsdSettings(
upsdConfig: IUpsdConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
logger.info('UPSD/NIS Connection Settings:');
logger.dim('Connect to a local NUT (Network UPS Tools) server');
// Host
const defaultHost = upsdConfig.host || '127.0.0.1';
const host = await prompt(`UPSD Host [${defaultHost}]: `);
upsdConfig.host = host.trim() || defaultHost;
// Port
const defaultPort = upsdConfig.port || UPSD.DEFAULT_PORT;
const portInput = await prompt(`UPSD Port [${defaultPort}]: `);
const port = parseInt(portInput, 10);
upsdConfig.port = portInput.trim() && !isNaN(port) ? port : defaultPort;
// UPS Name
const defaultUpsName = upsdConfig.upsName || UPSD.DEFAULT_UPS_NAME;
const upsName = await prompt(`NUT UPS Name [${defaultUpsName}]: `);
upsdConfig.upsName = upsName.trim() || defaultUpsName;
// Timeout
const defaultTimeout = (upsdConfig.timeout || UPSD.DEFAULT_TIMEOUT_MS) / 1000;
const timeoutInput = await prompt(`Timeout in seconds [${defaultTimeout}]: `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
upsdConfig.timeout = timeout * 1000;
}
// Authentication (optional)
logger.log('');
logger.info('Authentication (optional):');
logger.dim('Leave blank if your NUT server does not require authentication');
const username = await prompt(`Username [${upsdConfig.username || ''}]: `);
if (username.trim()) {
upsdConfig.username = username.trim();
const password = await prompt(`Password: `);
if (password.trim()) {
upsdConfig.password = password.trim();
}
}
}
/**
* Optionally test UPSD connection
* @param upsdConfig UPSD configuration to test
* @param prompt Function to prompt for user input
*/
private async optionallyTestUpsdConnection(
upsdConfig: IUpsdConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
const testConnection = await prompt(
'Would you like to test the connection to your UPS? (y/N): ',
);
if (testConnection.toLowerCase() === 'y') {
logger.log('\nTesting connection to UPSD server...');
try {
const testConfig = {
...upsdConfig,
timeout: Math.min(upsdConfig.timeout, 10000),
};
const status = await this.nupst.getUpsd().getUpsStatus(testConfig);
const boxWidth = 45;
logger.log('');
logger.logBoxTitle('Connection Successful!', boxWidth);
logger.logBoxLine('UPS Status:');
logger.logBoxLine(`✓ Power Status: ${status.powerStatus}`);
logger.logBoxLine(`✓ Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(`✓ Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd();
} catch (error) {
const errorBoxWidth = 45;
logger.log('');
logger.logBoxTitle('Connection Failed!', errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
logger.logBoxEnd();
logger.log('\nPlease check your NUT server settings and try again.');
}
}
}
/**
* Gather action configuration settings
* @param actions Actions array to configure
@@ -901,6 +1096,7 @@ export class UpsHandler {
logger.dim(' 1) Shutdown (system shutdown)');
logger.dim(' 2) Webhook (HTTP notification)');
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
logger.dim(' 4) Proxmox (gracefully shut down VMs/LXCs before host shutdown)');
const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1;
@@ -955,6 +1151,61 @@ export class UpsHandler {
if (timeoutInput.trim() && !isNaN(timeout)) {
action.scriptTimeout = timeout * 1000; // Convert to ms
}
} else if (typeValue === 4) {
// 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');
const pxHost = await prompt('Proxmox Host [localhost]: ');
action.proxmoxHost = pxHost.trim() || 'localhost';
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 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();
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));
}
const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: ');
const stopTimeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
action.proxmoxStopTimeout = stopTimeout;
}
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.');
} else {
logger.warn('Invalid action type, skipping');
continue;
@@ -1032,12 +1283,20 @@ export class UpsHandler {
*/
private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45;
const protocol = ups.protocol || 'snmp';
logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
logger.logBoxLine(`UPS ID: ${ups.id}`);
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
if (protocol === 'upsd' && ups.upsd) {
logger.logBoxLine(`UPSD Host: ${ups.upsd.host}:${ups.upsd.port}`);
logger.logBoxLine(`NUT UPS Name: ${ups.upsd.upsName}`);
} else if (ups.snmp) {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
}
if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);