← Learnings

SSH into your Mac, but only from home

I ssh into my Mac from an iPad downstairs, and the same Mac turns Remote Login off the moment it joins a cafe network. One 40-line script, one LaunchDaemon, a log that says what it did and why, no VPN, nothing to remember to toggle.

The whole article as a brief, if Claude Code is doing the typing
PROMPTCLAUDE CODE
Set up macOS so Remote Login (sshd) is ON only on my home network and OFF
everywhere else. Identify home by the default gateway's MAC address from the
ARP table (route -n get default, then arp -n), not the SSID (macOS 26 redacts
it from scripts) and not the subnet (10.x is reused everywhere). Read the MAC
now and put it in a HOME_ROUTER_MACS list at the top of the script.

Write /usr/local/sbin/ssh-home-guard.sh (create the directory; it does not
exist on a fresh Mac). It compares the gateway MAC to the list and drives
sshd with launchctl on system/com.openssh.sshd: enable + bootstrap
/System/Library/LaunchDaemons/ssh.plist to turn on, bootout + disable to
turn off. Do not use systemsetup -setremotelogin; it needs Full Disk Access
and fails silently from a daemon. Detect current state with launchctl print.

Log to /var/log/ssh-home-guard.log: an "observed gw= mac= sshd= want=" line
whenever that tuple differs from the last run (keep the last one in
/var/run/ssh-home-guard.state), a "flipped" line after a successful change,
a "FAILED" line with launchctl's output if the state did not change. Nothing
on quiet ticks. After each flip post a macOS notification to the console
user (launchctl asuser + osascript display notification) and, if NTFY_TOPIC
is set, curl a push to ntfy.sh.

Run it as a root LaunchDaemon at /Library/LaunchDaemons/com.dg.ssh-home-guard.plist:
RunAtLoad, WatchPaths on /private/var/run/resolv.conf and
/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist,
StartInterval 60. Give me one sudo install command that copies both files
root-owned and bootstraps the daemon, then verify with nc -z 127.0.0.1 22
and tail the log. Do not touch sshd_config.

Find the address only the home network has

The Wi-Fi name is the obvious identifier, and macOS 26 will not give it to a script without Location Services. The subnet is worse; 10.0.0.x is what half the routers on earth hand out. The router itself is unique, and the Mac already knows its hardware address.

zsh
$ route -n get default | awk '/gateway/{print $2}'
10.0.0.1
$ arp -n 10.0.0.1
? (10.0.0.1) at 4:2b:8c:1d:4e:6f on en0 ifscope [ethernet]
FIG 1 · THE ROUTER, BY ITS SERIAL NUMBER RATHER THAN ITS NAME

The gateway is the router, and the MAC address after at is burned into it. A cafe router shares your subnet by accident; sharing this string takes effort. That address is the whole test.

Turn Remote Login on or off to match

Remote Login, the switch in System Settings, is a program called sshd listening on port 22. The script reads the gateway's address, compares it with a list of home routers, and starts or stops sshd to match. It acts only when the state is wrong, and it writes a log line whenever what it sees changes and whenever it acts, so a quiet network leaves a quiet log.

SSH-HOME-GUARD.SHBASH
#!/bin/bash
HOME_ROUTER_MACS="a4:2b:8c:1d:4e:6f"   # space-separated; add more if needed
LOG=/var/log/ssh-home-guard.log
STATE=/var/run/ssh-home-guard.state   # last observation, so we log changes, not ticks
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }

# Normalize a MAC like "4:2b:8c:1d:4e:6f" -> "04:2b:8c:1d:4e:6f"
norm() { echo "$1" | tr 'A-F' 'a-f' | awk -F: '{for(i=1;i<=NF;i++){printf "%s%02s", (i>1?":":""), $i}; print ""}' | tr ' ' '0'; }

gw=$(route -n get default 2>/dev/null | awk '/gateway:/{print $2}')
mac=""
if [ -n "$gw" ]; then
  # ARP entry may not exist right after joining; nudge it.
  arp -n "$gw" 2>/dev/null | grep -q ' at ' || ping -c1 -W1 "$gw" >/dev/null 2>&1
  raw=$(arp -n "$gw" 2>/dev/null | awk '/ at /{print $4}')
  [ -n "$raw" ] && [ "$raw" != "(incomplete)" ] && mac=$(norm "$raw")
