From 42b8eaf6d2bb8dc036fd447813579cf9b9283b03 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 20 Feb 2026 11:51:59 +0000 Subject: [PATCH] feat(daemon): Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2 --- changelog.md | 12 + package.json | 6 +- readme.hints.md | 53 +- readme.md | 999 +++++++++++------------- ts/00_commitinfo_data.ts | 2 +- ts/actions/base-action.ts | 24 +- ts/actions/index.ts | 4 + ts/actions/proxmox-action.ts | 352 +++++++++ ts/actions/shutdown-action.ts | 13 +- ts/actions/webhook-action.ts | 2 +- ts/cli.ts | 41 +- ts/cli/action-handler.ts | 31 +- ts/cli/service-handler.ts | 124 +++ ts/cli/ups-handler.ts | 427 ++++++++-- ts/colors.ts | 4 +- ts/constants.ts | 56 ++ ts/daemon.ts | 261 ++++++- ts/http-server.ts | 14 +- ts/migrations/index.ts | 1 + ts/migrations/migration-runner.ts | 3 +- ts/migrations/migration-v4.1-to-v4.2.ts | 43 + ts/nupst.ts | 12 +- ts/protocol/index.ts | 7 + ts/protocol/resolver.ts | 49 ++ ts/protocol/types.ts | 4 + ts/snmp/types.ts | 2 +- ts/systemd.ts | 37 +- ts/upsd/client.ts | 269 +++++++ ts/upsd/index.ts | 7 + ts/upsd/types.ts | 21 + 30 files changed, 2183 insertions(+), 697 deletions(-) create mode 100644 ts/actions/proxmox-action.ts create mode 100644 ts/migrations/migration-v4.1-to-v4.2.ts create mode 100644 ts/protocol/index.ts create mode 100644 ts/protocol/resolver.ts create mode 100644 ts/protocol/types.ts create mode 100644 ts/upsd/client.ts create mode 100644 ts/upsd/index.ts create mode 100644 ts/upsd/types.ts diff --git a/changelog.md b/changelog.md index f1adf39..cbeec67 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2026-02-20 - 5.3.0 - feat(daemon) +Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2 + +- Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS queries (snmp or upsd). +- Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client, and route status requests through ProtocolResolver. +- Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions while unreachable. +- Implement pause/resume feature: PAUSE.FILE_PATH state file, CLI commands (pause/resume), daemon pause-state polling, auto-resume, and include pause state in HTTP API responses. +- Add ProxmoxAction (ts/actions/proxmox-action.ts) with Proxmox API interaction, configuration options (token, node, timeout, force, insecure) and CLI prompts to configure proxmox actions. +- CLI and UI updates: protocol selection when adding UPS, protocol/host shown in lists, action details column supports proxmox, and status displays include protocol and unreachable state. +- Add migration MigrationV4_1ToV4_2 to set protocol:'snmp' for existing devices and bump config.version to '4.2'. +- Add new constants (NETWORK, UPSD, PAUSE, PROXMOX), update package.json scripts (test/build/lint/format), and wire protocol support across daemon, systemd, http-server, and various handlers. + ## 2026-01-29 - 5.2.4 - fix() no changes diff --git a/package.json b/package.json index 3e31bb9..fe5ac07 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,10 @@ "scripts": { "postinstall": "node scripts/install-binary.js", "prepublishOnly": "echo 'Publishing NUPST binaries to npm...'", - "test": "echo 'Tests are run with Deno: deno task test'", - "build": "echo 'no build needed'" + "test": "deno task test", + "build": "deno task check", + "lint": "deno task lint", + "format": "deno task fmt" }, "files": [ "bin/", diff --git a/readme.hints.md b/readme.hints.md index 40aa557..b9747cd 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -12,8 +12,9 @@ 2. **Constants File (`ts/constants.ts`)** - Centralized all magic numbers (timeouts, intervals, thresholds) - - Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI` - - Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts` + - Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`, + `NETWORK`, `UPSD`, `PAUSE`, `PROXMOX` + - Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts` 3. **Logger Consistency** - Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls @@ -35,13 +36,47 @@ - Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, `ISnmpUpsStatus` +## Features Added (February 2026) + +### Network Loss Handling +- `TPowerStatus` extended with `'unreachable'` state +- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking +- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable` +- Shutdown action explicitly won't fire on `unreachable` (prevents false shutdowns) +- Recovery is logged when UPS comes back from unreachable + +### UPSD/NIS Protocol Support +- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers +- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries +- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'` +- `IUpsConfig.snmp` is now optional (not needed for UPSD devices) +- CLI supports protocol selection during `nupst ups add` +- Config version bumped to `4.2` with migration from `4.1` + +### Pause/Resume Command +- File-based signaling via `/etc/nupst/pause` JSON file +- `nupst pause [--duration 30m|2h|1d]` creates pause file +- `nupst resume` deletes pause file +- Daemon polls continue but actions are suppressed while paused +- Auto-resume after duration expires +- HTTP API includes pause state in response + +### Proxmox VM Shutdown Action +- New action type `'proxmox'` in `ts/actions/proxmox-action.ts` +- Uses Proxmox REST API with PVEAPIToken authentication +- Shuts down QEMU VMs and LXC containers before host shutdown +- Supports: exclude IDs, configurable timeout, force-stop, TLS skip for self-signed certs +- Should be placed BEFORE shutdown actions in the action chain + ## Architecture Notes - **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular imports +- **Protocol Resolver**: Routes to SNMP or UPSD based on `IUpsConfig.protocol` - **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input - **Constants**: All timing values should be referenced from `ts/constants.ts` - **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration +- **Config version**: Currently `4.2`, migrations run automatically ## File Organization @@ -54,9 +89,21 @@ ts/ │ ├── prompt.ts # Readline utility │ └── shortid.ts # ID generation ├── actions/ -│ ├── base-action.ts # Base action class and interfaces +│ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus │ ├── webhook-action.ts # Includes IWebhookPayload +│ ├── proxmox-action.ts # Proxmox VM/LXC shutdown │ └── ... +├── upsd/ +│ ├── types.ts # IUpsdConfig +│ ├── client.ts # NupstUpsd TCP client +│ └── index.ts +├── protocol/ +│ ├── types.ts # TProtocol = 'snmp' | 'upsd' +│ ├── resolver.ts # ProtocolResolver +│ └── index.ts +├── migrations/ +│ ├── migration-runner.ts +│ └── migration-v4.1-to-v4.2.ts # Adds protocol field └── cli/ └── ... # All handlers use helpers.withPrompt() ``` diff --git a/readme.md b/readme.md index bccf36c..13d8ec6 100644 --- a/readme.md +++ b/readme.md @@ -1,60 +1,48 @@ -# ⚡ NUPST - Network UPS Shutdown Tool +# ⚡ NUPST — Network UPS Shutdown Tool -**Keep your systems safe when the power goes out.** NUPST is a lightweight, battle-tested -command-line tool that monitors SNMP-enabled UPS devices and orchestrates graceful system shutdowns -during power emergencies. Distributed as self-contained binaries with zero runtime dependencies for -maximum reliability. +**Keep your systems safe when the power goes out.** NUPST is a lightweight, battle-tested CLI tool that monitors UPS devices via SNMP or NUT (UPSD) and orchestrates graceful shutdowns during power emergencies — including Proxmox VMs, LXC containers, and the host itself. -**Version 5.0+** is powered by Deno and distributed as single pre-compiled binaries—no installation, -no setup, just run. +Distributed as **self-contained binaries** with zero runtime dependencies. No Node.js, no Python, no package managers. Just download and run. ## Issue Reporting and Security -For reporting bugs, issues, or security vulnerabilities, please visit -[community.foss.global/](https://community.foss.global/). This is the central community hub for all -issue reporting. Developers who sign and comply with our contribution agreement and go through -identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull -Requests directly. +For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. ## ✨ Features -- **🔌 Multi-UPS Support**: Monitor multiple UPS devices from a single installation -- **👥 Group Management**: Organize UPS devices into groups with flexible operating modes - - **Redundant Mode**: Only shutdown when ALL UPS devices in a group are critical - - **Non-Redundant Mode**: Shutdown when ANY UPS device in a group is critical -- **⚙️ Action System**: Define custom actions with flexible trigger conditions - - Battery threshold triggers - - Runtime threshold triggers +- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon +- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT +- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown +- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes + - **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical + - **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical +- **⚙️ Action System** — Define custom responses with flexible trigger conditions + - Battery & runtime threshold triggers - Power status change triggers - - Webhook notifications + - Webhook notifications (POST/GET) - Custom shell scripts + - Proxmox VM/LXC shutdown - Configurable shutdown delays -- **🌐 Universal SNMP Support**: Full support for SNMP v1, v2c, and v3 with authentication and - encryption -- **🏭 Multiple UPS Brands**: Works with CyberPower, APC, Eaton, TrippLite, Liebert/Vertiv, and - custom OID configurations -- **🚀 Systemd Integration**: Simple service installation and management -- **📊 Real-time Monitoring**: Live status updates with detailed action and group information -- **🌐 HTTP API**: Optional HTTP server for JSON status export with authentication -- **⚡ Power Metrics**: Monitor output load, power (watts), voltage, and current for all UPS devices -- **📦 Self-Contained Binary**: Single executable with zero runtime dependencies—just download and - run -- **🖥️ Cross-Platform**: Binaries available for Linux (x64, ARM64), macOS (Intel, Apple Silicon), - and Windows +- **🏭 Multiple UPS Brands** — CyberPower, APC, Eaton, TrippLite, Liebert/Vertiv, and custom OID configurations +- **🌐 HTTP API** — Optional JSON status endpoint with token authentication +- **⏸️ Pause/Resume** — Temporarily suppress actions during maintenance windows +- **🛡️ Network Loss Detection** — Detects unreachable UPS devices and prevents false shutdowns +- **📊 Power Metrics** — Monitor output load, power (watts), voltage, and current +- **📦 Single Binary** — Zero runtime dependencies. Download, `chmod +x`, run. +- **🖥️ Cross-Platform** — Linux (x64, ARM64), macOS (Intel, Apple Silicon), Windows ## 🚀 Quick Start ### One-Line Installation ```bash -# Download and install NUPST automatically curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash ``` ### Initial Setup ```bash -# 1. Add your first UPS device +# 1. Add your first UPS device (interactive wizard) sudo nupst ups add # 2. Test the connection @@ -72,9 +60,7 @@ nupst service status ## 📥 Installation -### Automated Installer Script (Recommended) - -The installer script handles everything automatically: +### Automated Installer (Recommended) ```bash curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash @@ -82,13 +68,13 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | **What it does:** -1. Detects your platform (OS and architecture) +1. Detects your platform (OS + architecture) 2. Downloads the latest pre-compiled binary 3. Installs to `/opt/nupst/nupst` 4. Creates symlink at `/usr/local/bin/nupst` 5. Preserves existing configuration -### Installer Options +**Options:** ```bash # Install specific version @@ -105,8 +91,7 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | ### Manual Installation -Download the appropriate binary for your platform from -[releases](https://code.foss.global/serve.zone/nupst/releases): +Download the binary for your platform from [releases](https://code.foss.global/serve.zone/nupst/releases): | Platform | Binary | | ------------------- | ----------------------- | @@ -117,219 +102,122 @@ Download the appropriate binary for your platform from | Windows x64 | `nupst-windows-x64.exe` | ```bash -# Download binary (replace with your platform) +# Download (replace with your platform) curl -sSL https://code.foss.global/serve.zone/nupst/releases/download/v5.0.0/nupst-linux-x64 -o nupst - -# Make executable chmod +x nupst - -# Move to system path sudo mv nupst /usr/local/bin/nupst ``` -### Alternative: Via npm - -Alternatively, NUPST can be installed via npm: +### Via npm ```bash npm install -g @serve.zone/nupst ``` -**Note:** This method downloads the appropriate pre-compiled binary for your platform during -installation. +> This downloads the appropriate pre-compiled binary for your platform during installation. ### Verify Installation ```bash -# Check version nupst --version - -# View help nupst help ``` -## 📖 Usage +## 📖 CLI Reference ### Command Structure -NUPST uses an intuitive subcommand structure: +``` +nupst [subcommand] [options] +``` -``` -nupst [options] -``` +### Global Options + +| Flag | Description | +| ---------------- | -------------------------------------- | +| `--version`, `-v` | Show version | +| `--help`, `-h` | Show help | +| `--debug`, `-d` | Enable debug mode (verbose SNMP/UPSD logging) | ### Service Management ```bash -nupst service enable # Install and enable systemd service -nupst service disable # Stop and disable systemd service -nupst service start # Start the service -nupst service stop # Stop the service -nupst service restart # Restart the service -nupst service status # Show service and UPS status -nupst service logs # Show live service logs +nupst service enable # Install and enable systemd service +nupst service disable # Stop and disable systemd service +nupst service start # Start the service +nupst service stop # Stop the service +nupst service restart # Restart the service +nupst service status # Show service and UPS status +nupst service logs # Tail live service logs (Ctrl+C to exit) ``` ### UPS Device Management ```bash -nupst ups add # Add a new UPS device (interactive) -nupst ups edit [id] # Edit a UPS device -nupst ups remove # Remove a UPS device -nupst ups list # List all UPS devices -nupst ups test # Test UPS connections +nupst ups add # Add a new UPS device (interactive wizard) +nupst ups edit [id] # Edit a UPS device +nupst ups remove # Remove a UPS device +nupst ups list # List all UPS devices +nupst ups test # Test all UPS connections ``` +During `nupst ups add`, you'll choose a communication protocol: + +- **SNMP** — For network-attached UPS with an SNMP agent (default) +- **UPSD/NIS** — For USB-connected UPS managed by a local NUT server + ### Group Management ```bash -nupst group add # Create a new UPS group -nupst group edit # Edit a group -nupst group remove # Remove a group -nupst group list # List all groups +nupst group add # Create a new UPS group +nupst group edit # Edit a group +nupst group remove # Remove a group +nupst group list # List all groups ``` ### Action Management -Actions define what happens when UPS conditions are met. Actions can be attached to individual UPS -devices or to groups. - ```bash -# Add an action to a UPS device or group -nupst action add - -# Remove an action by index -nupst action remove - -# List all actions -nupst action list - -# List actions for specific UPS/group -nupst action list +nupst action add # Add action to a UPS or group +nupst action remove # Remove an action by index +nupst action list [target-id] # List actions (optionally for a target) ``` -**Supported Action Types:** +### Pause/Resume -| Type | Description | -| ---------- | ------------------------------------------------ | -| `shutdown` | Graceful system shutdown with configurable delay | -| `webhook` | HTTP POST/GET notification to external services | -| `script` | Execute custom shell scripts from `/etc/nupst/` | - -**Example: Adding an action** +Temporarily suppress actions during maintenance (UPS polling continues): ```bash -$ sudo nupst action add ups-main - -Add Action to UPS Main Server UPS - - Action type: shutdown - Battery threshold (%): 20 - Runtime threshold (minutes): 10 - - Trigger mode: - 1) onlyPowerChanges - Trigger only when power status changes - 2) onlyThresholds - Trigger only when thresholds are violated - 3) powerChangesAndThresholds - Trigger on power change AND thresholds - 4) anyChange - Trigger on any status change - Choice [2]: 2 - - Shutdown delay (seconds) [5]: 10 - -✓ Action added to UPS Main Server UPS - Changes saved and will be applied automatically +nupst pause # Pause indefinitely +nupst pause --duration 30m # Pause for 30 minutes (auto-resume) +nupst pause --duration 2h # Pause for 2 hours +nupst pause --duration 1d # Pause for 1 day (max: 24h) +nupst resume # Resume immediately ``` +When paused: +- UPS polling continues (status is still visible) +- All actions are suppressed (no shutdowns, webhooks, scripts) +- The HTTP API response includes `"paused": true` +- Status display shows a `[PAUSED]` indicator + ### Feature Management -Optional features like the HTTP server for JSON status export: - ```bash -# Configure HTTP server feature (interactive) -nupst feature httpServer +nupst feature httpServer # Configure HTTP JSON status API ``` -**Example: Enabling HTTP Server** +### Other Commands ```bash -$ sudo nupst feature httpServer - -HTTP Server Feature Configuration -Configure the HTTP server to expose UPS status as JSON - -HTTP Server is currently: DISABLED - -Enable or disable HTTP server? (enable/disable/cancel): enable - -HTTP Server Port [8080]: 8080 -URL Path [/ups-status]: /ups-status -Generated new authentication token - -✓ HTTP Server Configuration - - Status: ENABLED - Port: 8080 - Path: /ups-status - Auth Token: abc123xyz789def456 - - Usage examples: - curl -H "Authorization: Bearer abc123xyz789def456" http://localhost:8080/ups-status - curl "http://localhost:8080/ups-status?token=abc123xyz789def456" - -⚠ IMPORTANT: Save the authentication token securely! - -Service is running. Restart to apply changes? (Y/n): Y -``` - -**Query UPS Status via HTTP:** - -```bash -# Using Bearer token in header -curl -H "Authorization: Bearer abc123xyz789def456" \ - http://localhost:8080/ups-status - -# Using token as query parameter -curl "http://localhost:8080/ups-status?token=abc123xyz789def456" -``` - -**JSON Response:** - -```json -[ - { - "id": "ups-main", - "name": "Main Server UPS", - "powerStatus": "online", - "batteryCapacity": 100, - "batteryRuntime": 45, - "outputLoad": 23, - "outputPower": 115, - "outputVoltage": 230.5, - "outputCurrent": 0.5, - "lastStatusChange": 1729685123456, - "lastCheckTime": 1729685153456 - } -] -``` - -### Configuration - -```bash -nupst config show # Display current configuration -``` - -### Global Options - -```bash ---version, -v # Show version information ---help, -h # Show help message ---debug, -d # Enable debug mode (detailed SNMP logging) +nupst config show # Display current configuration +nupst update # Update to latest version (requires root) +nupst uninstall # Completely remove NUPST (requires root) ``` ## ⚙️ Configuration -NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through -interactive commands, but you can also edit the JSON directly. +NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the interactive CLI commands, but you can also edit the JSON directly. ### Example Configuration @@ -341,12 +229,13 @@ interactive commands, but you can also edit the JSON directly. "enabled": true, "port": 8080, "path": "/ups-status", - "authToken": "abc123xyz789def456" + "authToken": "your-secret-token" }, "upsDevices": [ { "id": "ups-main", "name": "Main Server UPS", + "protocol": "snmp", "snmp": { "host": "192.168.1.100", "port": 161, @@ -357,40 +246,42 @@ interactive commands, but you can also edit the JSON directly. }, "actions": [ { - "type": "shutdown", - "thresholds": { - "battery": 20, - "runtime": 10 - }, + "type": "proxmox", "triggerMode": "onlyThresholds", + "thresholds": { "battery": 30, "runtime": 15 }, + "proxmoxHost": "localhost", + "proxmoxPort": 8006, + "proxmoxTokenId": "root@pam!nupst", + "proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + }, + { + "type": "shutdown", + "triggerMode": "onlyThresholds", + "thresholds": { "battery": 20, "runtime": 10 }, "shutdownDelay": 10 } ], "groups": ["datacenter"] }, { - "id": "ups-backup", - "name": "Backup UPS", - "snmp": { - "host": "192.168.1.101", - "port": 161, - "community": "public", - "version": 1, - "timeout": 5000, - "upsModel": "apc" + "id": "ups-usb", + "name": "Local USB UPS", + "protocol": "upsd", + "upsd": { + "host": "127.0.0.1", + "port": 3493, + "upsName": "ups", + "timeout": 5000 }, "actions": [ { "type": "shutdown", - "thresholds": { - "battery": 15, - "runtime": 5 - }, "triggerMode": "onlyThresholds", + "thresholds": { "battery": 15, "runtime": 5 }, "shutdownDelay": 5 } ], - "groups": ["datacenter"] + "groups": [] } ], "groups": [ @@ -398,15 +289,12 @@ interactive commands, but you can also edit the JSON directly. "id": "datacenter", "name": "Data Center", "mode": "redundant", - "description": "Redundant UPS setup - only shutdown when both are critical", + "description": "Redundant UPS setup", "actions": [ { "type": "shutdown", - "thresholds": { - "battery": 10, - "runtime": 5 - }, "triggerMode": "onlyThresholds", + "thresholds": { "battery": 10, "runtime": 5 }, "shutdownDelay": 15 } ] @@ -415,337 +303,401 @@ interactive commands, but you can also edit the JSON directly. } ``` -### Configuration Fields +### UPS Device Configuration -#### Global Settings +#### Protocol Selection -- **`version`**: Config format version (current: "4.2") -- **`checkInterval`**: Polling interval in milliseconds (default: 30000) -- **`httpServer`**: Optional HTTP server configuration (see HTTP Server Configuration below) +Each UPS device has a `protocol` field: -#### UPS Device Settings +| Protocol | Use Case | Default Port | +| -------- | -------- | ------------ | +| `snmp` | Network-attached UPS with SNMP agent | 161 | +| `upsd` | USB-connected UPS via local NUT server | 3493 | -- **`id`**: Unique identifier for the UPS -- **`name`**: Friendly name -- **`groups`**: Array of group IDs this UPS belongs to -- **`actions`**: Array of action configurations (see Actions section) +#### SNMP Settings (`snmp` object) -**SNMP Configuration:** +| Field | Description | Values / Default | +| ----------- | -------------------------- | -------------------------------------------------------------- | +| `host` | IP address or hostname | e.g., `"192.168.1.100"` | +| `port` | SNMP port | Default: `161` | +| `version` | SNMP version | `1`, `2`, or `3` | +| `timeout` | Timeout in milliseconds | Default: `5000` | +| `upsModel` | UPS brand/model | `cyberpower`, `apc`, `eaton`, `tripplite`, `liebert`, `custom` | +| `community` | Community string (v1/v2c) | Default: `"public"` | -| Field | Description | Values | -| ----------- | ----------------------- | -------------------------------------------------------------- | -| `host` | IP address or hostname | e.g., "192.168.1.100" | -| `port` | SNMP port | Default: 161 | -| `version` | SNMP version | 1, 2, or 3 | -| `timeout` | Timeout in milliseconds | Default: 5000 | -| `upsModel` | UPS brand/model | 'cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom' | -| `community` | SNMP community (v1/v2c) | Default: "public" | +**SNMPv3 fields** (when `version: 3`): -**SNMPv3 Security:** +| Field | Description | Values | +| --------------- | ------------------------ | ----------------------------------- | +| `securityLevel` | Security level | `noAuthNoPriv`, `authNoPriv`, `authPriv` | +| `username` | Authentication username | — | +| `authProtocol` | Auth protocol | `MD5` or `SHA` | +| `authKey` | Auth password | — | +| `privProtocol` | Encryption protocol | `DES` or `AES` | +| `privKey` | Encryption password | — | -| Field | Description | -| --------------- | ------------------------------------------- | -| `securityLevel` | 'noAuthNoPriv', 'authNoPriv', or 'authPriv' | -| `username` | SNMPv3 username | -| `authProtocol` | 'MD5' or 'SHA' | -| `authKey` | Authentication password | -| `privProtocol` | 'DES' or 'AES' (for authPriv) | -| `privKey` | Privacy/encryption password | +#### UPSD/NIS Settings (`upsd` object) -#### Action Configuration +For USB-connected UPS via [NUT (Network UPS Tools)](https://networkupstools.org/): -Actions define automated responses to UPS conditions: +| Field | Description | Default | +| ---------- | --------------------------- | ------------- | +| `host` | NUT server address | `127.0.0.1` | +| `port` | NUT UPSD port | `3493` | +| `upsName` | NUT device name | `ups` | +| `timeout` | Connection timeout (ms) | `5000` | +| `username` | Optional auth username | — | +| `password` | Optional auth password | — | + +**NUT variables mapped:** `ups.status`, `battery.charge`, `battery.runtime`, `ups.load`, `ups.realpower`, `output.voltage`, `output.current` + +### Action Configuration + +Actions define automated responses to UPS conditions. They run **sequentially in array order**, so place Proxmox actions before shutdown actions. + +#### Action Types + +| Type | Description | +| ---------- | ------------------------------------------------------------ | +| `shutdown` | Graceful system shutdown with configurable delay | +| `webhook` | HTTP POST/GET notification to external services | +| `script` | Execute custom shell scripts from `/etc/nupst/` | +| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers via REST API | + +#### Common Fields + +| Field | Description | Values / Default | +| ------------- | -------------------------------- | ---------------------------------------- | +| `type` | Action type | `shutdown`, `webhook`, `script`, `proxmox` | +| `thresholds` | Battery and runtime limits | `{ "battery": 0-100, "runtime": minutes }` | +| `triggerMode` | When to trigger | See Trigger Modes below | + +#### Trigger Modes + +| Mode | Description | +| ----------------------------- | -------------------------------------------------------- | +| `onlyPowerChanges` | Only when power status changes (online ↔ onBattery) | +| `onlyThresholds` | Only when battery or runtime thresholds are violated | +| `powerChangesAndThresholds` | On power changes OR threshold violations (default) | +| `anyChange` | On every polling cycle | + +#### Shutdown Action ```json { "type": "shutdown", - "thresholds": { - "battery": 20, - "runtime": 10 - }, + "thresholds": { "battery": 20, "runtime": 10 }, "triggerMode": "onlyThresholds", "shutdownDelay": 10 } ``` -**Action Fields:** +| Field | Description | Default | +| --------------- | ---------------------------------- | ------- | +| `shutdownDelay` | Seconds to wait before shutdown | `5` | -| Field | Description | Values | -| --------------- | -------------------------------- | -------------------------------------- | -| `type` | Action type | 'shutdown', 'webhook', 'script' | -| `thresholds` | Battery and runtime limits | `{ battery: 0-100, runtime: minutes }` | -| `triggerMode` | When to trigger action | See Trigger Modes below | -| `shutdownDelay` | Delay before executing (seconds) | Default: 5 | - -**Webhook-Specific Fields:** - -| Field | Description | Values | -| ---------------- | --------------------- | --------------- | -| `webhookUrl` | URL to call | HTTP/HTTPS URL | -| `webhookMethod` | HTTP method | 'POST' or 'GET' | -| `webhookTimeout` | Request timeout in ms | Default: 10000 | - -**Script-Specific Fields:** - -| Field | Description | Values | -| --------------- | -------------------------------- | ------------------- | -| `scriptPath` | Script filename in `/etc/nupst/` | Must end with `.sh` | -| `scriptTimeout` | Execution timeout in ms | Default: 60000 | - -**Trigger Modes:** - -| Mode | Description | -| --------------------------- | -------------------------------------------------------------------------- | -| `onlyPowerChanges` | Trigger only when power status changes (on battery → online or vice versa) | -| `onlyThresholds` | Trigger only when battery or runtime thresholds are violated | -| `powerChangesAndThresholds` | Trigger only when power changes AND thresholds are violated | -| `anyChange` | Trigger on any status change | - -#### Group Settings - -Groups allow coordinated management of multiple UPS devices: +#### Webhook Action ```json { - "id": "datacenter", - "name": "Data Center", - "mode": "redundant", - "description": "Production servers with backup power", - "actions": [...] + "type": "webhook", + "thresholds": { "battery": 30, "runtime": 15 }, + "triggerMode": "powerChangesAndThresholds", + "webhookUrl": "https://hooks.slack.com/services/...", + "webhookMethod": "POST", + "webhookTimeout": 10000 } ``` +| Field | Description | Default | +| ---------------- | -------------------- | -------- | +| `webhookUrl` | URL to call | Required | +| `webhookMethod` | HTTP method | `POST` | +| `webhookTimeout` | Timeout in ms | `10000` | + +#### Script Action + +```json +{ + "type": "script", + "thresholds": { "battery": 25, "runtime": 10 }, + "triggerMode": "onlyThresholds", + "scriptPath": "pre-shutdown.sh", + "scriptTimeout": 60000 +} +``` + +| Field | Description | Default | +| --------------- | -------------------------------------- | ------- | +| `scriptPath` | Script filename in `/etc/nupst/` | Required | +| `scriptTimeout` | Execution timeout in ms | `60000` | + +#### 🖥️ Proxmox Action + +Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down. + +```json +{ + "type": "proxmox", + "thresholds": { "battery": 30, "runtime": 15 }, + "triggerMode": "onlyThresholds", + "proxmoxHost": "localhost", + "proxmoxPort": 8006, + "proxmoxTokenId": "root@pam!nupst", + "proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "proxmoxExcludeIds": [100, 101], + "proxmoxStopTimeout": 120, + "proxmoxForceStop": true, + "proxmoxInsecure": true +} +``` + +| Field | Description | Default | +| --------------------- | ----------------------------------------------- | ------------- | +| `proxmoxHost` | Proxmox API host | `localhost` | +| `proxmoxPort` | Proxmox API port | `8006` | +| `proxmoxNode` | Proxmox node name | Auto-detect via hostname | +| `proxmoxTokenId` | API token ID (e.g. `root@pam!nupst`) | Required | +| `proxmoxTokenSecret` | API token secret (UUID) | Required | +| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` | +| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` | +| `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` | +| `proxmoxInsecure` | Skip TLS verification (self-signed certs) | `true` | + +**Setting up the API token on Proxmox:** + +```bash +# Create token with full privileges (no privilege separation) +pveum user token add root@pam nupst --privsep=0 +``` + +> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so VMs are stopped before the host shuts down. + +### Group Configuration + +Groups coordinate actions across multiple UPS devices: + +| Field | Description | Values | +| ------------- | ---------------------------------- | -------------------- | +| `id` | Unique group identifier | — | +| `name` | Human-readable name | — | +| `mode` | Group operating mode | `redundant`, `nonRedundant` | +| `description` | Optional description | — | +| `actions` | Array of action configurations | — | + **Group Modes:** -- **`redundant`**: System shuts down only when ALL UPS devices in the group are critical. Perfect - for setups with backup UPS units. -- **`nonRedundant`**: System shuts down when ANY UPS device in the group is critical. Used when all - UPS devices must be operational. +- **`redundant`** — Actions trigger only when ALL UPS devices in the group are critical. Use for setups with backup power units. +- **`nonRedundant`** — Actions trigger when ANY UPS device is critical. Use when all UPS units must be operational. -#### HTTP Server Configuration +### HTTP Server Configuration -Enable optional HTTP server for JSON status export with authentication: +```bash +# Interactive setup +sudo nupst feature httpServer +``` ```json { - "enabled": true, - "port": 8080, - "path": "/ups-status", - "authToken": "abc123xyz789def456" + "httpServer": { + "enabled": true, + "port": 8080, + "path": "/ups-status", + "authToken": "your-secret-token" + } } ``` -**HTTP Server Fields:** +**Query the API:** -| Field | Description | Default | -| ----------- | ------------------------------------------------ | -------------- | -| `enabled` | Whether HTTP server is enabled | `false` | -| `port` | TCP port for HTTP server | `8080` | -| `path` | URL path for status endpoint | `/ups-status` | -| `authToken` | Authentication token (required for all requests) | Auto-generated | +```bash +# Bearer token +curl -H "Authorization: Bearer your-secret-token" http://localhost:8080/ups-status -**Authentication Methods:** +# Query parameter +curl "http://localhost:8080/ups-status?token=your-secret-token" +``` -The HTTP server supports two authentication methods: - -1. **Bearer Token** (Header): `Authorization: Bearer ` -2. **Query Parameter**: `?token=` - -**Security Features:** - -- Token-based authentication required for all requests -- Returns 401 Unauthorized for invalid/missing tokens -- Serves cached data from monitoring loop (no extra SNMP queries) -- No CORS headers (local network only) - -**Use Cases:** - -- Integration with monitoring systems (Prometheus, Grafana, etc.) -- Custom dashboards and visualizations -- Mobile apps and web interfaces -- Home automation systems - -### Supported UPS Models - -NUPST includes built-in OID mappings for: - -- **CyberPower** (`cyberpower`) -- **APC** (`apc`) -- **Eaton** (`eaton`) -- **TrippLite** (`tripplite`) -- **Liebert/Vertiv** (`liebert`) -- **Custom OIDs** (`custom`) - -For custom UPS models, specify `customOIDs`: +**Response format:** ```json -"customOIDs": { - "POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0", - "BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0", - "BATTERY_RUNTIME": "1.3.6.1.4.1.1234.1.3.0" +{ + "upsDevices": [ + { + "id": "ups-main", + "name": "Main Server UPS", + "powerStatus": "online", + "batteryCapacity": 100, + "batteryRuntime": 45, + "outputLoad": 23, + "outputPower": 115, + "outputVoltage": 230.5, + "outputCurrent": 0.5, + "consecutiveFailures": 0, + "unreachableSince": 0, + "lastStatusChange": 1729685123456, + "lastCheckTime": 1729685153456 + } + ], + "paused": false } ``` +When monitoring is paused: + +```json +{ + "upsDevices": [...], + "paused": true, + "pauseState": { + "pausedAt": 1729685123456, + "pausedBy": "cli", + "resumeAt": 1729686923456 + } +} +``` + +## 🛡️ Network Loss Detection + +NUPST tracks communication failures per UPS device: + +- After **3 consecutive failures**, the UPS status transitions to `unreachable` +- **Shutdown actions will NOT fire** on `unreachable` — this prevents false shutdowns from network glitches +- Webhook and script actions still fire, allowing you to send alerts +- When connectivity is restored, NUPST logs a recovery event with downtime duration +- The failure counter is capped at 100 to prevent overflow + +**Power status values:** `online` | `onBattery` | `unknown` | `unreachable` + ## 🖥️ Monitoring ### Status Display -The status command shows comprehensive information about your UPS devices, groups, and configured -actions: - ```bash $ nupst service status UPS Devices (2): ✓ Main Server UPS (online - 100%, 3840min) - Host: 192.168.1.100:161 + Host: 192.168.1.100:161 (SNMP) Groups: Data Center + Action: proxmox (onlyThresholds: battery<30%, runtime<15min) Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10s) - ✓ Backup UPS (online - 95%, 2400min) - Host: 192.168.1.101:161 - Groups: Data Center + ✓ Local USB UPS (online - 95%, 2400min) + Host: 127.0.0.1:3493 (UPSD) Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5s) Groups (1): ℹ Data Center (redundant) - Redundant UPS setup - only shutdown when both are critical - UPS Devices (2): Main Server UPS, Backup UPS + UPS Devices (1): Main Server UPS Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15s) ``` ### Live Logs -Monitor NUPST in real-time: - ```bash nupst service logs ``` -Example output: - ``` -[2025-01-15 10:30:15] ℹ NUPST daemon started -[2025-01-15 10:30:15] ✓ Connected to Main Server UPS (192.168.1.100) -[2025-01-15 10:30:15] ✓ Connected to Backup UPS (192.168.1.101) -[2025-01-15 10:30:45] ℹ Status check: All systems normal -[2025-01-15 10:31:15] ⚠ Main Server UPS on battery (85%, 45min remaining) +[2026-02-20 10:30:15] ℹ NUPST daemon started +[2026-02-20 10:30:15] ✓ Connected to Main Server UPS (192.168.1.100) +[2026-02-20 10:30:45] ℹ Status check: All systems normal +[2026-02-20 10:31:15] ⚠ Main Server UPS on battery (85%, 45min remaining) +[2026-02-20 10:35:00] ⚠ UPS Unreachable: Backup UPS (3 consecutive failures) +[2026-02-20 10:37:30] ✓ UPS Recovered: Backup UPS (downtime: 2m 30s) ``` ## 🔒 Security -NUPST is designed with security as a priority: +### Architecture -### Architecture Security - -- **Single Binary**: Self-contained executable with zero runtime dependencies -- **No Installation Required**: Pre-compiled binaries run immediately without package managers -- **Minimal Attack Surface**: Compiled Deno binary with only essential SNMP functionality -- **Reduced Supply Chain Risk**: Pre-compiled binaries with SHA256 checksums -- **Isolated Execution**: Runs with minimal required privileges +- **Single Binary** — Self-contained executable with zero runtime dependencies +- **Minimal Attack Surface** — Compiled Deno binary with only essential functionality +- **Reduced Supply Chain Risk** — Pre-compiled binaries with SHA256 checksums +- **No Telemetry** — No data sent to external servers ### SNMP Security Full SNMPv3 support with authentication and encryption: -| Security Level | Description | -| -------------- | ------------------------------------------------------ | -| `noAuthNoPriv` | No authentication, no encryption (not recommended) | -| `authNoPriv` | MD5/SHA authentication without encryption | -| `authPriv` | Full authentication + DES/AES encryption (recommended) | - -**Example SNMPv3 Configuration:** - -```json -{ - "version": 3, - "securityLevel": "authPriv", - "username": "nupst_monitor", - "authProtocol": "SHA", - "authKey": "your-auth-password", - "privProtocol": "AES", - "privKey": "your-encryption-password" -} -``` +| Security Level | Description | +| -------------- | ------------------------------------------ | +| `noAuthNoPriv` | No authentication, no encryption | +| `authNoPriv` | MD5/SHA authentication without encryption | +| `authPriv` | Authentication + DES/AES encryption ✅ | ### Network Security -- **Local-Only Communication**: Only connects to UPS devices on local network -- **No Telemetry**: No data sent to external servers -- **No Auto-Updates**: Manual update process only -- **HTTP Server** (optional): - - Disabled by default - - Token-based authentication required - - Local network access only (no CORS) - - Serves cached data (no additional SNMP queries) - - Configurable port and path +- Connects only to UPS devices and optionally Proxmox on local network +- HTTP API disabled by default; token-required when enabled +- No external internet connections ### Verifying Downloads -All releases include SHA256 checksums: - ```bash -# Download binary and checksums curl -sSL https://code.foss.global/serve.zone/nupst/releases/download/v5.0.0/nupst-linux-x64 -o nupst curl -sSL https://code.foss.global/serve.zone/nupst/releases/download/v5.0.0/SHA256SUMS.txt -o SHA256SUMS.txt - -# Verify checksum sha256sum -c SHA256SUMS.txt --ignore-missing ``` -## 🔄 Updating NUPST +## 🔒 Supported UPS Models -### Automatic Update +### SNMP-based -Re-run the installer to update to the latest version: +| Brand | Config Value | Notes | +| -------------- | ------------- | ------------------------------- | +| CyberPower | `cyberpower` | Full support including power metrics | +| APC | `apc` | Smart-UPS, Back-UPS series | +| Eaton | `eaton` | Eaton/Powerware UPS | +| TrippLite | `tripplite` | SmartPro and similar | +| Liebert/Vertiv | `liebert` | GXT, PSI series | +| Custom | `custom` | Provide your own OID mappings | + +**Custom OIDs example:** + +```json +{ + "upsModel": "custom", + "customOIDs": { + "POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0", + "BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0", + "BATTERY_RUNTIME": "1.3.6.1.4.1.1234.1.3.0" + } +} +``` + +### UPSD/NIS-based + +Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers **hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the [NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html). + +## 🔄 Updating + +### Built-in Update + +```bash +sudo nupst update +``` + +### Re-run Installer ```bash curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash ``` -The installer will: - -- Download the latest binary -- Replace the existing installation -- Preserve your configuration -- Restart the service if it was running - -### Manual Update - -```bash -# Stop service -sudo nupst service stop - -# Download and install new binary -curl -sSL https://code.foss.global/serve.zone/nupst/releases/download/v5.0.0/nupst-linux-x64 -o nupst -sudo mv nupst /opt/nupst/nupst -sudo chmod +x /opt/nupst/nupst - -# Start service -sudo nupst service start -``` - -### Check for Updates - -```bash -nupst --version -``` - -Visit the [releases page](https://code.foss.global/serve.zone/nupst/releases) for the latest -version. +The installer preserves your configuration and restarts the service if it was running. ## 🗑️ Uninstallation ```bash -# Stop and disable service -sudo nupst service disable +# Interactive uninstall +sudo nupst uninstall -# Remove binary and configuration +# Or manual removal +sudo nupst service disable sudo rm /usr/local/bin/nupst sudo rm -rf /opt/nupst sudo rm -rf /etc/nupst - -# Remove systemd service file (if it exists) sudo rm /etc/systemd/system/nupst.service sudo systemctl daemon-reload ``` @@ -755,79 +707,54 @@ sudo systemctl daemon-reload ### Binary Won't Execute ```bash -# Make executable chmod +x /opt/nupst/nupst - -# Check architecture -uname -m # Should match binary (x86_64 = x64, aarch64 = arm64) +uname -m # x86_64 = x64, aarch64 = arm64 ``` ### Service Won't Start ```bash -# Check service status sudo systemctl status nupst - -# View detailed logs sudo journalctl -u nupst -n 50 - -# Verify configuration nupst config show - -# Test SNMP connectivity nupst ups test --debug ``` ### Can't Connect to UPS ```bash -# Test with debug output +# SNMP nupst ups test --debug +ping +nc -zv 161 -# Check network connectivity -ping - -# Verify SNMP port -nc -zv 161 - -# Check SNMP settings on UPS -# - Ensure SNMP is enabled -# - Verify community string matches -# - Check IP access restrictions +# UPSD/NUT +nc -zv 127.0.0.1 3493 +upsc ups@localhost # if NUT CLI is installed ``` -### Permission Denied Errors - -Most system operations require root: +### Proxmox VMs Not Shutting Down ```bash -# Service management -sudo nupst service enable -sudo nupst service start +# Verify API token works +curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \ + https://localhost:8006/api2/json/nodes/$(hostname)/qemu -# Configuration changes -sudo nupst ups add -sudo nupst action add ups-main +# Check token permissions +pveum user token list root@pam ``` -### Action Not Triggering +### Actions Not Triggering ```bash -# Check action configuration nupst action list - -# View live logs to see trigger evaluation nupst service logs - -# Test with debug mode -sudo nupst service stop -sudo nupst service start-daemon --debug +# Check if monitoring is paused +nupst service status ``` ## 📊 System Changes -When installed, NUPST makes the following changes: - ### File System | Path | Description | @@ -835,6 +762,7 @@ When installed, NUPST makes the following changes: | `/opt/nupst/nupst` | Pre-compiled binary | | `/usr/local/bin/nupst` | Symlink to binary | | `/etc/nupst/config.json` | Configuration file | +| `/etc/nupst/pause` | Pause state file (when paused) | | `/etc/systemd/system/nupst.service` | Systemd service unit | ### Services @@ -844,64 +772,48 @@ When installed, NUPST makes the following changes: ### Network -- Outbound SNMP to UPS devices (default port 161) -- Optional inbound HTTP server (disabled by default, configurable port) +- Outbound SNMP to UPS devices (port 161) +- Outbound TCP to NUT servers (port 3493) +- Outbound HTTPS to Proxmox API (port 8006, if configured) +- Optional inbound HTTP server (disabled by default) - No external internet connections ## 🚀 Migration from v3.x -Upgrading from NUPST v3.x (Node.js) to v4.x+ (Deno) is seamless: - ```bash -# One command to migrate everything curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash ``` -**The installer automatically:** - -- Detects v3.x installation -- Stops the service -- Replaces Node.js version with Deno binary -- Migrates configuration (v4.0 → v4.2 format if needed) -- Restarts the service - -### Key Changes in v4.x+ +The installer auto-detects v3.x installations, migrates the configuration, and swaps the binary. Your settings are preserved. | Aspect | v3.x | v4.x+ | | ------------------------ | -------------------------- | ----------------------------- | -| **Runtime** | Node.js + npm | Deno | +| **Runtime** | Node.js + npm | Deno (self-contained) | | **Distribution** | Git repo + npm install | Pre-compiled binaries | -| **Runtime Dependencies** | node_modules required | Zero (self-contained) | -| **Size** | ~150MB (with node_modules) | ~80MB (single binary) | -| **Startup** | Seconds | Milliseconds | +| **Runtime Dependencies** | node_modules | Zero | +| **Size** | ~150MB | ~80MB | | **Commands** | Flat (`nupst add`) | Subcommands (`nupst ups add`) | -| **Configuration** | UPS-level thresholds | Action-based thresholds | - -### Configuration Compatibility - -Your v3.x configuration is **fully compatible**. The migration system automatically converts older -formats to the current version. ## 💻 Development -### Building from Source - **Requirements:** [Deno](https://deno.land/) v1.x or later ```bash -# Clone repository git clone https://code.foss.global/serve.zone/nupst.git cd nupst # Run directly deno run --allow-all mod.ts help +# Type check +deno task check + +# Lint +deno task lint + # Run tests deno test --allow-all test/ -# Type check -deno check ts/cli.ts - # Compile for current platform deno compile --allow-all --output nupst mod.ts @@ -913,51 +825,44 @@ deno task compile ``` nupst/ -├── mod.ts # Entry point +├── mod.ts # Entry point +├── deno.json # Deno configuration ├── ts/ -│ ├── cli.ts # CLI command routing -│ ├── nupst.ts # Main coordinator class -│ ├── daemon.ts # Background monitoring daemon -│ ├── systemd.ts # Systemd service management -│ ├── constants.ts # Centralized configuration constants -│ ├── interfaces/ # TypeScript interfaces -│ ├── snmp/ # SNMP implementation -│ ├── actions/ # Action system (shutdown, webhook, script) -│ ├── helpers/ # Utility functions -│ ├── migrations/ # Config migration system -│ └── cli/ # CLI handlers -├── test/ # Test files -├── scripts/ # Build scripts -└── deno.json # Deno configuration +│ ├── cli.ts # CLI command routing +│ ├── nupst.ts # Main coordinator class +│ ├── daemon.ts # Background monitoring daemon +│ ├── systemd.ts # Systemd service management +│ ├── http-server.ts # Optional HTTP JSON API +│ ├── constants.ts # Centralized constants +│ ├── snmp/ # SNMP protocol implementation +│ ├── upsd/ # UPSD/NIS protocol implementation (NUT) +│ ├── protocol/ # Protocol abstraction layer +│ ├── actions/ # Action system (shutdown, webhook, script, proxmox) +│ ├── migrations/ # Config version migrations +│ ├── helpers/ # Utility functions +│ ├── interfaces/ # Shared TypeScript interfaces +│ └── cli/ # CLI command handlers +├── scripts/ # Build and install scripts +└── test/ # Test files ``` ## License and Legal Information -This repository contains open-source code licensed under the MIT License. A copy of the license can -be found in the [LICENSE](./LICENSE) file. +This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. -**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. +**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 or third parties, and are not included within the scope of the MIT license granted -herein. +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 or third parties, 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 or the -guidelines of the respective third-party owners, and any usage must be approved in writing. -Third-party trademarks used herein are the property of their respective owners and used only in a -descriptive manner, e.g. for an implementation of an API or similar. +Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar. ### Company Information -Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany +Task Venture Capital GmbH +Registered at District Court Bremen HRB 35230 HB, Germany For any legal inquiries or 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. +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. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index bf381dc..c4c9b12 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/nupst', - version: '5.2.4', + version: '5.3.0', description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' } diff --git a/ts/actions/base-action.ts b/ts/actions/base-action.ts index 6336cc3..ccf5bdd 100644 --- a/ts/actions/base-action.ts +++ b/ts/actions/base-action.ts @@ -6,7 +6,7 @@ * 2. Threshold violations (battery/runtime cross below configured thresholds) */ -export type TPowerStatus = 'online' | 'onBattery' | 'unknown'; +export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable'; /** * Context provided to actions when they execute @@ -52,7 +52,7 @@ export type TActionTriggerMode = */ export interface IActionConfig { /** Type of action to execute */ - type: 'shutdown' | 'webhook' | 'script'; + type: 'shutdown' | 'webhook' | 'script' | 'proxmox'; // Trigger configuration /** @@ -96,6 +96,26 @@ export interface IActionConfig { scriptTimeout?: number; /** Only execute script on threshold violation */ scriptOnlyOnThresholdViolation?: boolean; + + // Proxmox action configuration + /** Proxmox API host (default: localhost) */ + proxmoxHost?: string; + /** Proxmox API port (default: 8006) */ + proxmoxPort?: number; + /** Proxmox node name (default: auto-detect via hostname) */ + proxmoxNode?: string; + /** Proxmox API token ID (e.g., 'root@pam!nupst') */ + proxmoxTokenId?: string; + /** Proxmox API token secret */ + proxmoxTokenSecret?: string; + /** VM/CT IDs to exclude from shutdown */ + proxmoxExcludeIds?: number[]; + /** Timeout for VM/CT shutdown in seconds (default: 120) */ + proxmoxStopTimeout?: number; + /** Force-stop VMs that don't shut down gracefully (default: true) */ + proxmoxForceStop?: boolean; + /** Skip TLS verification for self-signed certificates (default: true) */ + proxmoxInsecure?: boolean; } /** diff --git a/ts/actions/index.ts b/ts/actions/index.ts index 50b8a14..90b841a 100644 --- a/ts/actions/index.ts +++ b/ts/actions/index.ts @@ -10,6 +10,7 @@ import type { Action, IActionConfig, IActionContext } from './base-action.ts'; import { ShutdownAction } from './shutdown-action.ts'; import { WebhookAction } from './webhook-action.ts'; import { ScriptAction } from './script-action.ts'; +import { ProxmoxAction } from './proxmox-action.ts'; // Re-export types for convenience export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts'; @@ -18,6 +19,7 @@ export { Action } from './base-action.ts'; export { ShutdownAction } from './shutdown-action.ts'; export { WebhookAction } from './webhook-action.ts'; export { ScriptAction } from './script-action.ts'; +export { ProxmoxAction } from './proxmox-action.ts'; /** * ActionManager - Coordinates action creation and execution @@ -40,6 +42,8 @@ export class ActionManager { return new WebhookAction(config); case 'script': return new ScriptAction(config); + case 'proxmox': + return new ProxmoxAction(config); default: throw new Error(`Unknown action type: ${(config as IActionConfig).type}`); } diff --git a/ts/actions/proxmox-action.ts b/ts/actions/proxmox-action.ts new file mode 100644 index 0000000..e9331bd --- /dev/null +++ b/ts/actions/proxmox-action.ts @@ -0,0 +1,352 @@ +import * as os from 'node:os'; +import { Action, type IActionContext } from './base-action.ts'; +import { logger } from '../logger.ts'; +import { PROXMOX, UI } from '../constants.ts'; + +/** + * ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers + * + * Uses the Proxmox REST API via HTTPS with API token authentication. + * Shuts down running QEMU VMs and LXC containers, waits for completion, + * and optionally force-stops any that don't respond. + * + * This action should be placed BEFORE shutdown actions in the action chain + * so that VMs are stopped before the host is shut down. + */ +export class ProxmoxAction extends Action { + readonly type = 'proxmox'; + + /** + * Execute the Proxmox shutdown action + */ + async execute(context: IActionContext): Promise { + if (!this.shouldExecute(context)) { + logger.info( + `Proxmox action skipped (trigger mode: ${ + this.config.triggerMode || 'powerChangesAndThresholds' + })`, + ); + return; + } + + const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; + const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; + const node = this.config.proxmoxNode || os.hostname(); + const tokenId = this.config.proxmoxTokenId; + const tokenSecret = this.config.proxmoxTokenSecret; + const excludeIds = new Set(this.config.proxmoxExcludeIds || []); + const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000; + const forceStop = this.config.proxmoxForceStop !== false; // default true + const insecure = this.config.proxmoxInsecure !== false; // default true + + if (!tokenId || !tokenSecret) { + logger.error('Proxmox API token ID and secret are required'); + return; + } + + const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; + const headers: Record = { + 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`, + }; + + logger.log(''); + logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning'); + logger.logBoxLine(`Node: ${node}`); + logger.logBoxLine(`API: ${host}:${port}`); + logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`); + logger.logBoxLine(`Trigger: ${context.triggerReason}`); + if (excludeIds.size > 0) { + logger.logBoxLine(`Excluded IDs: ${[...excludeIds].join(', ')}`); + } + logger.logBoxEnd(); + logger.log(''); + + try { + // Collect running VMs and CTs + const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure); + const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure); + + // Filter out excluded IDs + const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid)); + const ctsToStop = runningCTs.filter((ct) => !excludeIds.has(ct.vmid)); + + const totalToStop = vmsToStop.length + ctsToStop.length; + if (totalToStop === 0) { + logger.info('No running VMs or containers to shut down'); + return; + } + + logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`); + + // Send shutdown commands to all VMs and CTs + for (const vm of vmsToStop) { + await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure); + logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); + } + + for (const ct of ctsToStop) { + await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure); + logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); + } + + // Poll until all stopped or timeout + const allIds = [ + ...vmsToStop.map((vm) => ({ type: 'qemu' as const, vmid: vm.vmid, name: vm.name })), + ...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })), + ]; + + const remaining = await this.waitForShutdown( + baseUrl, + node, + allIds, + headers, + insecure, + stopTimeout, + ); + + if (remaining.length > 0 && forceStop) { + logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`); + for (const item of remaining) { + try { + if (item.type === 'qemu') { + await this.stopVM(baseUrl, node, item.vmid, headers, insecure); + } else { + await this.stopCT(baseUrl, node, item.vmid, headers, insecure); + } + logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`); + } catch (error) { + logger.error( + ` Failed to force-stop ${item.type} ${item.vmid}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } else if (remaining.length > 0) { + logger.warn(`${remaining.length} VMs/CTs still running (force-stop disabled)`); + } + + logger.success('Proxmox shutdown sequence completed'); + } catch (error) { + logger.error( + `Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Make an API request to the Proxmox server + */ + private async apiRequest( + url: string, + method: string, + headers: Record, + insecure: boolean, + ): Promise { + const fetchOptions: RequestInit = { + method, + headers, + }; + + // Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs) + if (insecure) { + // deno-lint-ignore no-explicit-any + (globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'); + } + + try { + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Proxmox API error ${response.status}: ${body}`); + } + + return await response.json(); + } finally { + // Restore TLS verification + if (insecure) { + // deno-lint-ignore no-explicit-any + (globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'); + } + } + } + + /** + * Get list of running QEMU VMs + */ + private async getRunningVMs( + baseUrl: string, + node: string, + headers: Record, + insecure: boolean, + ): Promise> { + try { + const response = await this.apiRequest( + `${baseUrl}/nodes/${node}/qemu`, + 'GET', + headers, + insecure, + ) as { data: Array<{ vmid: number; name: string; status: string }> }; + + return (response.data || []) + .filter((vm) => vm.status === 'running') + .map((vm) => ({ vmid: vm.vmid, name: vm.name || '' })); + } catch (error) { + logger.error( + `Failed to list VMs: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } + + /** + * Get list of running LXC containers + */ + private async getRunningCTs( + baseUrl: string, + node: string, + headers: Record, + insecure: boolean, + ): Promise> { + try { + const response = await this.apiRequest( + `${baseUrl}/nodes/${node}/lxc`, + 'GET', + headers, + insecure, + ) as { data: Array<{ vmid: number; name: string; status: string }> }; + + return (response.data || []) + .filter((ct) => ct.status === 'running') + .map((ct) => ({ vmid: ct.vmid, name: ct.name || '' })); + } catch (error) { + logger.error( + `Failed to list CTs: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } + + /** + * Send graceful shutdown to a QEMU VM + */ + private async shutdownVM( + baseUrl: string, + node: string, + vmid: number, + headers: Record, + insecure: boolean, + ): Promise { + await this.apiRequest( + `${baseUrl}/nodes/${node}/qemu/${vmid}/status/shutdown`, + 'POST', + headers, + insecure, + ); + } + + /** + * Send graceful shutdown to an LXC container + */ + private async shutdownCT( + baseUrl: string, + node: string, + vmid: number, + headers: Record, + insecure: boolean, + ): Promise { + await this.apiRequest( + `${baseUrl}/nodes/${node}/lxc/${vmid}/status/shutdown`, + 'POST', + headers, + insecure, + ); + } + + /** + * Force-stop a QEMU VM + */ + private async stopVM( + baseUrl: string, + node: string, + vmid: number, + headers: Record, + insecure: boolean, + ): Promise { + await this.apiRequest( + `${baseUrl}/nodes/${node}/qemu/${vmid}/status/stop`, + 'POST', + headers, + insecure, + ); + } + + /** + * Force-stop an LXC container + */ + private async stopCT( + baseUrl: string, + node: string, + vmid: number, + headers: Record, + insecure: boolean, + ): Promise { + await this.apiRequest( + `${baseUrl}/nodes/${node}/lxc/${vmid}/status/stop`, + 'POST', + headers, + insecure, + ); + } + + /** + * Wait for VMs/CTs to shut down, return any that are still running after timeout + */ + private async waitForShutdown( + baseUrl: string, + node: string, + items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>, + headers: Record, + insecure: boolean, + timeout: number, + ): Promise> { + const startTime = Date.now(); + let remaining = [...items]; + + while (remaining.length > 0 && (Date.now() - startTime) < timeout) { + // Wait before polling + await new Promise((resolve) => setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)); + + // Check which are still running + const stillRunning: typeof remaining = []; + + for (const item of remaining) { + try { + const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`; + const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as { + data: { status: string }; + }; + + if (response.data?.status === 'running') { + stillRunning.push(item); + } else { + logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`); + } + } catch (_error) { + // If we can't check status, assume it might still be running + stillRunning.push(item); + } + } + + remaining = stillRunning; + + if (remaining.length > 0) { + const elapsed = Math.round((Date.now() - startTime) / 1000); + logger.dim(` Waiting... ${remaining.length} still running (${elapsed}s elapsed)`); + } + } + + return remaining; + } +} diff --git a/ts/actions/shutdown-action.ts b/ts/actions/shutdown-action.ts index 5f8e7e2..a80882a 100644 --- a/ts/actions/shutdown-action.ts +++ b/ts/actions/shutdown-action.ts @@ -34,10 +34,17 @@ export class ShutdownAction extends Action { // CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery // A low battery while on grid power is not an emergency (the battery is charging) + // When UPS is unreachable, we don't know the actual state - don't trigger false shutdown if (context.powerStatus !== 'onBattery') { - logger.info( - `Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`, - ); + if (context.powerStatus === 'unreachable') { + logger.info( + `Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`, + ); + } else { + logger.info( + `Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`, + ); + } return false; } diff --git a/ts/actions/webhook-action.ts b/ts/actions/webhook-action.ts index f01b9b4..7829522 100644 --- a/ts/actions/webhook-action.ts +++ b/ts/actions/webhook-action.ts @@ -14,7 +14,7 @@ export interface IWebhookPayload { /** UPS name */ upsName: string; /** Current power status */ - powerStatus: 'online' | 'onBattery' | 'unknown'; + powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable'; /** Current battery capacity percentage */ batteryCapacity: number; /** Current battery runtime in minutes */ diff --git a/ts/cli.ts b/ts/cli.ts index e87de8e..780cb29 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -26,8 +26,9 @@ export class NupstCli { const debugOptions = this.extractDebugOptions(args); if (debugOptions.debugMode) { logger.log('Debug mode enabled'); - // Enable debug mode in the SNMP client + // Enable debug mode in both protocol clients this.nupst.getSnmp().enableDebug(); + this.nupst.getUpsd().enableDebug(); } // Check for version flag @@ -259,6 +260,12 @@ export class NupstCli { // Handle top-level commands switch (command) { + case 'pause': + await serviceHandler.pause(commandArgs); + break; + case 'resume': + await serviceHandler.resume(); + break; case 'update': await serviceHandler.update(); break; @@ -351,18 +358,32 @@ export class NupstCli { // UPS Devices Table if (config.upsDevices.length > 0) { - const upsRows = config.upsDevices.map((ups) => ({ - name: ups.name, - id: theme.dim(ups.id), - host: `${ups.snmp.host}:${ups.snmp.port}`, - model: ups.snmp.upsModel || 'cyberpower', - actions: `${(ups.actions || []).length} configured`, - groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), - })); + const upsRows = config.upsDevices.map((ups) => { + const protocol = ups.protocol || 'snmp'; + let host = 'N/A'; + let model = ''; + if (protocol === 'upsd' && ups.upsd) { + host = `${ups.upsd.host}:${ups.upsd.port}`; + model = `NUT:${ups.upsd.upsName}`; + } else if (ups.snmp) { + host = `${ups.snmp.host}:${ups.snmp.port}`; + model = ups.snmp.upsModel || 'cyberpower'; + } + return { + name: ups.name, + id: theme.dim(ups.id), + protocol: protocol.toUpperCase(), + host, + model, + actions: `${(ups.actions || []).length} configured`, + groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), + }; + }); const upsColumns: ITableColumn[] = [ { header: 'Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'ID', key: 'id', align: 'left' }, + { header: 'Protocol', key: 'protocol', align: 'left' }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Model', key: 'model', align: 'left' }, { header: 'Actions', key: 'actions', align: 'left' }, @@ -534,6 +555,8 @@ export class NupstCli { this.printCommand('action ', 'Manage UPS actions'); this.printCommand('feature ', 'Manage optional features'); this.printCommand('config [show]', 'Display current configuration'); + this.printCommand('pause [--duration