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
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.
$ 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]
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.
#!/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
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.
<?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>
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.
~/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
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.
$ 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
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.
$ 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
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.
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
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.