]> git.99rst.org Git - openwrt-packages.git/commitdiff
nut: rewrite scripts
authorDaniel F. Dickinson <redacted>
Fri, 8 May 2026 01:32:59 +0000 (21:32 -0400)
committerJosef Schlehofer <redacted>
Thu, 2 Jul 2026 04:43:08 +0000 (06:43 +0200)
The scripts were a mess. Attempting even a simple update caused many
Copilot complaints. So we rewrite the scripts to be cleaner and
resolve the issues found by automated code review (such as Copilot).
Made extensive use of Qwen3.6-27B, LATE, and llama.ccp for local AI
code reviews during development.

In the process we deduplicate the nut-server and nut-monitor
initscripts and split them into several files, for easier automatic
and human review.

Incorporates and supersedes: #28308
Should supersede #21014

Closes: #28298
Signed-off-by: Daniel F. Dickinson <redacted>
25 files changed:
net/nut/Makefile
net/nut/files/30-libhid-ups.head [deleted file]
net/nut/files/30-libhid-ups.tail [deleted file]
net/nut/files/add_nut_httpd_conf [deleted file]
net/nut/files/add_nut_httpd_conf.default [new file with mode: 0644]
net/nut/files/libhid-ups.hotplug [new file with mode: 0644]
net/nut/files/nut-cgi.init [changed mode: 0755->0644]
net/nut/files/nut-common.sh.functions [new file with mode: 0644]
net/nut/files/nut-monitor-config.sh.functions [new file with mode: 0644]
net/nut/files/nut-monitor-migrate.default
net/nut/files/nut-monitor.init
net/nut/files/nut-notify-exec.default [new file with mode: 0644]
net/nut/files/nut-sched.default
net/nut/files/nut-sendmail-notify [changed mode: 0755->0644]
net/nut/files/nut-sendmail-notify.default
net/nut/files/nut-serial.hotplug [new file with mode: 0644]
net/nut/files/nut-server-config.sh.functions [new file with mode: 0644]
net/nut/files/nut-server-service.sh.functions [new file with mode: 0644]
net/nut/files/nut-server.init [changed mode: 0755->0644]
net/nut/files/nut-service.sh.functions [new file with mode: 0644]
net/nut/files/nut_cgi
net/nut/files/nut_monitor
net/nut/files/nut_serial.hotplug [deleted file]
net/nut/files/nut_server
net/nut/files/nutshutdown [changed mode: 0755->0644]

index 7301ca5e440a695c08c7aec9191812e9a2c21ff8..baa0d0cd74ec4312528432c4992fc6b7ed60c3b8 100644 (file)
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
 
 PKG_NAME:=nut
 PKG_VERSION:=2.8.5
-PKG_RELEASE:=1
+PKG_RELEASE:=2
 
 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
 PKG_SOURCE_URL:=https://www.networkupstools.org/source/2.8/
@@ -69,9 +69,12 @@ define Package/nut-server/install
        $(INSTALL_DIR) $(1)/etc/nut
        $(INSTALL_DIR) $(1)/usr/sbin
        $(INSTALL_DIR) $(1)/etc/init.d
+       $(INSTALL_DIR) $(1)/lib/functions/nut
        $(INSTALL_DIR) $(1)/usr/share/nut
        $(INSTALL_BIN) ./files/nut-server.init $(1)/etc/init.d/nut-server
-       $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/upsd $(1)/usr/sbin
+       $(INSTALL_DATA) ./files/nut-server-config.sh.functions $(1)/lib/functions/nut/nut-server-config.sh
+       $(INSTALL_DATA) ./files/nut-server-service.sh.functions $(1)/lib/functions/nut/nut-server-service.sh
+       $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/upsd $(1)/usr/sbin/
        $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/share/nut/cmdvartab $(1)/usr/share/nut/
        $(INSTALL_DIR) $(1)/etc/config
        $(INSTALL_CONF) ./files/nut_server $(1)/etc/config/nut_server
@@ -84,15 +87,15 @@ define Package/nut-server/install
        # Mangle libhid.usermap into a format (hotplug shell script) useful for OpenWrt
        $(INSTALL_DIR) $(1)/etc/hotplug.d/usb
        $(INSTALL_DIR) $(1)/etc/hotplug.d/tty
-       $(INSTALL_BIN) ./files/nut_serial.hotplug $(1)/etc/hotplug.d/tty/40-nut_serial
-       $(INSTALL_BIN) ./files/30-libhid-ups.head $(1)/etc/hotplug.d/usb/30-libhid-ups
-       $(CP) $(PKG_INSTALL_DIR)/etc/hotplug/usb/libhid.usermap $(PKG_BUILD_DIR)/30-libhid-ups.middle
+       $(INSTALL_BIN) ./files/nut-serial.hotplug $(1)/etc/hotplug.d/tty/40-nut-serial
+       $(CP) ./files/libhid-ups.hotplug $(PKG_BUILD_DIR)/30-libhid-ups
+       $(CP) $(PKG_INSTALL_DIR)/etc/hotplug/usb/libhid.usermap $(PKG_BUILD_DIR)/libhid-ups.parsed-usermap
        $(SED) '/^$$$$/d' \
                -e '/^#/d' \
                -E -e 's:^[^ ][^ ]*  *0x0003  *0x0{0,3}([^ ][^ ]*)  *0x0{0,3}*([^ ][^ ]*).*:\1/\2/* | \\:' \
-               $(PKG_BUILD_DIR)/30-libhid-ups.middle
-       tail -n+2 $(PKG_BUILD_DIR)/30-libhid-ups.middle >>$(1)/etc/hotplug.d/usb/30-libhid-ups
-       cat ./files/30-libhid-ups.tail >>$(1)/etc/hotplug.d/usb/30-libhid-ups
+               $(PKG_BUILD_DIR)/libhid-ups.parsed-usermap
+       $(SED) 's^### insert libhid-ups.parsed-usermap content here ###^\ncat "$(PKG_BUILD_DIR)/libhid-ups.parsed-usermap"^e' $(PKG_BUILD_DIR)/30-libhid-ups
+       $(INSTALL_BIN) $(PKG_BUILD_DIR)/30-libhid-ups $(1)/etc/hotplug.d/usb/30-libhid-ups
 endef
 
 define Package/nut-common
@@ -117,6 +120,9 @@ endef
 define Package/nut-common/install
        $(INSTALL_DIR) $(1)/etc/nut
        $(INSTALL_DIR) $(1)/usr/lib
+       $(INSTALL_DIR) $(1)/lib/functions/nut
+       $(INSTALL_DATA) ./files/nut-common.sh.functions $(1)/lib/functions/nut/nut-common.sh
+       $(INSTALL_DATA) ./files/nut-service.sh.functions $(1)/lib/functions/nut/nut-service.sh
        $(CP) $(PKG_INSTALL_DIR)/usr/lib/libupsclient.so* $(1)/usr/lib/
        ln -sf /var/etc/nut/nut.conf $(1)/etc/nut/nut.conf
 endef
@@ -161,12 +167,19 @@ process. Every UPS that is defined in the upsmon.conf configuration file
 is assigned a power value and a type (secondary or primary).
 endef
 
+define Package/nut-upsmon/conffiles
+/etc/config/nut_monitor
+/etc/nut/upsmon.conf
+endef
+
 define Package/nut-upsmon/install
        $(INSTALL_DIR) $(1)/etc/nut
        $(INSTALL_DIR) $(1)/usr/sbin
        $(INSTALL_DIR) $(1)/etc/init.d
-       $(INSTALL_DIR) $(1)/etc/uci-defaults
+       $(INSTALL_DIR) $(1)/lib/functions/nut
+       $(INSTALL_DIR) $(1)/usr/bin $(1)/etc/uci-defaults
        $(INSTALL_BIN) ./files/nut-monitor.init $(1)/etc/init.d/nut-monitor
+       $(INSTALL_DATA) ./files/nut-monitor-config.sh.functions $(1)/lib/functions/nut/nut-monitor-config.sh
        $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/upsmon $(1)/usr/sbin/
        $(INSTALL_BIN) ./files/nutshutdown $(1)/usr/sbin/nutshutdown
        $(INSTALL_DATA) ./files/nut-monitor-migrate.default $(1)/etc/uci-defaults/80-nut-monitor-migrate
@@ -195,7 +208,8 @@ endef
 define Package/nut-upsmon-sendmail-notify/install
        $(INSTALL_DIR) $(1)/usr/bin $(1)/etc/uci-defaults
        $(INSTALL_BIN) ./files/nut-sendmail-notify $(1)/usr/bin/
-       $(INSTALL_DATA) ./files/nut-sendmail-notify.default $(1)/etc/uci-defaults/nut-sendmail-notify
+       $(INSTALL_DATA) ./files/nut-sendmail-notify.default $(1)/etc/uci-defaults/81-nut-sendmail-notify
+       $(INSTALL_DATA) ./files/nut-notify-exec.default $(1)/etc/uci-defaults/71-nut-notify-exec
 endef
 
 define Package/nut-upsc
@@ -289,6 +303,10 @@ other uses that are possible.
 You can alternatively write your own script and save some space.
 endef
 
+define Package/nut-upssched/conffiles
+/etc/nut/upssched.conf
+endef
+
 define Package/nut-upssched/install
        $(INSTALL_DIR) $(1)/usr/bin
        $(INSTALL_DIR) $(1)/usr/sbin
@@ -297,7 +315,8 @@ define Package/nut-upssched/install
        $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/upssched-cmd $(1)/usr/bin/
        $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/upssched $(1)/usr/sbin/
        $(INSTALL_CONF) $(PKG_INSTALL_DIR)/etc/nut/upssched.conf.sample $(1)/etc/nut/upssched.conf
-       $(INSTALL_DATA) ./files/nut-sched.default $(1)/etc/uci-defaults/nut-upssched
+       $(INSTALL_DATA) ./files/nut-sched.default $(1)/etc/uci-defaults/80-nut-upssched
+       $(INSTALL_DATA) ./files/nut-notify-exec.default $(1)/etc/uci-defaults/70-nut-notify-exec
 endef
 
 define Package/nut-upssched/conffiles
@@ -317,6 +336,7 @@ commands from any web browser. Javascript and cookies are not required.
 endef
 
 define Package/nut-web-cgi/conffiles
+/etc/uci-defaults/add_nut_httpd_conf
 /etc/nut/hosts.conf
 /etc/nut/upsset.conf
 /etc/nut/upsstats.html
@@ -331,12 +351,9 @@ define Package/nut-web-cgi/install
        $(CP) $(PKG_INSTALL_DIR)/usr/html/* $(1)/www/nut/
        $(INSTALL_DIR) $(1)/etc/uci-defaults
        $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/cgi-bin/* $(1)/www/cgi-bin/nut
-       $(INSTALL_CONF) ./files/add_nut_httpd_conf $(1)/etc/uci-defaults/add_nut_httpd_conf
+       $(INSTALL_CONF) ./files/add_nut_httpd_conf.default $(1)/etc/uci-defaults/add_nut_httpd_conf
        $(INSTALL_CONF) $(PKG_INSTALL_DIR)/etc/nut/upsstats.html.sample $(1)/etc/nut/upsstats.html
        $(INSTALL_CONF) $(PKG_INSTALL_DIR)/etc/nut/upsstats-single.html.sample $(1)/etc/nut/upsstats-single.html
-       $(INSTALL_CONF) $(PKG_INSTALL_DIR)/etc/nut/upsset.conf.sample $(1)/etc/nut/upsset.conf.disable
-       $(INSTALL_CONF) $(PKG_INSTALL_DIR)/etc/nut/upsset.conf.sample $(1)/etc/nut/upsset.conf.enable
-       $(SED) 's/### \?//' $(1)/etc/nut/upsset.conf.enable
        $(INSTALL_DIR) $(1)/etc/config
        $(INSTALL_CONF) ./files/nut_cgi $(1)/etc/config/nut_cgi
        $(INSTALL_DIR) $(1)/etc/init.d
diff --git a/net/nut/files/30-libhid-ups.head b/net/nut/files/30-libhid-ups.head
deleted file mode 100755 (executable)
index eebda99..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/bin/sh
-
-nut_driver_config() {
-       local cfg="$1"
-       local nomatch="$2"
-
-       config_get runas "$cfg" runas "nut"
-       config_get vendorid "$cfg" vendorid
-       config_get productid "$cfg" productid
-
-       [ "$ACTION" = "add" ] && [ -n "$DEVNAME" ] && {
-               chmod 0660 /dev/"$DEVNAME"
-               chown "${runas:-root}":"$(id -gn "${runas:-root}")" /dev/"$DEVNAME"
-       }
-
-       if [ "$nomatch" = "1" ]; then
-               [ "$ACTION" = "add" ] && {
-                       /etc/init.d/nut-server start "$cfg"
-               }
-       elif [ "$(printf "%04x" 0x"$pvendid")" = "$vendorid" ] && \
-               [ "$(printf "%04x" 0x"$pprodid")" = "$productid" ]; then
-                       [ "$ACTION" = "add" ] && {
-                               /etc/init.d/nut-server start "$cfg"
-                       }
-                       [ "$ACTION" = "remove" ] && {
-                               /etc/init.d/nut-server stop "$cfg"
-                       }
-                       found=1
-       fi
-}
-
-perform_libhid_action() {
-       . /lib/functions.sh
-
-       local vendorid productid runas
-       local pvendid pprodid found
-
-       pvendid=${PRODUCT%/*}
-       pvendid=${pvendid%/*}
-       pprodid=${PRODUCT%/*}
-       pprodid=${pprodid##*/}
-
-       config_load nut_server
-       config_foreach nut_driver_config driver 0
-       [ "$found" != "1" ] && config_foreach nut_driver_config driver 1
-       /etc/init.d/nut-server start upsd
-}
-
-[ -n "$PRODUCT" ] && case "$PRODUCT" in
diff --git a/net/nut/files/30-libhid-ups.tail b/net/nut/files/30-libhid-ups.tail
deleted file mode 100644 (file)
index 3846bc3..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-"")
-       [ -f /var/run/nut/disable-hotplug ] || \
-               /etc/init.d/nut-server enabled && perform_libhid_action
-       ;;
-esac
diff --git a/net/nut/files/add_nut_httpd_conf b/net/nut/files/add_nut_httpd_conf
deleted file mode 100644 (file)
index b8fa847..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/sh
-
-grep -q '/cgi-bin/nut' /etc/httpd.conf 2>/dev/null || {
-       echo '/cgi-bin/nut:root:$p$root' >>/etc/httpd.conf
-       /etc/init.d/uhttpd restart
-}
diff --git a/net/nut/files/add_nut_httpd_conf.default b/net/nut/files/add_nut_httpd_conf.default
new file mode 100644 (file)
index 0000000..3937b3a
--- /dev/null
@@ -0,0 +1,28 @@
+#!/bin/sh
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# UCI default script to set default password to access the NUT CGI pages
+# to be the system password for the root user (as with LuCI)
+
+# $p$root is not an actual password, it is a reference to the password
+# for root in the system password (/etc/shadow) password file
+
+# Only add this default user:password (reference) for NUT CGI if there is
+# not an existing password configuration for the NUT CGI, include for other
+# users.
+
+[ -z "$IPKG_INSTROOT" ] || exit 0
+
+touch /etc/httpd.conf
+grep -q '^/cgi-bin/nut' /etc/httpd.conf 2>/dev/null || {
+       # The SC2016 shellcheck directive is required as shellcheck thinks the
+       # $p$root is a variable that gets expanded
+       # shellcheck disable=SC2016
+       echo '/cgi-bin/nut:root:$p$root' >>/etc/httpd.conf
+       if ! /etc/init.d/uhttpd restart; then
+               logger -s -t nut-cgi "Failed to restart uhttpd after http.conf update"
+               exit 1
+       fi
+}
diff --git a/net/nut/files/libhid-ups.hotplug b/net/nut/files/libhid-ups.hotplug
new file mode 100644 (file)
index 0000000..aad52cf
--- /dev/null
@@ -0,0 +1,347 @@
+#!/bin/sh
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+
+# The shellcheck source directives are used during development by external
+# tools to find the source files on the development system. They do not
+# affect runtime behaviour.
+
+# Also the filenames in the shellcheck source directives may differ from the
+# production names that are sourced on live OpenWrt devices.
+
+# shellcheck source=net/nut/files/functions.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-usb-hotplug "Unable to source 'functions.sh' in 'libhid-ups' hotplug. Bailing"
+       exit 1
+}
+
+# 'shellcheck' complains about nut-common.sh due to a case pattern it does not understand, even
+# though it is correctly POSIX compliant
+# shellcheck disable=SC1094
+# shellcheck source=net/nut/files/nut-common.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-common.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-usb-hotplug "Unable to source 'nut-common.sh' in 'libhid-ups' hotplug. Bailing"
+       exit 1
+}
+
+# shellcheck source=net/nut/files/nut-service.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-service.sh || {
+       log_source_error "nut-service.sh" "libhid-ups" nut-usb-hotplug
+       exit 1
+}
+
+# shellcheck source=net/nut/files/nut-server-service.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-server-service.sh || {
+       log_source_error "nut-server-service.sh" "libhid-ups" nut-usb-hotplug
+       exit 1
+}
+
+config_load nut_server || {
+       log_config_load_error "nut_server" "libhid-ups" nut-usb-hotplug
+       exit 1
+}
+
+# shellcheck disable=SC2329,SC2317
+start_stop_driver() {
+       local ups="$1"
+       local known_devname="$2"
+
+       # ACTION and DEVNAME come from the calling hotplug event
+       # shellcheck disable=SC2153
+       case "$ACTION" in
+       add)
+               # Skip hotplug if it is disabled or NUT is shutting down
+               if ! allow_hotplug_restart; then
+                       logger -s -t nut-usb-hotplug "Hotplug restart disabled starting '$ups' when processing '$ACTION' for '$DEVNAME'"
+                       # This is a configuration not an error
+                       return 0
+               fi
+
+               # Set permissions to allow the UPS device to start
+               # In some cases this may be enough for the UPS to start working
+               if ! ensure_usb_ups_access "$ups" "$known_devname"; then
+                       logger -s -t nut-usb-hotplug "Unable to enable access to '$ups' when processing '$ACTION' for '$DEVNAME'"
+                       return 0
+               fi
+
+               # Start the newly hotplugged UPS
+               # The initscript is effectively a noop if the UPS is already started
+               # (does not stop and restart, in that case).
+               # We check enabled again here, in case of a race condition since
+               # our last check.
+               if /etc/init.d/nut-server enabled; then
+                       if ! /etc/init.d/nut-server start "$ups"; then
+                               logger -s -t nut-usb-hotplug "Failed to start '$ups' processing '$ACTION' for '$DEVNAME'"
+                               return 0
+                       fi
+               else
+                       logger -s -t nut-usb-hotplug "Hotplug became disabled while processing '$ACTION' for '$DEVNAME'."
+                       return 0
+               fi
+               ;;
+       remove)
+               # Stop the newly removed UPS, even if hotplug is disabled.
+               # We stop only the removed UPS as we want the NUT service and other
+               # UPS (if any) to remain up.
+               /etc/init.d/nut-server stop "$ups" || {
+                       logger -s -t nut-usb-hotplug "Failed to stop nut-driver instance (UPS) '$ups'."
+                       return 0
+               }
+               ;;
+       *)
+               # Ignore all other hotplug ACTION types
+               return 0
+               ;;
+       esac
+       # We do not return 0 here as it is unnecessary (all case branches, including
+       # catch-all have a return) and shellcheck will complain if we do.
+}
+
+# We do not exit with error as that would abort processing for any other
+# drivers/devices
+
+# shellcheck disable=SC2329,SC2317
+nut_driver_config() {
+       local ups="$1"
+       local try_match="$2"
+       local cfg_vendorid cfg_productid
+
+       # Configuration might not have a vendorid. This is allowed, and in that case
+       # we keep going as we will restart this UPS with no match.
+       config_get cfg_vendorid "$ups" vendorid
+       if [ -n "$cfg_vendorid" ] && ! check_valid_hex_number "${cfg_vendorid/0x/}"; then
+               logger -s -t nut-usb-hotplug "Configured vendorid for '$ups' is not valid"
+               nd_driver_config_error=true
+               return 0
+       fi
+
+       case "$cfg_vendorid" in
+       '')
+               # Configuration might not have a vendorid. This is allowed, and in that
+               # case we keep going as we may restart this UPS with no match.
+               ;;
+       0x*)
+               cfg_vendorid="$(printf "%04x" "$cfg_vendorid")"
+               ;;
+       *)
+               cfg_vendorid="$(printf "%04x" "0x$cfg_vendorid")"
+               ;;
+       esac
+
+       # Configuration might not have a productid. This is allowed, and in that case
+       # we keep going as we will restart this UPS with no match.
+       config_get cfg_productid "$ups" productid
+       if [ -n "$cfg_productid" ] && ! check_valid_hex_number "${cfg_productid/0x/}"; then
+               logger -s -t nut-usb-hotplug "Configured productid for '$ups' is not valid"
+               nd_driver_config_error=true
+               return 0
+       fi
+       case "$cfg_productid" in
+       '')
+               # Configuration might not have a productid. This is allowed, and in that
+               # case we keep going as we may restart this UPS with no match.
+               ;;
+       0x*)
+               cfg_productid="$(printf "%04x" "$cfg_productid")"
+               ;;
+       *)
+               cfg_productid="$(printf "%04x" "0x$cfg_productid")"
+               ;;
+       esac
+
+       # ACTION and DEVNAME come from the calling hotplug event
+
+       # If we could not match a device in the previous round, or the configuration
+       # for the UPS does not have a vendorid and productid, start this UPS
+       # without checking for a match
+       if [ "$try_match" = "no" ]; then
+               if [ "$ACTION" = "add" ]; then
+                       if ! allow_hotplug_restart; then
+                               logger -s -t nut-usb-hotplug "Hotplug restart disabled starting '$ups' when processing '$ACTION' for '$DEVNAME'"
+                               return 0
+                       fi
+                       # We check enabled again here, in case of a race condition since
+                       # our last check.
+                       if /etc/init.d/nut-server enabled; then
+                               # If we don't have a specific match, attempt a reload rather
+                               # than restart of '$ups'. The nut-server initscript will do a
+                               # start if the reload fails.
+                               /etc/init.d/nut-server reload "$ups" || {
+                                       log_error "Failed to reload unmatched driver instance (UPS) '$ups' in nut_driver_config" libhid-ups nut-usb-hotplug
+                                       nd_driver_config_error=true
+                                       return 0
+                               }
+                       else
+                               # Initscript should have its own logging, and we do not exit with
+                               # error as that would abort processing other devices
+                               logger -s -t nut-usb-hotplug "NUT server disabled when processing '$ACTION' for '$DEVNAME'."
+                               return 0
+                       fi
+               else
+                       # We do not stop running UPS that do not match on vendorid
+                       # and productid on a removal event as that would mean all UPS would
+                       # be stopped if one nomatch UPS were to be removed. This would
+                       # be bad.
+                       return 0
+               fi
+       elif [ -n "$cfg_vendorid" ] && [ -n "$cfg_productid" ] && [ -n "$pvendid" ] && [ -n "$pprodid" ]; then
+               if ! check_valid_hex_number "$pvendid"; then
+                       logger -s -t nut-usb-hotplug "Hotplug vendor id for '$ups' is not valid"
+                       nd_driver_config_error=true
+                       return 0
+               fi
+               if ! check_valid_hex_number "$pprodid"; then
+                       logger -s -t nut-usb-hotplug "Hotplug product id for '$ups' is not valid"
+                       nd_driver_config_error=true
+                       return 0
+               fi
+               if [ "$(printf "%04x" 0x"$pvendid")" = "$cfg_vendorid" ] &&
+                       [ "$(printf "%04x" 0x"$pprodid")" = "$cfg_productid" ]; then
+                       # Round 1: If we have a match for hotplug event and the UCI config
+                       # start this UPS and record that fact, otherwise skip this UCI UPS
+                       # for this round
+                       # Initscript should have its own logging
+                       if ! start_stop_driver "$ups" "/dev/$DEVNAME"; then
+                               log_error "Error starting matched driver instance (UPS) in nut_driver_config" libhid-ups nut-usb-hotplug
+                               nd_driver_config_error=true
+                               return 0
+                       fi
+                       # shellcheck disable=SC2317
+                       nd_found=true
+                       return 0
+               fi
+       fi
+       # We do not return 0 here as it is unnecessary (all if branches have a
+       # return) and shellcheck will complain if we do.
+}
+
+perform_libhid_action() {
+       # Variable to indicate NUT driver instance (UPS) section found
+       local nd_found=false
+       local nd_driver_config_error=false
+       local pvendid pprodid
+
+       # Only find statepath and runas once per event
+       # defines STATEPATH
+       find_statepath "upsd" "nut_server" || {
+               logger -s -t nut-usb-hotplug "Failed to set STATEPATH"
+               return 1
+       }
+       # sets RUNAS
+       find_runas "upsd" "nut_server" || {
+               logger -s -t nut-usb-hotplug "Failed to set RUNAS"
+               return 1
+       }
+
+       # PRODUCT comes from the calling hotplug event, and
+       # is of the form vendorid/productid/other
+       # The code below uses shell parameter expansion to split out
+       # vendorid as pvendid and  productid as pprodid
+
+       # We disable the check for possible misspelling as the variables names
+       # are similar, but PRODUCT, pvendid, and pprodid are correct.
+       # shellcheck disable=SC2153
+
+       # PRODUCT from hotplug has the form aaaa/bbbb/cccc, where aaaa, bbbb,
+       # cccc are 1-4 hex digits.
+
+       # From PRODUCT, strip final / and everything after it (e.g. /cccc)
+       pvendid=${PRODUCT%/*}
+       # pvendid is now of form aaaa/bbbb
+       # Strip final / and anything after (e.g. /bbbb), leaving aaaa in pvendid
+       pvendid=${pvendid%/*}
+       # From PRODUCT, strip final / and everything after it (e.g. /cccc)
+       pprodid=${PRODUCT%/*}
+       # pprodid is now of form aaaa/bbbb
+       # Strip everything before and including the remaining / (e.g. aaaa/)
+       # This leaves bbbb in pprodid
+       pprodid=${pprodid##*/}
+
+       # Attempt to find and (re)start a UPS with a match for the
+       # pvendid and pprodid we have parsed.
+       config_foreach nut_driver_config driver yes
+
+       [ "$nd_driver_config_error" = "false" ] || return 1
+       # Only if we cannot find a matching UPS (driver instance) configuration
+       # do we try again, this time restarting all UPS (driver) instances
+       # This is for the event the device matching configuration for NUT does
+       # not use USB vendorid and productid (and possibly serial number), which
+       # is all we have to work with in this script (and sourced scripts)
+       if [ "$nd_found" = "false" ]; then
+               config_foreach nut_driver_config driver no
+       fi
+
+       [ "$nd_driver_config_error" = "false" ] || return 1
+       # Calls nut-server start for upsd, which has checks for the existence of a
+       # ups driver and upsd configuration and will not start if configuration
+       # is missing. In addition any UPS for which configuration has been removed
+       # and not already stopped, will be stopped. This is preferable to doing
+       # nothing on removals.
+       if ! allow_hotplug_restart; then
+               logger -s -t nut-usb-hotplug "Hotplug restart disabled when processing '$ACTION' for '$DEVNAME'"
+               return 0
+       fi
+       # We check enabled again here, in case of a race condition since
+       # our last check.
+       if /etc/init.d/nut-server enabled; then
+               # Initscript should have its own logging
+               if ! /etc/init.d/nut-server start upsd; then
+                       return 1
+               fi
+               return 0
+       else
+               logger -s -t nut-usb-hotplug "NUT server disabled when processing '$ACTION' for '$DEVNAME'."
+               return 0
+       fi
+       # We do not return 0 here as it is unnecessary (all if branches have a
+       # return) and shellcheck will complain if we do.
+}
+
+# PRODUCT comes from the calling hotplug event, and
+# is of the form vendorid/productid/other
+
+# This is an incomplete script which gets filled by libhid-ups.parsed-usermap.
+# This confuses shellcheck and reviewers, hence the disables below.
+# shellcheck disable=SC1073,SC1072
+case "$PRODUCT" in
+### insert libhid-ups.parsed-usermap content here ###
+# The result is cases of the form
+# "vendorid/productid/other" ) | \
+# to be matched against the PRODUCT from the calling hotplug event
+# and does NOT use ';;' (so falls through to code below).
+# The empty string matches when PRODUCT is empty, but does not match
+# when PRODUCT is neither empty nor one of the case targets, above
+
+"")
+       # Skip hotplug actions if NUT hotplug is disabled
+       # Uses NUT_DISABLE_HOTPLUG_PATH,  defined in nut-service.sh and sourced
+       # NUT_DISABLE_HOTPLUG_PATH is an external sentinel used to indicate NUT
+       # hotplug is disabled.
+       if ! allow_hotplug_restart; then
+               logger -s -t nut-usb-hotplug "Hotplug restart disabled when processing '$ACTION' for '$DEVNAME'"
+               exit 0
+       fi
+       # We check enabled again here, in case of a race condition since
+       # our last check.
+       if ! /etc/init.d/nut-server enabled; then
+               logger -s -t nut-usb-hotplug "NUT server disabled when processing '$ACTION' for '$DEVNAME'."
+               exit 0
+       fi
+       if ! perform_libhid_action; then
+               exit 1
+       fi
+       exit 0
+       ;;
+*)
+       # We intentionally do nothing when PRODUCT is neither empty, nor matches
+       # the libhid-ups.parsed-usermap content cases. In that case the PRODUCT is
+       # for a device other than a UPS supported by NUT's libhid-ups.
+       exit 0
+       ;;
+esac
old mode 100755 (executable)
new mode 100644 (file)
index e5c3cf1..9c95923
 #!/bin/sh /etc/rc.common
 # Copyright Â© 2012-2026 OpenWrt.org
