This commit is contained in:
Philipp Kunz 2025-03-25 09:06:23 +00:00
commit 1e74fbe367
27 changed files with 14700 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Build
dist*/
# Dependencies
node_modules/
# Bundled Node.js binaries
vendor/
# Logs
*.log
npm-debug.log*
# Environment
.env
# OS specific
.DS_Store
Thumbs.db
.nogit/

49
bin/nupst Executable file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# NUPST Launcher Script
# This script detects architecture and OS, then runs NUPST with the appropriate Node.js binary
# First, handle symlinks correctly
REAL_SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
SCRIPT_DIR=$(dirname "$REAL_SCRIPT_PATH")
# For debugging
# echo "Script path: $REAL_SCRIPT_PATH"
# echo "Script dir: $SCRIPT_DIR"
# If we're run via symlink from /usr/local/bin, use the hardcoded installation path
if [[ "$SCRIPT_DIR" == "/usr/local/bin" ]]; then
PROJECT_ROOT="/opt/nupst"
else
# Otherwise, use relative path from script location
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
fi
# For debugging
# echo "Project root: $PROJECT_ROOT"
# Set Node.js binary path directly
NODE_BIN="$PROJECT_ROOT/vendor/node-linux-x64/bin/node"
# If binary doesn't exist, try system Node as fallback
if [ ! -f "$NODE_BIN" ]; then
if command -v node &> /dev/null; then
NODE_BIN="node"
echo "Using system Node.js installation"
else
echo "Error: Node.js binary not found at $NODE_BIN"
echo "Please run the setup script or install Node.js manually."
exit 1
fi
fi
# Run NUPST with the Node.js binary
if [ -f "$PROJECT_ROOT/dist_ts/index.js" ]; then
exec "$NODE_BIN" "$PROJECT_ROOT/dist_ts/index.js" "$@"
elif [ -f "$PROJECT_ROOT/dist/index.js" ]; then
exec "$NODE_BIN" "$PROJECT_ROOT/dist/index.js" "$@"
else
echo "Error: Could not find NUPST's index.js file."
echo "Please run the setup script to download the required files."
exit 1
fi

184
changelog.md Normal file
View File

@ -0,0 +1,184 @@
# Changelog
## 2025-03-25 - 1.7.6 - fix(core)
Refactor SNMP, systemd, and CLI modules to improve error handling, logging, and code clarity
- Removed unused dependency 'net-snmp' from package.json
- Extracted helper functions for SNMP packet creation and parsing (using SnmpEncoder, SnmpPacketCreator and SnmpPacketParser)
- Improved debug logging and added detailed documentation comments across SNMP, systemd, CLI and daemon modules
- Refactored systemd service management to extract status display and service disabling logic
- Updated test suite to use proper modular methods from the new SNMP utilities
## 2025-03-25 - 1.7.5 - fix(cli)
Enable SNMP debug mode in CLI commands and update debug flag handling in daemon-start and test; bump version to 1.7.4
- Call enableDebug() on SNMP client earlier in command parsing
- Pass debug flag to 'daemon-start' and 'test' commands for consistent debug output
- Update package version from 1.7.3 to 1.7.4
## 2025-03-25 - 1.7.3 - fix(SNMP)
Refine SNMP packet creation and response parsing for more reliable UPS status monitoring
- Improve error handling and fallback logic when parsing SNMP responses
- Optimize TimeTicks conversion for CyberPower UPS devices
- Enhance test coverage for various UPS scenarios
## 2025-03-25 - 1.7.2 - fix(core)
Refactor internal SNMP response parsing and enhance daemon logging for improved error reporting and clarity.
- Improved fallback and error handling in SNMP response parsing
- Enhanced logging messages in daemon and systemd service management
- Minor refactoring for better maintainability without functional changes
## 2025-03-25 - 1.7.1 - fix(snmp-cli)
Improve SNMP response parsing and CLI UPS connection timeout handling
- Expand parsing loop in SNMP responses to capture Gauge32 and Timeticks values
- Add detailed debug logging for both SNMP v1/v2 and v3 responses
- Configure CLI test commands to use a shortened timeout for UPS connection tests
## 2025-03-25 - 1.7.0 - feat(SNMP/UPS)
Add UPS model selection and custom OIDs support to handle different UPS brands
- Introduce distinct OID sets for CyberPower, APC, Eaton, TrippLite, Liebert, and a custom option
- Update interactive setup to prompt for UPS model selection and custom OID entry when needed
- Refactor SNMP status retrieval to dynamically select the appropriate OIDs based on the configured UPS model
- Extend default configuration with an upsModel property for consistent behavior
## 2025-03-25 - 1.6.0 - feat(cli,snmp)
Enhance debug logging and add debug mode support in CLI and SNMP modules
- Enable debug flags (--debug, -d) in CLI to trigger detailed SNMP logging
- Pass debug mode options to daemonStart and test commands for improved diagnostics
- Add enableDebug method to the SNMP module for on-demand debug logging
- Improve timeout and discovery logging details for streamlined troubleshooting
## 2025-03-25 - 1.5.0 - feat(cli)
Enhance CLI output: display SNMPv3 auth/priv details and support timeout customization during setup
- Display authentication and privacy protocol details when SNMP version is 3
- Show timeout value in the configuration test output
- Clear out unused authentication or privacy settings based on selected security level
- Allow users to customize SNMP timeout during interactive setup
## 2025-03-25 - 1.4.1 - fix(version)
Bump patch version for consistency with commit info
## 2025-03-25 - 1.4.0 - feat(snmp)
Implement native SNMPv3 support with simulated encryption and enhanced authentication handling.
- Add fully native SNMPv3 GET request implementation replacing the snmpwalk fallback
- Simulate encryption for authPriv using AES and DES protocols
- Enhance SNMP response parser to properly handle SNMPv3 responses
- Introduce detailed security parameter management for SNMPv3
## 2025-03-25 - 1.3.1 - fix(cli)
Remove redundant SNMP tools checks in CLI and Systemd modules
- Eliminate unnecessary snmpwalk dependency checks in the test command and interactive setup flow.
- Adjust systemd configuration file check to avoid external dependency verification.
## 2025-03-25 - 1.3.0 - feat(cli)
add test command to verify UPS SNMP configuration and connectivity
- Introduce a new 'test' command in the CLI to check the SNMP configuration and UPS connection.
- Validate installation of SNMP tools and configuration file before testing.
- Output UPS status details and compare against defined shutdown thresholds.
## 2025-03-25 - 1.2.6 - fix(cli)
Refactor interactive setup to use dynamic import for readline and ensure proper cleanup
- Replaced synchronous require() with async import for ESM compatibility
- Wrapped interactive setup in try/finally to guarantee readline interface closure
- Enhanced error logging by outputting error.message
## 2025-03-25 - 1.2.5 - fix(error-handling)
Improve error handling in CLI, daemon, and systemd lifecycle management with enhanced logging for configuration issues
- Wrap daemon and service start commands in try-catch blocks to properly handle and log errors
- Throw explicit errors when configuration file is missing instead of silently defaulting
- Enhance log messages for service installation, startup, and status retrieval for clearer debugging
## 2025-03-25 - 1.2.4 - fix(cli/daemon)
Improve logging and user feedback in interactive setup and UPS monitoring
- Refactor configuration summary output in the interactive setup for clearer display
- Enhance logging in the daemon to show detailed SNMP settings and UPS status changes
- Improve error messages and user guidance during configuration and monitoring
## 2025-03-24 - 1.2.3 - fix(nupst)
No changes
## 2025-03-24 - 1.2.2 - fix(bin/nupst)
Improve symlink resolution in launcher script to correctly determine project root based on execution path.
- Replace directory determination with readlink for accurate symlink resolution
- Set project root to '/opt/nupst' when script is run via symlink from /usr/local/bin
- Add debugging comments to assist with path resolution
## 2025-03-24 - 1.2.1 - fix(bin)
Simplify Node.js binary detection in installation script
- Directly set Node binary path to vendor/node-linux-x64/bin/node
- Remove redundant architecture-specific detection logic
- Fallback to system Node if vendor binary is not found
## 2025-03-24 - 1.2.0 - feat(installer)
Improve Node.js binary detection and dynamic LTS version retrieval in setup scripts
- Enhanced bin/nupst to search multiple possible locations for the Node.js binary and fallback to system node if necessary
- Updated setup.sh to fetch the latest LTS Node.js version from nodejs.org and use a fallback version when the request fails
## 2025-03-24 - 1.1.2 - fix(setup.sh)
Improve error handling in setup.sh: exit immediately when the downloaded npm package lacks the dist_ts directory, removing the fallback build-from-source mechanism.
- Removed BUILD_FROM_SOURCE logic that attempted to build from source on missing dist_ts directory
- Updated error messages to clearly indicate failure in downloading a valid package
- Ensured installation halts if essential files are missing
## 2025-03-24 - 1.1.1 - fix(package.json)
Remove unused prepublishOnly script and update files field in package.json
- Removed prepublishOnly build trigger
- Updated files list to accurately include intended directories and files
## 2025-03-24 - 1.1.0 - feat(installer-setup)
Enhance installer and setup scripts for improved global installation and artifact management
- Detect piped installation in install.sh, clone repository automatically, and clean up previous installations
- Update readme.md with correct repository URL and clearer installation instructions
- Improve setup.sh to remove existing dist_ts, download build artifacts from the npm registry, and simplify dependency installation
## 2025-03-24 - 1.0.1 - fix(version)
Bump version to 1.0.1
- Updated commitinfo data to reflect the new patch version.
- Synchronized version information between commitinfo file and package metadata.
## 2025-03-24 - 1.0.1 - fix(build)
Update build script to use 'tsbuild tsfolders --allowimplicitany' and adjust distribution paths in .gitignore
- Replaced 'tsc' with 'tsbuild tsfolders --allowimplicitany' in package.json
- Updated .gitignore to reflect new compiled distribution folder pattern
- Updated changelog to document build improvements and regenerated type definitions
## 2025-03-24 - 1.0.1 - fix(build)
Update build script to use 'tsbuild tsfolders --allowimplicitany' and regenerate distribution type definitions for CLI, daemon, index, nupst, snmp, and systemd modules
- Replaced 'tsc' command with tsbuild in package.json
- Updated .gitignore to reflect new compiled distribution folder pattern
- Added new dist_ts files including .d.ts type definitions and compiled JavaScript for multiple modules
## 2025-03-24 - 1.0.1 - fix(build)
Update build script to use 'tsbuild tsfolders --allowimplicitany' and regenerate distribution type definitions for CLI, daemon, nupst, snmp, and systemd modules.
- Replaced the 'tsc' command with 'tsbuild tsfolders --allowimplicitany' in package.json.
- Added new dist_ts files including type definitions (d.ts) and compiled JavaScript for CLI, daemon, index, nupst, snmp, and systemd.
- Improved the generated CLI declarations and overall distribution build.
## 2025-03-23 - 1.0.0 - initial setup
This range covers the early commits that mainly established the repository structure.
- Initial repository commit with basic project initialization.

