**This project started with a noble cause. I wanted to shutdown my remote any time I shut down my local machine. Well, I started with creating a Go application that would run with the LogoutHook. Then I learned about the LaunchAgents/LaunchDaemons with launchd. I learned about plist and how to create daemons. Then I went back to try the LogoutHook one last time. The LaunchDaemons work but my issue was that MacOS terminates certain processes that I might require in my script in order to effectively shutdown the remote machine. I spent a weekend learning about "MacOS development" and it was time well spent. This project is dead at this point. If there were a way to prioritize my daemon so that is it first to be executed in the shutdown order that would probably work. I haven't figured out a reliable way to ensure that it is the first thing to run when a SIGTERM signal is sent. **
This is a small utility writen in Go used to listen for the shutdown signal from my local machine (MacbookPro) and if my remote machine (DGX Spark) is connected and on it will send the shutdown signal to it as well.
Simple! And effective.
--Naeem Gitonga
- Go 1.21 or later
- SSH key configured for the remote host (
ssh-copy-id user@remote-host)
Initialize the module (first time only):
go mod init shutdown-syncDownload dependencies:
go mod tidyThis project follows the standard Go project layout:
go-shutdown/
├── cmd/
│ └── shutdown-sync/
│ └── main.go ← application entry point
├── internal/
│ ├── dialog/
│ │ └── dialog.go ← macOS native dialog functions
│ └── ssh/
│ ├── ssh.go ← SSH command execution
│ └── ssh_test.go ← unit tests
├── build.sh ← build automation script
├── go.mod
├── go.sum
└── README.md
| Folder | Purpose |
|---|---|
cmd/ |
Entry points for executables. Each subfolder is a separate binary. |
internal/ |
Private packages that can only be imported within this project. |
macOS apps require a specific folder structure called an "app bundle":
ShutdownSync.app/
└── Contents/
├── Info.plist ← tells macOS how to run the app
└── MacOS/
└── shutdown-sync ← the compiled binary
The go build command only creates the binary—it doesn't create the folder structure or Info.plist. The build.sh script automates all of this.
Make the build script executable:
chmod +x build.shThis marks the file as executable so you can run ./build.sh directly. Without it, you'd have to run bash build.sh instead.
It has already be ran. You should be able to clone this repo and run the build script using ./build.sh.
./build.shThis script:
- Creates the
ShutdownSync.app/Contents/MacOS/directory structure - Generates the
Info.plistconfiguration file - Compiles the Go code into the app bundle
Double-click ShutdownSync.app in Finder, or from terminal:
open ShutdownSync.appYou will know it's running if you run:
$ pgrep -fl shutdown-sync
2893 /Users/YOUR_NAME/projects/shutdown-sync/ShutdownSync.app/Contents/MacOS/shutdown-syncAnd you see something like the previous output. If there is no output it is not running.
macOS provides a LogoutHook mechanism that runs a command when the user logs out, which includes shutdown and restart. This is the most reliable way to trigger the remote shutdown.
The binary supports a --hook mode that executes the SSH command immediately and exits:
shutdown-sync --hook --remote=user@host --command="sudo /usr/sbin/shutdown -h now"| Flag | Description |
|---|---|
--hook |
Run in hook mode: execute immediately and exit |
--remote |
Remote SSH host (required in hook mode) |
--command |
Command to run on remote host (default: sudo /usr/sbin/shutdown -h now) |
Install the LogoutHook:
sudo defaults write com.apple.loginwindow LogoutHook "/path/to/ShutdownSync.app/Contents/MacOS/shutdown-sync --hook --remote=user@host"Replace /path/to/ with the actual path to your ShutdownSync.app, and user@host with your remote SSH host.
Verify installation:
sudo defaults read com.apple.loginwindow LogoutHookUninstall the LogoutHook:
sudo defaults delete com.apple.loginwindow LogoutHookView logs:
cat /tmp/shutdownsync.logThe build script also generates a com.local.shutdownsync.plist file that runs ShutdownSync as a LaunchAgent with interactive dialogs. However, this method is less reliable for shutdown sync because macOS may kill LaunchAgents without sending SIGTERM during shutdown.
The LaunchAgent is useful if you want the interactive dialog prompts at login to configure the remote host dynamically.
Install the LaunchAgent (recommended via the build script):
The build.sh script generates the com.local.shutdownsync.plist. Use its helper commands to install/reload/uninstall the LaunchAgent easily:
# Build the app and generate the plist
./build.sh
# Install the LaunchAgent for the current user (copies plist and loads it)
./build.sh install
# Reload the LaunchAgent after rebuilding
./build.sh reload
# Uninstall the LaunchAgent
./build.sh uninstallAlternative manual install:
cp com.local.shutdownsync.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.local.shutdownsync.plistManual uninstall:
launchctl unload ~/Library/LaunchAgents/com.local.shutdownsync.plist
rm ~/Library/LaunchAgents/com.local.shutdownsync.plistCheck if it's running:
launchctl list | grep shutdownsyncNote: If you move the project folder, you'll need to rebuild (
./build.sh) and reinstall the LaunchAgent since the plist contains the absolute path to the executable.
- The agent now listens for SIGTERM, SIGHUP, and SIGQUIT in addition to the default interrupt, which improves chances of sending the remote shutdown during various logout/shutdown scenarios.
ExitTimeOutin the bundledcom.local.shutdownsync.plisthas been increased to 30 seconds to allow more time for the SSH command to complete during system shutdown.- For the most reliable delivery, prefer installing the LaunchDaemon (see next section). If you prefer a LaunchAgent for interactive dialogs, make sure the plist is installed and contains the correct absolute path to the built binary.
- Safety: Hook mode will refuse to execute destructive commands (e.g.,
shutdown,reboot,halt) unless you explicitly pass--confirmwhen not using--dry-run. This prevents accidental destructive runs when invoking--hookmanually. - Resilience: The SSH command now retries up to 3 times with exponential backoff for transient failures (100ms, 200ms, 400ms). If it still fails it will log the error in
/tmp/shutdownsync.log. - If you still see missed shutdowns, check
/tmp/shutdownsync.logand system logs forrunning ssh:andssh error:messages added to assist debugging.
For reliable delivery at system logout/shutdown (even when no GUI session is present), install the provided LaunchDaemon. This requires sudo and installs a system-level service that runs outside user sessions.
Install the LaunchDaemon:
# Build first
./build.sh
# Install system daemon (requires sudo)
sudo ./build.sh install-daemonThe LaunchDaemon runs in hook mode and includes --confirm so shutdown commands will execute.
Reload the daemon after rebuilding:
sudo ./build.sh reload-daemonUninstall the daemon:
sudo ./build.sh uninstall-daemonVerify status & logs:
sudo launchctl list | grep com.local.shutdownsync
sudo launchctl print system/com.local.shutdownsync
sudo tail -n 200 /var/log/shutdownsync.logSecurity notes:
- The daemon runs as root by default (remove
<key>UserName</key>from the plist). If you prefer a dedicated system user for SSH key isolation, setUserNameinpackaging/com.local.shutdownsync.daemon.plistand create that user before installing. - The daemon binary will be copied to
/Library/Application Support/ShutdownSync/shutdown-syncduring install. - Ensure SSH keys and sudoers for the remote host are configured appropriately (passwordless sudo for shutdown if required).
The shutdown command requires root privileges on Linux. When ShutdownSync sends the shutdown command via SSH, it runs non-interactively, meaning there's no way to enter a password when sudo prompts for one.
To allow passwordless shutdown on the remote machine (e.g., DGX Spark), you need to configure sudoers to permit the shutdown command without a password.
-
SSH into your remote machine:
ssh ubuntu@10.0.0.25
Or on the host machine open a terminal and run the following command:
which shutdown
That will tell you the location of the utility.
-
Open the sudoers file with
visudo(this safely edits the file with syntax checking):sudo visudo
If it opens with nano or some other editor Add the following line at the end of the file (replace
ubuntuwith your username):ubuntu ALL=(ALL) NOPASSWD: /usr/sbin/shutdownThis grants the user permission to run only the shutdown command without a password—all other
sudocommands still require a password. -
Save and exit:
- Press
Ctrl + Oto save, thenEnterto confirm - Press
Ctrl + Xto exit
- Press
-
Test that it works (this should run without prompting for a password):
sudo /usr/sbin/shutdown --help
Run all tests with verbose output:
go test -v ./...| Command | Description |
|---|---|
go test ./... |
Run all tests (quiet output) |
go test -v ./... |
Run all tests (verbose—shows each test name) |
go test ./internal/ssh |
Run tests for a specific package |
go test -v -run Success ./... |
Run only tests with "Success" in the name |
The ./... pattern means "current directory and all subdirectories."