fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing

This commit is contained in:
2026-04-14 14:27:29 +00:00
parent 2adf1d5548
commit 1f542ca271
22 changed files with 1571 additions and 702 deletions
+245 -171
View File
@@ -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();