97
install.sh Normal file
View File

@ -0,0 +1,97 @@
#!/bin/bash
# NUPST Installer Script
# Downloads and installs NUPST globally on the system
# Can be used directly with curl: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
exit 1
fi
# Detect if script is being piped or run directly
PIPED=0
if [ ! -t 0 ]; then
# Being piped, need to clone the repo
PIPED=1
fi
# Define installation directory
INSTALL_DIR="/opt/nupst"
REPO_URL="https://code.foss.global/serve.zone/nupst.git"
if [ $PIPED -eq 1 ]; then
echo "Installing NUPST from remote repository..."
# Check if git is installed
if ! command -v git &> /dev/null; then
echo "Git is required but not installed. Please install git first."
exit 1
fi
# Check if installation directory exists
if [ -d "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR/.git" ]; then
echo "Existing installation found at $INSTALL_DIR. Updating..."
cd "$INSTALL_DIR"
# Try to update the repository
git fetch origin
git reset --hard origin/main
if [ $? -ne 0 ]; then
echo "Failed to update repository. Reinstalling..."
cd /
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
git clone --depth 1 $REPO_URL "$INSTALL_DIR"
else
echo "Repository updated successfully."
fi
else
# Fresh installation
if [ -d "$INSTALL_DIR" ]; then
echo "Removing previous installation at $INSTALL_DIR..."
rm -rf "$INSTALL_DIR"
fi
# Create installation directory
mkdir -p "$INSTALL_DIR"
# Clone the repository
echo "Cloning NUPST repository to $INSTALL_DIR..."
git clone --depth 1 $REPO_URL "$INSTALL_DIR"
fi
if [ $? -ne 0 ]; then
echo "Failed to clone/update repository. Please check your internet connection."
exit 1
fi
# Set script directory to the cloned repo
SCRIPT_DIR="$INSTALL_DIR"
else
# Running directly from within the repo
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
fi
# Run setup script
echo "Running setup script..."
bash "$SCRIPT_DIR/setup.sh"
# Install globally
echo "Installing NUPST globally..."
ln -sf "$SCRIPT_DIR/bin/nupst" /usr/local/bin/nupst
# Installation completed
if [ $PIPED -eq 1 ]; then
echo "NUPST has been installed globally at $INSTALL_DIR"
else
echo "NUPST has been installed globally."
fi
echo "You can now run 'nupst' from anywhere."
echo ""
echo "To get started, try:"
echo " nupst help"
echo " nupst setup # To configure your UPS connection"

1
npmextra.json Normal file
View File

@ -0,0 +1 @@
{}

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "@serve.zone/nupst",
"version": "1.7.6",
"description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices",
"main": "dist/index.js",
"bin": {
"nupst": "bin/nupst"
},
"type": "module",
"scripts": {
"build": "tsbuild tsfolders --allowimplicitany",
"start": "bin/nupst",
"setup": "bash setup.sh",
"test": "tstest test/",
"install-global": "sudo bash install.sh",
"uninstall": "sudo bash uninstall.sh"
},
"keywords": [
"ups",
"snmp",
"shutdown",
"node",
"cli"
],
"files": [
"ts/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
"readme.md"
],
"author": "",
"license": "MIT",
"dependencies": {},
"devDependencies": {
"@git.zone/tsbuild": "^2.3.2",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^1.0.96",
"@push.rocks/qenv": "^6.1.0",
"@push.rocks/tapbundle": "^5.6.0",
"@types/node": "^20.11.0"
},
"engines": {
"node": ">=16.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
}
}

10187
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

0
readme.hints.md Normal file
View File

146
readme.md Normal file
View File

@ -0,0 +1,146 @@
# NUPST - Node.js UPS Shutdown Tool
NUPST is a command-line tool that monitors SNMP-enabled UPS devices and initiates system shutdown when power outages are detected and battery levels are low.
## Features
- Monitors UPS devices using SNMP
- Automatic shutdown when battery level falls below threshold
- Automatic shutdown when runtime remaining falls below threshold
- Simple systemd service integration
- Self-contained - includes its own Node.js runtime
## Installation
### Quick Install (One-line command)
```bash
# Install directly without cloning the repository (requires root privileges)
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
```
### Direct from Git
```bash
# Clone the repository
git clone https://code.foss.global/serve.zone/nupst.git
cd nupst
# Option 1: Quick install (requires root privileges)
sudo ./install.sh
# Option 2: Manual setup
./setup.sh
sudo ln -s $(pwd)/bin/nupst /usr/local/bin/nupst
```
### From NPM
```bash
npm install -g @serve.zone/nupst
```
## Uninstallation
```bash
# If installed from git repository:
cd /path/to/nupst
sudo ./uninstall.sh
# If installed from npm:
npm uninstall -g @serve.zone/nupst
```
The uninstaller will:
- Stop and disable the systemd service (if installed)
- Remove the systemd service file
- Remove the symlink from /usr/local/bin
- Optionally remove configuration files from /etc/nupst
## Usage
```
NUPST - Node.js UPS Shutdown Tool
Usage:
nupst enable - Install and enable the systemd service (requires root)
nupst disable - Stop and uninstall the systemd service (requires root)
nupst daemon-start - Start the daemon process directly
nupst logs - Show logs of the systemd service
nupst stop - Stop the systemd service
nupst start - Start the systemd service
nupst status - Show status of the systemd service and UPS status
nupst setup - Run the interactive setup to configure SNMP settings
nupst help - Show this help message
```
## Configuration
NUPST provides an interactive setup to configure your UPS:
```bash
nupst setup
```
This will guide you through setting up:
- UPS IP address and SNMP settings
- Shutdown thresholds for battery percentage and runtime
- Monitoring interval
- Test the connection to your UPS
Alternatively, you can manually edit the configuration file at `/etc/nupst/config.json`. A default configuration will be created on first run:
```json
{
"snmp": {
"host": "127.0.0.1",
"port": 161,
"community": "public",
"version": 1,
"timeout": 5000
},
"thresholds": {
"battery": 60,
"runtime": 20
},
"checkInterval": 30000
}
```
- `snmp`: SNMP connection settings
- `host`: IP address of your UPS (default: 127.0.0.1)
- `port`: SNMP port (default: 161)
- `version`: SNMP version (1, 2, or 3)
- `timeout`: Timeout in milliseconds (default: 5000)
- For SNMPv1/v2c:
- `community`: SNMP community string (default: public)
- For SNMPv3:
- `securityLevel`: Security level ('noAuthNoPriv', 'authNoPriv', or 'authPriv')
- `username`: SNMPv3 username
- `authProtocol`: Authentication protocol ('MD5' or 'SHA')
- `authKey`: Authentication password/key
- `privProtocol`: Privacy/encryption protocol ('DES' or 'AES')
- `privKey`: Privacy password/key
- `thresholds`: When to trigger shutdown
- `battery`: Battery percentage threshold (default: 60%)
- `runtime`: Runtime minutes threshold (default: 20 minutes)
- `checkInterval`: How often to check UPS status in milliseconds (default: 30000)
## Setup as a Service
To set up NUPST as a systemd service:
```bash
sudo nupst enable
sudo nupst start
```
To check the status:
```bash
nupst status
```
## License
MIT

227
setup.sh Normal file
View File

@ -0,0 +1,227 @@
#!/bin/bash
# NUPST Setup Script
# Downloads the appropriate Node.js binary for the current platform
# Find the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Create vendor directory if it doesn't exist
mkdir -p "$SCRIPT_DIR/vendor"
# Get the latest LTS Node.js version
echo "Determining latest LTS Node.js version..."
NODE_VERSIONS_JSON=$(curl -s https://nodejs.org/dist/index.json)
if [ $? -ne 0 ]; then
echo "Warning: Could not fetch latest Node.js versions. Using fallback version."
NODE_VERSION="20.11.1" # Fallback to a recent LTS version
else
# Extract the latest LTS version (those marked with lts field)
NODE_VERSION=$(echo "$NODE_VERSIONS_JSON" | grep -o '"version":"v[0-9.]*".*"lts":[^,]*' | grep -v '"lts":false' | grep -o 'v[0-9.]*' | head -1 | cut -c 2-)
if [ -z "$NODE_VERSION" ]; then
echo "Warning: Could not determine latest LTS version. Using fallback version."
NODE_VERSION="20.11.1" # Fallback to a recent LTS version
else
echo "Latest Node.js LTS version: $NODE_VERSION"
fi
fi
# Detect architecture
ARCH=$(uname -m)
OS=$(uname -s)
# Map architecture and OS to Node.js download URL
NODE_URL=""
NODE_DIR=""
case "$OS" in
Linux)
case "$ARCH" in
x86_64)
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz"
NODE_DIR="node-linux-x64"
;;
aarch64|arm64)
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-arm64.tar.gz"
NODE_DIR="node-linux-arm64"
;;
*)
echo "Unsupported architecture: $ARCH. Please install Node.js manually."
exit 1
;;
esac
;;
Darwin)
case "$ARCH" in
x86_64)
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-x64.tar.gz"
NODE_DIR="node-darwin-x64"
;;
arm64)
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-arm64.tar.gz"
NODE_DIR="node-darwin-arm64"
;;
*)
echo "Unsupported architecture: $ARCH. Please install Node.js manually."
exit 1
;;
esac
;;
*)
echo "Unsupported operating system: $OS. Please install Node.js manually."
exit 1
;;
esac
# Check if we already have the Node.js binary
if [ -f "$SCRIPT_DIR/vendor/$NODE_DIR/bin/node" ]; then
echo "Node.js binary already exists for $OS-$ARCH. Skipping download."
else
echo "Downloading Node.js v$NODE_VERSION for $OS-$ARCH..."
# Download and extract Node.js
TMP_FILE="$SCRIPT_DIR/vendor/node.tar.gz"
curl -L "$NODE_URL" -o "$TMP_FILE"
if [ $? -ne 0 ]; then
echo "Error downloading Node.js. Please check your internet connection and try again."
exit 1
fi
# Create target directory
mkdir -p "$SCRIPT_DIR/vendor/$NODE_DIR"
# Extract Node.js
tar -xzf "$TMP_FILE" -C "$SCRIPT_DIR/vendor"
# Move extracted files to the target directory
NODE_EXTRACT_DIR=$(find "$SCRIPT_DIR/vendor" -maxdepth 1 -name "node-v*" -type d | head -n 1)
if [ -d "$NODE_EXTRACT_DIR" ]; then
cp -R "$NODE_EXTRACT_DIR"/* "$SCRIPT_DIR/vendor/$NODE_DIR/"
rm -rf "$NODE_EXTRACT_DIR"
else
echo "Error extracting Node.js. Please try again."
exit 1
fi
# Clean up
rm "$TMP_FILE"
echo "Node.js v$NODE_VERSION for $OS-$ARCH has been downloaded and extracted."
fi
# Remove any existing dist_ts directory
if [ -d "$SCRIPT_DIR/dist_ts" ]; then
echo "Removing existing dist_ts directory..."
rm -rf "$SCRIPT_DIR/dist_ts"
fi
# Download dist_ts from npm registry
echo "Downloading dist_ts from npm registry..."
# Create temp directory
TEMP_DIR=$(mktemp -d)
# Get version from package.json
if [ -f "$SCRIPT_DIR/package.json" ]; then
echo "Reading version from package.json..."
# Extract version using grep and cut
VERSION=$(grep -o '"version": "[^"]*"' "$SCRIPT_DIR/package.json" | cut -d'"' -f4)
if [ -z "$VERSION" ]; then
echo "Error: Could not determine version from package.json."
rm -rf "$TEMP_DIR"
exit 1
fi
echo "Package version is $VERSION. Downloading matching package tarball..."
else
echo "Warning: package.json not found. Getting latest version from npm registry..."
VERSION=$(curl -s https://registry.npmjs.org/@serve.zone/nupst | grep -o '"latest":"[^"]*"' | cut -d'"' -f4)
if [ -z "$VERSION" ]; then
echo "Error: Could not determine version from npm registry."
rm -rf "$TEMP_DIR"
exit 1
fi
echo "Latest version is $VERSION. Using as fallback."
fi
# First try to download with the version from package.json
TARBALL_URL="https://registry.npmjs.org/@serve.zone/nupst/-/nupst-$VERSION.tgz"
TARBALL_PATH="$TEMP_DIR/nupst.tgz"
echo "Attempting to download version $VERSION from $TARBALL_URL..."
curl -sL "$TARBALL_URL" -o "$TARBALL_PATH"
# If download fails or file is empty, try to get the latest version from npm
if [ $? -ne 0 ] || [ ! -s "$TARBALL_PATH" ]; then
echo "Package version $VERSION not found on npm registry."
echo "Fetching latest version information from npm registry..."
# Get latest version from npm registry
NPM_REGISTRY_INFO=$(curl -s https://registry.npmjs.org/@serve.zone/nupst)
if [ $? -ne 0 ]; then
echo "Error: Could not connect to npm registry."
echo "Will attempt to build from source instead."
rm -rf "$TEMP_DIR"
mkdir -p "$SCRIPT_DIR/dist_ts"
BUILD_FROM_SOURCE=1
return 0
fi
# Extract latest version
LATEST_VERSION=$(echo "$NPM_REGISTRY_INFO" | grep -o '"latest":"[^"]*"' | cut -d'"' -f4)
if [ -z "$LATEST_VERSION" ]; then
echo "Error: Could not determine latest version from npm registry."
echo "Will attempt to build from source instead."
rm -rf "$TEMP_DIR"
mkdir -p "$SCRIPT_DIR/dist_ts"
BUILD_FROM_SOURCE=1
return 0
fi
echo "Found latest version: $LATEST_VERSION. Downloading..."
TARBALL_URL="https://registry.npmjs.org/@serve.zone/nupst/-/nupst-$LATEST_VERSION.tgz"
TARBALL_PATH="$TEMP_DIR/nupst.tgz"
curl -sL "$TARBALL_URL" -o "$TARBALL_PATH"
if [ $? -ne 0 ] || [ ! -s "$TARBALL_PATH" ]; then
echo "Error: Failed to download any package version from npm registry."
echo "Installation cannot continue without the dist_ts directory."
rm -rf "$TEMP_DIR"
exit 1
fi
fi
# Extract the tarball
mkdir -p "$TEMP_DIR/extract"
tar -xzf "$TARBALL_PATH" -C "$TEMP_DIR/extract"
# Copy dist_ts to the installation directory
if [ -d "$TEMP_DIR/extract/package/dist_ts" ]; then
echo "Copying dist_ts directory to installation..."
mkdir -p "$SCRIPT_DIR/dist_ts"
cp -R "$TEMP_DIR/extract/package/dist_ts/"* "$SCRIPT_DIR/dist_ts/"
else
echo "Error: dist_ts directory not found in the downloaded npm package."
rm -rf "$TEMP_DIR"
exit 1
fi
# Clean up
rm -rf "$TEMP_DIR"
echo "dist_ts directory successfully downloaded from npm registry."
# Make launcher script executable
chmod +x "$SCRIPT_DIR/bin/nupst"
echo "NUPST setup completed successfully."
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"

336
test/test.ts Normal file
View File

@ -0,0 +1,336 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { NupstSnmp } from '../ts/snmp.js';
import type { SnmpConfig, UpsStatus } from '../ts/snmp.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';
const testQenv = new qenv.Qenv('./', '.nogit/');
// Create an SNMP instance with debug enabled
const snmp = new NupstSnmp(true);
// Load the test configuration from .nogit/env.json
const testConfig = await testQenv.getEnvVarOnDemandAsObject('testConfig');
tap.test('should log config', async () => {
console.log(testConfig);
});
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
tap.test('Real UPS test', async () => {
try {
console.log('Testing with real UPS configuration...');
// Extract the correct SNMP config from the test configuration
const snmpConfig = testConfig.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');
}
});
// Export the default tap object
export default tap.start();

8
ts/00_commitinfo_data.ts Normal file
View File

@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '1.7.6',
description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices'
}

738
ts/cli.ts Normal file
View File

@ -0,0 +1,738 @@
import { execSync } from 'child_process';
import { Nupst } from './nupst.js';
/**
* Class for handling CLI commands
* Provides interface between user commands and the application
*/
export class NupstCli {
private readonly nupst: Nupst;
/**
* Create a new CLI handler
*/
constructor() {
this.nupst = new Nupst();
}
/**
* Parse command line arguments and execute the appropriate command
* @param args Command line arguments (process.argv)
*/
public async parseAndExecute(args: string[]): Promise<void> {
// Extract debug flag from any position
const debugOptions = this.extractDebugOptions(args);
if (debugOptions.debugMode) {
console.log('Debug mode enabled');
// Enable debug mode in the SNMP client
this.nupst.getSnmp().enableDebug();
}
// Get the command (default to help if none provided)
const command = args[2] || 'help';
// Route to the appropriate command handler
await this.executeCommand(command, debugOptions.debugMode);
}
/**
* Extract and remove debug options from args
* @param args Command line arguments
* @returns Object with debug flags and cleaned args
*/
private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } {
const debugMode = args.includes('--debug') || args.includes('-d');
// Remove debug flags from args
const cleanedArgs = args.filter(arg => arg !== '--debug' && arg !== '-d');
return { debugMode, cleanedArgs };
}
/**
* Execute the command with the given arguments
* @param command Command to execute
* @param debugMode Whether debug mode is enabled
*/
private async executeCommand(command: string, debugMode: boolean): Promise<void> {
switch (command) {
case 'enable':
await this.enable();
break;
case 'daemon-start':
await this.daemonStart(debugMode);
break;
case 'logs':
await this.logs();
break;
case 'stop':
await this.stop();
break;
case 'start':
await this.start();
break;
case 'status':
await this.status();
break;
case 'disable':
await this.disable();
break;
case 'setup':
await this.setup();
break;
case 'test':
await this.test(debugMode);
break;
case 'help':
default:
this.showHelp();
break;
}
}
/**
* Enable the service (requires root)
*/
private async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().install();
console.log('NUPST service has been installed. Use "nupst start" to start the service.');
}
/**
* Start the daemon directly
* @param debugMode Whether to enable debug mode
*/
private async daemonStart(debugMode: boolean = false): Promise<void> {
console.log('Starting NUPST daemon...');
try {
// Enable debug mode for SNMP if requested
if (debugMode) {
this.nupst.getSnmp().enableDebug();
console.log('SNMP debug mode enabled');
}
await this.nupst.getDaemon().start();
} catch (error) {
// Error is already logged and process.exit is called in daemon.start()
// No need to handle it here
}
}
/**
* Show logs of the systemd service
*/
private async logs(): Promise<void> {
try {
const logs = execSync('journalctl -u nupst.service -n 50 -f').toString();
console.log(logs);
} catch (error) {
console.error('Failed to retrieve logs:', error);
process.exit(1);
}
}
/**
* Stop the systemd service
*/
private async stop(): Promise<void> {
await this.nupst.getSystemd().stop();
}
/**
* Start the systemd service
*/
private async start(): Promise<void> {
try {
await this.nupst.getSystemd().start();
} catch (error) {
// Error will be displayed by systemd.start()
process.exit(1);
}
}
/**
* Show status of the systemd service and UPS
*/
private async status(): Promise<void> {
await this.nupst.getSystemd().getStatus();
}
/**
* Disable the service (requires root)
*/
private async disable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().disable();
}
/**
* Check if the user has root access
* @param errorMessage Error message to display if not root
*/
private checkRootAccess(errorMessage: string): void {
if (process.getuid && process.getuid() !== 0) {
console.error(errorMessage);
process.exit(1);
}
}
/**
* Test the current configuration by connecting to the UPS
* @param debugMode Whether to enable debug mode
*/
private async test(debugMode: boolean = false): Promise<void> {
try {
// Debug mode is now handled in parseAndExecute
if (debugMode) {
console.log('┌─ Debug Mode ─────────────────────────────┐');
console.log('│ SNMP debugging enabled - detailed logs will be shown');
console.log('└──────────────────────────────────────────┘');
}
// Try to load the configuration
try {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
console.error('┌─ Configuration Error ─────────────────────┐');
console.error('│ No configuration found.');
console.error('│ Please run \'nupst setup\' first to create a configuration.');
console.error('└──────────────────────────────────────────┘');
return;
}
// Get current configuration
const config = this.nupst.getDaemon().getConfig();
this.displayTestConfig(config);
await this.testConnection(config);
} catch (error) {
console.error(`Test failed: ${error.message}`);
}
}
/**
* Display the configuration for testing
* @param config Current configuration
*/
private displayTestConfig(config: any): void {
console.log('┌─ Testing Configuration ─────────────────────┐');
console.log('│ SNMP Settings:');
console.log(`│ Host: ${config.snmp.host}`);
console.log(`│ Port: ${config.snmp.port}`);
console.log(`│ Version: ${config.snmp.version}`);
console.log(`│ UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
if (config.snmp.version === 1 || config.snmp.version === 2) {
console.log(`│ Community: ${config.snmp.community}`);
} else if (config.snmp.version === 3) {
console.log(`│ Security Level: ${config.snmp.securityLevel}`);
console.log(`│ Username: ${config.snmp.username}`);
// Show auth and privacy details based on security level
if (config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv') {
console.log(`│ Auth Protocol: ${config.snmp.authProtocol || 'None'}`);
}
if (config.snmp.securityLevel === 'authPriv') {
console.log(`│ Privacy Protocol: ${config.snmp.privProtocol || 'None'}`);
}
// Show timeout value
console.log(`│ Timeout: ${config.snmp.timeout / 1000} seconds`);
}
// Show OIDs if custom model is selected
if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) {
console.log('│ Custom OIDs:');
console.log(`│ Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`);
console.log(`│ Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`);
console.log(`│ Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
console.log('│ Thresholds:');
console.log(`│ Battery: ${config.thresholds.battery}%`);
console.log(`│ Runtime: ${config.thresholds.runtime} minutes`);
console.log(`│ Check Interval: ${config.checkInterval / 1000} seconds`);
console.log('└──────────────────────────────────────────┘');
}
/**
* Test connection to the UPS
* @param config Current configuration
*/
private async testConnection(config: any): Promise<void> {
console.log('\nTesting connection to UPS...');
try {
// Create a test config with a short timeout
const testConfig = {
...config.snmp,
timeout: Math.min(config.snmp.timeout, 10000) // Use at most 10 seconds for testing
};
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
console.log('┌─ Connection Successful! ─────────────────┐');
console.log('│ UPS Status:');
console.log(`│ Power Status: ${status.powerStatus}`);
console.log(`│ Battery Capacity: ${status.batteryCapacity}%`);
console.log(`│ Runtime Remaining: ${status.batteryRuntime} minutes`);
console.log('└──────────────────────────────────────────┘');
// Check status against thresholds if on battery
if (status.powerStatus === 'onBattery') {
this.analyzeThresholds(status, config);
}
} catch (error) {
console.error('┌─ Connection Failed! ───────────────────────┐');
console.error(`│ Error: ${error.message}`);
console.error('└──────────────────────────────────────────┘');
console.log('\nPlease check your settings and run \'nupst setup\' to reconfigure.');
}
}
/**
* Analyze UPS status against thresholds
* @param status UPS status
* @param config Current configuration
*/
private analyzeThresholds(status: any, config: any): void {
console.log('┌─ Threshold Analysis ───────────────────────┐');
if (status.batteryCapacity < config.thresholds.battery) {
console.log('│ ⚠️ WARNING: Battery capacity below threshold');
console.log(`│ Current: ${status.batteryCapacity}% | Threshold: ${config.thresholds.battery}%`);
console.log('│ System would initiate shutdown');
} else {
console.log('│ ✓ Battery capacity above threshold');
console.log(`│ Current: ${status.batteryCapacity}% | Threshold: ${config.thresholds.battery}%`);
}
if (status.batteryRuntime < config.thresholds.runtime) {
console.log('│ ⚠️ WARNING: Runtime below threshold');
console.log(`│ Current: ${status.batteryRuntime} min | Threshold: ${config.thresholds.runtime} min`);
console.log('│ System would initiate shutdown');
} else {
console.log('│ ✓ Runtime above threshold');
console.log(`│ Current: ${status.batteryRuntime} min | Threshold: ${config.thresholds.runtime} min`);
}
console.log('└──────────────────────────────────────────┘');
}
/**
* Display help message
*/
private showHelp(): void {
console.log(`
NUPST - Node.js UPS Shutdown Tool
Usage:
nupst enable - Install and enable the systemd service (requires root)
nupst disable - Stop and uninstall the systemd service (requires root)
nupst daemon-start - Start the daemon process directly
nupst logs - Show logs of the systemd service
nupst stop - Stop the systemd service
nupst start - Start the systemd service
nupst status - Show status of the systemd service and UPS status
nupst setup - Run the interactive setup to configure SNMP settings
nupst test - Test the current configuration by connecting to the UPS
nupst help - Show this help message
Options:
--debug, -d - Enable debug mode for detailed SNMP logging
(Example: nupst test --debug)
`);
}
/**
* Interactive setup for configuring SNMP settings
*/
private async setup(): Promise<void> {
try {
// Import readline module (ESM style)
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await this.runSetupProcess(prompt);
} finally {
rl.close();
}
} catch (error) {
console.error('Setup error:', error.message);
}
}
/**
* Run the interactive setup process
* @param prompt Function to prompt for user input
*/
private async runSetupProcess(prompt: (question: string) => Promise<string>): Promise<void> {
console.log('\nNUPST Interactive Setup');
console.log('======================\n');
console.log('This will guide you through configuring your UPS SNMP settings.\n');
// Try to load existing config if available
let config;
try {
await this.nupst.getDaemon().loadConfig();
config = this.nupst.getDaemon().getConfig();
} catch (error) {
// If config doesn't exist, use default config
config = this.nupst.getDaemon().getConfig();
console.log('No existing configuration found. Creating a new configuration.');
}
// Gather SNMP settings
config = await this.gatherSnmpSettings(config, prompt);
// Gather threshold settings
config = await this.gatherThresholdSettings(config, prompt);
// Gather UPS model settings
config = await this.gatherUpsModelSettings(config, prompt);
// Save the configuration
await this.nupst.getDaemon().saveConfig(config);
this.displayConfigSummary(config);
// Test the connection if requested
await this.optionallyTestConnection(config, prompt);
console.log('\nSetup complete!');
await this.optionallyEnableService(prompt);
}
/**
* Gather SNMP settings
* @param config Current configuration
* @param prompt Function to prompt for user input
* @returns Updated configuration
*/
private async gatherSnmpSettings(config: any, prompt: (question: string) => Promise<string>): Promise<any> {
// SNMP IP Address
const defaultHost = config.snmp.host;
const host = await prompt(`UPS IP Address [${defaultHost}]: `);
config.snmp.host = host.trim() || defaultHost;
// SNMP Port
const defaultPort = config.snmp.port;
const portInput = await prompt(`SNMP Port [${defaultPort}]: `);
const port = parseInt(portInput, 10);
config.snmp.port = (portInput.trim() && !isNaN(port)) ? port : defaultPort;
// SNMP Version
const defaultVersion = config.snmp.version;
console.log('\nSNMP Version:');
console.log(' 1) SNMPv1');
console.log(' 2) SNMPv2c');
console.log(' 3) SNMPv3 (with security features)');
const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `);
const version = parseInt(versionInput, 10);
config.snmp.version = (versionInput.trim() && (version === 1 || version === 2 || version === 3)) ? version : defaultVersion;
if (config.snmp.version === 1 || config.snmp.version === 2) {
// SNMP Community String (for v1/v2c)
const defaultCommunity = config.snmp.community || 'public';
const community = await prompt(`SNMP Community String [${defaultCommunity}]: `);
config.snmp.community = community.trim() || defaultCommunity;
} else if (config.snmp.version === 3) {
// SNMP v3 settings
config = await this.gatherSnmpV3Settings(config, prompt);
}
return config;
}
/**
* Gather SNMPv3 specific settings
* @param config Current configuration
* @param prompt Function to prompt for user input
* @returns Updated configuration
*/
private async gatherSnmpV3Settings(config: any, prompt: (question: string) => Promise<string>): Promise<any> {
console.log('\nSNMPv3 Security Settings:');
// Security Level
console.log('\nSecurity Level:');
console.log(' 1) noAuthNoPriv (No Authentication, No Privacy)');
console.log(' 2) authNoPriv (Authentication, No Privacy)');
console.log(' 3) authPriv (Authentication and Privacy)');
const defaultSecLevel = config.snmp.securityLevel ?
(config.snmp.securityLevel === 'noAuthNoPriv' ? 1 :
config.snmp.securityLevel === 'authNoPriv' ? 2 : 3) : 3;
const secLevelInput = await prompt(`Select Security Level [${defaultSecLevel}]: `);
const secLevel = parseInt(secLevelInput, 10) || defaultSecLevel;
if (secLevel === 1) {
config.snmp.securityLevel = 'noAuthNoPriv';
// No auth, no priv - clear out authentication and privacy settings
config.snmp.authProtocol = '';
config.snmp.authKey = '';
config.snmp.privProtocol = '';
config.snmp.privKey = '';
// Set appropriate timeout for security level
config.snmp.timeout = 5000; // 5 seconds for basic security
} else if (secLevel === 2) {
config.snmp.securityLevel = 'authNoPriv';
// Auth, no priv - clear out privacy settings
config.snmp.privProtocol = '';
config.snmp.privKey = '';
// Set appropriate timeout for security level
config.snmp.timeout = 10000; // 10 seconds for authentication
} else {
config.snmp.securityLevel = 'authPriv';
// Set appropriate timeout for security level
config.snmp.timeout = 15000; // 15 seconds for full encryption
}
// Username
const defaultUsername = config.snmp.username || '';
const username = await prompt(`SNMPv3 Username [${defaultUsername}]: `);
config.snmp.username = username.trim() || defaultUsername;
if (secLevel >= 2) {
// Authentication settings
config = await this.gatherAuthenticationSettings(config, prompt);
if (secLevel === 3) {
// Privacy settings
config = await this.gatherPrivacySettings(config, prompt);
}
// Allow customizing the timeout value
const defaultTimeout = config.snmp.timeout / 1000; // Convert from ms to seconds for display
console.log('\nSNMPv3 operations with authentication and privacy may require longer timeouts.');
const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
config.snmp.timeout = timeout * 1000; // Convert to ms
}
}
return config;
}
/**
* Gather authentication settings for SNMPv3
* @param config Current configuration
* @param prompt Function to prompt for user input
* @returns Updated configuration
*/
private async gatherAuthenticationSettings(config: any, prompt: (question: string) => Promise<string>): Promise<any> {
// Authentication protocol
console.log('\nAuthentication Protocol:');
console.log(' 1) MD5');
console.log(' 2) SHA');
const defaultAuthProtocol = config.snmp.authProtocol === 'SHA' ? 2 : 1;
const authProtocolInput = await prompt(`Select Authentication Protocol [${defaultAuthProtocol}]: `);
const authProtocol = parseInt(authProtocolInput, 10) || defaultAuthProtocol;
config.snmp.authProtocol = authProtocol === 2 ? 'SHA' : 'MD5';
// Authentication Key/Password
const defaultAuthKey = config.snmp.authKey || '';
const authKey = await prompt(`Authentication Password ${defaultAuthKey ? '[*****]' : ''}: `);
config.snmp.authKey = authKey.trim() || defaultAuthKey;
return config;
}
/**
* Gather privacy settings for SNMPv3
* @param config Current configuration
* @param prompt Function to prompt for user input
* @returns Updated configuration
*/
private async gatherPrivacySettings(config: any, prompt: (question: string) => Promise<string>): Promise<any> {
// Privacy protocol
console.log('\nPrivacy Protocol:');
console.log(' 1) DES');
console.log(' 2) AES');
const defaultPrivProtocol = config.snmp.privProtocol === 'AES' ? 2 : 1;
const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `);
const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol;
config.snmp.privProtocol = privProtocol === 2 ? 'AES' : 'DES';
// Privacy Key/Password
const defaultPrivKey = config.snmp.privKey || '';
const privKey = await prompt(`Privacy Password ${defaultPrivKey ? '[*****]' : ''}: `);
config.snmp.privKey = privKey.trim() || defaultPrivKey;
return config;
}
/**
* Gather threshold settings
* @param config Current configuration
* @param prompt Function to prompt for user input
* @returns Updated configuration
*/
private async gatherThresholdSettings(config: any, prompt: (question: string) => Promise<string>): Promise<any> {
console.log('\nShutdown Thresholds:');
// Battery threshold
const defaultBatteryThreshold = config.thresholds.battery;
const batteryThresholdInput = await prompt(`Battery percentage threshold [${defaultBatteryThreshold}%]: `);
const batteryThreshold = parseInt(batteryThresholdInput, 10);
config.thresholds.battery = (batteryThresholdInput.trim() && !isNaN(batteryThreshold))
? batteryThreshold
: defaultBatteryThreshold;
// Runtime threshold
const defaultRuntimeThreshold = config.thresholds.runtime;
const runtimeThresholdInput = await prompt(`Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `);
const runtimeThreshold = parseInt(runtimeThresholdInput, 10);
config.thresholds.runtime = (runtimeThresholdInput.trim() && !isNaN(runtimeThreshold))
? runtimeThreshold
: defaultRuntimeThreshold;
// Check interval
const defaultInterval = config.checkInterval / 1000; // Convert from ms to seconds for display
const intervalInput = await prompt(`Check interval in seconds [${defaultInterval}]: `);
const interval = parseInt(intervalInput, 10);
config.checkInterval = (intervalInput.trim() && !isNaN(interval))
? interval * 1000 // Convert to ms
: defaultInterval * 1000;
return config;
}
/**
* Gather UPS model settings
* @param config Current configuration
* @param prompt Function to prompt for user input
* @returns Updated configuration
*/
private async gatherUpsModelSettings(config: any, prompt: (question: string) => Promise<string>): Promise<any> {
console.log('\nUPS Model Selection:');
console.log(' 1) CyberPower');
console.log(' 2) APC');
console.log(' 3) Eaton');
console.log(' 4) TrippLite');
console.log(' 5) Liebert/Vertiv');
console.log(' 6) Custom (Advanced)');
const defaultModelValue = config.snmp.upsModel === 'cyberpower' ? 1 :
config.snmp.upsModel === 'apc' ? 2 :
config.snmp.upsModel === 'eaton' ? 3 :
config.snmp.upsModel === 'tripplite' ? 4 :
config.snmp.upsModel === 'liebert' ? 5 :
config.snmp.upsModel === 'custom' ? 6 : 1;
const modelInput = await prompt(`Select UPS model [${defaultModelValue}]: `);
const modelValue = parseInt(modelInput, 10) || defaultModelValue;
if (modelValue === 1) {
config.snmp.upsModel = 'cyberpower';
} else if (modelValue === 2) {
config.snmp.upsModel = 'apc';
} else if (modelValue === 3) {
config.snmp.upsModel = 'eaton';
} else if (modelValue === 4) {
config.snmp.upsModel = 'tripplite';
} else if (modelValue === 5) {
config.snmp.upsModel = 'liebert';
} else if (modelValue === 6) {
config.snmp.upsModel = 'custom';
console.log('\nEnter custom OIDs for your UPS:');
console.log('(Leave blank to use standard RFC 1628 OIDs as fallback)');
// Custom OIDs
const powerStatusOID = await prompt('Power Status OID: ');
const batteryCapacityOID = await prompt('Battery Capacity OID: ');
const batteryRuntimeOID = await prompt('Battery Runtime OID: ');
// Create custom OIDs object
config.snmp.customOIDs = {
POWER_STATUS: powerStatusOID.trim(),
BATTERY_CAPACITY: batteryCapacityOID.trim(),
BATTERY_RUNTIME: batteryRuntimeOID.trim()
};
}
return config;
}
/**
* Display configuration summary
* @param config Current configuration
*/
private displayConfigSummary(config: any): void {
console.log('\n┌─ Configuration Summary ─────────────────┐');
console.log(`│ SNMP Host: ${config.snmp.host}:${config.snmp.port}`);
console.log(`│ SNMP Version: ${config.snmp.version}`);
console.log(`│ UPS Model: ${config.snmp.upsModel}`);
console.log(`│ Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`);
console.log(`│ Check Interval: ${config.checkInterval/1000} seconds`);
console.log('└──────────────────────────────────────────┘\n');
}
/**
* Optionally test connection to UPS
* @param config Current configuration
* @param prompt Function to prompt for user input
*/
private async optionallyTestConnection(config: any, prompt: (question: string) => Promise<string>): Promise<void> {
const testConnection = await prompt('Would you like to test the connection to your UPS? (y/N): ');
if (testConnection.toLowerCase() === 'y') {
console.log('\nTesting connection to UPS...');
try {
// Create a test config with a short timeout
const testConfig = {
...config.snmp,
timeout: Math.min(config.snmp.timeout, 10000) // Use at most 10 seconds for testing
};
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
console.log('\n┌─ Connection Successful! ─────────────────┐');
console.log('│ UPS Status:');
console.log(`│ ✓ Power Status: ${status.powerStatus}`);
console.log(`│ ✓ Battery Capacity: ${status.batteryCapacity}%`);
console.log(`│ ✓ Runtime Remaining: ${status.batteryRuntime} minutes`);
console.log('└──────────────────────────────────────────┘');
} catch (error) {
console.error('\n┌─ Connection Failed! ───────────────────────┐');
console.error('│ Error: ' + error.message);
console.error('└──────────────────────────────────────────┘');
console.log('\nPlease check your settings and try again.');
}
}
}
/**
* Optionally enable systemd service
* @param prompt Function to prompt for user input
*/
private async optionallyEnableService(prompt: (question: string) => Promise<string>): Promise<void> {
if (process.getuid && process.getuid() !== 0) {
console.log('\nNote: Run "sudo nupst enable" to set up NUPST as a system service.');
} else {
const setupService = await prompt('Would you like to enable NUPST as a system service? (y/N): ');
if (setupService.toLowerCase() === 'y') {
await this.nupst.getSystemd().install();
console.log('Service installed. Use "nupst start" to start the service.');
}
}
}
}

