Compare commits

...

3 Commits

16 changed files with 567 additions and 1951 deletions

View File

@ -22,16 +22,63 @@ fi
# For debugging # For debugging
# echo "Project root: $PROJECT_ROOT" # echo "Project root: $PROJECT_ROOT"
# Set Node.js binary path directly # Detect architecture and OS
NODE_BIN="$PROJECT_ROOT/vendor/node-linux-x64/bin/node" ARCH=$(uname -m)
OS=$(uname -s)
# Determine Node.js binary location based on architecture and OS
NODE_BIN=""
case "$OS" in
Linux)
case "$ARCH" in
x86_64)
NODE_BIN="$PROJECT_ROOT/vendor/node-linux-x64/bin/node"
;;
aarch64|arm64)
NODE_BIN="$PROJECT_ROOT/vendor/node-linux-arm64/bin/node"
;;
*)
# Use system Node as fallback for other architectures
if command -v node &> /dev/null; then
NODE_BIN="node"
echo "Using system Node.js installation for unsupported architecture: $ARCH"
fi
;;
esac
;;
Darwin)
case "$ARCH" in
x86_64)
NODE_BIN="$PROJECT_ROOT/vendor/node-darwin-x64/bin/node"
;;
arm64)
NODE_BIN="$PROJECT_ROOT/vendor/node-darwin-arm64/bin/node"
;;
*)
# Use system Node as fallback for other architectures
if command -v node &> /dev/null; then
NODE_BIN="node"
echo "Using system Node.js installation for unsupported architecture: $ARCH"
fi
;;
esac
;;
*)
# Use system Node as fallback for other operating systems
if command -v node &> /dev/null; then
NODE_BIN="node"
echo "Using system Node.js installation for unsupported OS: $OS"
fi
;;
esac
# If binary doesn't exist, try system Node as fallback # If binary doesn't exist, try system Node as fallback
if [ ! -f "$NODE_BIN" ]; then if [ -z "$NODE_BIN" ] || [ ! -f "$NODE_BIN" ]; then
if command -v node &> /dev/null; then if command -v node &> /dev/null; then
NODE_BIN="node" NODE_BIN="node"
echo "Using system Node.js installation" echo "Using system Node.js installation"
else else
echo "Error: Node.js binary not found at $NODE_BIN" echo "Error: Node.js binary not found for $OS-$ARCH"
echo "Please run the setup script or install Node.js manually." echo "Please run the setup script or install Node.js manually."
exit 1 exit 1
fi fi

View File

@ -1,5 +1,14 @@
# Changelog # Changelog
## 2025-03-26 - 2.5.2 - fix(installer)
Improve Node.js binary detection, dependency management, and SNMPv3 fallback logic
- Enhanced bin/nupst to detect OS and architecture (Linux and Darwin) and fall back to system Node.js for unsupported platforms
- Moved net-snmp from devDependencies to dependencies in package.json
- Updated setup.sh to install production dependencies and handle installation errors gracefully
- Refined SNMPv3 user configuration and fallback mechanism in ts/snmp/manager.ts
- Revised README to clarify minimal runtime dependencies and secure SNMP features
## 2025-03-25 - 2.5.1 - fix(snmp) ## 2025-03-25 - 2.5.1 - fix(snmp)
Fix Eaton UPS support by updating power status OID and adjusting battery runtime conversion. Fix Eaton UPS support by updating power status OID and adjusting battery runtime conversion.

View File

@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "2.5.1", "version": "2.5.2",
"description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices", "description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
@ -36,7 +36,9 @@
], ],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": {}, "dependencies": {
"net-snmp": "3.20.0"
},
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.3.2", "@git.zone/tsbuild": "^2.3.2",
"@git.zone/tsrun": "^1.3.3", "@git.zone/tsrun": "^1.3.3",

17
pnpm-lock.yaml generated
View File

@ -7,6 +7,10 @@ settings:
importers: importers:
.: .:
dependencies:
net-snmp:
specifier: ^3.20.0
version: 3.20.0
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^2.3.2 specifier: ^2.3.2
@ -1647,6 +1651,9 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'} engines: {node: '>=8'}
asn1-ber@1.2.2:
resolution: {integrity: sha512-CbNem/7hxrjSiOAOOTX4iZxu+0m3jiLqlsERQwwPM1IDR/22M8IPpA1VVndCLw5KtjRYyRODbvAEIfuTogNDng==}
ast-types@0.13.4: ast-types@0.13.4:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -3303,6 +3310,9 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
net-snmp@3.20.0:
resolution: {integrity: sha512-4Cp8ODkzgVXjUrIQFfL9Vo6qVsz+8OuAjUvkRGsSZOKSpoxpy9YWjVgNs+/a9N4Hd9MilIy90Zhw3EZlUUZB6A==}
netmask@2.0.2: netmask@2.0.2:
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
@ -7181,6 +7191,8 @@ snapshots:
array-union@2.1.0: {} array-union@2.1.0: {}
asn1-ber@1.2.2: {}
ast-types@0.13.4: ast-types@0.13.4:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -9133,6 +9145,11 @@ snapshots:
negotiator@0.6.3: {} negotiator@0.6.3: {}
net-snmp@3.20.0:
dependencies:
asn1-ber: 1.2.2
smart-buffer: 4.2.0
netmask@2.0.2: {} netmask@2.0.2: {}
new-find-package-json@2.0.0: new-find-package-json@2.0.0:

View File

@ -236,10 +236,10 @@ NUPST was designed with security in mind:
### Minimal Dependencies ### Minimal Dependencies
- **Zero Runtime NPM Dependencies**: NUPST is built without any external NPM packages to minimize the attack surface and avoid supply chain risks. - **Minimal Runtime Dependencies**: NUPST uses only one carefully selected NPM package (net-snmp) to minimize the attack surface and avoid supply chain risks while providing robust SNMP functionality.
- **Self-contained Node.js**: NUPST ships with its own Node.js binary, isolated from the system's Node.js installation. This ensures: - **Self-contained Node.js**: NUPST ships with its own Node.js binary, isolated from the system's Node.js installation. This ensures:
- No dependency on system Node.js versions - No dependency on system Node.js versions
- Zero external libraries that could become compromised - Minimal external libraries that could become compromised
- Consistent, tested environment for execution - Consistent, tested environment for execution
- Reduced risk of dependency-based attacks - Reduced risk of dependency-based attacks
@ -247,14 +247,30 @@ NUPST was designed with security in mind:
- **Privilege Separation**: Only specific commands that require elevated permissions (`enable`, `disable`, `update`) check for root access; all other functionality runs with minimal privileges. - **Privilege Separation**: Only specific commands that require elevated permissions (`enable`, `disable`, `update`) check for root access; all other functionality runs with minimal privileges.
- **Limited Network Access**: NUPST only communicates with the UPS device over SNMP and contacts npmjs.org only to check for updates. - **Limited Network Access**: NUPST only communicates with the UPS device over SNMP and contacts npmjs.org only to check for updates.
- **Secure SNMPv3 Support**: Supports encrypted authentication and privacy for secure communication with the UPS device.
- **Isolated Execution**: The application runs in its working directory (`/opt/nupst`) or specified installation location, minimizing the impact on the rest of the system. - **Isolated Execution**: The application runs in its working directory (`/opt/nupst`) or specified installation location, minimizing the impact on the rest of the system.
### SNMP Security Features
- **SNMPv3 Support with Secure Authentication and Privacy**:
- Three security levels available:
- `noAuthNoPriv`: No authentication or encryption (basic access)
- `authNoPriv`: Authentication without encryption (verifies identity)
- `authPriv`: Full authentication and encryption (most secure)
- Authentication protocols: MD5 or SHA
- Privacy/encryption protocols: DES or AES
- Automatic fallback mechanisms for compatibility
- Context support for segmented SNMP deployments
- Configurable timeouts based on security level
- **Graceful degradation**: If authentication or privacy details are missing or invalid, NUPST will automatically fall back to a lower security level while logging appropriate warnings.
- **Interactive setup**: Guided setup process to properly configure SNMPv3 security settings with clear explanations of each security option.
### Installation Security ### Installation Security
- The installation script can be reviewed before execution (`curl -sSL [url] | less`) - The installation script can be reviewed before execution (`curl -sSL [url] | less`)
- All setup scripts download only verified versions and check integrity - All setup scripts download only verified versions and check integrity
- Installation is transparent and places files in standard locations (`/opt/nupst`, `/usr/local/bin`, `/etc/systemd/system`) - Installation is transparent and places files in standard locations (`/opt/nupst`, `/usr/local/bin`, `/etc/systemd/system`)
- Automatically detects platform architecture and OS for proper binary selection
- Installs production dependencies locally without requiring global npm packages
### Audit and Review ### Audit and Review

View File

