Created
November 12, 2025 03:07
-
-
Save ergosteur/a1bee4dcb2c81771ed77270cd65cbf7b to your computer and use it in GitHub Desktop.
script to fix USB permissions for NUT (Network UPS Tools)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # fix-ups-permissions.sh | |
| # Standalone script to fix USB permissions for NUT (Network UPS Tools) | |
| set -e | |
| if [ "$EUID" -ne 0 ]; then | |
| echo "ERROR: Please run as root or with sudo." >&2 | |
| exit 1 | |
| fi | |
| # --- Functions --- | |
| select_ups_device() { | |
| echo "Scanning for USB devices..." >&2 | |
| mapfile -t devices < <(lsusb) | |
| if [ ${#devices[@]} -eq 0 ]; then | |
| echo "No USB devices found." >&2 | |
| return 1 | |
| fi | |
| echo "" >&2 | |
| echo "Select your UPS device:" >&2 | |
| local i=1 | |
| for device in "${devices[@]}"; do | |
| echo "$i) $device" >&2 | |
| ((i++)) | |
| done | |
| echo "$i) Quit" >&2 | |
| read -p "Enter number: " choice | |
| if [[ "$choice" =~ ^[Qq]$ ]] || [ "$choice" -eq "$i" ]; then | |
| return 1 | |
| fi | |
| if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#devices[@]}" ]; then | |
| echo "Invalid choice." >&2 | |
| return 1 | |
| fi | |
| local selected=${devices[$((choice - 1))]} | |
| if [[ "$selected" =~ ID[[:space:]]*([0-9a-fA-F]{4}):([0-9a-fA-F]{4}) ]]; then | |
| echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}" | |
| else | |
| echo "Failed to parse Vendor/Product ID." >&2 | |
| return 1 | |
| fi | |
| } | |
| create_udev_rule() { | |
| local idVendor=$1 idProduct=$2 | |
| local rule_file="/etc/udev/rules.d/50-nut-ups.rules" | |
| # Two complementary lines: one for HID devices, one for plain USB devices | |
| local rule_hid="SUBSYSTEM==\"hidraw\", ATTRS{idVendor}==\"$idVendor\", ATTRS{idProduct}==\"$idProduct\", MODE=\"0660\", GROUP=\"nut\"" | |
| local rule_usb="SUBSYSTEM==\"usb\", ATTR{idVendor}==\"$idVendor\", ATTR{idProduct}==\"$idProduct\", MODE=\"0660\", GROUP=\"nut\"" | |
| echo "Adding or verifying rules in $rule_file..." | |
| # Ensure file exists and each line appears exactly once | |
| touch "$rule_file" | |
| grep -qF "$rule_hid" "$rule_file" || echo "$rule_hid" >> "$rule_file" | |
| grep -qF "$rule_usb" "$rule_file" || echo "$rule_usb" >> "$rule_file" | |
| echo "Reloading udev..." | |
| udevadm control --reload-rules | |
| udevadm trigger | |
| udevadm settle | |
| } | |
| verify_permissions() { | |
| local idVendor=$1 idProduct=$2 | |
| echo "Verifying device permissions..." | |
| local idVendorUpper=$(echo "$idVendor" | tr '[:lower:]' '[:upper:]') | |
| local idProductUpper=$(echo "$idProduct" | tr '[:lower:]' '[:upper:]') | |
| local pattern=":$idVendorUpper:$idProductUpper" | |
| local found_dev="" | |
| for symlink in /sys/class/hidraw/hidraw*; do | |
| if [ -L "$symlink" ] && [[ "$(readlink "$symlink")" == *"$pattern"* ]]; then | |
| found_dev="/dev/$(basename "$symlink")" | |
| break | |
| fi | |
| done | |
| # Fallback: check /dev/bus/usb | |
| if [ -z "$found_dev" ]; then | |
| local busdev | |
| busdev=$(lsusb -d "$idVendor:$idProduct" | awk '{printf "/dev/bus/usb/%03d/%03d", $2, $4}' | tr -d ':') | |
| [ -e "$busdev" ] && found_dev="$busdev" | |
| fi | |
| if [ -z "$found_dev" ]; then | |
| echo "⚠️ Device not found in hidraw or /dev/bus/usb. Try replugging it." | |
| return 1 | |
| fi | |
| echo "✅ Found device node: $found_dev" | |
| echo "" | |
| echo " $(ls -l "$found_dev")" | |
| echo "" | |
| local perms=$(stat -c "%a" "$found_dev") | |
| local group=$(stat -c "%G" "$found_dev") | |
| echo " Current permissions: $perms | Group: $group" | |
| # Split out the numeric comparison (stat always gives decimal perms) | |
| if [ "$group" != "nut" ]; then | |
| echo "⚠️ Group is '$group' but expected 'nut'. You may need to replug or reload udev." | |
| return | |
| fi | |
| if (( perms < 660 )); then | |
| echo "❌ Permissions too restrictive (expected at least 660)." | |
| echo " Try replugging the UPS or reloading udev rules." | |
| elif (( perms > 660 )); then | |
| echo "✅ Permissions are at least 660 — access should be fine." | |
| echo " (Note: currently world-readable, which is more permissive than necessary.)" | |
| else | |
| echo "✅ Permissions exactly 660 — perfect for NUT." | |
| fi | |
| echo "" | |
| echo "You can now restart the NUT driver if needed:" | |
| echo " sudo systemctl restart nut-driver.target" | |
| } | |
| # Fix or clean up /etc/nut/ups.conf bus/device mappings | |
| fix_ups_conf_device_mapping() { | |
| local UPS_CONF="/etc/nut/ups.conf" | |
| local idVendor="$1" | |
| local idProduct="$2" | |
| echo "Checking $UPS_CONF for outdated or fragile bus/device mapping..." | |
| # Ensure config exists | |
| if [ ! -f "$UPS_CONF" ]; then | |
| echo "⚠️ No $UPS_CONF found — skipping." | |
| return | |
| fi | |
| # Always remove any busport line — it's not needed | |
| if grep -qE '^\s*busport\s*=' "$UPS_CONF"; then | |
| echo "🧹 Removing obsolete 'busport' line..." | |
| sed -i '/^\s*busport\s*=/d' "$UPS_CONF" | |
| fi | |
| # Check if bus/device lines exist | |
| local has_bus has_dev | |
| has_bus=$(grep -qE '^\s*bus\s*=' "$UPS_CONF" && echo 1 || echo 0) | |
| has_dev=$(grep -qE '^\s*device\s*=' "$UPS_CONF" && echo 1 || echo 0) | |
| if [ "$has_bus" -eq 0 ] && [ "$has_dev" -eq 0 ]; then | |
| echo "✔ No fixed bus/device entries found — already portable." | |
| return | |
| fi | |
| # Find the actual current device path for the given vendor/product | |
| local actual_path | |
| actual_path=$(lsusb | grep -i "${idVendor}:${idProduct}" | awk '{print "/dev/bus/usb/" $2 "/" $4}' | sed 's/://') | |
| if [ -z "$actual_path" ]; then | |
| echo "⚠️ Could not locate UPS via lsusb (maybe unplugged)." | |
| return | |
| fi | |
| local actual_bus actual_dev | |
| actual_bus=$(echo "$actual_path" | awk -F'/' '{print $(NF-1)}') | |
| actual_dev=$(echo "$actual_path" | awk -F'/' '{print $NF}') | |
| echo "" | |
| echo "Detected current UPS location: bus=$actual_bus, device=$actual_dev" | |
| echo "" | |
| echo "Choose how to handle 'bus' and 'device' lines in ups.conf:" | |
| echo " 1) Keep and update them (bus=$actual_bus, device=$actual_dev)" | |
| echo " 2) Remove them entirely (recommended for stability)" | |
| echo -n "Enter choice [1/2, default=2]: " | |
| read -r choice | |
| choice=${choice:-2} | |
| if [ "$choice" == "1" ]; then | |
| echo "🔧 Updating 'bus' and 'device' lines..." | |
| if grep -qE '^\s*bus\s*=' "$UPS_CONF"; then | |
| sed -i "s/^\s*bus\s*=.*/ bus = \"$actual_bus\"/" "$UPS_CONF" | |
| else | |
| echo " bus = \"$actual_bus\"" >> "$UPS_CONF" | |
| fi | |
| if grep -qE '^\s*device\s*=' "$UPS_CONF"; then | |
| sed -i "s/^\s*device\s*=.*/ device = \"$actual_dev\"/" "$UPS_CONF" | |
| else | |
| echo " device = \"$actual_dev\"" >> "$UPS_CONF" | |
| fi | |
| echo "✔ ups.conf updated with new bus/device values." | |
| else | |
| echo "🧹 Removing static 'bus' and 'device' entries..." | |
| sed -i '/^\s*bus\s*=/d' "$UPS_CONF" | |
| sed -i '/^\s*device\s*=/d' "$UPS_CONF" | |
| echo "✔ ups.conf cleaned up — NUT will now auto-detect via vendor/product." | |
| fi | |
| } | |
| # --- Main --- | |
| echo "=== NUT USB Permission Fixer ===" | |
| read -r idVendor idProduct < <(select_ups_device) | |
| if [ -z "$idVendor" ] || [ -z "$idProduct" ]; then | |
| echo "No device selected or unable to parse device IDs. Exiting." >&2 | |
| exit 1 | |
| fi | |
| echo "Selected: Vendor=$idVendor Product=$idProduct" | |
| create_udev_rule "$idVendor" "$idProduct" | |
| verify_permissions "$idVendor" "$idProduct" | |
| fix_ups_conf_device_mapping "$idVendor" "$idProduct" | |
| echo "" | |
| echo "Done. You may now restart the NUT driver:" | |
| echo " sudo systemctl restart nut-driver.target" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment