532 lines
19 KiB
TypeScript
532 lines
19 KiB
TypeScript
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import * as dgram from 'dgram';
|
|
import type { IOidSet, ISnmpConfig, TUpsModel, IUpsStatus } from './types.js';
|
|
import { UpsOidSets } from './oid-sets.js';
|
|
import { SnmpPacketCreator } from './packet-creator.js';
|
|
import { SnmpPacketParser } from './packet-parser.js';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
/**
|
|
* Class for SNMP communication with UPS devices
|
|
* Main entry point for SNMP functionality
|
|
*/
|
|
export class NupstSnmp {
|
|
// Active OID set
|
|
private activeOIDs: IOidSet;
|
|
// Reference to the parent Nupst instance
|
|
private nupst: any; // Type 'any' to avoid circular dependency
|
|
|
|
// Default SNMP configuration
|
|
private readonly DEFAULT_CONFIG: ISnmpConfig = {
|
|
host: '127.0.0.1', // Default to localhost
|
|
port: 161, // Default SNMP port
|
|
community: 'public', // Default community string for v1/v2c
|
|
version: 1, // SNMPv1
|
|
timeout: 5000, // 5 seconds timeout
|
|
upsModel: 'cyberpower', // Default UPS model
|
|
};
|
|
|
|
// SNMPv3 engine ID and counters
|
|
private engineID: Buffer = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
|
private engineBoots: number = 0;
|
|
private engineTime: number = 0;
|
|
private requestID: number = 1;
|
|
private debug: boolean = false; // Enable for debug output
|
|
|
|
/**
|
|
* Create a new SNMP manager
|
|
* @param debug Whether to enable debug mode
|
|
*/
|
|
constructor(debug = false) {
|
|
this.debug = debug;
|
|
// Set default OID set
|
|
this.activeOIDs = UpsOidSets.getOidSet('cyberpower');
|
|
}
|
|
|
|
/**
|
|
* Set reference to the main Nupst instance
|
|
* @param nupst Reference to the main Nupst instance
|
|
*/
|
|
public setNupst(nupst: any): void {
|
|
this.nupst = nupst;
|
|
}
|
|
|
|
/**
|
|
* Get reference to the main Nupst instance
|
|
*/
|
|
public getNupst(): any {
|
|
return this.nupst;
|
|
}
|
|
|
|
/**
|
|
* Set active OID set based on UPS model
|
|
* @param config SNMP configuration
|
|
*/
|
|
private setActiveOIDs(config: ISnmpConfig): void {
|
|
// If custom OIDs are provided, use them
|
|
if (config.upsModel === 'custom' && config.customOIDs) {
|
|
this.activeOIDs = config.customOIDs;
|
|
if (this.debug) {
|
|
console.log('Using custom OIDs:', this.activeOIDs);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Use OIDs for the specified UPS model or default to Cyberpower
|
|
const model = config.upsModel || 'cyberpower';
|
|
this.activeOIDs = UpsOidSets.getOidSet(model);
|
|
|
|
if (this.debug) {
|
|
console.log(`Using OIDs for UPS model: ${model}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable debug mode
|
|
*/
|
|
public enableDebug(): void {
|
|
this.debug = true;
|
|
console.log('SNMP debug mode enabled - detailed logs will be shown');
|
|
}
|
|
|
|
/**
|
|
* Send an SNMP GET request
|
|
* @param oid OID to query
|
|
* @param config SNMP configuration
|
|
* @returns Promise resolving to the SNMP response value
|
|
*/
|
|
public async snmpGet(oid: string, config = this.DEFAULT_CONFIG): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = dgram.createSocket('udp4');
|
|
|
|
// Create appropriate request based on SNMP version
|
|
let request: Buffer;
|
|
if (config.version === 3) {
|
|
request = SnmpPacketCreator.createSnmpV3GetRequest(
|
|
oid,
|
|
config,
|
|
this.engineID,
|
|
this.engineBoots,
|
|
this.engineTime,
|
|
this.requestID++,
|
|
this.debug
|
|
);
|
|
} else {
|
|
request = SnmpPacketCreator.createSnmpGetRequest(oid, config.community || 'public', this.debug);
|
|
}
|
|
|
|
if (this.debug) {
|
|
console.log(`Sending SNMP ${config.version === 3 ? 'v3' : ('v' + config.version)} request to ${config.host}:${config.port}`);
|
|
console.log('Request length:', request.length);
|
|
console.log('First 16 bytes of request:', request.slice(0, 16).toString('hex'));
|
|
console.log('Full request hex:', request.toString('hex'));
|
|
}
|
|
|
|
// Set timeout - add extra logging for debugging
|
|
const timeout = setTimeout(() => {
|
|
socket.close();
|
|
if (this.debug) {
|
|
console.error('---------------------------------------');
|
|
console.error('SNMP request timed out after', config.timeout, 'ms');
|
|
console.error('SNMP Version:', config.version);
|
|
if (config.version === 3) {
|
|
console.error('SNMPv3 Security Level:', config.securityLevel);
|
|
console.error('SNMPv3 Username:', config.username);
|
|
console.error('SNMPv3 Auth Protocol:', config.authProtocol || 'None');
|
|
console.error('SNMPv3 Privacy Protocol:', config.privProtocol || 'None');
|
|
}
|
|
console.error('OID:', oid);
|
|
console.error('Host:', config.host);
|
|
console.error('Port:', config.port);
|
|
console.error('---------------------------------------');
|
|
}
|
|
reject(new Error(`SNMP request timed out after ${config.timeout}ms`));
|
|
}, config.timeout);
|
|
|
|
// Listen for responses
|
|
socket.on('message', (message, rinfo) => {
|
|
clearTimeout(timeout);
|
|
|
|
if (this.debug) {
|
|
console.log(`Received SNMP response from ${rinfo.address}:${rinfo.port}`);
|
|
console.log('Response length:', message.length);
|
|
console.log('First 16 bytes of response:', message.slice(0, 16).toString('hex'));
|
|
console.log('Full response hex:', message.toString('hex'));
|
|
}
|
|
|
|
try {
|
|
const result = SnmpPacketParser.parseSnmpResponse(message, config, this.debug);
|
|
|
|
if (this.debug) {
|
|
console.log('Parsed SNMP response:', result);
|
|
}
|
|
|
|
socket.close();
|
|
resolve(result);
|
|
} catch (error) {
|
|
if (this.debug) {
|
|
console.error('Error parsing SNMP response:', error);
|
|
}
|
|
socket.close();
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
// Handle errors
|
|
socket.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
socket.close();
|
|
if (this.debug) {
|
|
console.error('Socket error during SNMP request:', error);
|
|
}
|
|
reject(error);
|
|
});
|
|
|
|
// First send the request directly without binding to a specific port
|
|
// This lets the OS pick an available port instead of trying to bind to one
|
|
socket.send(request, 0, request.length, config.port, config.host, (error) => {
|
|
if (error) {
|
|
clearTimeout(timeout);
|
|
socket.close();
|
|
if (this.debug) {
|
|
console.error('Error sending SNMP request:', error);
|
|
}
|
|
reject(error);
|
|
} else if (this.debug) {
|
|
console.log('SNMP request sent successfully');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the current status of the UPS
|
|
* @param config SNMP configuration
|
|
* @returns Promise resolving to the UPS status
|
|
*/
|
|
public async getUpsStatus(config = this.DEFAULT_CONFIG): Promise<IUpsStatus> {
|
|
try {
|
|
// Set active OID set based on UPS model in config
|
|
this.setActiveOIDs(config);
|
|
|
|
if (this.debug) {
|
|
console.log('---------------------------------------');
|
|
console.log('Getting UPS status with config:');
|
|
console.log(' Host:', config.host);
|
|
console.log(' Port:', config.port);
|
|
console.log(' Version:', config.version);
|
|
console.log(' Timeout:', config.timeout, 'ms');
|
|
console.log(' UPS Model:', config.upsModel || 'cyberpower');
|
|
if (config.version === 1 || config.version === 2) {
|
|
console.log(' Community:', config.community);
|
|
} else if (config.version === 3) {
|
|
console.log(' Security Level:', config.securityLevel);
|
|
console.log(' Username:', config.username);
|
|
console.log(' Auth Protocol:', config.authProtocol || 'None');
|
|
console.log(' Privacy Protocol:', config.privProtocol || 'None');
|
|
}
|
|
console.log('Using OIDs:');
|
|
console.log(' Power Status:', this.activeOIDs.POWER_STATUS);
|
|
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY);
|
|
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME);
|
|
console.log('---------------------------------------');
|
|
}
|
|
|
|
// For SNMPv3, we need to discover the engine ID first
|
|
if (config.version === 3) {
|
|
if (this.debug) {
|
|
console.log('SNMPv3 detected, starting engine ID discovery');
|
|
}
|
|
|
|
try {
|
|
const discoveredEngineId = await this.discoverEngineId(config);
|
|
if (discoveredEngineId) {
|
|
this.engineID = discoveredEngineId;
|
|
if (this.debug) {
|
|
console.log('Using discovered engine ID:', this.engineID.toString('hex'));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (this.debug) {
|
|
console.warn('Engine ID discovery failed, using default:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to get SNMP value with retry
|
|
const getSNMPValueWithRetry = async (oid: string, description: string) => {
|
|
if (oid === '') {
|
|
if (this.debug) {
|
|
console.log(`No OID provided for ${description}, skipping`);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (this.debug) {
|
|
console.log(`Getting ${description} OID: ${oid}`);
|
|
}
|
|
|
|
try {
|
|
const value = await this.snmpGet(oid, config);
|
|
if (this.debug) {
|
|
console.log(`${description} value:`, value);
|
|
}
|
|
return value;
|
|
} catch (error) {
|
|
if (this.debug) {
|
|
console.error(`Error getting ${description}:`, error.message);
|
|
}
|
|
|
|
// If we got a timeout and it's SNMPv3, try with different security levels
|
|
if (error.message.includes('timed out') && config.version === 3) {
|
|
if (this.debug) {
|
|
console.log(`Retrying ${description} with fallback settings...`);
|
|
}
|
|
|
|
// Create a retry config with lower security level
|
|
if (config.securityLevel === 'authPriv') {
|
|
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
|
|
try {
|
|
if (this.debug) {
|
|
console.log(`Retrying with authNoPriv security level`);
|
|
}
|
|
const value = await this.snmpGet(oid, retryConfig);
|
|
if (this.debug) {
|
|
console.log(`${description} retry value:`, value);
|
|
}
|
|
return value;
|
|
} catch (retryError) {
|
|
if (this.debug) {
|
|
console.error(`Retry failed for ${description}:`, retryError.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we're still having trouble, try with standard OIDs
|
|
if (config.upsModel !== 'custom') {
|
|
try {
|
|
// Try RFC 1628 standard UPS MIB OIDs
|
|
const standardOIDs = UpsOidSets.getStandardOids();
|
|
|
|
if (this.debug) {
|
|
console.log(`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`);
|
|
}
|
|
|
|
const standardValue = await this.snmpGet(standardOIDs[description], config);
|
|
if (this.debug) {
|
|
console.log(`${description} standard OID value:`, standardValue);
|
|
}
|
|
return standardValue;
|
|
} catch (stdError) {
|
|
if (this.debug) {
|
|
console.error(`Standard OID retry failed for ${description}:`, stdError.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return a default value if all attempts fail
|
|
if (this.debug) {
|
|
console.log(`Using default value 0 for ${description}`);
|
|
}
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
// Get all values with independent retry logic
|
|
const powerStatusValue = await getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status');
|
|
const batteryCapacity = await getSNMPValueWithRetry(this.activeOIDs.BATTERY_CAPACITY, 'battery capacity') || 0;
|
|
const batteryRuntime = await getSNMPValueWithRetry(this.activeOIDs.BATTERY_RUNTIME, 'battery runtime') || 0;
|
|
|
|
// Determine power status - handle different values for different UPS models
|
|
let powerStatus: 'online' | 'onBattery' | 'unknown' = 'unknown';
|
|
|
|
// Different UPS models use different values for power status
|
|
if (config.upsModel === 'cyberpower') {
|
|
// CyberPower RMCARD205: upsBaseOutputStatus values
|
|
// 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc.
|
|
if (powerStatusValue === 2) {
|
|
powerStatus = 'online';
|
|
} else if (powerStatusValue === 3) {
|
|
powerStatus = 'onBattery';
|
|
}
|
|
} else {
|
|
// Default interpretation for other UPS models
|
|
if (powerStatusValue === 1) {
|
|
powerStatus = 'online';
|
|
} else if (powerStatusValue === 2) {
|
|
powerStatus = 'onBattery';
|
|
}
|
|
}
|
|
|
|
// Convert TimeTicks to minutes for CyberPower runtime (value is in 1/100 seconds)
|
|
let processedRuntime = batteryRuntime;
|
|
if (config.upsModel === 'cyberpower' && batteryRuntime > 0) {
|
|
// TimeTicks is in 1/100 seconds, convert to minutes
|
|
processedRuntime = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
|
if (this.debug) {
|
|
console.log(`Converting CyberPower runtime from ${batteryRuntime} ticks to ${processedRuntime} minutes`);
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
powerStatus,
|
|
batteryCapacity,
|
|
batteryRuntime: processedRuntime,
|
|
raw: {
|
|
powerStatus: powerStatusValue,
|
|
batteryCapacity,
|
|
batteryRuntime,
|
|
},
|
|
};
|
|
|
|
if (this.debug) {
|
|
console.log('---------------------------------------');
|
|
console.log('UPS status result:');
|
|
console.log(' Power Status:', result.powerStatus);
|
|
console.log(' Battery Capacity:', result.batteryCapacity + '%');
|
|
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes');
|
|
console.log('---------------------------------------');
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
if (this.debug) {
|
|
console.error('---------------------------------------');
|
|
console.error('Error getting UPS status:', error.message);
|
|
console.error('---------------------------------------');
|
|
}
|
|
throw new Error(`Failed to get UPS status: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discover SNMP engine ID (for SNMPv3)
|
|
* Sends a proper discovery message to get the engine ID from the device
|
|
* @param config SNMP configuration
|
|
* @returns Promise resolving to the discovered engine ID
|
|
*/
|
|
public async discoverEngineId(config: ISnmpConfig): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = dgram.createSocket('udp4');
|
|
|
|
// Create a proper discovery message (SNMPv3 with noAuthNoPriv)
|
|
const discoveryConfig: ISnmpConfig = {
|
|
...config,
|
|
securityLevel: 'noAuthNoPriv',
|
|
username: '', // Empty username for discovery
|
|
};
|
|
|
|
// Create a simple GetRequest for sysDescr (a commonly available OID)
|
|
const request = SnmpPacketCreator.createDiscoveryMessage(discoveryConfig, this.requestID++);
|
|
|
|
if (this.debug) {
|
|
console.log('Sending SNMPv3 discovery message');
|
|
console.log('SNMPv3 Discovery message:', request.toString('hex'));
|
|
}
|
|
|
|
// Set timeout - use a longer timeout for discovery phase
|
|
const discoveryTimeout = Math.max(config.timeout, 15000); // At least 15 seconds for discovery
|
|
const timeout = setTimeout(() => {
|
|
socket.close();
|
|
// Fall back to default engine ID if discovery fails
|
|
if (this.debug) {
|
|
console.error('---------------------------------------');
|
|
console.error('Engine ID discovery timed out after', discoveryTimeout, 'ms');
|
|
console.error('SNMPv3 settings:');
|
|
console.error(' Username:', config.username);
|
|
console.error(' Security Level:', config.securityLevel);
|
|
console.error(' Host:', config.host);
|
|
console.error(' Port:', config.port);
|
|
console.error('Using default engine ID:', this.engineID.toString('hex'));
|
|
console.error('---------------------------------------');
|
|
}
|
|
resolve(this.engineID);
|
|
}, discoveryTimeout);
|
|
|
|
// Listen for responses
|
|
socket.on('message', (message, rinfo) => {
|
|
clearTimeout(timeout);
|
|
|
|
if (this.debug) {
|
|
console.log(`Received SNMPv3 discovery response from ${rinfo.address}:${rinfo.port}`);
|
|
console.log('Response:', message.toString('hex'));
|
|
}
|
|
|
|
try {
|
|
// Extract engine ID from response
|
|
const engineId = SnmpPacketParser.extractEngineId(message, this.debug);
|
|
if (engineId) {
|
|
this.engineID = engineId; // Update the engine ID
|
|
if (this.debug) {
|
|
console.log('Discovered engine ID:', engineId.toString('hex'));
|
|
}
|
|
socket.close();
|
|
resolve(engineId);
|
|
} else {
|
|
if (this.debug) {
|
|
console.log('Could not extract engine ID, using default');
|
|
}
|
|
socket.close();
|
|
resolve(this.engineID);
|
|
}
|
|
} catch (error) {
|
|
if (this.debug) {
|
|
console.error('Error extracting engine ID:', error);
|
|
}
|
|
socket.close();
|
|
resolve(this.engineID); // Fall back to default engine ID
|
|
}
|
|
});
|
|
|
|
// Handle errors
|
|
socket.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
socket.close();
|
|
if (this.debug) {
|
|
console.error('Engine ID discovery socket error:', error);
|
|
}
|
|
resolve(this.engineID); // Fall back to default engine ID
|
|
});
|
|
|
|
// Send request directly without binding
|
|
socket.send(request, 0, request.length, config.port, config.host, (error) => {
|
|
if (error) {
|
|
clearTimeout(timeout);
|
|
socket.close();
|
|
if (this.debug) {
|
|
console.error('Error sending discovery message:', error);
|
|
}
|
|
resolve(this.engineID); // Fall back to default engine ID
|
|
} else if (this.debug) {
|
|
console.log('Discovery message sent successfully');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initiate system shutdown
|
|
* @param reason Reason for shutdown
|
|
*/
|
|
public async initiateShutdown(reason: string): Promise<void> {
|
|
console.log(`Initiating system shutdown due to: ${reason}`);
|
|
try {
|
|
// Execute shutdown command with 5 minute delay to allow for VM graceful shutdown
|
|
const { stdout } = await execAsync('shutdown -h +5 "UPS battery critical, shutting down in 5 minutes"');
|
|
console.log('Shutdown initiated:', stdout);
|
|
console.log('Allowing 5 minutes for VMs to shut down safely');
|
|
} catch (error) {
|
|
console.error('Failed to initiate shutdown:', error);
|
|
// Try a different method if first one fails
|
|
try {
|
|
console.log('Trying alternative shutdown method...');
|
|
await execAsync('poweroff --force');
|
|
} catch (innerError) {
|
|
console.error('All shutdown methods failed:', innerError);
|
|
}
|
|
}
|
|
}
|
|
} |