-#
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
 # This is free software, licensed under the GNU General Public License v2.
 # See /LICENSE for more information.
 #
+
+# shellcheck disable=SC2034
 START=87
 STOP=23
 USE_PROCD=1
 
-DEFAULT=/etc/default/nut
-UPSCGI_C=/var/etc/nut/hosts.conf
-UPSCGI_S=/var/etc/nut/upsset.conf
+# Default is hardcoded in other program's configuration, as well as compile
+# time options for NUT CGI executable
+# /var is typically a tmpfs on OpenWrt and is not persisted across reboots
+UPSCGI_CONF_DIR=/var/etc/nut
+
+# Ephemeral configuration for configuring NUT hosts using CGI and upsset
+# (persisted using UCI)
+UPSCGI_UPSSET_CONF="${UPSCGI_CONF_DIR}/upsset.conf"
+# Ephemeral configuration for NUT hosts to display using CGI (persisted
+# using UCI)
+UPSCGI_HOSTS_CONF="${UPSCGI_CONF_DIR}/hosts.conf"
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+
+# Shellcheck source paths intentionally point to the location of files of
+# the scripts in the development environment (where shellcheck is used), not
+# on the live OpenWrt device
+
+# shellcheck source=net/nut/files/functions.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-cgi "Unable to source 'functions.sh'"
+       exit 1
+}
+
+# 'shellcheck' complains about nut-common.sh due to a case pattern it does not understand, even
+# though it is correctly POSIX compliant
+# shellcheck disable=SC1094
+# shellcheck source=net/nut/files/nut-common.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-common.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-cgi "Failed to load nut-common.sh"
+       exit 1
+}
+
+restore_umask_on_exit
+
+ensure_conf_dir_exists() {
+       # If the directory already exists we presume the creator of the directory
+       # has set the correct permissions.
+       if [ ! -d "$UPSCGI_CONF_DIR" ]; then
+               # Make directory for the UPS CGI configuration files, if not present
+               # Set permission on dir we create for UPS CGI configuration files
+               # All NUT configuration files are here, some which must be readable
+               # by other binaries with different group and ownership (therefore must
+               # be world readable)
+               # NOTE: Ensure sensitive files have more restrictive permissions
+               umask 022
+               if ! mkdir -p "$UPSCGI_CONF_DIR"; then
+                       restore_umask
+                       return 1
+               fi
+               restore_umask
+       fi
+       return 0
+}
+
+set_upsset_conf_content() {
+       local upsset_conf_content="$1"
+
+       umask 133
+       if ! printf "%s" "$upsset_conf_content" >"$UPSCGI_UPSSET_CONF"; then
+               restore_umask
+               log_error "failed to configure upsset; disabling NUT CGI" nut-cgi nut-cgi
+               return 1
+       fi
+       restore_umask
+       return 0
+}
+
+disable_upsset() {
+       ensure_conf_dir_exists || {
+               log_error "failed to disable upsset due to invalid directory; disabling NUT CGI" nut-cgi nut-cgi
+               return 1
+       }
+       # Use embedded content in preference to previous symlink method to avoid an
+       # attack vector via modified symlink target
+       # Prefer variable to heredoc due to complication with if, then around a
+       # heredoc when using shellcheck and shfmt together.
+       set_upsset_conf_content "# Network UPS Tools - upsset.conf sample file for OpenWrt
+#
+# This file is provided to ensure that you do not expose your upsd server
+# to the world upon installing the CGI programs.  Specifically, it keeps
+# the upsset.cgi program from running until you have assured it that you
+# have secured your web server's CGI directory.
+#
+# NOTE: Contents of this file should be pure ASCII (character codes
+# not in range would be ignored with a warning message).
+#
+###
+### I_HAVE_SECURED_MY_CGI_DIRECTORY
+###
+" || return 1
+       return 0
+}
 
-nut_upscgi_upsset() {
+configure_nut_upscgi_upsset() {
        local cfg="$1"
        local enable
 
        config_get_bool enable "$cfg" enable 0
 
-       if [ "$enable" -eq 1 ]; then
-               ln -sf /etc/nut/upsset.conf.enable "$UPSCGI_S"
+       # Use embedded content in preference to previous symlink method to avoid an
+       # attack vector via modified symlink target
+       # Prefer variable to heredoc due to complication with if..then around a
+       # heredoc when using shellcheck and shfmt together.
+       [ "$enable" = "1" ] || {
+               # Logs an error in the disable_upsset function call if it fails
+               disable_upsset || return 1
+               # not enabled (disabled) is a valid configuration choice
+               return 0
+       }
+       if ensure_conf_dir_exists; then
+               # Leading newline is intentional
+               set_upsset_conf_content "
+I_HAVE_SECURED_MY_CGI_DIRECTORY
+" || {
+                       upscgi_upsset_failed="true"
+                       return 1
+               }
+               return 0
        else
-               ln -sf /etc/nut/upsset.conf.disable "$UPSCGI_S"
+               upscgi_upsset_failed="true"
+               # Logs an error in the disable_upsset function call if it fails
+               disable_upsset
+               return 1
        fi
+       # No return 0 here as it would be dead code, and shellcheck would complain
+       # about the dead code.
 }
 
-nut_upscgi_add() {
-       local cfg="$1"
-       local upsname
-       local hostname
-       local port
-       local displayname
-
-       config_get upsname "$cfg" upsname
-       config_get hostname "$cfg" hostname localhost
-       config_get port "$cfg" port
-       config_get pass "$cfg" password
-       system="$upsname@$hostname"
+log_validation_error() {
+       local var="$1"
+       local ups="$2"
+       log_error "Unsafe characters in '$var' for '$ups'; skipping" nut-cgi nut-cgi
+}
+
+validate_host() {
+       local ups="$1"
+       local upsname="$2"
+       local hostname="$3"
+       local port="$4"
+       local displayname="$5"
+
+       # We do not unset upscgi_hosts_exists on error as we do not want to block
+       # monitoring hosts with the needed configuration as a result of those that
+       # do not
+       for var in upsname hostname port displayname; do
+               case "$var" in
+               upsname)
+                       if ! check_safe_uci_name "$upsname"; then
+                               log_validation_error "$var" "$ups"
+                               return 1
+                       fi
+                       ;;
+               hostname)
+                       case "$hostname" in
+                       "" | *[!a-zA-Z0-9.:-\[\]-]*)
+                               log_validation_error "$var" "$ups"
+                               return 1
+                               ;;
+                       esac
+                       ;;
+               displayname)
+                       # We accept a minimally useful set of characters for a display name
+                       # We specifically omit characters like $ % " and '
+                       # Some AI code reviewers get confused by \(\) and \space in the case
+                       # pattern, but this is correct for busybox ash in modern OpenWrt
+                       # and does not allow \ through the validation.
+                       case "$displayname" in
+                       *[!a-zA-Z0-9+!\(\)\ _=:.,-]*)
+                               log_validation_error "$var" "$ups"
+                               return 1
+                               ;;
+                       esac
+                       ;;
+               port)
+                       # We don't log missing port log, as port is optional (NUT defaults
+                       # to using 3493 when it is not present)
+                       if [ -n "$port" ]; then
+                               case "$port" in
+                               0*)
+                                       log_validation_error "$var" "$ups"
+                                       return 1
+                                       ;;
+                               *[!0-9]*)
+                                       log_validation_error "$var" "$ups"
+                                       return 1
+                                       ;;
+                               esac
+                               # Catches illegal port numbers, including negative port numbers
+                               if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
+                                       log_error "Port set, but not between 1-65535 for '$ups'" nut-cgi nut-cgi
+                                       return 1
+                               fi
+                       fi
+                       ;;
+               esac
+       done
+       return 0
+}
+
+# Must be called from service_reload
+nut_upscgi_add_host() {
+       local ups="$1"
+       local upsname hostname port system displayname
+
+       config_get upsname "$ups" upsname "$ups"
+       config_get hostname "$ups" hostname localhost
+       config_get port "$ups" port
+       config_get displayname "$ups" displayname "$ups"
+
+       # We do not error exit as that would abort processing of other hosts
+       validate_host "$ups" "$upsname" "$hostname" "$port" "$displayname" || return
+
+       system="${upsname}@$hostname"
        if [ -n "$port" ]; then
-           system="$system:$port";
+               system="$system:$port"
+       fi
+
+       if ! printf "%s\n" "MONITOR $system \"$displayname\"" >>"${UPSCGI_HOSTS_CONF}.new"; then
+               log_error "Failed to add MONITOR to CGI host.conf for '$ups'" nut-cgi nut-cgi
+               # Failed printf could result in inconsistent UPSCGI_HOSTS_CONF.new state, so recreate it as
+               # empty so later hosts can still potentially be properly configured
+               if rm -f "${UPSCGI_HOSTS_CONF}.new"; then
+                       return
+               else
+                       # Cascade failure. Bail.
+                       log_error_exit "Cascade failure in nut_upscgi_add_host. Bailing." nut-cgi nut-cgi
+               fi
        fi
-       config_get displayname "$cfg" displayname
-       echo "MONITOR $system \"$displayname\"" >> "$UPSCGI_C"
+       upscgi_hosts_exists="true"
+       return 0
 }
 
 service_reload() {
-       mkdir -m 0755 -p "$(dirname "$UPSCGI_C")"
-       rm -f "$UPSCGI_C"
-       rm -f "$UPSCGI_S"
+       # Callers should not need to see the variables below outside this function
+       # Conversely, functions called by this function are able to see and set
+       # the variables below, which act as 'pseudo-globals' to called functions.
+       local upscgi_hosts_exists="false"
+       local upscgi_upsset_failed="false"
+
+       config_load nut_cgi || {
+               log_config_load_error "nut_cgi" "nut-cgi" "nut-cgi"
+               exit 1
+       }
+
+       # NUT configuration does not exist if NUT CGI cannot be configured, so
+       # stop service and bailout.
+       ensure_conf_dir_exists || {
+               service_stop
+               return 1
+       }
 
-       config_load nut_cgi
+       rm -f "${UPSCGI_HOSTS_CONF}.new"
+       umask 133
+       touch "${UPSCGI_HOSTS_CONF}.new" || {
+               restore_umask
+               service_stop
+               return 1
+       }
+       restore_umask
 
-       config_foreach nut_upscgi_add host
-       config_foreach nut_upscgi_upsset upsset
+       # We do not exit on error here as we do not want to block monitoring hosts
+       # with the needed configuration as a result of those that do not
+       config_foreach nut_upscgi_add_host host
 
-       [ -s "$UPSCGI_C" ] && chmod 640 "$UPSCGI_C"
+       # If new host configuration was successfully created
+       if [ "$upscgi_hosts_exists" = "true" ] && [ -s "${UPSCGI_HOSTS_CONF}.new" ]; then
+               # Move the new configuration file to active use,
+               # removing the old configuration in the process
+               mv -f "${UPSCGI_HOSTS_CONF}.new" "${UPSCGI_HOSTS_CONF}"
+       elif [ "$upscgi_hosts_exists" = "true" ]; then
+               rm -f "${UPSCGI_HOSTS_CONF}.new"
+       else
+               # If no hosts are configured, use an empty hosts.conf (no configuration)
+               rm -f "${UPSCGI_HOSTS_CONF}"
+               rm -f "${UPSCGI_HOSTS_CONF}.new"
+               if ! touch "${UPSCGI_HOSTS_CONF}"; then
+                       log_error "Failed to create empty hosts.conf" nut-cgi nut-cgi
+                       return 1
+               fi
+               # Empty hosts is not necessarily an error, but we log for information
+               logger -s -t nut-cgi "No configured hosts"
+       fi
+
+       # NUT CGI host file configuration is independent of upsset configuration
+       config_foreach configure_nut_upscgi_upsset upsset
+       if [ "$upscgi_upsset_failed" = "true" ]; then
+               disable_upsset
+               return 1
+       fi
+       return 0
 }
 
 start_service() {
-       service_reload
+       service_reload || return 1
+       return 0
 }
 
 reload_service() {
-       service_reload
+       service_reload || {
+               stop_service
+               return 1
+       }
+       return 0
 }
 
 stop_service() {
-       rm -f "$UPSCGI_C"
-       rm -f "$UPSCGI_S"
-       ln -sf /etc/nut/upsset.conf.disable "$UPSCGI_S"
+       # Remove the hosts configuration file
+       # Since this is only a configuration generation service, the only way to
+       # stop serving (without stopping the web server for all uses) is to remove
+       # the NUT CGI configuration
+       rm -f "$UPSCGI_HOSTS_CONF"
+       rm -f "$UPSCGI_HOSTS_CONF.new"
+
+       disable_upsset || return 1
+       return 0
 }
 
 service_triggers() {
-       procd_add_reload_trigger "nut_cgi"
+       # Add a reload trigger on changes to the nut_cgi UCI config
+       procd_add_reload_trigger "nut_cgi" || return 1
+       return 0
 }
