Single bash script that creates an Ubuntu 24.04 VM on Proxmox, detects the USB UPS device, sets up USB passthrough, then installs and configures NUT (Network UPS Tools) in netserver mode inside the VM.
NUT cannot run reliably in LXC containers due to kernel driver detachment restrictions — a VM is required.
Confirmed specs:
- OS: Ubuntu 24.04 (Noble) cloud image
- NUT mode:
netserver - UPS connection: USB
- VM provisioning: cloud-init (automated)
- VM defaults: fixed but user can override interactively
Single deliverable:
vm/nut-vm.sh
The NUT install script is embedded as a heredoc inside the main script, SCP'd to the VM, and executed via SSH.
vm/nut-vm.sh
├── Section 2: Input/prompt helper functions (whiptail)
├── Section 3: Prerequisite checks
├── Section 4: VM configuration prompts
├── Section 5: NUT configuration prompts
├── Section 6: Storage type detection
├── Section 7: Cloud image download + SSH key + cloud-init snippet
├── Section 8: VM creation
├── Section 9: USB UPS detection and passthrough
├── Section 10: VM boot + SSH readiness wait
├── Section 11: NUT install heredoc + SCP + SSH execution
└── Section 12: Final summary output
UBUNTU_IMG_URL="https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-24.04-minimal-cloudimg-amd64.img"
UBUNTU_IMG_CHECKSUM_URL="https://cloud-images.ubuntu.com/minimal/releases/noble/release/SHA256SUMS"
UBUNTU_IMG_NAME="ubuntu-24.04-minimal-cloudimg-amd64.img"
IMG_CACHE_DIR="/var/lib/vz/template/iso"
NUT_DEFAULT_PORT=3493
SSH_TIMEOUT=300
SSH_POLL_INTERVAL=5
SCRIPT_VERSION="1.0.0"
AUTO_GENERATE_PASSWORDS=false| Function | Signature | Notes |
|---|---|---|
generate_password |
length |
openssl rand + urandom fallback |
prompt_autogenerate_passwords |
— | whiptail yes/no; sets AUTO_GENERATE_PASSWORDS |
prompt_default |
VARNAME "text" "default" |
Enter = use default |
prompt_password |
VARNAME "text" |
read -s, confirm twice; auto-generates if enabled |
prompt_yes_no |
"question" default |
Returns 0=yes 1=no |
prompt_menu |
VARNAME "title" items... |
Numbered list, validated |
prompt_integer |
VARNAME "text" default min max |
Range-validated number |
| Function | What it checks |
|---|---|
check_root |
$EUID -eq 0 |
check_proxmox |
qm, pvesh, pveversion, pvesm, python3 exist |
check_dependencies |
ssh, scp, wget/curl, lsusb, whiptail, timeout, openssl, ssh-keygen, ip, sha256sum, dpkg |
check_architecture |
dpkg --print-architecture is amd64 |
get_next_vmid |
pvesh get /cluster/nextid |
list_storage_pools |
pvesm status --content images |
validate_vmid |
Check ID not already in use |
validate_bridge |
ip link show bridge exists |
Globals: VM_ID, VM_NAME, VM_STORAGE, VM_BRIDGE, VM_RAM, VM_CORES, VM_DISK_GB, VM_USER, VM_PASSWORD
| Prompt | Default |
|---|---|
| VM ID | next available |
| VM hostname | nut-server |
| Storage pool | first available |
| Network bridge | vmbr0 |
| RAM (MB) | 1024 |
| CPU cores | 1 |
| Disk size (GB) | 8 |
| VM username | ubuntu |
| VM password | (prompted, hidden or auto-generated) |
Shows confirmation table + prompt_yes_no before proceeding.
Globals: NUT_UPS_NAME, NUT_UPS_DESC, NUT_DRIVER, NUTWATCH_USER, NUTWATCH_PASS, NUT_MONITOR_USER, NUT_MONITOR_PASS, NUT_LISTEN_ADDR, NUT_LISTEN_PORT
| Prompt | Default |
|---|---|
| UPS name | ups |
| UPS description | My UPS |
| Driver | usbhid-ups (hardcoded) |
| Admin username | admin |
| Admin password | (prompted, hidden or auto-generated) |
| Monitor username | monuser |
| Monitor password | (prompted, hidden or auto-generated) |
| Listen address | 0.0.0.0 |
| Listen port | 3493 |
| Function | Description |
|---|---|
determine_storage_type |
Parses pvesm status to set DISK_EXT, DISK_REF_PREFIX, DISK_IMPORT based on storage backend (nfs/dir/cifs/btrfs vs lvm-thin/zfs) |
| Function | Description |
|---|---|
download_cloud_image |
wget with resume; skip if cached and SHA-256 checksum verifies |
inject_ssh_key |
Generate temp ed25519 keypair in /tmp/nut-setup-$$/; register trap EXIT cleanup |
generate_cloudinit_snippet |
Creates vendor cloud-init YAML in /var/lib/vz/snippets/ that installs qemu-guest-agent and sets the VM user password via chpasswd |
| Function | Description |
|---|---|
create_vm |
Full qm create + importdisk + set + resize + setup_cloud_init (from cloud-init.func) + vendor snippet sequence |
create_vm command sequence:
qm create $VM_ID --name $VM_NAME --memory $VM_RAM --cores $VM_CORES \
--net0 virtio,bridge=$VM_BRIDGE --ostype l26 --agent enabled=1 \
--serial0 socket --vga serial0 --onboot 1 --tags 'community-script;nut;network;ups'
qm importdisk $VM_ID $IMG_CACHE_DIR/$UBUNTU_IMG_NAME $VM_STORAGE --format <qcow2|raw>
qm set $VM_ID --scsihw virtio-scsi-pci --scsi0 ${DISK0_REF}
qm resize $VM_ID scsi0 ${VM_DISK_GB}G
qm set $VM_ID --boot c --bootdisk scsi0
setup_cloud_init "$VM_ID" "$VM_STORAGE" "$VM_NAME" "yes" "$VM_USER"
qm set $VM_ID --cicustom "vendor=local:snippets/nut-vm-${VM_ID}-cloudinit.yaml"| Function | Description |
|---|---|
detect_ups |
Parse lsusb, cross-ref UPS_VENDORS, interactive selection; falls back to manual entry or skip |
setup_usb_passthrough |
qm set $VM_ID --usb0 host=VENDOR:PRODUCT or host=BUS-PORT |
| Function | Description |
|---|---|
start_vm |
qm start $VM_ID |
get_vm_ip |
pvesh get /nodes/{node}/qemu/{VM_ID}/agent/network-get-interfaces with retry up to 2 min; falls back to manual entry |
wait_ssh |
Poll /dev/tcp/HOST/PORT with spinner; exit 1 on timeout |
| Function | Description |
|---|---|
build_nut_install_script |
Base64-encode all 5 NUT config files, build heredoc, then use Python to safely substitute variables (handles special chars in passwords) |
deploy_nut_script |
SCP to VM with retry loop + ssh sudo bash execution; parses NUT_TEST_OK / NUT_TEST_FAIL |
run_nut_install |
Orchestrate build + deploy |
verify_nut_post_reboot |
SSH upsc retry loop after VM reboot to confirm NUT server is responding |
Remote script steps:
- Wait for
cloud-init status --wait - Wait for apt lock (up to 60s)
apt-get update && apt-get install -y nut-server nut-client usbutils- Poll
lsusbfor UPS vendor ID (up to 12 × 5s retries) - Run
nut-scanner -Ufor auto-detection; if successful, writeups.confwith detected driver/port/vendorid/productid - If
nut-scannerfails, decode and write fallbackups.conffrom base64 - Decode and write remaining NUT config files (
nut.conf,upsd.conf,upsd.users,upsmon.conf) from base64 chown root:nut /etc/nut/*.conf && chmod 640 /etc/nut/*.confmkdir -p /var/run/nut && chown nut:nut /var/run/nutsystemctl enable nut-driver nut-server nut-monitor && systemctl restart ...- Poll
upsc $UPS_NAME@localhost(up to 12 × 5s retries) → printNUT_TEST_OKorNUT_TEST_FAIL
MODE=netserver
[$NUT_UPS_NAME]
driver = $NUT_DRIVER
port = auto
desc = "$NUT_UPS_DESC"
pollinterval = 2
LISTEN $NUT_LISTEN_ADDR $NUT_LISTEN_PORT
MAXAGE 15
STATEPATH /var/run/nut
[$NUTWATCH_USER]
password = $NUTWATCH_PASS
actions = SET
instcmds = ALL
[$NUT_MONITOR_USER]
password = $NUT_MONITOR_PASS
upsmon master
Permissions required:
chmod 640,chown root:nut— NUT refuses to start otherwise.
MONITOR $NUT_UPS_NAME@localhost:$NUT_LISTEN_PORT 1 $NUT_MONITOR_USER $NUT_MONITOR_PASS master
MINSUPPLIES 1
SHUTDOWNCMD "/sbin/shutdown -h +0"
POLLFREQ 5
POLLFREQALERT 5
HOSTSYNC 15
DEADTIME 15
POWERDOWNFLAG /etc/killpower
NOTIFYMSG ONLINE "UPS %s on line power"
NOTIFYMSG ONBATT "UPS %s on battery"
NOTIFYMSG LOWBATT "UPS %s battery is low"
NOTIFYMSG COMMOK "Communications with UPS %s established"
NOTIFYMSG COMMBAD "Communications with UPS %s lost"
NOTIFYMSG SHUTDOWN "UPS %s forcing system shutdown"
NOTIFYFLAG ONLINE SYSLOG+WALL
NOTIFYFLAG ONBATT SYSLOG+WALL
NOTIFYFLAG LOWBATT SYSLOG+WALL
RBWARNTIME 43200
NOCOMMWARNTIME 300
FINALDELAY 5
- Parse CLI flags (
--help,--version,--debug) - Print header banner (
header_info) - Source
build.func+cloud-init.funcfromcommunity-scripts/ProxmoxVEDvia curl check_root→check_proxmox→check_dependencies→check_architectureinject_ssh_key— generate temp keypair, registertrap EXITcleanupprompt_autogenerate_passwords— optional auto-generationcollect_vm_config— interactive VM prompts + confirmation loopcollect_nut_config— interactive NUT prompts- Print confirmed NUT settings summary + confirmation
download_cloud_image(with SHA-256 checksum verification)create_vm— full qm sequence +setup_cloud_init+ vendor snippetdetect_ups→setup_usb_passthrough(or skip)start_vmget_vm_ip(guest agent with retry)wait_sshbuild_nut_install_script+deploy_nut_script- Parse
NUT_TEST_OK/NUT_TEST_FAILfrom SSH output - Reboot VM (
qm reboot) and wait for SSH to come back verify_nut_post_reboot— confirmupscrespondsprint_summarytrap EXITfires: remove temp keys
╔═══════════════════════════════════════════════════════════╗
║ NUT VM Setup - Complete! ║
╠═══════════════════════════════════════════════════════════╣
║ VM ID: 100 ║
║ VM Name: nut-server ║
║ VM IP: 192.168.1.50 ║
║ ║
║ NUT Server: 192.168.1.50:3493 ║
║ UPS Name: ups ║
║ ║
║ Test command: ║
║ upsc ups@192.168.1.50 ║
╠═══════════════════════════════════════════════════════════╣
║ Client upsmon.conf snippet: ║
║ MONITOR ups@192.168.1.50:3493 1 monuser PASS slave ║
╚═══════════════════════════════════════════════════════════╝
| Scenario | Handling |
|---|---|
| VMID claimed between detection and creation | validate_vmid loops until an unused ID is chosen |
| Storage type (dir vs lvm-thin vs nfs vs btrfs) affects disk name | determine_storage_type sets DISK_EXT, DISK_REF_PREFIX, and DISK_IMPORT flags before import |
| Image partial download | Use wget -c (resume); verify SHA-256 checksum after download |
| Cached image checksum mismatch | Re-download if sha256sum -c fails |
vmbr0 doesn't exist |
Validate bridge with ip link show before VM creation |
| Two identical UPS models on same host | Use bus-port notation host=4-1 instead of vendor:product |
| UPS not plugged in yet | Offer: manual entry or skip passthrough |
| DHCP / guest agent slow to report IP | Retry pvesh get /nodes/.../network-get-interfaces for up to 2 min; fallback to manual whiptail prompt |
| Cloud-init not done when SSH opens | wait_ssh polls until port 22 is open; remote script waits for cloud-init status --wait |
| Apt lock held on first boot | Remote script polls /var/lib/dpkg/lock-frontend up to 60s before proceeding |
| NUT driver mismatch | Remote script runs nut-scanner -U and overrides driver if detected; fallback to user-selected driver; post-reboot verify_nut_post_reboot confirms |
Password with special chars ($, `, backslash, etc.) |
Base64-encode all config files in the heredoc, then decode on the VM; variable substitution done via Python str.replace to avoid shell escaping issues |
| Script interrupted mid-run | trap INT TERM kills spinner and prints interrupt message; trap EXIT cleans up temp SSH keys |
| Running on Proxmox cluster | Note: VM created on local node only |
| Non-amd64 architecture | check_architecture aborts with warning (Ubuntu cloud image is amd64-only) |
| Auto-generated passwords | generate_password uses openssl rand with fallback to /dev/urandom; passwords displayed in final summary whiptail + console output |
- Run on Proxmox host:
bash vm/nut-vm.sh - Walk through all prompts — confirm defaults work (just press Enter)
- Verify VM created:
qm list - Verify USB passthrough:
qm config <vmid> | grep usb - Run test command from summary:
upsc ups@<VM_IP> - From another machine:
upsc ups@<VM_IP>:3493— confirms netserver is accessible - Check services inside VM:
ssh ubuntu@<VM_IP> systemctl status nut-driver nut-server nut-monitor