Support authentication and auto-UPS-ID-detection

This commit is contained in:
2025-12-13 19:18:37 -05:00
parent 47a2a02e90
commit 7b38b6a4e2
5 changed files with 97 additions and 24 deletions
+5 -4
View File
@@ -2,9 +2,10 @@
This is a simple daemon that monitors the in-built NUT server on a Ubiquiti UniFi UPS and safely shuts down the active system when the UPS switches to battery power (on AC loss).
## Usage
1. Install the `unifi-shutd` daemon to `/usr/local/bin/unifi-shutd`.
2. Install the service file for your init system (be sure to edit the arguments first!!)
1. Install the `unifi-shutd` daemon to `/usr/local/bin/unifi-shutd`
2. Generate the config file using `unifi-shutd config` (run as root)
3. Install the service file for your init system
- OpenRC: Save [services/openrc](https://raw.githubusercontent.com/rwinkhart/unifi-shutd/refs/heads/main/services/openrc) to `/etc/init.d/unifi-shutd`
- systemd: Not yet implemented (feel free to contribute; I just haven't bothered because all my baremetal servers are based on Alpine Linux)
3. Enable and start the service
4. Test disconnecting your UPS from AC and ensure the service properly shuts down your server
4. Enable and start the service
5. Test disconnecting your UPS from AC and ensure the service properly shuts down your server
+7 -2
View File
@@ -1,8 +1,13 @@
module unifi-shutd
go 1.25.2
go 1.25.5
require (
github.com/rwinkhart/go-boilerplate v0.1.0
github.com/rwinkhart/uni-nut v1.1.1
github.com/rwinkhart/uni-nut v1.2.0
)
require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
)
+6 -2
View File
@@ -1,4 +1,8 @@
github.com/rwinkhart/go-boilerplate v0.1.0 h1:EzlVj6R7Bxtl79Nl7R5zRcg6s+Cf2FAqGIzR4giWTQg=
github.com/rwinkhart/go-boilerplate v0.1.0/go.mod h1:cnzIF45I0FCOvE4YIB+26pLCUx2kWyY2llKYZruNaRY=
github.com/rwinkhart/uni-nut v1.1.1 h1:Hy567wno4XqcgTaT+floRYCimf5iHhy6XvqqsSQ7rpM=
github.com/rwinkhart/uni-nut v1.1.1/go.mod h1:H5s8llqTO0UnerDqZHn+30bJVY07OLSoR1PaZa4yzPk=
github.com/rwinkhart/uni-nut v1.2.0 h1:AmCIfKQf3bTCiDF25RrqWybPz6LGo0ru8OrnyNsGaXY=
github.com/rwinkhart/uni-nut v1.2.0/go.mod h1:DTL9S0qxUA2Ziqf4WQWnat4zLbvcAmRPOl6RyU4Oodo=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
+76 -15
View File
@@ -1,43 +1,104 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"time"
"github.com/rwinkhart/go-boilerplate/front"
"github.com/rwinkhart/go-boilerplate/other"
nut "github.com/rwinkhart/uni-nut"
)
const configPath = "/etc/unifi-shutd.json"
type configT struct {
IP string `json:"ip"`
Port string `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ScanIntervalSeconds int `json:"scan_interval_seconds"`
BadScanLimit int `json:"bad_scan_limit"`
}
func main() {
if len(os.Args) != 5 {
var config configT
if len(os.Args) < 2 {
printUsageInfo()
}
intervalInt, err := strconv.Atoi(os.Args[3])
if err != nil {
other.PrintError("Failed to parse scan interval as integer: "+err.Error(), 2)
switch os.Args[1] {
case "start":
configBytes, err := os.ReadFile(configPath)
if err != nil {
other.PrintError("Failed to read ("+configPath+"): "+err.Error(), 1)
}
err = json.Unmarshal(configBytes, &config)
if err != nil {
other.PrintError("Failed to unmarshal config file: "+err.Error(), 2)
}
case "config":
if os.Geteuid() != 0 {
fmt.Printf("\"%s config\" must be run as root\n", os.Args[0])
os.Exit(0)
}
var username, password *string
if front.InputBinary("Does your NUT server use authentication (username/password)?") {
usernameStr := front.Input("NUT Username:")
passwordStr := string(front.InputHidden("NUT Password:"))
username = &usernameStr
password = &passwordStr
}
config = configT{
IP: front.Input("NUT IP:"),
Port: front.Input("NUT Port:"),
Username: username,
Password: password,
ScanIntervalSeconds: front.InputInt("Scan Interval (seconds):", 1, -1),
BadScanLimit: front.InputInt("Bad Scan Limit:", 1, -1),
}
configBytes, err := json.Marshal(config)
if err != nil {
other.PrintError("Failed to marshal config file: "+err.Error(), 3)
}
err = os.WriteFile(configPath, configBytes, 0600)
if err != nil {
other.PrintError("Failed to write ("+configPath+"): "+err.Error(), 4)
}
os.Exit(0)
default:
printUsageInfo()
}
intervalDur := time.Duration(intervalInt) * time.Second
shutdownMultiplier, err := strconv.Atoi(os.Args[4])
intervalDur := time.Duration(config.ScanIntervalSeconds) * time.Second
client, err := nut.Dial(config.IP + ":" + config.Port)
if err != nil {
other.PrintError("Failed to parse bad scan limit as integer: "+err.Error(), 3)
other.PrintError("Failed to dial NUT server ("+os.Args[1]+"): "+err.Error(), 7)
}
client, err := nut.Dial(os.Args[1])
if config.Username != nil {
err = client.Authenticate(*config.Username, *config.Password)
if err != nil {
other.PrintError("Failed to authenticate with NUT server: "+err.Error(), 8)
}
}
err = client.AutomaticallySetID()
if err != nil {
other.PrintError("Failed to dial NUT server ("+os.Args[1]+"): "+err.Error(), 4)
other.PrintError("Failed to detect UPS ID: "+err.Error(), 9)
}
log.Println("Starting NUT monitoring loop...")
cmd := exec.Command("/sbin/poweroff")
var value string
var badCycles int
for {
time.Sleep(intervalDur)
value, err = client.GetVar(os.Args[2], "ups.status")
value, err = client.GetVar("ups.status")
if err != nil {
other.PrintError("Failed to get status from \""+os.Args[2]+"\": "+err.Error(), 5)
other.PrintError("Failed to get status from \""+os.Args[2]+"\": "+err.Error(), 10)
}
switch value {
@@ -47,17 +108,17 @@ func main() {
default:
badCycles = 0
}
if badCycles >= shutdownMultiplier {
if badCycles >= config.BadScanLimit {
log.Println("Bad scan limit reached; initiating shutdown...")
err := cmd.Run()
if err != nil {
other.PrintError("Failed to execute shutdown command: "+err.Error(), 6)
other.PrintError("Failed to execute shutdown command: "+err.Error(), 11)
}
}
}
}
func printUsageInfo() {
fmt.Print("\nUsage: unifi-shutd <host (ip:port)> <ups id> <scan interval (seconds)> <bad scan limit (int)>\n\n")
fmt.Printf("Usage: %s [start | config]\n", os.Args[0])
os.Exit(0)
}
+3 -1
View File
@@ -4,7 +4,9 @@ supervisor=supervise-daemon
name="UniFi UPS safe shutdown"
command="/usr/local/bin/unifi-shutd"
command_args="<ip:port> <ups id> <scan interval> <bad scan limit>"
command_args="start"
output_log="/var/log/unifi-shutd.log"
error_log="/var/log/unifi-shutd.log"
depend() {
need net localmount