mirror of
https://github.com/rwinkhart/unifi-shutd.git
synced 2026-09-06 16:57:14 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b38b6a4e2 |
@@ -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).
|
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
|
## Usage
|
||||||
1. Install the `unifi-shutd` daemon to `/usr/local/bin/unifi-shutd`.
|
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!!)
|
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`
|
- 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)
|
- 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. Enable and start the service
|
||||||
4. Test disconnecting your UPS from AC and ensure the service properly shuts down your server
|
5. Test disconnecting your UPS from AC and ensure the service properly shuts down your server
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
module unifi-shutd
|
module unifi-shutd
|
||||||
|
|
||||||
go 1.25.2
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/rwinkhart/go-boilerplate v0.1.0
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
github.com/rwinkhart/go-boilerplate v0.1.0 h1:EzlVj6R7Bxtl79Nl7R5zRcg6s+Cf2FAqGIzR4giWTQg=
|
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/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.2.0 h1:AmCIfKQf3bTCiDF25RrqWybPz6LGo0ru8OrnyNsGaXY=
|
||||||
github.com/rwinkhart/uni-nut v1.1.1/go.mod h1:H5s8llqTO0UnerDqZHn+30bJVY07OLSoR1PaZa4yzPk=
|
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=
|
||||||
|
|||||||
@@ -1,43 +1,104 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rwinkhart/go-boilerplate/front"
|
||||||
"github.com/rwinkhart/go-boilerplate/other"
|
"github.com/rwinkhart/go-boilerplate/other"
|
||||||
nut "github.com/rwinkhart/uni-nut"
|
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() {
|
func main() {
|
||||||
if len(os.Args) != 5 {
|
var config configT
|
||||||
|
if len(os.Args) < 2 {
|
||||||
printUsageInfo()
|
printUsageInfo()
|
||||||
}
|
}
|
||||||
intervalInt, err := strconv.Atoi(os.Args[3])
|
switch os.Args[1] {
|
||||||
if err != nil {
|
case "start":
|
||||||
other.PrintError("Failed to parse scan interval as integer: "+err.Error(), 2)
|
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 {
|
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 {
|
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...")
|
log.Println("Starting NUT monitoring loop...")
|
||||||
cmd := exec.Command("/sbin/poweroff")
|
cmd := exec.Command("/sbin/poweroff")
|
||||||
var value string
|
var value string
|
||||||
var badCycles int
|
var badCycles int
|
||||||
for {
|
for {
|
||||||
time.Sleep(intervalDur)
|
time.Sleep(intervalDur)
|
||||||
value, err = client.GetVar(os.Args[2], "ups.status")
|
value, err = client.GetVar("ups.status")
|
||||||
if err != nil {
|
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 {
|
switch value {
|
||||||
@@ -47,17 +108,17 @@ func main() {
|
|||||||
default:
|
default:
|
||||||
badCycles = 0
|
badCycles = 0
|
||||||
}
|
}
|
||||||
if badCycles >= shutdownMultiplier {
|
if badCycles >= config.BadScanLimit {
|
||||||
log.Println("Bad scan limit reached; initiating shutdown...")
|
log.Println("Bad scan limit reached; initiating shutdown...")
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
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() {
|
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)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@ supervisor=supervise-daemon
|
|||||||
name="UniFi UPS safe shutdown"
|
name="UniFi UPS safe shutdown"
|
||||||
|
|
||||||
command="/usr/local/bin/unifi-shutd"
|
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() {
|
depend() {
|
||||||
need net localmount
|
need net localmount
|
||||||
|
|||||||
Reference in New Issue
Block a user