fi

want=off
for m in $HOME_ROUTER_MACS; do
  [ "$(norm "$m")" = "$mac" ] && want=on
done

# Current state: is sshd loaded in launchd? (systemsetup -setremotelogin needs
# Full Disk Access on modern macOS, so we drive launchd directly instead; the
# System Settings > Sharing > Remote Login toggle reflects the same state.)
SSHD=system/com.openssh.sshd
SSHD_PLIST=/System/Library/LaunchDaemons/ssh.plist
if launchctl print "$SSHD" >/dev/null 2>&1; then cur=on; else cur=off; fi

# Log every run whose observation differs from the last one (network change,
# someone toggling Sharing by hand, first run after boot) -- not every tick.
obs="gw=${gw:-none} mac=${mac:-none} sshd=$cur want=$want"
if [ "$obs" != "$(cat "$STATE" 2>/dev/null)" ]; then
  log "observed  $obs"
  echo "$obs" > "$STATE"
fi

if [ "$cur" != "$want" ]; then
  if [ "$want" = on ]; then
    out=$(launchctl enable "$SSHD" 2>&1; launchctl bootstrap system "$SSHD_PLIST" 2>&1)
  else
    out=$(launchctl bootout "$SSHD" 2>&1; launchctl disable "$SSHD" 2>&1)
  fi
  if launchctl print "$SSHD" >/dev/null 2>&1; then now=on; else now=off; fi
  if [ "$now" = "$want" ]; then
    log "flipped   remote login $cur -> $want${out:+  [$out]}"
  else
    log "FAILED    remote login $cur -> $want, still $now${out:+  [$out]}"
  fi
fi
FIG 2 · THE GUARD: READ THE ROUTER, SET REMOTE LOGIN

The first draft used systemsetup -setremotelogin. From a daemon on macOS 26 it exits quietly and changes nothing, because it wants Full Disk Access. The Remote Login toggle is a launchctl enable and bootstrap of com.openssh.sshd underneath, and that needs no permission.

Run the guard whenever the network changes

The guard script checks once per run, so a LaunchDaemon has to run it every time the network changes. macOS rewrites /private/var/run/resolv.conf whenever an interface joins or leaves a network, which makes that file a free trigger. The daemon watches it, runs at boot, and re-checks every sixty seconds in case a change slips past.

COM.DG.SSH-HOME-GUARD.PLISTXML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.dg.ssh-home-guard</string>
  <key>ProgramArguments</key>
  <array><string>/usr/local/sbin/ssh-home-guard.sh</string></array>
  <key>RunAtLoad</key><true/>
  <!-- fires on every network change (DNS config is rewritten on join/leave) -->
  <key>WatchPaths</key>
  <array>
    <string>/private/var/run/resolv.conf</string>
    <string>/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist</string>
  </array>
  <!-- safety net: re-check every 60s regardless -->
  <key>StartInterval</key><integer>60</integer>
  <key>StandardErrorPath</key><string>/var/log/ssh-home-guard.err</string>
</dict>
</plist>
FIG 3 · ROOT, AT BOOT, ON NETWORK CHANGE, AND EVERY MINUTE FOR LUCK

Put the files in place and check

Two files, written anywhere you like, then copied into the two places macOS expects them. Everything the guard creates on its own goes in /var/log.

WHERE THINGS LIVETREE
~/ssh-home-guard/                        # your working copy, anywhere
├── ssh-home-guard.sh                    # FIG 2; the notify function is added later
└── com.dg.ssh-home-guard.plist          # FIG 3

/usr/local/sbin/
└── ssh-home-guard.sh                    # installed copy; root-owned, mode 755

/Library/LaunchDaemons/
└── com.dg.ssh-home-guard.plist          # installed copy; root-owned, mode 644

/var/log/
├── ssh-home-guard.log                   # observations and flips, written by the script
└── ssh-home-guard.err                   # anything the script prints to stderr

