--- # NUT (Network UPS Tools) Setup Playbook # Run from laptop: ansible-playbook -i inventory nut-setup.yml # # Prerequisites: # - UPS physically connected to server via USB # - SSH access to target server # # Variables to customize: # - ups_name: logical name for the UPS in NUT # - ups_password: password for upsmon user # - uptime_kuma_push_url: your Uptime Kuma push monitor URL - name: Setup NUT for CyberPower UPS hosts: nodito become: true vars: ups_name: cyberpower ups_desc: "CyberPower CP900EPFCLCD" ups_driver: usbhid-ups ups_port: auto ups_user: counterweight ups_password: "changeme" # TODO: use ansible-vault in production ups_offdelay: 120 # Seconds after shutdown command before UPS cuts outlet power ups_ondelay: 30 # Seconds after mains returns before UPS restores outlet power # Note: Shutdown threshold is controlled by UPS's battery.runtime.low (default 300s = 5 min) uptime_kuma_push_url: "https://uptime.example.com/api/push/xxxxx" tasks: # ------------------------------------------------------------------ # Installation # ------------------------------------------------------------------ - name: Install NUT packages ansible.builtin.apt: name: - nut - nut-client - nut-server state: present update_cache: true # ------------------------------------------------------------------ # Verify UPS is detected (informational) # ------------------------------------------------------------------ - name: Check if UPS is detected via USB ansible.builtin.shell: lsusb | grep -i cyber register: lsusb_output changed_when: false failed_when: false - name: Display USB detection result ansible.builtin.debug: msg: "{{ lsusb_output.stdout | default('UPS not detected via USB - ensure it is plugged in') }}" - name: Reload udev rules (NUT installs rules but they need triggering for already-plugged devices) ansible.builtin.shell: | udevadm control --reload-rules udevadm trigger --subsystem-match=usb --action=add changed_when: true - name: Verify USB device has nut group permissions ansible.builtin.shell: | # Find the UPS device and check its permissions BUS_DEV=$(lsusb | grep -i cyber | grep -oP 'Bus \K\d+|Device \K\d+' | tr '\n' '/' | sed 's/\/$//') if [ -n "$BUS_DEV" ]; then BUS=$(echo $BUS_DEV | cut -d'/' -f1) DEV=$(echo $BUS_DEV | cut -d'/' -f2) ls -la /dev/bus/usb/$BUS/$DEV else echo "UPS device not found" exit 1 fi register: usb_permissions changed_when: false - name: Display USB permissions ansible.builtin.debug: msg: "{{ usb_permissions.stdout }} — should show 'root nut', not 'root root'" - name: Scan for UPS with nut-scanner ansible.builtin.command: nut-scanner -U register: nut_scanner_output changed_when: false failed_when: false - name: Display nut-scanner result ansible.builtin.debug: msg: "{{ nut_scanner_output.stdout_lines }}" # ------------------------------------------------------------------ # Configuration files # ------------------------------------------------------------------ - name: Configure NUT mode (standalone) ansible.builtin.copy: dest: /etc/nut/nut.conf content: | # Managed by Ansible MODE=standalone owner: root group: nut mode: "0640" notify: Restart NUT services - name: Configure UPS device ansible.builtin.copy: dest: /etc/nut/ups.conf content: | # Managed by Ansible [{{ ups_name }}] driver = {{ ups_driver }} port = {{ ups_port }} desc = "{{ ups_desc }}" offdelay = {{ ups_offdelay }} ondelay = {{ ups_ondelay }} owner: root group: nut mode: "0640" notify: Restart NUT services - name: Configure upsd to listen on localhost ansible.builtin.copy: dest: /etc/nut/upsd.conf content: | # Managed by Ansible LISTEN 127.0.0.1 3493 owner: root group: nut mode: "0640" notify: Restart NUT services - name: Configure upsd users ansible.builtin.copy: dest: /etc/nut/upsd.users content: | # Managed by Ansible [{{ ups_user }}] password = {{ ups_password }} upsmon master owner: root group: nut mode: "0640" notify: Restart NUT services - name: Configure upsmon ansible.builtin.copy: dest: /etc/nut/upsmon.conf content: | # Managed by Ansible MONITOR {{ ups_name }}@localhost 1 {{ ups_user }} {{ ups_password }} master MINSUPPLIES 1 SHUTDOWNCMD "/sbin/shutdown -h +0" POLLFREQ 5 POLLFREQALERT 5 HOSTSYNC 15 DEADTIME 15 POWERDOWNFLAG /etc/killpower # Notifications NOTIFYMSG ONLINE "UPS %s on line power" NOTIFYMSG ONBATT "UPS %s on battery" NOTIFYMSG LOWBATT "UPS %s battery is low" NOTIFYMSG FSD "UPS %s: forced shutdown in progress" NOTIFYMSG COMMOK "Communications with UPS %s established" NOTIFYMSG COMMBAD "Communications with UPS %s lost" NOTIFYMSG SHUTDOWN "Auto logout and shutdown proceeding" NOTIFYMSG REPLBATT "UPS %s battery needs replacing" # Log all events to syslog (upsmon handles LB shutdown automatically) NOTIFYFLAG ONLINE SYSLOG NOTIFYFLAG ONBATT SYSLOG NOTIFYFLAG LOWBATT SYSLOG NOTIFYFLAG FSD SYSLOG NOTIFYFLAG COMMOK SYSLOG NOTIFYFLAG COMMBAD SYSLOG NOTIFYFLAG SHUTDOWN SYSLOG NOTIFYFLAG REPLBATT SYSLOG owner: root group: nut mode: "0640" notify: Restart NUT services # NOTE: No upssched configuration needed. The UPS sets LB (Low Battery) flag # when runtime < battery.runtime.low (default 300s) or charge < battery.charge.low (default 10%). # upsmon handles LB automatically and triggers shutdown—no custom scripts required. # NOTE: /lib/systemd/system-shutdown/nutshutdown is provided by the NUT package. # It already checks for the killpower flag and runs `upsdrvctl shutdown` to tell # the UPS to cut outlet power. No need to create or modify it. - name: Verify late-stage shutdown script exists ansible.builtin.stat: path: /lib/systemd/system-shutdown/nutshutdown register: nutshutdown_script - name: Warn if nutshutdown script is missing ansible.builtin.debug: msg: "WARNING: /lib/systemd/system-shutdown/nutshutdown not found. UPS may not cut power after shutdown, breaking auto-restart." when: not nutshutdown_script.stat.exists # ------------------------------------------------------------------ # Services # Note: nut-driver-enumerator reads ups.conf and starts drivers via nut-driver@.service # ------------------------------------------------------------------ - name: Enable and start NUT driver enumerator ansible.builtin.systemd: name: nut-driver-enumerator enabled: true state: started - name: Enable and start NUT server ansible.builtin.systemd: name: nut-server enabled: true state: started - name: Enable and start NUT monitor ansible.builtin.systemd: name: nut-monitor enabled: true state: started # ------------------------------------------------------------------ # Uptime Kuma heartbeat monitoring # ------------------------------------------------------------------ - name: Create UPS heartbeat script ansible.builtin.copy: dest: /usr/local/bin/ups-heartbeat.sh content: | #!/bin/bash # UPS heartbeat for Uptime Kuma - Managed by Ansible STATUS=$(upsc {{ ups_name }}@localhost ups.status 2>/dev/null) if [[ -z "$STATUS" ]]; then # Cannot communicate with UPS curl -fsS "{{ uptime_kuma_push_url }}?status=down&msg=UPS%20communication%20lost" > /dev/null 2>&1 elif [[ "$STATUS" == *"OL"* ]]; then # On line power curl -fsS "{{ uptime_kuma_push_url }}?status=up&msg=UPS%20on%20mains" > /dev/null 2>&1 else # On battery or other state curl -fsS "{{ uptime_kuma_push_url }}?status=down&msg=UPS%20on%20battery%20($STATUS)" > /dev/null 2>&1 fi owner: root group: root mode: "0755" - name: Setup cron job for UPS heartbeat ansible.builtin.cron: name: "UPS heartbeat to Uptime Kuma" minute: "*" job: "/usr/local/bin/ups-heartbeat.sh" user: root # ------------------------------------------------------------------ # Verification # ------------------------------------------------------------------ - name: Verify NUT can communicate with UPS ansible.builtin.command: upsc {{ ups_name }}@localhost register: upsc_output changed_when: false failed_when: false - name: Display UPS status ansible.builtin.debug: msg: "{{ upsc_output.stdout_lines }}" - name: Get UPS status summary ansible.builtin.shell: | echo "Status: $(upsc {{ ups_name }}@localhost ups.status 2>/dev/null)" echo "Battery: $(upsc {{ ups_name }}@localhost battery.charge 2>/dev/null)%" echo "Runtime: $(upsc {{ ups_name }}@localhost battery.runtime 2>/dev/null)s" echo "Load: $(upsc {{ ups_name }}@localhost ups.load 2>/dev/null)%" register: ups_summary changed_when: false - name: Display UPS summary ansible.builtin.debug: msg: "{{ ups_summary.stdout_lines }}" - name: Verify low battery thresholds ansible.builtin.shell: | echo "Runtime threshold: $(upsc {{ ups_name }}@localhost battery.runtime.low 2>/dev/null)s" echo "Charge threshold: $(upsc {{ ups_name }}@localhost battery.charge.low 2>/dev/null)%" echo "LB triggers when runtime < threshold OR charge < threshold" register: thresholds changed_when: false - name: Display low battery thresholds ansible.builtin.debug: msg: "{{ thresholds.stdout_lines }}" # ------------------------------------------------------------------ # Handlers # ------------------------------------------------------------------ handlers: - name: Restart NUT services ansible.builtin.systemd: name: "{{ item }}" state: restarted loop: - nut-driver-enumerator - nut-server - nut-monitor