fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
This commit is contained in:
+245
-171
@@ -6,6 +6,73 @@ import { SNMP } from '../constants.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import type { INupstAccessor } from '../interfaces/index.ts';
|
||||
|
||||
type TSnmpMetricDescription =
|
||||
| 'power status'
|
||||
| 'battery capacity'
|
||||
| 'battery runtime'
|
||||
| 'output load'
|
||||
| 'output power'
|
||||
| 'output voltage'
|
||||
| 'output current';
|
||||
|
||||
type TSnmpResponseValue = string | number | bigint | boolean | Buffer;
|
||||
type TSnmpValue = string | number | boolean | Buffer;
|
||||
|
||||
interface ISnmpVarbind {
|
||||
oid: string;
|
||||
type: number;
|
||||
value: TSnmpResponseValue;
|
||||
}
|
||||
|
||||
interface ISnmpSessionOptions {
|
||||
port: number;
|
||||
retries: number;
|
||||
timeout: number;
|
||||
transport: 'udp4' | 'udp6';
|
||||
idBitsSize: 16 | 32;
|
||||
context: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface ISnmpV3User {
|
||||
name: string;
|
||||
level: number;
|
||||
authProtocol?: string;
|
||||
authKey?: string;
|
||||
privProtocol?: string;
|
||||
privKey?: string;
|
||||
}
|
||||
|
||||
interface ISnmpSession {
|
||||
get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface ISnmpModule {
|
||||
Version1: number;
|
||||
Version2c: number;
|
||||
Version3: number;
|
||||
SecurityLevel: {
|
||||
noAuthNoPriv: number;
|
||||
authNoPriv: number;
|
||||
authPriv: number;
|
||||
};
|
||||
AuthProtocols: {
|
||||
md5: string;
|
||||
sha: string;
|
||||
};
|
||||
PrivProtocols: {
|
||||
des: string;
|
||||
aes: string;
|
||||
};
|
||||
createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession;
|
||||
createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession;
|
||||
isVarbindError(varbind: ISnmpVarbind): boolean;
|
||||
varbindError(varbind: ISnmpVarbind): string;
|
||||
}
|
||||
|
||||
const snmpLib = snmp as unknown as ISnmpModule;
|
||||
|
||||
/**
|
||||
* Class for SNMP communication with UPS devices
|
||||
* Main entry point for SNMP functionality
|
||||
@@ -84,6 +151,120 @@ export class NupstSnmp {
|
||||
}
|
||||
}
|
||||
|
||||
private createSessionOptions(config: ISnmpConfig): ISnmpSessionOptions {
|
||||
return {
|
||||
port: config.port,
|
||||
retries: SNMP.RETRIES,
|
||||
timeout: config.timeout,
|
||||
transport: 'udp4',
|
||||
idBitsSize: 32,
|
||||
context: config.context || '',
|
||||
version: config.version === 1
|
||||
? snmpLib.Version1
|
||||
: config.version === 2
|
||||
? snmpLib.Version2c
|
||||
: snmpLib.Version3,
|
||||
};
|
||||
}
|
||||
|
||||
private buildV3User(
|
||||
config: ISnmpConfig,
|
||||
): { user: ISnmpV3User; levelLabel: NonNullable<ISnmpConfig['securityLevel']> } {
|
||||
const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||
const user: ISnmpV3User = {
|
||||
name: config.username || '',
|
||||
level: snmpLib.SecurityLevel.noAuthNoPriv,
|
||||
};
|
||||
let levelLabel: NonNullable<ISnmpConfig['securityLevel']> = 'noAuthNoPriv';
|
||||
|
||||
if (requestedSecurityLevel === 'authNoPriv') {
|
||||
user.level = snmpLib.SecurityLevel.authNoPriv;
|
||||
levelLabel = 'authNoPriv';
|
||||
|
||||
if (config.authProtocol && config.authKey) {
|
||||
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
|
||||
user.authKey = config.authKey;
|
||||
} else {
|
||||
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
|
||||
levelLabel = 'noAuthNoPriv';
|
||||
if (this.debug) {
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
} else if (requestedSecurityLevel === 'authPriv') {
|
||||
user.level = snmpLib.SecurityLevel.authPriv;
|
||||
levelLabel = 'authPriv';
|
||||
|
||||
if (config.authProtocol && config.authKey) {
|
||||
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
|
||||
user.authKey = config.authKey;
|
||||
|
||||
if (config.privProtocol && config.privKey) {
|
||||
user.privProtocol = this.resolvePrivProtocol(config.privProtocol);
|
||||
user.privKey = config.privKey;
|
||||
} else {
|
||||
user.level = snmpLib.SecurityLevel.authNoPriv;
|
||||
levelLabel = 'authNoPriv';
|
||||
if (this.debug) {
|
||||
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
|
||||
levelLabel = 'noAuthNoPriv';
|
||||
if (this.debug) {
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { user, levelLabel };
|
||||
}
|
||||
|
||||
private resolveAuthProtocol(protocol: NonNullable<ISnmpConfig['authProtocol']>): string {
|
||||
return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha;
|
||||
}
|
||||
|
||||
private resolvePrivProtocol(protocol: NonNullable<ISnmpConfig['privProtocol']>): string {
|
||||
return protocol === 'DES' ? snmpLib.PrivProtocols.des : snmpLib.PrivProtocols.aes;
|
||||
}
|
||||
|
||||
private normalizeSnmpValue(value: TSnmpResponseValue): TSnmpValue {
|
||||
if (Buffer.isBuffer(value)) {
|
||||
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
|
||||
return isPrintableAscii ? value.toString() : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private coerceNumericSnmpValue(
|
||||
value: TSnmpValue | 0,
|
||||
description: TSnmpMetricDescription,
|
||||
): number {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmedValue = value.trim();
|
||||
const parsedValue = Number(trimmedValue);
|
||||
if (trimmedValue && Number.isFinite(parsedValue)) {
|
||||
return parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
logger.warn(`Non-numeric ${description} value received from SNMP, using 0`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SNMP GET request using the net-snmp package
|
||||
* @param oid OID to query
|
||||
@@ -95,130 +276,39 @@ export class NupstSnmp {
|
||||
oid: string,
|
||||
config = this.DEFAULT_CONFIG,
|
||||
_retryCount = 0,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
): Promise<TSnmpValue> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
|
||||
);
|
||||
logger.dim(`Using community: ${config.community}`);
|
||||
}
|
||||
|
||||
// Create SNMP options based on configuration
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const options: any = {
|
||||
port: config.port,
|
||||
retries: SNMP.RETRIES, // Number of retries
|
||||
timeout: config.timeout,
|
||||
transport: 'udp4',
|
||||
idBitsSize: 32,
|
||||
context: config.context || '',
|
||||
};
|
||||
|
||||
// Set version based on config
|
||||
if (config.version === 1) {
|
||||
options.version = snmp.Version1;
|
||||
} else if (config.version === 2) {
|
||||
options.version = snmp.Version2c;
|
||||
} else {
|
||||
options.version = snmp.Version3;
|
||||
}
|
||||
|
||||
// Create appropriate session based on SNMP version
|
||||
let session;
|
||||
|
||||
if (config.version === 3) {
|
||||
// For SNMPv3, we need to set up authentication and privacy
|
||||
// For SNMPv3, we need a valid security level
|
||||
const securityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||
|
||||
// Create the user object with required structure for net-snmp
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const user: any = {
|
||||
name: config.username || '',
|
||||
};
|
||||
|
||||
// Set security level
|
||||
if (securityLevel === 'noAuthNoPriv') {
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
} else if (securityLevel === 'authNoPriv') {
|
||||
user.level = snmp.SecurityLevel.authNoPriv;
|
||||
|
||||
// Set auth protocol - must provide both protocol and key
|
||||
if (config.authProtocol && config.authKey) {
|
||||
if (config.authProtocol === 'MD5') {
|
||||
user.authProtocol = snmp.AuthProtocols.md5;
|
||||
} else if (config.authProtocol === 'SHA') {
|
||||
user.authProtocol = snmp.AuthProtocols.sha;
|
||||
}
|
||||
user.authKey = config.authKey;
|
||||
} else {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
} else if (securityLevel === 'authPriv') {
|
||||
user.level = snmp.SecurityLevel.authPriv;
|
||||
|
||||
// Set auth protocol - must provide both protocol and key
|
||||
if (config.authProtocol && config.authKey) {
|
||||
if (config.authProtocol === 'MD5') {
|
||||
user.authProtocol = snmp.AuthProtocols.md5;
|
||||
} else if (config.authProtocol === 'SHA') {
|
||||
user.authProtocol = snmp.AuthProtocols.sha;
|
||||
}
|
||||
user.authKey = config.authKey;
|
||||
|
||||
// Set privacy protocol - must provide both protocol and key
|
||||
if (config.privProtocol && config.privKey) {
|
||||
if (config.privProtocol === 'DES') {
|
||||
user.privProtocol = snmp.PrivProtocols.des;
|
||||
} else if (config.privProtocol === 'AES') {
|
||||
user.privProtocol = snmp.PrivProtocols.aes;
|
||||
}
|
||||
user.privKey = config.privKey;
|
||||
} else {
|
||||
// Fallback to authNoPriv if priv details missing
|
||||
user.level = snmp.SecurityLevel.authNoPriv;
|
||||
if (this.debug) {
|
||||
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
if (config.version === 1 || config.version === 2) {
|
||||
logger.dim(`Using community: ${config.community}`);
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
|
||||
snmp.SecurityLevel[key] === user.level
|
||||
);
|
||||
logger.dim(
|
||||
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${
|
||||
user.authProtocol ? 'Set' : 'Not Set'
|
||||
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
|
||||
);
|
||||
}
|
||||
|
||||
session = snmp.createV3Session(config.host, user, options);
|
||||
} else {
|
||||
// For SNMPv1/v2c, we use the community string
|
||||
session = snmp.createSession(config.host, config.community || 'public', options);
|
||||
}
|
||||
|
||||
const options = this.createSessionOptions(config);
|
||||
const session: ISnmpSession = config.version === 3
|
||||
? (() => {
|
||||
const { user, levelLabel } = this.buildV3User(config);
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${
|
||||
user.authProtocol ? 'Set' : 'Not Set'
|
||||
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return snmpLib.createV3Session(config.host, user, options);
|
||||
})()
|
||||
: snmpLib.createSession(config.host, config.community || 'public', options);
|
||||
|
||||
// Convert the OID string to an array of OIDs if multiple OIDs are needed
|
||||
const oids = [oid];
|
||||
|
||||
// Send the GET request
|
||||
// deno-lint-ignore no-explicit-any
|
||||
session.get(oids, (error: Error | null, varbinds: any[]) => {
|
||||
session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
|
||||
// Close the session to release resources
|
||||
session.close();
|
||||
|
||||
@@ -230,7 +320,9 @@ export class NupstSnmp {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!varbinds || varbinds.length === 0) {
|
||||
const varbind = varbinds?.[0];
|
||||
|
||||
if (!varbind) {
|
||||
if (this.debug) {
|
||||
logger.error('No varbinds returned in response');
|
||||
}
|
||||
@@ -239,36 +331,20 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
// Check for SNMP errors in the response
|
||||
if (
|
||||
varbinds[0].type === snmp.ObjectType.NoSuchObject ||
|
||||
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
|
||||
varbinds[0].type === snmp.ObjectType.EndOfMibView
|
||||
) {
|
||||
if (snmpLib.isVarbindError(varbind)) {
|
||||
const errorMessage = snmpLib.varbindError(varbind);
|
||||
if (this.debug) {
|
||||
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`);
|
||||
logger.error(`SNMP error: ${errorMessage}`);
|
||||
}
|
||||
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
|
||||
reject(new Error(`SNMP error: ${errorMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the response value based on its type
|
||||
let value = varbinds[0].value;
|
||||
|
||||
// Handle specific types that might need conversion
|
||||
if (Buffer.isBuffer(value)) {
|
||||
// If value is a Buffer, try to convert it to a string if it's printable ASCII
|
||||
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
|
||||
if (isPrintableAscii) {
|
||||
value = value.toString();
|
||||
}
|
||||
} else if (typeof value === 'bigint') {
|
||||
// Convert BigInt to a normal number or string if needed
|
||||
value = Number(value);
|
||||
}
|
||||
const value = this.normalizeSnmpValue(varbind.value);
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`,
|
||||
`SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,43 +391,44 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
// Get all values with independent retry logic
|
||||
const powerStatusValue = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.POWER_STATUS,
|
||||
const powerStatusValue = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
|
||||
'power status',
|
||||
config,
|
||||
);
|
||||
const batteryCapacity = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_CAPACITY,
|
||||
const batteryCapacity = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_CAPACITY,
|
||||
'battery capacity',
|
||||
config,
|
||||
),
|
||||
'battery capacity',
|
||||
config,
|
||||
) || 0;
|
||||
const batteryRuntime = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_RUNTIME,
|
||||
);
|
||||
const batteryRuntime = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_RUNTIME,
|
||||
'battery runtime',
|
||||
config,
|
||||
),
|
||||
'battery runtime',
|
||||
config,
|
||||
) || 0;
|
||||
);
|
||||
|
||||
// Get power draw metrics
|
||||
const outputLoad = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_LOAD,
|
||||
const outputLoad = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
|
||||
'output load',
|
||||
config,
|
||||
) || 0;
|
||||
const outputPower = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_POWER,
|
||||
);
|
||||
const outputPower = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
|
||||
'output power',
|
||||
config,
|
||||
) || 0;
|
||||
const outputVoltage = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_VOLTAGE,
|
||||
);
|
||||
const outputVoltage = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
|
||||
'output voltage',
|
||||
config,
|
||||
) || 0;
|
||||
const outputCurrent = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_CURRENT,
|
||||
);
|
||||
const outputCurrent = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
|
||||
'output current',
|
||||
config,
|
||||
) || 0;
|
||||
);
|
||||
|
||||
// Determine power status - handle different values for different UPS models
|
||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||
@@ -430,10 +507,9 @@ export class NupstSnmp {
|
||||
*/
|
||||
private async getSNMPValueWithRetry(
|
||||
oid: string,
|
||||
description: string,
|
||||
description: TSnmpMetricDescription,
|
||||
config: ISnmpConfig,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
if (oid === '') {
|
||||
if (this.debug) {
|
||||
logger.dim(`No OID provided for ${description}, skipping`);
|
||||
@@ -485,10 +561,9 @@ export class NupstSnmp {
|
||||
*/
|
||||
private async tryFallbackSecurityLevels(
|
||||
oid: string,
|
||||
description: string,
|
||||
description: TSnmpMetricDescription,
|
||||
config: ISnmpConfig,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
if (this.debug) {
|
||||
logger.dim(`Retrying ${description} with fallback security level...`);
|
||||
}
|
||||
@@ -551,10 +626,9 @@ export class NupstSnmp {
|
||||
*/
|
||||
private async tryStandardOids(
|
||||
_oid: string,
|
||||
description: string,
|
||||
description: TSnmpMetricDescription,
|
||||
config: ISnmpConfig,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
try {
|
||||
// Try RFC 1628 standard UPS MIB OIDs
|
||||
const standardOIDs = UpsOidSets.getStandardOids();
|
||||
|
||||
Reference in New Issue
Block a user