/var/run/
└── ssh-home-guard.state                 # last observation; cleared at boot
FIG 4 · TWO FILES IN, TWO FILES OUT

Both copies go in as root, and bootstrap loads the daemon, which runs the first check right away. /usr/local/sbin does not exist on a fresh Mac, so the install creates it first.

zsh
$ sudo mkdir -p /usr/local/sbin
$ sudo install -o root -g wheel -m 755 ssh-home-guard.sh /usr/local/sbin/
$ sudo install -o root -g wheel -m 644 com.dg.ssh-home-guard.plist /Library/LaunchDaemons/
$ sudo launchctl bootstrap system /Library/LaunchDaemons/com.dg.ssh-home-guard.plist
FIG 5 · INSTALL: THE SCRIPT, THEN THE THING THAT RUNS IT

Two checks say whether it took. Port 22 answering means Remote Login is on, and the log says what the guard saw and what it did about it.

zsh
$ nc -z 127.0.0.1 22 && echo open || echo closed
Connection to 127.0.0.1 port 22 [tcp/ssh] succeeded!
open
$ tail -2 /var/log/ssh-home-guard.log
2026-08-24 11:34:57 observed  gw=10.0.0.1 mac=a4:2b:8c:1d:4e:6f sshd=off want=on
2026-08-24 11:34:57 flipped   remote login off -> on
FIG 6 · ON THE HOME NETWORK, SO REMOTE LOGIN IS ON

The log at /var/log/ssh-home-guard.log has three kinds of line and stays empty otherwise. An observed line appears whenever the gateway, the router, the state of sshd or the verdict changes, so a network you have never joined shows up as a new router address and nothing else. A flipped line records an action that took. A FAILED line records one that did not, with whatever launchctl said in brackets, and is the first place to look when Remote Login is not where you expect it.

Joining a phone hotspot turns Remote Login off within a minute and logs an observed line with the foreign router and a flipped line after it; walking back onto home Wi-Fi turns it on again. The Sharing pane shows the same state either way, because it reads the flag the script sets.

Get told when it flips

A daemon that silently turns Remote Login off is one you will debug for twenty minutes someday. One function, called after each flip, posts a banner to whoever is at the console and a push to a phone through ntfy. Root cannot draw a notification, so it runs osascript as you.

SSH-HOME-GUARD.SHBASH
NTFY_TOPIC="your-private-topic"   # set to "" to disable phone push

notify() {  # $1=title $2=body
  local user uid
  user=$(stat -f %Su /dev/console 2>/dev/null); uid=$(id -u "$user" 2>/dev/null)
  if [ -n "$uid" ] && [ "$user" != "root" ]; then
    launchctl asuser "$uid" sudo -u "$user" osascript \
      -e "display notification \"$2\" with title \"$1\" sound name \"Glass\"" >/dev/null 2>&1 &
  fi
  if [ -n "$NTFY_TOPIC" ]; then
    curl -s -m 5 -H "Title: $1" -d "$2" "https://ntfy.sh/$NTFY_TOPIC" >/dev/null 2>&1 &
  fi
}

# after the log line inside the flip branch:
if [ "$want" = on ]; then
  notify "SSH: Remote Login ON" "Home router detected (gw $gw). sshd listening on port 22."
else
  notify "SSH: Remote Login OFF" "Not on home network (gw ${gw:-none}). Port 22 closed."
fi
FIG 7 · THE BANNER THAT SAVES THE TWENTY MINUTES

The guard does not touch sshd_config, so a password still opens the door at home; a key in ~/.ssh/authorized_keys and PasswordAuthentication no is a separate five-minute step. The door is open exactly when you are home, closed everywhere else, and nothing about how you connect changes.

The script, the daemon and the log are also one package. brew install daksh-gargas/tap/ssh-home-guard, then sudo "$(brew --prefix)/bin/ssh-home-guard" install while on the home network, records the router you are on, installs the daemon and turns Remote Login on; ssh-home-guard status and ssh-home-guard log are the two checks from FIG 6 with names. The source is at github.com/daksh-gargas/ssh-home-guard.

TOOL: SSH-HOME-GUARD ↗