Skip to content

naeem-gitonga/shutdown-sync

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Go Shutdown

**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

Prerequisites

  • Go 1.21 or later
  • SSH key configured for the remote host (ssh-copy-id user@remote-host)

Local Development

Initialize the module (first time only):

go mod init shutdown-sync

Download dependencies:

go mod tidy

Project Structure

This 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.

Building the App

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.

First-time setup

Make the build script executable:

chmod +x build.sh

This 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

./build.sh

This script:

  1. Creates the ShutdownSync.app/Contents/MacOS/ directory structure
  2. Generates the Info.plist configuration file
  3. Compiles the Go code into the app bundle

Run

Double-click ShutdownSync.app in Finder, or from terminal:

open ShutdownSync.app

You will know it's running if you run:

$ pgrep -fl shutdown-sync
2893 /Users/YOUR_NAME/projects/shutdown-sync/ShutdownSync.app/Contents/MacOS/shutdown-sync

And you see something like the previous output. If there is no output it is not running.

Shutdown Sync via LogoutHook (Recommended)

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 LogoutHook

Uninstall the LogoutHook:

sudo defaults delete com.apple.loginwindow LogoutHook

View logs:

cat /tmp/shutdownsync.log

Run at Login (LaunchAgent) - Optional

The 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 uninstall

Alternative manual install:

cp com.local.shutdownsync.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.local.shutdownsync.plist

Manual uninstall:

launchctl unload ~/Library/LaunchAgents/com.local.shutdownsync.plist
rm ~/Library/LaunchAgents/com.local.shutdownsync.plist

Check if it's running:

launchctl list | grep shutdownsync

Note: 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.

Notes on reliability and signals ⚠️

  • 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.
  • ExitTimeOut in the bundled com.local.shutdownsync.plist has 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 --confirm when not using --dry-run. This prevents accidental destructive runs when invoking --hook manually.
  • 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.log and system logs for running ssh: and ssh error: messages added to assist debugging.

System Install (LaunchDaemon) - Recommended for Shutdown Delivery

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-daemon

The LaunchDaemon runs in hook mode and includes --confirm so shutdown commands will execute.

Reload the daemon after rebuilding:

sudo ./build.sh reload-daemon

Uninstall the daemon:

sudo ./build.sh uninstall-daemon

Verify status & logs:

sudo launchctl list | grep com.local.shutdownsync
sudo launchctl print system/com.local.shutdownsync
sudo tail -n 200 /var/log/shutdownsync.log

Security 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, set UserName in packaging/com.local.shutdownsync.daemon.plist and create that user before installing.
  • The daemon binary will be copied to /Library/Application Support/ShutdownSync/shutdown-sync during install.
  • Ensure SSH keys and sudoers for the remote host are configured appropriately (passwordless sudo for shutdown if required).

Remote Machine Setup

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.

Configure Passwordless Sudo for Shutdown

  1. 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.

  2. 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 ubuntu with your username):

    ubuntu ALL=(ALL) NOPASSWD: /usr/sbin/shutdown
    

    This grants the user permission to run only the shutdown command without a password—all other sudo commands still require a password.

  3. Save and exit:

    • Press Ctrl + O to save, then Enter to confirm
    • Press Ctrl + X to exit
  4. Test that it works (this should run without prompting for a password):

    sudo /usr/sbin/shutdown --help

Testing Instructions

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."

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published