diff --git a/net/nut/files/nut-common.sh.functions b/net/nut/files/nut-common.sh.functions
new file mode 100644 (file)
index 0000000..a10ff11
--- /dev/null
@@ -0,0 +1,193 @@
+#!/bin/sh
+# Shebang line included so editors and shellcheck/shfmt know this contains
+# shell code
+
+# Common helper functions for NUT server initscript
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# Pre-requisite sourcing for functions this script uses
+# * /lib/functions.sh has been sourced
+
+# We use explicit return 0 at the end of functions throughout, except where they
+# would be dead code, for clarity
+
+# Preserve current umask in CUR_UMASK, unless a umask was previously captured
+CUR_UMASK="${CUR_UMASK:-$(umask)}"
+
+restore_umask() {
+       umask "$CUR_UMASK"
+}
+
+restore_umask_on_exit() {
+       # Restore umask on exit
+       # shellcheck disable=SC3047
+       trap restore_umask EXIT
+}
+
+# Check string to contain only characters allowed in an UCI section name
+check_safe_uci_name() {
+       local val="$1"
+       [ -n "$val" ] || return 1
+       case "$val" in
+       *[!a-zA-Z0-9_-]*)
+               return 1
+               ;;
+       esac
+       return 0
+}
+
+# Check string to contain only characters valid in a RFC822 Date header
+check_safe_date() {
+       local in_date="$1"
+       [ -n "$in_date" ] || return 1
+       # Some AI code reviewers get confused by \(\) and \space in the case
+       # pattern, but this is correct for busybox ash in modern OpenWrt
+       # and does not allow \ through the validation.
+       # We want to allow space, comma, and hyphen as they can all appear in an
+       # RFC822 Date: header
+       case "$in_date" in
+       *[!a-zA-Z0-9_:+\(\),\ -]*)
+               return 1
+               ;;
+       esac
+       return 0
+}
+
+# Check if input is a valid user or group name
+check_valid_user_group_name() {
+       local in_name="$1"
+       case "$in_name" in
+       [a-zA-Z_][a-zA-Z0-9_-]*) ;;
+       # Includes the case of an empty string
+       *)
+               return 1
+               ;;
+       esac
+       return 0
+}
+
+# shellcheck disable=SC2329,SC2317
+check_valid_hex_number() {
+       local number="$1"
+       local max_length="$2"
+       [ -n "$max_length" ] || max_length=4
+       case "$number" in
+       '')
+               # Empty string is not valid
+               return 1
+               ;;
+       0x*)
+               case "$number" in
+               0x[!0-9a-fA-F]*)
+                       return 1
+                       ;;
+               0x[0-9a-fA-F]*)
+                       # Minimum of 1 hex digit
+                       # Has a max of 4 hex digits (1-max_length hex digits allowed) and min of 1
+                       if [ "${#number}" -gt "$((max_length + 2))" ] || [ "${#number}" -lt 1 ]; then
+                               return 1
+                       fi
+                       return 0
+                       ;;
+               *)
+                       # Redundant, but add clarity for reviewers, especially automated
+                       # ones that can get confused without it.
+                       return 1
+                       ;;
+               esac
+               ;;
+       *[!0-9a-fA-F]*)
+               return 1
+               ;;
+       *)
+               # Has a maximum of 4 hex digits (1-max_length hex digits allowed)
+               if [ "${#number}" -gt "$max_length" ]; then
+                       return 1
+               fi
+               return 0
+               ;;
+       esac
+       # We do not return 0 here as it is unnecessary (all case branches, including
+       # catch-all have a return) and shellcheck will complain if we do.
+}
+
+# Wrap logging, in case we decide to update the logging, everywhere
+log_msg() {
+       local reason="$1"
+       local current_script="$2"
+       local syslog_id="$3"
+       local level="${4:-notice}"
+       local facility="${5:-daemon}"
+
+       logger -s -t "$syslog_id" -p "${facility}.${level}" "'$reason' in '$current_script'" || {
+               # We use 'tr' as changing case via variable parameter substitution is not available in ash on OpenWrt
+               # shellcheck disable=SC2018,SC2019
+               level="$(echo "$level" | tr 'a-z' 'A-Z')"
+               printf "'%s': Logging failed during $level: '%s' in '%s'\n" "$syslog_id" "$reason" "$current_script" >&2
+       }
+       case "$level" in
+       crit)
+               exit 1
+               ;;
+       err | error | warn)
+               return 1
+               ;;
+       *)
+               return 0
+               ;;
+       esac
+}
+
+log_source_error() {
+       local sourced_script="$1"
+       local current_script="$2"
+       local syslog_id="$3"
+       log_msg "Unable to source '$sourced_script'" "$current_script" "$syslog_id" "crit"
+}
+
+log_config_load_error() {
+       local config="$1"
+       local current_script="$2"
+       local syslog_id="$3"
+
+       log_msg "Unable to load configuration '$config'" "$current_script" "$syslog_id" "crit"
+}
+
+# Wrap logging of errors, in case we decide to update the logging, everywhere
+# we do error logging with script exit
+log_error_exit() {
+       local reason="$1"
+       local current_script="$2"
+       local syslog_id="$3"
+
+       log_msg "$reason" "$current_script" "$syslog_id" "crit"
+}
+
+# Wrap logging of errors, in case we decide to update the logging, everywhere
+# we do error logging without hard exit
+log_error() {
+       local reason="$1"
+       local current_script="$2"
+       local syslog_id="$3"
+
+       log_msg "$reason" "$current_script" "$syslog_id" "error"
+}
+
+note_section_of_type() {
+       have_expected_section="true"
+}
+
+have_section_of_type() {
+       local section_type="$1"
+
+       # 'pseudo-global' to capture result from config_foreach
+       local have_expected_section="false"
+       config_foreach note_section_of_type "$section_type"
+
+       if [ "$have_expected_section" = "true" ]; then
+               return 0
+       fi
+       return 1
+}
diff --git a/net/nut/files/nut-monitor-config.sh.functions b/net/nut/files/nut-monitor-config.sh.functions
new file mode 100644 (file)
index 0000000..865e466
--- /dev/null
@@ -0,0 +1,359 @@
+#!/bin/sh
+# Shebang line included so editors and shellcheck/shfmt know this contains
+# shell code
+
+# Helper functions for NUT monitor (upsmon) configuration handling
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# Pre-requisite sourcing for functions this script uses
+# * /lib/functions has been sourced
+# * /lib/functions/nut/nut-common.sh has been sourced
+# * /lib/functions/nut/nut-service.sh has been sourced
+
+# Upstream NUT event type names (in the order listed in
+# https://networkupstools.org/docs/man/upsmon.conf.html)
+# NUT_EVENT_TYPES is used later without quotes for intentional word-splitting
+# Globbing is disabled in that case
+NUT_EVENT_TYPES="ONLINE ONBATT LOWBATT FSD COMMOK COMMBAD SHUTDOWN REPLBATT"
+append NUT_EVENT_TYPES "NOCOMM NOPARENT CAL NOTCAL OFF NOTOFF BYPASS NOTBYPASS"
+append NUT_EVENT_TYPES "ECO NOTECO OVER NOTOVER TRIM NOTTRIM BOOST NOTBOOST"
+append NUT_EVENT_TYPES "OTHER NOTOTHER SUSPEND_STARTING SUSPEND_FINISHED"
+
+# Upstream NUT upsmon.conf option names (in the order listed in
+# https://networkupstools.org/docs/man/upsmon.conf.html)
+# except MONITOR, NOTIFYMSG, NOTIFYFLAG, and RUN_AS_USER which are handled
+# separately
+NUT_UPSMON_OPTIONS="DEADTIME FINALDELAY HOSTSYNC MINSUPPLIES NOCOMMWARNTIME"
+append NUT_UPSMON_OPTIONS "POLLFAIL_LOG_THROTTLE_MAX NOTIFYCMD POLLFREQ"
+append NUT_UPSMON_OPTIONS "POLLFREQALERT POWERDOWNFLAG OFFDURATION OVERDURATION"
+append NUT_UPSMON_OPTIONS "OBLBDURATION RBWARNTIME SHUTDOWNCMD SHUTDOWNEXIT"
+append NUT_UPSMON_OPTIONS "CERTPATH CERTFILE CERTIDENT CERTHOST DEBUG_MIN"
+
+NUT_UPSMON_BOOL_OPTIONS="ALARMCRITICAL CERTVERIFY FORCESSL"
+
+# Location of NUT's UPS monitoring client (upsmon) configuration
+UPSMON_C=/var/etc/nut/upsmon.conf
+# Location of NUT's mode configuration
+NUT_CONF=/var/etc/nut/nut.conf
+# Path to PID file for upsmon
+# shellcheck disable=SC2034
+PIDFILE=/var/run/upsmon.pid
+
+# config_load is done by the sourcing script, before executing any functions in
+# this file
+
+# Get notification configuration
+# In nut_monitor UCI configuration a 'notification'-type config section must
+# be named with the name of a NUT_EVENT_TYPE. This NUT_EVENT_TYPE maps to
+# a notification event type emitted by upsmon.
+nut_get_notifications() {
+       local event="$1"
+       local defaultnotify="$2"
+       local config_file="$3"
+       local event_types val
+
+       # Try to remove the name of the UCI section surrounded by spaces.
+       # If this differs from the NUT_EVENT_TYPE contents, then the section
+       # is a valid NUT event type, so use it.
+       event_types=" $NUT_EVENT_TYPES "
+       if [ "${event_types#*" $event "}" != "${event_types}" ]; then
+               config_get val "$event" message
+               if [ -n "$val" ]; then
+                       if ! printf 'NOTIFYMSG %s %s\n' "$event" "$val" >>"$config_file"; then
+                               log_error "upsmon section '$event' failed to write 'NOTIFYMSG' for '$event'" nut-monitor-config.sh nut-monitor-config
+                               return 1
+                       fi
+               fi
+               config_get val "$event" flag "$defaultnotify"
+               if [ -n "$val" ]; then
+                       if ! printf 'NOTIFYFLAG %s %s\n' "$event" "$val" >>"$config_file"; then
+                               log_error "upsmon section '$event' failed to write 'NOTIFYFLAG' for '$event'" nut-monitor-config.sh nut-monitor-config
+                               return 1
+                       fi
+                       event_notify_flags_not_found="${event_notify_flags_not_found/$event/}"
+               fi
+       else
+               log_error "$event is not a valid NUT message event type" nut-monitor-config.sh nut-monitor-config
+               return 1
+       fi
+}
+
+upsmon_conf_get_write() {
+       local cfg="$1"
+       local config_file="$2"
+       local uci_option="$3"
+       local nut_option="$4"
+       local is_bool="$5" # optional parameter
+       local default="$6"
+       local val
+
+       if [ -n "$is_bool" ] && [ "$is_bool" = "true" ]; then
+               # Will always get a value - either 0 or 1
+               config_get_bool val "$cfg" "$uci_option" "$default"
+       else
+               config_get val "$cfg" "$uci_option" "$default"
+       fi
+
+       if [ -n "$val" ]; then
+               printf "%s %s\n" "$nut_option" "$val" >>"$config_file" || {
+                       log_error "upsmon section '$cfg' failed to write '$nut_option'" nut-monitor-config.sh nut-monitor-config
+                       return 1
+               }
+               return 0
+       else
+               # Will never trigger for a bool
+               return 2
+       fi
+}
+
+# Generate upsmon.conf
+nut_upsmon_conf() {
+       local config_file="$1"
+       local cfg="upsmon"
+       local val defaultnotify nut_option uci_option conf_ret
+       local event_notify_flags_not_found upsmon_bool_options nut_bool
+       upsmon_bool_options=" $NUT_UPSMON_BOOL_OPTIONS "
+
+       # Note that we use '-u $RUNAS' on the daemon command line in preference to
+       # the RUN_AS_USER configuration in "$config_file"
+
+       # Word-splitting is intentional here, and we know NUT_UPSMON_OPTIONS and
+       # NUT_UPSMON_BOOL_OPTIONS are safe because we define them.
+       set -f
+       for nut_option in $NUT_UPSMON_OPTIONS $NUT_UPSMON_BOOL_OPTIONS; do
+               nut_bool="false"
+               # The nut_option and corresponding uci_option are known to be
+               # pure 7-bit ASCII
+               # We use tr because ash does not support *global* replacement using
+               # parameter expansion
+               # shellcheck disable=SC2019,SC2018
+               uci_option="$(echo "$nut_option" | tr 'A-Z_' 'a-z')"
+               case "$nut_option" in
+               # integer: negative values allowed
+               POLLFAIL_LOG_THROTTLE_MAX | \
+               OFFDURATION | \
+               OVERDURATION | \
+               OBLBDURATION)
+                       config_get val "$cfg" "$uci_option"
+                       case "${val#[-]}" in
+                       *[!0123456789]*)
+                               log_error "upsmon section '$cfg' bad value for '$uci_option'" nut-monitor-config.sh nut-monitor-config
+                               return 1
+                               ;;
+                       '')
+                               # Ignore options with no configuration
+                               :
+                               ;;
+                       *)
+                               if ! printf "%s %s\n" "$nut_option" "$val" >>"$config_file"; then
+                                       log_error "upsmon section '$cfg' failed to write '$nut_option'" nut-monitor-config.sh nut-monitor-config
+                                       return 1
+                               fi
+                               ;;
+                       esac
+                       ;;
+               # integer: negative values not allowed
+               DEADTIME | \
+               DEBUG_MIN | \
+               FINALDELAY | \
+               HOSTSYNC | \
+               MINSUPPLIES | \
+               NOCOMMWARNTIME | \
+               POLLFREQ | \
+               POLLFREQALERT | \
+               RBWARNTIME)
+                       config_get val "$cfg" "$uci_option"
+                       case "$val" in
+                       *[!0123456789]*)
+                               log_error "upsmon section '$cfg' bad value for '$uci_option'" nut-monitor-config.sh nut-monitor-config
+                               return 1
+                               ;;
+                       '')
+                               # Ignore options with no configuration
+                               :
+                               ;;
+                       *)
+                               if ! printf "%s %s\n" "$nut_option" "$val" >>"$config_file"; then
+                                       log_error "upsmon section '$cfg' failed to write '$nut_option'" nut-monitor-config.sh nut-monitor-config
+                                       return 1
+                               fi
+                               ;;
+                       esac
+                       ;;
+               SHUTDOWNCMD)
+                       upsmon_conf_get_write "$cfg" "$config_file" "$uci_option" "$nut_option"
+                       conf_ret=$?
+                       case "$conf_ret" in
+                       1)
+                               return 1
+                               ;;
+                       2)
+                               if ! printf "%s %s\n" "$nut_option" "/usr/sbin/nutshutdown" >>"$config_file"; then
+                                       log_error "upsmon section '$cfg' failed to write 'SHUTDOWNCMD'" nut-monitor-config.sh nut-monitor-config
+                                       return 1
+                               fi
+                               ;;
+                       esac
+                       ;;
+               ALARMCRITICAL)
+                       upsmon_conf_get_write "$cfg" "$config_file" "$uci_option" "$nut_option" "true" "1"
+                       conf_ret=$?
+                       if [ "$conf_ret" -eq 1 ]; then
+                               return 1
+                       fi
+                       ;;
+               POWERDOWNFLAG)
+                       upsmon_conf_get_write "$cfg" "$config_file" "$uci_option" "$nut_option" "false" "$NUT_KILLPOWER"
+                       conf_ret=$?
+                       if [ "$conf_ret" -eq 1 ]; then
+                               return 1
+                       fi
+                       ;;
+               *)
+                       if [ "${upsmon_bool_options/$nut_option/}" != "${upsmon_bool_options}" ]; then
+                               nut_bool="true"
+                       fi
+                       upsmon_conf_get_write "$cfg" "$config_file" "$uci_option" "$nut_option" "$nut_bool"
+                       conf_ret=$?
+                       if [ "$conf_ret" -eq 1 ]; then
+                               return 1
+                       fi
+                       ;;
+               esac
+       done
+       set +f
+
+       event_notify_flags_not_found="$NUT_EVENT_TYPES"
+       config_get val "$cfg" defaultnotify "SYSLOG"
+       defaultnotify="$val"
+       config_foreach nut_get_notifications notifications "$val" "$config_file"
+
+       # Otherwise the default is WALL+SYSLOG
+       event_notify_flags_not_found="$(printf "%s" "$event_notify_flags_not_found" | tr -s ' ')"
+       # We intentionally word-split on event_notify_flags-not-found to iterate
+       # over event types.
+       set -f
+       for event in $event_notify_flags_not_found; do
+               if ! printf "NOTIFYFLAG %s %s\n" "$event" "$defaultnotify" >>"$config_file"; then
+                       log_error "upsmon section '$cfg' failed to write 'NOTIFYFLAG' for '$event'" nut-monitor-config.sh nut-monitor-config
+                       return 1
+               fi
+       done
+       set +f
+       return 0
+}
+
+nut_upsmon_add() {
+       local cfg="$1"
+       local config_file="$2"
+       local upsname
+       local hostname
+       local port
+       local powervalue
+       local username
+       local password
+       local system
+       local type
+
+       config_get upsname "$cfg" upsname "$cfg"
+       config_get hostname "$cfg" hostname localhost
+       config_get port "$cfg" port
+       config_get powervalue "$cfg" powervalue 1
+       config_get username "$cfg" username
+       config_get password "$cfg" password
+       config_get type "$cfg" type secondary
+
+       system="$upsname@$hostname"
+       if [ -n "$port" ]; then
+               system="$system:$port"
+       fi
+
+       if [ -z "$username" ] || [ -z "$password" ]; then
+               log_error "upsmon section '$cfg' missing value(s) for MONITOR line" nut-monitor-config.sh nut-monitor-config
+               upsmon_conf_fail="true"
+       else
+               # Write MONITOR line (including password) to config_file (upsmon.conf)
+               if ! printf "MONITOR %s %s %s %s %s\n" "$system" "$powervalue" "$username" "$password" "$type" >>"$config_file"; then
+                       log_error "upsmon section '$cfg' failed to write MONITOR line for '$system'" nut-monitor-config.sh nut-monitor-config
+                       upsmon_conf_fail="true"
+               else
+                       have_monitor_line="true"
+               fi
+       fi
+}
+
+build_config() {
+       local conf_group
+       local upsmon_conf_fail="false"
+       local have_monitor_line="false"
+
+       RUNAS="${RUNAS:-nutmon}"
+       conf_group="$(id -gn "$RUNAS")"
+
+       if [ -z "$conf_group" ]; then
+               log_error "upsmon build_config failed to find group for the RUNAS user ('$RUNAS')" nut-monitor-config.sh nut-monitor-config
+               upsmon_conf_fail="true"
+       else
+               # This directory is shared with the nut-server which run as as a
+               # different user and group, so must be all readable. We set the
+               # ownership and permissions on individual files more restrictively, as
+               # needed.
+               # shellcheck disable=SC2174
+               umask 022
+               mkdir -p "$(dirname "$UPSMON_C")"
+
+               umask 127
+               touch "$UPSMON_C.new"
+               chgrp "$conf_group" "$UPSMON_C.new"
+               printf "%s\n" "# Config file automatically generated from UCI config" >>"$UPSMON_C.new"
+
+               if nut_upsmon_conf "$UPSMON_C.new"; then
+                       # upsmon_conf_fail will be set in this function's context by nut_upsmon_add, on error
+                       config_foreach nut_upsmon_add monitor "$UPSMON_C.new"
+                       if [ "$upsmon_conf_fail" = "true" ]; then
+                               log_error "'monitor' type sections must be correctly configured" nut-monitor-config.sh nut-monitor-config
+                               return 1
+                       fi
+                       if [ "$have_monitor_line" = "false" ]; then
+                               log_msg "Must have at least one 'monitor' type section" nut-monitor-config.sh nut-monitor-config warn
+                               return 1
+                       fi
+               else
+                       log_error "upsmon section name 'upsmon' not correctly configured" nut-monitor-config.sh nut-monitor-config
+                       return 1
+               fi
+       fi
+
+       # In the event of configuration failure, stop the
+       # service and remove the ephemeral configuration files
+       if [ "$upsmon_conf_fail" = "true" ]; then
+               # If we no longer have configuration, stop the service
+               return 1
+       else
+               # Atomically make the new config the active config
+               mv -f "$UPSMON_C.new" "$UPSMON_C" || return 1
+       fi
+
+       # Failure to write nut.conf is not hard-fatal although it means the NUT will
+       # not start the service.
+       # Also, we only write nut.conf if there is not one already
+       if [ ! -s "$NUT_CONF" ]; then
+               umask 133
+               if ! printf "MODE=netclient\n" >"$NUT_CONF"; then
+                       log_error "upsmon creation of nut.conf failed" nut-monitor-config.sh nut-monitor-config
+                       return 1
+               fi
+       else
+               # Otherwise if nut-server is already configured, make sure both
+               # nut-server and nut-monitor (this service) are started
+               if grep -q 'MODE=netserver' "$NUT_CONF"; then
+                       # In modern OpenWrt 'sed -i' modifies the specified files, without backup
+                       sed -i -e 's/netserver/both/' "$NUT_CONF" || {
+                               log_error "Failed to update nut.conf to support both upsmon and upsd" nut-monitor-config.sh nut-monitor-config
+                       }
+               fi
+       fi
+       return 0
+}
index ff65a3915c8f9c9e4d28579055371aab87db5070..5403f29de5b0709ecf0bb97e8493bc552845650c 100644 (file)
@@ -100,13 +100,32 @@ get_notification_flags() {
 # shellcheck disable=SC2329,SC2317
 process_upsmon_section() {
        local cfg="$1"
-       local notify_type flag_name notify_msg notify_section
+       local notify_type flag_name notify_msg notify_section val
        local notification_name notify_flags_as_var upsmon_final_flags
        # List of notification messages options in existing (to be converted)
        # UCI config for nut_monitor
        local nut_notify_message_types="onlinemsg onbattmsg lowbattmsg fsdmsg commokmsg"
        append nut_notify_message_types "commbadmsg shutdownmsg replbattmsg"
        append nut_notify_message_types "nocommmsmsg nocommmsg noparentmsg"
+       local nocomm_exists="false"
+
+       # Get hotsync (typo) value if present
+       config_get val "$cfg" hotsync
+       # Get hostsync value, only using hotsync as default if hostsync does not exist
+       config_get val "$cfg" hostsync "$val"
+
+       if [ -n "$val" ]; then
+               if ! uci -q set nut_monitor."$cfg".hostsync="$val"; then
+                       log_migration_error "Failed to set hostsync value"
+               fi
+       fi
+
+       # Remove invalid hotsync option unconditionally
+       uci -q delete nut_monitor."$cfg".hotsync || true
+
+       if uci -q get nut_monitor.NOCOMM; then
+               nocomm_exists="true"
+       fi
 
        # We need word-splitting to iterate over nut_notify_message_types
        # This is a local variable defined by us, so it is safe to do this.
@@ -120,10 +139,26 @@ process_upsmon_section() {
                # so we use tr.
                notification_name="$(printf '%s' "$notification_name" | tr '[:lower:]' '[:upper:]')"
                # Transform wrong NOCOMMS to NOCOMM (typo in nocommsmsg which should
-               # have been nocommmsg in previous script)
+               # have been nocommmsg in previous script), preferring NOCOMM (correct
+               # version) if both exist
                if [ "$notification_name" = "NOCOMMMS" ]; then
                        notification_name="NOCOMM"
                fi
+               if [ "$notification_name" = "NOCOMM" ]; then
+                       if [ "$nocomm_exists" = "true" ]; then
+                               # Do not delete or overwrite pre-existing NOCOMM section
+                               continue
+                       fi
+                       if uci -q get nut_monitor."$notification_name"; then
+                               uci -q delete nut_monitor."$notification_name" || {
+                                       log_migration_error "Failed to remove duplicate NOCOMM section"
+                                       # We don't want to prevent other sections from being processed
+                                       # if this one fails, so do not error exit, but keep going.
+                                       continue
+                               }
+                               printf "'%s': WARN: '%s'\n" "nut-monitor-migrate" "Removed duplicate NOCOMM section, likely from NOCOMMS typo" >&2
+                       fi
+               fi
                config_get notify_msg "$cfg" "$notify_type"
                config_get notify_flags_as_var "$cfg" "$flag_name"
                if [ -n "$notify_msg" ] || [ -n "$notify_flags_as_var" ]; then
index cd6a460141d4f517247a98a27156f0e609f1433c..3cca8d975edfcc67d9ee64aab60d3d9eae21d374 100644 (file)
 #!/bin/sh /etc/rc.common
 
-# shellcheck shell=ash
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
 
 # shellcheck disable=SC2034
 START=82
 STOP=28
 USE_PROCD=1
-UPSMON_C=/var/etc/nut/upsmon.conf
-PIDFILE=/var/run/upsmon.pid
 
-# Upstream NUT event type names (in the order listed in
-# https://networkupstools.org/docs/man/upsmon.conf.html)
-NUT_EVENT_TYPES=" ONLINE ONBATT LOWBATT FSD COMMOK COMMBAD SHUTDOWN REPLBATT NOCOMM NOPARENT CAL NOTCAL OFF NOTOFF BYPASS NOTBYPASS ECO NOTECO OVER NOTOVER TRIM NOTTRIM BOOST NOTBOOST OTHER NOTOTHER SUSPEND_STARTING SUSPEND_FINISHED "
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
 
-nut_get_notifications() {
-       local event="$1"
-       local defaultnotify="$2"
+# Shellcheck source paths intentionally point to the location of files of
+# the scripts in the development environment (where shellcheck is used), not
+# on the live OpenWrt device
 
-       if [ "${NUT_EVENT_TYPES#*" $event "}" != "${NUT_EVENT_TYPES}" ]; then
-               config_get val "$event" message
-               [ -n "$val" ] && echo "NOTIFYMSG $event \"$val\"" >>"$UPSMON_C"
-               config_get val "$event" flag "$defaultnotify"
-               [ -n "$val" ] && echo "NOTIFYFLAG $event $val" >>"$UPSMON_C"
-       else
-               logger -t nut-monitor -s "$event is not a valid NUT message event type"
-       fi
+# shellcheck source=net/nut/files/functions.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-monitor "Unable to source 'functions.sh' in 'nut-monitor'. Bailing"
+       exit 1
 }
 
-nut_upsmon_conf() {
-       local cfg="$1"
-       local RUNAS val optval defaultnotify
-
-       echo "# Config file automatically generated from UCI config" >"$UPSMON_C"
-
-       config_get RUNAS "$cfg" runas "nutmon"
-       [ -n "$RUNAS" ] && echo "RUN_AS_USER $RUNAS" >>"$UPSMON_C"
-       runas="$RUNAS"
-
-       config_get val "$cfg" deadtime 15
-       echo "DEADTIME $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" finaldelay 5
-       echo "FINALDELAY $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" hostsync 15
-       echo "HOSTSYNC $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" minsupplies 1
-       echo "MINSUPPLIES $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" nocommwarntime 300
-       echo "NOCOMMWARNTIME $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" pollfaillogthrottlemax -1
-       echo "POLLFAIL_LOG_THROTTLE_MAX $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" notifycmd
-       [ -n "$val" ] && echo "NOTIFYCMD \"$val\"" >>"$UPSMON_C"
-
-       config_get val "$cfg" pollfreq 5
-       echo "POLLFREQ $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" pollfreqalert 5
-       echo "POLLFREQALERT $val" >>"$UPSMON_C"
-
-       echo "POWERDOWNFLAG /var/run/killpower" >>"$UPSMON_C"
-
-       config_get val "$cfg" offduration 30
-       echo "OFFDURATION $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" overduration -1
-       echo "OVERDURATION $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" oblbduration 0
-       echo "OBLBDURATION $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" rbwarntime 43200
-       echo "RBWARNTIME $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" alarmcritical 1
-       echo "ALARMCRITICAL $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" shutdownexit 0
-       echo "SHUTDOWNEXIT $val" >>"$UPSMON_C"
-
-       config_get val "$cfg" shutdowncmd "/usr/sbin/nutshutdown"
-       echo "SHUTDOWNCMD \"$val\"" >>"$UPSMON_C"
-
-       config_get val "$cfg" certpath
-       if [ -n "$val" ]; then echo "CERTPATH $val" >>"$UPSMON_C"; fi
-
-       config_get_bool val "$cfg" certverify 0
-       if [ -n "$val" ]; then echo "CERTVERIFY $val" >>"$UPSMON_C"; fi
-
-       config_get_bool val "$cfg" forcessl 0
-       if [ -n "$val" ]; then echo "FORCESSL $val" >>"$UPSMON_C"; fi
-
-       # debugmin must be a positive integer or zero
-       config_get val "$cfg" debugmin 0
-       case "$val" in
-               ''|*[!0-9]*) ;;
-               *) echo "DEBUG_MIN $val" >>"$UPSMON_C" ;;
-       esac
-
-       config_get val "$cfg" defaultnotify "SYSLOG"
-       defaultnotify="$val"
-       config_foreach nut_get_notifications notifications "$val"
-
-       # Otherwise the default is WALL+SYSLOG
-       for event in $NUT_EVENT_TYPES; do
-               if ! grep -q "NOTIFYFLAG $event" "$UPSMON_C"; then
-                       echo "NOTIFYFLAG $event $defaultnotify" >>"$UPSMON_C"
-               fi
-       done
-
-       havemon=1
+# 'shellcheck' complains about nut-common.sh due to a case pattern it does not understand, even
+# though it is correctly POSIX compliant
+# shellcheck disable=SC1094
+# shellcheck source=net/nut/files/nut-common.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-common.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-monitor "Unable to source 'nut-common.sh' in 'nut-monitor'. Bailing"
+       exit 1
 }
 
-nut_upsmon_add() {
-       local cfg="$1"
-       local type="$2"
-       local upsname
-       local hostname
-       local port
-       local powervalue
-       local username
-       local password
-       local system
-
-       config_get upsname "$cfg" upsname
-       config_get hostname "$cfg" hostname localhost
-       config_get port "$cfg" port
-       config_get powervalue "$cfg" powervalue 1
-       config_get username "$cfg" username
-       config_get password "$cfg" password
-       config_get type "$cfg" type "${type:-secondary}"
-
-       system="$upsname@$hostname"
-       if [ -n "$port" ]; then
-               system="$system:$port"
-       fi
-       echo "MONITOR $system $powervalue $username $password $type" >>"$UPSMON_C"
-
-       havems=1
+# shellcheck source=/dev/null
+. "${IPKG_INSTROOT}"/usr/share/libubox/jshn.sh || {
+       log_source_error "jshn.sh" "nut-monitor" "nut-monitor"
+       exit 1
 }
 
-build_config() {
-       # shellcheck disable=SC2174
-       mkdir -m 0750 -p "$(dirname "$UPSMON_C")"
-
-       config_load nut_monitor
-       config_foreach nut_upsmon_conf upsmon
-       config_foreach nut_upsmon_add primary primary
-       config_foreach nut_upsmon_add secondary secondary
-       config_foreach nut_upsmon_add monitor
-       # master and slave are legacy section names. Prefer monitor.
-       config_foreach nut_upsmon_add master primary
-       config_foreach nut_upsmon_add slave secondary
+# shellcheck source=net/nut/files/nut-service.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-service.sh || {
+       log_source_error "nut-service.sh" "nut-monitor" "nut-monitor"
+       exit 1
+}
 
-       [ ! -s /var/etc/nut/nut.conf ] && {
-               echo "MODE=netclient" >>/var/etc/nut/nut.conf
-               chmod 640 /var/etc/nut/nut.conf
-               chgrp "$(id -gn "${runas:-nutmon}")" /var/etc/nut/nut.conf
-       }
+# /lib/functions/network.sh exists on live OpenWrt systems (base-files), but
+# not our development environment. We do not run the script in a development
+# environment as it depends on cross-compiled binaries and files being in
+# location and having live target names.
+# shellcheck disable=SC1091
+# shellcheck source=/dev/null
+. "${IPKG_INSTROOT}"/lib/functions/network.sh || {
+       log_source_error "network.sh" "nut-monitor" "nut-monitor"
+       exit 1
+}
 
-       [ -s "$UPSMON_C" ] && chmod 640 "$UPSMON_C"
-       [ -s "$UPSMON_C" ] && chgrp "$(id -gn "${runas:-nutmon}")" "$UPSMON_C"
+# defines NUT_EVENT_TYPES PIDFILE UPSMON_C
+# shellcheck source=net/nut/files/nut-monitor-config.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-monitor-config.sh || {
+       log_source_error "nut-monitor-config.sh" "nut-monitor" "nut-monitor"
+       exit 1
 }
 
-interface_triggers() {
+restore_umask_on_exit
+
+service_preconditions() {
        local action="$1"
-       local triggerlist trigger
+       # action can be one of start, stop, or reload
 
-       config_get triggerlist "upsmon" triggerlist
+       case "$action" in
+       start | reload)
+               # NUT_KILLPOWER is an external sentinel that indicates a forced shutdown
+               if [ -f "$NUT_KILLPOWER" ]; then
+                       return 1
+               fi
 
-       # shellcheck disable=SC1091
-       . "${IPKG_INSTROOT}"/lib/functions/network.sh
+               config_load nut_monitor || {
+                       log_config_load_error "nut_monitor" "nut-monitor" "nut-monitor"
+                       exit 1
+               }
 
-       if [ -n "$triggerlist" ]; then
-               for trigger in $triggerlist; do
-                       if [ "$action" = "add_trigger" ]; then
-                               procd_add_interface_trigger "interface.*" "$trigger" /etc/init.d/nut-monitor restart
-                       else
-                               network_is_up "$trigger" && return 0
+               if have_section_of_type "upsmon"; then
+                       # Find the RUNAS user for upsmon
+                       if ! find_runas "upsmon" "nut_monitor" "nutmon" || [ -z "$RUNAS" ]; then
+                               log_error_exit "Failed to determine RUNAS user" "nut-monitor" "nut-monitor"
                        fi
-               done
-       else
-               if [ "$action" = "add_trigger" ]; then
-                       procd_add_raw_trigger "interface.*.up" 2000 /etc/init.d/nut-monitor restart
                else
-                       ubus call network.device status | grep -q '"up": true' && return 0
+                       # If we do not have a 'upsmon' section in nut_monitor, use defaults
+                       log_msg "No upsmon section so no RUNAS user, falling back to nutmon" "nut-monitor" "nut-monitor" "notice"
+                       RUNAS=nutmon
                fi
-       fi
-       [ "$action" = "add_trigger" ] || return 1
-}
-
-start_service() {
-       local runas=nutmon
-       local havemon havems
-       build_config
 
-       [ "$havemon" != 1 ] && return 1
-       [ "$havems" != 1 ] && return 1
-       interface_triggers "check_interface_up" || return 0
+               build_config || return 1
+               interface_triggers "check_interface_up" || return 1
+               return 0
+               ;;
+       *)
+               return 0
+               ;;
+       esac
+}
 
