A .NET 8 service that monitors a NUT (Network UPS Tools) server and runs a shutdown script when UPS power is lost. Works on Windows and Linux.
NUT Server (Pi) This Client (any server)
┌──────────────┐ ┌──────────────────────────┐
│ upsd :3493 │◄── TCP poll ──────│ NutClient │
│ │ every 5s │ │
│ ups1, ups2 │ │ On battery → 60s timer │
└──────────────┘ │ Timer expires → script │
│ Low battery → immediate │
│ FSD → immediate │
│ Power restored → cancel │
└──────────────────────────┘
- Maintains a persistent TCP connection to a NUT server (port 3493) with
LOGINregistration - Polls
ups.statusevery 5 seconds (configurable) - On battery (
OB): starts a 60-second countdown - Low battery (
LB) or Forced Shutdown (FSD): immediate shutdown - Power restored during countdown: cancels shutdown
- Runs a configurable shutdown script (PowerShell on Windows, bash on Linux)
- Writes a status file (
nutclient-status.json) with current state and last 25 poll results
- NUT server accessible on the network (port 3493)
- No .NET SDK required — pre-built binaries are available
Download the latest release for your platform from GitHub Releases:
nutclient-win-x64.zip— Windowsnutclient-linux-x64.tar.gz— Linux x64nutclient-linux-arm64.tar.gz— Linux ARM64 (Raspberry Pi, Synology)
Linux:
tar xzf nutclient-linux-x64.tar.gz
cd linux-x64
sudo ./install.sh # installs to /opt/nutclient
sudo nano /opt/nutclient/nutclient.json # set NUT server host, UPS name, credentials
sudo systemctl start nutclient # start the service
sudo systemctl status nutclient # verify it's running
install.shcopiesnutclient.json.linux-exampleto/opt/nutclient/nutclient.json(the Linux template, not the Windows-defaultsnutclient.jsonin the archive). It registers the service but doesn't start it — edit the config first, then start manually. On reboot it'll start automatically.
Distro compatibility:
install.shis written for Debian-based distros (Debian, Ubuntu, Raspberry Pi OS) and has been tested there. It should also work on any systemd-based distro (RHEL, CentOS, Fedora, Rocky, Alma, openSUSE, Arch) since it only usessystemctland standard FHS paths. A few notes:
- RHEL / Fedora family: SELinux may block the shutdown script or log writes. If the service fails, check
sudo ausearch -m avcfor denials. For testing you can temporarily runsudo setenforce 0; for production, create a proper SELinux policy or setLogFileinnutclient.jsonto a writable path like/opt/nutclient/nutclient.log.- Synology DSM: has pre-installed NUT and its own init system —
install.shwon't work directly. Manual install: copyNutClientto/usr/local/binand create a startup entry via Task Scheduler or synoservice.- Alpine / OpenRC / runit / Void: no systemd, so
install.shbails out early. You'll need to write a distro-native service unit (OpenRC init script, runit sv directory, etc.) and copy the binary + config manually.
Windows (run PowerShell as Administrator):
Expand-Archive nutclient-win-x64.zip -DestinationPath C:\NutClient
cd C:\NutClient\win-x64
powershell -ExecutionPolicy Bypass -File install.ps1 # installs to C:\NutClient
notepad C:\NutClient\nutclient.json # set NUT server host, UPS name, credentials
Start-Service NutUpsMonitor # start the service
Get-Service NutUpsMonitor # verify it's runningNote:
install.ps1is not code-signed, so PowerShell's default execution policy will block it. Use-ExecutionPolicy Bypassas shown above, or runUnblock-File install.ps1first. The install script registers the Windows service but doesn't start it — edit the config first, then start manually the first time. On subsequent boots the service starts automatically.
The install scripts will:
- Copy the binary, config, and shutdown script to the right locations
- Install and enable the service (systemd or Windows service)
- Preserve existing config if upgrading
Requires .NET 8 SDK.
# Clone
git clone https://github.com/KD5RYN/nutclient.git
cd nutclient
# Build
dotnet publish nutclient -c Release -r linux-x64 -o publish
# or: -r win-x64, -r linux-arm64
# Install
cd publish
nano nutclient.json
sudo ./install.sh # Linux
# or: powershell -File install.ps1 # WindowsBefore relying on the service, run interactively to verify it connects:
# Windows
C:\NutClient\NutClient.exe
# Linux
/opt/nutclient/NutClientYou should see output like:
2026-04-10 14:30:41 NUT UPS Monitor started
2026-04-10 14:30:41 Monitoring ups1@your-nut-server.example.com:3493
With LogLevel: "events" (default), it will be silent during normal polling and only log when something happens. Press Ctrl+C to stop.
Windows:
Get-Service NutUpsMonitor # check status
Start-Service NutUpsMonitor # start
Stop-Service NutUpsMonitor # stop
Restart-Service NutUpsMonitor # restart
Get-Content C:\Scripts\nutclient.log -Tail 20 # view logs
Get-Content C:\Scripts\nutclient-status.json # quick statusTo uninstall: powershell -File C:\NutClient\uninstall-service.ps1
Linux:
sudo systemctl status nutclient # check status
sudo systemctl start nutclient # start
sudo systemctl stop nutclient # stop
sudo systemctl restart nutclient # restart
journalctl -u nutclient -f # view logs
cat /var/log/nutclient-status.json # quick statusTo uninstall: sudo systemctl disable --now nutclient && sudo rm -rf /opt/nutclient /etc/systemd/system/nutclient.service
The config file is at:
- Linux:
/opt/nutclient/nutclient.json - Windows:
C:\NutClient\nutclient.json
After editing, restart the service to pick up the changes:
Linux:
sudo nano /opt/nutclient/nutclient.json
sudo systemctl restart nutclient
sudo systemctl status nutclient # verify it's still runningWindows:
notepad C:\NutClient\nutclient.json
Restart-Service NutUpsMonitor
Get-Service NutUpsMonitor # verify it's still runninginstall.sh and install.ps1 both preserve your existing config if you re-run them later (e.g., for an upgrade). They only create a fresh template if no config exists at the destination.
If the service fails to start after a config change, check the log for parse errors:
- Linux:
journalctl -u nutclient -n 50 - Windows:
Get-Content C:\Scripts\nutclient.log -Tail 50
All settings are in nutclient.json, which must be in the same directory as the executable.
{
"NutServer": {
"Host": "your-nut-server.example.com",
"Port": 3493,
"UpsName": "ups1",
"Username": "monuser",
"Password": "CHANGE_ME"
},
"Monitoring": {
"PollIntervalSeconds": 5,
"ShutdownDelaySeconds": 60,
"ShutdownCommand": "powershell.exe",
"ShutdownArguments": "-ExecutionPolicy Bypass -File C:\\Scripts\\graceful-shutdown.ps1",
"LogFile": "C:\\Scripts\\nutclient.log",
"StatusFile": "C:\\Scripts\\nutclient-status.json",
"LogLevel": "events",
"LogMaxBytes": 1048576,
"DeadTimeSeconds": 30,
"BatteryChargePercent": null,
"BatteryRuntimeSeconds": null,
"InputVoltageMinWarn": null,
"LoadPercentWarn": null,
"PreShutdownCommand": null,
"PreShutdownArguments": null,
"PreShutdownDelaySeconds": 5
}
}| Setting | Description |
|---|---|
Host |
Hostname or IP of the NUT server |
Port |
NUT server port (default 3493) |
UpsName |
Name of the UPS to monitor (e.g., ups1, ups2) |
Username |
NUT username for authentication |
Password |
NUT password for authentication |
| Setting | Description | Default |
|---|---|---|
PollIntervalSeconds |
How often to check UPS status | 5 |
ShutdownDelaySeconds |
Seconds to wait on battery before shutting down. Set to 0 to disable the timer and rely on thresholds / LB / FSD only. |
60 |
ShutdownCommand |
Program to run for shutdown | Windows: powershell.exe, Linux: /bin/bash |
ShutdownArguments |
Arguments passed to shutdown command | Path to shutdown script |
LogFile |
Path to the log file | Windows: C:\Scripts\nutclient.log, Linux: /var/log/nutclient.log |
StatusFile |
Path to the status JSON file (leave empty for default next to exe) | Windows: C:\Scripts\nutclient-status.json, Linux: /var/log/nutclient-status.json |
BatteryChargePercent |
Shut down when battery charge drops to or below this % (on battery only) | null (disabled) |
BatteryRuntimeSeconds |
Shut down when estimated runtime drops to or below this many seconds (on battery only) | null (disabled) |
InputVoltageMinWarn |
Log a warning when input AC voltage drops below this value | null (disabled) |
LoadPercentWarn |
Log a warning when UPS load exceeds this % | null (disabled) |
DeadTimeSeconds |
If server is unreachable while last known on battery, shut down after this many seconds | 30 |
PreShutdownCommand |
Program to run before the main shutdown command (e.g., send alert, sync) | null (disabled) |
PreShutdownArguments |
Arguments for the pre-shutdown command | null |
PreShutdownDelaySeconds |
Seconds to wait between pre-shutdown and shutdown commands | 5 |
LogMaxBytes |
Rotate log file when it exceeds this size in bytes | 1048576 (1 MB) |
LogLevel |
"events" = only state changes, warnings, errors, shutdown; "all" = every poll |
"events" |
Log level: In events mode (default), the log is silent during normal AC operation — it only writes when something happens (power loss, restore, countdown, warnings, errors, shutdown). Use "all" for debugging to see every poll with UPS status. The status file is always updated every poll regardless of log level.
Threshold notes:
BatteryChargePercentandBatteryRuntimeSecondsonly trigger shutdown when the UPS is already on battery (OB). They won't trigger during normal charging.InputVoltageMinWarnandLoadPercentWarnare warning-only — they log but don't trigger shutdown.- Set to
nullto disable (default). The basic OB/LB/FSD status-based shutdown works without any thresholds configured.
Shutdown strategies — pick whichever fits your needs:
Default (timer-based): wait 60 seconds on battery, then shut down regardless of charge level.
"ShutdownDelaySeconds": 60,
"BatteryChargePercent": null,
"BatteryRuntimeSeconds": nullThreshold-only (charge %): disable the timer, shut down when battery drops below a percentage.
"ShutdownDelaySeconds": 0,
"BatteryChargePercent": 30,
"BatteryRuntimeSeconds": nullThreshold-only (runtime): disable the timer, shut down when estimated runtime drops below a threshold.
"ShutdownDelaySeconds": 0,
"BatteryChargePercent": null,
"BatteryRuntimeSeconds": 180Belt-and-suspenders: use all three — whichever triggers first wins.
"ShutdownDelaySeconds": 300,
"BatteryChargePercent": 20,
"BatteryRuntimeSeconds": 120In all cases, LB (low battery) and FSD (forced shutdown) from the UPS still trigger immediate shutdown as a safety net.
Dead time: If the NUT server becomes unreachable while the UPS was last known to be on battery, the client assumes the worst (power is still out, UPS is draining) and triggers shutdown after DeadTimeSeconds. This prevents a network failure during a power outage from leaving the server running until the UPS dies.
Pre-shutdown hook: Runs before the main shutdown script with the same arguments (reason, charge, runtime, status). Use it to send a final email alert, flush caches, notify users, or stop databases before the main shutdown script runs. The PreShutdownDelaySeconds gap gives the pre-shutdown command time to complete.
Log rotation: When the log file exceeds LogMaxBytes, it's copied to <logfile>.1 and the original is cleared. Only one rotated backup is kept.
The client runs a script when shutdown is triggered. Example scripts are in the scripts/ directory.
NutClient appends the following arguments to your configured ShutdownArguments:
| Argument | Position | Description |
|---|---|---|
| Reason | 1 | Why shutdown was triggered (see table below) |
| Battery Charge | 2 | Battery % at time of shutdown (-1 if unknown) |
| Battery Runtime | 3 | Estimated runtime in seconds (-1 if unknown) |
| UPS Status | 4 | Raw UPS status string (e.g., "OB LB") |
Reason values:
| Reason | Meaning |
|---|---|
timer_expired |
On battery for ShutdownDelaySeconds with no power restore |
low_battery |
UPS flagged LB (low battery) |
forced_shutdown |
UPS flagged FSD (forced shutdown) |
battery_charge |
Battery charge dropped to or below BatteryChargePercent threshold |
battery_runtime |
Estimated runtime dropped to or below BatteryRuntimeSeconds threshold |
dead_time |
NUT server unreachable while last known on battery for DeadTimeSeconds |
Default location: C:\Scripts\graceful-shutdown.ps1
The script receives arguments as named PowerShell parameters:
param(
[string]$Reason = "unknown",
[int]$BatteryCharge = -1,
[int]$BatteryRuntime = -1,
[string]$UpsStatus = ""
)What the example does:
- Logs the shutdown event with reason and battery info
- Stops all running Hyper-V VMs in parallel (if Hyper-V is installed), with a 3-minute timeout
- Shuts down Windows
Customize it to stop your own services, save application state, or take different actions based on the reason.
Default location: /opt/nutclient/scripts/graceful-shutdown.sh
The script receives arguments as positional parameters:
REASON="${1:-unknown}"
BATTERY_CHARGE="${2:--1}"
BATTERY_RUNTIME="${3:--1}"
UPS_STATUS="${4:-}"What the example does:
- Logs the shutdown event with reason and battery info
- Includes commented-out examples for stopping Docker containers and systemd services
- Runs
poweroff
install.shautomatically sets the executable bit when it copies the script. If you write your own shutdown script from scratch, runchmod +x graceful-shutdown.shyourself.
The client writes a nutclient-status.json file every poll cycle. This gives you a quick way to check current state without reading logs.
# Windows
type C:\Scripts\nutclient-status.json
# Linux
cat /var/log/nutclient-status.jsonExample output:
{
"server": "your-nut-server.example.com:3493",
"upsName": "ups1",
"state": "Online",
"currentStatus": "OL",
"batteryCharge": 100,
"batteryRuntime": 608,
"inputVoltage": 115.6,
"upsLoad": 49,
"lastPoll": "2026-04-10 14:30:41",
"consecutiveFailures": 0,
"history": [
{ "time": "2026-04-10 14:30:41", "status": "OL", "event": "poll" },
{ "time": "2026-04-10 14:30:36", "status": "OL", "event": "poll" }
]
}| Field | Description |
|---|---|
state |
Human-readable: Online, On Battery, Shutting Down, Error, Access Denied |
currentStatus |
Raw UPS status string (e.g., OL, OB, OB LB) |
batteryCharge |
Battery charge % (null if not available) |
batteryRuntime |
Estimated runtime in seconds (null if not available) |
inputVoltage |
AC input voltage (null if InputVoltageMinWarn not configured) |
upsLoad |
UPS load % (null if LoadPercentWarn not configured) |
consecutiveFailures |
Number of failed polls in a row (0 = healthy) |
onBatterySince |
Timestamp when battery mode started (null if on AC) |
shutdownInSeconds |
Seconds until shutdown triggers (null if not counting down) |
history |
Last 25 status entries, newest first |
- Connection failures: Retries with exponential backoff (5s, 10s, 20s, 40s, up to 60s max)
- Bad credentials: Stops immediately — fix
nutclient.jsonand restart the service - Server unreachable: Logs a warning after 6 consecutive failures, continues retrying
- Connection restored: Logs recovery and resets to normal polling interval
The default nutclient.service includes a conservative set of systemd hardening directives that block exotic attacks but don't restrict what your shutdown script can do:
NoNewPrivileges,ProtectKernelTunables,ProtectKernelModulesProtectControlGroups,LockPersonalityRestrictRealtime,RestrictSUIDSGID
If your shutdown script is simple (e.g., just runs poweroff and writes a log file), you can opt into stronger restrictions by editing /etc/systemd/system/nutclient.service and uncommenting some or all of the directives in the "Optional aggressive hardening" section. The most useful ones:
| Directive | What it blocks | What it might break |
|---|---|---|
ProtectSystem=strict |
Writes to /usr, /boot, /etc |
Scripts that update /etc config or /var/lib state |
ProtectHome=yes |
Access to /home, /root |
Scripts that write to a user home dir |
PrivateTmp=yes |
Shared /tmp access |
Scripts that read other processes' temp files |
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
Raw sockets, Bluetooth, etc. | Scripts using exotic networking |
MemoryDenyWriteExecute=yes |
Writable+executable memory | Some interpreters with JIT |
CapabilityBoundingSet=CAP_SYS_BOOT CAP_KILL |
All capabilities except boot/kill | Mount, raw network, ptrace, ZFS commands, etc. |
ReadWritePaths=/var/log /opt/nutclient |
Required if using ProtectSystem=strict |
— |
After editing the unit file:
sudo systemctl daemon-reload
sudo systemctl restart nutclient
sudo systemctl status nutclient # verify it actually startedTest the shutdown script after enabling hardening — restrictions are silent, and you only find out the script is broken when you actually need it. Run a real or simulated power loss and confirm nutclient-shutdown.log (or whatever log your script writes) shows the script ran successfully.
nutclient/ <- Main application
├── NutClient.csproj <- .NET 8 project file
├── Program.cs <- Entry point — loads config, sets up DI, detects OS
├── NutMonitorService.cs <- BackgroundService — poll loop, I/O, shutdown execution
├── UpsStateMachine.cs <- Pure state machine — all decision logic, no I/O
├── NutConnection.cs <- TCP client for NUT protocol (connect, auth, query)
├── Config.cs <- Configuration model classes
├── Models.cs <- Shared data models (UpsData, StatusSnapshot, etc.)
├── nutclient.json <- Config file (Windows defaults)
├── nutclient.json.linux-example
├── scripts/
│ ├── graceful-shutdown.ps1 <- Example Windows shutdown script
│ └── graceful-shutdown.sh <- Example Linux shutdown script
├── install-service.ps1 <- Installs as Windows service
├── uninstall-service.ps1 <- Removes Windows service
└── nutclient.service <- systemd unit file for Linux
nutclient.tests/ <- Test suite
├── NutClient.Tests.csproj
├── MockNutServer.cs <- TCP server that speaks NUT protocol for tests
├── NutConnectionTests.cs <- Connection, auth, response parsing, error handling
├── UpsStateMachineTests.cs <- State transitions, thresholds, timers, dead time
└── BackoffTests.cs <- Exponential backoff calculation
The core logic is split into two layers:
-
UpsStateMachine— Pure decision logic with no I/O. Takes UPS data in, returns decisions (log messages, shutdown actions) out. Uses .NET 8'sTimeProviderfor testable time. This is where all the state transitions, threshold checks, timer logic, and dead time detection live. -
NutMonitorService— Thin orchestration shell. Runs the poll loop, callsNutConnectionto fetch data, feeds it to the state machine, and executes the resulting decisions (logging, writing files, running shutdown scripts).
This separation means the state machine can be tested with a fake clock and no network, while NutConnection is tested against a MockNutServer.
# Build (debug)
dotnet build nutclient
# Build for deployment
dotnet publish nutclient -c Release -r linux-x64 -o publish
dotnet publish nutclient -c Release -r win-x64 -o publish
dotnet publish nutclient -c Release -r linux-arm64 -o publish# Run all tests
dotnet test nutclient.tests
# Run with detailed output
dotnet test nutclient.tests --verbosity normal
# Run a specific test class
dotnet test nutclient.tests --filter "FullyQualifiedName~UpsStateMachineTests"
# Run a specific test
dotnet test nutclient.tests --filter "FullyQualifiedName~TimerExpiry_TriggersShutdown"89 tests across 4 files:
| File | Tests | What's covered |
|---|---|---|
NutConnectionTests |
19 | TCP connection, auth success/failure, variable queries, error classification (Transient vs AccessDenied vs Protocol), server disconnect, persistent connection, LOGIN registration |
UpsStateMachineTests |
41 | OL/OB/LB/FSD state transitions, power restore cancels shutdown, timer expiry, disabled timer (ShutdownDelaySeconds=0), battery charge/runtime thresholds, thresholds ignored on AC, dead time (comms loss while on battery), input voltage/load warnings, combined status flags (OL CHRG, OB LB DISCHRG), history capping, shutdown-only-once, first-connect/startup-notice messages |
SanitizeUpsStatusTests |
20 | UPS status string parsing and sanitization (parameterized) |
BackoffTests |
9 | Exponential backoff formula (parameterized), max cap at 60s, reset after success |
Tests run automatically on every push and pull request via GitHub Actions (ci.yml). Contributors can see test results directly on their PRs.
Tests use:
MockNutServer— A real TCP listener on localhost (random port) that speaks the NUT text protocol. Tests configure what responses it returns.FakeTimeProvider— .NET 8's built-in fake clock (Microsoft.Extensions.Time.Testing). Lets tests advance time without real waits, making timer and dead time tests instant.
To test a new state machine behavior:
[Fact]
public void MyNewBehavior_DoesTheThing()
{
// _sm and _clock are set up in the constructor
_sm.HandleStatus(Status("OB")); // put it on battery
_clock.Advance(TimeSpan.FromSeconds(10)); // advance time
var decision = _sm.HandleStatus(Status("OB", charge: 50));
Assert.Null(decision.Shutdown); // or Assert.NotNull
Assert.Contains(decision.LogMessages, m => m.Contains("expected text"));
}To test NutConnection against the mock server:
[Fact]
public async Task MyNewConnectionTest()
{
_server.Variables["ups.status"] = "OB"; // configure mock response
using var conn = CreateConnection();
await conn.ConnectAsync();
var status = await conn.GetVariableAsync("ups1", "ups.status");
Assert.Equal("OB", status);
}NutClient is released under the MIT License. Free to use, modify, and distribute for any purpose — commercial or personal. Only requirement is to keep the copyright notice in derivative works.