261
ts/daemon.ts Normal file
View File

@ -0,0 +1,261 @@
import * as fs from 'fs';
import * as path from 'path';
import { NupstSnmp, type SnmpConfig } from './snmp.js';
/**
* Configuration interface for the daemon
*/
export interface NupstConfig {
/** SNMP configuration settings */
snmp: SnmpConfig;
/** Threshold settings for initiating shutdown */
thresholds: {
/** Shutdown when battery below this percentage */
battery: number;
/** Shutdown when runtime below this minutes */
runtime: number;
};
/** Check interval in milliseconds */
checkInterval: number;
}
/**
* Daemon class for monitoring UPS and handling shutdown
* Responsible for loading/saving config and monitoring the UPS status
*/
export class NupstDaemon {
/** Default configuration path */
private readonly CONFIG_PATH = '/etc/nupst/config.json';
/** Default configuration */
private readonly DEFAULT_CONFIG: NupstConfig = {
snmp: {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
// SNMPv3 defaults (used only if version === 3)
securityLevel: 'authPriv',
username: '',
authProtocol: 'SHA',
authKey: '',
privProtocol: 'AES',
privKey: '',
// UPS model for OID selection
upsModel: 'cyberpower'
},
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
checkInterval: 30000, // Check every 30 seconds
};
private config: NupstConfig;
private snmp: NupstSnmp;
private isRunning: boolean = false;
/**
* Create a new daemon instance with the given SNMP manager
*/
constructor(snmp: NupstSnmp) {
this.snmp = snmp;
this.config = this.DEFAULT_CONFIG;
}
/**
* Load configuration from file
* @throws Error if configuration file doesn't exist
*/
public async loadConfig(): Promise<NupstConfig> {
try {
// Check if config file exists
const configExists = fs.existsSync(this.CONFIG_PATH);
if (!configExists) {
const errorMsg = `No configuration found at ${this.CONFIG_PATH}`;
this.logConfigError(errorMsg);
throw new Error(errorMsg);
}
// Read and parse config
const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8');
this.config = JSON.parse(configData);
return this.config;
} catch (error) {
if (error.message.includes('No configuration found')) {
throw error; // Re-throw the no configuration error
}
this.logConfigError(`Error loading configuration: ${error.message}`);
throw new Error('Failed to load configuration');
}
}
/**
* Save configuration to file
*/
public async saveConfig(config: NupstConfig): Promise<void> {
try {
const configDir = path.dirname(this.CONFIG_PATH);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(config, null, 2));
this.config = config;
console.log('┌─ Configuration Saved ─────────────────────┐');
console.log(`│ Location: ${this.CONFIG_PATH}`);
console.log('└──────────────────────────────────────────┘');
} catch (error) {
console.error('Error saving configuration:', error);
}
}
/**
* Helper method to log configuration errors consistently
*/
private logConfigError(message: string): void {
console.error('┌─ Configuration Error ─────────────────────┐');
console.error(`${message}`);
console.error('│ Please run \'nupst setup\' first to create a configuration.');
console.error('└──────────────────────────────────────────┘');
}
/**
* Get the current configuration
*/
public getConfig(): NupstConfig {
return this.config;
}
/**
* Get the SNMP instance
*/
public getNupstSnmp(): NupstSnmp {
return this.snmp;
}
/**
* Start the monitoring daemon
*/
public async start(): Promise<void> {
if (this.isRunning) {
console.log('Daemon is already running');
return;
}
console.log('Starting NUPST daemon...');
try {
// Load configuration - this will throw an error if config doesn't exist
await this.loadConfig();
this.logConfigLoaded();
// Start UPS monitoring
this.isRunning = true;
await this.monitor();
} catch (error) {
this.isRunning = false;
console.error(`Daemon failed to start: ${error.message}`);
process.exit(1); // Exit with error
}
}
/**
* Log the loaded configuration settings
*/
private logConfigLoaded(): void {
console.log('┌─ Configuration Loaded ─────────────────────┐');
console.log('│ SNMP Settings:');
console.log(`│ Host: ${this.config.snmp.host}`);
console.log(`│ Port: ${this.config.snmp.port}`);
console.log(`│ Version: ${this.config.snmp.version}`);
console.log('│ Thresholds:');
console.log(`│ Battery: ${this.config.thresholds.battery}%`);
console.log(`│ Runtime: ${this.config.thresholds.runtime} minutes`);
console.log(`│ Check Interval: ${this.config.checkInterval / 1000} seconds`);
console.log('└──────────────────────────────────────────┘');
}
/**
* Stop the monitoring daemon
*/
public stop(): void {
console.log('Stopping NUPST daemon...');
this.isRunning = false;
}
/**
* Monitor the UPS status and trigger shutdown when necessary
*/
private async monitor(): Promise<void> {
console.log('Starting UPS monitoring...');
let lastStatus: 'online' | 'onBattery' | 'unknown' = 'unknown';
// Monitor continuously
while (this.isRunning) {
try {
const status = await this.snmp.getUpsStatus(this.config.snmp);
// Log status changes
if (status.powerStatus !== lastStatus) {
console.log('┌──────────────────────────────────────────┐');
console.log(`│ Power status changed: ${lastStatus}${status.powerStatus}`);
console.log('└──────────────────────────────────────────┘');
lastStatus = status.powerStatus;
}
// Handle battery power status
if (status.powerStatus === 'onBattery') {
await this.handleOnBatteryStatus(status);
}
// Wait before next check
await this.sleep(this.config.checkInterval);
} catch (error) {
console.error('Error during UPS monitoring:', error);
await this.sleep(this.config.checkInterval);
}
}
console.log('UPS monitoring stopped');
}
/**
* Handle UPS status when running on battery
*/
private async handleOnBatteryStatus(status: {
powerStatus: string,
batteryCapacity: number,
batteryRuntime: number
}): Promise<void> {
console.log('┌─ UPS Status ───────────────────────────────┐');
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min │`);
console.log('└──────────────────────────────────────────┘');
// Check battery threshold
if (status.batteryCapacity < this.config.thresholds.battery) {
console.log('⚠️ WARNING: Battery capacity below threshold');
console.log(`Current: ${status.batteryCapacity}% | Threshold: ${this.config.thresholds.battery}%`);
await this.snmp.initiateShutdown('Battery capacity below threshold');
return;
}
// Check runtime threshold
if (status.batteryRuntime < this.config.thresholds.runtime) {
console.log('⚠️ WARNING: Runtime below threshold');
console.log(`Current: ${status.batteryRuntime} min | Threshold: ${this.config.thresholds.runtime} min`);
await this.snmp.initiateShutdown('Runtime below threshold');
return;
}
}
/**
* Sleep for the specified milliseconds
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

18
ts/index.ts Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env node
import { NupstCli } from './cli.js';
/**
* Main entry point for NUPST
* Initializes the CLI and executes the given command
*/
async function main() {
const cli = new NupstCli();
await cli.parseAndExecute(process.argv);
}
// Run the main function and handle any errors
main().catch(error => {
console.error('Error:', error);
process.exit(1);
});

43
ts/nupst.ts Normal file
View File

@ -0,0 +1,43 @@
import { NupstSnmp } from './snmp.js';
import { NupstDaemon } from './daemon.js';
import { NupstSystemd } from './systemd.js';
/**
* Main Nupst class that coordinates all components
* Acts as a facade to access SNMP, Daemon, and Systemd functionality
*/
export class Nupst {
private readonly snmp: NupstSnmp;
private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd;
/**
* Create a new Nupst instance with all necessary components
*/
constructor() {
this.snmp = new NupstSnmp();
this.daemon = new NupstDaemon(this.snmp);
this.systemd = new NupstSystemd(this.daemon);
}
/**
* Get the SNMP manager for UPS communication
*/
public getSnmp(): NupstSnmp {
return this.snmp;
}
/**
* Get the daemon manager for background monitoring
*/
public getDaemon(): NupstDaemon {
return this.daemon;
}
/**
* Get the systemd manager for service operations
*/
public getSystemd(): NupstSystemd {
return this.systemd;
}
}

6
ts/snmp.ts Normal file
View File

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

98
ts/snmp/encoder.ts Normal file
View File

@ -0,0 +1,98 @@
/**
* 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];
}
}
}

10
ts/snmp/index.ts Normal file
View File

@ -0,0 +1,10 @@
/**
* Main module entry point for SNMP functionality
* Re-exports public types and classes
*/
// Re-export all public types
export type { UpsStatus, OIDSet, UpsModel, SnmpConfig } from './types.js';
// Re-export the SNMP manager class
export { NupstSnmp } from './manager.js';

514
ts/snmp/manager.ts Normal file
View File

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

75
ts/snmp/oid-sets.ts Normal file
View File

@ -0,0 +1,75 @@
import type { OIDSet, UpsModel } from './types.js';
/**
* OID sets for different UPS models
* Contains mappings between UPS models and their SNMP OIDs
*/
export class UpsOidSets {
/**
* OID sets for different UPS models
*/
private static readonly UPS_OID_SETS: Record<UpsModel, OIDSet> = {
// Cyberpower OIDs for RMCARD205 (based on CyberPower_MIB_v2.11)
cyberpower: {
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus (2=online, 3=on battery)
BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage)
BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks)
},
// APC OIDs
apc: {
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // Power status (1=online, 2=on battery)
BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes
},
// Eaton OIDs
eaton: {
POWER_STATUS: '1.3.6.1.4.1.534.1.1.2.0', // Power status
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // Remaining runtime in minutes
},
// TrippLite OIDs
tripplite: {
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // Power status
BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes
},
// Liebert/Vertiv OIDs
liebert: {
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // Power status
BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes
},
// Custom OIDs (to be provided by the user)
custom: {
POWER_STATUS: '',
BATTERY_CAPACITY: '',
BATTERY_RUNTIME: '',
}
};
/**
* Get OID set for a specific UPS model
* @param model UPS model name
* @returns OID set for the model
*/
public static getOidSet(model: UpsModel): OIDSet {
return this.UPS_OID_SETS[model];
}
/**
* Get standard RFC 1628 OID set as fallback
* @returns Standard OID set
*/
public static getStandardOids(): Record<string, string> {
return {
'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource
'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining
'battery runtime': '1.3.6.1.2.1.33.1.2.3.0' // upsEstimatedMinutesRemaining
};
}
}

651
ts/snmp/packet-creator.ts Normal file
View File

@ -0,0 +1,651 @@
import * as crypto from 'crypto';
import type { SnmpConfig, SnmpV3SecurityParams } 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: SnmpConfig,
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: SnmpV3SecurityParams = {
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: SnmpConfig): 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: SnmpConfig, 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: SnmpConfig, 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
]);
}
}

553
ts/snmp/packet-parser.ts Normal file
View File

@ -0,0 +1,553 @@
import type { SnmpConfig } 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: SnmpConfig, 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;
}
}
}

90
ts/snmp/types.ts Normal file
View File

@ -0,0 +1,90 @@
/**
* Type definitions for SNMP module
*/
/**
* UPS status interface
*/
export interface UpsStatus {
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
/** Battery capacity percentage */
batteryCapacity: number;
/** Remaining runtime in minutes */
batteryRuntime: number;
/** Raw values from SNMP responses */
raw: Record<string, any>;
}
/**
* SNMP OID Sets for different UPS brands
*/
export interface OIDSet {
/** OID for power status */
POWER_STATUS: string;
/** OID for battery capacity */
BATTERY_CAPACITY: string;
/** OID for battery runtime */
BATTERY_RUNTIME: string;
}
/**
* Supported UPS model types
*/
export type UpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
/**
* SNMP Configuration interface
*/
export interface SnmpConfig {
/** SNMP server host */
host: string;
/** SNMP server port (default 161) */
port: number;
/** SNMP version (1, 2, or 3) */
version: number;
/** Timeout in milliseconds */
timeout: number;
// SNMPv1/v2c
/** Community string for SNMPv1/v2c */
community?: string;
// SNMPv3
/** Security level for SNMPv3 */
securityLevel?: 'noAuthNoPriv' | 'authNoPriv' | 'authPriv';
/** Username for SNMPv3 authentication */
username?: string;
/** Authentication protocol for SNMPv3 */
authProtocol?: 'MD5' | 'SHA';
/** Authentication key for SNMPv3 */
authKey?: string;
/** Privacy protocol for SNMPv3 */
privProtocol?: 'DES' | 'AES';
/** Privacy key for SNMPv3 */
privKey?: string;
// UPS model and custom OIDs
/** UPS model for OID selection */
upsModel?: UpsModel;
/** Custom OIDs when using custom UPS model */
customOIDs?: OIDSet;
}
/**
* SNMPv3 security parameters
*/
export interface SnmpV3SecurityParams {
/** Engine ID for the SNMP server */
msgAuthoritativeEngineID: Buffer;
/** Engine boots counter */
msgAuthoritativeEngineBoots: number;
/** Engine time counter */
msgAuthoritativeEngineTime: number;
/** Username for authentication */
msgUserName: string;
/** Authentication parameters */
msgAuthenticationParameters: Buffer;
/** Privacy parameters */
msgPrivacyParameters: Buffer;
}

246
ts/systemd.ts Normal file
View File

@ -0,0 +1,246 @@
import { promises as fs } from 'fs';
import { execSync } from 'child_process';
import { NupstDaemon } from './daemon.js';
/**
* Class for managing systemd service
* Handles installation, removal, and control of the NUPST systemd service
*/
export class NupstSystemd {
/** Path to the systemd service file */
private readonly serviceFilePath = '/etc/systemd/system/nupst.service';
private readonly daemon: NupstDaemon;
/** Template for the systemd service file */
private readonly serviceTemplate = `[Unit]
Description=Node.js UPS Shutdown Tool
After=network.target
[Service]
ExecStart=/usr/bin/nupst daemon-start
Restart=always
User=root
Group=root
Environment=PATH=/usr/bin:/usr/local/bin
Environment=NODE_ENV=production
WorkingDirectory=/tmp
[Install]
WantedBy=multi-user.target
`;
/**
* Create a new systemd manager
* @param daemon The daemon instance to manage
*/
constructor(daemon: NupstDaemon) {
this.daemon = daemon;
}
/**
* Check if a configuration file exists
* @private
* @throws Error if configuration not found
*/
private async checkConfigExists(): Promise<void> {
const configPath = '/etc/nupst/config.json';
try {
await fs.access(configPath);
} catch (error) {
console.error('┌─ Configuration Error ─────────────────────┐');
console.error(`│ No configuration file found at ${configPath}`);
console.error('│ Please run \'nupst setup\' first to create a configuration.');
console.error('└──────────────────────────────────────────┘');
throw new Error('Configuration not found');
}
}
/**
* Install the systemd service file
* @throws Error if installation fails
*/
public async install(): Promise<void> {
try {
// Check if configuration exists before installing
await this.checkConfigExists();
// Write the service file
await fs.writeFile(this.serviceFilePath, this.serviceTemplate);
console.log('┌─ Service Installation ─────────────────────┐');
console.log(`│ Service file created at ${this.serviceFilePath}`);
// Reload systemd daemon
execSync('systemctl daemon-reload');
console.log('│ Systemd daemon reloaded');
// Enable the service
execSync('systemctl enable nupst.service');
console.log('│ Service enabled to start on boot');
console.log('└──────────────────────────────────────────┘');
} catch (error) {
if (error.message === 'Configuration not found') {
// Just rethrow the error as the message has already been displayed
throw error;
}
console.error('Failed to install systemd service:', error);
throw error;
}
}
/**
* Start the systemd service
* @throws Error if start fails
*/
public async start(): Promise<void> {
try {
// Check if configuration exists before starting
await this.checkConfigExists();
execSync('systemctl start nupst.service');
console.log('┌─ Service Status ─────────────────────────┐');
console.log('│ NUPST service started successfully');
console.log('└──────────────────────────────────────────┘');
} catch (error) {
if (error.message === 'Configuration not found') {
// Exit with error code since configuration is required
process.exit(1);
}
console.error('Failed to start service:', error);
throw error;
}
}
/**
* Stop the systemd service
* @throws Error if stop fails
*/
public async stop(): Promise<void> {
try {
execSync('systemctl stop nupst.service');
console.log('NUPST service stopped');
} catch (error) {
console.error('Failed to stop service:', error);
throw error;
}
}
/**
* Get status of the systemd service and UPS
*/
public async getStatus(): Promise<void> {
try {
// Check if config exists first
try {
await this.checkConfigExists();
} catch (error) {
// Error message already displayed by checkConfigExists
return;
}
await this.displayServiceStatus();
await this.displayUpsStatus();
} catch (error) {
console.error(`Failed to get status: ${error.message}`);
}
}
/**
* Display the systemd service status
* @private
*/
private async displayServiceStatus(): Promise<void> {
try {
const serviceStatus = execSync('systemctl status nupst.service').toString();
console.log('┌─ Service Status ─────────────────────────┐');
console.log(serviceStatus.split('\n').map(line => `${line}`).join('\n'));
console.log('└──────────────────────────────────────────┘');
} catch (error) {
console.error('┌─ Service Status ─────────────────────────┐');
console.error('│ Service is not running');
console.error('└──────────────────────────────────────────┘');
}
}
/**
* Display the UPS status
* @private
*/
private async displayUpsStatus(): Promise<void> {
try {
const upsStatus = await this.daemon.getConfig().snmp;
const snmp = this.daemon.getNupstSnmp();
const status = await snmp.getUpsStatus(upsStatus);
console.log('┌─ UPS Status ───────────────────────────────┐');
console.log(`│ Power Status: ${status.powerStatus}`);
console.log(`│ Battery Capacity: ${status.batteryCapacity}%`);
console.log(`│ Runtime Remaining: ${status.batteryRuntime} minutes`);
console.log('└──────────────────────────────────────────┘');
} catch (error) {
console.error('┌─ UPS Status ───────────────────────────────┐');
console.error(`│ Failed to retrieve UPS status: ${error.message}`);
console.error('└──────────────────────────────────────────┘');
}
}
/**
* Disable and uninstall the systemd service
* @throws Error if disabling fails
*/
public async disable(): Promise<void> {
try {
await this.stopService();
await this.disableService();
await this.removeServiceFile();
// Reload systemd daemon
execSync('systemctl daemon-reload');
console.log('Systemd daemon reloaded');
console.log('NUPST service has been successfully uninstalled');
} catch (error) {
console.error('Failed to disable and uninstall service:', error);
throw error;
}
}
/**
* Stop the service if it's running
* @private
*/
private async stopService(): Promise<void> {
try {
console.log('Stopping NUPST service...');
execSync('systemctl stop nupst.service');
} catch (error) {
// Service might not be running, that's okay
console.log('Service was not running or could not be stopped');
}
}
/**
* Disable the service
* @private
*/
private async disableService(): Promise<void> {
try {
console.log('Disabling NUPST service...');
execSync('systemctl disable nupst.service');
} catch (error) {
console.log('Service was not enabled or could not be disabled');
}
}
/**
* Remove the service file if it exists
* @private
*/
private async removeServiceFile(): Promise<void> {
if (await fs.stat(this.serviceFilePath).catch(() => null)) {
console.log(`Removing service file ${this.serviceFilePath}...`);
await fs.unlink(this.serviceFilePath);
console.log('Service file removed');
} else {
console.log('Service file did not exist');
}
}
}

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
},
"exclude": [
"dist_*/**/*.d.ts"
]
}

68
uninstall.sh Normal file
View File

@ -0,0 +1,68 @@
#!/bin/bash
# NUPST Uninstaller Script
# Completely removes NUPST from the system
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (sudo ./uninstall.sh)"
exit 1
fi
echo "NUPST Uninstaller"
echo "================="
echo "This script will completely remove NUPST from your system."
# Find the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Step 1: Stop and disable the systemd service if it exists
if [ -f "/etc/systemd/system/nupst.service" ]; then
echo "Stopping NUPST service..."
systemctl stop nupst.service 2>/dev/null
echo "Disabling NUPST service..."
systemctl disable nupst.service 2>/dev/null
echo "Removing systemd service file..."
rm -f /etc/systemd/system/nupst.service
echo "Reloading systemd daemon..."
systemctl daemon-reload
fi
# Step 2: Remove global symlink
if [ -L "/usr/local/bin/nupst" ]; then
echo "Removing global symlink..."
rm -f /usr/local/bin/nupst
fi
# Step 3: Ask about removing configuration
read -p "Do you want to remove the NUPST configuration files? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Removing configuration files..."
rm -rf /etc/nupst
fi
# Step 4: Check if this was a git installation
if [ -d "$SCRIPT_DIR/.git" ]; then
echo
echo "This appears to be a git installation. The local repository will remain intact."
echo "If you wish to completely remove it, you can delete the directory:"
echo " rm -rf $SCRIPT_DIR"
fi
# Check for npm global installation
NODE_PATH=$(which node 2>/dev/null)
if [ -n "$NODE_PATH" ]; then
NPM_PATH=$(dirname "$NODE_PATH")/npm
if [ -x "$NPM_PATH" ]; then
echo
echo "If you installed NUPST via npm, you may want to uninstall it with:"
echo " npm uninstall -g @serve.zone/nupst"
fi
fi
echo
echo "NUPST has been uninstalled from your system."