-       procd_open_instance "upsmon"
-       procd_set_param respawn 10 20 6
-       procd_set_param stderr 1
-       procd_set_param stdout 0
+start_monitor_instance() {
+       procd_open_instance upsmon
+       procd_set_param respawn
+       procd_set_param stderr 0 # stderr is just a dup of stdout + syslog
+       procd_set_param stdout 1
        procd_set_param env NUT_QUIET_INIT_UPSNOTIFY=true
        procd_set_param reload_signal HUP
-       procd_set_param command /usr/sbin/upsmon -FF
+       procd_set_param command /usr/sbin/upsmon
+       procd_append_param command -FF
+       if [ -n "$RUNAS" ]; then
+               # upsmon needs root for some actions (like
+               # shutting down the system, but forks
+               # and drops root for most of its operation)
+               procd_append_param command -u "$RUNAS"
+       fi
        procd_close_instance
+}
 
-       return 0
+perform_instance_action() {
+       local active_upsmon_action="$1"
+       shift || true
+       local active_upsmon_signal="$1"
+       shift || true
+       local action_command="$1"
+       shift || true
+       local action_arg="$1"
+       shift || true
+       local active_upsmon_secondary_command="$1"
+       shift || true
+
+       # If nut-monitor is running but has no instances (even upsmon)
+       if service_active_no_instances "nut-monitor"; then
+               log_msg "nut-monitor active with no instances" "nut-monitor" "nut-monitor" "info"
+               if [ -n "$action_command" ]; then
+                       "$action_command" "$action_arg"
+               fi
+       # The only possible instance of nut-monitor in this package is a single
+       # upsmon instance. Therefore we do not need to check for other instances
+       elif procd_running nut-monitor upsmon; then
+               # If nut-monitor has an active upsmon instance
+               signal_instance "upsmon" \
+                       "upsmon" \
+                       "$active_upsmon_action" \
+                       "$active_upsmon_signal" "$PIDFILE" "" \
+                       "nut-monitor" \
+                       "$active_upsmon_secondary_command" \
+                       "$@"
+       fi
 }
 
-# pkill is not available, and pgrep does not accept --signal
-pgrepkill() {
-       local pids
+manage_service() {
+       local action="$1"
 
-       [ $# -eq 2 ] || return 1
+       # We only support one instance of upsmon, and its UCI
+       # section must be named 'upsmon'
+
+       service_preconditions "$action" || return 1
+       case "$action" in
+       start)
+               start_monitor_instance
+               ;;
+       reload)
+               # We need to use rpc_procd to start an instance from service_reload
+               perform_instance_action "reload" "HUP" "rc_procd" "start_monitor_instance"
+               ;;
+       stop)
+               # Note that the middle procd_kill is *without* upsmon which stops the procd
+               # service entirely, not just the instances (so reload triggers will have
+               # no effect until the service is started again).
+               perform_instance_action "stop" "TERM" "procd_kill" "nut-monitor" "procd_kill" "nut-monitor" "upsmon"
+               rm -f "$UPSMON_C"
+               ;;
+       stop-instance)
+               # Stop the upsmon instance but leave nut-monitor procd service active
+               perform_instance_action "stop" "TERM" "" "" "procd_kill" "nut-monitor" "upsmon"
+               rm -f "$UPSMON_C"
+               ;;
+       *)
+               # Do not abort on unknown $action, but log it.
+               log_error "Unknown action: '$action'" "nut-monitor" nut-monitor
+               ;;
+       esac
+}
 
-       pids="$(pgrep "$1" 2>/dev/null)" || return 0
+stop_and_cleanup_on_error() {
+       manage_service stop-instance
+       rm -f "$UPSMON_C.new"
+}
 
-       for pid in $pids; do
-               kill -"$2" "$pid"
-       done
+start_service() {
+       manage_service start || stop_and_cleanup_on_error
 }
 
 reload_service() {
-       local should_stop
-
-       # shellcheck disable=SC1091
-       . /lib/functions/procd.sh
-
-       build_config
-
-       should_stop=0
-
-       [ "$havemon" = 1 ] || should_stop=1
-       [ "$havems" = 1 ] || should_stop=1
-       interface_triggers "check_interface_up" || should_stop=1
-
-       if [ "$should_stop" = "0" ]; then
-               if procd_running nut-monitor upsmon; then
-                       if [ -s "$PIDFILE" ]; then
-                               upsmon -c reload 2>&1 | logger -t nut-monitor
-                               # We don't care about the exit code
-                               return
-                       elif pgrep upsmon >/dev/null 2>/dev/null; then
-                               procd_send_signal nut-monitor upsmon HUP 2>&1 | logger -t nut-monitor
-                               # We don't care about the exit code
-                               return
-                       fi
-               else
-                       /etc/init.d/nut-monitor start
-               fi
-       else
-               if procd_running nut-monitor upsmon; then
-                       if [ -s "$PIDFILE" ]; then
-                               upsmon -c stop 2>&1 | logger -t nut-monitor
-                       else
-                               pgrepkill upsmon TERM >/dev/null 2>/dev/null
-                       fi
-                       procd_kill nut-monitor upsmon 2>/dev/null | logger -t nut-monitor
-               fi
-       fi
+       manage_service reload || stop_and_cleanup_on_error
 }
 
 stop_service() {
-       if [ -s "$PIDFILE" ]; then
-               upsmon -c stop 2>&1 | logger -t nut-monitor
-               procd_kill nut-monitor 2>/dev/null | logger -t nut-monitor
-       else
-               pgrepkill upsmon TERM >/dev/null 2>/dev/null
-               procd_kill nut-monitor 2>/dev/null | logger -t nut-monitor
-       fi
+       manage_service stop
 }
 
 service_triggers() {
-       config_load nut_monitor
-       interface_triggers "add_trigger"
+       # No config, or error loading config is fatal
+       config_load nut_monitor || {
+               log_config_load_error "nut_monitor" "nut-monitor" "nut-monitor"
+               exit 1
+       }
+
+       config_get interface_reload_delay upsmon interface_reload_delay $DEFAULT_PROCD_INTERFACE_RELOAD_DELAY
+
+       interface_triggers "add_trigger" "upsmon" || {
+               log_error_exit "Failed to add interface triggers" nut-monitor nut-monitor
+       }
        procd_add_reload_trigger "nut_monitor"
 }
diff --git a/net/nut/files/nut-notify-exec.default b/net/nut/files/nut-notify-exec.default
new file mode 100644 (file)
index 0000000..242066b
--- /dev/null
@@ -0,0 +1,78 @@
+#!/bin/sh
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# uci-defaults script to set notify flags to enable execution of notifycmd
+# (set separate scripts) unless the user has configured that notifications
+# should be ignored.
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+
+# Shellcheck source paths intentionally point to the location of files of
+# the scripts in the development environment (where shellcheck is used), not
+# on the live OpenWrt device
+
+# In the nut-upssched package, this script lives in
+# /etc/uci-defaults/70-notify-exec
+# In the nut-sendmail-notify package, this script lives in
+# /etc/uci-defaults/71-notify-exec
+# The difference in names is required to avoid conflicting files should both
+# packages be installed on the same device.
+
+# Only run this uci-defaults script on a live OpenWrt device
+[ -z "${IPKG_INSTROOT}" ] || exit 0
+
+# shellcheck source=net/nut/files/functions.sh.functions
+. /lib/functions.sh || {
+       logger -s -t nut-notify-exec "FATAL: Unable to source 'functions.sh'." || true
+       exit 1
+}
+
+# Flags for whether defaultnotify option is set to IGNORE
+DEFAULTNOTIFY_IS_IGNORE="false"
+
+# Load and process nut_monitor (upsmon) configuration
+config_load nut_monitor || {
+       logger -s -t nut-notify-exec "FATAL: Loading 'nut_monitor' failed" || true
+       exit 1
+}
+
+config_get val "upsmon" defaultnotify
+# shellcheck disable=SC2154
+if [ "${val}" = "IGNORE" ]; then
+       DEFAULTNOTIFY_IS_IGNORE="true"
+else
+       # Ensure upsmon type section with the name upsmon exists (creating if
+       # needed, doing nothing if a upsmon type section named 'upsmon' already
+       # exists).
+       uci set "nut_monitor.upsmon=upsmon" || {
+               logger -s -t nut-notify-exec "Failed to ensure an upsmon section named upsmon exists" || true
+               exit 1
+       }
+fi
+
+# Set default notifications to enable notifycmd unless set to ignore
+if [ "${DEFAULTNOTIFY_IS_IGNORE}" = "true" ]; then
+       defaultnotify="IGNORE"
+# We only support IGNORE, SYSLOG, and EXEC options to defaultnotify
+# Since we want logging (SYSLOG) and require EXEC so that /usr/sbin/upssched
+# gets called by NUT, we enforce EXEC as well, producing SYSLOG+EXEC, unless
+# the user is ignoring all notifications.
+else
+       defaultnotify="SYSLOG+EXEC"
+fi
+
+uci set "nut_monitor.upsmon.defaultnotify"="$defaultnotify" || {
+       logger -s -t nut-notify-exec "FATAL: Failed to set 'defaultnotify' for 'upsmon'" || true
+       uci revert nut_monitor || true
+       exit 1
+}
+
+uci commit nut_monitor || {
+       logger -s -t nut-notify-exec "ERROR: Failed to commit changes to nut_monitor" || true
+       exit 1
+}
+
+exit 0
index 4b73b6c61c51e50f301f4e678d5692b439ded0cb..e0636b4ee937f17075a5474050f6d9490fec5364 100644 (file)
@@ -1,53 +1,69 @@
 #!/bin/sh
 
-. "${IPKG_INSTROOT}"/lib/functions.sh
-
-REMOVEDEFAULTNOTIFY=0
-SKIPADDSYSLOG=0
-SKIPADDEXEC=0
-SKIPADDNOTIFYCMD=0
-
-upsmon() {
-       local cfg="$1"
-       local val
-
-       config_get val "$cfg" notifycmd
-       if [ -n "$val" ]; then
-               SKIPADDNOTIFYCMD=1
-       fi
-
-       config_get val "$cfg" defaultnotify
-       if [ -n "$val" ]; then
-               if echo "$val" |grep -q "IGNORE"; then
-                       REMOVEDEFAULTNOTIFY=1
-               else
-                       SKIPADDSYSLOG=1
-                       if echo "$val" |grep -q "EXEC"; then
-                               SKIPADDEXEC=1
-                       fi
-               fi
-       fi
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# uci-defaults script to setup upsmon notifications using upssched
+# applied during install or on first boot after install, if setting on install
+# fails. Expects nut-notify-exec to also be run in uci-defaults.
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+
+# Shellcheck source paths intentionally point to the location of files of
+# the scripts in the development environment (where shellcheck is used), not
+# on the live OpenWrt device.
+
+# This script lives in the nut-upssched package, which is independent of the
+# nut-sendmail-notify package in which nut-sendmail-notify.default lives
+# In the nut-upssched package, this script lives in
+# /etc/uci-defaults/80-nut-sched
+
+# The separate packages limit the opportunities for code-sharing across the
+# scripts.
+
+# Only run this uci-defaults script on a live OpenWrt device
+[ -z "${IPKG_INSTROOT}" ] || exit 0
+
+# shellcheck source=net/nut/files/functions.sh.functions
+. /lib/functions.sh || {
+       logger -s -t nut-sched "FATAL: Unable to source 'functions.sh'" || true
+       exit 1
 }
 
-config_load nut_monitor
-config_foreach upsmon upsmon
+# Flags for existing notifycmd
+SKIP_ADD_NOTIFYCMD="false"
 
-uci set nut_monitor.@upsmon[-1]=upsmon
+# Load and process nut_monitor (upsmon) configuration
+config_load nut_monitor || {
+       logger -s -t nut-sched "FATAL: loading 'nut_monitor' failed" || true
+       exit 1
+}
 
-if [ "$SKIPADDNOTIFYCMD" != "1" ]; then
-       uci set nut_monitor.@upsmon[-1].notifycmd=/usr/sbin/upssched
+config_get val "upsmon" notifycmd
+# shellcheck disable=SC2154
+if [ -n "${val}" ]; then
+       SKIP_ADD_NOTIFYCMD="true"
 fi
 
-if [ "$REMOVEDEFAULTNOTIFY" = "1" ]; then
-       uci delete nut_monitor.@upsmon[-1].defaultnotify || true
-fi
+# If notification command is not set, set it
+if [ "${SKIP_ADD_NOTIFYCMD}" = "false" ]; then
+       # Ensure upsmon type section with the name upsmon exists
+       uci set "nut_monitor.upsmon=upsmon" || {
+               logger -s -t nut-sched "Failed to ensure an upsmon section named upsmon exists" || true
+               exit 1
+       }
 
-if [ "$SKIPADDEXEC" != "1" ]; then
-       uci add_list nut_monitor.@upsmon[-1].defaultnotify="EXEC"
-fi
+       uci set "nut_monitor.upsmon.notifycmd=/usr/sbin/upssched" || {
+               logger -s -t nut-sched "Failed to set notifycmd to upssched" || true
+               uci revert nut_monitor || true
+               exit 1
+       }
 
-if [ "$SKIPADDSYSLOG" != "1" ]; then
-       uci add_list nut_monitor.@upsmon[-1].defaultnotify="SYSLOG"
+       uci commit nut_monitor || {
+               logger -s -t nut-sched "Failed to commit changes to nut_monitor" || true
+               exit 1
+       }
 fi
 
-uci commit nut_monitor
+exit 0
old mode 100755 (executable)
new mode 100644 (file)
index f21e41e..6cf9d20
 #!/bin/sh
 
-{
-exec /usr/sbin/sendmail root <<EOF
-From: root
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# ## SAMPLE SCRIPT ##
+#
+# To be replaced by more tailored script created by user / admin
+
+# Example of a script for notification via sendmail, triggered by upsmon state
+# changes. Primarily a placeholder for a more tailored user-generated script.
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+
+# shellcheck source=net/nut/files/functions.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions.sh || {
+       logger -s -t nut-sendmail-notify "Unable to source 'functions.sh' in 'nut-sendmail-notify'. Bailing"
+       exit 1
+}
+
+# 'shellcheck' complains about nut-common.sh due to a case pattern it does not understand, even
+# though it is correctly POSIX compliant
+# shellcheck disable=SC1094
+# shellcheck source=net/nut/files/nut-common.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-common.sh || {
+       logger -s -t nut-sendmail-notify "Unable to source 'nut-common.sh' in 'nut-sendmail-notify'. Bailing"
+       exit 1
+}
+
+# function to log reason message was not sent
+log_send_failure_and_exit() {
+       local reason="$1"
+       local syslog_id="nut-sendmail-notify"
+       local message_prefix="Message was not sent"
+       logger -s -t "$syslog_id" "${message_prefix}: $reason" || {
+               printf "%s: %s: %s, and logging failed\n" "$syslog_id" "$message_prefix" "$reason" >&2
+       }
+       exit 1
+}
+
+# 'shellcheck' and 'shfmt' get confused by () and space in the case
+# pattern, but () and space are correct for POSIX shells
+# shellcheck disable=SC1072,SC1073,SC1085,SC1009
+check_safe_body_string() {
+       case "$1" in
+       # Disallow newline (single line body only). Also disallows a single dot on a line (so no
+       # SMTP dot injection)
+       *"
+"*)
+               log_send_failure_and_exit "Illegal character in body line for message"
+               ;;
+       # We allow as permissive a possible set of characters for the body text
+       # as possible, without allowing shell injection or requiring more complex
+       # parsing of the body line. We also avoid % for possible printf
+       # substitutions
+       # Some AI code reviewers get confused by \(\) and \space in the case
+       # pattern, but this is correct for busybox ash in modern OpenWrt
+       # and does not allow \ through the validation.
+       *[!a-zA-Z0-9_:!@#^+,*=~.?\(\)/\ -]*)
+               log_send_failure_and_exit "Illegal character in body line for message"
+               ;;
+       esac
+       return 0
+}
+
+NUT_NOTIFICATION_DATE="$(date -R)" || {
+       log_send_failure_and_exit "Failed to get current date and time"
+}
+
+email_content="From: root
 To: root
-Subject: UPS $NOTIFYTYPE Notification
+Subject: SUBJECT_PREFIX Notification
+Date: MESSAGE_DATE
+Content-Type: text/plain; charset=utf-8
 
-$1
+ONE_BODY_LINE
 .
-EOF
-} &
+"
+
+# Set IFS to empty to prevent word splitting
+OLD_IFS="$IFS"
+
+# Restore IFS to allow word-splitting, and reenable globbing, for if this
+# script is sourced
+# We want this expanded at sourcing time, not execution time
+# shellcheck disable=SC2064
+trap "IFS='$OLD_IFS'; set +f" EXIT
+
+set -f
+IFS=
+# / is NOTIFYTYPE, NUT_NOTIFICATION_DATE, or $1 are not a security risk,
+# because in a POSIX shell variable parameter expansion substitution, the
+# content to be substituted (after the second /) inside quotes is taken
+# literally. This has been tested on ash with the following simple
+# script commands
+#
+# sub_val="sub/val"
+# original="An original with old_val message"
+# new="${original/old_val/$sub_val}"
+# echo "$new"
+# The result output is
+# An original with sub/val message
+#
+# As we are using a single replacement, not global, the presence of a slash (/)
+# is not an issue.
+email_content="${email_content/SUBJECT_PREFIX/$NOTIFYTYPE}"
+email_content="${email_content/MESSAGE_DATE/$NUT_NOTIFICATION_DATE}"
+email_content="${email_content/ONE_BODY_LINE/$1}"
+
+# Mail is only sent if NOTIFYTYPE is set and a body line has been included as a
+# parameter ($1), and the {} succeeds (none of the checks exit with
+# log_send_failure_and_exit)
+if [ -n "$NOTIFYTYPE" ] && [ -n "$1" ] && {
+       command -v sendmail || log_send_failure_and_exit "'sendmail' is not available"
+       # setsid is available in OpenWrt old stable, stable, and current tip, since before 14a27ac99d
+       command -v setsid || log_send_failure_and_exit "'setsid' is not available"
+       # NOTIFYTYPE comes from an UCI section name, so validate for that
+       check_safe_uci_name "$NOTIFYTYPE" || log_send_failure_and_exit "Illegal name for notification type name."
+       check_safe_date "$NUT_NOTIFICATION_DATE" || log_send_failure_and_exit "Illegal string for notification date."
+       check_safe_body_string "$1" || log_send_failure_and_exit "Illegal characters, or more than one line for message body."
+}; then
+       printf '%s' "$email_content" | setsid sendmail root || {
+               log_send_failure_and_exit "Output to sendmail and initiating sendmail of notification failed."
+       }
+else
+       exit 1
+fi
+
+# IFS and globbing will be restored by EXIT trap above
+exit 0
index ebffba55322e080ec5a8b44348a87aa39f40ad0b..05c63f4e9ee9ce4f771771cc5e025f87f87cbae0 100644 (file)
@@ -1,48 +1,76 @@
 #!/bin/sh
 