@ -222,6 +222,25 @@ echo "dist_ts directory successfully downloaded from npm registry."
# Make launcher script executable # Make launcher script executable
chmod +x "$SCRIPT_DIR/bin/nupst" chmod +x "$SCRIPT_DIR/bin/nupst"
# Install production dependencies
echo "Installing production dependencies..."
"$SCRIPT_DIR/vendor/$NODE_DIR/bin/npm" --prefix "$SCRIPT_DIR" ci --only=production --no-audit --no-fund
if [ $? -ne 0 ]; then
echo "Warning: Failed to install dependencies with 'npm ci'. Trying 'npm install'..."
"$SCRIPT_DIR/vendor/$NODE_DIR/bin/npm" --prefix "$SCRIPT_DIR" install --only=production --no-audit --no-fund
if [ $? -ne 0 ]; then
echo "Error: Failed to install dependencies. NUPST may not function correctly."
echo "You can try to install dependencies manually by running:"
echo "cd $SCRIPT_DIR && npm install --only=production"
else
echo "Dependencies installed successfully with 'npm install'."
fi
else
echo "Dependencies installed successfully with 'npm ci'."
fi
echo "NUPST setup completed successfully." echo "NUPST setup completed successfully."
echo "You can now run NUPST using: $SCRIPT_DIR/bin/nupst" echo "You can now run NUPST using: $SCRIPT_DIR/bin/nupst"
echo "To install NUPST globally, run: sudo ln -s $SCRIPT_DIR/bin/nupst /usr/local/bin/nupst" echo "To install NUPST globally, run: sudo ln -s $SCRIPT_DIR/bin/nupst /usr/local/bin/nupst"

View File

