diff --git a/README.md b/README.md index cdbf8f4..424d90f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod index 18afbb5..69d198c 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index cedbd1d..32fc724 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index ccbd1b5..a7ca0ae 100644 --- a/main.go +++ b/main.go @@ -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 \n\n") + fmt.Printf("Usage: %s [start | config]\n", os.Args[0]) os.Exit(0) } diff --git a/services/openrc b/services/openrc index 2debb42..4aa9cfc 100644 --- a/services/openrc +++ b/services/openrc @@ -4,7 +4,9 @@ supervisor=supervise-daemon name="UniFi UPS safe shutdown" command="/usr/local/bin/unifi-shutd" -command_args=" " +command_args="start" +output_log="/var/log/unifi-shutd.log" +error_log="/var/log/unifi-shutd.log" depend() { need net localmount