-. "${IPKG_INSTROOT}"/lib/functions.sh
-
-REMOVEDEFAULTNOTIFY=0
-SKIPADDSYSLOG=0
-SKIPADDEXEC=0
-
-upsmon() {
-       local cfg="$1"
-       local val
-
-       config_get val "$cfg" defaultnotify
-       if [ -n "$val" ]; then
-               if echo "$val" |grep -q "IGNORE"; then
-                       REMOVEDEFAULTNOTIFY=1
-               else
-                       SKIPADDSYSLOG=1
-                       if echo "$val" |grep -q "EXEC"; then
-                               SKIPADDEXEC=1
-                       fi
-               fi
-       fi
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# uci-defaults script to configure sendmail notifications for UPS monitor on
+# first boot after installation (if not possible during image creation)
+# Expects nut-notify-exec to also be run in uci-defaults.
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+
+# Shellcheck source paths intentionally point to the location of files of
+# the scripts in the development environment (where shellcheck is used), not
+# on the live OpenWrt device
+
+# This script lives in the nut-sendmail-notify package, which is independent of
+# the nut-upssched package in which nut-sched.default lives.
+# In the nut-sendmail-notify package, this script lives in
+# /etc/uci-defaults/81-sendmail-notify
+
+# The separate packages limit the opportunities for code-sharing across the
+# scripts.
+
+# Only run this uci-defaults script on a live OpenWrt device
+[ -z "${IPKG_INSTROOT}" ] || exit 0
+
+# shellcheck source=net/nut/files/functions.sh.functions
+. /lib/functions.sh || {
+       logger -s -t nut-sendmail-notify "FATAL: Unable to source 'functions.sh'" || true
+       exit 1
 }
 
-config_load nut_monitor
-config_foreach upsmon upsmon
+# Flags for existing notifycmd
+SKIP_ADD_NOTIFYCMD="false"
 
-uci set nut_monitor.@upsmon[-1]=upsmon
-uci set nut_monitor.@upsmon[-1].notifycmd=/usr/bin/nut-sendmail-notify
+# Load and process nut_monitor (upsmon) configuration
+config_load nut_monitor || {
+       logger -s -t nut-sendmail-notify "FATAL: Loading 'nut_monitor' failed." || true
+       exit 1
+}
 
-if [ "$REMOVEDEFAULTNOTIFY" = "1" ]; then
-       uci delete nut_monitor.@upsmon[-1].defaultnotify || true
+config_get val "upsmon" notifycmd
+# shellcheck disable=SC2154
+if [ -n "${val}" ]; then
+       SKIP_ADD_NOTIFYCMD="true"
 fi
 
-if [ "$SKIPADDEXEC" != "1" ]; then
-       if [ "$SKIPADDSYSLOG" != "1" ]; then
-               uci set nut_monitor.@upsmon[-1].defaultnotify="SYSLOG+EXEC"
-       else
-               uci set nut_monitor.@upsmon[-1].defaultnotify="EXEC"
-       fi
-else
-       if [ "$SKIPADDSYSLOG" != "1" ]; then
-               uci set nut_monitor.@upsmon[-1].defaultnotify="SYSLOG"
+# If notification command is not set, set it
+if [ "${SKIP_ADD_NOTIFYCMD}" = "false" ]; then
+       # Ensure upsmon type section with the name upsmon exists
+       uci set "nut_monitor.upsmon=upsmon" || {
+               logger -s -t nut-sendmail-notify "Failed to ensure an upsmon section named upsmon exists" || true
+               exit 1
+       }
+
+       # Only configure notifycmd to be nut-sendmail-notify if sendmail is present
+       if [ -n "$(command -v sendmail)" ]; then
+               # If nut-upssched (default script nut-sched) is also present, notifycmd will
+               # already be set and SKIP_ADD_NOTIFYCMD will be true, so this code will
+               # never be reached. This is intentional. upssched takes precedence over
+               # nut-sendmail-notify, unless the user writes a script that supports both.
+               uci set "nut_monitor.upsmon.notifycmd=/usr/bin/nut-sendmail-notify" || {
+                       logger -s -t nut-sendmail-notify "Failed to set notifycmd to be nut-sendmail-notify" || true
+                       uci revert nut_monitor || true
+                       exit 1
+               }
        fi
+
+       uci commit nut_monitor || {
+               logger -s -t nut-sendmail-notify "Failed to commit changes to nut_monitor" || true
+               exit 1
+       }
 fi
 
-uci commit nut_monitor
+exit 0
diff --git a/net/nut/files/nut-serial.hotplug b/net/nut/files/nut-serial.hotplug
new file mode 100644 (file)
index 0000000..d6ebd70
--- /dev/null
@@ -0,0 +1,187 @@
+#!/bin/sh
+
+# In recent versions of shellcheck, busybox is a valid shell type
+# shellcheck shell=busybox
+
+# OpenWrt hotplug script to set permissions on USB serial
+# ports to allow NUT to access UPSes attached via
+# USB-to-serial cables
+
+# OpenWrt hotplug calls this script with DEVNAME and
+# ACTION set in the environment
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+
+# shellcheck source=net/nut/files/functions.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-serial-hotplug "Unable to source 'functions.sh' in 'nut-serial' hotplug. Bailing"
+       exit 1
+}
+
+# 'shellcheck' complains about nut-common.sh due to a case pattern it does not understand, even
+# though it is correctly POSIX compliant
+# shellcheck disable=SC1094
+# shellcheck source=net/nut/files/nut-common.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-common.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t nut-serial-hotplug "Unable to source 'nut-common.sh' in 'nut-serial' hotplug. Bailing"
+       exit 1
+}
+
+# shellcheck source=net/nut/files/nut-service.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-service.sh || {
+       log_source_error "nut-service.sh" "nut-serial" "nut-serial-hotplug"
+       exit 1
+}
+
+# 'shellcheck' does not understand config_foreach and therefore treats
+# functions and variables referenced only from config_foreach as not reachable,
+# or never invoked.
+# shellcheck disable=SC2317,SC2329
+nut_set_serial_port_permissions() {
+       local devname="$1" # Must be full path (e.g. /dev/...)
+       local ups="$2"
+       local device_group
+
+       # Get group of RUNAS user, to set the group of the hotplugged device node
+       # If RUNAS user does not exist, exit with an error.
+       device_group="$(id -gn "${RUNAS:-nut}")" || {
+               log_error "Unable to find group for '$RUNAS'. Skipping ups '$ups'" nut-serial nut-serial-hotplug
+               return 1
+       }
+
+       # Guard against disappearing device (e.g. due to 'bouncing' device)
+       if [ -e "$devname" ]; then
+               if ! chgrp "$device_group" "$devname"; then
+                       log_error "Unable to set group on '$devname'" nut-serial nut-serial-hotplug
+                       return 1
+               fi
+       else
+               log_error "'$devname' disappeared before setting group" nut-serial nut-serial-hotplug
+               return 1
+       fi
+
+       # Device could disappear between chgrp and chmod
+       if [ -e "$devname" ]; then
+               if ! chmod g+rw "$devname"; then
+                       log_error "Unable to set permissions on '$devname'" nut-serial nut-serial-hotplug
+                       return 1
+               fi
+       else
+               log_error "'$devname' disappeared before setting permissions" nut-serial nut-serial-hotplug
+               return 1
+       fi
+       return 0
+}
+
+# If enable_usb_serial is set, set the permissions on applicable USB
+# serial devices to allow NUT access
+# We do not exit with error as that would stop processing of other UPS config
+# sections.
+# We use 'did_set_perms' as a 'pseudo-global' from nut_on_hotplug_add in order
+# to return a value to config_foreach
+
+# 'shellcheck' does not understand config_foreach and therefore treats
+# functions and variables referenced only from config_foreach as not reachable,
+# or never invoked.
+# shellcheck disable=SC2317,SC2329
+nut_serial() {
+       local ups="$1" # UCI section name is also the UPS configuration name
+       local enable_usb_serial port normal_devname
+
+       config_get_bool enable_usb_serial "$ups" enable_usb_serial 0
+       config_get port "$ups" port
+
+       [ "$enable_usb_serial" = "1" ] && {
+               # If port is specified only change tty's matching port
+               if [ -n "$port" ] && [ "${port#/dev/}" = "${port}" ]; then
+                       port="/dev/$port"
+               fi
+               # Normalize DEVNAME
+               normal_devname="$DEVNAME"
+               if [ -n "$DEVNAME" ] && [ "${DEVNAME#/dev/}" = "${DEVNAME}" ]; then
+                       normal_devname="/dev/$DEVNAME"
+               fi
+               # Empty port from configuration means match any device.
+               if [ "$port" = "$normal_devname" ] || [ "$port" = "" ]; then
+                       if nut_set_serial_port_permissions "$normal_devname" "$ups"; then
+                               did_set_perms="true"
+                       fi
+               fi
+       }
+}
+
+nut_on_hotplug_add() {
+       # We use did_set_perms as a 'pseudo-global'. It is visible and can be
+       # modified by called functions, but the changes do not propagate up from
+       # this function.
+       local did_set_perms="false"
+
+       config_load nut_server || {
+               log_config_load_error "nut_server" "nut-serial" "nut-serial-hotplug"
+               exit 1
+       }
+
+       # Only find statepath and runas once per event
+       # sets RUNAS
+       find_runas "upsd" "nut_server" || {
+               log_error_exit "Failed to set RUNAS" nut-serial nut-serial-hotplug
+       }
+
+       # Check if serial USB UPS for each NUT driver instance (UPS), and if so
+       # configure permissions to allow NUT to access the port
+       config_foreach nut_serial driver
+
+       # If we set permissions on a device that may have a UPS attached, run
+       # the UPS (NUT) server reload to check for UPS configuration and
+       # attempt to reload the driver and/or server
+       if [ "$did_set_perms" = "true" ]; then
+               # Informational and not an error
+               logger -t nut-serial-hotplug "Successfully set permissions for serial device(s)"
+               # We shouldn't get here if hotplug is disabled (race condition if we do), so log it as an error
+               if ! allow_hotplug_restart; then
+                       log_error "Did not restart NUT after setting permissions as hotplug restart is disabled" nut-serial nut-serial-hotplug
+                       # Disabled hotplug is an allowed state
+                       return 0
+               fi
+               if ! /etc/init.d/nut-server enabled; then
+                       # shellcheck disable=SC2153
+                       log_error "NUT server disabled when processing hotplug event 'add' for '$DEVNAME'." nut-serial nut-serial-hotplug
+                       # Disabled nut-server is an allowed state
+                       return 0
+               else
+                       # Initscript should have its own logging
+                       /etc/init.d/nut-server reload || return 1
+               fi
+       fi
+       return 0
+}
+
+# Do not do any hotplug actions if hotplug is disabled. Not an error but log
+# for information
+if ! allow_hotplug_restart; then
+       # Informational and not an error
+       logger -t nut-serial-hotplug "Did not set permissions on serial port hotplug as NUT hotplug is disabled"
+       exit 0
+fi
+
+if ! [ "$(id -u)" = "0" ]; then
+       log_error_exit "Cannot set update device node on serial port hotplug. Hotplug not running as root." nut-serial nut-serial-hotplug
+fi
+
+# Only act on hotplug events with ACTION 'add' and where
+# the hotplugged device is a USB serial port
+# shellcheck disable=SC2153
+if [ "$ACTION" = "add" ] && [ -n "$DEVNAME" ]; then
+       # On add of a USB serial port with name
+       # ttyUSB* or ttyAMA* or ttyACM* or ttyGS*
+       case "$DEVNAME" in
+       ttyUSB* | ttyAMA* | ttyACM* | ttyGS*)
+               nut_on_hotplug_add || exit 1
+               ;;
+       esac
+fi
+
+exit 0
diff --git a/net/nut/files/nut-server-config.sh.functions b/net/nut/files/nut-server-config.sh.functions
new file mode 100644 (file)
index 0000000..1a5d36e
--- /dev/null
@@ -0,0 +1,384 @@
+#!/bin/sh
+# Shebang line included so editors and shellcheck/shfmt know this contains
+# shell code
+
+# Create configuration for upsd and UPS drivers for NUT
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# Pre-requisite sourcing for functions this script uses
+# * /lib/functions.sh has been sourced
+# * /lib/functions/nut/nut-service.sh has been sourced
+
+# * /lib/functions/nut/nut-common.sh must be source and find_statepath executed
+#   before build_config is called
+
+# Ensure find_statepath from nut-service.sh is executed
+# before srv_config in this file.
+
+# Ephemeral files, recreated on each OpenWrt boot
+USERS_C=/var/etc/nut/upsd.users # NUT user and password file
+UPSD_C=/var/etc/nut/upsd.conf   # NUT upsd (UPS daemon) configuration
+UPS_C=/var/etc/nut/ups.conf     # NUT UPS configuration (ini-style section per UPS)
+NUT_CONF=/var/etc/nut/nut.conf  # NUT mode config
+
+# config_load is done by the sourcing script, before executing any functions in
+# this file
+
+# Writes configuration values to the UPS run-time configuration file
+get_write_ups_config() {
+       local ups="$1"
+       local var="$2"
+       local def="$3"
+       local flag="$4"
+       local config_file="$5"
+       local val
+
+       if [ -z "$flag" ] || [ "$flag" = "0" ]; then
+               config_get val "$ups" "$var" "$def"
+               [ -n "$val" ] && printf "%s = %s\n" "$var" "$val" >>"$config_file"
+       else
+               config_get_bool val "$ups" "$var" "$def"
+               # In NUT config a flag is either present or not present
+               # present is true, not present is false. Map UCI bool to NUT flags.
+               if [ "$val" = "1" ]; then
+                       printf "%s\n" "$var" >>"$config_file"
+               fi
+       fi
+}
+
+# Add upsd listen address and port to the run-time configuration
+listen_address() {
+       local srv="$1"
+       local config_file="$2"
+       local address port
+
+       config_get address "$srv" address "::1"
+       config_get port "$srv" port
+       if [ -n "$port" ]; then
+               printf "LISTEN %s %s\n" "$address" "$port" >>"$config_file"
+       else
+               printf "LISTEN %s\n" "$address" >>"$config_file"
+       fi
+}
+
+# Adds upsd (NUT server) run-time configuration
+srv_config() {
+       local srv="$1"
+       local config_file="$2"
+       local maxage maxconn certfile
+
+       config_get maxage "$srv" maxage
+       [ -n "$maxage" ] && printf "MAXAGE %s\n" "$maxage" >>"$config_file"
+
+       [ -n "$STATEPATH" ] && printf "STATEPATH %s\n" "$STATEPATH" >>"$config_file"
+
+       config_get maxconn "$srv" maxconn
+       [ -n "$maxconn" ] && printf "MAXCONN %s\n" "$maxconn" >>"$config_file"
+
+       #NOTE: certs only apply to SSL-enabled version
+       config_get certfile "$srv" certfile
+       [ -n "$certfile" ] && printf "CERTFILE %s\n" "$certfile" >>"$config_file"
+}
+
+nut_user_instcmd() {
+       local val="$1"
+       local config_file="$2"
+       printf "  instcmds = %s\n" "$val" >>"$config_file"
+}
+
+# Adds run-time configuration for NUT users to the upsd.users file
+nut_user_add() {
+       local user="$1"
+       local config_file="$2"
+       local a
+       local val
+
+       config_get val "$user" username "$user"
+       printf "[%s]\n" "$val" >>"$config_file"
+
+       config_get val "$user" password
+       [ -n "$val" ] && printf "  password = %s\n" "$val" >>"$config_file"
+
+       config_get val "$user" actions
+       set -f
+       for a in $val; do
+               printf "  actions = %s\n" "$a" >>"$config_file"
+       done
+       set +f
+
+       # The name instcmd is use for both the UCI option and the callback function
+       config_list_foreach "$user" instcmd nut_user_instcmd "$config_file"
+
+       config_get val "$user" upsmon
+       if [ -n "$val" ]; then
+               printf "  upsmon %s\n" "$val" >>"$config_file"
+       fi
+}
+
+# Builds upsd (NUT server) run-time configuration
+build_server_config() {
+       local conf_group="$1"
+       local nut_conf_err="false"
+
+       umask 022
+       mkdir -p "$(dirname "$UPSD_C")" || {
+               log_error "Failed to create directory for upsd configuration file '$UPSD_C'" nut-server-config.sh nut-server-config
+               return 1
+       }
+
+       rm -f "$USERS_C.new"
+       rm -f "$UPSD_C.new"
+
+       umask 117
+       echo "# Config file automatically generated from UCI config" >"$USERS_C.new"
+       echo "# Config file automatically generated from UCI config" >"$UPSD_C.new"
+       restore_umask
+
+       # For nut_user and listen_address, the section type and callback function
+       # are named the same
+       config_foreach nut_user_add user "$USERS_C.new"
+       config_foreach listen_address listen_address "$UPSD_C.new"
+       if have_section_of_type "upsd"; then
+               config_foreach srv_config upsd "$UPSD_C.new"
+       else
+               # If config 'nut_server' does not have a 'upsd' section, use a default
+               # configuration
+               [ -n "$STATEPATH" ] && printf "STATEPATH %s\n" "$STATEPATH" >>"$UPSD_C.new"
+       fi
+
+       # Failure to write nut.conf is not hard-fatal although it means the NUT will
+       # not start the service.
+       # Also, we only write nut.conf if there is not one already
+       if [ ! -s "$NUT_CONF" ] || grep -q '^MODE=none' "$NUT_CONF"; then
+               umask 133
+               if ! printf "MODE=netserver\n" >"$NUT_CONF"; then
+                       log_error "nut-server creation of nut.conf failed" nut-server-config.sh nut-server-config
+                       return 1
+               fi
+       else
+               # Otherwise if nut-server is already configured, make sure both
+               # nut-server (this service) and nut-monitor are started
+               if grep -q '^MODE=netclient' "$NUT_CONF"; then
+                       # In modern OpenWrt 'sed -i' modifies the specified files, without backup
+                       sed -i -e 's/^MODE=netclient/MODE=both/' "$NUT_CONF" || {
+                               log_error "Failed to update nut.conf to support both upsmon and upsd" nut-server-config.sh nut-server-config
+                               nut_conf_err="true"
+                       }
+               fi
+       fi
+
+       if [ "$nut_conf_err" = "false" ] && [ -n "$conf_group" ]; then
+               chgrp "$conf_group" "$USERS_C.new" || {
+                       log_error "failed to set group on '$USERS_C.new'" nut-server-config.sh nut-server-config
+                       nut_conf_err="true"
+               }
+               chgrp "$conf_group" "$UPSD_C.new" || {
+                       log_error "failed to set group on '$UPSD_C.new'" nut-server-config.sh nut-server-config
+                       nut_conf_err="true"
+               }
+       fi
+
+       # shellcheck disable=SC2034
+       [ "$nut_conf_err" = "false" ] || return 1
+}
+
+# shellcheck disable=SC2317,SC2329
+nut_ups_defoverride() {
+       local overvar="$1"
+       local defover="$2"
+       local config_file="$3"
+       local ups="$4"
+       local overtype
+       local overval
+
+       # tr is used as global replace in variable parameter expansion is not
+       # available in ash.
+       overtype="$(printf "%s" "$overvar" | tr '_' '.')"
+
+       config_get overval "$ups" "${defover}_${overvar}"
+       [ -n "$overval" ] && printf "%s.%s = %s\n" "${defover}" "${overtype}" "$overval" >>"$config_file"
+}
+
+# From documentation and fixing of nut_ups_other (previously other or do_other)
+# function (below) in https://github.com/rpavlik in
+# https://github.com/openwrt/packages/pull/28308
+#
+# For each value "x" in the per-driver ($ups) list "other", get the value of
+#   per-driver config variable "other_x", and if it is not empty,
+#   write "x = {value of other_x}" to the config file.
+# For each value "x" in the per-driver ($ups) list "otherflag", get the value of
+#   per-driver config variable "otherflag_x", and if it is true, write
+#  "x" to the config file.
+
+# shellcheck disable=SC2317,SC2329
+nut_ups_other() {
+       local othervar="$1"
+       local othervarflag="$2"
+       local config_file="$3"
+       local ups="$4"
+       local otherval
+
+       if [ "$othervarflag" = "otherflag" ]; then
+               config_get_bool otherval "${ups}" "${othervarflag}_${othervar}"
+               [ "$otherval" = "1" ] && printf "%s\n" "${othervar}" >>"$config_file"
+       else
+               config_get otherval "${ups}" "${othervarflag}_${othervar}"
+               [ -n "$otherval" ] && printf "%s = %s\n" "${othervar}" "$otherval" >>"$config_file"
+       fi
+}
+
+# Builds UPS-specific run-time configuration
+build_ups_config() {
+       local ups="$1"
+       local config_file="$2"
+
+       printf "[%s]\n" "$ups" >>"$config_file"
+
+       get_write_ups_config "$ups" bus "" "" "$config_file"
+       get_write_ups_config "$ups" cable "" "" "$config_file"
+       get_write_ups_config "$ups" community "" "" "$config_file"
+       get_write_ups_config "$ups" desc "" "" "$config_file"
+       get_write_ups_config "$ups" driver "usbhid-ups" "" "$config_file"
+       get_write_ups_config "$ups" ignorelb 0 1 "$config_file"
+       get_write_ups_config "$ups" interruptonly 0 1 "$config_file"
+       get_write_ups_config "$ups" interruptsize "" "" "$config_file"
+       get_write_ups_config "$ups" maxreport "" "" "$config_file"
+       get_write_ups_config "$ups" maxstartdelay "" "" "$config_file"
+       get_write_ups_config "$ups" mfr "" "" "$config_file"
+       get_write_ups_config "$ups" model "" "" "$config_file"
+       get_write_ups_config "$ups" nolock 0 1 "$config_file"
+       get_write_ups_config "$ups" notransferoids 0 1 "$config_file"
+       get_write_ups_config "$ups" offdelay "" "" "$config_file"
+       get_write_ups_config "$ups" ondelay "" "" "$config_file"
+       get_write_ups_config "$ups" pollfreq "" "" "$config_file"
+       get_write_ups_config "$ups" port "auto" "" "$config_file"
+       get_write_ups_config "$ups" product "" "" "$config_file"
+       get_write_ups_config "$ups" productid "" "" "$config_file"
+       get_write_ups_config "$ups" retrydelay "" "" "$config_file"
+       get_write_ups_config "$ups" sdorder "" "" "$config_file"
+       get_write_ups_config "$ups" sdtime "" "" "$config_file"
+       get_write_ups_config "$ups" serial "" "" "$config_file"
+       get_write_ups_config "$ups" shutdown_delay "" "" "$config_file"
+       get_write_ups_config "$ups" snmp_version "" "" "$config_file"
+       get_write_ups_config "$ups" snmp_retries "" "" "$config_file"
+       get_write_ups_config "$ups" snmp_timeout "" "" "$config_file"
+       get_write_ups_config "$ups" synchronous "" "" "$config_file"
+       get_write_ups_config "$ups" usd "" "" "$config_file"
+       get_write_ups_config "$ups" vendor "" "" "$config_file"
+       get_write_ups_config "$ups" vendorid "" "" "$config_file"
+
+       # Params specific to NetXML driver
+       get_write_ups_config "$ups" login "" "" "$config_file"
+       get_write_ups_config "$ups" password "" "" "$config_file"
+       get_write_ups_config "$ups" subscribe 0 1 "$config_file"
+
+       config_list_foreach "$ups" override nut_ups_defoverride override "$config_file" "$ups"
+       config_list_foreach "$ups" default nut_ups_defoverride default "$config_file" "$ups"
+
+       # For each entry in the "$ups" list "other", get the
+       # variable "other_${entry}", and if not empty, write
+       # "${entry} = ${value of other_${entry}}" to the config file.
+       config_list_foreach "$ups" other nut_ups_other other "$config_file" "$ups"
+
+       # For each entry in the "$ups" list "otherflag", get the
+       # variable "otherflag_${entry}", and if true, write "${entry}" to
+       # the config file.
+       config_list_foreach "$ups" otherflag nut_ups_other otherflag "$config_file" "$ups"
+
+       echo "" >>"$config_file"
+       # shellcheck disable=SC2034
+       haveupscfg=true
+}
+
+# Add global drivers settings to run-time config
+build_global_driver_config() {
+       local cfg="$1"
+       local config_file="$2"
+
+       # Global driver config
+       get_write_ups_config "$cfg" chroot "" "" "$config_file"
+       get_write_ups_config "$cfg" driverpath "" "" "$config_file"
+       get_write_ups_config "$cfg" maxstartdelay "" "" "$config_file"
+       get_write_ups_config "$cfg" maxretry "" "" "$config_file"
+       get_write_ups_config "$cfg" retrydelay "" "" "$config_file"
+       get_write_ups_config "$cfg" pollinterval "" "" "$config_file"
+       get_write_ups_config "$cfg" synchronous "" "" "$config_file"
+       echo "" >>"$config_file"
+}
+
+# Orchestrate the run-time configuration building process
+build_config() {
+       local conf_group
+       # 'pseudo-global' to allow config_foreach build_ups_config to signal that
+       # at least one UPS section was configured. We do not want return 1 in
+       # the config_foreach as that would abort processing for other UPS sections.
+       local haveupscfg="false"
+
+       if [ -z "$RUNAS" ]; then
+               log_error "RUNAS is empty; cannot determine group for configuration files" nut-server-config.sh nut-server-config
+               return 1
+       fi
+
+       if ! conf_group="$(id -gn "$RUNAS")"; then
+               log_error "Failed to determine group for user '$RUNAS'" nut-server-config.sh nut-server-config
+               return 1
+       fi
+       [ -n "$conf_group" ] || return 1
+
+       # shellcheck disable=SC2153
+       [ -d "$STATEPATH" ] || {
+               umask 007
+               mkdir -p "$STATEPATH" || {
+                       # We hard fail here, as we cannot rely on service state if
+                       # STATEPATH does not exist
+                       log_error "Failed to create ${STATEPATH}." nut-server nut-server
+                       # return so we can do cleanup
+                       return 1
+               }
+               if [ -n "$conf_group" ]; then
+                       chown "root:${conf_group}" "$STATEPATH" || {
+                               log_error "Failed to change owner:group on STATEPATH ('$STATEPATH')" nut-server-config.sh nut-server-config
+                               return 1
+                       }
+               fi
+       }
+
+       # At this point we know STATEPATH exists and we have either created it with
+       # the required owner, group, and permissions, or it was created elsewhere.
+       # We do not second-guess other sources of creating this STATEPATH and
+       # presume that as long as we have read and traversal access, that the
+       # permissions are intentional. As 'stat' is not available by default in
+       # OpenWrt we just check if we can read and traverse the directory.
+       if [ ! -r "$STATEPATH" ] || [ ! -x "$STATEPATH" ]; then
+               log_error "We do not have access to STATEPATH '$STATEPATH'." nut-server-config.sh nut-server-config
+               return 1
+       fi
+
+       # This directory is shared with the nut-monitor which runs as a different
+       # user and group, so must be all readable. We set the ownership and
+       # permissions on individual files more restrictively, as needed
+       # shellcheck disable=SC2174
+       umask 022
+       mkdir -p "$(dirname "$UPS_C")"
+       rm -f "$UPS_C"
+       umask 137
+       echo "# Config file automatically generated from UCI config" >"$UPS_C.new"
+       chgrp "$conf_group" "$UPS_C.new" || {
+               log_error "Failed to change group on UPS_C.new (new UPS config)" nut-server-config.sh nut-server-config
+               return 1
+       }
+
+       config_foreach build_global_driver_config driver_global "$UPS_C.new"
+       config_foreach build_ups_config driver "$UPS_C.new"
+
+       if ! build_server_config "$conf_group"; then
+               log_msg "Failed to configure upsd. Not starting." nut-server-config.sh nut-server-config warn
+               return 1
+       elif [ "$haveupscfg" = "false" ]; then
+               log_msg "No configured UPS. Not starting upsd." nut-server-config.sh nut-server-config warn
+               return 2
+       fi
+       return 0
+}
diff --git a/net/nut/files/nut-server-service.sh.functions b/net/nut/files/nut-server-service.sh.functions
new file mode 100644 (file)
index 0000000..eed71d1
--- /dev/null
@@ -0,0 +1,207 @@
+#!/bin/sh
+# Shebang line included so editors and shellcheck/shfmt know this contains
+# shell code
+
+# Helper functions for NUT server service handling
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# config_load is done by the sourcing script, before executing any functions in
+# this file
+
+# Pre-requisite sourcing for functions this script uses
+# * /lib/functions.sh has been sourced
+# * /lib/functions/nut/nut-common.sh has been sourced
+
+# find_runas to find the RUNAS user should already have been
+# run by nut-server initscript prior to calling
+# ensure_usb_ups_access.
+
+# Ensure the RUNAS user has access to the USB UPS device
+ensure_usb_ups_access() {
+       local ups="$1"
+       local known_devname="$2"
+       local cfg_vendorid
+       local cfg_productid
+       local device_owner
+       local device_group
+       local usb_bus
+       local usb_dev
+       local device_path
+       local cur_devname
+       local device_serial
+       local sysfs_vendorid
+       local sysfs_productid
+       local serial
+       # When no USB bus or USB device is found, the value returned in each case,
+       # after printf, will be '000', which is not a valid USB bus or USB device
+       # number (i.e. it is a missing data indicator).
+       local missing_usb_num="000"
+
+       device_owner="$RUNAS"
+       [ -n "$RUNAS" ] || {
+               log_error "No RUNAS user specified. Skipping ups '$ups'" nut-server-service.sh nut-server-service
+               return 1
+       }
+
+       device_group="$(id -gn "$RUNAS")" || {
+               log_error "Unable to find group for RUNAS='$RUNAS'. Skipping ups '$ups'" nut-server-service.sh nut-server-service
+               return 1
+       }
+
+       config_get cfg_vendorid "$ups" vendorid
+       # replacement in variable parameter expansion is supported by ash in OpenWrt
+       # since at least OpenWrt-24.10 (the previous stable release, which two
+       # versions older than the targetted next release of OpenWrt (after
+       # OpenWrt-25.12). This has been verified by the author of this script).
+       if [ -n "$cfg_vendorid" ] && ! check_valid_hex_number "${cfg_vendorid/0x/}"; then
+               log_error "Configured vendorid for '$ups' is not valid" nut-server-service.sh nut-server-service
+               return 1
+       fi
+
+       # Configuration might not have a vendorid. This is allowed, but we have
+       # nothing to do here in that case.
+       [ -n "$cfg_vendorid" ] || return 0
+
+       case "$cfg_vendorid" in
+       0x*)
+               cfg_vendorid="$(printf "%04x" "$cfg_vendorid")"
+               ;;
+       *)
+               cfg_vendorid="$(printf "%04x" "0x$cfg_vendorid")"
+               ;;
+       esac
+
+       config_get cfg_productid "$ups" productid
+       if [ -n "$cfg_productid" ] && ! check_valid_hex_number "${cfg_productid/0x/}"; then
+               log_error "Configured productid for '$ups' is not valid" nut-server-service.sh nut-server-service
+               return 1
+       fi
+
+       # Configuration might not have a productid. This is allowed, but we have
+       # nothing to do here in that case.
+       [ -n "$cfg_productid" ] || return 0
+
+       case "$cfg_productid" in
+       0x*)
+               cfg_productid="$(printf "%04x" "$cfg_productid")"
+               ;;
+       *)
+               cfg_productid="$(printf "%04x" "0x$cfg_productid")"
+               ;;
+       esac
+
+       config_get serial "$ups" serial
+
+       # $'\x' syntax is supported by ash in OpenWrt since at least OpenWrt-24.10
+       # (the previous stable release, which two versions older than the targetted
+       # next release of OpenWrt (after OpenWrt-25.12). This has been verified by
+       # the author of this script).
+       # shellcheck disable=SC3003
+       local NL=$'\n'
+
+       # Loop on the idVendor of all USB devices attached to the system
+       # We do not need any return values from this loop, which exists solely for the side-effects of
+       # setting the appropriate device permissions and group, so subshell is okay.
+       # IFS is set to newline *only*. Default IFS also includes tab and space
+       # Disable globbing. `find` will still use patterns.
+       set -f
+       find /sys/devices -name idVendor -a -path '*/usb[0-9]*/*' | while IFS="$NL" read -r vendor_path; do
+               set +f
+               usb_bus=""
+               usb_dev=""
+               device_path=""
+               cur_devname=""
+               device_serial=""
+               # Filter by vendor ID first
+
+               sysfs_vendorid="$(cat "$vendor_path")"
+               if [ -z "$sysfs_vendorid" ]; then
+                       log_error "Could not read vendor ID from $vendor_path" nut-server-service.sh nut-server-service
+                       continue
+               fi
+
+               if [ "$sysfs_vendorid" != "$cfg_vendorid" ]; then
+                       continue
+               fi
+
+               device_path="$(dirname "$vendor_path")"
+
+               # Then filter by product ID
+
+               sysfs_productid="$(cat "$device_path/idProduct")"
+
+               if [ -z "$sysfs_productid" ]; then
+                       log_error "Could not read product ID from $device_path" nut-server-service.sh nut-server-service
+                       continue
+               fi
+               if [ "$sysfs_productid" != "$cfg_productid" ]; then
+                       continue
+               fi
+
+               # Next filter by serial (number), if provided
+
+               # guard against disappearing device_path (e.g. due to 'bouncing' device connection)
+               if [ ! -e "$device_path" ]; then
+                       continue
+               fi
+
+               # Only check against device serial number if a serial number is
+               # in the configuration for this UPS
+               if [ -n "$serial" ]; then
+                       device_serial="$(cat "$device_path/serial" 2>/dev/null)"
+
+                       if [ "$serial" != "$device_serial" ]; then
+                               continue
+                       fi
+               fi
+
+               usb_bus="$(printf "%03d" "$(cat "$device_path/busnum")")"
+               usb_dev="$(printf "%03d" "$(cat "$device_path/devnum")")"
+
+               # usb_bus and usb_dev must each be at least 001
+               # a missing value will be present as 000 due to 'printf "%03d"'
+               if [ "$usb_bus" = "$missing_usb_num" ] || [ "$usb_dev" = "$missing_usb_num" ]; then
+                       # If this sysfs device is not valid, keep looking (and ignore this
+                       # sysfs device).
+                       continue
+               else
+                       # If we are called from hotplug, we know the device name, so skip any other devices
+                       cur_devname="/dev/bus/usb/$usb_bus/$usb_dev"
+                       if [ -n "$known_devname" ] && [ "$known_devname" != "$cur_devname" ]; then
+                               continue
+                       fi
+
+                       # guard against disappearing cur_devname (e.g. due to 'bouncing' device connection)
+                       if [ -e "$cur_devname" ]; then
+                               chmod 0660 "$cur_devname" || {
+                                       log_error "'chmod' failed for $cur_devname (ups '$ups')" nut-server-service.sh nut-server-service
+                                       continue
+                               }
+                               # Re-check in case device disappeared after chmod
+                               if [ -e "$cur_devname" ]; then
+                                       chown "${device_owner}:${device_group}" "$cur_devname" || {
+                                               log_error "'chown' failed for $cur_devname (ups '$ups')" nut-server-service.sh nut-server-service
+                                               continue
+                                       }
+                               fi
+                       else
+                               continue
+                       fi
+
+                       # Serial numbers are defined as unique, so do not loop further (on /sys/devices - while),
+                       # if serial was present and matched
+                       if [ -n "$serial" ]; then
+                               # Break out of while loop (so we do not process more 'find' results)
+                               break
+                       fi
+                       # If a serial number is not provided we need all vendor:product matches
+                       # to have permissions for NUT as we do not know the method used
+                       # to match UPS device to a configuration, in this script
+                       # (but if we have a known_devname, limit to that device)
+               fi
+               set -f
+       done
+       set +f
+}
old mode 100755 (executable)
new mode 100644 (file)
index be45306..5f7e6e8
 #!/bin/sh /etc/rc.common
 # Copyright Â© 2012-2026 OpenWrt.org
 #