@ -1,9 +1,6 @@
import { tap, expect } from '@push.rocks/tapbundle'; import { tap, expect } from '@push.rocks/tapbundle';
import { NupstSnmp } from '../ts/snmp.js'; import { NupstSnmp } from '../ts/snmp/manager.js';
import type { SnmpConfig, UpsStatus } from '../ts/snmp.js'; import type { ISnmpConfig, IUpsStatus } from '../ts/snmp/types.js';
import { SnmpEncoder } from '../ts/snmp/encoder.js';
import { SnmpPacketCreator } from '../ts/snmp/packet-creator.js';
import { SnmpPacketParser } from '../ts/snmp/packet-parser.js';
import * as qenv from '@push.rocks/qenv'; import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', '.nogit/'); const testQenv = new qenv.Qenv('./', '.nogit/');
@ -12,295 +9,57 @@ const testQenv = new qenv.Qenv('./', '.nogit/');
const snmp = new NupstSnmp(true); const snmp = new NupstSnmp(true);
// Load the test configuration from .nogit/env.json // Load the test configuration from .nogit/env.json
const testConfig = await testQenv.getEnvVarOnDemandAsObject('testConfig'); const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
tap.test('should log config', async () => { tap.test('should log config', async () => {
console.log(testConfig); console.log(testConfigV1);
});
tap.test('SNMP packet creation and parsing test', async () => {
// We'll test the internal methods that are now in separate classes
// Test OID conversion
const oidStr = '1.3.6.1.4.1.3808.1.1.1.4.1.1.0';
const oidArray = SnmpEncoder.oidToArray(oidStr);
console.log('OID array length:', oidArray.length);
console.log('OID array:', oidArray);
// The OID has 14 elements after splitting
expect(oidArray.length).toEqual(14);
expect(oidArray[0]).toEqual(1);
expect(oidArray[1]).toEqual(3);
// Test OID encoding
const encodedOid = SnmpEncoder.encodeOID(oidArray);
expect(encodedOid).toBeInstanceOf(Buffer);
// Test SNMP request creation
const request = SnmpPacketCreator.createSnmpGetRequest(oidStr, 'public', true);
expect(request).toBeInstanceOf(Buffer);
expect(request.length).toBeGreaterThan(20);
// Log the request for debugging
console.log('SNMP Request buffer:', request.toString('hex'));
// Test integer encoding
const int = SnmpEncoder.encodeInteger(42);
expect(int).toBeInstanceOf(Buffer);
expect(int.length).toBeGreaterThanOrEqual(1);
// Test SNMPv3 engine ID discovery message
const discoveryMsg = SnmpPacketCreator.createDiscoveryMessage(testConfig, 1);
expect(discoveryMsg).toBeInstanceOf(Buffer);
expect(discoveryMsg.length).toBeGreaterThan(20);
console.log('SNMPv3 Discovery message:', discoveryMsg.toString('hex'));
});
tap.test('SNMP response parsing simulation', async () => {
// Create a simulated SNMP response for parsing
// Simulate an INTEGER response (battery capacity)
const intResponse = Buffer.from([
0x30, 0x29, // Sequence, length 41
0x02, 0x01, 0x00, // Integer (version), value 0
0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public"
0xa2, 0x1c, // GetResponse
0x02, 0x01, 0x01, // Integer (request ID), value 1
0x02, 0x01, 0x00, // Integer (error status), value 0
0x02, 0x01, 0x00, // Integer (error index), value 0
0x30, 0x11, // Sequence (varbinds)
0x30, 0x0f, // Sequence (varbind)
0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x01, 0x00, // OID (example)
0x02, 0x01, 0x64 // Integer (value), value 100 (100%)
]);
// Simulate a Gauge32 response (battery capacity)
const gauge32Response = Buffer.from([
0x30, 0x29, // Sequence, length 41
0x02, 0x01, 0x00, // Integer (version), value 0
0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public"
0xa2, 0x1c, // GetResponse
0x02, 0x01, 0x01, // Integer (request ID), value 1
0x02, 0x01, 0x00, // Integer (error status), value 0
0x02, 0x01, 0x00, // Integer (error index), value 0
0x30, 0x11, // Sequence (varbinds)
0x30, 0x0f, // Sequence (varbind)
0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x01, 0x00, // OID (example)
0x42, 0x01, 0x64 // Gauge32 (value), value 100 (100%)
]);
// Simulate a TimeTicks response (battery runtime)
const timeTicksResponse = Buffer.from([
0x30, 0x29, // Sequence, length 41
0x02, 0x01, 0x00, // Integer (version), value 0
0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public"
0xa2, 0x1c, // GetResponse
0x02, 0x01, 0x01, // Integer (request ID), value 1
0x02, 0x01, 0x00, // Integer (error status), value 0
0x02, 0x01, 0x00, // Integer (error index), value 0
0x30, 0x11, // Sequence (varbinds)
0x30, 0x0f, // Sequence (varbind)
0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x01, 0x00, // OID (example)
0x43, 0x01, 0x0f // TimeTicks (value), value 15 (0.15 seconds or 15/100 seconds)
]);
// Test parsing INTEGER response
const intValue = SnmpPacketParser.parseSnmpResponse(intResponse, testConfig, true);
console.log('Parsed INTEGER value:', intValue);
expect(intValue).toEqual(100);
// Test parsing Gauge32 response
const gauge32Value = SnmpPacketParser.parseSnmpResponse(gauge32Response, testConfig, true);
console.log('Parsed Gauge32 value:', gauge32Value);
expect(gauge32Value).toEqual(100);
// Test parsing TimeTicks response
const timeTicksValue = SnmpPacketParser.parseSnmpResponse(timeTicksResponse, testConfig, true);
console.log('Parsed TimeTicks value:', timeTicksValue);
expect(timeTicksValue).toEqual(15);
});
tap.test('CyberPower TimeTicks conversion', async () => {
// Test the conversion of TimeTicks to minutes for CyberPower UPS
// Set up a config for CyberPower
const cyberPowerConfig: SnmpConfig = {
...testConfig,
upsModel: 'cyberpower'
};
// Create a simulated TimeTicks response with a value of 104 (104/100 seconds)
const ticksResponse = Buffer.from([
0x30, 0x29, // Sequence
0x02, 0x01, 0x00, // Integer (version), value 0
0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public"
0xa2, 0x1c, // GetResponse
0x02, 0x01, 0x01, // Integer (request ID), value 1
0x02, 0x01, 0x00, // Integer (error status), value 0
0x02, 0x01, 0x00, // Integer (error index), value 0
0x30, 0x11, // Sequence (varbinds)
0x30, 0x0f, // Sequence (varbind)
0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x04, 0x00, // OID (battery runtime)
0x43, 0x01, 0x68 // TimeTicks (value), value 104 (104/100 seconds)
]);
// Mock the getUpsStatus function to test our TimeTicks conversion logic
const mockGetUpsStatus = async () => {
// Parse the TimeTicks value from the response
const runtime = SnmpPacketParser.parseSnmpResponse(ticksResponse, testConfig, true);
console.log('Raw runtime value:', runtime);
// Create a sample UPS status result
const result = {
powerStatus: 'onBattery',
batteryCapacity: 100,
batteryRuntime: 0,
raw: {
powerStatus: 2,
batteryCapacity: 100,
batteryRuntime: runtime,
},
};
// Convert TimeTicks to minutes for CyberPower
if (cyberPowerConfig.upsModel === 'cyberpower' && runtime > 0) {
result.batteryRuntime = Math.floor(runtime / 6000);
console.log(`Converting CyberPower runtime from ${runtime} ticks to ${result.batteryRuntime} minutes`);
} else {
result.batteryRuntime = runtime;
}
return result;
};
// Call our mock function
const status = await mockGetUpsStatus();
// Assert the conversion worked correctly
console.log('Final status object:', status);
expect(status.batteryRuntime).toEqual(0); // 104 ticks / 6000 = 0.0173... rounds to 0 minutes
});
tap.test('Simulate fully charged online UPS', async () => {
// Test a realistic scenario of an online UPS with high battery capacity and ~30 mins runtime
// Create simulated responses for power status (online), battery capacity (95%), runtime (30 min)
// Power Status = 2 (online for CyberPower)
const powerStatusResponse = Buffer.from([
0x30, 0x29, // Sequence
0x02, 0x01, 0x00, // Integer (version), value 0
0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public"
0xa2, 0x1c, // GetResponse
0x02, 0x01, 0x01, // Integer (request ID), value 1
0x02, 0x01, 0x00, // Integer (error status), value 0
0x02, 0x01, 0x00, // Integer (error index), value 0
0x30, 0x11, // Sequence (varbinds)
0x30, 0x0f, // Sequence (varbind)
0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0xed, 0x08, 0x01, 0x01, 0x01, 0x00, // OID (power status)
0x02, 0x01, 0x02 // Integer (value), value 2 (online)
]);
// Battery Capacity = 95% (as Gauge32)
const batteryCapacityResponse = Buffer.from([
0x30, 0x29, // Sequence
0x02, 0x01, 0x00, // Integer (version), value 0
0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public"
0xa2, 0x1c, // GetResponse
0x02, 0x01, 0x02, // Integer (request ID), value 2
0x02, 0x01, 0x00, // Integer (error status), value 0
0x02, 0x01, 0x00, // Integer (error index), value 0
0x30, 0x11, // Sequence (varbinds)
0x30, 0x0f, // Sequence (varbind)
0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0xed, 0x08, 0x01, 0x02, 0x01, 0x00, // OID (battery capacity)
0x42, 0x01, 0x5F // Gauge32 (value), value 95 (95%)
]);
// Battery Runtime = 30 minutes (as TimeTicks)
// 30 minutes = 1800 seconds = 180000 ticks (in 1/100 seconds)
const batteryRuntimeResponse = Buffer.from([
0x30, 0x2c, // Sequence
0x02, 0x01, 0x00, // Integer (version), value 0
0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public"
0xa2, 0x1f, // GetResponse
0x02, 0x01, 0x03, // Integer (request ID), value 3
0x02, 0x01, 0x00, // Integer (error status), value 0
0x02, 0x01, 0x00, // Integer (error index), value 0
0x30, 0x14, // Sequence (varbinds)
0x30, 0x12, // Sequence (varbind)
0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0xed, 0x08, 0x01, 0x02, 0x04, 0x00, // OID (battery runtime)
0x43, 0x04, 0x00, 0x02, 0xBF, 0x20 // TimeTicks (value), value 180000 (1800 seconds = 30 minutes)
]);
// Mock the getUpsStatus function to test with our simulated data
const mockGetUpsStatus = async () => {
console.log('Simulating UPS status request with synthetic data');
// Create a config that specifies this is a CyberPower UPS
const upsConfig: SnmpConfig = {
host: '192.168.1.1',
port: 161,
version: 1,
community: 'public',
timeout: 5000,
upsModel: 'cyberpower',
};
// Parse each simulated response
const powerStatus = SnmpPacketParser.parseSnmpResponse(powerStatusResponse, upsConfig, true);
console.log('Power status value:', powerStatus);
const batteryCapacity = SnmpPacketParser.parseSnmpResponse(batteryCapacityResponse, upsConfig, true);
console.log('Battery capacity value:', batteryCapacity);
const batteryRuntime = SnmpPacketParser.parseSnmpResponse(batteryRuntimeResponse, upsConfig, true);
console.log('Battery runtime value:', batteryRuntime);
// Convert TimeTicks to minutes for CyberPower UPSes
const runtimeMinutes = Math.floor(batteryRuntime / 6000);
console.log(`Converting ${batteryRuntime} ticks to ${runtimeMinutes} minutes`);
// Interpret power status for CyberPower
// CyberPower: 2=online, 3=on battery
let powerStatusText: 'online' | 'onBattery' | 'unknown' = 'unknown';
if (powerStatus === 2) {
powerStatusText = 'online';
} else if (powerStatus === 3) {
powerStatusText = 'onBattery';
}
// Create the status result
const result: UpsStatus = {
powerStatus: powerStatusText,
batteryCapacity: batteryCapacity,
batteryRuntime: runtimeMinutes,
raw: {
powerStatus,
batteryCapacity,
batteryRuntime,
},
};
return result;
};
// Call our mock function
const status = await mockGetUpsStatus();
// Assert that the values match our expectations
console.log('UPS Status Result:', status);
expect(status.powerStatus).toEqual('online');
expect(status.batteryCapacity).toEqual(95);
expect(status.batteryRuntime).toEqual(30);
}); });
// Test with real UPS using the configuration from .nogit/env.json // Test with real UPS using the configuration from .nogit/env.json
tap.test('Real UPS test', async () => { tap.test('Real UPS test v1', async () => {
try { try {
console.log('Testing with real UPS configuration...'); console.log('Testing with real UPS configuration...');
// Extract the correct SNMP config from the test configuration // Extract the correct SNMP config from the test configuration
const snmpConfig = testConfig.snmp; const snmpConfig = testConfigV1.snmp;
console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`);
console.log(` UPS Model: ${snmpConfig.upsModel}`);
// Use a short timeout for testing
const testSnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000) // Use at most 10 seconds for testing
};
// Try to get the UPS status
const status = await snmp.getUpsStatus(testSnmpConfig);
console.log('UPS Status:');
console.log(` Power Status: ${status.powerStatus}`);
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
// Just make sure we got valid data types back
expect(status).toBeTruthy();
expect(['online', 'onBattery', 'unknown']).toContain(status.powerStatus);
expect(typeof status.batteryCapacity).toEqual('number');
expect(typeof status.batteryRuntime).toEqual('number');
} catch (error) {
console.log('Real UPS test failed:', error);
// Skip the test if we can't connect to the real UPS
console.log('Skipping this test since the UPS might not be available');
}
});
tap.test('Real UPS test v3', async () => {
try {
console.log('Testing with real UPS configuration...');
// Extract the correct SNMP config from the test configuration
const snmpConfig = testConfigV3.snmp;
console.log('SNMP Config:'); console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`); console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`); console.log(` Version: SNMPv${snmpConfig.version}`);

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/nupst', name: '@serve.zone/nupst',
version: '2.5.1', version: '2.5.2',
description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices' description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices'
} }

View File

@ -2,7 +2,8 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { NupstSnmp, type ISnmpConfig } from './snmp.js'; import { NupstSnmp } from './snmp/manager.js';
import type { ISnmpConfig } from './snmp/types.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);

View File

@ -1,4 +1,4 @@
import { NupstSnmp } from './snmp.js'; import { NupstSnmp } from './snmp/manager.js';
import { NupstDaemon } from './daemon.js'; import { NupstDaemon } from './daemon.js';
import { NupstSystemd } from './systemd.js'; import { NupstSystemd } from './systemd.js';
import { commitinfo } from './00_commitinfo_data.js'; import { commitinfo } from './00_commitinfo_data.js';

View File

@ -1,6 +0,0 @@
/**
* Re-export from the snmp module
* This file is kept for backward compatibility
*/
export * from './snmp/index.js';

View File

@ -1,98 +0,0 @@
/**
* SNMP encoding utilities
* Contains helper methods for encoding SNMP data
*/
export class SnmpEncoder {
/**
* Convert OID string to array of integers
* @param oid OID string in dotted notation (e.g. "1.3.6.1.2.1")
* @returns Array of integers representing the OID
*/
public static oidToArray(oid: string): number[] {
return oid.split('.').map(n => parseInt(n, 10));
}
/**
* Encode an SNMP integer
* @param value Integer value to encode
* @returns Buffer containing the encoded integer
*/
public static encodeInteger(value: number): Buffer {
const buf = Buffer.alloc(4);
buf.writeInt32BE(value, 0);
// Find first non-zero byte
let start = 0;
while (start < 3 && buf[start] === 0) {
start++;
}
// Handle negative values
if (value < 0 && buf[start] === 0) {
start--;
}
return buf.slice(start);
}
/**
* Encode an OID
* @param oid Array of integers representing the OID
* @returns Buffer containing the encoded OID
*/
public static encodeOID(oid: number[]): Buffer {
// First two numbers are encoded as 40*x+y
let encodedOid = Buffer.from([40 * (oid[0] || 0) + (oid[1] || 0)]);
// Encode remaining numbers
for (let i = 2; i < oid.length; i++) {
const n = oid[i];
if (n < 128) {
// Simple case: number fits in one byte
encodedOid = Buffer.concat([encodedOid, Buffer.from([n])]);
} else {
// Number needs multiple bytes
const bytes = [];
let value = n;
// Create bytes array in reverse order
do {
bytes.unshift(value & 0x7F);
value >>= 7;
} while (value > 0);
// Set high bit on all but the last byte
for (let j = 0; j < bytes.length - 1; j++) {
bytes[j] |= 0x80;
}
encodedOid = Buffer.concat([encodedOid, Buffer.from(bytes)]);
}
}
return encodedOid;
}
/**
* Decode an ASN.1 integer
* @param buffer Buffer containing the encoded integer
* @param offset Offset in the buffer
* @param length Length of the integer in bytes
* @returns Decoded integer value
*/
public static decodeInteger(buffer: Buffer, offset: number, length: number): number {
if (length === 1) {
return buffer[offset];
} else if (length === 2) {
return buffer.readInt16BE(offset);
} else if (length === 3) {
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
} else if (length === 4) {
return buffer.readInt32BE(offset);
} else {
// For longer integers, we'll just return a simple value
return buffer[offset];
}
}
}

View File

@ -1,8 +1,7 @@
import * as dgram from 'dgram'; import * as dgram from 'dgram';
import * as snmp from 'net-snmp';
import type { IOidSet, ISnmpConfig, TUpsModel, IUpsStatus } from './types.js'; import type { IOidSet, ISnmpConfig, TUpsModel, IUpsStatus } from './types.js';
import { UpsOidSets } from './oid-sets.js'; import { UpsOidSets } from './oid-sets.js';
import { SnmpPacketCreator } from './packet-creator.js';
import { SnmpPacketParser } from './packet-parser.js';
/** /**
* Class for SNMP communication with UPS devices * Class for SNMP communication with UPS devices
@ -13,6 +12,8 @@ export class NupstSnmp {
private activeOIDs: IOidSet; private activeOIDs: IOidSet;
// Reference to the parent Nupst instance // Reference to the parent Nupst instance
private nupst: any; // Type 'any' to avoid circular dependency private nupst: any; // Type 'any' to avoid circular dependency
// Debug mode flag
private debug: boolean = false;
// Default SNMP configuration // Default SNMP configuration
private readonly DEFAULT_CONFIG: ISnmpConfig = { private readonly DEFAULT_CONFIG: ISnmpConfig = {
@ -24,13 +25,6 @@ export class NupstSnmp {
upsModel: 'cyberpower', // Default UPS model 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 * Create a new SNMP manager
* @param debug Whether to enable debug mode * @param debug Whether to enable debug mode
@ -56,6 +50,14 @@ export class NupstSnmp {
return this.nupst; return this.nupst;
} }
/**
* Enable debug mode
*/
public enableDebug(): void {
this.debug = true;
console.log('SNMP debug mode enabled - detailed logs will be shown');
}
/** /**
* Set active OID set based on UPS model * Set active OID set based on UPS model
* @param config SNMP configuration * @param config SNMP configuration
@ -78,121 +80,190 @@ export class NupstSnmp {
console.log(`Using OIDs for UPS model: ${model}`); 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 * Send an SNMP GET request using the net-snmp package
* @param oid OID to query * @param oid OID to query
* @param config SNMP configuration * @param config SNMP configuration
* @param retryCount Current retry count (unused in this implementation)
* @returns Promise resolving to the SNMP response value * @returns Promise resolving to the SNMP response value
*/ */
public async snmpGet(oid: string, config = this.DEFAULT_CONFIG): Promise<any> { public async snmpGet(
oid: string,
config = this.DEFAULT_CONFIG,
retryCount = 0
): Promise<any> {
return new Promise((resolve, reject) => { 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) { if (this.debug) {
console.log(`Sending SNMP ${config.version === 3 ? 'v3' : ('v' + config.version)} request to ${config.host}:${config.port}`); console.log(`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`);
console.log('Request length:', request.length); console.log('Using community:', config.community);
console.log('First 16 bytes of request:', request.slice(0, 16).toString('hex'));
console.log('Full request hex:', request.toString('hex'));
} }
// Create SNMP options based on configuration
const options: any = {
port: config.port,
retries: 2, // 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;
// Set timeout - add extra logging for debugging if (config.version === 3) {
const timeout = setTimeout(() => { // For SNMPv3, we need to set up authentication and privacy
socket.close(); // For SNMPv3, we need a valid security level
if (this.debug) { const securityLevel = config.securityLevel || 'noAuthNoPriv';
console.error('---------------------------------------');
console.error('SNMP request timed out after', config.timeout, 'ms'); // Create the user object with required structure for net-snmp
console.error('SNMP Version:', config.version); const user: any = {
if (config.version === 3) { name: config.username || ''
console.error('SNMPv3 Security Level:', config.securityLevel); };
console.error('SNMPv3 Username:', config.username);
console.error('SNMPv3 Auth Protocol:', config.authProtocol || 'None'); // Set security level
console.error('SNMPv3 Privacy Protocol:', config.privProtocol || 'None'); 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) {
console.log('Warning: 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) {
console.log('Warning: Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
} }
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) { if (this.debug) {
console.log(`Received SNMP response from ${rinfo.address}:${rinfo.port}`); console.log('SNMPv3 user configuration:', {
console.log('Response length:', message.length); name: user.name,
console.log('First 16 bytes of response:', message.slice(0, 16).toString('hex')); level: Object.keys(snmp.SecurityLevel).find(key => snmp.SecurityLevel[key] === user.level),
console.log('Full response hex:', message.toString('hex')); authProtocol: user.authProtocol ? 'Set' : 'Not Set',
authKey: user.authKey ? 'Set' : 'Not Set',
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
privKey: user.privKey ? 'Set' : 'Not Set'
});
} }
try { session = snmp.createV3Session(config.host, user, options);
const result = SnmpPacketParser.parseSnmpResponse(message, config, this.debug); } else {
// For SNMPv1/v2c, we use the community string
if (this.debug) { session = snmp.createSession(config.host, config.community || 'public', options);
console.log('Parsed SNMP response:', result); }
}
// Convert the OID string to an array of OIDs if multiple OIDs are needed
socket.close(); const oids = [oid];
resolve(result);
} catch (error) { // Send the GET request
if (this.debug) { session.get(oids, (error: any, varbinds: any[]) => {
console.error('Error parsing SNMP response:', error); // Close the session to release resources
} session.close();
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) { if (error) {
clearTimeout(timeout);
socket.close();
if (this.debug) { if (this.debug) {
console.error('Error sending SNMP request:', error); console.error('SNMP GET error:', error);
} }
reject(error); reject(new Error(`SNMP GET error: ${error.message || error}`));
} else if (this.debug) { return;
console.log('SNMP request sent successfully');
} }
if (!varbinds || varbinds.length === 0) {
if (this.debug) {
console.error('No varbinds returned in response');
}
reject(new Error('No varbinds returned in response'));
return;
}
// 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 (this.debug) {
console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]);
}
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
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 => 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);
}
if (this.debug) {
console.log('SNMP response:', {
oid: varbinds[0].oid,
type: varbinds[0].type,
value: value
});
}
resolve(value);
}); });
}); });
} }
@ -230,157 +301,16 @@ export class NupstSnmp {
console.log('---------------------------------------'); 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 // Get all values with independent retry logic
const powerStatusValue = await getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status'); const powerStatusValue = await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config);
const batteryCapacity = await getSNMPValueWithRetry(this.activeOIDs.BATTERY_CAPACITY, 'battery capacity') || 0; const batteryCapacity = await this.getSNMPValueWithRetry(this.activeOIDs.BATTERY_CAPACITY, 'battery capacity', config) || 0;
const batteryRuntime = await getSNMPValueWithRetry(this.activeOIDs.BATTERY_RUNTIME, 'battery runtime') || 0; const batteryRuntime = await this.getSNMPValueWithRetry(this.activeOIDs.BATTERY_RUNTIME, 'battery runtime', config) || 0;
// Determine power status - handle different values for different UPS models // Determine power status - handle different values for different UPS models
let powerStatus: 'online' | 'onBattery' | 'unknown' = 'unknown'; const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// 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 if (config.upsModel === 'eaton') {
// Eaton UPS: xupsOutputSource values
// 3=normal/mains, 5=battery, etc.
if (powerStatusValue === 3) {
powerStatus = 'online';
} else if (powerStatusValue === 5) {
powerStatus = 'onBattery';
}
} else {
// Default interpretation for other UPS models
if (powerStatusValue === 1) {
powerStatus = 'online';
} else if (powerStatusValue === 2) {
powerStatus = 'onBattery';
}
}
// Convert to minutes for UPS models with different time units // Convert to minutes for UPS models with different time units
let processedRuntime = batteryRuntime; const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
if (config.upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: 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`);
}
} else if (config.upsModel === 'eaton' && batteryRuntime > 0) {
// Eaton: Runtime is in seconds, convert to minutes
processedRuntime = Math.floor(batteryRuntime / 60);
if (this.debug) {
console.log(`Converting Eaton runtime from ${batteryRuntime} seconds to ${processedRuntime} minutes`);
}
}
const result = { const result = {
powerStatus, powerStatus,
@ -414,109 +344,231 @@ export class NupstSnmp {
} }
/** /**
* Discover SNMP engine ID (for SNMPv3) * Helper method to get SNMP value with retry and fallback logic
* Sends a proper discovery message to get the engine ID from the device * @param oid OID to query
* @param description Description of the value for logging
* @param config SNMP configuration * @param config SNMP configuration
* @returns Promise resolving to the discovered engine ID * @returns Promise resolving to the SNMP value
*/ */
public async discoverEngineId(config: ISnmpConfig): Promise<Buffer> { private async getSNMPValueWithRetry(
return new Promise((resolve, reject) => { oid: string,
const socket = dgram.createSocket('udp4'); description: string,
config: ISnmpConfig
// Create a proper discovery message (SNMPv3 with noAuthNoPriv) ): Promise<any> {
const discoveryConfig: ISnmpConfig = { if (oid === '') {
...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) { if (this.debug) {
console.log('Sending SNMPv3 discovery message'); console.log(`No OID provided for ${description}, skipping`);
console.log('SNMPv3 Discovery message:', request.toString('hex')); }
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);
} }
// Set timeout - use a longer timeout for discovery phase // If we're using SNMPv3, try with different security levels
const discoveryTimeout = Math.max(config.timeout, 15000); // At least 15 seconds for discovery if (config.version === 3) {
const timeout = setTimeout(() => { return await this.tryFallbackSecurityLevels(oid, description, config);
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 // Try with standard OIDs as fallback
socket.on('message', (message, rinfo) => { if (config.upsModel !== 'custom') {
clearTimeout(timeout); return await this.tryStandardOids(oid, description, config);
}
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 // Return a default value if all attempts fail
socket.on('error', (error) => { if (this.debug) {
clearTimeout(timeout); console.log(`Using default value 0 for ${description}`);
socket.close(); }
if (this.debug) { return 0;
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');
}
});
});
} }
// initiateShutdown method has been moved to the NupstDaemon class /**
* Try fallback security levels for SNMPv3
* @param oid OID to query
* @param description Description of the value for logging
* @param config SNMP configuration
* @returns Promise resolving to the SNMP value
*/
private async tryFallbackSecurityLevels(
oid: string,
description: string,
config: ISnmpConfig
): Promise<any> {
if (this.debug) {
console.log(`Retrying ${description} with fallback security level...`);
}
// Try with authNoPriv if current level is authPriv
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);
}
}
}
// Try with noAuthNoPriv as a last resort
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
try {
if (this.debug) {
console.log(`Retrying with noAuthNoPriv 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);
}
}
}
return 0;
}
/**
* Try standard OIDs as fallback
* @param oid OID to query
* @param description Description of the value for logging
* @param config SNMP configuration
* @returns Promise resolving to the SNMP value
*/
private async tryStandardOids(
oid: string,
description: string,
config: ISnmpConfig
): Promise<any> {
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 0;
}
/**
* Determine power status based on UPS model and raw value
* @param upsModel UPS model
* @param powerStatusValue Raw power status value
* @returns Standardized power status
*/
private determinePowerStatus(
upsModel: TUpsModel | undefined,
powerStatusValue: number
): 'online' | 'onBattery' | 'unknown' {
if (upsModel === 'cyberpower') {
// CyberPower RMCARD205: upsBaseOutputStatus values
// 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc.
if (powerStatusValue === 2) {
return 'online';
} else if (powerStatusValue === 3) {
return 'onBattery';
}
} else if (upsModel === 'eaton') {
// Eaton UPS: xupsOutputSource values
// 3=normal/mains, 5=battery, etc.
if (powerStatusValue === 3) {
return 'online';
} else if (powerStatusValue === 5) {
return 'onBattery';
}
} else if (upsModel === 'apc') {
// APC UPS: upsBasicOutputStatus values
// 2=online, 3=onBattery, etc.
if (powerStatusValue === 2) {
return 'online';
} else if (powerStatusValue === 3) {
return 'onBattery';
}
} else {
// Default interpretation for other UPS models
if (powerStatusValue === 1) {
return 'online';
} else if (powerStatusValue === 2) {
return 'onBattery';
}
}
return 'unknown';
}
/**
* Process runtime value based on UPS model
* @param upsModel UPS model
* @param batteryRuntime Raw battery runtime value
* @returns Processed runtime in minutes
*/
private processRuntimeValue(
upsModel: TUpsModel | undefined,
batteryRuntime: number
): number {
if (this.debug) {
console.log('Raw runtime value:', batteryRuntime);
}
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
if (this.debug) {
console.log(`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`);
}
return minutes;
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
console.log(`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`);
}
return minutes;
} else if (batteryRuntime > 10000) {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
}
return minutes;
}
return batteryRuntime;
}
} }

View File

@ -1,651 +0,0 @@
import * as crypto from 'crypto';
import type { ISnmpConfig, ISnmpV3SecurityParams } from './types.js';
import { SnmpEncoder } from './encoder.js';
/**
* SNMP packet creation utilities
* Creates SNMP request packets for different SNMP versions
*/
export class SnmpPacketCreator {
/**
* Create an SNMPv1 GET request
* @param oid OID to query
* @param community Community string
* @param debug Whether to enable debug output
* @returns Buffer containing the SNMP request
*/
public static createSnmpGetRequest(oid: string, community: string, debug: boolean = false): Buffer {
const oidArray = SnmpEncoder.oidToArray(oid);
const encodedOid = SnmpEncoder.encodeOID(oidArray);
if (debug) {
console.log('OID array length:', oidArray.length);
console.log('OID array:', oidArray);
}
// SNMP message structure
// Sequence
// Version (Integer)
// Community (String)
// PDU (GetRequest)
// Request ID (Integer)
// Error Status (Integer)
// Error Index (Integer)
// Variable Bindings (Sequence)
// Variable (Sequence)
// OID (ObjectIdentifier)
// Value (Null)
// Use the standard method from our test that is known to work
// Create a fixed request ID (0x00000001) to ensure deterministic behavior
const requestId = Buffer.from([0x00, 0x00, 0x00, 0x01]);
// Encode values
const versionBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x00]) // SNMP version 1 (0)
]);
const communityBuf = Buffer.concat([
Buffer.from([0x04, community.length]), // ASN.1 Octet String, length
Buffer.from(community) // Community string
]);
const requestIdBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
requestId // Fixed Request ID
]);
const errorStatusBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x00]) // Error Status (0 = no error)
]);
const errorIndexBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x00]) // Error Index (0)
]);
const oidValueBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([encodedOid.length + 2]), // Length
Buffer.from([0x06]), // ASN.1 Object Identifier
Buffer.from([encodedOid.length]), // Length
encodedOid, // OID
Buffer.from([0x05, 0x00]) // Null value
]);
const varBindingsBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([oidValueBuf.length]), // Length
oidValueBuf // Variable binding
]);
const pduBuf = Buffer.concat([
Buffer.from([0xa0]), // ASN.1 Context-specific Constructed 0 (GetRequest)
Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]), // Length
requestIdBuf, // Request ID
errorStatusBuf, // Error Status
errorIndexBuf, // Error Index
varBindingsBuf // Variable Bindings
]);
const messageBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([versionBuf.length + communityBuf.length + pduBuf.length]), // Length
versionBuf, // Version
communityBuf, // Community
pduBuf // PDU
]);
if (debug) {
console.log('SNMP Request buffer:', messageBuf.toString('hex'));
}
return messageBuf;
}
/**
* Create an SNMPv3 GET request
* @param oid OID to query
* @param config SNMP configuration
* @param engineID Engine ID
* @param engineBoots Engine boots counter
* @param engineTime Engine time counter
* @param requestID Request ID
* @param debug Whether to enable debug output
* @returns Buffer containing the SNMP request
*/
public static createSnmpV3GetRequest(
oid: string,
config: ISnmpConfig,
engineID: Buffer,
engineBoots: number,
engineTime: number,
requestID: number,
debug: boolean = false
): Buffer {
if (debug) {
console.log('Creating SNMPv3 GET request for OID:', oid);
console.log('With config:', {
...config,
authKey: config.authKey ? '***' : undefined,
privKey: config.privKey ? '***' : undefined
});
}
const oidArray = SnmpEncoder.oidToArray(oid);
const encodedOid = SnmpEncoder.encodeOID(oidArray);
if (debug) {
console.log('Using engine ID:', engineID.toString('hex'));
console.log('Engine boots:', engineBoots);
console.log('Engine time:', engineTime);
console.log('Request ID:', requestID);
}
// Create security parameters
const securityParams: ISnmpV3SecurityParams = {
msgAuthoritativeEngineID: engineID,
msgAuthoritativeEngineBoots: engineBoots,
msgAuthoritativeEngineTime: engineTime,
msgUserName: config.username || '',
msgAuthenticationParameters: Buffer.alloc(12, 0), // Will be filled in later for auth
msgPrivacyParameters: Buffer.alloc(8, 0), // For privacy
};
// Create the PDU (Protocol Data Unit)
// This is wrapped within the security parameters
const requestIdBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(requestID) // Request ID
]);
const errorStatusBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x00]) // Error Status (0 = no error)
]);
const errorIndexBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x00]) // Error Index (0)
]);
const oidValueBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([encodedOid.length + 2]), // Length
Buffer.from([0x06]), // ASN.1 Object Identifier
Buffer.from([encodedOid.length]), // Length
encodedOid, // OID
Buffer.from([0x05, 0x00]) // Null value
]);
const varBindingsBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([oidValueBuf.length]), // Length
oidValueBuf // Variable binding
]);
const pduBuf = Buffer.concat([
Buffer.from([0xa0]), // ASN.1 Context-specific Constructed 0 (GetRequest)
Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]), // Length
requestIdBuf, // Request ID
errorStatusBuf, // Error Status
errorIndexBuf, // Error Index
varBindingsBuf // Variable Bindings
]);
// Create the security parameters
const engineIdBuf = Buffer.concat([
Buffer.from([0x04, securityParams.msgAuthoritativeEngineID.length]), // ASN.1 Octet String
securityParams.msgAuthoritativeEngineID
]);
const engineBootsBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(securityParams.msgAuthoritativeEngineBoots)
]);
const engineTimeBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(securityParams.msgAuthoritativeEngineTime)
]);
const userNameBuf = Buffer.concat([
Buffer.from([0x04, securityParams.msgUserName.length]), // ASN.1 Octet String
Buffer.from(securityParams.msgUserName)
]);
const authParamsBuf = Buffer.concat([
Buffer.from([0x04, securityParams.msgAuthenticationParameters.length]), // ASN.1 Octet String
securityParams.msgAuthenticationParameters
]);
const privParamsBuf = Buffer.concat([
Buffer.from([0x04, securityParams.msgPrivacyParameters.length]), // ASN.1 Octet String
securityParams.msgPrivacyParameters
]);
// Security parameters sequence
const securityParamsBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([engineIdBuf.length + engineBootsBuf.length + engineTimeBuf.length +
userNameBuf.length + authParamsBuf.length + privParamsBuf.length]), // Length
engineIdBuf,
engineBootsBuf,
engineTimeBuf,
userNameBuf,
authParamsBuf,
privParamsBuf
]);
// Determine security level flags
let securityFlags = 0;
if (config.securityLevel === 'authNoPriv' || config.securityLevel === 'authPriv') {
securityFlags |= 0x01; // Authentication flag
}
if (config.securityLevel === 'authPriv') {
securityFlags |= 0x02; // Privacy flag
}
// Set reportable flag - required for SNMPv3
securityFlags |= 0x04; // Reportable flag
// Create SNMPv3 header
const msgIdBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(requestID) // Message ID (same as request ID for simplicity)
]);
const msgMaxSizeBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(65507) // Max message size
]);
const msgFlagsBuf = Buffer.concat([
Buffer.from([0x04, 0x01]), // ASN.1 Octet String, length 1
Buffer.from([securityFlags])
]);
const msgSecModelBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x03]) // Security model (3 = USM)
]);
// SNMPv3 header
const msgHeaderBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([msgIdBuf.length + msgMaxSizeBuf.length + msgFlagsBuf.length + msgSecModelBuf.length]), // Length
msgIdBuf,
msgMaxSizeBuf,
msgFlagsBuf,
msgSecModelBuf
]);
// SNMPv3 security parameters
const msgSecurityBuf = Buffer.concat([
Buffer.from([0x04]), // ASN.1 Octet String
Buffer.from([securityParamsBuf.length]), // Length
securityParamsBuf
]);
// Create scopedPDU
// In SNMPv3, the PDU is wrapped in a "scoped PDU" structure
const contextEngineBuf = Buffer.concat([
Buffer.from([0x04, engineID.length]), // ASN.1 Octet String
engineID
]);
const contextNameBuf = Buffer.concat([
Buffer.from([0x04, 0x00]), // ASN.1 Octet String, length 0 (empty context name)
]);
const scopedPduBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([contextEngineBuf.length + contextNameBuf.length + pduBuf.length]), // Length
contextEngineBuf,
contextNameBuf,
pduBuf
]);
// For authPriv, we need to encrypt the scopedPDU
let encryptedPdu = scopedPduBuf;
if (config.securityLevel === 'authPriv' && config.privKey) {
// In a real implementation, encryption would be applied here
// For this example, we'll just simulate it
encryptedPdu = this.simulateEncryption(scopedPduBuf, config);
}
// Final scopedPDU (encrypted or not)
const finalScopedPduBuf = Buffer.concat([
Buffer.from([0x04]), // ASN.1 Octet String
Buffer.from([encryptedPdu.length]), // Length
encryptedPdu
]);
// Combine everything for the final message
const versionBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x03]) // SNMP version 3 (3)
]);
const messageBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([versionBuf.length + msgHeaderBuf.length + msgSecurityBuf.length + finalScopedPduBuf.length]), // Length
versionBuf,
msgHeaderBuf,
msgSecurityBuf,
finalScopedPduBuf
]);
// If using authentication, calculate and insert the authentication parameters
if ((config.securityLevel === 'authNoPriv' || config.securityLevel === 'authPriv') &&
config.authKey && config.authProtocol) {
const authenticatedMsg = this.addAuthentication(messageBuf, config, authParamsBuf);
if (debug) {
console.log('Created authenticated SNMPv3 message');
console.log('Final message length:', authenticatedMsg.length);
}
return authenticatedMsg;
}
if (debug) {
console.log('Created SNMPv3 message without authentication');
console.log('Final message length:', messageBuf.length);
}
return messageBuf;
}
/**
* Simulate encryption for authPriv security level
* In a real implementation, this would use the specified privacy protocol (DES/AES)
* @param data Data to encrypt
* @param config SNMP configuration
* @returns Encrypted data
*/
private static simulateEncryption(data: Buffer, config: ISnmpConfig): Buffer {
// This is a placeholder - in a real implementation, you would:
// 1. Generate an initialization vector (IV)
// 2. Use the privacy key derived from the privKey
// 3. Apply the appropriate encryption algorithm (DES/AES)
// For demonstration purposes only
if (config.privProtocol === 'AES' && config.privKey) {
try {
// Create a deterministic IV for demo purposes (not secure for production)
const iv = Buffer.alloc(16, 0);
const engineID = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
for (let i = 0; i < 8; i++) {
iv[i] = engineID[i % engineID.length];
}
// Create a key from the privKey (proper key localization should be used in production)
const key = crypto.createHash('md5').update(config.privKey).digest();
// Create cipher and encrypt
const cipher = crypto.createCipheriv('aes-128-cfb', key, iv);
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
return encrypted;
} catch (error) {
console.warn('AES encryption failed, falling back to plaintext:', error);
return data;
}
} else if (config.privProtocol === 'DES' && config.privKey) {
try {
// Create a deterministic IV for demo purposes (not secure for production)
const iv = Buffer.alloc(8, 0);
const engineID = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
for (let i = 0; i < 8; i++) {
iv[i] = engineID[i % engineID.length];
}
// Create a key from the privKey (proper key localization should be used in production)
const key = crypto.createHash('md5').update(config.privKey).digest().slice(0, 8);
// Create cipher and encrypt
const cipher = crypto.createCipheriv('des-cbc', key, iv);
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
return encrypted;
} catch (error) {
console.warn('DES encryption failed, falling back to plaintext:', error);
return data;
}
}
return data; // Return unencrypted data as fallback
}
/**
* Add authentication to SNMPv3 message
* @param message Message to authenticate
* @param config SNMP configuration
* @param authParamsBuf Authentication parameters buffer
* @returns Authenticated message
*/
private static addAuthentication(message: Buffer, config: ISnmpConfig, authParamsBuf: Buffer): Buffer {
// In a real implementation, this would:
// 1. Zero out the authentication parameters field
// 2. Calculate HMAC-MD5 or HMAC-SHA1 over the entire message
// 3. Insert the HMAC into the authentication parameters field
if (!config.authKey) {
return message;
}
try {
// Find position of auth parameters in the message
// This is a more reliable way to find the exact position
let authParamsPos = -1;
for (let i = 0; i < message.length - 16; i++) {
// Look for the auth params pattern: 0x04 0x0C 0x00 0x00...
if (message[i] === 0x04 && message[i + 1] === 0x0C) {
// Check if next 12 bytes are all zeros
let allZeros = true;
for (let j = 0; j < 12; j++) {
if (message[i + 2 + j] !== 0) {
allZeros = false;
break;
}
}
if (allZeros) {
authParamsPos = i;
break;
}
}
}
if (authParamsPos === -1) {
return message;
}
// Create a copy of the message with zeroed auth parameters
const msgCopy = Buffer.from(message);
// Prepare the authentication key according to RFC3414
// We should use the standard key localization process
const localizedKey = this.localizeAuthKey(config.authKey,
Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]),
config.authProtocol);
// Calculate HMAC
let hmac;
if (config.authProtocol === 'SHA') {
hmac = crypto.createHmac('sha1', localizedKey).update(msgCopy).digest().slice(0, 12);
} else {
// Default to MD5
hmac = crypto.createHmac('md5', localizedKey).update(msgCopy).digest().slice(0, 12);
}
// Copy HMAC into original message
hmac.copy(message, authParamsPos + 2);
return message;
} catch (error) {
console.warn('Authentication failed:', error);
return message;
}
}
/**
* Localize authentication key according to RFC3414
* @param key Authentication key
* @param engineId Engine ID
* @param authProtocol Authentication protocol
* @returns Localized key
*/
private static localizeAuthKey(key: string, engineId: Buffer, authProtocol: string = 'MD5'): Buffer {
try {
// Convert password to key using hash
let initialHash;
if (authProtocol === 'SHA') {
initialHash = crypto.createHash('sha1');
} else {
initialHash = crypto.createHash('md5');
}
// Generate the initial key - repeated hashing of password + padding
const password = Buffer.from(key);
let passwordIndex = 0;
// Create a buffer of 1MB (1048576 bytes) filled with the password
const buffer = Buffer.alloc(1048576);
for (let i = 0; i < 1048576; i++) {
buffer[i] = password[passwordIndex];
passwordIndex = (passwordIndex + 1) % password.length;
}
initialHash.update(buffer);
let initialKey = initialHash.digest();
// Localize the key with engine ID
let localHash;
if (authProtocol === 'SHA') {
localHash = crypto.createHash('sha1');
} else {
localHash = crypto.createHash('md5');
}
localHash.update(initialKey);
localHash.update(engineId);
localHash.update(initialKey);
return localHash.digest();
} catch (error) {
console.error('Error localizing auth key:', error);
// Return a fallback key
return Buffer.from(key);
}
}
/**
* Create a discovery message for SNMPv3 engine ID discovery
* @param config SNMP configuration
* @param requestID Request ID
* @returns Discovery message
*/
public static createDiscoveryMessage(config: ISnmpConfig, requestID: number): Buffer {
// Basic SNMPv3 header for discovery
const msgIdBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(requestID)
]);
const msgMaxSizeBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(65507) // Max message size
]);
const msgFlagsBuf = Buffer.concat([
Buffer.from([0x04, 0x01]), // ASN.1 Octet String, length 1
Buffer.from([0x00]) // No authentication or privacy
]);
const msgSecModelBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x03]) // Security model (3 = USM)
]);
// SNMPv3 header
const msgHeaderBuf = Buffer.concat([
Buffer.from([0x30]), // ASN.1 Sequence
Buffer.from([msgIdBuf.length + msgMaxSizeBuf.length + msgFlagsBuf.length + msgSecModelBuf.length]), // Length
msgIdBuf,
msgMaxSizeBuf,
msgFlagsBuf,
msgSecModelBuf
]);
// Simple security parameters for discovery
const securityBuf = Buffer.concat([
Buffer.from([0x04, 0x00]), // Empty octet string
]);
// Simple Get request for discovery
const requestIdBuf = Buffer.concat([
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
SnmpEncoder.encodeInteger(requestID + 1)
]);
const errorStatusBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x00]) // Error Status (0 = no error)
]);
const errorIndexBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x00]) // Error Index (0)
]);
// Empty varbinds for discovery
const varBindingsBuf = Buffer.concat([
Buffer.from([0x30, 0x00]), // Empty sequence
]);
const pduBuf = Buffer.concat([
Buffer.from([0xa0]), // GetRequest
Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]),
requestIdBuf,
errorStatusBuf,
errorIndexBuf,
varBindingsBuf
]);
// Context data
const contextEngineBuf = Buffer.concat([
Buffer.from([0x04, 0x00]), // Empty octet string
]);
const contextNameBuf = Buffer.concat([
Buffer.from([0x04, 0x00]), // Empty octet string
]);
const scopedPduBuf = Buffer.concat([
Buffer.from([0x30]),
Buffer.from([contextEngineBuf.length + contextNameBuf.length + pduBuf.length]),
contextEngineBuf,
contextNameBuf,
pduBuf
]);
// Version
const versionBuf = Buffer.concat([
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
Buffer.from([0x03]) // SNMP version 3 (3)
]);
// Complete message
return Buffer.concat([
Buffer.from([0x30]),
Buffer.from([versionBuf.length + msgHeaderBuf.length + securityBuf.length + scopedPduBuf.length]),
versionBuf,
msgHeaderBuf,
securityBuf,
scopedPduBuf
]);
}
}

View File

@ -1,553 +0,0 @@
import type { ISnmpConfig } from './types.js';
import { SnmpEncoder } from './encoder.js';
/**
* SNMP packet parsing utilities
* Parses SNMP response packets
*/
export class SnmpPacketParser {
/**
* Parse an SNMP response
* @param buffer Response buffer
* @param config SNMP configuration
* @param debug Whether to enable debug output
* @returns Parsed value or null if parsing failed
*/
public static parseSnmpResponse(buffer: Buffer, config: ISnmpConfig, debug: boolean = false): any {
// Check if we have a response packet
if (buffer[0] !== 0x30) {
throw new Error('Invalid SNMP response format');
}
// For SNMPv3, we need to handle the message differently
if (config.version === 3) {
return this.parseSnmpV3Response(buffer, debug);
}
if (debug) {
console.log('Parsing SNMPv1/v2 response: ', buffer.toString('hex'));
}
try {
// Enhanced structured parsing approach
// SEQUENCE header
let pos = 0;
if (buffer[pos] !== 0x30) {
throw new Error('Missing SEQUENCE at start of response');
}
// Skip SEQUENCE header - assume length is in single byte for simplicity
// In a more robust implementation, we'd handle multi-byte lengths
pos += 2;
// VERSION
if (buffer[pos] !== 0x02) {
throw new Error('Missing INTEGER for version');
}
const versionLength = buffer[pos + 1];
pos += 2 + versionLength;
// COMMUNITY STRING
if (buffer[pos] !== 0x04) {
throw new Error('Missing OCTET STRING for community');
}
const communityLength = buffer[pos + 1];
pos += 2 + communityLength;
// PDU TYPE - should be RESPONSE (0xA2)
if (buffer[pos] !== 0xA2) {
throw new Error(`Unexpected PDU type: 0x${buffer[pos].toString(16)}, expected 0xA2`);
}
// Skip PDU header
pos += 2;
// REQUEST ID
if (buffer[pos] !== 0x02) {
throw new Error('Missing INTEGER for request ID');
}
const requestIdLength = buffer[pos + 1];
pos += 2 + requestIdLength;
// ERROR STATUS
if (buffer[pos] !== 0x02) {
throw new Error('Missing INTEGER for error status');
}
const errorStatusLength = buffer[pos + 1];
const errorStatus = SnmpEncoder.decodeInteger(buffer, pos + 2, errorStatusLength);
if (errorStatus !== 0) {
throw new Error(`SNMP error status: ${errorStatus}`);
}
pos += 2 + errorStatusLength;
// ERROR INDEX
if (buffer[pos] !== 0x02) {
throw new Error('Missing INTEGER for error index');
}
const errorIndexLength = buffer[pos + 1];
pos += 2 + errorIndexLength;
// VARBIND LIST
if (buffer[pos] !== 0x30) {
throw new Error('Missing SEQUENCE for varbind list');
}
// Skip varbind list header
pos += 2;
// VARBIND
if (buffer[pos] !== 0x30) {
throw new Error('Missing SEQUENCE for varbind');
}
// Skip varbind header
pos += 2;
// OID
if (buffer[pos] !== 0x06) {
throw new Error('Missing OBJECT IDENTIFIER for OID');
}
const oidLength = buffer[pos + 1];
pos += 2 + oidLength;
// VALUE - this is what we want
const valueType = buffer[pos];
const valueLength = buffer[pos + 1];
if (debug) {
console.log(`Found value type: 0x${valueType.toString(16)}, length: ${valueLength}`);
}
return this.parseValueByType(valueType, valueLength, buffer, pos, debug);
} catch (error) {
if (debug) {
console.error('Error in structured parsing:', error);
console.error('Falling back to scan-based parsing method');
}
return this.scanBasedParsing(buffer, debug);
}
}
/**
* Parse value by ASN.1 type
* @param valueType ASN.1 type
* @param valueLength Value length
* @param buffer Buffer containing the value
* @param pos Position of the value in the buffer
* @param debug Whether to enable debug output
* @returns Parsed value
*/
private static parseValueByType(
valueType: number,
valueLength: number,
buffer: Buffer,
pos: number,
debug: boolean
): any {
switch (valueType) {
case 0x02: // INTEGER
{
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
if (debug) {
console.log('Parsed INTEGER value:', value);
}
return value;
}
case 0x04: // OCTET STRING
{
const value = buffer.slice(pos + 2, pos + 2 + valueLength).toString();
if (debug) {
console.log('Parsed OCTET STRING value:', value);
}
return value;
}
case 0x05: // NULL
if (debug) {
console.log('Parsed NULL value');
}
return null;
case 0x06: // OBJECT IDENTIFIER (rare in a value position)
{
// Usually this would be encoded as a string representation
const value = buffer.slice(pos + 2, pos + 2 + valueLength).toString('hex');
if (debug) {
console.log('Parsed OBJECT IDENTIFIER value (hex):', value);
}
return value;
}
case 0x40: // IP ADDRESS
{
if (valueLength !== 4) {
throw new Error(`Invalid IP address length: ${valueLength}, expected 4`);
}
const octets = [];
for (let i = 0; i < 4; i++) {
octets.push(buffer[pos + 2 + i]);
}
const value = octets.join('.');
if (debug) {
console.log('Parsed IP ADDRESS value:', value);
}
return value;
}
case 0x41: // COUNTER
case 0x42: // GAUGE32
case 0x43: // TIMETICKS
case 0x44: // OPAQUE
{
// All these are essentially unsigned 32-bit integers
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
if (debug) {
console.log(`Parsed ${valueType === 0x41 ? 'COUNTER'
: valueType === 0x42 ? 'GAUGE32'
: valueType === 0x43 ? 'TIMETICKS'
: 'OPAQUE'} value:`, value);
}
return value;
}
default:
if (debug) {
console.log(`Unknown value type: 0x${valueType.toString(16)}`);
}
return null;
}
}
/**
* Fallback scan-based parsing method
* @param buffer Buffer containing the SNMP response
* @param debug Whether to enable debug output
* @returns Parsed value or null if parsing failed
*/
private static scanBasedParsing(buffer: Buffer, debug: boolean): any {
// Look for various data types in the response
// The value is near the end of the packet after the OID
// We're looking for one of these:
// 0x02 - Integer - can be at the end of a varbind
// 0x04 - OctetString
// 0x05 - Null
// 0x42 - Gauge32 - special type for unsigned 32-bit integers
// 0x43 - Timeticks - special type for time values
// This algorithm performs a thorough search for data types
// by iterating from the start and watching for varbind structures
// Walk through the buffer looking for varbinds
let i = 0;
// First, find the varbinds section (0x30 sequence)
while (i < buffer.length - 2) {
// Look for a varbinds sequence
if (buffer[i] === 0x30) {
const varbindsLength = buffer[i + 1];
const varbindsEnd = i + 2 + varbindsLength;
// Now search within the varbinds for the value
let j = i + 2;
while (j < varbindsEnd - 2) {
// Look for a varbind (0x30 sequence)
if (buffer[j] === 0x30) {
const varbindLength = buffer[j + 1];
const varbindEnd = j + 2 + varbindLength;
// Skip over the OID and find the value within this varbind
let k = j + 2;
while (k < varbindEnd - 1) {
// First find the OID
if (buffer[k] === 0x06) { // OID
const oidLength = buffer[k + 1];
k += 2 + oidLength; // Skip past the OID
// We should now be at the value
// Check what type it is
if (k < varbindEnd - 1) {
return this.parseValueAtPosition(buffer, k, debug);
}
// If we didn't find a value, move to next byte
k++;
} else {
// Move to next byte
k++;
}
}
// Move to next varbind
j = varbindEnd;
} else {
// Move to next byte
j++;
}
}
// Move to next sequence
i = varbindsEnd;
} else {
// Move to next byte
i++;
}
}
if (debug) {
console.log('No valid value found in SNMP response');
}
return null;
}
/**
* Parse value at a specific position in the buffer
* @param buffer Buffer containing the SNMP response
* @param pos Position of the value in the buffer
* @param debug Whether to enable debug output
* @returns Parsed value or null if parsing failed
*/
private static parseValueAtPosition(buffer: Buffer, pos: number, debug: boolean): any {
if (buffer[pos] === 0x02) { // Integer
const valueLength = buffer[pos + 1];
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
if (debug) {
console.log('Found Integer value:', value);
}
return value;
} else if (buffer[pos] === 0x42) { // Gauge32
const valueLength = buffer[pos + 1];
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
if (debug) {
console.log('Found Gauge32 value:', value);
}
return value;
} else if (buffer[pos] === 0x43) { // TimeTicks
const valueLength = buffer[pos + 1];
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
if (debug) {
console.log('Found Timeticks value:', value);
}
return value;
} else if (buffer[pos] === 0x04) { // OctetString
const valueLength = buffer[pos + 1];
if (debug) {
console.log('Found OctetString value');
}
// Just return the string value as-is
return buffer.slice(pos + 2, pos + 2 + valueLength).toString();
} else if (buffer[pos] === 0x05) { // Null
if (debug) {
console.log('Found Null value');
}
return null;
}
return null;
}
/**
* Parse an SNMPv3 response
* @param buffer Buffer containing the SNMP response
* @param debug Whether to enable debug output
* @returns Parsed value or null if parsing failed
*/
public static parseSnmpV3Response(buffer: Buffer, debug: boolean = false): any {
// SNMPv3 parsing is complex. In a real implementation, we would:
// 1. Parse the header and get the security parameters
// 2. Verify authentication if used
// 3. Decrypt the PDU if privacy was used
// 4. Extract the PDU and parse it
if (debug) {
console.log('Parsing SNMPv3 response: ', buffer.toString('hex'));
}
// Find the scopedPDU - it should be the last OCTET STRING in the message
let scopedPduPos = -1;
for (let i = buffer.length - 50; i >= 0; i--) {
if (buffer[i] === 0x04 && buffer[i + 1] > 10) { // OCTET STRING with reasonable length
scopedPduPos = i;
break;
}
}
if (scopedPduPos === -1) {
if (debug) {
console.log('Could not find scoped PDU in SNMPv3 response');
}
return null;
}
// Skip to the PDU content
let pduContent = buffer.slice(scopedPduPos + 2); // Skip OCTET STRING header
// This improved algorithm performs a more thorough search for varbinds
// in the scoped PDU
// First, look for the response PDU (sequence with tag 0xa2)
let responsePdu = null;
for (let i = 0; i < pduContent.length - 3; i++) {
if (pduContent[i] === 0xa2) {
// Found the response PDU
const pduLength = pduContent[i + 1];
responsePdu = pduContent.slice(i, i + 2 + pduLength);
break;
}
}
if (!responsePdu) {
// Try to find the varbinds directly
for (let i = 0; i < pduContent.length - 3; i++) {
if (pduContent[i] === 0x30) {
const seqLength = pduContent[i + 1];
if (i + 2 + seqLength <= pduContent.length) {
// Check if this sequence might be the varbinds
const possibleVarbinds = pduContent.slice(i, i + 2 + seqLength);
// Look for varbind structure inside
for (let j = 0; j < possibleVarbinds.length - 3; j++) {
if (possibleVarbinds[j] === 0x30) {
// Might be a varbind - look for an OID inside
for (let k = j; k < j + 10 && k < possibleVarbinds.length - 1; k++) {
if (possibleVarbinds[k] === 0x06) {
// Found an OID, so this is likely the varbinds sequence
responsePdu = possibleVarbinds;
break;
}
}
if (responsePdu) break;
}
}
if (responsePdu) break;
}
}
}
}
if (!responsePdu) {
if (debug) {
console.log('Could not find response PDU in SNMPv3 response');
}
return null;
}
// Now that we have the response PDU, search for varbinds
// Skip the first few bytes to get past the header fields
let varbindsPos = -1;
for (let i = 10; i < responsePdu.length - 3; i++) {
if (responsePdu[i] === 0x30) {
// Check if this is the start of the varbinds
// by seeing if it contains a varbind sequence
for (let j = i + 2; j < i + 10 && j < responsePdu.length - 3; j++) {
if (responsePdu[j] === 0x30) {
varbindsPos = i;
break;
}
}
if (varbindsPos !== -1) break;
}
}
if (varbindsPos === -1) {
if (debug) {
console.log('Could not find varbinds in SNMPv3 response');
}
return null;
}
// Get the varbinds
const varbindsLength = responsePdu[varbindsPos + 1];
const varbinds = responsePdu.slice(varbindsPos, varbindsPos + 2 + varbindsLength);
// Now search for values inside the varbinds
for (let i = 2; i < varbinds.length - 3; i++) {
// Look for a varbind sequence
if (varbinds[i] === 0x30) {
const varbindLength = varbinds[i + 1];
const varbind = varbinds.slice(i, i + 2 + varbindLength);
// Inside the varbind, look for the OID and then the value
for (let j = 0; j < varbind.length - 3; j++) {
if (varbind[j] === 0x06) { // OID
const oidLength = varbind[j + 1];
// The value should be right after the OID
const valuePos = j + 2 + oidLength;
if (valuePos < varbind.length - 1) {
// Check what type of value it is
if (varbind[valuePos] === 0x02) { // INTEGER
const valueLength = varbind[valuePos + 1];
const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength);
if (debug) {
console.log('Found INTEGER value in SNMPv3 response:', value);
}
return value;
} else if (varbind[valuePos] === 0x42) { // Gauge32
const valueLength = varbind[valuePos + 1];
const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength);
if (debug) {
console.log('Found Gauge32 value in SNMPv3 response:', value);
}
return value;
} else if (varbind[valuePos] === 0x43) { // TimeTicks
const valueLength = varbind[valuePos + 1];
const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength);
if (debug) {
console.log('Found TimeTicks value in SNMPv3 response:', value);
}
return value;
} else if (varbind[valuePos] === 0x04) { // OctetString
const valueLength = varbind[valuePos + 1];
const value = varbind.slice(valuePos + 2, valuePos + 2 + valueLength).toString();
if (debug) {
console.log('Found OctetString value in SNMPv3 response:', value);
}
return value;
}
}
}
}
}
}
if (debug) {
console.log('No valid value found in SNMPv3 response');
}
return null;
}
/**
* Extract engine ID from SNMPv3 response
* @param buffer Buffer containing the SNMP response
* @param debug Whether to enable debug output
* @returns Extracted engine ID or null if extraction failed
*/
public static extractEngineId(buffer: Buffer, debug: boolean = false): Buffer | null {
try {
// Simple parsing to find the engine ID
// Look for the first octet string with appropriate length
for (let i = 0; i < buffer.length - 10; i++) {
if (buffer[i] === 0x04) { // Octet string
const len = buffer[i + 1];
if (len >= 5 && len <= 32) { // Engine IDs are typically 5-32 bytes
// Verify this looks like an engine ID (usually starts with 0x80)
if (buffer[i + 2] === 0x80) {
if (debug) {
console.log('Found engine ID at position', i);
console.log('Engine ID:', buffer.slice(i + 2, i + 2 + len).toString('hex'));
}
return buffer.slice(i + 2, i + 2 + len);
}
}
}
}
return null;
} catch (error) {
console.error('Error extracting engine ID:', error);
return null;
}
}
}

View File

@ -45,6 +45,8 @@ export interface ISnmpConfig {
version: number; version: number;
/** Timeout in milliseconds */ /** Timeout in milliseconds */
timeout: number; timeout: number;
context?: string;
// SNMPv1/v2c // SNMPv1/v2c
/** Community string for SNMPv1/v2c */ /** Community string for SNMPv1/v2c */