Compare commits
82 Commits
Author | SHA1 | Date | |
---|---|---|---|
806f81c6a0 | |||
88e353eec6 | |||
80ff1b1230 | |||
1075335497 | |||
eafb5207a4 | |||
9969e0f703 | |||
ac4b2c95f3 | |||
c593d76ead | |||
01ccf2d080 | |||
0e55f22dad | |||
bd3042de25 | |||
456351ca34 | |||
00afa317ef | |||
45ee8208b5 | |||
39bf3e2239 | |||
f3de3f0618 | |||
03056d279d | |||
f860f39e59 | |||
fa4516de3b | |||
539547beb8 | |||
6eb92959ec | |||
4af9af0845 | |||
f7e12cdcbb | |||
002498b91b | |||
459911fe5f | |||
9859a02ea2 | |||
65444b6d25 | |||
d049e8741f | |||
1123a99aea | |||
d01e878310 | |||
588aeabf4b | |||
87005e72f1 | |||
f799c2ee66 | |||
1a029ba493 | |||
5b756dd223 | |||
4cac599a58 | |||
be6a7314c3 | |||
83ba9c2611 | |||
22ab472e58 | |||
9a77030377 | |||
ceff285ff5 | |||
d8bfbf0be3 | |||
3e6b883b38 | |||
47ef918128 | |||
5951638967 | |||
b06e2b2273 | |||
cc1cfe894c | |||
da49b7a5bf | |||
4de6081a74 | |||
5a13e49803 | |||
2737fca294 | |||
896233914f | |||
5bb775b17d | |||
ae8219acf7 | |||
4ad383884c | |||
65a9d1c798 | |||
f583e1466f | |||
9d893a97b6 | |||
aa52d5e9f6 | |||
623b7ee51f | |||
897e86ad60 | |||
ed78db20e2 | |||
bd00dfe02c | |||
55c040df82 | |||
e68654a022 | |||
89a5d23d2f | |||
f9aa1cfd2f | |||
e47f316d0a | |||
901127f784 | |||
dc4fd5afba | |||
a7ced10f92 | |||
9b9e009523 | |||
1819b6827a | |||
bd5b85f6b0 | |||
c7db209da7 | |||
bbb8f4a22c | |||
ebc6f65fa9 | |||
0a459f9cd0 | |||
cf231e9785 | |||
edce110c8a | |||
5eefe8cf40 | |||
ecfd171f97 |
53
bin/nupst
53
bin/nupst
@ -22,16 +22,63 @@ fi
|
||||
# For debugging
|
||||
# echo "Project root: $PROJECT_ROOT"
|
||||
|
||||
# Set Node.js binary path directly
|
||||
# Detect architecture and OS
|
||||
ARCH=$(uname -m)
|
||||
OS=$(uname -s)
|
||||
|
||||
# Determine Node.js binary location based on architecture and OS
|
||||
NODE_BIN=""
|
||||
case "$OS" in
|
||||
Linux)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
NODE_BIN="$PROJECT_ROOT/vendor/node-linux-x64/bin/node"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
NODE_BIN="$PROJECT_ROOT/vendor/node-linux-arm64/bin/node"
|
||||
;;
|
||||
*)
|
||||
# Use system Node as fallback for other architectures
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_BIN="node"
|
||||
echo "Using system Node.js installation for unsupported architecture: $ARCH"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
Darwin)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
NODE_BIN="$PROJECT_ROOT/vendor/node-darwin-x64/bin/node"
|
||||
;;
|
||||
arm64)
|
||||
NODE_BIN="$PROJECT_ROOT/vendor/node-darwin-arm64/bin/node"
|
||||
;;
|
||||
*)
|
||||
# Use system Node as fallback for other architectures
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_BIN="node"
|
||||
echo "Using system Node.js installation for unsupported architecture: $ARCH"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
# Use system Node as fallback for other operating systems
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_BIN="node"
|
||||
echo "Using system Node.js installation for unsupported OS: $OS"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# If binary doesn't exist, try system Node as fallback
|
||||
if [ ! -f "$NODE_BIN" ]; then
|
||||
if [ -z "$NODE_BIN" ] || [ ! -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 "Error: Node.js binary not found for $OS-$ARCH"
|
||||
echo "Please run the setup script or install Node.js manually."
|
||||
exit 1
|
||||
fi
|
||||
|
266
changelog.md
266
changelog.md
@ -1,5 +1,271 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-03-28 - 3.1.2 - fix(cli/ups-handler)
|
||||
Improve UPS device listing table formatting for better column alignment
|
||||
|
||||
- Adjusted header spacing for the Host column and overall table alignment in the UPS handler output.
|
||||
|
||||
## 2025-03-28 - 3.1.1 - fix(cli)
|
||||
Improve table header formatting in group and UPS listings
|
||||
|
||||
- Adjusted column padding in group listing for proper alignment
|
||||
- Fixed UPS table header spacing for consistent CLI output
|
||||
|
||||
## 2025-03-28 - 3.1.0 - feat(cli)
|
||||
Refactor CLI commands to use dedicated handlers for UPS, group, and service management
|
||||
|
||||
- Extracted UPS-related CLI logic into a new UpsHandler
|
||||
- Introduced GroupHandler to manage UPS groups commands
|
||||
- Added ServiceHandler for systemd service operations
|
||||
- Updated CLI routing in cli.ts to delegate commands to the new handlers
|
||||
- Exposed getters for the new handlers in the Nupst class
|
||||
|
||||
## 2025-03-28 - 3.0.1 - fix(cli)
|
||||
Simplify UPS ID generation by removing the redundant promptForUniqueUpsId function in the CLI module and replacing it with the shortId helper.
|
||||
|
||||
- Deleted the unused promptForUniqueUpsId method from ts/cli.ts.
|
||||
- Updated UPS configuration to generate a unique ID directly using helpers.shortId().
|
||||
- Improved code clarity by removing unnecessary interactive prompts for UPS IDs.
|
||||
|
||||
## 2025-03-28 - 3.0.0 - BREAKING CHANGE(core)
|
||||
Add multi-UPS support and group management; update CLI, configuration and documentation to support multiple UPS devices with group modes
|
||||
|
||||
- Implemented multi-UPS configuration with an array of UPS devices and groups in the configuration file
|
||||
- Added group management commands (group add, edit, delete, list) with redundant and non-redundant modes
|
||||
- Revamped CLI command parsing for UPS management (add, edit, delete, list, setup) and group subcommands
|
||||
- Updated readme and documentation to reflect new configuration structure and features
|
||||
- Enhanced logging and status display for multiple UPS devices
|
||||
|
||||
## 2025-03-26 - 2.6.17 - fix(logger)
|
||||
Preserve logbox width after logBoxEnd so that subsequent logBoxLine calls continue using the set width.
|
||||
|
||||
- Removed the reset of currentBoxWidth in logBoxEnd to allow persistent width across logbox calls.
|
||||
- Ensures that logBoxLine uses the previously set width when no new width is provided.
|
||||
|
||||
## 2025-03-26 - 2.6.16 - fix(cli)
|
||||
Improve CLI logging consistency by replacing direct console output with unified logger calls.
|
||||
|
||||
- Replaced console.log and console.error with logger.log and logger.error in CLI commands
|
||||
- Standardized debug, error, and status messages using logger's logbox utilities
|
||||
- Enhanced consistency of log output throughout the ts/cli.ts file
|
||||
|
||||
## 2025-03-26 - 2.6.15 - fix(logger)
|
||||
Replace direct console logging with unified logger interface for consistent formatting
|
||||
|
||||
- Substitute console.log, console.error, and related calls with logger methods in cli, daemon, systemd, nupst, and index modules
|
||||
- Integrate logBox formatting for structured output and consistent log presentation
|
||||
- Update test expectations in test.logger.ts to check for standardized error messages
|
||||
- Refactor logging calls throughout the codebase for improved clarity and maintainability
|
||||
|
||||
## 2025-03-26 - 2.6.14 - fix(systemd)
|
||||
Shorten closing log divider in systemd service installation output for consistent formatting.
|
||||
|
||||
- Replaced the overly long footer with a shorter one in ts/systemd.ts.
|
||||
- This change improves log readability without affecting functionality.
|
||||
|
||||
## 2025-03-26 - 2.6.13 - fix(cli)
|
||||
Fix CLI update output box formatting
|
||||
|
||||
- Adjusted the closing box line in the update process log messages for consistent visual formatting
|
||||
|
||||
## 2025-03-26 - 2.6.12 - fix(systemd)
|
||||
Adjust logging border in systemd service installation output
|
||||
|
||||
- Updated the closing border line for consistent output formatting in ts/systemd.ts
|
||||
|
||||
## 2025-03-26 - 2.6.11 - fix(cli, systemd)
|
||||
Adjust log formatting for consistent output in CLI and systemd commands
|
||||
|
||||
- Fixed spacing issues in service installation and status log messages in the systemd module.
|
||||
- Revised output formatting in the CLI to improve message clarity.
|
||||
|
||||
## 2025-03-26 - 2.6.10 - fix(daemon)
|
||||
Adjust console log box formatting for consistent output in daemon status messages
|
||||
|
||||
- Updated closing box borders to align properly in configuration error, periodic updates, and UPS status logs
|
||||
- Improved visual consistency in log messages
|
||||
|
||||
## 2025-03-26 - 2.6.9 - fix(cli)
|
||||
Improve console output formatting for status banners and logging messages
|
||||
|
||||
- Standardize banner messages in daemon status updates
|
||||
- Refine version information banner in nupst logging
|
||||
- Update UPS connection and status banners in systemd
|
||||
|
||||
## 2025-03-26 - 2.6.8 - fix(cli)
|
||||
Improve CLI formatting, refine debug option filtering, and remove unused dgram import in SNMP manager
|
||||
|
||||
- Standardize whitespace and formatting in ts/cli.ts for consistency
|
||||
- Refine argument filtering for debug mode and prompt messages
|
||||
- Remove unused 'dgram' import from ts/snmp/manager.ts
|
||||
|
||||
## 2025-03-26 - 2.6.7 - fix(setup.sh)
|
||||
Clarify net-snmp dependency installation message in setup.sh
|
||||
|
||||
- Updated echo statement to indicate installation of net-snmp along with 2 subdependencies
|
||||
- Improves clarity on dependency installation during setup
|
||||
|
||||
## 2025-03-26 - 2.6.6 - fix(setup.sh)
|
||||
Improve setup script to detect and execute npm-cli.js directly using the Node.js binary
|
||||
|
||||
- Replace use of the npm binary with direct execution of npm-cli.js
|
||||
- Add fallback logic to locate npm-cli.js when not found at the expected path
|
||||
- Simplify cleanup by removing unnecessary PATH modifications
|
||||
|
||||
## 2025-03-26 - 2.6.5 - fix(daemon, setup)
|
||||
Improve shutdown command detection and fallback logic; update setup script to use absolute Node/npm paths
|
||||
|
||||
- Use execFileAsync to execute shutdown commands reliably
|
||||
- Add multiple fallback alternatives for shutdown and emergency shutdown handling
|
||||
- Update setup.sh to log the Node and NPM versions using absolute paths without modifying PATH
|
||||
|
||||
## 2025-03-26 - 2.6.4 - fix(setup)
|
||||
Improve installation process in setup script by cleaning up package files and ensuring a minimal net-snmp dependency installation.
|
||||
|
||||
- Remove existing package-lock.json along with node_modules to prevent stale artifacts.
|
||||
- Back up the original package.json before modifying it.
|
||||
- Create a minimal package.json with only the net-snmp dependency based on the backed-up version.
|
||||
- Use a clean install to guarantee that only net-snmp is installed.
|
||||
- Restore the original package.json if the installation fails.
|
||||
|
||||
## 2025-03-26 - 2.6.3 - fix(setup)
|
||||
Update setup script to install only net-snmp dependency and create a minimal package-lock.json for better dependency control.
|
||||
|
||||
- Removed full production dependency install in favor of installing only net-snmp@3.20.0
|
||||
- Added verification step to confirm net-snmp installation
|
||||
- Generate a minimal package-lock.json if one does not exist
|
||||
|
||||
## 2025-03-26 - 2.6.2 - fix(setup/readme)
|
||||
Improve force update instructions and dependency installation process in setup.sh and readme.md
|
||||
|
||||
- Clarify force update commands with explicit paths in readme.md
|
||||
- Remove existing node_modules before installing dependencies in setup.sh
|
||||
- Switch from 'npm ci --only=production' to 'npm install --omit=dev' with updated error instructions
|
||||
|
||||
## 2025-03-26 - 2.6.1 - fix(setup)
|
||||
Update setup.sh to temporarily add vendor Node.js binary to PATH for dependency installation, log Node and npm versions, and restore the original PATH afterwards.
|
||||
|
||||
- Temporarily prepend vendor Node.js binary directory to PATH to ensure proper npm execution.
|
||||
- Log Node.js and npm versions for debugging purposes.
|
||||
- Restore the original PATH after installing dependencies.
|
||||
|
||||
## 2025-03-26 - 2.6.0 - feat(setup)
|
||||
Add --force update flag to setup script and update installation instructions
|
||||
|
||||
- Implemented --force option in setup.sh to force-update Node.js binary and dependencies
|
||||
- Updated readme.md to document the --force flag and revised update steps
|
||||
- Modified ts/cli.ts update command to pass the --force flag to setup.sh
|
||||
|
||||
## 2025-03-26 - 2.5.2 - fix(installer)
|
||||
Improve Node.js binary detection, dependency management, and SNMPv3 fallback logic
|
||||
|
||||
- Enhanced bin/nupst to detect OS and architecture (Linux and Darwin) and fall back to system Node.js for unsupported platforms
|
||||
- Moved net-snmp from devDependencies to dependencies in package.json
|
||||
- Updated setup.sh to install production dependencies and handle installation errors gracefully
|
||||
- Refined SNMPv3 user configuration and fallback mechanism in ts/snmp/manager.ts
|
||||
- Revised README to clarify minimal runtime dependencies and secure SNMP features
|
||||
|
||||
## 2025-03-25 - 2.5.1 - fix(snmp)
|
||||
Fix Eaton UPS support by updating power status OID and adjusting battery runtime conversion.
|
||||
|
||||
- Updated Eaton UPS power status OID to '1.3.6.1.4.1.534.1.4.4.0' to correctly detect online/battery status.
|
||||
- Added conversion for Eaton UPS battery runtime from seconds to minutes in SNMP manager.
|
||||
|
||||
## 2025-03-25 - 2.5.0 - feat(cli)
|
||||
Automatically restart running NUPST service after configuration changes in interactive setup
|
||||
|
||||
- Added restartServiceIfRunning() to check and restart the service if it's active.
|
||||
- Invoked the restart function post-setup to apply configuration changes immediately.
|
||||
|
||||
## 2025-03-25 - 2.4.8 - fix(installer)
|
||||
Improve Git dependency handling and repository cloning in install.sh
|
||||
|
||||
- Add explicit check for git installation and prompt the user interactively if git is missing.
|
||||
- Auto-install git when '-y' flag is provided in non-interactive mode.
|
||||
- Ensure proper cloning of the repository when running the installer outside the repo.
|
||||
|
||||
## 2025-03-25 - 2.4.7 - fix(readme)
|
||||
Update installation instructions to combine download and execution into a single command for clarity
|
||||
|
||||
- Method 1 now uses a unified one-line command to download and run the install script
|
||||
|
||||
## 2025-03-25 - 2.4.6 - fix(installer)
|
||||
Improve installation instructions for interactive and non-interactive setups
|
||||
|
||||
- Changed install.sh to require explicit download of the install script and updated error messages for non-interactive modes
|
||||
- Updated readme.md to include three distinct installation methods with clear command examples
|
||||
|
||||
## 2025-03-25 - 2.4.5 - fix(install)
|
||||
Improve interactive terminal detection and update installation instructions
|
||||
|
||||
- Enhanced install.sh to better detect non-interactive environments and provide clearer guidance for both interactive and non-interactive installations
|
||||
- Updated README.md quick install instructions to recommend process substitution and clarify auto-yes usage
|
||||
|
||||
## 2025-03-25 - 2.4.4 - fix(install)
|
||||
Improve interactive mode detection and non-interactive installation handling in install.sh
|
||||
|
||||
- Detect and warn when running without a controlling terminal
|
||||
- Attempt to use /dev/tty for user input when possible
|
||||
- Update prompts and error messages for auto-installation of dependencies
|
||||
- Clarify installation instructions in readme for interactive and non-interactive modes
|
||||
|
||||
## 2025-03-25 - 2.4.3 - fix(readme)
|
||||
Update Quick Install command syntax in readme for auto-yes installation
|
||||
|
||||
- Changed installation command to use: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -c "bash -s -- -y"
|
||||
|
||||
## 2025-03-25 - 2.4.2 - fix(daemon)
|
||||
Refactor shutdown initiation logic in daemon by moving the initiateShutdown and monitorDuringShutdown methods from the SNMP manager to the daemon, and update calls accordingly
|
||||
|
||||
- Moved initiateShutdown and monitorDuringShutdown to the daemon class for improved cohesion
|
||||
- Updated references in the daemon to call its own shutdown method instead of the SNMP manager
|
||||
- Removed redundant initiateShutdown method from the SNMP manager
|
||||
|
||||
## 2025-03-25 - 2.4.1 - fix(docs)
|
||||
Update readme with detailed legal and trademark guidance
|
||||
|
||||
- Clarified legal section by adding trademark and company information
|
||||
- Ensured users understand that licensing terms do not imply endorsement by the company
|
||||
|
||||
## 2025-03-25 - 2.4.0 - feat(installer)
|
||||
Add auto-yes flag to installer and update installation documentation
|
||||
|
||||
- Enhance install.sh to parse -y/--yes and -h/--help options, automating git installation when auto-yes is provided
|
||||
- Improve user prompts for dependency installation and provide clearer instructions
|
||||
- Update readme.md to document new installer options and enhanced file system and service changes details
|
||||
|
||||
## 2025-03-25 - 2.3.0 - feat(installer/cli)
|
||||
Add OS detection and git auto-installation support to install.sh and improve service setup prompt in CLI
|
||||
|
||||
- Implemented helper functions in install.sh to detect OS type and automatically install git if missing
|
||||
- Prompt user for git installation if not present before cloning the repository
|
||||
- Enhanced CLI service setup flow to offer starting the NUPST service immediately after installation
|
||||
|
||||
## 2025-03-25 - 2.2.0 - feat(cli)
|
||||
Add 'config' command to display current configuration and update CLI help
|
||||
|
||||
- Introduce new 'config' command to show SNMP settings, thresholds, and configuration file location
|
||||
- Update help text to include details for 'nupst config' command
|
||||
|
||||
## 2025-03-25 - 2.1.0 - feat(cli)
|
||||
Add uninstall command to CLI and update shutdown delay for graceful VM shutdown
|
||||
|
||||
- Implement uninstall command in ts/cli.ts that locates and executes uninstall.sh with user prompts
|
||||
- Update uninstall.sh to support environment variables for configuration and repository removal
|
||||
- Increase shutdown delay in ts/snmp/manager.ts from 1 minute to 5 minutes to allow VMs more time to shut down
|
||||
|
||||
## 2025-03-25 - 2.0.1 - fix(cli/systemd)
|
||||
Fix status command to pass debug flag and improve systemd status logging output
|
||||
|
||||
- ts/cli.ts: Now extracts debug options from process arguments and passes debug mode to getStatus.
|
||||
- ts/systemd.ts: Updated getStatus to accept a debugMode parameter, enabling detailed SNMP debug logging, explicitly reloading configuration, and printing connection details.
|
||||
|
||||
## 2025-03-25 - 2.0.0 - BREAKING CHANGE(snmp)
|
||||
refactor: update SNMP type definitions and interface names for consistency
|
||||
|
||||
- Renamed SnmpConfig to ISnmpConfig, OIDSet to IOidSet, UpsStatus to IUpsStatus, and UpsModel to TUpsModel in ts/snmp/types.ts.
|
||||
- Updated internal references in ts/daemon.ts, ts/snmp/index.ts, ts/snmp/manager.ts, ts/snmp/oid-sets.ts, ts/snmp/packet-creator.ts, and ts/snmp/packet-parser.ts to use the new interface names.
|
||||
|
||||
## 2025-03-25 - 1.10.1 - fix(systemd/readme)
|
||||
Improve README documentation and fix UPS status retrieval in systemd service
|
||||
|
||||
|
213
install.sh
213
install.sh
@ -2,7 +2,45 @@
|
||||
|
||||
# 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
|
||||
# Can be used directly with curl:
|
||||
# Without auto-installing dependencies:
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
# With auto-installing dependencies:
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y
|
||||
#
|
||||
# Options:
|
||||
# -y, --yes Automatically answer yes to all prompts
|
||||
# -h, --help Show this help message
|
||||
|
||||
# Parse command line arguments
|
||||
AUTO_YES=0
|
||||
SHOW_HELP=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
-y|--yes)
|
||||
AUTO_YES=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
SHOW_HELP=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
# Unknown option
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $SHOW_HELP -eq 1 ]; then
|
||||
echo "NUPST Installer Script"
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -y, --yes Automatically answer yes to all prompts"
|
||||
echo " -h, --help Show this help message"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
@ -12,23 +50,149 @@ fi
|
||||
|
||||
# Detect if script is being piped or run directly
|
||||
PIPED=0
|
||||
INTERACTIVE=1
|
||||
if [ ! -t 0 ]; then
|
||||
# Being piped, need to clone the repo
|
||||
PIPED=1
|
||||
fi
|
||||
|
||||
# Check if stdin is a terminal
|
||||
if [ ! -t 0 ] || [ ! -t 1 ]; then
|
||||
# Either stdin or stdout is not a terminal, check if -y was provided
|
||||
if [ $AUTO_YES -ne 1 ]; then
|
||||
echo "Script detected it's running in a non-interactive environment without -y flag."
|
||||
echo "Attempting to find a controlling terminal for interactive prompts..."
|
||||
# Try to use a controlling terminal for user input
|
||||
if [ -t 1 ]; then
|
||||
# Stdout is a terminal, use it
|
||||
exec < /dev/tty 2>/dev/null || INTERACTIVE=0
|
||||
else
|
||||
# Try to find controlling terminal
|
||||
exec < /dev/tty 2>/dev/null || INTERACTIVE=0
|
||||
fi
|
||||
|
||||
if [ $INTERACTIVE -eq 0 ]; then
|
||||
echo "ERROR: No controlling terminal available for interactive prompts."
|
||||
echo "For interactive installation (RECOMMENDED):"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh -o nupst-install.sh"
|
||||
echo " sudo bash nupst-install.sh"
|
||||
echo ""
|
||||
echo "For non-interactive installation with automatic dependency installation:"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y"
|
||||
exit 1
|
||||
else
|
||||
echo "Interactive terminal found, continuing with prompts..."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper function to detect OS type
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
elif type lsb_release >/dev/null 2>&1; then
|
||||
OS=$(lsb_release -si | tr '[:upper:]' '[:lower:]')
|
||||
elif [ -f /etc/lsb-release ]; then
|
||||
. /etc/lsb-release
|
||||
OS=$DISTRIB_ID
|
||||
elif [ -f /etc/debian_version ]; then
|
||||
OS="debian"
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
if grep -q "CentOS" /etc/redhat-release; then
|
||||
OS="centos"
|
||||
elif grep -q "Fedora" /etc/redhat-release; then
|
||||
OS="fedora"
|
||||
else
|
||||
OS="rhel"
|
||||
fi
|
||||
else
|
||||
OS=$(uname -s)
|
||||
fi
|
||||
echo $OS
|
||||
}
|
||||
|
||||
# Helper function to install git
|
||||
install_git() {
|
||||
OS=$(detect_os)
|
||||
echo "Detected OS: $OS"
|
||||
|
||||
case "$OS" in
|
||||
ubuntu|debian|pop|mint|elementary|kali|zorin)
|
||||
echo "Installing git using apt..."
|
||||
apt-get update && apt-get install -y git
|
||||
;;
|
||||
fedora|rhel|centos|almalinux|rocky)
|
||||
echo "Installing git using dnf/yum..."
|
||||
if command -v dnf &> /dev/null; then
|
||||
dnf install -y git
|
||||
else
|
||||
yum install -y git
|
||||
fi
|
||||
;;
|
||||
arch|manjaro|endeavouros|garuda)
|
||||
echo "Installing git using pacman..."
|
||||
pacman -Sy --noconfirm git
|
||||
;;
|
||||
opensuse*|suse|sles)
|
||||
echo "Installing git using zypper..."
|
||||
zypper install -y git
|
||||
;;
|
||||
alpine)
|
||||
echo "Installing git using apk..."
|
||||
apk add git
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS"
|
||||
echo "Please install git manually and run the installer again."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check if git was installed successfully
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "Failed to install git. Please install git manually and run the installer again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Git installed successfully."
|
||||
}
|
||||
|
||||
# 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
|
||||
# Check if git is installed - needed for both piped and direct execution
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "Git is required but not installed. Please install git first."
|
||||
echo "Git is required but not installed."
|
||||
|
||||
if [ $AUTO_YES -eq 1 ]; then
|
||||
echo "Auto-installing git (-y flag provided)..."
|
||||
install_git
|
||||
elif [ $INTERACTIVE -eq 1 ]; then
|
||||
# If interactive and no -y flag, ask the user
|
||||
echo "Would you like to install git now? (y/N): "
|
||||
read -r install_git_prompt
|
||||
|
||||
if [[ "$install_git_prompt" =~ ^[Yy]$ ]]; then
|
||||
install_git
|
||||
else
|
||||
echo "Git installation skipped. Please install git manually and run the installer again."
|
||||
echo "Alternatively, you can run the installer with -y flag to automatically install git:"
|
||||
echo " sudo bash install.sh -y"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Non-interactive mode without -y flag
|
||||
echo "Error: Git is required but not installed."
|
||||
echo "In non-interactive mode, use -y flag to auto-install dependencies:"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $PIPED -eq 1 ]; then
|
||||
echo "Installing NUPST from remote repository..."
|
||||
|
||||
# Check if installation directory exists
|
||||
if [ -d "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR/.git" ]; then
|
||||
@ -71,12 +235,47 @@ if [ $PIPED -eq 1 ]; then
|
||||
# Set script directory to the cloned repo
|
||||
SCRIPT_DIR="$INSTALL_DIR"
|
||||
else
|
||||
# Running directly from within the repo
|
||||
# Running directly from within the repo or downloaded script
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
|
||||
# When running from a downloaded script in a different location
|
||||
# we need to clone the repository first
|
||||
if [ ! -f "$SCRIPT_DIR/setup.sh" ]; then
|
||||
echo "Running installer from downloaded script outside repository."
|
||||
echo "Will clone the repository to $INSTALL_DIR..."
|
||||
|
||||
# Create installation directory if needed
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Removing previous installation at $INSTALL_DIR..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Clone the repository
|
||||
echo "Cloning NUPST repository to $INSTALL_DIR..."
|
||||
git clone --depth 1 $REPO_URL "$INSTALL_DIR"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone repository. Please check your internet connection."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update script directory to use the cloned repo
|
||||
SCRIPT_DIR="$INSTALL_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run setup script
|
||||
echo "Running setup script..."
|
||||
if [ ! -f "$SCRIPT_DIR/setup.sh" ]; then
|
||||
echo "ERROR: Setup script not found at $SCRIPT_DIR/setup.sh"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Script directory: $SCRIPT_DIR"
|
||||
ls -la "$SCRIPT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash "$SCRIPT_DIR/setup.sh"
|
||||
|
||||
# Install globally
|
||||
|
21
license
Normal file
21
license
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "1.10.1",
|
||||
"version": "3.1.2",
|
||||
"description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@ -36,7 +36,9 @@
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"net-snmp": "3.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsrun": "^1.3.3",
|
||||
@ -54,5 +56,6 @@
|
||||
"mongodb-memory-server",
|
||||
"puppeteer"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
}
|
||||
|
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -7,6 +7,10 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
net-snmp:
|
||||
specifier: 3.20.0
|
||||
version: 3.20.0
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^2.3.2
|
||||
@ -1647,6 +1651,9 @@ packages:
|
||||
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
asn1-ber@1.2.2:
|
||||
resolution: {integrity: sha512-CbNem/7hxrjSiOAOOTX4iZxu+0m3jiLqlsERQwwPM1IDR/22M8IPpA1VVndCLw5KtjRYyRODbvAEIfuTogNDng==}
|
||||
|
||||
ast-types@0.13.4:
|
||||
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
|
||||
engines: {node: '>=4'}
|
||||
@ -3303,6 +3310,9 @@ packages:
|
||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
net-snmp@3.20.0:
|
||||
resolution: {integrity: sha512-4Cp8ODkzgVXjUrIQFfL9Vo6qVsz+8OuAjUvkRGsSZOKSpoxpy9YWjVgNs+/a9N4Hd9MilIy90Zhw3EZlUUZB6A==}
|
||||
|
||||
netmask@2.0.2:
|
||||
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@ -7181,6 +7191,8 @@ snapshots:
|
||||
|
||||
array-union@2.1.0: {}
|
||||
|
||||
asn1-ber@1.2.2: {}
|
||||
|
||||
ast-types@0.13.4:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@ -9133,6 +9145,11 @@ snapshots:
|
||||
|
||||
negotiator@0.6.3: {}
|
||||
|
||||
net-snmp@3.20.0:
|
||||
dependencies:
|
||||
asn1-ber: 1.2.2
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
netmask@2.0.2: {}
|
||||
|
||||
new-find-package-json@2.0.0:
|
||||
|
212
readme.md
212
readme.md
@ -4,6 +4,10 @@ NUPST is a command-line tool that monitors SNMP-enabled UPS devices and initiate
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-UPS Support**: Monitor and manage multiple UPS devices from a single installation
|
||||
- **Group Management**: Organize UPS devices into groups with different operating modes
|
||||
- **Redundant Mode**: Only shutdown when ALL UPS devices in a group are in critical condition
|
||||
- **Non-Redundant Mode**: Shutdown when ANY UPS device in a group is in critical condition
|
||||
- Monitors UPS devices using SNMP (v1, v2c, and v3 supported)
|
||||
- Automatic shutdown when battery level falls below threshold
|
||||
- Automatic shutdown when runtime remaining falls below threshold
|
||||
@ -19,8 +23,19 @@ NUPST is a command-line tool that monitors SNMP-enabled UPS devices and initiate
|
||||
### 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
|
||||
# Method 1: Download and run (most reliable across all environments)
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh -o nupst-install.sh && sudo bash nupst-install.sh && rm nupst-install.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# Method 2: Pipe with automatic yes for dependencies (non-interactive)
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y
|
||||
```
|
||||
|
||||
```bash
|
||||
# Method 3: Process substitution (only on systems that support /dev/fd/)
|
||||
# Note: This may fail on some systems with "No such file or directory" errors
|
||||
sudo bash <(curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
### Direct from Git
|
||||
@ -33,20 +48,58 @@ cd nupst
|
||||
# Option 1: Quick install (requires root privileges)
|
||||
sudo ./install.sh
|
||||
|
||||
# Option 1a: Quick install with auto-yes for dependencies
|
||||
sudo ./install.sh -y
|
||||
|
||||
# Option 2: Manual setup
|
||||
./setup.sh
|
||||
sudo ln -s $(pwd)/bin/nupst /usr/local/bin/nupst
|
||||
```
|
||||
|
||||
### Installation Options
|
||||
|
||||
The installer script (`install.sh`) supports the following options:
|
||||
|
||||
```
|
||||
-y, --yes Automatically answer yes to all prompts (like installing git)
|
||||
-h, --help Show the help message
|
||||
```
|
||||
|
||||
### From NPM
|
||||
|
||||
```bash
|
||||
npm install -g @serve.zone/nupst
|
||||
```
|
||||
|
||||
## System Changes
|
||||
|
||||
When installed, NUPST makes the following changes to your system:
|
||||
|
||||
### File System Changes
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/opt/nupst/` | Main installation directory containing the NUPST files |
|
||||
| `/etc/nupst/config.json` | Configuration file |
|
||||
| `/usr/local/bin/nupst` | Symlink to the NUPST executable |
|
||||
| `/etc/systemd/system/nupst.service` | Systemd service file (when enabled) |
|
||||
|
||||
### Service Changes
|
||||
|
||||
- Creates and enables a systemd service called `nupst.service` (when enabled with `nupst enable`)
|
||||
- The service runs with root permissions to allow system shutdown capabilities
|
||||
|
||||
### Network Access
|
||||
|
||||
- NUPST only communicates with your UPS device via SNMP (default port 161)
|
||||
- Brief connections to npmjs.org to check for updates
|
||||
|
||||
## Uninstallation
|
||||
|
||||
```bash
|
||||
# Using the CLI tool:
|
||||
sudo nupst uninstall
|
||||
|
||||
# If installed from git repository:
|
||||
cd /path/to/nupst
|
||||
sudo ./uninstall.sh
|
||||
@ -57,9 +110,10 @@ 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
|
||||
- Remove the systemd service file from `/etc/systemd/system/nupst.service`
|
||||
- Remove the symlink from `/usr/local/bin/nupst`
|
||||
- Optionally remove configuration files from `/etc/nupst/`
|
||||
- Remove the repository directory from `/opt/nupst/` (when using `nupst uninstall`)
|
||||
|
||||
## Usage
|
||||
|
||||
@ -74,9 +128,25 @@ Usage:
|
||||
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
|
||||
|
||||
UPS Management:
|
||||
nupst add - Add a new UPS device
|
||||
nupst edit [id] - Edit an existing UPS (default UPS if no ID provided)
|
||||
nupst delete <id> - Delete a UPS by ID
|
||||
nupst list - List all configured UPS devices
|
||||
nupst setup - Alias for 'nupst edit' (backward compatibility)
|
||||
|
||||
Group Management:
|
||||
nupst group list - List all UPS groups
|
||||
nupst group add - Add a new UPS group
|
||||
nupst group edit <id> - Edit an existing UPS group
|
||||
nupst group delete <id> - Delete a UPS group
|
||||
|
||||
System Commands:
|
||||
nupst test - Test the current configuration by connecting to all UPS devices
|
||||
nupst config - Display the current configuration
|
||||
nupst update - Update NUPST from repository and refresh systemd service (requires root)
|
||||
nupst uninstall - Completely uninstall NUPST from the system (requires root)
|
||||
nupst help - Show this help message
|
||||
|
||||
Options:
|
||||
@ -86,22 +156,30 @@ Options:
|
||||
|
||||
## Configuration
|
||||
|
||||
NUPST provides an interactive setup to configure your UPS:
|
||||
NUPST supports monitoring multiple UPS devices organized into groups. You can set up your UPS devices using the interactive commands:
|
||||
|
||||
```bash
|
||||
nupst setup
|
||||
# Add a new UPS device
|
||||
nupst add
|
||||
|
||||
# Create a new group
|
||||
nupst group add
|
||||
|
||||
# Assign UPS devices to groups
|
||||
nupst group edit <group-id>
|
||||
```
|
||||
|
||||
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
|
||||
### Configuration File Structure
|
||||
|
||||
Alternatively, you can manually edit the configuration file at `/etc/nupst/config.json`. A default configuration will be created on first run:
|
||||
The configuration file is located at `/etc/nupst/config.json`. Here's an example of a multi-UPS configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"checkInterval": 30000,
|
||||
"upsDevices": [
|
||||
{
|
||||
"id": "ups-1",
|
||||
"name": "Server Room UPS",
|
||||
"snmp": {
|
||||
"host": "192.168.1.100",
|
||||
"port": 161,
|
||||
@ -114,10 +192,43 @@ Alternatively, you can manually edit the configuration file at `/etc/nupst/confi
|
||||
"battery": 60,
|
||||
"runtime": 20
|
||||
},
|
||||
"checkInterval": 30000
|
||||
"groups": ["datacenter"]
|
||||
},
|
||||
{
|
||||
"id": "ups-2",
|
||||
"name": "Network Rack UPS",
|
||||
"snmp": {
|
||||
"host": "192.168.1.101",
|
||||
"port": 161,
|
||||
"community": "public",
|
||||
"version": 1,
|
||||
"timeout": 5000,
|
||||
"upsModel": "apc"
|
||||
},
|
||||
"thresholds": {
|
||||
"battery": 50,
|
||||
"runtime": 15
|
||||
},
|
||||
"groups": ["datacenter"]
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "datacenter",
|
||||
"name": "Data Center",
|
||||
"mode": "redundant",
|
||||
"description": "Main data center UPS group"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Fields
|
||||
|
||||
- `checkInterval`: How often to check UPS status in milliseconds (default: 30000)
|
||||
- `upsDevices`: Array of UPS device configurations
|
||||
- `id`: Unique identifier for the UPS
|
||||
- `name`: Friendly name for the UPS
|
||||
- `snmp`: SNMP connection settings
|
||||
- `host`: IP address of your UPS (default: 127.0.0.1)
|
||||
- `port`: SNMP port (default: 161)
|
||||
@ -141,7 +252,18 @@ Alternatively, you can manually edit the configuration file at `/etc/nupst/confi
|
||||
- `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)
|
||||
- `groups`: Array of group IDs this UPS belongs to
|
||||
- `groups`: Array of group configurations
|
||||
- `id`: Unique identifier for the group
|
||||
- `name`: Friendly name for the group
|
||||
- `mode`: Group operating mode ('redundant' or 'nonRedundant')
|
||||
- `description`: Optional description of the group
|
||||
|
||||
### Group Modes
|
||||
|
||||
- **Redundant Mode**: The system will only initiate shutdown if ALL UPS devices in the group are in critical condition (below threshold). This is ideal for redundant power setups where one UPS can keep systems running.
|
||||
|
||||
- **Non-Redundant Mode**: The system will initiate shutdown if ANY UPS device in the group is in critical condition. This is useful for scenarios where all UPS devices must be operational for the system to function properly.
|
||||
|
||||
## Setup as a Service
|
||||
|
||||
@ -175,8 +297,19 @@ sudo nupst update
|
||||
This will:
|
||||
1. Pull the latest changes from the git repository
|
||||
2. Run the installation scripts
|
||||
3. Refresh the systemd service configuration
|
||||
4. Restart the service if it was running
|
||||
3. Force-update Node.js and all dependencies, even if they already exist
|
||||
4. Refresh the systemd service configuration
|
||||
5. Restart the service if it was running
|
||||
|
||||
You can also manually run the setup script with the force flag to update Node.js and dependencies without updating the application code:
|
||||
|
||||
```bash
|
||||
# If you're in the nupst directory:
|
||||
bash ./setup.sh --force
|
||||
|
||||
# If you're in another directory, specify the full path:
|
||||
bash /opt/nupst/setup.sh --force
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
@ -184,10 +317,10 @@ NUPST was designed with security in mind:
|
||||
|
||||
### Minimal Dependencies
|
||||
|
||||
- **Zero Runtime NPM Dependencies**: NUPST is built without any external NPM packages to minimize the attack surface and avoid supply chain risks.
|
||||
- **Minimal Runtime Dependencies**: NUPST uses only one carefully selected NPM package (net-snmp) to minimize the attack surface and avoid supply chain risks while providing robust SNMP functionality.
|
||||
- **Self-contained Node.js**: NUPST ships with its own Node.js binary, isolated from the system's Node.js installation. This ensures:
|
||||
- No dependency on system Node.js versions
|
||||
- Zero external libraries that could become compromised
|
||||
- Minimal external libraries that could become compromised
|
||||
- Consistent, tested environment for execution
|
||||
- Reduced risk of dependency-based attacks
|
||||
|
||||
@ -195,19 +328,50 @@ NUPST was designed with security in mind:
|
||||
|
||||
- **Privilege Separation**: Only specific commands that require elevated permissions (`enable`, `disable`, `update`) check for root access; all other functionality runs with minimal privileges.
|
||||
- **Limited Network Access**: NUPST only communicates with the UPS device over SNMP and contacts npmjs.org only to check for updates.
|
||||
- **Secure SNMPv3 Support**: Supports encrypted authentication and privacy for secure communication with the UPS device.
|
||||
- **Isolated Execution**: The application runs in its working directory (`/opt/nupst`) or specified installation location, minimizing the impact on the rest of the system.
|
||||
|
||||
### SNMP Security Features
|
||||
|
||||
- **SNMPv3 Support with Secure Authentication and Privacy**:
|
||||
- Three security levels available:
|
||||
- `noAuthNoPriv`: No authentication or encryption (basic access)
|
||||
- `authNoPriv`: Authentication without encryption (verifies identity)
|
||||
- `authPriv`: Full authentication and encryption (most secure)
|
||||
- Authentication protocols: MD5 or SHA
|
||||
- Privacy/encryption protocols: DES or AES
|
||||
- Automatic fallback mechanisms for compatibility
|
||||
- Context support for segmented SNMP deployments
|
||||
- Configurable timeouts based on security level
|
||||
- **Graceful degradation**: If authentication or privacy details are missing or invalid, NUPST will automatically fall back to a lower security level while logging appropriate warnings.
|
||||
- **Interactive setup**: Guided setup process to properly configure SNMPv3 security settings with clear explanations of each security option.
|
||||
|
||||
### Installation Security
|
||||
|
||||
- The installation script can be reviewed before execution (`curl -sSL [url] | less`)
|
||||
- All setup scripts download only verified versions and check integrity
|
||||
- Installation is transparent and places files in standard locations (`/opt/nupst`, `/usr/local/bin`, `/etc/systemd/system`)
|
||||
- Automatically detects platform architecture and OS for proper binary selection
|
||||
- Installs production dependencies locally without requiring global npm packages
|
||||
|
||||
### Audit and Review
|
||||
|
||||
The codebase is small, focused, and designed to be easily auditable. All code is open source and available for review.
|
||||
|
||||
## License
|
||||
## License and Legal Information
|
||||
|
||||
MIT
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
103
setup.sh
103
setup.sh
@ -2,6 +2,22 @@
|
||||
|
||||
# NUPST Setup Script
|
||||
# Downloads the appropriate Node.js binary for the current platform
|
||||
# and installs production dependencies
|
||||
|
||||
# Parse command line arguments
|
||||
FORCE_UPDATE=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--force|-f)
|
||||
FORCE_UPDATE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
# Unknown option
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Find the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
@ -74,8 +90,9 @@ case "$OS" in
|
||||
esac
|
||||
|
||||
# Check if we already have the Node.js binary
|
||||
if [ -f "$SCRIPT_DIR/vendor/$NODE_DIR/bin/node" ]; then
|
||||
if [ -f "$SCRIPT_DIR/vendor/$NODE_DIR/bin/node" ] && [ $FORCE_UPDATE -eq 0 ]; then
|
||||
echo "Node.js binary already exists for $OS-$ARCH. Skipping download."
|
||||
echo "Use --force or -f to force update Node.js."
|
||||
else
|
||||
echo "Downloading Node.js v$NODE_VERSION for $OS-$ARCH..."
|
||||
|
||||
@ -222,6 +239,90 @@ echo "dist_ts directory successfully downloaded from npm registry."
|
||||
# Make launcher script executable
|
||||
chmod +x "$SCRIPT_DIR/bin/nupst"
|
||||
|
||||
# Set up Node.js binary path
|
||||
NODE_BIN_DIR="$SCRIPT_DIR/vendor/$NODE_DIR/bin"
|
||||
NODE_BIN="$NODE_BIN_DIR/node"
|
||||
NPM_CLI_JS="$NODE_BIN_DIR/../lib/node_modules/npm/bin/npm-cli.js"
|
||||
|
||||
# Ensure we have executable permissions
|
||||
chmod +x "$NODE_BIN"
|
||||
|
||||
# Make sure the npm-cli.js exists
|
||||
if [ ! -f "$NPM_CLI_JS" ]; then
|
||||
# Try to find npm-cli.js
|
||||
NPM_CLI_JS=$(find "$NODE_BIN_DIR/.." -name "npm-cli.js" | head -1)
|
||||
|
||||
if [ -z "$NPM_CLI_JS" ]; then
|
||||
echo "Warning: Could not find npm-cli.js, npm commands may fail"
|
||||
# Set to a fallback value so code can continue
|
||||
NPM_CLI_JS="$NODE_BIN_DIR/npm"
|
||||
else
|
||||
echo "Found npm-cli.js at: $NPM_CLI_JS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Display which binaries we're using
|
||||
echo "Using Node binary: $NODE_BIN"
|
||||
echo "Using NPM CLI JS: $NPM_CLI_JS"
|
||||
|
||||
# Remove existing node_modules directory and package files
|
||||
echo "Cleaning up existing installation..."
|
||||
rm -rf "$SCRIPT_DIR/node_modules"
|
||||
rm -f "$SCRIPT_DIR/package-lock.json"
|
||||
|
||||
# Back up existing package.json if it exists
|
||||
if [ -f "$SCRIPT_DIR/package.json" ]; then
|
||||
echo "Backing up existing package.json..."
|
||||
cp "$SCRIPT_DIR/package.json" "$SCRIPT_DIR/package.json.bak"
|
||||
fi
|
||||
|
||||
# Create a clean minimal package.json with ONLY net-snmp dependency
|
||||
echo "Creating minimal package.json with only net-snmp dependency..."
|
||||
VERSION=$(grep -o '"version": "[^"]*"' "$SCRIPT_DIR/package.json.bak" | head -1 | cut -d'"' -f4 || echo "2.6.3")
|
||||
echo '{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "'$VERSION'",
|
||||
"description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices",
|
||||
"main": "dist_ts/index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"nupst": "bin/nupst"
|
||||
},
|
||||
"dependencies": {
|
||||
"net-snmp": "3.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"private": true
|
||||
}' > "$SCRIPT_DIR/package.json"
|
||||
|
||||
# Install ONLY net-snmp
|
||||
echo "Installing ONLY net-snmp dependency (+ 2 subdependencies)..."
|
||||
echo "Node version: $("$NODE_BIN" --version)"
|
||||
echo "Executing NPM directly with Node.js"
|
||||
|
||||
# Execute npm-cli.js directly with our Node.js binary
|
||||
"$NODE_BIN" "$NPM_CLI_JS" --prefix "$SCRIPT_DIR" install --no-audit --no-fund
|
||||
|
||||
INSTALL_STATUS=$?
|
||||
if [ $INSTALL_STATUS -ne 0 ]; then
|
||||
echo "Error: Failed to install net-snmp dependency. NUPST may not function correctly."
|
||||
echo "Restoring original package.json..."
|
||||
mv "$SCRIPT_DIR/package.json.bak" "$SCRIPT_DIR/package.json"
|
||||
exit 1
|
||||
else
|
||||
echo "net-snmp dependency installed successfully."
|
||||
# Show what's actually installed
|
||||
echo "Installed modules:"
|
||||
find "$SCRIPT_DIR/node_modules" -maxdepth 1 -type d | grep -v "^$SCRIPT_DIR/node_modules$" | sort
|
||||
|
||||
# Remove backup if successful
|
||||
rm -f "$SCRIPT_DIR/package.json.bak"
|
||||
fi
|
||||
|
||||
# No temporary files to clean up
|
||||
|
||||
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"
|
||||
|
160
test/test.logger.ts
Normal file
160
test/test.logger.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { Logger } from '../ts/logger.js';
|
||||
|
||||
// Create a Logger instance for testing
|
||||
const logger = new Logger();
|
||||
|
||||
tap.test('should create a logger instance', async () => {
|
||||
expect(logger instanceof Logger).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should log messages with different log levels', async () => {
|
||||
// We're not testing console output directly, just ensuring no errors
|
||||
logger.log('Regular log message');
|
||||
logger.error('Error message');
|
||||
logger.warn('Warning message');
|
||||
logger.success('Success message');
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should create a logbox with title, content, and end', async () => {
|
||||
// Just ensuring no errors occur
|
||||
logger.logBoxTitle('Test Box', 40);
|
||||
logger.logBoxLine('This is a test line');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle width persistence between logbox calls', async () => {
|
||||
logger.logBoxTitle('Width Test', 45);
|
||||
|
||||
// These should use the width from the title
|
||||
logger.logBoxLine('Line 1');
|
||||
logger.logBoxLine('Line 2');
|
||||
logger.logBoxEnd();
|
||||
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
// This should work fine after the reset in logBoxEnd
|
||||
logger.logBoxTitle('New Box', 30);
|
||||
logger.logBoxLine('New line');
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should use default width when no width is specified', async () => {
|
||||
// This should automatically use the default width instead of throwing
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
logger.logBoxLine('This should use default width');
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
}
|
||||
|
||||
// Verify no error was thrown
|
||||
expect(errorThrown).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should create a complete logbox in one call', async () => {
|
||||
// Just ensuring no errors occur
|
||||
logger.logBox('Complete Box', [
|
||||
'Line 1',
|
||||
'Line 2',
|
||||
'Line 3'
|
||||
], 40);
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle content that exceeds box width', async () => {
|
||||
// Just ensuring no errors occur when content is too long
|
||||
logger.logBox('Truncation Test', [
|
||||
'This line is way too long and should be truncated because it exceeds the available space'
|
||||
], 30);
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should create dividers with custom characters', async () => {
|
||||
// Just ensuring no errors occur
|
||||
logger.logDivider(30);
|
||||
logger.logDivider(20, '*');
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should create divider with default width', async () => {
|
||||
// This should use the default width
|
||||
logger.logDivider(undefined, '-');
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('Logger Demo', async () => {
|
||||
console.log('\n=== LOGGER DEMO ===\n');
|
||||
|
||||
// Basic logging
|
||||
logger.log('Regular log message');
|
||||
logger.error('Error message');
|
||||
logger.warn('Warning message');
|
||||
logger.success('Success message');
|
||||
|
||||
// Logbox with title, content lines, and end
|
||||
logger.logBoxTitle('Configuration Loaded', 50);
|
||||
logger.logBoxLine('SNMP Settings:');
|
||||
logger.logBoxLine(' Host: 127.0.0.1');
|
||||
logger.logBoxLine(' Port: 161');
|
||||
logger.logBoxLine(' Version: 1');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Complete logbox in one call
|
||||
logger.logBox('UPS Status', [
|
||||
'Power Status: onBattery',
|
||||
'Battery Capacity: 75%',
|
||||
'Runtime Remaining: 30 minutes'
|
||||
], 45);
|
||||
|
||||
// Logbox with content that's too long for the width
|
||||
logger.logBox('Truncation Example', [
|
||||
'This line is short enough to fit within the box width',
|
||||
'This line is way too long and will be truncated because it exceeds the available space for content within the logbox'
|
||||
], 40);
|
||||
|
||||
// Demonstrating logbox width being remembered
|
||||
logger.logBoxTitle('Width Persistence Example', 60);
|
||||
logger.logBoxLine('These lines use the width from the title');
|
||||
logger.logBoxLine('No need to specify the width again');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Demonstrating default width
|
||||
console.log('\nDefault Width Example:');
|
||||
logger.logBoxLine('This line uses the default width');
|
||||
logger.logBoxLine('Still using default width');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Divider example
|
||||
logger.log('\nDivider example:');
|
||||
logger.logDivider(30);
|
||||
logger.logDivider(30, '*');
|
||||
logger.logDivider(undefined, '=');
|
||||
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
// Export the default tap object
|
||||
export default tap.start();
|
329
test/test.ts
329
test/test.ts
@ -1,9 +1,6 @@
|
||||
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 { NupstSnmp } from '../ts/snmp/manager.js';
|
||||
import type { ISnmpConfig, IUpsStatus } from '../ts/snmp/types.js';
|
||||
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
const testQenv = new qenv.Qenv('./', '.nogit/');
|
||||
@ -12,295 +9,57 @@ const testQenv = new qenv.Qenv('./', '.nogit/');
|
||||
const snmp = new NupstSnmp(true);
|
||||
|
||||
// Load the test configuration from .nogit/env.json
|
||||
const testConfig = await testQenv.getEnvVarOnDemandAsObject('testConfig');
|
||||
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
|
||||
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
|
||||
|
||||
tap.test('should log config', async () => {
|
||||
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);
|
||||
console.log(testConfigV1);
|
||||
});
|
||||
|
||||
// Test with real UPS using the configuration from .nogit/env.json
|
||||
tap.test('Real UPS test', async () => {
|
||||
tap.test('Real UPS test v1', async () => {
|
||||
try {
|
||||
console.log('Testing with real UPS configuration...');
|
||||
|
||||
// Extract the correct SNMP config from the test configuration
|
||||
const snmpConfig = testConfig.snmp;
|
||||
const snmpConfig = testConfigV1.snmp;
|
||||
console.log('SNMP Config:');
|
||||
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
|
||||
console.log(` Version: SNMPv${snmpConfig.version}`);
|
||||
console.log(` UPS Model: ${snmpConfig.upsModel}`);
|
||||
|
||||
// Use a short timeout for testing
|
||||
const testSnmpConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000) // Use at most 10 seconds for testing
|
||||
};
|
||||
|
||||
// Try to get the UPS status
|
||||
const status = await snmp.getUpsStatus(testSnmpConfig);
|
||||
|
||||
console.log('UPS Status:');
|
||||
console.log(` Power Status: ${status.powerStatus}`);
|
||||
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
|
||||
// Just make sure we got valid data types back
|
||||
expect(status).toBeTruthy();
|
||||
expect(['online', 'onBattery', 'unknown']).toContain(status.powerStatus);
|
||||
expect(typeof status.batteryCapacity).toEqual('number');
|
||||
expect(typeof status.batteryRuntime).toEqual('number');
|
||||
} catch (error) {
|
||||
console.log('Real UPS test failed:', error);
|
||||
// Skip the test if we can't connect to the real UPS
|
||||
console.log('Skipping this test since the UPS might not be available');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Real UPS test v3', async () => {
|
||||
try {
|
||||
console.log('Testing with real UPS configuration...');
|
||||
|
||||
// Extract the correct SNMP config from the test configuration
|
||||
const snmpConfig = testConfigV3.snmp;
|
||||
console.log('SNMP Config:');
|
||||
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
|
||||
console.log(` Version: SNMPv${snmpConfig.version}`);
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/nupst',
|
||||
version: '1.10.1',
|
||||
version: '3.1.2',
|
||||
description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices'
|
||||
}
|
||||
|
565
ts/cli/group-handler.ts
Normal file
565
ts/cli/group-handler.ts
Normal file
@ -0,0 +1,565 @@
|
||||
import { Nupst } from '../nupst.js';
|
||||
import { logger } from '../logger.js';
|
||||
import * as helpers from '../helpers/index.js';
|
||||
import { type IGroupConfig } from '../daemon.js';
|
||||
|
||||
/**
|
||||
* Class for handling group-related CLI commands
|
||||
* Provides interface for managing UPS groups
|
||||
*/
|
||||
export class GroupHandler {
|
||||
private readonly nupst: Nupst;
|
||||
|
||||
/**
|
||||
* Create a new Group handler
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
constructor(nupst: Nupst) {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all UPS groups
|
||||
*/
|
||||
public async list(): Promise<void> {
|
||||
try {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||
logger.logBoxLine('No configuration found.');
|
||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
// Legacy or missing groups configuration
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
||||
logger.logBoxLine('No groups configured.');
|
||||
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Display group list
|
||||
const boxWidth = 60;
|
||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
||||
|
||||
if (config.groups.length === 0) {
|
||||
logger.logBoxLine('No UPS groups configured.');
|
||||
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
|
||||
} else {
|
||||
logger.logBoxLine(`Found ${config.groups.length} group(s)`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('ID | Name | Mode | UPS Devices');
|
||||
logger.logBoxLine('-----------+----------------------+--------------+----------------');
|
||||
|
||||
for (const group of config.groups) {
|
||||
const id = group.id.padEnd(10, ' ').substring(0, 10);
|
||||
const name = (group.name || '').padEnd(20, ' ').substring(0, 20);
|
||||
const mode = (group.mode || 'unknown').padEnd(12, ' ').substring(0, 12);
|
||||
|
||||
// Count UPS devices in this group
|
||||
const upsInGroup = config.upsDevices.filter(ups => ups.groups.includes(group.id));
|
||||
const upsCount = upsInGroup.length;
|
||||
const upsNames = upsInGroup.map(ups => ups.name).join(', ');
|
||||
|
||||
logger.logBoxLine(`${id} | ${name} | ${mode} | ${upsCount > 0 ? upsNames : 'None'}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list UPS groups: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new UPS group
|
||||
*/
|
||||
public async add(): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
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 {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error('No configuration found. Please run "nupst setup" first to create a configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Initialize groups array if not exists
|
||||
if (!config.groups) {
|
||||
config.groups = [];
|
||||
}
|
||||
|
||||
// Check if upsDevices is initialized
|
||||
if (!config.upsDevices) {
|
||||
config.upsDevices = [];
|
||||
}
|
||||
|
||||
logger.log('\nNUPST Add Group');
|
||||
logger.log('==============\n');
|
||||
logger.log('This will guide you through creating a new UPS group.\n');
|
||||
|
||||
// Generate a new unique group ID
|
||||
const groupId = helpers.shortId();
|
||||
|
||||
// Get group name
|
||||
const name = await prompt('Group Name: ');
|
||||
|
||||
// Get group mode
|
||||
const modeInput = await prompt('Group Mode (redundant/nonRedundant) [redundant]: ');
|
||||
const mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant';
|
||||
|
||||
// Get optional description
|
||||
const description = await prompt('Group Description (optional): ');
|
||||
|
||||
// Create the new group
|
||||
const newGroup: IGroupConfig = {
|
||||
id: groupId,
|
||||
name: name || `Group-${groupId}`,
|
||||
mode,
|
||||
description: description || undefined
|
||||
};
|
||||
|
||||
// Add the group to the configuration
|
||||
config.groups.push(newGroup);
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
// Display summary
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Group Created', boxWidth);
|
||||
logger.logBoxLine(`ID: ${newGroup.id}`);
|
||||
logger.logBoxLine(`Name: ${newGroup.name}`);
|
||||
logger.logBoxLine(`Mode: ${newGroup.mode}`);
|
||||
if (newGroup.description) {
|
||||
logger.logBoxLine(`Description: ${newGroup.description}`);
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Check if there are UPS devices to assign to this group
|
||||
if (config.upsDevices.length > 0) {
|
||||
const assignUps = await prompt('Would you like to assign UPS devices to this group now? (y/N): ');
|
||||
if (assignUps.toLowerCase() === 'y') {
|
||||
await this.assignUpsToGroup(newGroup.id, config, prompt);
|
||||
|
||||
// Save again after assigning UPS devices
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup setup complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Add group error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing UPS group
|
||||
* @param groupId ID of the group to edit
|
||||
*/
|
||||
public async edit(groupId: string): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
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 {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error('No configuration found. Please run "nupst setup" first to create a configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if groups are initialized
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.error('No groups configured. Please run "nupst group add" first to create a group.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the group to edit
|
||||
const groupIndex = config.groups.findIndex(group => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = config.groups[groupIndex];
|
||||
|
||||
logger.log(`\nNUPST Edit Group: ${group.name} (${group.id})`);
|
||||
logger.log('==============================================\n');
|
||||
|
||||
// Edit group name
|
||||
const newName = await prompt(`Group Name [${group.name}]: `);
|
||||
if (newName.trim()) {
|
||||
group.name = newName;
|
||||
}
|
||||
|
||||
// Edit group mode
|
||||
const currentMode = group.mode || 'redundant';
|
||||
const modeInput = await prompt(`Group Mode (redundant/nonRedundant) [${currentMode}]: `);
|
||||
if (modeInput.trim()) {
|
||||
group.mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant';
|
||||
}
|
||||
|
||||
// Edit description
|
||||
const currentDesc = group.description || '';
|
||||
const newDesc = await prompt(`Group Description [${currentDesc}]: `);
|
||||
if (newDesc.trim() || newDesc === '') {
|
||||
group.description = newDesc.trim() || undefined;
|
||||
}
|
||||
|
||||
// Update the group in the configuration
|
||||
config.groups[groupIndex] = group;
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
// Display summary
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Group Updated', boxWidth);
|
||||
logger.logBoxLine(`ID: ${group.id}`);
|
||||
logger.logBoxLine(`Name: ${group.name}`);
|
||||
logger.logBoxLine(`Mode: ${group.mode}`);
|
||||
if (group.description) {
|
||||
logger.logBoxLine(`Description: ${group.description}`);
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Edit UPS assignments if requested
|
||||
const editAssignments = await prompt('Would you like to edit UPS assignments for this group? (y/N): ');
|
||||
if (editAssignments.toLowerCase() === 'y') {
|
||||
await this.assignUpsToGroup(group.id, config, prompt);
|
||||
|
||||
// Save again after editing assignments
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
}
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup edit complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Edit group error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing UPS group
|
||||
* @param groupId ID of the group to delete
|
||||
*/
|
||||
public async delete(groupId: string): Promise<void> {
|
||||
try {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error('No configuration found. Please run "nupst setup" first to create a configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if groups are initialized
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.error('No groups configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the group to delete
|
||||
const groupIndex = config.groups.findIndex(group => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupToDelete = config.groups[groupIndex];
|
||||
|
||||
// Get confirmation before deleting
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const confirm = await new Promise<string>(resolve => {
|
||||
rl.question(`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `, answer => {
|
||||
resolve(answer.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
rl.close();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove this group from all UPS device group assignments
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
||||
for (const ups of config.upsDevices) {
|
||||
const groupIndex = ups.groups.indexOf(groupId);
|
||||
if (groupIndex !== -1) {
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the group from the array
|
||||
config.groups.splice(groupIndex, 1);
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log(`Group "${groupToDelete.name}" (${groupId}) has been deleted.`);
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign UPS devices to groups
|
||||
* @param ups UPS configuration to update
|
||||
* @param groups Available groups
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async assignUpsToGroups(
|
||||
ups: any,
|
||||
groups: any[],
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
// Initialize groups array if it doesn't exist
|
||||
if (!ups.groups) {
|
||||
ups.groups = [];
|
||||
}
|
||||
|
||||
// Show current group assignments
|
||||
logger.log('\nCurrent Group Assignments:');
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
for (const groupId of ups.groups) {
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
if (group) {
|
||||
logger.log(`- ${group.name} (${group.id})`);
|
||||
} else {
|
||||
logger.log(`- Unknown group (${groupId})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.log('- None');
|
||||
}
|
||||
|
||||
// Show available groups
|
||||
logger.log('\nAvailable Groups:');
|
||||
if (groups.length === 0) {
|
||||
logger.log('- No groups available. Use "nupst group add" to create groups.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
const assigned = ups.groups && ups.groups.includes(group.id);
|
||||
logger.log(`${i + 1}) ${group.name} (${group.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`);
|
||||
}
|
||||
|
||||
// Prompt for group selection
|
||||
const selection = await prompt('\nSelect groups to assign/unassign (comma-separated numbers, or "clear" to remove all): ');
|
||||
|
||||
if (selection.toLowerCase() === 'clear') {
|
||||
// Clear all group assignments
|
||||
ups.groups = [];
|
||||
logger.log('All group assignments cleared.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.trim()) {
|
||||
// No change if empty input
|
||||
return;
|
||||
}
|
||||
|
||||
// Process selections
|
||||
const selections = selection.split(',').map(s => s.trim());
|
||||
|
||||
for (const sel of selections) {
|
||||
const index = parseInt(sel, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= groups.length) {
|
||||
logger.error(`Invalid selection: ${sel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = groups[index];
|
||||
|
||||
// Initialize groups array if needed (should already be done above)
|
||||
if (!ups.groups) {
|
||||
ups.groups = [];
|
||||
}
|
||||
|
||||
// Toggle assignment
|
||||
const groupIndex = ups.groups.indexOf(group.id);
|
||||
if (groupIndex === -1) {
|
||||
// Add to group
|
||||
ups.groups.push(group.id);
|
||||
logger.log(`Added to group: ${group.name} (${group.id})`);
|
||||
} else {
|
||||
// Remove from group
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
logger.log(`Removed from group: ${group.name} (${group.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign UPS devices to a specific group
|
||||
* @param groupId Group ID to assign UPS devices to
|
||||
* @param config Full configuration
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async assignUpsToGroup(
|
||||
groupId: string,
|
||||
config: any,
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
if (!config.upsDevices || config.upsDevices.length === 0) {
|
||||
logger.log('No UPS devices available. Use "nupst add" to add UPS devices.');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = config.groups.find(g => g.id === groupId);
|
||||
if (!group) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show current assignments
|
||||
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
|
||||
const upsInGroup = config.upsDevices.filter(ups => ups.groups && ups.groups.includes(groupId));
|
||||
if (upsInGroup.length === 0) {
|
||||
logger.log('- None');
|
||||
} else {
|
||||
for (const ups of upsInGroup) {
|
||||
logger.log(`- ${ups.name} (${ups.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show all UPS devices
|
||||
logger.log('\nAvailable UPS devices:');
|
||||
for (let i = 0; i < config.upsDevices.length; i++) {
|
||||
const ups = config.upsDevices[i];
|
||||
const assigned = ups.groups && ups.groups.includes(groupId);
|
||||
logger.log(`${i + 1}) ${ups.name} (${ups.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`);
|
||||
}
|
||||
|
||||
// Prompt for UPS selection
|
||||
const selection = await prompt('\nSelect UPS devices to assign/unassign (comma-separated numbers, or "clear" to remove all): ');
|
||||
|
||||
if (selection.toLowerCase() === 'clear') {
|
||||
// Clear all UPS from this group
|
||||
for (const ups of config.upsDevices) {
|
||||
if (ups.groups) {
|
||||
const groupIndex = ups.groups.indexOf(groupId);
|
||||
if (groupIndex !== -1) {
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.log(`All UPS devices removed from group "${group.name}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.trim()) {
|
||||
// No change if empty input
|
||||
return;
|
||||
}
|
||||
|
||||
// Process selections
|
||||
const selections = selection.split(',').map(s => s.trim());
|
||||
|
||||
for (const sel of selections) {
|
||||
const index = parseInt(sel, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= config.upsDevices.length) {
|
||||
logger.error(`Invalid selection: ${sel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ups = config.upsDevices[index];
|
||||
|
||||
// Initialize groups array if needed
|
||||
if (!ups.groups) {
|
||||
ups.groups = [];
|
||||
}
|
||||
|
||||
// Toggle assignment
|
||||
const groupIndex = ups.groups.indexOf(groupId);
|
||||
if (groupIndex === -1) {
|
||||
// Add to group
|
||||
ups.groups.push(groupId);
|
||||
logger.log(`Added "${ups.name}" to group "${group.name}"`);
|
||||
} else {
|
||||
// Remove from group
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
logger.log(`Removed "${ups.name}" from group "${group.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
320
ts/cli/service-handler.ts
Normal file
320
ts/cli/service-handler.ts
Normal file
@ -0,0 +1,320 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { Nupst } from '../nupst.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Class for handling service-related CLI commands
|
||||
* Provides interface for managing systemd service
|
||||
*/
|
||||
export class ServiceHandler {
|
||||
private readonly nupst: Nupst;
|
||||
|
||||
/**
|
||||
* Create a new Service handler
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
constructor(nupst: Nupst) {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the service (requires root)
|
||||
*/
|
||||
public async enable(): Promise<void> {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
await this.nupst.getSystemd().install();
|
||||
logger.log('NUPST service has been installed. Use "nupst start" to start the service.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon directly
|
||||
* @param debugMode Whether to enable debug mode
|
||||
*/
|
||||
public async daemonStart(debugMode: boolean = false): Promise<void> {
|
||||
logger.log('Starting NUPST daemon...');
|
||||
try {
|
||||
// Enable debug mode for SNMP if requested
|
||||
if (debugMode) {
|
||||
this.nupst.getSnmp().enableDebug();
|
||||
logger.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
|
||||
*/
|
||||
public async logs(): Promise<void> {
|
||||
try {
|
||||
// Use exec with spawn to properly follow logs in real-time
|
||||
const { spawn } = await import('child_process');
|
||||
logger.log('Tailing nupst service logs (Ctrl+C to exit)...\n');
|
||||
|
||||
const journalctl = spawn('journalctl', ['-u', 'nupst.service', '-n', '50', '-f'], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
// Forward signals to child process
|
||||
process.on('SIGINT', () => {
|
||||
journalctl.kill('SIGINT');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Wait for process to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
journalctl.on('exit', () => resolve());
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to retrieve logs: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the systemd service
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
await this.nupst.getSystemd().stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the systemd service
|
||||
*/
|
||||
public 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
|
||||
*/
|
||||
public async status(): Promise<void> {
|
||||
// Extract debug options from args array
|
||||
const debugOptions = this.extractDebugOptions(process.argv);
|
||||
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the service (requires root)
|
||||
*/
|
||||
public 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) {
|
||||
logger.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NUPST from repository and refresh systemd service
|
||||
*/
|
||||
public async update(): Promise<void> {
|
||||
try {
|
||||
// Check if running as root
|
||||
this.checkRootAccess(
|
||||
'This command must be run as root to update NUPST and refresh the systemd service.'
|
||||
);
|
||||
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('NUPST Update Process', boxWidth);
|
||||
logger.logBoxLine('Updating NUPST from repository...');
|
||||
|
||||
// Determine the installation directory (assuming it's either /opt/nupst or the current directory)
|
||||
const { existsSync } = await import('fs');
|
||||
let installDir = '/opt/nupst';
|
||||
|
||||
if (!existsSync(installDir)) {
|
||||
// If not installed in /opt/nupst, use the current directory
|
||||
const { dirname } = await import('path');
|
||||
installDir = dirname(dirname(process.argv[1])); // Go up two levels from the executable
|
||||
logger.logBoxLine(`Using local installation directory: ${installDir}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Update the repository
|
||||
logger.logBoxLine('Pulling latest changes from git repository...');
|
||||
execSync(`cd ${installDir} && git fetch origin && git reset --hard origin/main`, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// 2. Run the install.sh script
|
||||
logger.logBoxLine('Running install.sh to update NUPST...');
|
||||
execSync(`cd ${installDir} && bash ./install.sh`, { stdio: 'pipe' });
|
||||
|
||||
// 3. Run the setup.sh script with force flag to update Node.js and dependencies
|
||||
logger.logBoxLine('Running setup.sh to update Node.js and dependencies...');
|
||||
execSync(`cd ${installDir} && bash ./setup.sh --force`, { stdio: 'pipe' });
|
||||
|
||||
// 4. Refresh the systemd service
|
||||
logger.logBoxLine('Refreshing systemd service...');
|
||||
|
||||
// First check if service exists
|
||||
let serviceExists = false;
|
||||
try {
|
||||
const output = execSync('systemctl list-unit-files | grep nupst.service').toString();
|
||||
serviceExists = output.includes('nupst.service');
|
||||
} catch (error) {
|
||||
// If grep fails (service not found), serviceExists remains false
|
||||
serviceExists = false;
|
||||
}
|
||||
|
||||
if (serviceExists) {
|
||||
// Stop the service if it's running
|
||||
const isRunning =
|
||||
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
if (isRunning) {
|
||||
logger.logBoxLine('Stopping nupst service...');
|
||||
execSync('systemctl stop nupst.service');
|
||||
}
|
||||
|
||||
// Reinstall the service
|
||||
logger.logBoxLine('Reinstalling systemd service...');
|
||||
await this.nupst.getSystemd().install();
|
||||
|
||||
// Restart the service if it was running
|
||||
if (isRunning) {
|
||||
logger.logBoxLine('Restarting nupst service...');
|
||||
execSync('systemctl start nupst.service');
|
||||
}
|
||||
} else {
|
||||
logger.logBoxLine('Systemd service not installed, skipping service refresh.');
|
||||
logger.logBoxLine('Run "nupst enable" to install the service.');
|
||||
}
|
||||
|
||||
logger.logBoxLine('Update completed successfully!');
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
logger.logBoxLine('Error during update process:');
|
||||
logger.logBoxLine(`${error.message}`);
|
||||
logger.logBoxEnd();
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Update failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely uninstall NUPST from the system
|
||||
*/
|
||||
public async uninstall(): Promise<void> {
|
||||
// Check if running as root
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
|
||||
try {
|
||||
// Import readline module for user input
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
console.log('\nNUPST Uninstaller');
|
||||
console.log('===============');
|
||||
console.log('This will completely remove NUPST from your system.\n');
|
||||
|
||||
// Ask about removing configuration
|
||||
const removeConfig = await prompt(
|
||||
'Do you want to remove the NUPST configuration files? (y/N): '
|
||||
);
|
||||
|
||||
// Find the uninstall.sh script location
|
||||
let uninstallScriptPath: string;
|
||||
|
||||
// Try to determine script location based on executable path
|
||||
try {
|
||||
// For ESM, we can use import.meta.url, but since we might be in CJS
|
||||
// we'll use a more reliable approach based on process.argv[1]
|
||||
const binPath = process.argv[1];
|
||||
const { dirname, join } = await import('path');
|
||||
const modulePath = dirname(dirname(binPath));
|
||||
uninstallScriptPath = join(modulePath, 'uninstall.sh');
|
||||
|
||||
// Check if the script exists
|
||||
const { access } = await import('fs/promises');
|
||||
await access(uninstallScriptPath);
|
||||
} catch (error) {
|
||||
// If we can't find it in the expected location, try common installation paths
|
||||
const commonPaths = ['/opt/nupst/uninstall.sh', `${process.cwd()}/uninstall.sh`];
|
||||
const { existsSync } = await import('fs');
|
||||
|
||||
uninstallScriptPath = '';
|
||||
for (const path of commonPaths) {
|
||||
if (existsSync(path)) {
|
||||
uninstallScriptPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!uninstallScriptPath) {
|
||||
console.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Close readline before executing script
|
||||
rl.close();
|
||||
|
||||
// Execute uninstall.sh with the appropriate option
|
||||
console.log(`\nRunning uninstaller from ${uninstallScriptPath}...`);
|
||||
|
||||
// Pass the configuration removal option as an environment variable
|
||||
const env = {
|
||||
...process.env,
|
||||
REMOVE_CONFIG: removeConfig.toLowerCase() === 'y' ? 'yes' : 'no',
|
||||
REMOVE_REPO: 'yes', // Always remove repo as requested
|
||||
NUPST_CLI_CALL: 'true', // Flag to indicate this is being called from CLI
|
||||
};
|
||||
|
||||
// Run the uninstall script with sudo
|
||||
execSync(`sudo bash ${uninstallScriptPath}`, {
|
||||
env,
|
||||
stdio: 'inherit', // Show output in the terminal
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Uninstall failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and remove debug options from args array
|
||||
* @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 };
|
||||
}
|
||||
}
|
986
ts/cli/ups-handler.ts
Normal file
986
ts/cli/ups-handler.ts
Normal file
@ -0,0 +1,986 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { Nupst } from '../nupst.js';
|
||||
import { logger } from '../logger.js';
|
||||
import * as helpers from '../helpers/index.js';
|
||||
|
||||
/**
|
||||
* Class for handling UPS-related CLI commands
|
||||
* Provides interface for managing UPS devices
|
||||
*/
|
||||
export class UpsHandler {
|
||||
private readonly nupst: Nupst;
|
||||
|
||||
/**
|
||||
* Create a new UPS handler
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
constructor(nupst: Nupst) {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new UPS configuration
|
||||
*/
|
||||
public async add(): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
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.runAddProcess(prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Add UPS error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive process to add a new UPS
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async runAddProcess(prompt: (question: string) => Promise<string>): Promise<void> {
|
||||
logger.log('\nNUPST Add UPS');
|
||||
logger.log('=============\n');
|
||||
logger.log('This will guide you through configuring a new UPS.\n');
|
||||
|
||||
// Try to load existing config if available
|
||||
let config;
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Convert old format to new format if needed
|
||||
if (!config.upsDevices) {
|
||||
// Initialize with the current config as the first UPS
|
||||
config = {
|
||||
checkInterval: config.checkInterval,
|
||||
upsDevices: [{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds,
|
||||
groups: []
|
||||
}],
|
||||
groups: []
|
||||
};
|
||||
logger.log('Converting existing configuration to multi-UPS format.');
|
||||
}
|
||||
} catch (error) {
|
||||
// If config doesn't exist, initialize with empty config
|
||||
config = {
|
||||
checkInterval: 30000, // Default check interval
|
||||
upsDevices: [],
|
||||
groups: []
|
||||
};
|
||||
logger.log('No existing configuration found. Creating a new configuration.');
|
||||
}
|
||||
|
||||
// Get UPS ID and name
|
||||
const upsId = helpers.shortId();
|
||||
const name = await prompt('UPS Name: ');
|
||||
|
||||
// Create a new UPS configuration object with defaults
|
||||
const newUps = {
|
||||
id: upsId,
|
||||
name: name || `UPS-${upsId}`,
|
||||
snmp: {
|
||||
host: '127.0.0.1',
|
||||
port: 161,
|
||||
community: 'public',
|
||||
version: 1,
|
||||
timeout: 5000,
|
||||
upsModel: 'cyberpower'
|
||||
},
|
||||
thresholds: {
|
||||
battery: 60,
|
||||
runtime: 20
|
||||
},
|
||||
groups: []
|
||||
};
|
||||
|
||||
// Gather SNMP settings
|
||||
await this.gatherSnmpSettings(newUps.snmp, prompt);
|
||||
|
||||
// Gather threshold settings
|
||||
await this.gatherThresholdSettings(newUps.thresholds, prompt);
|
||||
|
||||
// Gather UPS model settings
|
||||
await this.gatherUpsModelSettings(newUps.snmp, prompt);
|
||||
|
||||
// Get access to GroupHandler for group assignments
|
||||
const groupHandler = this.nupst.getGroupHandler();
|
||||
|
||||
// Assign to groups if any exist
|
||||
if (config.groups && config.groups.length > 0) {
|
||||
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
||||
}
|
||||
|
||||
// Add the new UPS to the config
|
||||
config.upsDevices.push(newUps);
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
this.displayUpsConfigSummary(newUps);
|
||||
|
||||
// Test the connection if requested
|
||||
await this.optionallyTestConnection(newUps.snmp, prompt);
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
await this.restartServiceIfRunning();
|
||||
|
||||
logger.log('\nSetup complete!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing UPS configuration
|
||||
* @param upsId ID of the UPS to edit (undefined for default UPS)
|
||||
*/
|
||||
public async edit(upsId?: string): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
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.runEditProcess(upsId, prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Edit UPS error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive process to edit a UPS
|
||||
* @param upsId ID of the UPS to edit (undefined for default UPS)
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async runEditProcess(upsId: string | undefined, prompt: (question: string) => Promise<string>): Promise<void> {
|
||||
logger.log('\nNUPST Edit UPS');
|
||||
logger.log('=============\n');
|
||||
|
||||
// Try to load existing config
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
if (!upsId) {
|
||||
// For default UPS (no ID specified), run setup if no config exists
|
||||
logger.log('No existing configuration found. Running setup for new UPS.');
|
||||
await this.runAddProcess(prompt);
|
||||
return;
|
||||
} else {
|
||||
// For specific UPS ID, error if config doesn't exist
|
||||
logger.error('No configuration found. Please run "nupst setup" first.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the config
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Convert old format to new format if needed
|
||||
if (!config.upsDevices) {
|
||||
// Initialize with the current config as the first UPS
|
||||
config.upsDevices = [{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds,
|
||||
groups: []
|
||||
}];
|
||||
config.groups = [];
|
||||
logger.log('Converting existing configuration to multi-UPS format.');
|
||||
}
|
||||
|
||||
// Find the UPS to edit
|
||||
let upsToEdit;
|
||||
if (upsId) {
|
||||
// Find specific UPS by ID
|
||||
upsToEdit = config.upsDevices.find(ups => ups.id === upsId);
|
||||
if (!upsToEdit) {
|
||||
logger.error(`UPS with ID "${upsId}" not found.`);
|
||||
return;
|
||||
}
|
||||
logger.log(`Editing UPS: ${upsToEdit.name} (${upsToEdit.id})\n`);
|
||||
} else {
|
||||
// For backward compatibility, edit the first UPS if no ID specified
|
||||
if (config.upsDevices.length === 0) {
|
||||
logger.error('No UPS devices configured. Please run "nupst add" to add a UPS.');
|
||||
return;
|
||||
}
|
||||
upsToEdit = config.upsDevices[0];
|
||||
logger.log(`Editing default UPS: ${upsToEdit.name} (${upsToEdit.id})\n`);
|
||||
}
|
||||
|
||||
// Allow editing UPS name
|
||||
const newName = await prompt(`UPS Name [${upsToEdit.name}]: `);
|
||||
if (newName.trim()) {
|
||||
upsToEdit.name = newName;
|
||||
}
|
||||
|
||||
// Edit SNMP settings
|
||||
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
|
||||
|
||||
// Edit threshold settings
|
||||
await this.gatherThresholdSettings(upsToEdit.thresholds, prompt);
|
||||
|
||||
// Edit UPS model settings
|
||||
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
|
||||
|
||||
// Get access to GroupHandler for group assignments
|
||||
const groupHandler = this.nupst.getGroupHandler();
|
||||
|
||||
// Edit group assignments
|
||||
if (config.groups && config.groups.length > 0) {
|
||||
await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt);
|
||||
}
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
this.displayUpsConfigSummary(upsToEdit);
|
||||
|
||||
// Test the connection if requested
|
||||
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
await this.restartServiceIfRunning();
|
||||
|
||||
logger.log('\nEdit complete!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a UPS by ID
|
||||
* @param upsId ID of the UPS to delete
|
||||
*/
|
||||
public async delete(upsId: string): Promise<void> {
|
||||
try {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||
logger.logBoxLine('No configuration found.');
|
||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
||||
logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.');
|
||||
logger.log('Use "nupst add" to migrate to multi-UPS configuration format first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the UPS to delete
|
||||
const upsIndex = config.upsDevices.findIndex(ups => ups.id === upsId);
|
||||
if (upsIndex === -1) {
|
||||
logger.error(`UPS with ID "${upsId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const upsToDelete = config.upsDevices[upsIndex];
|
||||
|
||||
// Get confirmation before deleting
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const confirm = await new Promise<string>(resolve => {
|
||||
rl.question(`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `, answer => {
|
||||
resolve(answer.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
rl.close();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the UPS from the array
|
||||
config.upsDevices.splice(upsIndex, 1);
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log(`UPS "${upsToDelete.name}" (${upsId}) has been deleted.`);
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
await this.restartServiceIfRunning();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete UPS: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured UPS devices
|
||||
*/
|
||||
public async list(): Promise<void> {
|
||||
try {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||
logger.logBoxLine('No configuration found.');
|
||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
||||
// Legacy single UPS configuration
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
||||
logger.logBoxLine('Legacy single-UPS configuration detected.');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Default UPS:');
|
||||
logger.logBoxLine(` Host: ${config.snmp.host}:${config.snmp.port}`);
|
||||
logger.logBoxLine(` Model: ${config.snmp.upsModel || 'cyberpower'}`);
|
||||
logger.logBoxLine(` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Use "nupst add" to add more UPS devices and migrate');
|
||||
logger.logBoxLine('to the multi-UPS configuration format.');
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Display UPS list
|
||||
const boxWidth = 60;
|
||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
||||
|
||||
if (config.upsDevices.length === 0) {
|
||||
logger.logBoxLine('No UPS devices configured.');
|
||||
logger.logBoxLine('Use "nupst add" to add a UPS device.');
|
||||
} else {
|
||||
logger.logBoxLine(`Found ${config.upsDevices.length} UPS device(s)`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('ID | Name | Host | Mode | Groups');
|
||||
logger.logBoxLine('-----------+----------------------+-----------------+--------------+----------------');
|
||||
|
||||
for (const ups of config.upsDevices) {
|
||||
const id = ups.id.padEnd(10, ' ').substring(0, 10);
|
||||
const name = (ups.name || '').padEnd(20, ' ').substring(0, 20);
|
||||
const host = `${ups.snmp.host}:${ups.snmp.port}`.padEnd(15, ' ').substring(0, 15);
|
||||
const model = (ups.snmp.upsModel || 'cyberpower').padEnd(12, ' ').substring(0, 12);
|
||||
const groups = ups.groups.length > 0 ? ups.groups.join(', ') : 'None';
|
||||
|
||||
logger.logBoxLine(`${id} | ${name} | ${host} | ${model} | ${groups}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list UPS devices: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the current configuration by connecting to the UPS
|
||||
* @param debugMode Whether to enable debug mode
|
||||
*/
|
||||
public async test(debugMode: boolean = false): Promise<void> {
|
||||
try {
|
||||
// Debug mode is now handled in parseAndExecute
|
||||
if (debugMode) {
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Debug Mode', boxWidth);
|
||||
logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown');
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
|
||||
// Try to load the configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||
logger.logBoxLine('No configuration found.');
|
||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Handle new multi-UPS configuration format
|
||||
if (config.upsDevices && config.upsDevices.length > 0) {
|
||||
logger.log(`Found ${config.upsDevices.length} UPS devices in configuration.`);
|
||||
|
||||
for (let i = 0; i < config.upsDevices.length; i++) {
|
||||
const ups = config.upsDevices[i];
|
||||
logger.log(`\nTesting UPS: ${ups.name} (${ups.id})`);
|
||||
this.displayTestConfig(ups);
|
||||
await this.testConnection(ups);
|
||||
}
|
||||
} else {
|
||||
// Legacy configuration format
|
||||
this.displayTestConfig(config);
|
||||
await this.testConnection(config);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Test failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the configuration for testing
|
||||
* @param config Current configuration or individual UPS configuration
|
||||
*/
|
||||
private displayTestConfig(config: any): void {
|
||||
// Check if this is a UPS device or full configuration
|
||||
const isUpsConfig = config.snmp && config.thresholds;
|
||||
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
|
||||
const thresholds = isUpsConfig ? config.thresholds : config.thresholds || {};
|
||||
const checkInterval = config.checkInterval || 30000;
|
||||
|
||||
// Get UPS name and ID if available
|
||||
const upsName = config.name ? config.name : 'Default UPS';
|
||||
const upsId = config.id ? config.id : 'default';
|
||||
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
|
||||
logger.logBoxLine(`UPS ID: ${upsId}`);
|
||||
logger.logBoxLine('SNMP Settings:');
|
||||
logger.logBoxLine(` Host: ${snmpConfig.host}`);
|
||||
logger.logBoxLine(` Port: ${snmpConfig.port}`);
|
||||
logger.logBoxLine(` Version: ${snmpConfig.version}`);
|
||||
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
|
||||
|
||||
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
|
||||
logger.logBoxLine(` Community: ${snmpConfig.community}`);
|
||||
} else if (snmpConfig.version === 3) {
|
||||
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
|
||||
logger.logBoxLine(` Username: ${snmpConfig.username}`);
|
||||
|
||||
// Show auth and privacy details based on security level
|
||||
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
|
||||
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
|
||||
}
|
||||
|
||||
if (snmpConfig.securityLevel === 'authPriv') {
|
||||
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
|
||||
}
|
||||
|
||||
// Show timeout value
|
||||
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
|
||||
}
|
||||
|
||||
// Show OIDs if custom model is selected
|
||||
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
|
||||
logger.logBoxLine('Custom OIDs:');
|
||||
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
|
||||
logger.logBoxLine(` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`);
|
||||
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
||||
}
|
||||
logger.logBoxLine('Thresholds:');
|
||||
logger.logBoxLine(` Battery: ${thresholds.battery}%`);
|
||||
logger.logBoxLine(` Runtime: ${thresholds.runtime} minutes`);
|
||||
|
||||
// Show group assignments if this is a UPS config
|
||||
if (config.groups && Array.isArray(config.groups)) {
|
||||
logger.logBoxLine(`Group Assignments: ${config.groups.length === 0 ? 'None' : config.groups.join(', ')}`);
|
||||
}
|
||||
|
||||
logger.logBoxLine(`Check Interval: ${checkInterval / 1000} seconds`);
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to the UPS
|
||||
* @param config Current UPS configuration or legacy config
|
||||
*/
|
||||
private async testConnection(config: any): Promise<void> {
|
||||
const upsId = config.id || 'default';
|
||||
const upsName = config.name || 'Default UPS';
|
||||
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
|
||||
|
||||
try {
|
||||
// Create a test config with a short timeout
|
||||
const snmpConfig = config.snmp ? config.snmp : config.snmp;
|
||||
const thresholds = config.thresholds ? config.thresholds : config.thresholds;
|
||||
|
||||
const testConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
||||
};
|
||||
|
||||
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
|
||||
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
|
||||
logger.logBoxLine('UPS Status:');
|
||||
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
|
||||
logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Check status against thresholds if on battery
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
this.analyzeThresholds(status, thresholds);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
||||
logger.logBoxLine(`Error: ${error.message}`);
|
||||
logger.logBoxEnd();
|
||||
logger.log("\nPlease check your settings and run 'nupst edit' to reconfigure this UPS.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze UPS status against thresholds
|
||||
* @param status UPS status
|
||||
* @param thresholds Threshold configuration
|
||||
*/
|
||||
private analyzeThresholds(status: any, thresholds: any): void {
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Threshold Analysis', boxWidth);
|
||||
|
||||
if (status.batteryCapacity < thresholds.battery) {
|
||||
logger.logBoxLine('⚠️ WARNING: Battery capacity below threshold');
|
||||
logger.logBoxLine(
|
||||
` Current: ${status.batteryCapacity}% | Threshold: ${thresholds.battery}%`
|
||||
);
|
||||
logger.logBoxLine(' System would initiate shutdown');
|
||||
} else {
|
||||
logger.logBoxLine('✓ Battery capacity above threshold');
|
||||
logger.logBoxLine(
|
||||
` Current: ${status.batteryCapacity}% | Threshold: ${thresholds.battery}%`
|
||||
);
|
||||
}
|
||||
|
||||
if (status.batteryRuntime < thresholds.runtime) {
|
||||
logger.logBoxLine('⚠️ WARNING: Runtime below threshold');
|
||||
logger.logBoxLine(
|
||||
` Current: ${status.batteryRuntime} min | Threshold: ${thresholds.runtime} min`
|
||||
);
|
||||
logger.logBoxLine(' System would initiate shutdown');
|
||||
} else {
|
||||
logger.logBoxLine('✓ Runtime above threshold');
|
||||
logger.logBoxLine(
|
||||
` Current: ${status.batteryRuntime} min | Threshold: ${thresholds.runtime} min`
|
||||
);
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather SNMP settings
|
||||
* @param snmpConfig SNMP configuration object to update
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherSnmpSettings(
|
||||
snmpConfig: any,
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
// SNMP IP Address
|
||||
const defaultHost = snmpConfig.host || '127.0.0.1';
|
||||
const host = await prompt(`UPS IP Address [${defaultHost}]: `);
|
||||
snmpConfig.host = host.trim() || defaultHost;
|
||||
|
||||
// SNMP Port
|
||||
const defaultPort = snmpConfig.port || 161;
|
||||
const portInput = await prompt(`SNMP Port [${defaultPort}]: `);
|
||||
const port = parseInt(portInput, 10);
|
||||
snmpConfig.port = portInput.trim() && !isNaN(port) ? port : defaultPort;
|
||||
|
||||
// SNMP Version
|
||||
const defaultVersion = snmpConfig.version || 1;
|
||||
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);
|
||||
snmpConfig.version =
|
||||
versionInput.trim() && (version === 1 || version === 2 || version === 3)
|
||||
? version
|
||||
: defaultVersion;
|
||||
|
||||
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
|
||||
// SNMP Community String (for v1/v2c)
|
||||
const defaultCommunity = snmpConfig.community || 'public';
|
||||
const community = await prompt(`SNMP Community String [${defaultCommunity}]: `);
|
||||
snmpConfig.community = community.trim() || defaultCommunity;
|
||||
} else if (snmpConfig.version === 3) {
|
||||
// SNMP v3 settings
|
||||
await this.gatherSnmpV3Settings(snmpConfig, prompt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather SNMPv3 specific settings
|
||||
* @param snmpConfig SNMP configuration object to update
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherSnmpV3Settings(
|
||||
snmpConfig: any,
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
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 = snmpConfig.securityLevel
|
||||
? snmpConfig.securityLevel === 'noAuthNoPriv'
|
||||
? 1
|
||||
: snmpConfig.securityLevel === 'authNoPriv'
|
||||
? 2
|
||||
: 3
|
||||
: 3;
|
||||
const secLevelInput = await prompt(`Select Security Level [${defaultSecLevel}]: `);
|
||||
const secLevel = parseInt(secLevelInput, 10) || defaultSecLevel;
|
||||
|
||||
if (secLevel === 1) {
|
||||
snmpConfig.securityLevel = 'noAuthNoPriv';
|
||||
// No auth, no priv - clear out authentication and privacy settings
|
||||
snmpConfig.authProtocol = '';
|
||||
snmpConfig.authKey = '';
|
||||
snmpConfig.privProtocol = '';
|
||||
snmpConfig.privKey = '';
|
||||
// Set appropriate timeout for security level
|
||||
snmpConfig.timeout = 5000; // 5 seconds for basic security
|
||||
} else if (secLevel === 2) {
|
||||
snmpConfig.securityLevel = 'authNoPriv';
|
||||
// Auth, no priv - clear out privacy settings
|
||||
snmpConfig.privProtocol = '';
|
||||
snmpConfig.privKey = '';
|
||||
// Set appropriate timeout for security level
|
||||
snmpConfig.timeout = 10000; // 10 seconds for authentication
|
||||
} else {
|
||||
snmpConfig.securityLevel = 'authPriv';
|
||||
// Set appropriate timeout for security level
|
||||
snmpConfig.timeout = 15000; // 15 seconds for full encryption
|
||||
}
|
||||
|
||||
// Username
|
||||
const defaultUsername = snmpConfig.username || '';
|
||||
const username = await prompt(`SNMPv3 Username [${defaultUsername}]: `);
|
||||
snmpConfig.username = username.trim() || defaultUsername;
|
||||
|
||||
if (secLevel >= 2) {
|
||||
// Authentication settings
|
||||
await this.gatherAuthenticationSettings(snmpConfig, prompt);
|
||||
|
||||
if (secLevel === 3) {
|
||||
// Privacy settings
|
||||
await this.gatherPrivacySettings(snmpConfig, prompt);
|
||||
}
|
||||
|
||||
// Allow customizing the timeout value
|
||||
const defaultTimeout = snmpConfig.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)) {
|
||||
snmpConfig.timeout = timeout * 1000; // Convert to ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather authentication settings for SNMPv3
|
||||
* @param snmpConfig SNMP configuration object to update
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherAuthenticationSettings(
|
||||
snmpConfig: any,
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
// Authentication protocol
|
||||
console.log('\nAuthentication Protocol:');
|
||||
console.log(' 1) MD5');
|
||||
console.log(' 2) SHA');
|
||||
const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1;
|
||||
const authProtocolInput = await prompt(
|
||||
`Select Authentication Protocol [${defaultAuthProtocol}]: `
|
||||
);
|
||||
const authProtocol = parseInt(authProtocolInput, 10) || defaultAuthProtocol;
|
||||
snmpConfig.authProtocol = authProtocol === 2 ? 'SHA' : 'MD5';
|
||||
|
||||
// Authentication Key/Password
|
||||
const defaultAuthKey = snmpConfig.authKey || '';
|
||||
const authKey = await prompt(`Authentication Password ${defaultAuthKey ? '[*****]' : ''}: `);
|
||||
snmpConfig.authKey = authKey.trim() || defaultAuthKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather privacy settings for SNMPv3
|
||||
* @param snmpConfig SNMP configuration object to update
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherPrivacySettings(
|
||||
snmpConfig: any,
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
// Privacy protocol
|
||||
console.log('\nPrivacy Protocol:');
|
||||
console.log(' 1) DES');
|
||||
console.log(' 2) AES');
|
||||
const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1;
|
||||
const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `);
|
||||
const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol;
|
||||
snmpConfig.privProtocol = privProtocol === 2 ? 'AES' : 'DES';
|
||||
|
||||
// Privacy Key/Password
|
||||
const defaultPrivKey = snmpConfig.privKey || '';
|
||||
const privKey = await prompt(`Privacy Password ${defaultPrivKey ? '[*****]' : ''}: `);
|
||||
snmpConfig.privKey = privKey.trim() || defaultPrivKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather threshold settings
|
||||
* @param thresholds Thresholds configuration object to update
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherThresholdSettings(
|
||||
thresholds: any,
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
console.log('\nShutdown Thresholds:');
|
||||
|
||||
// Battery threshold
|
||||
const defaultBatteryThreshold = thresholds.battery || 60;
|
||||
const batteryThresholdInput = await prompt(
|
||||
`Battery percentage threshold [${defaultBatteryThreshold}%]: `
|
||||
);
|
||||
const batteryThreshold = parseInt(batteryThresholdInput, 10);
|
||||
thresholds.battery =
|
||||
batteryThresholdInput.trim() && !isNaN(batteryThreshold)
|
||||
? batteryThreshold
|
||||
: defaultBatteryThreshold;
|
||||
|
||||
// Runtime threshold
|
||||
const defaultRuntimeThreshold = thresholds.runtime || 20;
|
||||
const runtimeThresholdInput = await prompt(
|
||||
`Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `
|
||||
);
|
||||
const runtimeThreshold = parseInt(runtimeThresholdInput, 10);
|
||||
thresholds.runtime =
|
||||
runtimeThresholdInput.trim() && !isNaN(runtimeThreshold)
|
||||
? runtimeThreshold
|
||||
: defaultRuntimeThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather UPS model settings
|
||||
* @param snmpConfig SNMP configuration object to update
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherUpsModelSettings(
|
||||
snmpConfig: any,
|
||||
prompt: (question: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
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 =
|
||||
snmpConfig.upsModel === 'cyberpower'
|
||||
? 1
|
||||
: snmpConfig.upsModel === 'apc'
|
||||
? 2
|
||||
: snmpConfig.upsModel === 'eaton'
|
||||
? 3
|
||||
: snmpConfig.upsModel === 'tripplite'
|
||||
? 4
|
||||
: snmpConfig.upsModel === 'liebert'
|
||||
? 5
|
||||
: snmpConfig.upsModel === 'custom'
|
||||
? 6
|
||||
: 1;
|
||||
|
||||
const modelInput = await prompt(`Select UPS model [${defaultModelValue}]: `);
|
||||
const modelValue = parseInt(modelInput, 10) || defaultModelValue;
|
||||
|
||||
if (modelValue === 1) {
|
||||
snmpConfig.upsModel = 'cyberpower';
|
||||
} else if (modelValue === 2) {
|
||||
snmpConfig.upsModel = 'apc';
|
||||
} else if (modelValue === 3) {
|
||||
snmpConfig.upsModel = 'eaton';
|
||||
} else if (modelValue === 4) {
|
||||
snmpConfig.upsModel = 'tripplite';
|
||||
} else if (modelValue === 5) {
|
||||
snmpConfig.upsModel = 'liebert';
|
||||
} else if (modelValue === 6) {
|
||||
snmpConfig.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
|
||||
snmpConfig.customOIDs = {
|
||||
POWER_STATUS: powerStatusOID.trim(),
|
||||
BATTERY_CAPACITY: batteryCapacityOID.trim(),
|
||||
BATTERY_RUNTIME: batteryRuntimeOID.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display UPS configuration summary
|
||||
* @param ups UPS configuration
|
||||
*/
|
||||
private displayUpsConfigSummary(ups: any): void {
|
||||
const boxWidth = 45;
|
||||
logger.log('');
|
||||
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
|
||||
logger.logBoxLine(`UPS ID: ${ups.id}`);
|
||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||
logger.logBoxLine(
|
||||
`Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`
|
||||
);
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
||||
} else {
|
||||
logger.logBoxLine('Groups: None');
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally test connection to UPS
|
||||
* @param snmpConfig SNMP configuration to test
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async optionallyTestConnection(
|
||||
snmpConfig: 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') {
|
||||
logger.log('\nTesting connection to UPS...');
|
||||
try {
|
||||
// Create a test config with a short timeout
|
||||
const testConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
||||
};
|
||||
|
||||
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
|
||||
const boxWidth = 45;
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Connection Successful!', boxWidth);
|
||||
logger.logBoxLine('UPS Status:');
|
||||
logger.logBoxLine(`✓ Power Status: ${status.powerStatus}`);
|
||||
logger.logBoxLine(`✓ Battery Capacity: ${status.batteryCapacity}%`);
|
||||
logger.logBoxLine(`✓ Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Connection Failed!', errorBoxWidth);
|
||||
logger.logBoxLine(`Error: ${error.message}`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('\nPlease check your settings and try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the systemd service is running and restart it if it is
|
||||
* This is useful after configuration changes
|
||||
*/
|
||||
public async restartServiceIfRunning(): Promise<void> {
|
||||
try {
|
||||
// Check if the service is active
|
||||
const isActive =
|
||||
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
|
||||
if (isActive) {
|
||||
// Service is running, restart it
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Service Update', boxWidth);
|
||||
logger.logBoxLine('Configuration has changed.');
|
||||
logger.logBoxLine('Restarting NUPST service to apply changes...');
|
||||
|
||||
try {
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
// We have root access, restart directly
|
||||
execSync('systemctl restart nupst.service');
|
||||
logger.logBoxLine('Service restarted successfully.');
|
||||
} else {
|
||||
// No root access, show instructions
|
||||
logger.logBoxLine('Please restart the service with:');
|
||||
logger.logBoxLine(' sudo systemctl restart nupst.service');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.logBoxLine(`Error restarting service: ${error.message}`);
|
||||
logger.logBoxLine('You may need to restart the service manually:');
|
||||
logger.logBoxLine(' sudo systemctl restart nupst.service');
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors checking service status
|
||||
}
|
||||
}
|
||||
}
|
693
ts/daemon.ts
693
ts/daemon.ts
@ -1,13 +1,24 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { NupstSnmp, type SnmpConfig } from './snmp.js';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { NupstSnmp } from './snmp/manager.js';
|
||||
import type { ISnmpConfig } from './snmp/types.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Configuration interface for the daemon
|
||||
* UPS configuration interface
|
||||
*/
|
||||
export interface NupstConfig {
|
||||
export interface IUpsConfig {
|
||||
/** Unique ID for the UPS */
|
||||
id: string;
|
||||
/** Friendly name for the UPS */
|
||||
name: string;
|
||||
/** SNMP configuration settings */
|
||||
snmp: SnmpConfig;
|
||||
snmp: ISnmpConfig;
|
||||
/** Threshold settings for initiating shutdown */
|
||||
thresholds: {
|
||||
/** Shutdown when battery below this percentage */
|
||||
@ -15,8 +26,58 @@ export interface NupstConfig {
|
||||
/** Shutdown when runtime below this minutes */
|
||||
runtime: number;
|
||||
};
|
||||
/** Group IDs this UPS belongs to */
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group configuration interface
|
||||
*/
|
||||
export interface IGroupConfig {
|
||||
/** Unique ID for the group */
|
||||
id: string;
|
||||
/** Friendly name for the group */
|
||||
name: string;
|
||||
/** Group operation mode */
|
||||
mode: 'redundant' | 'nonRedundant';
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration interface for the daemon
|
||||
*/
|
||||
export interface INupstConfig {
|
||||
/** UPS devices configuration */
|
||||
upsDevices: IUpsConfig[];
|
||||
/** Groups configuration */
|
||||
groups: IGroupConfig[];
|
||||
/** Check interval in milliseconds */
|
||||
checkInterval: number;
|
||||
|
||||
// Legacy fields for backward compatibility
|
||||
/** SNMP configuration settings (legacy) */
|
||||
snmp?: ISnmpConfig;
|
||||
/** Threshold settings (legacy) */
|
||||
thresholds?: {
|
||||
/** Shutdown when battery below this percentage */
|
||||
battery: number;
|
||||
/** Shutdown when runtime below this minutes */
|
||||
runtime: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UPS status tracking interface
|
||||
*/
|
||||
interface IUpsStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
batteryCapacity: number;
|
||||
batteryRuntime: number;
|
||||
lastStatusChange: number;
|
||||
lastCheckTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,7 +89,11 @@ export class NupstDaemon {
|
||||
private readonly CONFIG_PATH = '/etc/nupst/config.json';
|
||||
|
||||
/** Default configuration */
|
||||
private readonly DEFAULT_CONFIG: NupstConfig = {
|
||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||
upsDevices: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: {
|
||||
host: '127.0.0.1',
|
||||
port: 161,
|
||||
@ -49,12 +114,17 @@ export class NupstDaemon {
|
||||
battery: 60, // Shutdown when battery below 60%
|
||||
runtime: 20, // Shutdown when runtime below 20 minutes
|
||||
},
|
||||
groups: []
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
checkInterval: 30000, // Check every 30 seconds
|
||||
};
|
||||
|
||||
private config: NupstConfig;
|
||||
private config: INupstConfig;
|
||||
private snmp: NupstSnmp;
|
||||
private isRunning: boolean = false;
|
||||
private upsStatus: Map<string, IUpsStatus> = new Map();
|
||||
|
||||
/**
|
||||
* Create a new daemon instance with the given SNMP manager
|
||||
@ -68,7 +138,7 @@ export class NupstDaemon {
|
||||
* Load configuration from file
|
||||
* @throws Error if configuration file doesn't exist
|
||||
*/
|
||||
public async loadConfig(): Promise<NupstConfig> {
|
||||
public async loadConfig(): Promise<INupstConfig> {
|
||||
try {
|
||||
// Check if config file exists
|
||||
const configExists = fs.existsSync(this.CONFIG_PATH);
|
||||
@ -80,10 +150,36 @@ export class NupstDaemon {
|
||||
|
||||
// Read and parse config
|
||||
const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8');
|
||||
this.config = JSON.parse(configData);
|
||||
const parsedConfig = JSON.parse(configData);
|
||||
|
||||
// Handle legacy configuration format
|
||||
if (!parsedConfig.upsDevices && parsedConfig.snmp) {
|
||||
// Convert legacy format to new format
|
||||
this.config = {
|
||||
upsDevices: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: parsedConfig.snmp,
|
||||
thresholds: parsedConfig.thresholds,
|
||||
groups: []
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
checkInterval: parsedConfig.checkInterval
|
||||
};
|
||||
|
||||
logger.log('Legacy configuration format detected. Converting to multi-UPS format.');
|
||||
|
||||
// Save the new format
|
||||
await this.saveConfig(this.config);
|
||||
} else {
|
||||
this.config = parsedConfig;
|
||||
}
|
||||
|
||||
return this.config;
|
||||
} catch (error) {
|
||||
if (error.message.includes('No configuration found')) {
|
||||
if (error.message && error.message.includes('No configuration found')) {
|
||||
throw error; // Re-throw the no configuration error
|
||||
}
|
||||
|
||||
@ -95,7 +191,7 @@ export class NupstDaemon {
|
||||
/**
|
||||
* Save configuration to file
|
||||
*/
|
||||
public async saveConfig(config: NupstConfig): Promise<void> {
|
||||
public async saveConfig(config: INupstConfig): Promise<void> {
|
||||
try {
|
||||
const configDir = path.dirname(this.CONFIG_PATH);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
@ -119,13 +215,13 @@ export class NupstDaemon {
|
||||
console.error('┌─ Configuration Error ─────────────────────┐');
|
||||
console.error(`│ ${message}`);
|
||||
console.error('│ Please run \'nupst setup\' first to create a configuration.');
|
||||
console.error('└──────────────────────────────────────────┘');
|
||||
console.error('└───────────────────────────────────────────┘');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current configuration
|
||||
*/
|
||||
public getConfig(): NupstConfig {
|
||||
public getConfig(): INupstConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
@ -141,11 +237,11 @@ export class NupstDaemon {
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
console.log('Daemon is already running');
|
||||
logger.log('Daemon is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting NUPST daemon...');
|
||||
logger.log('Starting NUPST daemon...');
|
||||
|
||||
try {
|
||||
// Load configuration - this will throw an error if config doesn't exist
|
||||
@ -159,45 +255,87 @@ export class NupstDaemon {
|
||||
this.snmp.getNupst().checkForUpdates().then(updateAvailable => {
|
||||
if (updateAvailable) {
|
||||
const updateStatus = this.snmp.getNupst().getUpdateStatus();
|
||||
console.log('┌─ Update Available ───────────────────────┐');
|
||||
console.log(`│ Current Version: ${updateStatus.currentVersion}`);
|
||||
console.log(`│ Latest Version: ${updateStatus.latestVersion}`);
|
||||
console.log('│ Run "sudo nupst update" to update');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Update Available', boxWidth);
|
||||
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
|
||||
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}).catch(() => {}); // Ignore errors checking for updates
|
||||
|
||||
// Initialize UPS status tracking
|
||||
this.initializeUpsStatus();
|
||||
|
||||
// Start UPS monitoring
|
||||
this.isRunning = true;
|
||||
await this.monitor();
|
||||
} catch (error) {
|
||||
this.isRunning = false;
|
||||
console.error(`Daemon failed to start: ${error.message}`);
|
||||
logger.error(`Daemon failed to start: ${error.message}`);
|
||||
process.exit(1); // Exit with error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize UPS status tracking for all UPS devices
|
||||
*/
|
||||
private initializeUpsStatus(): void {
|
||||
this.upsStatus.clear();
|
||||
|
||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||
for (const ups of this.config.upsDevices) {
|
||||
this.upsStatus.set(ups.id, {
|
||||
id: ups.id,
|
||||
name: ups.name,
|
||||
powerStatus: 'unknown',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 999, // High value as default
|
||||
lastStatusChange: Date.now(),
|
||||
lastCheckTime: 0
|
||||
});
|
||||
}
|
||||
|
||||
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
|
||||
} else {
|
||||
logger.error('No UPS devices found in configuration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 50;
|
||||
logger.logBoxTitle('Configuration Loaded', boxWidth);
|
||||
|
||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||
logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
|
||||
for (const ups of this.config.upsDevices) {
|
||||
logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
}
|
||||
} else {
|
||||
logger.logBoxLine('No UPS devices configured');
|
||||
}
|
||||
|
||||
if (this.config.groups && this.config.groups.length > 0) {
|
||||
logger.logBoxLine(`Groups: ${this.config.groups.length}`);
|
||||
for (const group of this.config.groups) {
|
||||
logger.logBoxLine(` - ${group.name} (${group.id}): ${group.mode} mode`);
|
||||
}
|
||||
} else {
|
||||
logger.logBoxLine('No Groups configured');
|
||||
}
|
||||
|
||||
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the monitoring daemon
|
||||
*/
|
||||
public stop(): void {
|
||||
console.log('Stopping NUPST daemon...');
|
||||
logger.log('Stopping NUPST daemon...');
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
@ -205,81 +343,482 @@ export class NupstDaemon {
|
||||
* Monitor the UPS status and trigger shutdown when necessary
|
||||
*/
|
||||
private async monitor(): Promise<void> {
|
||||
console.log('Starting UPS monitoring...');
|
||||
logger.log('Starting UPS monitoring...');
|
||||
|
||||
if (!this.config.upsDevices || this.config.upsDevices.length === 0) {
|
||||
logger.error('No UPS devices found in configuration. Monitoring stopped.');
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let lastStatus: 'online' | 'onBattery' | 'unknown' = 'unknown';
|
||||
let lastLogTime = 0; // Track when we last logged status
|
||||
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
|
||||
|
||||
// Monitor continuously
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const status = await this.snmp.getUpsStatus(this.config.snmp);
|
||||
const currentTime = Date.now();
|
||||
const shouldLogStatus = (currentTime - lastLogTime) >= LOG_INTERVAL;
|
||||
// Check all UPS devices
|
||||
await this.checkAllUpsDevices();
|
||||
|
||||
// Log status changes
|
||||
if (status.powerStatus !== lastStatus) {
|
||||
console.log('┌──────────────────────────────────────────┐');
|
||||
console.log(`│ Power status changed: ${lastStatus} → ${status.powerStatus}`);
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
lastStatus = status.powerStatus;
|
||||
lastLogTime = currentTime; // Reset log timer when status changes
|
||||
}
|
||||
// Log status periodically (at least every 5 minutes)
|
||||
else if (shouldLogStatus) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log('┌──────────────────────────────────────────┐');
|
||||
console.log(`│ [${timestamp}] Periodic Status Update`);
|
||||
console.log(`│ Power Status: ${status.powerStatus}`);
|
||||
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
// Log periodic status update
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastLogTime >= LOG_INTERVAL) {
|
||||
this.logAllUpsStatus();
|
||||
lastLogTime = currentTime;
|
||||
}
|
||||
|
||||
// Handle battery power status
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
await this.handleOnBatteryStatus(status);
|
||||
}
|
||||
// Check if shutdown is required based on group configurations
|
||||
await this.evaluateGroupShutdownConditions();
|
||||
|
||||
// Wait before next check
|
||||
await this.sleep(this.config.checkInterval);
|
||||
} catch (error) {
|
||||
console.error('Error during UPS monitoring:', error);
|
||||
logger.error(`Error during UPS monitoring: ${error.message}`);
|
||||
await this.sleep(this.config.checkInterval);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('UPS monitoring stopped');
|
||||
logger.log('UPS monitoring stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle UPS status when running on battery
|
||||
* Check status of all UPS devices
|
||||
*/
|
||||
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('└──────────────────────────────────────────┘');
|
||||
private async checkAllUpsDevices(): Promise<void> {
|
||||
for (const ups of this.config.upsDevices) {
|
||||
try {
|
||||
const upsStatus = this.upsStatus.get(ups.id);
|
||||
if (!upsStatus) {
|
||||
// Initialize status for this UPS if not exists
|
||||
this.upsStatus.set(ups.id, {
|
||||
id: ups.id,
|
||||
name: ups.name,
|
||||
powerStatus: 'unknown',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 999,
|
||||
lastStatusChange: Date.now(),
|
||||
lastCheckTime: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 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');
|
||||
// Check UPS status
|
||||
const status = await this.snmp.getUpsStatus(ups.snmp);
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Get the current status from the map
|
||||
const currentStatus = this.upsStatus.get(ups.id);
|
||||
|
||||
// Update status with new values
|
||||
const updatedStatus = {
|
||||
...currentStatus,
|
||||
powerStatus: status.powerStatus,
|
||||
batteryCapacity: status.batteryCapacity,
|
||||
batteryRuntime: status.batteryRuntime,
|
||||
lastCheckTime: currentTime
|
||||
};
|
||||
|
||||
// Check if power status changed
|
||||
if (currentStatus.powerStatus !== status.powerStatus) {
|
||||
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50);
|
||||
logger.logBoxLine(`Status changed: ${currentStatus.powerStatus} → ${status.powerStatus}`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
}
|
||||
|
||||
// Update the status in the map
|
||||
this.upsStatus.set(ups.id, updatedStatus);
|
||||
} catch (error) {
|
||||
logger.error(`Error checking UPS ${ups.name} (${ups.id}): ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log status of all UPS devices
|
||||
*/
|
||||
private logAllUpsStatus(): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const boxWidth = 60;
|
||||
logger.logBoxTitle('Periodic Status Update', boxWidth);
|
||||
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
||||
logger.logBoxLine('');
|
||||
|
||||
for (const [id, status] of this.upsStatus.entries()) {
|
||||
logger.logBoxLine(`UPS: ${status.name} (${id})`);
|
||||
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
|
||||
logger.logBoxLine(` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
|
||||
logger.logBoxLine('');
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if shutdown is required based on group configurations
|
||||
*/
|
||||
private async evaluateGroupShutdownConditions(): Promise<void> {
|
||||
if (!this.config.groups || this.config.groups.length === 0) {
|
||||
// No groups defined, check individual UPS conditions
|
||||
for (const [id, status] of this.upsStatus.entries()) {
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
// Find the UPS config
|
||||
const ups = this.config.upsDevices.find(u => u.id === id);
|
||||
if (ups) {
|
||||
await this.evaluateUpsShutdownCondition(ups, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
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');
|
||||
// Evaluate each group
|
||||
for (const group of this.config.groups) {
|
||||
// Find all UPS devices in this group
|
||||
const upsDevicesInGroup = this.config.upsDevices.filter(ups =>
|
||||
ups.groups && ups.groups.includes(group.id)
|
||||
);
|
||||
|
||||
if (upsDevicesInGroup.length === 0) {
|
||||
// No UPS devices in this group
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.mode === 'redundant') {
|
||||
// Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition
|
||||
await this.evaluateRedundantGroup(group, upsDevicesInGroup);
|
||||
} else {
|
||||
// Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition
|
||||
await this.evaluateNonRedundantGroup(group, upsDevicesInGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a redundant group for shutdown conditions
|
||||
* In redundant mode, we only shut down if ALL UPS devices are in critical condition
|
||||
*/
|
||||
private async evaluateRedundantGroup(group: IGroupConfig, upsDevices: IUpsConfig[]): Promise<void> {
|
||||
// Count UPS devices on battery and in critical condition
|
||||
let upsOnBattery = 0;
|
||||
let upsInCriticalCondition = 0;
|
||||
|
||||
for (const ups of upsDevices) {
|
||||
const status = this.upsStatus.get(ups.id);
|
||||
if (!status) continue;
|
||||
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
upsOnBattery++;
|
||||
|
||||
// Check if this UPS is in critical condition
|
||||
if (status.batteryCapacity < ups.thresholds.battery ||
|
||||
status.batteryRuntime < ups.thresholds.runtime) {
|
||||
upsInCriticalCondition++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All UPS devices must be online for a redundant group to be considered healthy
|
||||
const allUpsCount = upsDevices.length;
|
||||
|
||||
// If all UPS are on battery and in critical condition, shutdown
|
||||
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
|
||||
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
|
||||
logger.logBoxLine(`Mode: Redundant`);
|
||||
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
await this.initiateShutdown(`All UPS devices in redundant group "${group.name}" in critical condition`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a non-redundant group for shutdown conditions
|
||||
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
|
||||
*/
|
||||
private async evaluateNonRedundantGroup(group: IGroupConfig, upsDevices: IUpsConfig[]): Promise<void> {
|
||||
for (const ups of upsDevices) {
|
||||
const status = this.upsStatus.get(ups.id);
|
||||
if (!status) continue;
|
||||
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
// Check if this UPS is in critical condition
|
||||
if (status.batteryCapacity < ups.thresholds.battery ||
|
||||
status.batteryRuntime < ups.thresholds.runtime) {
|
||||
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
|
||||
logger.logBoxLine(`Mode: Non-Redundant`);
|
||||
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
|
||||
logger.logBoxLine(`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`);
|
||||
logger.logBoxLine(`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
await this.initiateShutdown(`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`);
|
||||
return; // Exit after initiating shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate an individual UPS for shutdown conditions
|
||||
*/
|
||||
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
|
||||
// Only evaluate UPS devices not in any group
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check threshold conditions
|
||||
if (status.batteryCapacity < ups.thresholds.battery ||
|
||||
status.batteryRuntime < ups.thresholds.runtime) {
|
||||
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
|
||||
logger.logBoxLine(`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`);
|
||||
logger.logBoxLine(`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate system shutdown with UPS monitoring during shutdown
|
||||
* @param reason Reason for shutdown
|
||||
*/
|
||||
public async initiateShutdown(reason: string): Promise<void> {
|
||||
logger.log(`Initiating system shutdown due to: ${reason}`);
|
||||
|
||||
// Set a longer delay for shutdown to allow VMs and services to close
|
||||
const shutdownDelayMinutes = 5;
|
||||
|
||||
try {
|
||||
// Find shutdown command in common system paths
|
||||
const shutdownPaths = [
|
||||
'/sbin/shutdown',
|
||||
'/usr/sbin/shutdown',
|
||||
'/bin/shutdown',
|
||||
'/usr/bin/shutdown'
|
||||
];
|
||||
|
||||
let shutdownCmd = '';
|
||||
for (const path of shutdownPaths) {
|
||||
try {
|
||||
if (fs.existsSync(path)) {
|
||||
shutdownCmd = path;
|
||||
logger.log(`Found shutdown command at: ${shutdownCmd}`);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue checking other paths
|
||||
}
|
||||
}
|
||||
|
||||
if (shutdownCmd) {
|
||||
// Execute shutdown command with delay to allow for VM graceful shutdown
|
||||
logger.log(`Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`);
|
||||
const { stdout } = await execFileAsync(shutdownCmd, [
|
||||
'-h',
|
||||
`+${shutdownDelayMinutes}`,
|
||||
`UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`
|
||||
]);
|
||||
logger.log(`Shutdown initiated: ${stdout}`);
|
||||
logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
|
||||
} else {
|
||||
// Try using the PATH to find shutdown
|
||||
try {
|
||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
||||
const { stdout } = await execAsync(`shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`, {
|
||||
env: process.env // Pass the current environment
|
||||
});
|
||||
logger.log(`Shutdown initiated: ${stdout}`);
|
||||
} catch (e) {
|
||||
throw new Error(`Shutdown command not found: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low
|
||||
logger.log('Monitoring UPS during shutdown process...');
|
||||
await this.monitorDuringShutdown();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initiate shutdown: ${error}`);
|
||||
|
||||
// Try alternative shutdown methods
|
||||
const alternatives = [
|
||||
{ cmd: 'poweroff', args: ['--force'] },
|
||||
{ cmd: 'halt', args: ['-p'] },
|
||||
{ cmd: 'systemctl', args: ['poweroff'] },
|
||||
{ cmd: 'reboot', args: ['-p'] } // Some systems allow reboot -p for power off
|
||||
];
|
||||
|
||||
for (const alt of alternatives) {
|
||||
try {
|
||||
// First check if command exists in common system paths
|
||||
const paths = [
|
||||
`/sbin/${alt.cmd}`,
|
||||
`/usr/sbin/${alt.cmd}`,
|
||||
`/bin/${alt.cmd}`,
|
||||
`/usr/bin/${alt.cmd}`
|
||||
];
|
||||
|
||||
let cmdPath = '';
|
||||
for (const path of paths) {
|
||||
if (fs.existsSync(path)) {
|
||||
cmdPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdPath) {
|
||||
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
||||
await execFileAsync(cmdPath, alt.args);
|
||||
return; // Exit if successful
|
||||
} else {
|
||||
// Try using PATH environment
|
||||
logger.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`);
|
||||
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
|
||||
env: process.env // Pass the current environment
|
||||
});
|
||||
return; // Exit if successful
|
||||
}
|
||||
} catch (altError) {
|
||||
logger.error(`Alternative method ${alt.cmd} failed: ${altError}`);
|
||||
// Continue to next method
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('All shutdown methods failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor UPS during system shutdown
|
||||
* Force immediate shutdown if any UPS gets critically low
|
||||
*/
|
||||
private async monitorDuringShutdown(): Promise<void> {
|
||||
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
|
||||
const CHECK_INTERVAL = 30000; // Check every 30 seconds during shutdown
|
||||
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.log(`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`);
|
||||
|
||||
// Continue monitoring until max monitoring time is reached
|
||||
while (Date.now() - startTime < MAX_MONITORING_TIME) {
|
||||
try {
|
||||
logger.log('Checking UPS status during shutdown...');
|
||||
|
||||
// Check all UPS devices
|
||||
for (const ups of this.config.upsDevices) {
|
||||
try {
|
||||
const status = await this.snmp.getUpsStatus(ups.snmp);
|
||||
|
||||
logger.log(`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`);
|
||||
|
||||
// If any UPS battery runtime gets critically low, force immediate shutdown
|
||||
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
|
||||
logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
|
||||
logger.logBoxLine(`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`);
|
||||
logger.logBoxLine('Forcing immediate shutdown!');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Force immediate shutdown
|
||||
await this.forceImmediateShutdown();
|
||||
return;
|
||||
}
|
||||
} catch (upsError) {
|
||||
logger.error(`Error checking UPS ${ups.name} during shutdown: ${upsError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before checking again
|
||||
await this.sleep(CHECK_INTERVAL);
|
||||
} catch (error) {
|
||||
logger.error(`Error monitoring UPS during shutdown: ${error.message}`);
|
||||
await this.sleep(CHECK_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('UPS monitoring during shutdown completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an immediate system shutdown
|
||||
*/
|
||||
private async forceImmediateShutdown(): Promise<void> {
|
||||
try {
|
||||
// Find shutdown command in common system paths
|
||||
const shutdownPaths = [
|
||||
'/sbin/shutdown',
|
||||
'/usr/sbin/shutdown',
|
||||
'/bin/shutdown',
|
||||
'/usr/bin/shutdown'
|
||||
];
|
||||
|
||||
let shutdownCmd = '';
|
||||
for (const path of shutdownPaths) {
|
||||
if (fs.existsSync(path)) {
|
||||
shutdownCmd = path;
|
||||
logger.log(`Found shutdown command at: ${shutdownCmd}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shutdownCmd) {
|
||||
logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
|
||||
await execFileAsync(shutdownCmd, ['-h', 'now', 'EMERGENCY: UPS battery critically low, shutting down NOW']);
|
||||
} else {
|
||||
// Try using the PATH to find shutdown
|
||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
||||
await execAsync('shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"', {
|
||||
env: process.env // Pass the current environment
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Emergency shutdown failed, trying alternative methods...');
|
||||
|
||||
// Try alternative shutdown methods in sequence
|
||||
const alternatives = [
|
||||
{ cmd: 'poweroff', args: ['--force'] },
|
||||
{ cmd: 'halt', args: ['-p'] },
|
||||
{ cmd: 'systemctl', args: ['poweroff'] }
|
||||
];
|
||||
|
||||
for (const alt of alternatives) {
|
||||
try {
|
||||
// Check common paths
|
||||
const paths = [
|
||||
`/sbin/${alt.cmd}`,
|
||||
`/usr/sbin/${alt.cmd}`,
|
||||
`/bin/${alt.cmd}`,
|
||||
`/usr/bin/${alt.cmd}`
|
||||
];
|
||||
|
||||
let cmdPath = '';
|
||||
for (const path of paths) {
|
||||
if (fs.existsSync(path)) {
|
||||
cmdPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdPath) {
|
||||
logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
|
||||
await execFileAsync(cmdPath, alt.args);
|
||||
return; // Exit if successful
|
||||
} else {
|
||||
// Try using PATH
|
||||
logger.log(`Emergency: trying ${alt.cmd} via PATH`);
|
||||
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
|
||||
env: process.env
|
||||
});
|
||||
return; // Exit if successful
|
||||
}
|
||||
} catch (altError) {
|
||||
// Continue to next method
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('All emergency shutdown methods failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
1
ts/helpers/index.ts
Normal file
1
ts/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './shortid.js';
|
22
ts/helpers/shortid.ts
Normal file
22
ts/helpers/shortid.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Generate a short unique ID of 6 alphanumeric characters
|
||||
* @returns A 6-character alphanumeric string
|
||||
*/
|
||||
export function shortId(): string {
|
||||
// Define the character set: a-z, A-Z, 0-9
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
// Generate cryptographically secure random values
|
||||
const randomValues = new Uint8Array(6);
|
||||
crypto.getRandomValues(randomValues);
|
||||
|
||||
// Map each random value to a character in our set
|
||||
let result = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
// Use modulo to map the random byte to a character index
|
||||
const index = randomValues[i] % chars.length;
|
||||
result += chars[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { NupstCli } from './cli.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Main entry point for NUPST
|
||||
@ -13,6 +14,6 @@ async function main() {
|
||||
|
||||
// Run the main function and handle any errors
|
||||
main().catch(error => {
|
||||
console.error('Error:', error);
|
||||
logger.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
147
ts/logger.ts
Normal file
147
ts/logger.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* A simple logger class that provides consistent formatting for log messages
|
||||
* including support for logboxes with title, lines, and closing
|
||||
*/
|
||||
export class Logger {
|
||||
private currentBoxWidth: number | null = null;
|
||||
private static instance: Logger;
|
||||
|
||||
/** Default width to use when no width is specified */
|
||||
private readonly DEFAULT_WIDTH = 60;
|
||||
|
||||
/**
|
||||
* Creates a new Logger instance
|
||||
*/
|
||||
constructor() {
|
||||
this.currentBoxWidth = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton logger instance
|
||||
* @returns The singleton logger instance
|
||||
*/
|
||||
public static getInstance(): Logger {
|
||||
if (!Logger.instance) {
|
||||
Logger.instance = new Logger();
|
||||
}
|
||||
return Logger.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message
|
||||
* @param message Message to log
|
||||
*/
|
||||
public log(message: string): void {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
* @param message Error message to log
|
||||
*/
|
||||
public error(message: string): void {
|
||||
console.error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message with a warning emoji
|
||||
* @param message Warning message to log
|
||||
*/
|
||||
public warn(message: string): void {
|
||||
console.warn(`⚠️ ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a success message with a checkmark
|
||||
* @param message Success message to log
|
||||
*/
|
||||
public success(message: string): void {
|
||||
console.log(`✓ ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a logbox title and set the current box width
|
||||
* @param title Title of the logbox
|
||||
* @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH
|
||||
*/
|
||||
public logBoxTitle(title: string, width?: number): void {
|
||||
this.currentBoxWidth = width || this.DEFAULT_WIDTH;
|
||||
|
||||
// Create the title line with appropriate padding
|
||||
const paddedTitle = ` ${title} `;
|
||||
const remainingSpace = this.currentBoxWidth - 3 - paddedTitle.length;
|
||||
|
||||
// Title line: ┌─ Title ───┐
|
||||
const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}┐`;
|
||||
|
||||
console.log(titleLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a logbox line
|
||||
* @param content Content of the line
|
||||
* @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH.
|
||||
*/
|
||||
public logBoxLine(content: string, width?: number): void {
|
||||
if (!this.currentBoxWidth && !width) {
|
||||
// No current width and no width provided, use default width
|
||||
this.logBoxTitle('', this.DEFAULT_WIDTH);
|
||||
}
|
||||
|
||||
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
|
||||
|
||||
// Calculate the available space for content
|
||||
const availableSpace = boxWidth - 2; // Account for left and right borders
|
||||
|
||||
if (content.length <= availableSpace - 1) {
|
||||
// If content fits with at least one space for the right border stripe
|
||||
const padding = availableSpace - content.length - 1;
|
||||
console.log(`│ ${content}${' '.repeat(padding)}│`);
|
||||
} else {
|
||||
// Content is too long, let it flow out of boundaries.
|
||||
console.log(`│ ${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a logbox end
|
||||
* @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH.
|
||||
*/
|
||||
public logBoxEnd(width?: number): void {
|
||||
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
|
||||
|
||||
// Create the bottom border: └────────┘
|
||||
console.log(`└${'─'.repeat(boxWidth - 2)}┘`);
|
||||
|
||||
// Reset the current box width
|
||||
this.currentBoxWidth = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a complete logbox with title, content lines, and ending
|
||||
* @param title Title of the logbox
|
||||
* @param lines Array of content lines
|
||||
* @param width Width of the logbox, defaults to DEFAULT_WIDTH
|
||||
*/
|
||||
public logBox(title: string, lines: string[], width?: number): void {
|
||||
this.logBoxTitle(title, width || this.DEFAULT_WIDTH);
|
||||
|
||||
for (const line of lines) {
|
||||
this.logBoxLine(line);
|
||||
}
|
||||
|
||||
this.logBoxEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a divider line
|
||||
* @param width Width of the divider, defaults to DEFAULT_WIDTH
|
||||
* @param character Character to use for the divider (default: ─)
|
||||
*/
|
||||
public logDivider(width?: number, character: string = '─'): void {
|
||||
console.log(character.repeat(width || this.DEFAULT_WIDTH));
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for easy use
|
||||
export const logger = Logger.getInstance();
|
68
ts/nupst.ts
68
ts/nupst.ts
@ -1,8 +1,11 @@
|
||||
import { NupstSnmp } from './snmp.js';
|
||||
import { NupstSnmp } from './snmp/manager.js';
|
||||
import { NupstDaemon } from './daemon.js';
|
||||
import { NupstSystemd } from './systemd.js';
|
||||
import { commitinfo } from './00_commitinfo_data.js';
|
||||
import { spawn } from 'child_process';
|
||||
import { logger } from './logger.js';
|
||||
import { UpsHandler } from './cli/ups-handler.js';
|
||||
import { GroupHandler } from './cli/group-handler.js';
|
||||
import { ServiceHandler } from './cli/service-handler.js';
|
||||
import * as https from 'https';
|
||||
|
||||
/**
|
||||
@ -13,6 +16,9 @@ export class Nupst {
|
||||
private readonly snmp: NupstSnmp;
|
||||
private readonly daemon: NupstDaemon;
|
||||
private readonly systemd: NupstSystemd;
|
||||
private readonly upsHandler: UpsHandler;
|
||||
private readonly groupHandler: GroupHandler;
|
||||
private readonly serviceHandler: ServiceHandler;
|
||||
private updateAvailable: boolean = false;
|
||||
private latestVersion: string = '';
|
||||
|
||||
@ -20,10 +26,16 @@ export class Nupst {
|
||||
* Create a new Nupst instance with all necessary components
|
||||
*/
|
||||
constructor() {
|
||||
// Initialize core components
|
||||
this.snmp = new NupstSnmp();
|
||||
this.snmp.setNupst(this); // Set up bidirectional reference
|
||||
this.daemon = new NupstDaemon(this.snmp);
|
||||
this.systemd = new NupstSystemd(this.daemon);
|
||||
|
||||
// Initialize handlers
|
||||
this.upsHandler = new UpsHandler(this);
|
||||
this.groupHandler = new GroupHandler(this);
|
||||
this.serviceHandler = new ServiceHandler(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,6 +59,27 @@ export class Nupst {
|
||||
return this.systemd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UPS handler for UPS management
|
||||
*/
|
||||
public getUpsHandler(): UpsHandler {
|
||||
return this.upsHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Group handler for group management
|
||||
*/
|
||||
public getGroupHandler(): GroupHandler {
|
||||
return this.groupHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Service handler for service management
|
||||
*/
|
||||
public getServiceHandler(): ServiceHandler {
|
||||
return this.serviceHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version of NUPST
|
||||
* @returns The current version string
|
||||
@ -70,7 +103,7 @@ export class Nupst {
|
||||
|
||||
return this.updateAvailable;
|
||||
} catch (error) {
|
||||
console.error(`Error checking for updates: ${error.message}`);
|
||||
logger.error(`Error checking for updates: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -162,28 +195,33 @@ export class Nupst {
|
||||
*/
|
||||
public logVersionInfo(checkForUpdates: boolean = true): void {
|
||||
const version = this.getVersion();
|
||||
console.log('┌─ NUPST Version ────────────────────────┐');
|
||||
console.log(`│ Current Version: ${version}`);
|
||||
const boxWidth = 45;
|
||||
|
||||
logger.logBoxTitle('NUPST Version', boxWidth);
|
||||
logger.logBoxLine(`Current Version: ${version}`);
|
||||
|
||||
if (this.updateAvailable && this.latestVersion) {
|
||||
console.log(`│ Update Available: ${this.latestVersion}`);
|
||||
console.log('│ Run "sudo nupst update" to update');
|
||||
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||
logger.logBoxEnd();
|
||||
} else if (checkForUpdates) {
|
||||
console.log('│ Checking for updates...');
|
||||
logger.logBoxLine('Checking for updates...');
|
||||
|
||||
// We can't end the box yet since we're in an async operation
|
||||
this.checkForUpdates().then(updateAvailable => {
|
||||
if (updateAvailable) {
|
||||
console.log(`│ Update Available: ${this.latestVersion}`);
|
||||
console.log('│ Run "sudo nupst update" to update');
|
||||
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||
} else {
|
||||
console.log('│ You are running the latest version');
|
||||
logger.logBoxLine('You are running the latest version');
|
||||
}
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxEnd();
|
||||
}).catch(() => {
|
||||
console.log('│ Could not check for updates');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxLine('Could not check for updates');
|
||||
logger.logBoxEnd();
|
||||
});
|
||||
} else {
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Re-export from the snmp module
|
||||
* This file is kept for backward compatibility
|
||||
*/
|
||||
|
||||
export * from './snmp/index.js';
|
@ -1,98 +0,0 @@
|
||||
/**
|
||||
* SNMP encoding utilities
|
||||
* Contains helper methods for encoding SNMP data
|
||||
*/
|
||||
export class SnmpEncoder {
|
||||
/**
|
||||
* Convert OID string to array of integers
|
||||
* @param oid OID string in dotted notation (e.g. "1.3.6.1.2.1")
|
||||
* @returns Array of integers representing the OID
|
||||
*/
|
||||
public static oidToArray(oid: string): number[] {
|
||||
return oid.split('.').map(n => parseInt(n, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an SNMP integer
|
||||
* @param value Integer value to encode
|
||||
* @returns Buffer containing the encoded integer
|
||||
*/
|
||||
public static encodeInteger(value: number): Buffer {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeInt32BE(value, 0);
|
||||
|
||||
// Find first non-zero byte
|
||||
let start = 0;
|
||||
while (start < 3 && buf[start] === 0) {
|
||||
start++;
|
||||
}
|
||||
|
||||
// Handle negative values
|
||||
if (value < 0 && buf[start] === 0) {
|
||||
start--;
|
||||
}
|
||||
|
||||
return buf.slice(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an OID
|
||||
* @param oid Array of integers representing the OID
|
||||
* @returns Buffer containing the encoded OID
|
||||
*/
|
||||
public static encodeOID(oid: number[]): Buffer {
|
||||
// First two numbers are encoded as 40*x+y
|
||||
let encodedOid = Buffer.from([40 * (oid[0] || 0) + (oid[1] || 0)]);
|
||||
|
||||
// Encode remaining numbers
|
||||
for (let i = 2; i < oid.length; i++) {
|
||||
const n = oid[i];
|
||||
|
||||
if (n < 128) {
|
||||
// Simple case: number fits in one byte
|
||||
encodedOid = Buffer.concat([encodedOid, Buffer.from([n])]);
|
||||
} else {
|
||||
// Number needs multiple bytes
|
||||
const bytes = [];
|
||||
let value = n;
|
||||
|
||||
// Create bytes array in reverse order
|
||||
do {
|
||||
bytes.unshift(value & 0x7F);
|
||||
value >>= 7;
|
||||
} while (value > 0);
|
||||
|
||||
// Set high bit on all but the last byte
|
||||
for (let j = 0; j < bytes.length - 1; j++) {
|
||||
bytes[j] |= 0x80;
|
||||
}
|
||||
|
||||
encodedOid = Buffer.concat([encodedOid, Buffer.from(bytes)]);
|
||||
}
|
||||
}
|
||||
|
||||
return encodedOid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an ASN.1 integer
|
||||
* @param buffer Buffer containing the encoded integer
|
||||
* @param offset Offset in the buffer
|
||||
* @param length Length of the integer in bytes
|
||||
* @returns Decoded integer value
|
||||
*/
|
||||
public static decodeInteger(buffer: Buffer, offset: number, length: number): number {
|
||||
if (length === 1) {
|
||||
return buffer[offset];
|
||||
} else if (length === 2) {
|
||||
return buffer.readInt16BE(offset);
|
||||
} else if (length === 3) {
|
||||
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
|
||||
} else if (length === 4) {
|
||||
return buffer.readInt32BE(offset);
|
||||
} else {
|
||||
// For longer integers, we'll just return a simple value
|
||||
return buffer[offset];
|
||||
}
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
// Re-export all public types
|
||||
export type { UpsStatus, OIDSet, UpsModel, SnmpConfig } from './types.js';
|
||||
export type { IUpsStatus, IOidSet, TUpsModel, ISnmpConfig } from './types.js';
|
||||
|
||||
// Re-export the SNMP manager class
|
||||
export { NupstSnmp } from './manager.js';
|
@ -1,12 +1,6 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as dgram from 'dgram';
|
||||
import type { OIDSet, SnmpConfig, UpsModel, UpsStatus } from './types.js';
|
||||
import * as snmp from 'net-snmp';
|
||||
import type { IOidSet, ISnmpConfig, TUpsModel, IUpsStatus } from './types.js';
|
||||
import { UpsOidSets } from './oid-sets.js';
|
||||
import { SnmpPacketCreator } from './packet-creator.js';
|
||||
import { SnmpPacketParser } from './packet-parser.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Class for SNMP communication with UPS devices
|
||||
@ -14,12 +8,14 @@ const execAsync = promisify(exec);
|
||||
*/
|
||||
export class NupstSnmp {
|
||||
// Active OID set
|
||||
private activeOIDs: OIDSet;
|
||||
private activeOIDs: IOidSet;
|
||||
// Reference to the parent Nupst instance
|
||||
private nupst: any; // Type 'any' to avoid circular dependency
|
||||
// Debug mode flag
|
||||
private debug: boolean = false;
|
||||
|
||||
// Default SNMP configuration
|
||||
private readonly DEFAULT_CONFIG: SnmpConfig = {
|
||||
private readonly DEFAULT_CONFIG: ISnmpConfig = {
|
||||
host: '127.0.0.1', // Default to localhost
|
||||
port: 161, // Default SNMP port
|
||||
community: 'public', // Default community string for v1/v2c
|
||||
@ -28,13 +24,6 @@ export class NupstSnmp {
|
||||
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
|
||||
@ -60,11 +49,19 @@ export class NupstSnmp {
|
||||
return this.nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable debug mode
|
||||
*/
|
||||
public enableDebug(): void {
|
||||
this.debug = true;
|
||||
console.log('SNMP debug mode enabled - detailed logs will be shown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active OID set based on UPS model
|
||||
* @param config SNMP configuration
|
||||
*/
|
||||
private setActiveOIDs(config: SnmpConfig): void {
|
||||
private setActiveOIDs(config: ISnmpConfig): void {
|
||||
// If custom OIDs are provided, use them
|
||||
if (config.upsModel === 'custom' && config.customOIDs) {
|
||||
this.activeOIDs = config.customOIDs;
|
||||
@ -84,119 +81,188 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable debug mode
|
||||
*/
|
||||
public enableDebug(): void {
|
||||
this.debug = true;
|
||||
console.log('SNMP debug mode enabled - detailed logs will be shown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SNMP GET request
|
||||
* Send an SNMP GET request using the net-snmp package
|
||||
* @param oid OID to query
|
||||
* @param config SNMP configuration
|
||||
* @param retryCount Current retry count (unused in this implementation)
|
||||
* @returns Promise resolving to the SNMP response value
|
||||
*/
|
||||
public async snmpGet(oid: string, config = this.DEFAULT_CONFIG): Promise<any> {
|
||||
public async snmpGet(
|
||||
oid: string,
|
||||
config = this.DEFAULT_CONFIG,
|
||||
retryCount = 0
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = dgram.createSocket('udp4');
|
||||
if (this.debug) {
|
||||
console.log(`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`);
|
||||
console.log('Using community:', config.community);
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
// Create SNMP options based on configuration
|
||||
const options: any = {
|
||||
port: config.port,
|
||||
retries: 2, // Number of retries
|
||||
timeout: config.timeout,
|
||||
transport: 'udp4',
|
||||
idBitsSize: 32,
|
||||
context: config.context || ''
|
||||
};
|
||||
|
||||
// Set version based on config
|
||||
if (config.version === 1) {
|
||||
options.version = snmp.Version1;
|
||||
} else if (config.version === 2) {
|
||||
options.version = snmp.Version2c;
|
||||
} else {
|
||||
request = SnmpPacketCreator.createSnmpGetRequest(oid, config.community || 'public', this.debug);
|
||||
options.version = snmp.Version3;
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
// Create appropriate session based on SNMP version
|
||||
let session;
|
||||
|
||||
// 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);
|
||||
// For SNMPv3, we need to set up authentication and privacy
|
||||
// For SNMPv3, we need a valid security level
|
||||
const securityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||
|
||||
// Listen for responses
|
||||
socket.on('message', (message, rinfo) => {
|
||||
clearTimeout(timeout);
|
||||
// Create the user object with required structure for net-snmp
|
||||
const user: any = {
|
||||
name: config.username || ''
|
||||
};
|
||||
|
||||
// Set security level
|
||||
if (securityLevel === 'noAuthNoPriv') {
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
} else if (securityLevel === 'authNoPriv') {
|
||||
user.level = snmp.SecurityLevel.authNoPriv;
|
||||
|
||||
// Set auth protocol - must provide both protocol and key
|
||||
if (config.authProtocol && config.authKey) {
|
||||
if (config.authProtocol === 'MD5') {
|
||||
user.authProtocol = snmp.AuthProtocols.md5;
|
||||
} else if (config.authProtocol === 'SHA') {
|
||||
user.authProtocol = snmp.AuthProtocols.sha;
|
||||
}
|
||||
user.authKey = config.authKey;
|
||||
} else {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
} else if (securityLevel === 'authPriv') {
|
||||
user.level = snmp.SecurityLevel.authPriv;
|
||||
|
||||
// Set auth protocol - must provide both protocol and key
|
||||
if (config.authProtocol && config.authKey) {
|
||||
if (config.authProtocol === 'MD5') {
|
||||
user.authProtocol = snmp.AuthProtocols.md5;
|
||||
} else if (config.authProtocol === 'SHA') {
|
||||
user.authProtocol = snmp.AuthProtocols.sha;
|
||||
}
|
||||
user.authKey = config.authKey;
|
||||
|
||||
// Set privacy protocol - must provide both protocol and key
|
||||
if (config.privProtocol && config.privKey) {
|
||||
if (config.privProtocol === 'DES') {
|
||||
user.privProtocol = snmp.PrivProtocols.des;
|
||||
} else if (config.privProtocol === 'AES') {
|
||||
user.privProtocol = snmp.PrivProtocols.aes;
|
||||
}
|
||||
user.privKey = config.privKey;
|
||||
} else {
|
||||
// Fallback to authNoPriv if priv details missing
|
||||
user.level = snmp.SecurityLevel.authNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing privProtocol or privKey, falling back to authNoPriv');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
console.log('SNMPv3 user configuration:', {
|
||||
name: user.name,
|
||||
level: Object.keys(snmp.SecurityLevel).find(key => snmp.SecurityLevel[key] === user.level),
|
||||
authProtocol: user.authProtocol ? 'Set' : 'Not Set',
|
||||
authKey: user.authKey ? 'Set' : 'Not Set',
|
||||
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
|
||||
privKey: user.privKey ? 'Set' : 'Not Set'
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
session = snmp.createV3Session(config.host, user, options);
|
||||
} else {
|
||||
// For SNMPv1/v2c, we use the community string
|
||||
session = snmp.createSession(config.host, config.community || 'public', options);
|
||||
}
|
||||
|
||||
// Convert the OID string to an array of OIDs if multiple OIDs are needed
|
||||
const oids = [oid];
|
||||
|
||||
// Send the GET request
|
||||
session.get(oids, (error: any, varbinds: any[]) => {
|
||||
// Close the session to release resources
|
||||
session.close();
|
||||
|
||||
if (error) {
|
||||
clearTimeout(timeout);
|
||||
socket.close();
|
||||
if (this.debug) {
|
||||
console.error('Error sending SNMP request:', error);
|
||||
console.error('SNMP GET error:', error);
|
||||
}
|
||||
reject(error);
|
||||
} else if (this.debug) {
|
||||
console.log('SNMP request sent successfully');
|
||||
reject(new Error(`SNMP GET error: ${error.message || error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!varbinds || varbinds.length === 0) {
|
||||
if (this.debug) {
|
||||
console.error('No varbinds returned in response');
|
||||
}
|
||||
reject(new Error('No varbinds returned in response'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for SNMP errors in the response
|
||||
if (varbinds[0].type === snmp.ObjectType.NoSuchObject ||
|
||||
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
|
||||
varbinds[0].type === snmp.ObjectType.EndOfMibView) {
|
||||
if (this.debug) {
|
||||
console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]);
|
||||
}
|
||||
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the response value based on its type
|
||||
let value = varbinds[0].value;
|
||||
|
||||
// Handle specific types that might need conversion
|
||||
if (Buffer.isBuffer(value)) {
|
||||
// If value is a Buffer, try to convert it to a string if it's printable ASCII
|
||||
const isPrintableAscii = value.every(byte => byte >= 32 && byte <= 126);
|
||||
if (isPrintableAscii) {
|
||||
value = value.toString();
|
||||
}
|
||||
} else if (typeof value === 'bigint') {
|
||||
// Convert BigInt to a normal number or string if needed
|
||||
value = Number(value);
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log('SNMP response:', {
|
||||
oid: varbinds[0].oid,
|
||||
type: varbinds[0].type,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -206,7 +272,7 @@ export class NupstSnmp {
|
||||
* @param config SNMP configuration
|
||||
* @returns Promise resolving to the UPS status
|
||||
*/
|
||||
public async getUpsStatus(config = this.DEFAULT_CONFIG): Promise<UpsStatus> {
|
||||
public async getUpsStatus(config = this.DEFAULT_CONFIG): Promise<IUpsStatus> {
|
||||
try {
|
||||
// Set active OID set based on UPS model in config
|
||||
this.setActiveOIDs(config);
|
||||
@ -234,142 +300,16 @@ export class NupstSnmp {
|
||||
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;
|
||||
const powerStatusValue = await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config);
|
||||
const batteryCapacity = await this.getSNMPValueWithRetry(this.activeOIDs.BATTERY_CAPACITY, 'battery capacity', config) || 0;
|
||||
const batteryRuntime = await this.getSNMPValueWithRetry(this.activeOIDs.BATTERY_RUNTIME, 'battery runtime', config) || 0;
|
||||
|
||||
// Determine power status - handle different values for different UPS models
|
||||
let powerStatus: 'online' | 'onBattery' | 'unknown' = 'unknown';
|
||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||
|
||||
// Different UPS models use different values for power status
|
||||
if (config.upsModel === 'cyberpower') {
|
||||
// CyberPower RMCARD205: upsBaseOutputStatus values
|
||||
// 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc.
|
||||
if (powerStatusValue === 2) {
|
||||
powerStatus = 'online';
|
||||
} else if (powerStatusValue === 3) {
|
||||
powerStatus = 'onBattery';
|
||||
}
|
||||
} else {
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
// Convert to minutes for UPS models with different time units
|
||||
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
|
||||
|
||||
const result = {
|
||||
powerStatus,
|
||||
@ -403,129 +343,231 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover SNMP engine ID (for SNMPv3)
|
||||
* Sends a proper discovery message to get the engine ID from the device
|
||||
* Helper method to get SNMP value with retry and fallback logic
|
||||
* @param oid OID to query
|
||||
* @param description Description of the value for logging
|
||||
* @param config SNMP configuration
|
||||
* @returns Promise resolving to the discovered engine ID
|
||||
* @returns Promise resolving to the SNMP value
|
||||
*/
|
||||
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++);
|
||||
|
||||
private async getSNMPValueWithRetry(
|
||||
oid: string,
|
||||
description: string,
|
||||
config: ISnmpConfig
|
||||
): Promise<any> {
|
||||
if (oid === '') {
|
||||
if (this.debug) {
|
||||
console.log('Sending SNMPv3 discovery message');
|
||||
console.log('SNMPv3 Discovery message:', request.toString('hex'));
|
||||
console.log(`No OID provided for ${description}, skipping`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 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'));
|
||||
console.log(`Getting ${description} OID: ${oid}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract engine ID from response
|
||||
const engineId = SnmpPacketParser.extractEngineId(message, this.debug);
|
||||
if (engineId) {
|
||||
this.engineID = engineId; // Update the engine ID
|
||||
const value = await this.snmpGet(oid, config);
|
||||
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);
|
||||
console.log(`${description} value:`, value);
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.error('Error extracting engine ID:', error);
|
||||
console.error(`Error getting ${description}:`, error.message);
|
||||
}
|
||||
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);
|
||||
// If we're using SNMPv3, try with different security levels
|
||||
if (config.version === 3) {
|
||||
return await this.tryFallbackSecurityLevels(oid, description, config);
|
||||
}
|
||||
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();
|
||||
// Try with standard OIDs as fallback
|
||||
if (config.upsModel !== 'custom') {
|
||||
return await this.tryStandardOids(oid, description, config);
|
||||
}
|
||||
|
||||
// Return a default value if all attempts fail
|
||||
if (this.debug) {
|
||||
console.error('Error sending discovery message:', error);
|
||||
console.log(`Using default value 0 for ${description}`);
|
||||
}
|
||||
resolve(this.engineID); // Fall back to default engine ID
|
||||
} else if (this.debug) {
|
||||
console.log('Discovery message sent successfully');
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate system shutdown
|
||||
* @param reason Reason for shutdown
|
||||
* Try fallback security levels for SNMPv3
|
||||
* @param oid OID to query
|
||||
* @param description Description of the value for logging
|
||||
* @param config SNMP configuration
|
||||
* @returns Promise resolving to the SNMP value
|
||||
*/
|
||||
public async initiateShutdown(reason: string): Promise<void> {
|
||||
console.log(`Initiating system shutdown due to: ${reason}`);
|
||||
private async tryFallbackSecurityLevels(
|
||||
oid: string,
|
||||
description: string,
|
||||
config: ISnmpConfig
|
||||
): Promise<any> {
|
||||
if (this.debug) {
|
||||
console.log(`Retrying ${description} with fallback security level...`);
|
||||
}
|
||||
|
||||
// Try with authNoPriv if current level is authPriv
|
||||
if (config.securityLevel === 'authPriv') {
|
||||
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
|
||||
try {
|
||||
// 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
|
||||
if (this.debug) {
|
||||
console.log(`Retrying with authNoPriv security level`);
|
||||
}
|
||||
const value = await this.snmpGet(oid, retryConfig);
|
||||
if (this.debug) {
|
||||
console.log(`${description} retry value:`, value);
|
||||
}
|
||||
return value;
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
console.error(`Retry failed for ${description}:`, retryError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try with noAuthNoPriv as a last resort
|
||||
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
|
||||
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
|
||||
try {
|
||||
console.log('Trying alternative shutdown method...');
|
||||
await execAsync('poweroff --force');
|
||||
} catch (innerError) {
|
||||
console.error('All shutdown methods failed:', innerError);
|
||||
if (this.debug) {
|
||||
console.log(`Retrying with noAuthNoPriv security level`);
|
||||
}
|
||||
const value = await this.snmpGet(oid, retryConfig);
|
||||
if (this.debug) {
|
||||
console.log(`${description} retry value:`, value);
|
||||
}
|
||||
return value;
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
console.error(`Retry failed for ${description}:`, retryError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try standard OIDs as fallback
|
||||
* @param oid OID to query
|
||||
* @param description Description of the value for logging
|
||||
* @param config SNMP configuration
|
||||
* @returns Promise resolving to the SNMP value
|
||||
*/
|
||||
private async tryStandardOids(
|
||||
oid: string,
|
||||
description: string,
|
||||
config: ISnmpConfig
|
||||
): Promise<any> {
|
||||
try {
|
||||
// Try RFC 1628 standard UPS MIB OIDs
|
||||
const standardOIDs = UpsOidSets.getStandardOids();
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`);
|
||||
}
|
||||
|
||||
const standardValue = await this.snmpGet(standardOIDs[description], config);
|
||||
if (this.debug) {
|
||||
console.log(`${description} standard OID value:`, standardValue);
|
||||
}
|
||||
return standardValue;
|
||||
} catch (stdError) {
|
||||
if (this.debug) {
|
||||
console.error(`Standard OID retry failed for ${description}:`, stdError.message);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine power status based on UPS model and raw value
|
||||
* @param upsModel UPS model
|
||||
* @param powerStatusValue Raw power status value
|
||||
* @returns Standardized power status
|
||||
*/
|
||||
private determinePowerStatus(
|
||||
upsModel: TUpsModel | undefined,
|
||||
powerStatusValue: number
|
||||
): 'online' | 'onBattery' | 'unknown' {
|
||||
if (upsModel === 'cyberpower') {
|
||||
// CyberPower RMCARD205: upsBaseOutputStatus values
|
||||
// 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc.
|
||||
if (powerStatusValue === 2) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 3) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else if (upsModel === 'eaton') {
|
||||
// Eaton UPS: xupsOutputSource values
|
||||
// 3=normal/mains, 5=battery, etc.
|
||||
if (powerStatusValue === 3) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 5) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else if (upsModel === 'apc') {
|
||||
// APC UPS: upsBasicOutputStatus values
|
||||
// 2=online, 3=onBattery, etc.
|
||||
if (powerStatusValue === 2) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 3) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else {
|
||||
// Default interpretation for other UPS models
|
||||
if (powerStatusValue === 1) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 2) {
|
||||
return 'onBattery';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process runtime value based on UPS model
|
||||
* @param upsModel UPS model
|
||||
* @param batteryRuntime Raw battery runtime value
|
||||
* @returns Processed runtime in minutes
|
||||
*/
|
||||
private processRuntimeValue(
|
||||
upsModel: TUpsModel | undefined,
|
||||
batteryRuntime: number
|
||||
): number {
|
||||
if (this.debug) {
|
||||
console.log('Raw runtime value:', batteryRuntime);
|
||||
}
|
||||
|
||||
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
||||
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
||||
if (this.debug) {
|
||||
console.log(`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`);
|
||||
}
|
||||
return minutes;
|
||||
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
|
||||
// Eaton: Runtime is in seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 60);
|
||||
if (this.debug) {
|
||||
console.log(`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`);
|
||||
}
|
||||
return minutes;
|
||||
} else if (batteryRuntime > 10000) {
|
||||
// Generic conversion for large tick values (likely TimeTicks)
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return batteryRuntime;
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import type { OIDSet, UpsModel } from './types.js';
|
||||
import type { IOidSet, TUpsModel } from './types.js';
|
||||
|
||||
/**
|
||||
* OID sets for different UPS models
|
||||
@ -8,7 +8,7 @@ export class UpsOidSets {
|
||||
/**
|
||||
* OID sets for different UPS models
|
||||
*/
|
||||
private static readonly UPS_OID_SETS: Record<UpsModel, OIDSet> = {
|
||||
private static readonly UPS_OID_SETS: Record<TUpsModel, IOidSet> = {
|
||||
// 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)
|
||||
@ -25,9 +25,9 @@ export class UpsOidSets {
|
||||
|
||||
// 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
|
||||
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource (3=normal/mains, 5=battery)
|
||||
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage)
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds)
|
||||
},
|
||||
|
||||
// TrippLite OIDs
|
||||
@ -57,7 +57,7 @@ export class UpsOidSets {
|
||||
* @param model UPS model name
|
||||
* @returns OID set for the model
|
||||
*/
|
||||
public static getOidSet(model: UpsModel): OIDSet {
|
||||
public static getOidSet(model: TUpsModel): IOidSet {
|
||||
return this.UPS_OID_SETS[model];
|
||||
}
|
||||
|
||||
|
@ -1,651 +0,0 @@
|
||||
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
|
||||
]);
|
||||
}
|
||||
}
|
@ -1,553 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@
|
||||
/**
|
||||
* UPS status interface
|
||||
*/
|
||||
export interface UpsStatus {
|
||||
export interface IUpsStatus {
|
||||
/** Current power status */
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
/** Battery capacity percentage */
|
||||
@ -19,7 +19,7 @@ export interface UpsStatus {
|
||||
/**
|
||||
* SNMP OID Sets for different UPS brands
|
||||
*/
|
||||
export interface OIDSet {
|
||||
export interface IOidSet {
|
||||
/** OID for power status */
|
||||
POWER_STATUS: string;
|
||||
/** OID for battery capacity */
|
||||
@ -31,12 +31,12 @@ export interface OIDSet {
|
||||
/**
|
||||
* Supported UPS model types
|
||||
*/
|
||||
export type UpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
|
||||
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
|
||||
|
||||
/**
|
||||
* SNMP Configuration interface
|
||||
*/
|
||||
export interface SnmpConfig {
|
||||
export interface ISnmpConfig {
|
||||
/** SNMP server host */
|
||||
host: string;
|
||||
/** SNMP server port (default 161) */
|
||||
@ -46,6 +46,8 @@ export interface SnmpConfig {
|
||||
/** Timeout in milliseconds */
|
||||
timeout: number;
|
||||
|
||||
context?: string;
|
||||
|
||||
// SNMPv1/v2c
|
||||
/** Community string for SNMPv1/v2c */
|
||||
community?: string;
|
||||
@ -66,15 +68,15 @@ export interface SnmpConfig {
|
||||
|
||||
// UPS model and custom OIDs
|
||||
/** UPS model for OID selection */
|
||||
upsModel?: UpsModel;
|
||||
upsModel?: TUpsModel;
|
||||
/** Custom OIDs when using custom UPS model */
|
||||
customOIDs?: OIDSet;
|
||||
customOIDs?: IOidSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* SNMPv3 security parameters
|
||||
*/
|
||||
export interface SnmpV3SecurityParams {
|
||||
export interface ISnmpV3SecurityParams {
|
||||
/** Engine ID for the SNMP server */
|
||||
msgAuthoritativeEngineID: Buffer;
|
||||
/** Engine boots counter */
|
||||
|
191
ts/systemd.ts
191
ts/systemd.ts
@ -1,6 +1,7 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { NupstDaemon } from './daemon.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Class for managing systemd service
|
||||
@ -13,7 +14,7 @@ export class NupstSystemd {
|
||||
|
||||
/** Template for the systemd service file */
|
||||
private readonly serviceTemplate = `[Unit]
|
||||
Description=Node.js UPS Shutdown Tool
|
||||
Description=Node.js UPS Shutdown Tool for Multiple UPS Devices
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@ -47,10 +48,11 @@ WantedBy=multi-user.target
|
||||
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('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 50;
|
||||
logger.logBoxTitle('Configuration Error', boxWidth);
|
||||
logger.logBoxLine(`No configuration file found at ${configPath}`);
|
||||
logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration.");
|
||||
logger.logBoxEnd();
|
||||
throw new Error('Configuration not found');
|
||||
}
|
||||
}
|
||||
@ -66,23 +68,24 @@ WantedBy=multi-user.target
|
||||
|
||||
// Write the service file
|
||||
await fs.writeFile(this.serviceFilePath, this.serviceTemplate);
|
||||
console.log('┌─ Service Installation ─────────────────────┐');
|
||||
console.log(`│ Service file created at ${this.serviceFilePath}`);
|
||||
const boxWidth = 50;
|
||||
logger.logBoxTitle('Service Installation', boxWidth);
|
||||
logger.logBoxLine(`Service file created at ${this.serviceFilePath}`);
|
||||
|
||||
// Reload systemd daemon
|
||||
execSync('systemctl daemon-reload');
|
||||
console.log('│ Systemd daemon reloaded');
|
||||
logger.logBoxLine('Systemd daemon reloaded');
|
||||
|
||||
// Enable the service
|
||||
execSync('systemctl enable nupst.service');
|
||||
console.log('│ Service enabled to start on boot');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxLine('Service enabled to start on boot');
|
||||
logger.logBoxEnd();
|
||||
} 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);
|
||||
logger.error(`Failed to install systemd service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -97,15 +100,16 @@ WantedBy=multi-user.target
|
||||
await this.checkConfigExists();
|
||||
|
||||
execSync('systemctl start nupst.service');
|
||||
console.log('┌─ Service Status ─────────────────────────┐');
|
||||
console.log('│ NUPST service started successfully');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Service Status', boxWidth);
|
||||
logger.logBoxLine('NUPST service started successfully');
|
||||
logger.logBoxEnd();
|
||||
} 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);
|
||||
logger.error(`Failed to start service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -117,18 +121,28 @@ WantedBy=multi-user.target
|
||||
public async stop(): Promise<void> {
|
||||
try {
|
||||
execSync('systemctl stop nupst.service');
|
||||
console.log('NUPST service stopped');
|
||||
logger.success('NUPST service stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop service:', error);
|
||||
logger.error(`Failed to stop service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of the systemd service and UPS
|
||||
* @param debugMode Whether to enable debug mode for SNMP
|
||||
*/
|
||||
public async getStatus(): Promise<void> {
|
||||
public async getStatus(debugMode: boolean = false): Promise<void> {
|
||||
try {
|
||||
// Enable debug mode if requested
|
||||
if (debugMode) {
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Debug Mode', boxWidth);
|
||||
logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown');
|
||||
logger.logBoxEnd();
|
||||
this.daemon.getNupstSnmp().enableDebug();
|
||||
}
|
||||
|
||||
// Display version information
|
||||
this.daemon.getNupstSnmp().getNupst().logVersionInfo();
|
||||
|
||||
@ -141,9 +155,9 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
await this.displayServiceStatus();
|
||||
await this.displayUpsStatus();
|
||||
await this.displayAllUpsStatus();
|
||||
} catch (error) {
|
||||
console.error(`Failed to get status: ${error.message}`);
|
||||
logger.error(`Failed to get status: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,42 +168,115 @@ WantedBy=multi-user.target
|
||||
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('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Service Status', boxWidth);
|
||||
// Process each line of the status output
|
||||
serviceStatus.split('\n').forEach(line => {
|
||||
logger.logBoxLine(line);
|
||||
});
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
console.error('┌─ Service Status ─────────────────────────┐');
|
||||
console.error('│ Service is not running');
|
||||
console.error('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Service Status', boxWidth);
|
||||
logger.logBoxLine('Service is not running');
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the UPS status
|
||||
* Display all UPS statuses
|
||||
* @private
|
||||
*/
|
||||
private async displayUpsStatus(): Promise<void> {
|
||||
private async displayAllUpsStatus(): Promise<void> {
|
||||
try {
|
||||
// Explicitly load the configuration first to ensure it's up-to-date
|
||||
await this.daemon.loadConfig();
|
||||
const config = this.daemon.getConfig();
|
||||
const snmp = this.daemon.getNupstSnmp();
|
||||
|
||||
// Create a test config with appropriate timeout, similar to the test command
|
||||
const snmpConfig = {
|
||||
...config.snmp,
|
||||
timeout: Math.min(config.snmp.timeout, 10000) // Use at most 10 seconds for status check
|
||||
// Check if we have the new multi-UPS config format
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
|
||||
logger.log(`Found ${config.upsDevices.length} UPS device(s) in configuration`);
|
||||
|
||||
// Show status for each UPS
|
||||
for (const ups of config.upsDevices) {
|
||||
await this.displaySingleUpsStatus(ups, snmp);
|
||||
}
|
||||
} else if (config.snmp) {
|
||||
// Legacy single UPS configuration
|
||||
const legacyUps = {
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds,
|
||||
groups: []
|
||||
};
|
||||
|
||||
const status = await snmp.getUpsStatus(snmpConfig);
|
||||
|
||||
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('└──────────────────────────────────────────┘');
|
||||
await this.displaySingleUpsStatus(legacyUps, snmp);
|
||||
} else {
|
||||
logger.error('No UPS devices found in configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('┌─ UPS Status ───────────────────────────────┐');
|
||||
console.error(`│ Failed to retrieve UPS status: ${error.message}`);
|
||||
console.error('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('UPS Status', boxWidth);
|
||||
logger.logBoxLine(`Failed to retrieve UPS status: ${error.message}`);
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display status of a single UPS
|
||||
* @param ups UPS configuration
|
||||
* @param snmp SNMP manager
|
||||
*/
|
||||
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle(`Connecting to UPS: ${ups.name}`, boxWidth);
|
||||
logger.logBoxLine(`ID: ${ups.id}`);
|
||||
logger.logBoxLine(`Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel || 'cyberpower'}`);
|
||||
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
// Get group names if available
|
||||
const config = this.daemon.getConfig();
|
||||
const groupNames = ups.groups.map(groupId => {
|
||||
const group = config.groups?.find(g => g.id === groupId);
|
||||
return group ? group.name : groupId;
|
||||
});
|
||||
logger.logBoxLine(`Groups: ${groupNames.join(', ')}`);
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
|
||||
try {
|
||||
// Create a test config with a short timeout
|
||||
const testConfig = {
|
||||
...ups.snmp,
|
||||
timeout: Math.min(ups.snmp.timeout, 10000) // Use at most 10 seconds for status check
|
||||
};
|
||||
|
||||
const status = await snmp.getUpsStatus(testConfig);
|
||||
|
||||
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth);
|
||||
logger.logBoxLine(`Power Status: ${status.powerStatus}`);
|
||||
logger.logBoxLine(`Battery Capacity: ${status.batteryCapacity}%`);
|
||||
logger.logBoxLine(`Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
|
||||
// Show threshold status
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Thresholds:');
|
||||
logger.logBoxLine(` Battery: ${status.batteryCapacity}% / ${ups.thresholds.battery}% ${
|
||||
status.batteryCapacity < ups.thresholds.battery ? '⚠️' : '✓'
|
||||
}`);
|
||||
logger.logBoxLine(` Runtime: ${status.batteryRuntime} min / ${ups.thresholds.runtime} min ${
|
||||
status.batteryRuntime < ups.thresholds.runtime ? '⚠️' : '✓'
|
||||
}`);
|
||||
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth);
|
||||
logger.logBoxLine(`Failed to retrieve UPS status: ${error.message}`);
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,10 +292,10 @@ WantedBy=multi-user.target
|
||||
|
||||
// Reload systemd daemon
|
||||
execSync('systemctl daemon-reload');
|
||||
console.log('Systemd daemon reloaded');
|
||||
console.log('NUPST service has been successfully uninstalled');
|
||||
logger.log('Systemd daemon reloaded');
|
||||
logger.success('NUPST service has been successfully uninstalled');
|
||||
} catch (error) {
|
||||
console.error('Failed to disable and uninstall service:', error);
|
||||
logger.error(`Failed to disable and uninstall service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -219,11 +306,11 @@ WantedBy=multi-user.target
|
||||
*/
|
||||
private async stopService(): Promise<void> {
|
||||
try {
|
||||
console.log('Stopping NUPST service...');
|
||||
logger.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');
|
||||
logger.log('Service was not running or could not be stopped');
|
||||
}
|
||||
}
|
||||
|
||||
@ -233,10 +320,10 @@ WantedBy=multi-user.target
|
||||
*/
|
||||
private async disableService(): Promise<void> {
|
||||
try {
|
||||
console.log('Disabling NUPST service...');
|
||||
logger.log('Disabling NUPST service...');
|
||||
execSync('systemctl disable nupst.service');
|
||||
} catch (error) {
|
||||
console.log('Service was not enabled or could not be disabled');
|
||||
logger.log('Service was not enabled or could not be disabled');
|
||||
}
|
||||
}
|
||||
|
||||
@ -246,11 +333,11 @@ WantedBy=multi-user.target
|
||||
*/
|
||||
private async removeServiceFile(): Promise<void> {
|
||||
if (await fs.stat(this.serviceFilePath).catch(() => null)) {
|
||||
console.log(`Removing service file ${this.serviceFilePath}...`);
|
||||
logger.log(`Removing service file ${this.serviceFilePath}...`);
|
||||
await fs.unlink(this.serviceFilePath);
|
||||
console.log('Service file removed');
|
||||
logger.log('Service file removed');
|
||||
} else {
|
||||
console.log('Service file did not exist');
|
||||
logger.log('Service file did not exist');
|
||||
}
|
||||
}
|
||||
}
|
55
uninstall.sh
55
uninstall.sh
@ -5,13 +5,22 @@
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (sudo ./uninstall.sh)"
|
||||
echo "Please run as root (sudo nupst uninstall or sudo ./uninstall.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# This script can be called directly or through the CLI
|
||||
# When called through the CLI, environment variables are set
|
||||
# REMOVE_CONFIG=yes|no - whether to remove configuration files
|
||||
# REMOVE_REPO=yes|no - whether to remove the repository
|
||||
|
||||
# If not set through CLI, use defaults
|
||||
REMOVE_CONFIG=${REMOVE_CONFIG:-"no"}
|
||||
REMOVE_REPO=${REMOVE_REPO:-"no"}
|
||||
|
||||
echo "NUPST Uninstaller"
|
||||
echo "================="
|
||||
echo "This script will completely remove NUPST from your system."
|
||||
echo "This 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 )"
|
||||
@ -37,20 +46,52 @@ if [ -L "/usr/local/bin/nupst" ]; then
|
||||
rm -f /usr/local/bin/nupst
|
||||
fi
|
||||
|
||||
# Step 3: Ask about removing configuration
|
||||
# Step 3: Remove configuration if requested
|
||||
if [ "$REMOVE_CONFIG" = "yes" ]; then
|
||||
echo "Removing configuration files..."
|
||||
rm -rf /etc/nupst
|
||||
else
|
||||
# If not called through CLI, ask user
|
||||
if [ -z "$NUPST_CLI_CALL" ]; then
|
||||
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
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4: Check if this was a git installation
|
||||
# Step 4: Remove repository if requested
|
||||
if [ "$REMOVE_REPO" = "yes" ]; then
|
||||
if [ -d "$SCRIPT_DIR/.git" ]; then
|
||||
echo "Removing NUPST repository directory..."
|
||||
|
||||
# Get parent directory to remove it after the script exits
|
||||
PARENT_DIR=$(dirname "$SCRIPT_DIR")
|
||||
REPO_NAME=$(basename "$SCRIPT_DIR")
|
||||
|
||||
# Create a temporary cleanup script
|
||||
CLEANUP_SCRIPT=$(mktemp)
|
||||
echo "#!/bin/bash" > "$CLEANUP_SCRIPT"
|
||||
echo "sleep 1" >> "$CLEANUP_SCRIPT"
|
||||
echo "rm -rf \"$SCRIPT_DIR\"" >> "$CLEANUP_SCRIPT"
|
||||
echo "echo \"NUPST repository has been removed.\"" >> "$CLEANUP_SCRIPT"
|
||||
chmod +x "$CLEANUP_SCRIPT"
|
||||
|
||||
# Run the cleanup script in the background
|
||||
nohup "$CLEANUP_SCRIPT" > /dev/null 2>&1 &
|
||||
|
||||
echo "NUPST repository will be removed after uninstaller exits."
|
||||
else
|
||||
echo "No git repository found."
|
||||
fi
|
||||
else
|
||||
# If not requested, just display info
|
||||
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"
|
||||
echo "NUPST repository at $SCRIPT_DIR will remain intact."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for npm global installation
|
||||
|
Loading…
x
Reference in New Issue
Block a user