+# Startup script for NUT (UPS) server
+#
 # This is free software, licensed under the GNU General Public License v2.
 # See /LICENSE for more information.
 #
 
-# shellcheck shell=ash
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
 
 # shellcheck disable=SC2034
 START=70
 STOP=30
 
-USERS_C=/var/etc/nut/upsd.users
-UPSD_C=/var/etc/nut/upsd.conf
-UPS_C=/var/etc/nut/ups.conf
-
 USE_PROCD=1
 
-get_write_ups_config() {
-       local ups="$1"
-       local var="$2"
-       local def="$3"
-       local flag="$4"
-       local val
-
-       [ -z "$flag" ] && {
-               config_get val "$ups" "$var" "$def"
-               [ -n "$val" ] && [ "$val" != "0" ] && echo "$var = $val" >>"$UPS_C"
-       }
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
 
-       [ -n "$flag" ] && {
-               config_get_bool val "$ups" "$var" "$def"
-               [ "$val" = 1 ] && echo "$var" >>"$UPS_C"
-       }
-}
+# The shellcheck source directives are used during development by external
+# tools to find the source files on the development system. They do not
+# affect runtime behaviour.
 
-srv_statepath() {
-       local statepath
+# Also the filenames in the shellcheck source directives may differ from the
+# production names that are sourced on live OpenWrt devices.
 
-       config_get statepath upsd statepath /var/run/nut
-       STATEPATH="$statepath"
+# shellcheck source=net/nut/files/functions.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t "nut-server" "FATAL: Unable to source 'functions.sh' in 'nut-server'"
+       exit 1
 }
 
-srv_runas() {
-       local runas
-
-       [ -n "$RUNAS" ] && return 0
-
-       config_get runas upsd runas nut
-       RUNAS="$runas"
+# 'shellcheck' complains about nut-common.sh due to a case pattern it does not understand, even
+# though it is correctly POSIX compliant
+# shellcheck disable=SC1094
+# shellcheck source=net/nut/files/nut-common.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-common.sh || {
+       # Before our sourcing error logging definitions are sourced
+       logger -s -t "nut-server" "FATAL: Unable to source 'nut-common.sh' in 'nut-server'"
+       exit 1
 }
 
-listen_address() {
-       local srv="$1"
-
-       config_get address "$srv" address "::1"
-       config_get port "$srv" port
-       # shellcheck disable=SC2154
-       echo "LISTEN $address $port" >>"$UPSD_C"
+# shellcheck source=/dev/null
+. "${IPKG_INSTROOT}"/lib/functions/network.sh || {
+       log_source_error "network.sh" "nut-server" "nut-server"
+       exit 1
 }
 
-srv_config() {
-       local srv="$1"
-       local maxage maxconn certfile runas statepath
-
-       # Note runas support requires you make sure USB device file is readable by
-       # the runas user
-       config_get runas "$srv" runas nut
-       RUNAS="$runas"
-
-       config_get statepath "$srv" statepath /var/run/nut
-       STATEPATH="$statepath"
-
-       config_get maxage "$srv" maxage
-       [ -n "$maxage" ] && echo "MAXAGE $maxage" >>"$UPSD_C"
-
-       [ -n "$statepath" ] && echo "STATEPATH $statepath" >>"$UPSD_C"
-
-       config_get maxconn "$srv" maxconn
-       [ -n "$maxconn" ] && echo "MAXCONN $maxconn" >>"$UPSD_C"
-
-       #NOTE: certs only apply to SSL-enabled version
-       config_get certfile "$srv" certfile
-       [ -n "$certfile" ] && echo "CERTFILE $certfile" >>"$UPSD_C"
-}
-
-nut_user_add() {
-       local user="$1"
-       local a
-       local val
-
-       config_get val "$user" username "$1"
-       echo "[$val]" >> "$USERS_C"
-
-       config_get val "$user" password
-       echo "  password = $val" >> "$USERS_C"
-
-       config_get val "$user" actions
-       for a in $val; do
-               echo "  actions = $a" >> "$USERS_C"
-       done
-
-       instcmd() {
-               # shellcheck disable=2317
-               local val="$1"
-               # shellcheck disable=2317
-               echo "  instcmds = $val" >> "$USERS_C"
-       }
-
-       config_list_foreach "$user" instcmd instcmd
-
-       config_get val "$user" upsmon
-       if [ -n "$val" ]; then
-               echo "  upsmon $val" >> "$USERS_C"
-       fi
+# shellcheck source=net/nut/files/nut-server-config.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-server-config.sh || {
+       log_source_error "nut-server-config.sh" "nut-server" "nut-server"
+       exit 1
 }
 
-build_server_config() {
-       mkdir -p "$(dirname "$UPSD_C")"
-       chmod 0640 "$UPS_C"
-       rm -f "$USERS_C"
-       rm -f "$UPSD_C"
-       rm -f /var/etc/nut/nut.conf
-
-       echo "# Config file automatically generated from UCI config" > "$USERS_C"
-       echo "# Config file automatically generated from UCI config" > "$UPSD_C"
-
-       config_foreach nut_user_add user
-       config_foreach listen_address listen_address
-       config_foreach srv_config upsd
-       echo "MODE=netserver" >>/var/etc/nut/nut.conf
-
-       chmod 0640 "$USERS_C"
-       chmod 0640 "$UPSD_C"
-       chmod 0644 /var/etc/nut/nut.conf
-
-       if [ -n "$RUNAS" ]; then
-               chgrp "$(id -gn "$RUNAS")" "$USERS_C"
-               chgrp "$(id -gn "$RUNAS")" "$UPSD_C"
-       fi
-       havesrvcfg=1
+# shellcheck source=/dev/null
+. "${IPKG_INSTROOT}"/usr/share/libubox/jshn.sh || {
+       log_source_error "jshn.sh" "nut-server" "nut-server"
+       exit 1
 }
 
-build_ups_config() {
-       local ups="$1"
-
-       echo "[$ups]" >>"$UPS_C"
-
-       get_write_ups_config "$ups" bus
-       get_write_ups_config "$ups" cable
-       get_write_ups_config "$ups" community
-       get_write_ups_config "$ups" desc
-       get_write_ups_config "$ups" driver "usbhid-ups"
-       get_write_ups_config "$ups" ignorelb 0 1
-       get_write_ups_config "$ups" interruptonly 0 1
-       get_write_ups_config "$ups" interruptsize
-       get_write_ups_config "$ups" maxreport
-       get_write_ups_config "$ups" maxstartdelay
-       get_write_ups_config "$ups" mfr
-       get_write_ups_config "$ups" model
-       get_write_ups_config "$ups" nolock 0 1
-       get_write_ups_config "$ups" notransferoids 0 1
-       get_write_ups_config "$ups" offdelay
-       get_write_ups_config "$ups" ondelay
-       get_write_ups_config "$ups" pollfreq
-       get_write_ups_config "$ups" port "auto"
-       get_write_ups_config "$ups" product
-       get_write_ups_config "$ups" productid
-       get_write_ups_config "$ups" retrydelay
-       get_write_ups_config "$ups" sdorder
-       get_write_ups_config "$ups" sdtime
-       get_write_ups_config "$ups" serial
-       get_write_ups_config "$ups" shutdown_delay
-       get_write_ups_config "$ups" snmp_version
-       get_write_ups_config "$ups" snmp_retries
-       get_write_ups_config "$ups" snmp_timeout
-       get_write_ups_config "$ups" synchronous
-       get_write_ups_config "$ups" usd
-       get_write_ups_config "$ups" vendor
-       get_write_ups_config "$ups" vendorid
-
-       # Params specific to NetXML driver
-       get_write_ups_config "$ups" login
-       get_write_ups_config "$ups" password
-       get_write_ups_config "$ups" subscribe 0 1
-
-       # shellcheck disable=SC2317
-       defoverride() {
-               local overvar="$1"
-               local defover="$2"
-               local overtype
-               local overval
-
-               overtype="$(echo "$overvar" | tr '_' '.')"
-
-               config_get overval "${defover}_${overvar}" value
-               [ -n "$overval" ] && echo "${defover}.${overtype} = $overval" >>"$UPS_C"
-       }
-
-       config_list_foreach "$ups" override defoverride override
-       config_list_foreach "$ups" default defoverride default
-
-       other() {
-               # shellcheck disable=SC2317
-               local othervar="$1"
-               # shellcheck disable=SC2317
-               local othervarflag="$2"
-               # shellcheck disable=SC2317
-               local otherval
-
-               # shellcheck disable=SC2317
-               if [ "$othervarflag" = "otherflag" ]; then
-                       config_get_bool otherval "${othervarflag}_${othervar}" value
-                       [ "$otherval" = "1" ] && echo "${othervar}" >>"$UPS_C"
-               else
-                       config_get otherval "${othervarflag}_${othervar}" value
-                       [ -n "$otherval" ] && echo "${othervar} = $otherval" >>"$UPS_C"
-               fi
-       }
-
-       config_list_foreach "$ups" other other other
-       config_list_foreach "$ups" otherflag other otherflag
-       echo "" >>$UPS_C
-       haveupscfg=1
+# shellcheck source=net/nut/files/nut-service.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-service.sh || {
+       log_source_error "nut-service.sh" "nut-server" "nut-server"
+       exit 1
 }
 
-build_global_driver_config() {
-       local cfg="$1"
-
-       # Global driver config
-       get_write_ups_config "$cfg" chroot
-       get_write_ups_config "$cfg" driverpath
-       get_write_ups_config "$cfg" maxstartdelay
-       get_write_ups_config "$cfg" maxretry
-       get_write_ups_config "$cfg" retrydelay
-       get_write_ups_config "$cfg" pollinterval
-       get_write_ups_config "$cfg" synchronous
-       config_get runas "$cfg" user nut
-       RUNAS="$runas"
-
-       echo "" >>"$UPS_C"
+# shellcheck source=net/nut/files/nut-server-service.sh.functions
+. "${IPKG_INSTROOT}"/lib/functions/nut/nut-server-service.sh || {
+       log_source_error "nut-server-service.sh" "nut-server" "nut-server"
+       exit 1
 }
 
-build_config() {
-       local STATEPATH=/var/run/nut
-
-       mkdir -p "$(dirname "$UPS_C")"
-       rm -f "$UPS_C"
-       echo "# Config file automatically generated from UCI config" > "$UPS_C"
-       chmod 0640 "$UPS_C"
+restore_umask_on_exit
 
-       config_load nut_server
+# Start a ups driver instance
+start_ups_driver() {
+       local ups="$1"
+       local requested="$2"
+       local driver=""
 
-       srv_runas
-       srv_statepath
+       # If wanting a specific instance, only start that instance
+       if [ -n "$requested" ] && [ "$requested" != "$ups" ]; then
+               return 0
+       fi
 
-       [ -d "${STATEPATH}" ] || {
-               mkdir -p "${STATEPATH}"
+       # For non-USB devices this is effectively a no-op
+       # For USB devices, if the USB productid and USB vendorid of the configured
+       # driver instance (UPS) match a USB device in sysfs, set the permissions
+       # on the USB device node in /dev/bus/usb/... to allow NUT to access and
+       # control the device.
+       ensure_usb_ups_access "$ups" || return 1
+       config_get driver "$ups" driver
+
+       [ -n "$driver" ] || {
+               log_error "Missing driver when starting UPS '$ups'" "nut-server" "nut-server"
+               return 1
        }
-       chmod 0770 "${STATEPATH}"
 
+       procd_open_instance "$ups"
+       procd_set_param respawn
+       procd_set_param stderr 0 # stderr is just a dup of stdout + syslog
+       procd_set_param stdout 1
+       procd_set_param env NUT_QUIET_INIT_UPSNOTIFY=true
+       procd_set_param env NUT_STATEPATH="${STATEPATH}"
+       procd_set_param command "/usr/libexec/nut/${driver}"
+       procd_append_param command -FF -a "$ups"
        if [ -n "$RUNAS" ]; then
-               chown root:"$(id -gn "$RUNAS")" "${STATEPATH}"
+               procd_append_param command -u "$RUNAS"
        fi
+       procd_close_instance
 
-       SRV_RUNAS="$RUNAS"
-       config_foreach build_global_driver_config driver_global
-       if [ "$SRV_RUNAS" != "$RUNAS" ]; then
-               echo "WARNING: for proper communication drivers and server must 'runas' the same user" | logger -t nut-server
+       # If upsd is running, reload it to pick up new driver
+       if procd_running "nut-server" "upsd" >/dev/null 2>&1; then
+               signal_instance "upsd" "upsd" "reload" "HUP" "${STATEPATH}/upsd.pid" "" "nut-server"
+       else
+               start_server_instance
        fi
-
-       config_foreach build_ups_config driver
-
-       build_server_config
-       [ -n "$RUNAS" ] && chgrp "$(id -gn "$RUNAS")" "$UPS_C"
 }
 
-ensure_usb_ups_access() {
+# On reload, we need to use rpc_procd to add an instance to an already running
+# service, as procd_open_service is not called by default when doing a
+# reload vs start
+add_ups_driver() {
        local ups="$1"
-       local vendorid
-       local productid
-       local runas=nut
-
-       runas="$RUNAS"
-
-       config_load nut_server
-       config_get vendorid "$ups" vendorid
-       config_get productid "$ups" productid
-       config_get serial "$ups" serial
-
-       [ -n "$vendorid" ] || return
-       [ -n "$productid" ] || return
-
-       local NL='
-'
-
-       find /sys/devices -name idVendor -a -path '*usb*'| while IFS="$NL" read -r vendor_path; do
-                       local usb_bus usb_dev device_path
-
-                       # Filter by vendor ID first
-                       if [ "$(cat "$vendor_path" 2>/dev/null)" != "$vendorid" ]; then
-                               continue
-                       fi
-
-                       device_path="$(dirname "$vendor_path")"
-
-                       # Then filter by product ID
-                       if [ "$(cat "$device_path/idProduct" 2>/dev/null)" != "$productid" ]; then
-                               continue
-                       fi
-
-                       # Next filter by serial, if provided
-                       if [ -n "$serial" ] && [ "$serial" != "$(cat "$device_path"/serial)" ]; then
-                               continue
-                       fi
-
-                       usb_bus="$(printf "%03d" "$(cat "$device_path"/busnum)")"
-                       usb_dev="$(printf "%03d" "$(cat "$device_path"/devnum)")"
-
-                       # usb_bus and usb_dev must each be at least 001
-                       # a missing value will be present as 000 due to 'printf "%03d"'
-                       local MISSING_USB_NUM="000"
-                       if [ "$usb_bus" != "$MISSING_USB_NUM" ] && [ "$usb_dev" != "$MISSING_USB_NUM" ]; then
-                               chmod 0660 /dev/bus/usb/"$usb_bus"/"$usb_dev"
-                               chown "${runas:-root}":"$(id -gn "${runas:-root}")" /dev/bus/usb/"$usb_bus"/"$usb_dev"
-                       fi
+       local requested="$2"
+       rc_procd start_ups_driver "$ups" "$requested"
+}
 
-                       # Serial numbers are defined as unique, so do not loop further if serial
-                       # was present and matched
-                       if [ -n "$serial" ]; then
-                               break
-                               # If a serial number is not provided we need all vendor:product matches
-                               # to have permissions for NUT as we do not know the matching method here
-                       fi
-               done
+have_upsd_section() {
+       have_upsd_section="true"
 }
 
-# Must be called from start_service
-start_ups_driver() {
-       local ups="$1"
-       local requested="$2"
-       local driver
-       local STATEPATH=/var/run/nut
-       local RUNAS=nut
+common_preconditions() {
+       local ret
 
-       # If wanting a specific instance, only start it
-       if [ "$requested" != "$ups" ] && [ "$requested" != "" ]; then
-               return 0
+       # No config, or error loading config is fatal
+       config_load nut_server || {
+               log_config_load_error "nut_server" "nut-server" "nut-server"
+               exit 1
+       }
+
+       if have_section_of_type "upsd"; then
+               # Only find statepath and runas once per service start or reload
+               # defines STATEPATH
+               find_statepath "upsd" "nut_server" || {
+                       log_error_exit "Failed to determine STATEPATH" "nut-server" "nut-server"
+               }
+               # sets RUNAS
+               find_runas "upsd" "nut_server" || {
+                       log_error "Failed to determine RUNAS" "nut-server" "nut-server"
+                       return 1
+               }
+       else
+               # If there is no 'upsd' section in the nut_server config file, use
+               # default settings
+               STATEPATH=/var/run/nut
+               RUNAS=nut
        fi
 
-       # Avoid hotplug inadvertently restarting driver during
-       # forced shutdown
-       [ -f /var/run/killpower ] && return 0
-       if [ -d /var/run/nut ] && [ -f /var/run/nut/disable-hotplug ]; then
-               return 0
+       # NUT_KILLPOWER is an external sentinel that indicates a forced shutdown
+       if [ -f "$NUT_KILLPOWER" ]; then
+               return 1
        fi
 
-       # Depends on config_load from start_service
-       srv_statepath
-       srv_runas
-       ensure_usb_ups_access "$ups"
+       build_config
+       ret=$?
 
-       config_get driver "$ups" driver "usbhid-ups"
-       procd_open_instance "$ups"
-       procd_set_param respawn
-       procd_set_param stderr 1
-       procd_set_param stdout 0 # Subset of stderr
-       procd_set_param env NUT_QUIET_INIT_UPSNOTIFY=true
-       procd_set_param env NUT_STATEPATH="${STATEPATH}"
-       procd_set_param command /usr/libexec/nut/"${driver}" -FF -a "$ups" ${RUNAS:+-u "$RUNAS"}
-       procd_close_instance
-       haveupscfg=1
+       return $ret
 }
 
-interface_triggers() {
-       local action="$1"
-       local triggerlist trigger
-
-       config_get triggerlist upsd triggerlist
-
-       # shellcheck disable=SC1091
-       . /lib/functions/network.sh
-
-       if [ -n "$triggerlist" ]; then
-               for trigger in $triggerlist; do
-                       if [ "$action" = "add_trigger" ]; then
-                               procd_add_interface_trigger "interface.*" "$trigger" /etc/init.d/nut-server reload
-                       else
-                               network_is_up "$trigger" && return 0
-                       fi
-               done
-       else
-               if [ "$action" = "add_trigger" ]; then
-                       procd_add_raw_trigger "interface.*.up" 2000 /etc/init.d/nut-server reload
-               else
-                       ubus call network.device status | grep -q '"up": true' && return 0
+stop_no_longer_configured_instances() {
+       local have_driver_instance=false
+       local have_upsd_instance=false
+       local instance instances
+
+       # Stop any driver instances which are no longer configured
+       # We can only reliably do this for instances managed by procd
+       set -f
+       instances="$(list_running_instances "nut-server")"
+       [ -n "$instances" ] || return 0
+       for instance in $instances; do
+               if [ "$instance" = "upsd" ]; then
+                       continue
+               fi
+               config_get driver "$instance" driver
+               # Only stop not configured but running instances
+               if [ -z "$driver" ] && [ -n "$instance" ] && procd_running "nut-server" "$instance" >/dev/null 2>&1; then
+                       procd_kill "nut-server" "$instance" 2>&1 | logger -s -t nut-server
                fi
+       done
+       set +f
+
+       # Need to stop drivers before stopping upsd
+       # The second loop is required in case some instances failed to stop in the
+       # first loop (so we cannot just track instances in the above loop).
+       # In addition, it is possible for hotplug triggered drivers start to start
+       # instances between the stop above, and this loop.
+       set -f
+       instances="$(list_running_instances "nut-server")"
+       [ -n "$instances" ] || return 0
+       for instance in $instances; do
+               if [ "$instance" = "upsd" ]; then
+                       have_upsd_instance="true"
+                       continue
+               fi
+               have_driver_instance="true"
+               break
+       done
+       set +f
+
+       # If we have no UPS instances we must stop upsd or it will crash
+       # The "nut-server" service remains active and will 'see' configuration
+       # changes and execute reload_service when they are detected
+       if [ "$have_upsd_instance" = "true" ] && [ "$have_driver_instance" = "false" ]; then
+               # stop_server_instance
+               signal_instance "upsd" "upsd" "stop" "TERM" "${STATEPATH}/upsd.pid" "" "nut-server" "procd_kill" "nut-server" "upsd"
        fi
-       [ "$action" = "add_trigger" ] || return 1
 }
 
-start_server_instance() {
-       local srv="$1"
+stop_all_instances() {
+       local instance instances
 
-       procd_open_instance "$srv"
-       procd_set_param respawn
-       procd_set_param stderr 1
-       procd_set_param stdout 0 # Subset of stderr
-       procd_set_param env NUT_QUIET_INIT_UPSNOTIFY=true
-       procd_set_param env NUT_STATEPATH="$STATEPATH"
-       procd_set_param command /usr/sbin/upsd -FF ${RUNAS:+-u "$RUNAS"}
-       procd_close_instance
-}
-
-# shellcheck disable=SC2120
-start_service() {
-       local STATEPATH=/var/run/nut
-       local haveupscfg=0
-       local havesrvcfg=0
-
-       # Avoid hotplug inadvertently restarting driver during
-       # forced shutdown
-       [ -f /var/run/killpower ] && return 0
-
-       srv_statepath
-       config_load nut_server
-       build_config
-
-       should_start_srv=1
-       [ "$havesrvcfg" = "1" ] || should_start_srv=0
-       # Avoid crashloop on server (upsd) when no ups is configured; make sure server
-       # is not running if no ups is found in configuration
-       [ "$haveupscfg" = "1" ] || should_start_srv=0
-       interface_triggers "check_interface_up" || should_start_srv=0
+       set -f
+       instances="$(list_running_instances "nut-server")"
+       [ -n "$instances" ] || return 0
+       for instance in $instances; do
+               # stop upsd last
+               if [ "$instance" = "upsd" ]; then
+                       continue
+               fi
+               config_get driver "$instance" driver
+               [ -n "$driver" ] || continue
 
-       [ "$should_start_srv" = "1" ] || return 0
+               signal_instance "$instance" "/usr/libexec/nut/${driver}" "stop" "TERM" \
+                       "${STATEPATH}/${driver}-${instance}.pid" "" "nut-server" "procd_kill" "nut-server" "$instance"
+       done
+       set +f
+       # stop_server_instance
+       signal_instance "upsd" "upsd" "stop" "TERM" "${STATEPATH}/upsd.pid" "" "nut-server" "procd_kill" "nut-server" "upsd"
+       return 0
+}
 
-       # We only start one service (upsd or one driver) from a given invocation
-       case "$1" in
-       "")
-               config_foreach start_ups_driver driver
-               start_server_instance upsd
+service_preconditions() {
+       local action="$1"
+       local should_stop_srv=false
+       local should_start_srv=true
+       local ret=0
+
+       case "$action" in
+       start)
+               common_preconditions
+               ret=$?
+
+               case $ret in
+               0)
+                       interface_triggers "check_interface_up" || should_start_srv=false
+                       ;;
+               *)
+                       should_start_srv=false
+                       ;;
+               esac
                ;;
-       *upsd*)
-               start_server_instance upsd
+       reload)
+               common_preconditions
+               ret=$?
+
+               case $ret in
+               0 | 2)
+                       interface_triggers "check_interface_up" || should_stop_srv=true
+                       ;;
+               *)
+                       should_stop_srv=true
+                       ;;
+               esac
                ;;
        *)
-               config_foreach start_ups_driver driver "$1"
+               log_error "Unknown action '$action' in service_preconditions" "nut-server" "nut-server"
+               ret=1
                ;;
        esac
-}
-
-server_active() {
-       local nut_server_active
-
-       nut_server_active=$(_procd_ubus_call list | jsonfilter -l 1 -e "@['nut-server']")
-       [ "$nut_server_active" = "{ }" ] && return 0
-}
 
