mirror of
https://github.com/ergosteur/ergo-proxmox-scripts.git
synced 2026-10-28 05:00:35 -04:00
Add setup-nut-synology.sh
Sets up NUT from scratch for a USB UPS and serves it to a Synology, which is otherwise fiddly because DSM's client cannot be configured: it always asks for a UPS named "ups" and always logs in as monuser with the password "secret". The script guarantees all three, plus a reachable LISTEN. Detects the UPS with nut-scanner, writes nut.conf, ups.conf, upsd.conf, upsd.users and upsmon.conf, then starts and verifies the stack. Re-runnable: it reuses an existing admin password rather than churning it, backs up anything it replaces, and prompts before overwriting unless given --yes. --dry-run prints the files without writing, and --etc-dir writes elsewhere so the output can be reviewed against a live config. Warns when another process already holds the USB device, naming QEMU specifically. That is what broke this host: a passthrough to a guest claimed the UPS, and the driver crash-looped for months with a message that did not point at the cause. Drops nut-scanner's bus, device and busport keys, which change across replug, and its ###NOTMATCHED-YET### markers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d33d814876
commit
722f93a01c
Executable
+355
@@ -0,0 +1,355 @@
|
||||
#!/bin/bash
|
||||
# Set up NUT from scratch for a USB UPS, as a NUT server a Synology can use.
|
||||
#
|
||||
# Detects the attached USB UPS, writes a working NUT configuration, and starts
|
||||
# the driver, upsd and upsmon. The result is a NUT server that Synology DSM can
|
||||
# connect to with "UPS Type: Synology UPS Server".
|
||||
#
|
||||
# DSM's client is rigid about three things, which this script guarantees:
|
||||
# * the UPS must be named exactly "ups"
|
||||
# * it logs in as user "monuser" with password "secret"
|
||||
# * upsd must listen on an address DSM can reach
|
||||
#
|
||||
# Usage:
|
||||
# ./setup-nut-synology.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# -n, --dry-run Show what would be written, change nothing
|
||||
# -y, --yes Do not prompt before overwriting an existing config
|
||||
# --listen ADDR Address for upsd to listen on (default: 0.0.0.0)
|
||||
# --admin-password P Password for the local admin user
|
||||
# (default: reuse existing, else generate)
|
||||
# --ups-name NAME NUT device name (default: ups)
|
||||
# --device-index N Pick the Nth UPS when more than one is detected
|
||||
# --etc-dir DIR Write config to DIR instead of /etc/nut (for testing)
|
||||
# -h, --help This text
|
||||
#
|
||||
# Example:
|
||||
# sudo ./setup-nut-synology.sh --listen 10.20.28.30
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ETC_DIR=/etc/nut
|
||||
LISTEN_ADDR=0.0.0.0
|
||||
UPS_NAME=ups
|
||||
ADMIN_PASSWORD=""
|
||||
DEVICE_INDEX=1
|
||||
DRY_RUN=false
|
||||
ASSUME_YES=false
|
||||
|
||||
# DSM hardcodes these; they are not configurable on the Synology side.
|
||||
SYNOLOGY_USER=monuser
|
||||
SYNOLOGY_PASSWORD=secret
|
||||
|
||||
usage() { sed -n '2,/^$/p' "$0" | sed -e 's/^#//' -e 's/^ //'; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--dry-run) DRY_RUN=true; shift ;;
|
||||
-y|--yes) ASSUME_YES=true; shift ;;
|
||||
--listen) LISTEN_ADDR="${2:?--listen needs an address}"; shift 2 ;;
|
||||
--admin-password) ADMIN_PASSWORD="${2:?--admin-password needs a value}"; shift 2 ;;
|
||||
--ups-name) UPS_NAME="${2:?--ups-name needs a value}"; shift 2 ;;
|
||||
--device-index) DEVICE_INDEX="${2:?--device-index needs a number}"; shift 2 ;;
|
||||
--etc-dir) ETC_DIR="${2:?--etc-dir needs a path}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
warn() { printf 'Warning: %s\n' "$*" >&2; }
|
||||
die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
die "must run as root (try: sudo $0)"
|
||||
fi
|
||||
|
||||
if [[ "$UPS_NAME" != "ups" ]]; then
|
||||
warn "Synology DSM only ever asks for a UPS named 'ups'. With --ups-name $UPS_NAME the DSM client will not find it."
|
||||
fi
|
||||
|
||||
# --- 1. Packages ------------------------------------------------------------
|
||||
|
||||
need_pkgs=()
|
||||
command -v upsd >/dev/null || need_pkgs+=(nut-server)
|
||||
command -v upsc >/dev/null || need_pkgs+=(nut-client)
|
||||
command -v nut-scanner >/dev/null || need_pkgs+=(nut-server)
|
||||
|
||||
if [[ ${#need_pkgs[@]} -gt 0 ]]; then
|
||||
if $DRY_RUN; then
|
||||
say "[dry-run] would install: ${need_pkgs[*]}"
|
||||
else
|
||||
command -v apt-get >/dev/null || die "missing ${need_pkgs[*]} and apt-get is not available; install NUT manually"
|
||||
say "Installing ${need_pkgs[*]}..."
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${need_pkgs[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. Detect the UPS ------------------------------------------------------
|
||||
|
||||
say "Scanning for a USB UPS..."
|
||||
if ! command -v nut-scanner >/dev/null; then
|
||||
die "nut-scanner not available, cannot detect the UPS"
|
||||
fi
|
||||
|
||||
SCAN=$(nut-scanner -U -q 2>/dev/null || true)
|
||||
SECTIONS=$(grep -c '^\[' <<<"$SCAN" || true)
|
||||
|
||||
if [[ -z "$SCAN" || "$SECTIONS" -eq 0 ]]; then
|
||||
say ""
|
||||
say "No USB UPS detected. Things worth checking:"
|
||||
say " * is the UPS actually connected over USB, and powered on?"
|
||||
say " * lsusb -- does the UPS appear at all?"
|
||||
say " * is another process holding the device? On a hypervisor a USB"
|
||||
say " passthrough to a guest claims it exclusively and NUT can never"
|
||||
say " attach. On Proxmox, look for a 'usb0: host=VVVV:PPPP' line in"
|
||||
say " the guest config and remove it."
|
||||
die "nothing to configure"
|
||||
fi
|
||||
|
||||
if [[ "$SECTIONS" -gt 1 ]]; then
|
||||
say "Detected $SECTIONS UPS devices:"
|
||||
awk '/^\[/ {n++; name=$0} /product =/ {if (n) printf " %d) %s %s\n", n, name, $0}' <<<"$SCAN"
|
||||
if [[ "$DEVICE_INDEX" -gt "$SECTIONS" ]]; then
|
||||
die "--device-index $DEVICE_INDEX is out of range (1-$SECTIONS)"
|
||||
fi
|
||||
say "Using device $DEVICE_INDEX (override with --device-index)."
|
||||
fi
|
||||
|
||||
# Pull the Nth section's settings, dropping keys that should not be pinned:
|
||||
# "bus" changes across replug, and the section header is renamed to $UPS_NAME.
|
||||
DEVICE_CONF=$(awk -v want="$DEVICE_INDEX" '
|
||||
/^\[/ { n++; next }
|
||||
n == want && /=/ {
|
||||
# nut-scanner indents its own output; normalise before re-indenting.
|
||||
sub(/^[[:space:]]+/, "")
|
||||
key = $1
|
||||
# Skip its ###NOTMATCHED-YET### markers for attributes it could not map.
|
||||
if (key ~ /^#/) next
|
||||
if (key == "bus" || key == "busport" || key == "device") next
|
||||
print "\t" $0
|
||||
}
|
||||
' <<<"$SCAN")
|
||||
|
||||
[[ -n "$DEVICE_CONF" ]] || die "could not parse nut-scanner output for device $DEVICE_INDEX"
|
||||
|
||||
# A friendly description for DSM to display.
|
||||
UPS_VENDOR=$(sed -n 's/.*vendor = "\(.*\)".*/\1/p' <<<"$DEVICE_CONF" | head -1)
|
||||
UPS_PRODUCT=$(sed -n 's/.*product = "\(.*\)".*/\1/p' <<<"$DEVICE_CONF" | head -1)
|
||||
UPS_DESC="${UPS_VENDOR:-USB} ${UPS_PRODUCT:-UPS}"
|
||||
|
||||
say "Found: $UPS_DESC"
|
||||
|
||||
# --- 3. Is anything else holding the device? --------------------------------
|
||||
|
||||
VID=$(sed -n 's/.*vendorid = "\(.*\)".*/\1/p' <<<"$DEVICE_CONF" | head -1)
|
||||
PID=$(sed -n 's/.*productid = "\(.*\)".*/\1/p' <<<"$DEVICE_CONF" | head -1)
|
||||
|
||||
if [[ -n "$VID" && -n "$PID" ]] && command -v fuser >/dev/null; then
|
||||
for dev in /sys/bus/usb/devices/*; do
|
||||
[[ -f "$dev/idVendor" ]] || continue
|
||||
[[ "$(cat "$dev/idVendor" 2>/dev/null)" == "$VID" ]] || continue
|
||||
[[ "$(cat "$dev/idProduct" 2>/dev/null)" == "$PID" ]] || continue
|
||||
node=$(printf '/dev/bus/usb/%03d/%03d' \
|
||||
"$(cat "$dev/busnum" 2>/dev/null || echo 0)" \
|
||||
"$(cat "$dev/devnum" 2>/dev/null || echo 0)")
|
||||
[[ -e "$node" ]] || continue
|
||||
# fuser can report several PIDs; handle each one separately rather than
|
||||
# letting them run together into a single meaningless number.
|
||||
for holder in $(fuser "$node" 2>/dev/null || true); do
|
||||
holder=${holder//[^0-9]/}
|
||||
[[ -n "$holder" ]] || continue
|
||||
hname=$(ps -o comm= -p "$holder" 2>/dev/null || echo unknown)
|
||||
case "$hname" in
|
||||
usbhid-ups|*nut*|blazer*|snmp-ups|*-ups)
|
||||
continue ;; # our own driver from a previous run, fine
|
||||
esac
|
||||
warn "$node is held by PID $holder ($hname)."
|
||||
if [[ "$hname" == "kvm" || "$hname" == "qemu"* ]]; then
|
||||
warn "That is a VM holding the UPS via USB passthrough. NUT cannot attach until it is released."
|
||||
warn "On Proxmox: find the guest with 'usb0: host=$VID:$PID' and remove that line."
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# --- 4. Preserve the admin password across re-runs --------------------------
|
||||
|
||||
if [[ -z "$ADMIN_PASSWORD" && -r "$ETC_DIR/upsd.users" ]]; then
|
||||
ADMIN_PASSWORD=$(awk '
|
||||
/^\[admin\]/ { inblock = 1; next }
|
||||
/^\[/ { inblock = 0 }
|
||||
inblock && /password/ { sub(/.*=[[:space:]]*/, ""); print; exit }
|
||||
' "$ETC_DIR/upsd.users")
|
||||
[[ -n "$ADMIN_PASSWORD" ]] && say "Reusing the existing admin password."
|
||||
fi
|
||||
|
||||
if [[ -z "$ADMIN_PASSWORD" ]]; then
|
||||
ADMIN_PASSWORD=$(openssl rand -base64 18 2>/dev/null | tr -d '/+=' | cut -c1-20)
|
||||
[[ -n "$ADMIN_PASSWORD" ]] || die "could not generate an admin password; pass --admin-password"
|
||||
say "Generated a new admin password."
|
||||
fi
|
||||
|
||||
# --- 5. Confirm before touching an existing config --------------------------
|
||||
|
||||
EXISTING=()
|
||||
for f in nut.conf ups.conf upsd.conf upsd.users upsmon.conf; do
|
||||
[[ -f "$ETC_DIR/$f" ]] && EXISTING+=("$f")
|
||||
done
|
||||
|
||||
if [[ ${#EXISTING[@]} -gt 0 ]] && ! $DRY_RUN && ! $ASSUME_YES; then
|
||||
say ""
|
||||
say "About to overwrite in $ETC_DIR: ${EXISTING[*]}"
|
||||
say "Each will be backed up alongside with a .bak-<timestamp> suffix."
|
||||
read -r -p "Continue? [y/N] " reply
|
||||
[[ "$reply" == [yY]* ]] || die "aborted"
|
||||
fi
|
||||
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
write_file() {
|
||||
local path="$1" mode="$2" group="$3" content="$4"
|
||||
if $DRY_RUN; then
|
||||
say ""
|
||||
say "===== [dry-run] $path (mode $mode, root:$group) ====="
|
||||
printf '%s\n' "$content"
|
||||
return
|
||||
fi
|
||||
mkdir -p "$(dirname "$path")"
|
||||
[[ -f "$path" ]] && cp -a "$path" "$path.bak-$STAMP"
|
||||
printf '%s\n' "$content" > "$path"
|
||||
chown "root:$group" "$path" 2>/dev/null || chown root:root "$path"
|
||||
chmod "$mode" "$path"
|
||||
}
|
||||
|
||||
# --- 6. Write the configuration ---------------------------------------------
|
||||
|
||||
NUT_GROUP=nut
|
||||
getent group nut >/dev/null || NUT_GROUP=root
|
||||
|
||||
write_file "$ETC_DIR/nut.conf" 0640 "$NUT_GROUP" \
|
||||
"# Managed by setup-nut-synology.sh
|
||||
# netserver: run a driver locally and serve it to other machines over the network.
|
||||
MODE=netserver"
|
||||
|
||||
write_file "$ETC_DIR/ups.conf" 0640 "$NUT_GROUP" \
|
||||
"# Managed by setup-nut-synology.sh
|
||||
#
|
||||
# The section name is what clients ask for. Synology DSM only ever requests
|
||||
# \"ups\", so renaming this section will stop DSM from finding the UPS.
|
||||
#
|
||||
# The vendorid/productid/serial lines below pin this entry to the exact unit
|
||||
# that was detected. Drop the serial and product lines if you want the config to
|
||||
# survive swapping in a different UPS of the same model.
|
||||
|
||||
maxretry = 3
|
||||
|
||||
[$UPS_NAME]
|
||||
desc = \"$UPS_DESC\"
|
||||
$DEVICE_CONF"
|
||||
|
||||
write_file "$ETC_DIR/upsd.conf" 0640 "$NUT_GROUP" \
|
||||
"# Managed by setup-nut-synology.sh
|
||||
# Must be reachable by the Synology. 0.0.0.0 listens on every interface.
|
||||
LISTEN $LISTEN_ADDR 3493"
|
||||
|
||||
write_file "$ETC_DIR/upsd.users" 0640 "$NUT_GROUP" \
|
||||
"# Managed by setup-nut-synology.sh
|
||||
#
|
||||
# admin: used by this machine's own upsmon, which owns the UPS.
|
||||
[admin]
|
||||
password = $ADMIN_PASSWORD
|
||||
upsmon primary
|
||||
actions = SET
|
||||
instcmds = ALL
|
||||
|
||||
# monuser: for Synology DSM, which hardcodes both the username and the password
|
||||
# and cannot be configured to use anything else. The secondary role only permits
|
||||
# reading status and receiving shutdown notifications, not commanding the UPS.
|
||||
[$SYNOLOGY_USER]
|
||||
password = $SYNOLOGY_PASSWORD
|
||||
upsmon secondary"
|
||||
|
||||
write_file "$ETC_DIR/upsmon.conf" 0640 "$NUT_GROUP" \
|
||||
"# Managed by setup-nut-synology.sh
|
||||
#
|
||||
# This machine owns the UPS, so it is the primary: it shuts down last, after
|
||||
# secondaries such as the Synology have been told to go.
|
||||
|
||||
RUN_AS_USER nut
|
||||
|
||||
MONITOR $UPS_NAME@localhost 1 admin $ADMIN_PASSWORD primary
|
||||
|
||||
MINSUPPLIES 1
|
||||
SHUTDOWNCMD \"/sbin/shutdown -h +0\"
|
||||
POLLFREQ 5
|
||||
POLLFREQALERT 5
|
||||
HOSTSYNC 15
|
||||
DEADTIME 15
|
||||
POWERDOWNFLAG /etc/killpower
|
||||
FINALDELAY 5"
|
||||
|
||||
if $DRY_RUN; then
|
||||
say ""
|
||||
say "[dry-run] no services were started and nothing was written."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 7. Start everything ----------------------------------------------------
|
||||
|
||||
if [[ "$ETC_DIR" != "/etc/nut" ]]; then
|
||||
say ""
|
||||
say "Config written to $ETC_DIR; skipping service start because that is not /etc/nut."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "Starting services..."
|
||||
# Regenerate the per-device nut-driver@ instances from the new ups.conf.
|
||||
systemctl daemon-reload
|
||||
if systemctl list-unit-files nut-driver-enumerator.service >/dev/null 2>&1; then
|
||||
systemctl restart nut-driver-enumerator.service || true
|
||||
fi
|
||||
|
||||
systemctl enable --now nut-server >/dev/null 2>&1 || true
|
||||
systemctl enable --now nut-monitor >/dev/null 2>&1 || true
|
||||
systemctl restart "nut-driver@$UPS_NAME.service" 2>/dev/null || systemctl restart nut-driver.target || true
|
||||
systemctl restart nut-server
|
||||
systemctl restart nut-monitor
|
||||
|
||||
# --- 8. Verify --------------------------------------------------------------
|
||||
|
||||
say ""
|
||||
say "Verifying..."
|
||||
ok=true
|
||||
for i in $(seq 1 10); do
|
||||
if upsc "$UPS_NAME" >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
[[ $i -eq 10 ]] && ok=false
|
||||
done
|
||||
|
||||
if ! $ok; then
|
||||
say "Could not read the UPS after 20s. Check:"
|
||||
say " journalctl -u nut-driver@$UPS_NAME -n 30"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATUS=$(upsc "$UPS_NAME" ups.status 2>/dev/null || echo "?")
|
||||
CHARGE=$(upsc "$UPS_NAME" battery.charge 2>/dev/null || echo "?")
|
||||
|
||||
say " UPS reachable: ups.status=$STATUS battery.charge=$CHARGE"
|
||||
|
||||
if command -v ss >/dev/null && ss -lnt 2>/dev/null | grep -q ':3493'; then
|
||||
say " upsd listening on 3493"
|
||||
else
|
||||
warn "upsd does not appear to be listening on 3493"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "Done. On the Synology: Control Panel > Hardware & Power > UPS,"
|
||||
say "enable UPS support, set UPS Type to \"Synology UPS Server\", and point it"
|
||||
say "at this host's IP. It will log in as $SYNOLOGY_USER automatically."
|
||||
say ""
|
||||
say "The local admin password is stored in $ETC_DIR/upsd.users (root-readable only)."
|
||||
Reference in New Issue
Block a user