-list_running_instances() {
-       local service="$1"
-       local running_instances
+       # If server not configured or no interfaces are up, diagnostic should already have been logged.
+       # Do not spam log more, but remove config and exit.
+       if [ "$should_start_srv" = "false" ]; then
+               remove_var_config
+               return 1
+       fi
 
-       running_instances=$(_procd_ubus_call list | jsonfilter -e "@['$service'][@.*.running=true]")
+       # Stop all instances, remove config, and exit with error, if should_stop_srv is true
+       if [ "$should_stop_srv" = "true" ]; then
+               stop_all_instances || true
+               remove_var_config
+               return $ret
+       fi
 
-       if [ -n "$running_instances" ]; then
-               json_init
-               json_load "$running_instances"
-               json_get_keys instance_names
-               # shellcheck disable=SC2154
-               echo "$instance_names"
-               json_cleanup
+       # NUT_KILLPOWER is an external sentinel that may be set between now and
+       # the previous check
+       if [ -f "$NUT_KILLPOWER" ]; then
+               return 1
        fi
+
+       # If we're here we should have good new config; activate it
+       mv -f "$USERS_C.new" "$USERS_C" || return 1
+       mv -f "$UPS_C.new" "$UPS_C" || return 1
+       mv -f "$UPSD_C.new" "$UPSD_C" || return 1
+       return 0
 }
 
-signal_instance() {
-       local instance_name="$1"
-       local process_name="$2"
-       local signal_command="$3"
-       local signal="$4"
-       local pidfile="$5"
-       local secondary_command="$6"
-
-       if [ -s "$pidfile" ]; then
-               $signal_command | logger -t nut-server
-       elif pgrep "$process_name" >/dev/null 2>/dev/null; then
-               procd_send_signal nut-server "$instance_name" "$signal" 2>&1 | logger -t nut-server
+# Start upsd instance
+start_server_instance() {
+       if [ -f "$NUT_KILLPOWER" ]; then
+               return 1
        fi
-       if [ -n "$secondary_command" ] && procd_running nut-server "$instance_name"; then
-               $secondary_command 2>&1 | logger -t nut-server
+
+       procd_open_instance upsd
+       procd_set_param respawn
+       procd_set_param stderr 0 # stderr is just a dup of stdout + syslog
+       procd_set_param stdout 1
+       procd_set_param env NUT_QUIET_INIT_UPSNOTIFY=true
+       procd_set_param env NUT_STATEPATH="$STATEPATH"
+       procd_set_param command /usr/sbin/upsd
+       procd_append_param command -FF
+       if [ -n "$RUNAS" ]; then
+               procd_append_param command -u "$RUNAS"
        fi
+       procd_close_instance
+}
+
+remove_var_config() {
+       for rm_file in "$USERS_C" "$UPS_C" "$UPSD_C" \
+               "$USERS_C.new" "$UPS_C.new" "$UPSD_C.new"; do
+               rm -f "$rm_file"
+       done
 }
 
+# Stop NUT drivers and upsd (NUT server)
+# We do not error exit (only log issues) because we do not want to prevent
+# processing of further drivers.
 stop_ups_driver() {
        local ups="$1" # The ups (driver instance)
        local requested="$2"
        local driver
 
        # If wanting a specific instance, only stop it
-       if [ "$requested" != "$ups" ] && [ "$requested" != "" ]; then
+       if [ -n "$requested" ] && [ "$requested" != "$ups" ]; then
                return 0
        fi
 
-       srv_statepath
-       build_ups_config "$ups"
+       config_get driver "$ups" driver
 
-       # If we don't have UPS configuration simply stop all instances
-       if [ "$haveupscfg" != "1" ]; then
-               if procd_running nut-server '*' >/dev/null 2>&1; then
-                       procd_kill nut-server '*' 2>&1 | logger -t nut-server
-               fi
+       if [ -z "$driver" ]; then
+               log_error "No driver specified for UPS '$ups'. Attempting procd_kill." "nut-server" "nut-server"
+               procd_kill "nut-server" "$ups" || log_error "No driver specified for UPS '$ups' and procd_kill fallback failed" "nut-server" "nut-server"
                return 0
        fi
 
-       config_get driver "$ups" driver "usbhid-ups"
-
-       if procd_running nut-server "$ups"; then
-               signal_instance "$ups" "$driver" "/usr/libexec/nut/${driver} -c exit -a ${ups}" "TERM" "${STATEPATH}/${driver}-${ups}.pid"
-               if procd_running nut-server upsd >/dev/null 2>&1; then
-                       signal_instance upsd upsd "upsd -c stop" "TERM" "${STATEPATH}/upsd.pid" "procd_kill nut-server upsd"
-               fi
+       # If there is a running driver instance for this UPS
+       if procd_running "nut-server" "$ups" >/dev/null 2>&1; then
+               signal_instance "$ups" "/usr/libexec/nut/${driver}" exit "TERM" "${STATEPATH}/${driver}-${ups}.pid" "${ups}" "nut-server"
        fi
+       return 0
 }
 
+# Reload NUT drivers and upsd (NUT server)
+# We do not error exit (only log issues) because we do not want to prevent
+# processing of further drivers.
 reload_ups_driver() {
        local ups="$1"
        local requested="$2"
        local driver
 
        # If wanting a specific instance, only reload that instance
-       if [ "$requested" != "$ups" ] && [ "$requested" != "" ]; then
+       if [ -n "$requested" ] && [ "$requested" != "$ups" ]; then
                return 0
        fi
 
-       # Avoid hotplug inadvertently restarting driver during
-       # forced shutdown
-       [ -f /var/run/killpower ] && return 0
-       if [ -d /var/run/nut ] && [ -f /var/run/nut/disable-hotplug ]; then
+       if [ -f "$NUT_KILLPOWER" ]; then
+               stop_ups_driver "$ups"
                return 0
        fi
 
-       config_get driver "$ups" driver "usbhid-ups"
+       config_get driver "$ups" driver
 
-       srv_statepath
+       # Try to reload, otherwise exit politely
+       if procd_running "nut-server" "$ups"; then
+               signal_instance "$ups" "/usr/libexec/nut/${driver}" "reload-or-exit" "HUP" "${STATEPATH}/${driver}-${ups}.pid" "${ups}" "nut-server"
+               # Fall through to start driver, if it exited due to 'reload-or-exit'
+               # finding a configuration condition which requires a stop and start
+               # for the configuration to be applied.
+       fi
 
-       # Try to reload, otherwise exit politely, then stop and restart procd instance
-       if procd_running nut-server "$ups"; then
-               signal_instance "$ups" "$driver" "/usr/libexec/nut/${driver} -c reload-or-exit -a ${ups}" HUP "${STATEPATH}/${driver}-${ups}.pid"
+       # If the driver instance (UPS) was terminated, or stopped 'naturally',
+       # start it up again
+       if ! procd_running "nut-server" "$ups" >/dev/null 2>&1; then
+               add_ups_driver "$ups" "$ups"
        fi
-       /etc/init.d/nut-server start "$ups" 2>&1 | logger -t nut-server
+       return 0
 }
 
-reload_service() {
-       local should_stop_srv
-       local STATEPATH=/var/run/nut
-       local havesrvcfg=0
-       local haveupscfg=0
-       local running_instances
-       local driver
-
-       # Avoid hotplug inadvertently restarting driver during forced shutdown
-       [ -f /var/run/killpower ] && return 0
-
-       config_load nut_server
-       build_config
+stop_service_if_no_instances() {
+       if service_active_no_instances "nut-server"; then
+               logger -s -t "nut-server" "nut-server active with no instances"
+               # If "nut-server" is active with no instances
+               # We don't care about the exit code of procd_kill, and logging its
+               # stderr can aid debugging.
+               procd_kill "nut-server" 2>&1 | logger -s -t "nut-server"
+       fi
+}
 
-       should_stop_srv=0
-       [ "$havesrvcfg" = "1" ] || should_stop_srv=1
-       # Avoid crashloop on server (upsd) when no ups is configured; make sure server
-       # is not running if no ups is found in configuration
-       [ "$haveupscfg" = "1" ] || should_stop_srv=1
-       interface_triggers "check_interface_up" || should_stop_srv=1
+# Stop or reload active instances
+manage_instances() {
+       local action="$1"
 
-       if [ "$should_stop_srv" != "0" ]; then
-               if procd_running nut-server upsd >/dev/null 2>&1; then
-                       procd_kill nut-server upsd 2>&1 | logger -t nut-server
+       case "$action" in
+       start)
+               config_foreach start_ups_driver driver
+               ;;
+       reload)
+               if service_active_no_instances "nut-server"; then
+                       log_msg "nut-server active with no instances. Reloading." "nut-server" "nut-server" "info"
                fi
-               config_foreach stop_ups_driver driver
-
-               # Also stop any driver instances which are no longer configured
-               for instance in $(list_running_instances "nut-server"); do
-                       if [ "$instance" != "upsd" ] && procd_running nut-server "$instance" >/dev/null 2>&1; then
-                               procd_kill nut-server "$instance" 2>&1 | logger -t nut-server
-                       fi
-               done
-       fi
-
-       # If nut-server was started but has no instances (even upsd)
-       if server_active; then
-               logger -t nut-server "nut-server active with no instances"
-               /etc/init.d/nut-server start 2>&1 | logger -t nut-server
-       # Otherwise, if we have at least one instance running
-       elif procd_running nut-server; then
-               # If server (upsd) is running
-               if procd_running nut-server upsd; then
-                       # Try to signal server (upsd) to reload configuration
-                       signal_instance "upsd" "upsd" "upsd -c reload" HUP "${STATEPATH}/upsd.pid"
-               # If server (upsd) is not running
-               else
-                       # Start server (upsd)
-                       /etc/init.d/nut-server start "upsd" 2>&1 | logger -t nut-server
+               if procd_running "nut-server" upsd >/dev/null 2>&1; then
+                       signal_instance "upsd" "upsd" "reload" "HUP" "${STATEPATH}/upsd.pid" "" "nut-server" "procd_kill" "nut-server" "upsd"
                fi
                config_foreach reload_ups_driver driver
+               stop_no_longer_configured_instances
+               ;;
+       stop)
+               stop_all_instances
+               stop_service_if_no_instances
+               remove_var_config
+               ;;
+       stop-instances)
+               stop_all_instances
+               remove_var_config
+               ;;
+       esac
+}
 
-               # Stop any driver instances which are no longer configured
-               # We can only reliably do this for instances managed by procd
-               for instance in $(list_running_instances "nut-server"); do
-                       if [ "$instance" = "upsd" ]; then
-                               continue
+# Manage NUT drivers and upsd (NUT server) services
+manage_service() {
+       local action="$1"
+       # If we have no second parameter, we use the "") case and affect all instances
+       shift || true
+       # We only consume a single additional parameter (if not acting on all
+       # instances, we act only on one instance).
+       local instance="$1"
+       local ret=0
+
+       case "$action" in
+       start)
+               case "$instance" in
+               "")
+                       service_preconditions "$action"
+                       ret=$?
+
+                       stop_no_longer_configured_instances
+
+                       if [ "$ret" = "0" ]; then
+                               manage_instances start
+                       else
+                               return 1
                        fi
-                       unset driver
-                       config_get driver "$instance" driver
-                       if [ -z "$driver" ] && procd_running nut-server "$instance" >/dev/null 2>&1; then
-                               procd_kill nut-server "$instance" 2>&1 | logger -t nut-server
+                       ;;
+               # We only start one service (upsd or one driver) from a given invocation
+               upsd)
+                       service_preconditions "$action"
+                       ret=$?
+                       if [ "$ret" = "0" ]; then
+                               start_server_instance
                        fi
-               done
-       # Nut-server is not started, so start it
-       else
-               /etc/init.d/nut-server start 2>&1 | logger -t nut-server
-       fi
-}
-
-stop_service() {
-       config_load nut_server
-       srv_statepath
-
-       # We only handle the first parameter passed
-       case "$1" in
-       "")
-               # If nut-server was started but has no instances (even upsd)
-               if server_active; then
-                       logger -t nut-server "nut-server active with no instances"
-                       procd_kill nut-server 2>&1 | logger -t nut-server
-               elif procd_running nut-server; then # if have at least one instance
-                       signal_instance "upsd" "upsd" "upsd -c stop" TERM "${STATEPATH}/upsd.pid" "procd_kill nut-server upsd"
-
-                       config_foreach stop_ups_driver driver
-
-                       # Also stop any driver instances which are no longer configured
-                       for instance in $(list_running_instances "nut-server"); do
-                               if [ "$instance" != "upsd" ] && procd_running nut-server "$instance" >/dev/null 2>&1; then
-                                       procd_kill nut-server "$instance" 2>&1 | logger -t nut-server
-                               fi
-                       done
-
-                       # If nut-server active but has no instances (even upsd)
-                       if server_active >/dev/null 2>&1; then
-                               procd_kill nut-server 2>&1 | logger -t nut-server
+                       stop_no_longer_configured_instances
+
+                       return $ret
+                       ;;
+               # We only start one service (upsd or one driver) from a given invocation
+               *)
+                       service_preconditions "$action"
+                       ret=$?
+                       if [ "$ret" = "0" ]; then
+                               config_foreach start_ups_driver driver "$instance"
                        fi
+                       stop_no_longer_configured_instances
+
+                       return $ret
+                       ;;
+               esac
+               ;;
+       reload)
+               service_preconditions "$action"
+               ret=$?
+
+               if [ "$ret" != "1" ]; then
+                       manage_instances reload
+               fi
+
+               stop_no_longer_configured_instances
+
+               if [ "$ret" = "1" ]; then
+                       return 1
                fi
                ;;
-       *upsd*)
-               if procd_running nut-server upsd; then
-                       signal_instance "upsd" "upsd" "upsd -c stop" TERM "${STATEPATH}/upsd.pid" "procd_kill nut-server upsd"
-                       # If nut-server is active with no instances
-                       if server_active; then
-                               procd_kill nut-server 2>&1 | logger -t nut-server
+       stop)
+               case "$instance" in
+               "")
+                       manage_instances stop
+                       ;;
+               upsd)
+                       if procd_running "nut-server" upsd >/dev/null 2>&1; then
+                               signal_instance "upsd" "upsd" "stop" "TERM" "${STATEPATH}/upsd.pid" "" "nut-server" "procd_kill" "nut-server" "upsd"
                        fi
-               fi
+                       stop_service_if_no_instances
+                       ;;
+               *)
+                       # We only handle the first parameter, so do not pass in all parameters
+                       config_foreach stop_ups_driver driver "$instance"
+                       stop_service_if_no_instances
+                       ;;
+               esac
                ;;
-       *)
-               # We only handle the first parameter, so do not pass in all parameters
-               config_foreach stop_ups_driver driver "$1"
+       stop-instances)
+               case "$instance" in
+               "")
+                       manage_instances stop-instances
+                       ;;
+               upsd)
+                       if procd_running "nut-server" upsd >/dev/null 2>&1; then
+                               signal_instance "upsd" "upsd" "stop" "TERM" "${STATEPATH}/upsd.pid" "" "nut-server" "procd_kill" "nut-server" "upsd"
+                       fi
+                       ;;
+               *)
+                       # We only handle the first parameter, so do not pass in all parameters
+                       config_foreach stop_ups_driver driver "$instance"
+                       ;;
+               esac
                ;;
        esac
 }
 
+# Start NUT drivers and upsd (NUT server)
+start_service() {
+       manage_service start "$@"
+}
+
+# Reload NUT drivers and upsd (NUT server)
+reload_service() {
+       manage_service reload "$@"
+}
+
+# Stop NUT drivers and upsd (NUT server)
+stop_service() {
+       manage_service stop "$@"
+}
+
 service_triggers() {
-       config_load nut_server
+       # No config, or error loading config is fatal
+       config_load nut_server || {
+               log_config_load_error "nut_server" "nut-server" "nut-server"
+               exit 1
+       }
+
+       config_get interface_reload_delay upsd interface_reload_delay $DEFAULT_PROCD_INTERFACE_RELOAD_DELAY
 
-       interface_triggers "add_trigger"
+       interface_triggers "add_trigger" "upsd" "$interface_reload_delay"
        procd_add_reload_trigger "nut_server"
 }
diff --git a/net/nut/files/nut-service.sh.functions b/net/nut/files/nut-service.sh.functions
new file mode 100644 (file)
index 0000000..eb8b663
--- /dev/null
@@ -0,0 +1,340 @@
+#!/bin/sh
+# Shebang line included so editors and shellcheck/shfmt know this contains
+# shell code
+
+# Service helper functions for NUT server initscript
+
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# Pre-requisite sourcing for functions this script uses
+# * /lib/functions.sh has been sourced
+# * /lib/functions/network.sh has been sourced
+# * /lib/functions/nut/nut-common.sh has been sourced
+# * /usr/share/libubox/jshn.sh has been sourced
+# * /lib/functions/procd.sh has been sourced (automatic for initscripts)
+
+# /var/run/killpower is a system-level sentinel created by another program
+# which indicates that the system should be powering off
+# /var/run is typically a tmpfs on OpenWrt and is not persisted across reboots
+NUT_KILLPOWER="/var/run/killpower"
+
+# Disable NUT hotplug path
+NUT_DISABLE_HOTPLUG_PATH="/var/run/nut/disable-hotplug"
+
+# Fallback STATEPATH setting
+NUT_BASE_STATEPATH="/var/run/nut"
+
+# STATEPATH and RUNAS values are not valid until after find_statepath
+# and/or find_runas
+STATEPATH=""
+RUNAS=""
+
+# Default delay between interface change and interface trigger reload action
+DEFAULT_PROCD_INTERFACE_RELOAD_DELAY=3000
+
+allow_hotplug_restart() {
+       # Allow hotplug to restart driver only if no NUT forced shutdown
+       # (as indicated by /var/run/killpower) is in progress
+       # and hotplug is not disabled
+
+       # NUT_KILLPOWER is a sentinel created by another program
+       # which indicates that the system should be powering off
+       [ ! -f "$NUT_KILLPOWER" ] || return 1
+
+       # Disable hotplug path; a sentinel created by config
+       # or nutshutdown
+       [ ! -f "$NUT_DISABLE_HOTPLUG_PATH" ] || return 1
+
+       return 0
+}
+
+# Emit list of running UPS service instances (empty if none)
+# returns 0 if there are active instances, 1 if no instances are active
+# 'service' (first positional parameter) must not come from user input, and is
+# expected to be static values provided by developers
+list_running_instances() {
+       local service="$1"
+       local running_instances service_filter
+
+       check_safe_uci_name "$service" || return 1
+
+       service_filter="@['""$service""'][@.*.running=true]"
+       running_instances="$(_procd_ubus_call list | jsonfilter -e "$service_filter")"
+
+       if [ -n "$running_instances" ]; then
+               json_init
+               json_load "$running_instances" || {
+                       logger -s -t nut-service "json_load failed in list_running_instances"
+                       return 1
+               }
+               json_get_keys instance_names
+               # shellcheck disable=SC2154
+               echo "$instance_names"
+               json_cleanup
+               return 0
+       fi
+       return 1
+}
+
+# Send a signal to a UPS service instance
+# **Not to be used with user input**, only use static information provided by
+# developers as signal_command and secondary_command execute commands in the
+# script's context
+signal_instance() {
+       if [ "$#" -lt 7 ]; then
+               log_error "signal_instance called with too few parameters."
+               return 1
+       fi
+       local instance_name="$1"
+       local signal_command="$2"
+       local signal_action="$3"
+       local signal="$4"
+       local pidfile="$5"
+       local signal_extra_arg="$6"
+       local service_name="$7"
+       # We use shift here and below to allow use of additional variadic parameters
+       shift 7
+       local secondary_command="$1"
+       shift || true
+       local process_name ret
+
+       process_name=$(basename "$signal_command")
+
+       # Prefer sending signal using '$signal_command', which requires that a
+       # PID file exists. Otherwise, send signal(s) to the process(es) by their
+       # instance name
+       if [ -s "$pidfile" ]; then
+               # Informational log message, not error
+               log_msg "Sending signal action '$signal_action' to '$signal_command' for '$instance_name'" "nut-service.sh" "$service_name" "info"
+               set -f
+               # **Not to be used with user input**
+               # shellcheck disable=SC2086
+               if [ -n "$signal_extra_arg" ]; then
+                       "$signal_command" -c "$signal_action" -a "$signal_extra_arg"
+                       ret=$?
+               else
+                       "$signal_command" -c "$signal_action"
+                       ret=$?
+               fi
+               set +f
+               # Modern OpenWrt (since OpenWrt-24.10, which is old stable, two releases
+               # prior to the release targetted by this script) has pgrep with the expected
+               # functionality--it, however, cannot send signals to processes.
+       elif pgrep "$process_name" >/dev/null 2>/dev/null; then
+               # If 'process_name' is in the process table, signal it with procd_send_signal
+               # Informational log message, not error
+               log_msg "Sending signal '$signal' to '$instance_name'" "nut-service.sh" "$service_name" "info"
+               procd_send_signal "$service_name" "$instance_name" "$signal"
+               ret=$?
+       else
+               # No matching instance/process, which is an allowed possible state.
+               :
+       fi
+
+       # **Not to be used with user input**
+       # For NUT exit code 0 is success, 1 is error, and other codes have other meanings
+       # We only want to issue secondary command if the primary command fails
+       if [ "$ret" = "1" ] && [ -n "$secondary_command" ] && procd_running "$service_name" "$instance_name"; then
+               # Informational log message, not error
+               logger -s -t "$service_name" "Performing '$secondary_command' $* for '$instance_name'"
+               set -f
+               "$secondary_command" "$@" 2>&1 | logger -s -t "$service_name"
+               set +f
+       fi
+       # No signal sent is a valid possibility. Also, if sending a signal fails
+       # we want to keep going, so don't error, but do log the condition.
+       return 0
+}
+
+config_foreach_get() {
+       local section="$1"
+       local option="$2"
+       local value
+
+       # nut_found_value is a 'pseudo-global' variable so that the value can be
+       # passed back to the calling function (that is the variable is defined in
+       # that function's context, and modification in functions called from that
+       # function will update the variable there).
+       config_get value "$section" "$option"
+       if [ -n "$value" ]; then
+               nut_found_value="$value"
+               nut_found_value_count=$((nut_found_value_count + 1))
+       fi
+       return 0
+}
+
+find_foreach_value() {
+       local section_type="$1"
+       local package="$2"
+       local option="$3"
+       local default="$4"
+       local nut_found_value=""
+       local nut_found_value_count=0
+
+       # 'pseudo-global' variable so that the value can be passed back to this function (that is the
+       # variable is defined in this function's context, and modification in functions called from
+       # this function will update variable here).
+       nut_value_via_foreach=""
+
+       config_foreach config_foreach_get "$section_type" "$option" || {
+               log_error "config_foreach for config_foreach_get for '$package'.'$section_type' failed." nut-service.sh nut-service
+               # nut_found_value and nut_found_value_count are indeterminate on config_foreach failure
+               nut_found_value=""
+               nut_found_value_count=0
+               return 1
+       }
+
+       if [ "$nut_found_value_count" -gt 1 ]; then
+               # Informational log messages, not error
+               logger -t nut-common "Found more than one '$option' setting in '$package' for '$section_type'."
+               logger -t nut-common "Using last found '$option': nut_found_value='$nut_found_value'"
+       fi
+
+       if [ -z "$nut_found_value" ]; then
+               nut_found_value="$default"
+       fi
+
+       nut_value_via_foreach="$nut_found_value"
+       return 0
+}
+
+# Store path for NUT working data in the global variable STATEPATH
+# shellcheck disable=SC2329
+find_statepath() {
+       local section_type="$1"
+       local package="$2"
+       # Callers should not need to see the variable below outside this function
+       # Conversely, functions called by this function are able to see and set
+       # the variable below, which acts as a 'pseudo-global' to called functions.
+       local nut_value_via_foreach=""
+
+       find_foreach_value "$section_type" "$package" statepath "$NUT_BASE_STATEPATH" || return 1
+       # Modern OpenWrt (since at least OpenWrt-24.10 which is now old stable, we are developing for
+       # not yet released OpenWrt, so two versions later) supports readlink -f from busybox.
+       [ -n "$nut_value_via_foreach" ] || return 1
+       [ -d "$(readlink -f "$nut_value_via_foreach" 2>/dev/null)" ] || return 1
+
+       # shellcheck disable=2034
+       STATEPATH="$nut_value_via_foreach"
+       return 0
+}
+
+# Store user under which to run daemon processes
+# shellcheck disable=SC2329
+find_runas() {
+       local section_type="$1"
+       local package="$2"
+       local fallback_runas="$3"
+       # Callers should not need to see the variable below outside this function
+       # Conversely, functions called by this function are able to see and set
+       # the variable below, which acts as a 'pseudo-global' to called functions.
+       local nut_value_via_foreach=""
+
+       find_foreach_value "$section_type" "$package" runas "${fallback_runas:-nut}" || return 1
+       [ -n "$nut_value_via_foreach" ] || return 1
+       [ -n "$(id -un "$nut_value_via_foreach")" ] || {
+               log_error "User '$nut_value_via_foreach' specified for RUNAS does not exist." nut-service.sh nut-service
+               return 1
+       }
+
+       # shellcheck disable=2034
+       RUNAS="$nut_value_via_foreach"
+       return 0
+}
+
+# Detect if service is running under procd, with no instances
+# (e.g. upsd with no UPS drivers running)
+service_active_no_instances() {
+       local service="$1"
+       local active_instances
+       local active_var service_filter
+
+       check_safe_uci_name "$service" || return 1
+
+       service_filter="@['""${service}""']"
+       active_instances="$(_procd_ubus_call list "{\"name\":\"$service\"}" | jsonfilter -l 1 -e active_var="$service_filter")"
+       # If we get no output at all, the service is not running
+       [ -z "$active_instances" ] && return 1
+
+       case "$active_instances" in
+       export\ active_var=*\;\ )
+               active_var="${active_instances#export active_var=}"
+               active_var="${active_var%%; }"
+               ;;
+       *)
+               log_error "Unexpected value '$active_instances' for 'active_instances' from jsonfilter in service_active_no_instances" nut-service.sh nut-service
+               return 1
+               ;;
+       esac
+
+       # active_var is only empty, but present, if there are no instances for the service
+       [ -z "$active_var" ] && return 0
+
+       return 1
+}
+
+# Setup triggers in procd for changes to specified network interfaces
+# network_is_up depends on /lib/functions/network.sh having been sourced
+interface_triggers() {
+       local action="$1"
+       local section="$2"
+       local interface_reload_delay="${3:-$DEFAULT_PROCD_INTERFACE_RELOAD_DELAY}"
+       local interfaces interface
+       local have_up_interface
+       local trigger_failed
+
+       config_get interfaces "$section" triggerlist
+
+       # We list the actions we care about. Other actions are irrelevant.
+       # We do not need to log or otherwise 'notice' other actions
+       case "$action" in
+       add_trigger)
+               if [ -n "$interfaces" ]; then
+                       set -f
+                       trigger_failed="false"
+                       # interfaces is deliberately unquoted so we get word-splitting for
+                       # the for loop
+                       for interface in $interfaces; do
+                               # We use the variadic third and fourth parameters of procd_add_interface_trigger to
+                               # restart instead of reload on interface up/down events. Additionally the variadic
+                               # fourth and fifth parameters of procd_add_interface_trigger are passed through to
+                               # the <script> (in this case /etc/init.d/nut-server), so the trigger action becomes
+                               # /etc/init.d/nut-server restart "upsd").
+                               procd_add_interface_trigger "interface.*.up" "$interface" /etc/init.d/nut-server restart "upsd" || trigger_failed="true"
+                       done
+                       set +f
+                       if [ "$trigger_failed" = "true" ]; then
+                               return 1
+                       fi
+               else
+                       # Add a restart trigger on any interface's up status changing
+                       procd_add_raw_trigger "interface.*.up" "$interface_reload_delay" /etc/init.d/nut-server restart || return 1
+               fi
+               ;;
+       check_interface_up)
+               if [ -n "$interfaces" ]; then
+                       set -f
+                       have_up_interface="false"
+                       for interface in $interfaces; do
+                               network_is_up "$interface" && have_up_interface="true" && break
+                       done
+                       set +f
+                       if [ "$have_up_interface" = "false" ]; then
+                               log_msg "None of the trigger interfaces are up." "nut-service.sh" "nut-service" "notice"
+                               return 1
+                       fi
+               else
+                       # Return true if any interface is up
+                       # -q for jsonfilter only suppress error output, not messages on
+                       # stdout, redirect to >/dev/null is needed to avoid all output
+                       if ubus -S call network.device status | jsonfilter -q -l 1 -e '$[@.up=true].up' >/dev/null; then
+                               return 0
+                       fi
+                       log_msg "No interfaces are up." "nut-service.sh" "nut-service" "notice"
+                       return 1
+               fi
+               ;;
+       esac
+       return 0
+}
index dca5de07cc645d5ceedd17bbc26f24c4a70a3bbc..1ed96eccfab4c9a9dfbdc605c7b45cff520c0e7c 100644 (file)
@@ -1,7 +1,7 @@
 #config host
 #      option upsname upsname
 #      option hostname localhost
-#      option port # optional port number
+#      option port <number> # optional port number
 #      option displayname "Display Name"
 
 config upsset
index 1a8d4bf3d3c7716b6f28a9373287f18615ec7ddf..ce9302dbb515caca9a045d403d0068b765ca94c7 100644 (file)
@@ -1,4 +1,16 @@
-#config upsmon 'upsmon'
+# **NOTE**: Due to the presence of passwords in this file, restrictive file
+# permissions on this file in actual use in /etc/config/nut_monitor should be
+# used. For example:
+# chown root:root /etc/config/nut_monitor
+# chmod 0600 /etc/config/nut_monitor
+
+# We only support one instance of upsmon, and its UCI
+# section must be named 'upsmon'
+
+# defaults for the upsmon section are compile time defaults
+# except /path/to/dir values which have no default
+# and defaultnotify which is an UCI specific setting
+#config upsmon upsmon
 #      option runas nutmon
 #      option deadtime 15
 #      option finaldelay 5 # final delay
@@ -20,7 +32,8 @@
 #      option certverify 0
 #      option forcessl 0
 #      option debugmin 0
-# option defaultnotify SYSLOG
+#      option defaultnotify SYSLOG
+# option interface_reload_delay 3000 # in milliseconds
 
 #config monitor
 #      option type primary
 #      option username upsuser
 #      option password upspassword
 
+# notification config sections must be named with an uppercase valid
+# NUT event type, from the list at
+# https://networkupstools.org/docs/man/upsmon.conf.html
+#
+# Empty notifications sections will emit a default NOTIFYFLAG <event> SYSLOG
+# line, as otherwise the NUT default of SYSLOG+WALL would be used, and the
+# `wall` command is not present in OpenWrt by default
+
 #config notifications 'COMMOK'
 #      option message "Communications with UPS %s established"
 #      option flag "SYSLOG"
 #      option flag "SYSLOG+EXEC"
 
 #config notifications 'ONLINE'
-#      option message "UPS %s is on line power"
-#      option flag "SYSLOG"
+#      option flag "IGNORE"
 
 #config notifications 'ONBATT'
 #      option message "UPS %s is on battery power"
 #      option flag "SYSLOG+EXEC"
+
+#config notifications 'SHUTDOWN'
+#      option message "UPS %s shutting down"
+#      option flag "SYSLOG+EXEC"
diff --git a/net/nut/files/nut_serial.hotplug b/net/nut/files/nut_serial.hotplug
deleted file mode 100644 (file)
index 4bab3b6..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/bin/sh
-
-nut_serial() {
-       local cfg="$cfg"
-       local runas enable_usb_serial port
-       config_get_bool enable_usb_serial "$cfg" enable_usb_serial 0
-       config_get port "$cfg" port
-       config_get runas "$cfg" runas "nut"
-
-       [ -z "$runas" ] && config_get runas upsd runas "nut"
-
-       [ "$enable_usb_serial" -eq 1 ] && {
-               # If port is specified only change tty's matching port
-               if [ -n "$port" ] && [ "$port" != /dev/"$DEVNAME" ]; then
-                       return 0
-               fi
-               [ -n "$runas" ] && chgrp "$(id -gn "${runas}")" /dev/"$DEVNAME"
-               chmod g+rw /dev/"$DEVNAME"
-       }
-}
-
-nut_on_hotplug_add() {
-       . "${IPKG_INSTROOT}"/lib/functions.sh
-
-       config load nut_server
-       config_foreach nut_serial driver
-}
-
-[ "$ACTION" = "add" ] && [ -n "$DEVNAME" ] && {
-       # On add of a serial port with name ttyUSB*
-       [ -z "${DEVNAME%ttyUSB*}" ] && nut_on_hotplug_add
-       # On add of a serial port with name ttyAMA*
-       [ -z "${DEVNAME%ttyAMA*}" ] && nut_on_hotplug_add
-}
index 98149ee50abdaf0277779f0bb24839d269963f51..45d286a6ac22f67c2af769c7048f9c2ce2796318 100644 (file)
@@ -1,10 +1,17 @@
 #config driver_global 'driver_global'
-#      option user nut
+#      option driverpath /usr/libexec/nut # compile-time default
 
 #config driver 'upsname'
-#      option driver usbhid-ups
-#      option port auto
-#      option enable_usb_serial 0
+#      option driver usbhid-ups # example
+#      option port auto # example
+#      option enable_usb_serial 0 # example
+#
+# ### NOTE ###
+# For USB UPSes it is necessary to properly configure
+# productid and vendorid (and serial number if you have
+# more than one device with the same productid and vendorid) in
+# nut_server so that the hotplug script can give NUT
+# permissions to access the UPS device(s)
 
 #config user
 #      option username upsuser
 #      option upsmon secondary|primary
 
 #config listen_address
-#      option address ::1
-#      option port 3493
+#      option address ::1 # example
+#      option port 3493 # compile-time default
 
+# defaults for the upsd section are compile time defaults
+# We only support one 'upsd' section and it must be named 'upsd'
 #config upsd 'upsd'
 #      option maxage 15
 #      option statepath /var/run/nut
 #      option maxconn 1024
 #      option runas nut
+# option interface_reload_delay 3000 # in milliseconds
 # NB: certificates only apply to SSL-enabled version
 #       option certfile /usr/local/etc/upsd.pem
old mode 100755 (executable)
new mode 100644 (file)
index f811527..586d173
 #
 # This is free software, licensed under the GNU General Public License v2.
 # See /LICENSE for more information.
-#
 
-stop_instance() {
-       /etc/init.d/nut-server stop "$1"
+# In recent (relevant) versions of shellcheck busybox is a valid shell type
+# shellcheck shell=busybox
+
+# Script called by NUT, as root, to safely shutdown the system in the event of
+# impending loss of power due to low battery discharge during a power outage,
+# or due to a FSD (Forced ShutDown) command.
+
+# Path to NUT server UCI configuration file
+NUT_SERVER_CONFIG=/etc/config/nut_server
+# Path to NUT drivers
+NUT_DRIVER_PATH=/usr/libexec/nut
+# Path to file created by NUT indicating a forced shutdown (FSD) is needed
+# On OpenWrt this will (intentionally) normally not exist after a reboot
+# as /var/run is typically a tmpfs
+NUT_KILLPOWER=/var/run/killpower
+# Disable NUT hotplug path
+NUT_DISABLE_HOTPLUG_PATH=/var/run/nut/disable-hotplug
+# Path to NUT server (de)initscript
+NUT_SERVER_INIT=/etc/init.d/nut-server
+# Delay for FSD to succeed before forcing shutdown
+# 2 minutes to allow UPS offdelay + safe shutdown grace period
+# Not a configuration option as reading config could fail
+NUT_FSD_FAIL_DELAY=120
+
+MISSING_CONFIG_BAIL_MESSAGE="No nut server config found while doing 'nutshutdown'. Bailing"
+MISSING_LIB_FUNCTIONS_SH_BAIL_MESSAGE="/lib/functions.sh is missing. Bailing"
+MISSING_UPS_INSTANCE_SKIP_MESSAGE="stop_nut_server_instance called with no ups (section) name. Skipping."
+NOT_A_LIVE_SYSTEM_BAIL_MESSAGE="Trying to use 'nutshutdown' when not on a live OpenWrt system. Bailing."
+UNSAFE_UPS_INSTANCE_NAME_SKIP_MESSAGE="No valid UPS UCI section name in shutdown_actual_ups. Skipping."
+
+# Only enact killpower (FSD: Forced Shut Down) if certain conditions are met
+DO_KILLPOWER="0"
+
+# shellcheck disable=SC2329,SC2317
+check_safe_name() {
+       case "$1" in
+       *[!a-zA-Z0-9_-]* | "")
+               return 1
+               ;;
+       *)
+               return 0
+               ;;
+       esac
+}
+
+# shellcheck disable=SC2329,SC2317
+stop_nut_server_instance() {
+       # We're in an emergency shutdown; best effort shutdown and don't block
+       if [ -z "$1" ]; then
+               echo "$MISSING_UPS_INSTANCE_SKIP_MESSAGE" >&2
+               logger -s -t nut-shutdown "$MISSING_UPS_INSTANCE_SKIP_MESSAGE" || true
+               return 1
+       fi
+       "$NUT_SERVER_INIT" stop "$1" || true
+}
+
+stop_drivers() {
+       if [ "$DO_KILLPOWER" = "1" ]; then
+               # FSD may not work unless drivers are stopped; try to stop them but
+               # don't block on failure; partial success would be better than none
+               config_foreach stop_nut_server_instance driver || true
+       fi
 }
 
-shutdown_instance() {
-       local cfg="$1"
-       config_get driver "$cfg" driver "usbhid-ups"
+# shellcheck disable=SC2329,SC2317
+shutdown_actual_ups() {
+       local ups="$1"
+       local driver nut_ups_driver_full_path
+
+       # Avoid potentially blocking errors if "$ups" is somehow not defined
+       # with a proper UCI name (should never happen, hence skipping if it does)
+       if ! check_safe_name "$ups"; then
+               echo "$UNSAFE_UPS_INSTANCE_NAME_SKIP_MESSAGE" >&2
+               logger -s -t nut-shutdown "$UNSAFE_UPS_INSTANCE_NAME_SKIP_MESSAGE" || true
+               return 1
+       fi
+
+       config_get driver "$ups" driver
+
+       # If we didn't get the current driver, skip to the next one (best effort)
+       [ -n "$driver" ] || return 0
+
+       # ensure driver name is 'safe'/valid. This not being true could be a
+       # simple mistake, so we do not bail on the shutdown, we just ignore this UPS.
+       check_safe_name "$driver" || return 0
+
+       nut_ups_driver_full_path="${NUT_DRIVER_PATH}/${driver}"
 
        # Only FSD if killpower was indicated
-       if [ -f /var/run/killpower ]; then
-               /usr/libexec/nut/"${driver}" -a "$cfg" -k
+       if [ -f "$NUT_KILLPOWER" ]; then
+               # We're in an emergency shutdown; best effort shutdown and don't block
+               # Ideally this will also tell the UPS to poweroff after 'offdelay'
+               # We do this late in the sequence to minimize the chance the forced
+               # shutdown of the UPS (-k) will result in power loss before we
+               # finish
+               "$nut_ups_driver_full_path" -a "$ups" -k || true
        fi
 }
 
-do_fsd() {
-       if [ -f /var/run/killpower ]; then
-               # Only make FS readonly if we are doing an FSD
-               mount -o remount,ro /overlay /overlay
-               mount -o remount,ro / /
-
-               . ${IPKG_INSTROOT}/lib/functions.sh
-
-               if [ -f /etc/config/nut_server ]; then
-                       config_load nut_server
-
-                       # Can't FSD unless drivers are stopped
-                       config_foreach stop_instance driver
-                       # Driver will  wait 'offdelay' before shutting down
-                       config_foreach shutdown_instance driver
-                       # So this can happen
-                       rm -f /var/run/killpower
-                       poweroff
-                       # And just in case
-                       sleep 120
-                       # Uh-oh failed to poweroff UPS
-                       reboot -f
-               else
-                       poweroff
-               fi
-       else
-               poweroff
+remount_filesystems_as_readonly() {
+       # Make FS readonly, if we are doing an FSD, but only on a best effort basis,
+       # do not stop shutdown if it does not succeed as power is going to be lost
+       # anyway. Also logging may already be stopped, so no point (and could hang
+       # shutdown) trying to log any errors
+       mount -o remount,ro /overlay /overlay || true
+       mount -o remount,ro / / || true
+}
+
+# Do power off with delay and then forced reboot
+do_forced_shutdown() {
+       # In case poweroff forks and returns (but ignore error code, since if
+       # poweroff does return 'something' is wrong and we should not rely on it)
+       poweroff || true
+       # And just in case (even if poweroff claims it worked, it may not. If
+       # poweroff works properly this will never be reached).
+       sleep "$NUT_FSD_FAIL_DELAY"
+       # Uh-oh failed to poweroff system, force power off (not shutdown scripts
+       # will be run)
+       poweroff -f || true
+       sleep 1
+       # It still failed; hard force a non-syncing reboot
+       # Requires root and /proc to still be mounted
+       echo 1 >/proc/sys/kernel/sysrq || true
+       # Will immediately reboot the system
+       # without syncing or unmounting your disks.
+       # We do this as there is no bypass and halt option
+       # 'o' is essentially ACPI poweroff, which may not help us in an emergency.
+       echo b >/proc/sysrq-trigger || true
+}
+
+# IPKG_INSTROOT is intentionally only set when building an image and
+# is intentionally empty on a live OpenWrt device
+if [ -n "${IPKG_INSTROOT}" ]; then
+       echo "$NOT_A_LIVE_SYSTEM_BAIL_MESSAGE" >&2
+       # Improbable logging will work
+       logger -s -t nut-shutdown "$NOT_A_LIVE_SYSTEM_BAIL_MESSAGE" || true
+       exit 1
+fi
+
+# shellcheck source=/dev/null
+. "/lib/functions.sh" || {
+       # If /lib/functions.sh is missing on a live system (or we are not on a
+       # live system) bail with an error.
+       # Logging probably won't work if /lib/functions.sh is not available but try
+       echo "$MISSING_LIB_FUNCTIONS_SH_BAIL_MESSAGE" >&2
+       logger -s -t nut-shutdown "$MISSING_LIB_FUNCTIONS_SH_BAIL_MESSAGE" || true
+       exit 1
+}
+
+disable_hotplug() {
+       # If killpower is set disable hotplug until next boot
+       # (NUT_DISABLE_HOTPLUG_PATH) *should* be on non-persistent media that is
+       # available during forced shutdown
+       # If it is on persistent storage, manual intervention will be required
+       # for the NUT hotplug scripts to execute again
+       if [ ! -f "$NUT_DISABLE_HOTPLUG_PATH" ]; then
+               mkdir -p "$(dirname "$NUT_DISABLE_HOTPLUG_PATH")" 2>/dev/null || true
+               # Try to touch the file even if mkdir fails, but swallow an errors,
+               # because this is a best-effort emergency shutdown.
+               touch "$NUT_DISABLE_HOTPLUG_PATH" 2>/dev/null || true
        fi
 }
 
-do_fsd
+# If a forced shutdown has been triggered ($NUT_KILLPOWER is present)
+# and we have a server config and the config loads correctly
+if [ -f "$NUT_KILLPOWER" ] && [ -f "$NUT_SERVER_CONFIG" ] && config_load nut_server; then
+       DO_KILLPOWER="1"
+
+       # UPS will wait 'offdelay' before shutting down
+       disable_hotplug || true
+
+       stop_drivers || true
+       remount_filesystems_as_readonly || true
+
+       # Drivers are already stopped so we don't require r/w access to filesystems
+       config_foreach shutdown_actual_ups driver || true
+elif [ -f "$NUT_KILLPOWER" ]; then
+       # If we can't load config, attempt to log that fact, but shutdown anyway
+       DO_KILLPOWER="1"
+       disable_hotplug || true
+       echo "$MISSING_CONFIG_BAIL_MESSAGE" >&2 || true
+       logger -s -t nut-shutdown "$MISSING_CONFIG_BAIL_MESSAGE" || true
+
+       remount_filesystems_as_readonly || true
+fi
+
+if [ "$DO_KILLPOWER" = "1" ]; then
+       # This is it, if the forced shutdown doesn't work we just wait for power
+       # to cutoff
+       do_forced_shutdown
+else
+       # Not a forced shutdown, just do a normal poweroff (calls normal
+       # power down sequence). Do not ignore errors in this case.
+       poweroff
+fi
+
+# To be explicit to readers that this script does nothing after this
+# It's an error exit, as it should never be reached
+exit 1
git clone https://git.99rst.org/PROJECT