Set Up Remote Access Through a VPS
Follow this guide when the Mac running herdr is behind NAT and Heeler cannot reach it directly.
The resulting connection path is:
iPhone -> VPS public SSH port -> VPS 127.0.0.1:2222 -> Mac SSH port 22The VPS exposes only its normal SSH service. Port 2222 remains on the VPS
loopback interface and is never opened in a public firewall.
For design rationale, threat boundaries, key rotation, and a full migration runbook, see VPS Jump Host and Reverse-Tunnel Deployment.
Before You Start
You need:
- an iPhone with Heeler installed;
- a Mac with herdr installed;
- administrator access to a maintained Debian or Ubuntu VPS;
- the VPS public IP address;
- the VPS SSH service port, normally
22; - a recovery path to the VPS, such as the provider console.
Keep the current VPS administrator session open until the restricted accounts have passed validation.
Choose values for these placeholders:
| Placeholder | Example | Meaning |
|---|---|---|
VPS_PUBLIC_IP | 203.0.113.10 | Public VPS address |
VPS_SSH_PORT | 22 | Public VPS SSH port |
VPS_NAME | work-vps | Short local alias |
MAC_USER | alice | macOS login account |
TUNNEL_PORT | 2222 | VPS loopback port for this Mac |
Replace uppercase placeholders before running a command. Do not paste commands containing unresolved placeholders.
If several Macs share one VPS, give each Mac a different TUNNEL_PORT.
Step 1: Enable Remote Login on the Mac
Run on: macOS
Open:
System Settings -> General -> Sharing -> Remote LoginTurn on Remote Login and allow only the Mac account Heeler should use. Full Disk Access is not required by this network setup.
Confirm the SSH listener:
nc -z 127.0.0.1 22 && echo 'Mac SSH is reachable'Expected:
Mac SSH is reachableDo not continue until this succeeds.
Step 2: Create a Dedicated Tunnel Key
Run on: macOS
This key keeps the reverse tunnel alive. It must not be reused for administrator or interactive login.
ssh-keygen \
-t ed25519 \
-a 100 \
-N '' \
-f ~/.ssh/VPS_NAME-tunnel \
-C VPS_NAME-tunnelFor example, when VPS_NAME is work-vps:
ssh-keygen \
-t ed25519 \
-a 100 \
-N '' \
-f ~/.ssh/work-vps-tunnel \
-C work-vps-tunnelDisplay the public key and fingerprint:
cat ~/.ssh/VPS_NAME-tunnel.pub
ssh-keygen -lf ~/.ssh/VPS_NAME-tunnel.pubCopy the public-key line. Never copy or upload the file without .pub.
Checkpoint
- The private key is mode
600. - The public key starts with
ssh-ed25519. - The public key has a recorded SHA-256 fingerprint.
Step 3: Copy the Heeler Device Key
Run on: iOS
In Heeler, open Hosts > Add Host. Under Authentication, keep Device Key selected and tap Copy authorized_keys Line. It looks like:
ssh-ed25519 AAAA... heelerThe private key remains in the iOS Keychain. Only the public line is copied.
Record this line as DEVICE_PUBLIC_KEY. It must be authorized on both the VPS
Jump account and the Mac account.
Step 4: Authorize the Device Key on the Mac
Run on: macOS
Replace DEVICE_PUBLIC_KEY with the complete public-key line copied from
Heeler:
DEVICE_KEY='DEVICE_PUBLIC_KEY'
DEVICE_ENTRY="no-agent-forwarding,no-X11-forwarding,no-user-rc $DEVICE_KEY"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
grep -qxF "$DEVICE_ENTRY" ~/.ssh/authorized_keys 2>/dev/null \
|| printf '%s\n' "$DEVICE_ENTRY" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keysThese options keep the PTY, exec, and SFTP features Heeler needs while disabling agent and X11 forwarding.
Do not add no-port-forwarding here. OpenSSH applies it to stream-local
forwarding as well as TCP, and Heeler reaches the herdr API socket through
direct-streamlocal channels (ADR 0011). With that option, preflight fails at
the herdr socket even though SSH authentication succeeds. It would add little
protection anyway, because this key can already run commands on the Mac.
Checkpoint
grep -F "$DEVICE_KEY" ~/.ssh/authorized_keysThe command must print exactly one matching authorized-key line.
Step 5: Create the Restricted VPS Accounts
Run on: VPS
id -u herdr-tunnel >/dev/null 2>&1 \
|| sudo adduser --disabled-password --gecos '' herdr-tunnel
id -u herdr-jump >/dev/null 2>&1 \
|| sudo adduser --disabled-password --gecos '' herdr-jump
sudo install -d -m 700 \
-o herdr-tunnel \
-g herdr-tunnel \
/home/herdr-tunnel/.ssh
sudo install -d -m 700 \
-o herdr-jump \
-g herdr-jump \
/home/herdr-jump/.sshIf an account already exists, inspect it instead of blindly recreating it:
getent passwd herdr-tunnel
getent passwd herdr-jumpThe accounts have no password. The SSH policy added later prevents shell and subsystem sessions while still permitting the one required forwarding mode.
Step 6: Install the Restricted Public Keys on the VPS
Run on: VPS
First install the Mac tunnel public key. Replace TUNNEL_PUBLIC_KEY with the
complete contents of ~/.ssh/VPS_NAME-tunnel.pub:
TUNNEL_PORT='2222'
TUNNEL_KEY='TUNNEL_PUBLIC_KEY'
TUNNEL_ENTRY="restrict,port-forwarding,permitlisten=\"127.0.0.1:${TUNNEL_PORT}\" $TUNNEL_KEY"
printf '%s\n' "$TUNNEL_ENTRY" \
| sudo tee /home/herdr-tunnel/.ssh/authorized_keys >/dev/nullReplace 2222 before running the block if this Mac uses another loopback port.
Now append the Heeler Device Key. Replace DEVICE_PUBLIC_KEY and
change 2222 if needed:
TUNNEL_PORT='2222'
DEVICE_KEY='DEVICE_PUBLIC_KEY'
DEVICE_ENTRY="restrict,port-forwarding,permitopen=\"127.0.0.1:${TUNNEL_PORT}\" $DEVICE_KEY"
sudo grep -qxF "$DEVICE_ENTRY" /home/herdr-jump/.ssh/authorized_keys 2>/dev/null \
|| printf '%s\n' "$DEVICE_ENTRY" \
| sudo tee -a /home/herdr-jump/.ssh/authorized_keys >/dev/nullApply ownership and permissions:
sudo chown -R herdr-tunnel:herdr-tunnel /home/herdr-tunnel/.ssh
sudo chmod 700 /home/herdr-tunnel/.ssh
sudo chmod 600 /home/herdr-tunnel/.ssh/authorized_keys
sudo chown -R herdr-jump:herdr-jump /home/herdr-jump/.ssh
sudo chmod 700 /home/herdr-jump/.ssh
sudo chmod 600 /home/herdr-jump/.ssh/authorized_keysInspect both files:
sudo cat /home/herdr-tunnel/.ssh/authorized_keys
sudo cat /home/herdr-jump/.ssh/authorized_keysCheckpoint
- The tunnel line contains
permitlisten="127.0.0.1:TUNNEL_PORT". - The Device Key line contains
permitopen="127.0.0.1:TUNNEL_PORT". - Neither file contains a private key.
Step 7: Restrict Both VPS Accounts in OpenSSH
Run on: VPS
Confirm the main SSH configuration includes drop-ins:
grep -nE '^[[:space:]]*Include[[:space:]]+.*/sshd_config\.d/' \
/etc/ssh/sshd_configIf there is no matching line, stop and consult the VPS distribution documentation before continuing.
Create /etc/ssh/sshd_config.d/60-herdr-forwarding.conf:
sudoedit /etc/ssh/sshd_config.d/60-herdr-forwarding.confInsert the following, replacing both instances of TUNNEL_PORT:
# Restrict the persistent reverse-tunnel account.
Match User herdr-tunnel
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowTcpForwarding remote
AllowStreamLocalForwarding no
PermitListen 127.0.0.1:TUNNEL_PORT
PermitOpen none
GatewayPorts no
PermitTTY no
PermitTunnel no
PermitUserRC no
X11Forwarding no
AllowAgentForwarding no
MaxSessions 0
# Restrict the external Jump Host account.
Match User herdr-jump
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowTcpForwarding local
AllowStreamLocalForwarding no
PermitListen none
PermitOpen 127.0.0.1:TUNNEL_PORT
GatewayPorts no
PermitTTY no
PermitTunnel no
PermitUserRC no
X11Forwarding no
AllowAgentForwarding no
MaxSessions 0
Match allValidate before reloading:
sudo /usr/sbin/sshd -tNo output means the syntax is valid. Do not reload if this command reports an error.
Inspect the effective restrictions:
sudo /usr/sbin/sshd -T \
-C user=herdr-tunnel,host=localhost,addr=127.0.0.1 \
| grep -E 'allowtcpforwarding|permitlisten|permitopen|gatewayports|maxsessions'
sudo /usr/sbin/sshd -T \
-C user=herdr-jump,host=localhost,addr=127.0.0.1 \
| grep -E 'allowtcpforwarding|permitlisten|permitopen|gatewayports|maxsessions'Reload on Debian or Ubuntu:
sudo systemctl reload sshSome distributions name the service sshd.
Open a second administrator connection before closing the first one.
Step 8: Configure the VPS Firewall
Run on: VPS provider console and VPS
Allow inbound TCP only for the VPS SSH service port:
VPS_SSH_PORTDo not create a public rule for TUNNEL_PORT.
Inspect the host firewall without enabling or replacing it blindly:
sudo ufw status verbose
sudo nft list rulesetThe exact firewall tool depends on the distribution and provider. The required result is:
Public: VPS_SSH_PORT
Private loopback only: TUNNEL_PORTPreserve deliberate rules for unrelated services already hosted on the VPS. This guide requires no additional public port beyond the selected SSH port.
Step 9: Verify the VPS Host Key
Run first on: VPS
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubRecord the SHA-256 fingerprint through the provider console or the trusted administrator session.
Then run on: macOS
ssh-keyscan -T 10 -t ed25519 -p VPS_SSH_PORT VPS_PUBLIC_IP 2>/dev/null \
| ssh-keygen -lf -The fingerprints must match exactly. ssh-keyscan alone is discovery, not
authentication. Stop if the fingerprints differ.
Step 10: Add the Mac SSH Tunnel Alias
Run on: macOS
Add this block to ~/.ssh/config, replacing every placeholder:
Host VPS_NAME-tunnel
HostName VPS_PUBLIC_IP
User herdr-tunnel
Port VPS_SSH_PORT
IdentityFile ~/.ssh/VPS_NAME-tunnel
IdentitiesOnly yes
BatchMode yes
ExitOnForwardFailure yes
ServerAliveInterval 30
ServerAliveCountMax 3
StrictHostKeyChecking yesCheck the resolved values:
ssh -G VPS_NAME-tunnel \
| grep -E '^(hostname|user|port|identityfile|stricthostkeychecking) 'Every value must point to the new VPS and dedicated tunnel identity.
Step 11: Test the Reverse Tunnel Manually
Run on: macOS
ssh -NT \
-R 127.0.0.1:TUNNEL_PORT:127.0.0.1:22 \
VPS_NAME-tunnelLeave this command running.
Run in another session on: VPS
sudo ss -ltnp | grep ':TUNNEL_PORT'The local-address column must show:
127.0.0.1:TUNNEL_PORTIt must not show:
0.0.0.0:TUNNEL_PORT
[::]:TUNNEL_PORTStop the interactive tunnel with Control-C after this checkpoint passes.
Step 12: Install the Persistent macOS LaunchAgent
Run on: macOS
Create:
~/Library/LaunchAgents/dev.herdr.VPS_NAME-reverse-ssh.plistUse this template, replacing MAC_USER, VPS_NAME, and TUNNEL_PORT:
<?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>dev.herdr.VPS_NAME-reverse-ssh</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-NT</string>
<string>-R</string>
<string>127.0.0.1:TUNNEL_PORT:127.0.0.1:22</string>
<string>VPS_NAME-tunnel</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
<key>StandardOutPath</key>
<string>/Users/MAC_USER/Library/Logs/VPS_NAME-reverse-ssh.log</string>
<key>StandardErrorPath</key>
<string>/Users/MAC_USER/Library/Logs/VPS_NAME-reverse-ssh.error.log</string>
</dict>
</plist>Validate and load:
PLIST=~/Library/LaunchAgents/dev.herdr.VPS_NAME-reverse-ssh.plist
LABEL=dev.herdr.VPS_NAME-reverse-ssh
plutil -lint "$PLIST"
launchctl bootstrap "gui/$(id -u)" "$PLIST"
launchctl kickstart -k "gui/$(id -u)/$LABEL"
launchctl print "gui/$(id -u)/$LABEL"Expected state:
state = runningCheck the error log:
tail -n 50 ~/Library/Logs/VPS_NAME-reverse-ssh.error.logAn empty error log is expected.
This user LaunchAgent starts after the Mac user logs in. It does not run at the pre-login FileVault screen.
Step 13: Add the Host in Heeler
Run on: iOS
In Hosts > Add Host, tap Add Route under Routes to enter the tunnel and Jump Host fields, tap Save on the route page, and fill in the remaining Host fields:
| Field | Value |
|---|---|
| Name | Any descriptive name |
| User | MAC_USER |
| Address (in Route) | 127.0.0.1 |
| Port (in Route) | TUNNEL_PORT |
| Jump Host address (in Route) | VPS_PUBLIC_IP |
| Jump Host port (in Route) | VPS_SSH_PORT |
| Jump Host user (in Route) | herdr-jump |
| Authentication | Device Key |
| herdr Session | Leave blank |
The Jump Host port and user fields appear on the route page only after Jump Host address is filled in. If Jump Host user is left blank, Heeler reuses the Host user, and the VPS rejects it as an unknown account.
Leave herdr Session blank to use the default session at
~/.config/herdr/herdr.sock. Any value, including default, selects
~/.config/herdr/sessions/<name>/herdr.sock.
Tap Save on Add Host to start the Preflight checks.
Heeler presents two independent host-key confirmations:
- the VPS Jump Host key;
- the Mac host key reached through the reverse tunnel.
Confirm each fingerprint through its trusted system. Do not assume that trusting the VPS also trusts the Mac.
Successful preflight must verify:
- Jump Host authentication;
- Mac authentication;
- herdr socket reachability;
- protocol compatibility.
Open an Agent through Attach to complete functional acceptance.
Step 14: Prove Automatic Recovery
Run on: macOS
Find the managed tunnel process:
ps -axo pid=,command= | grep '[s]sh .*VPS_NAME-tunnel'Terminate only that PID:
kill TUNNEL_PROCESS_PIDWait a few seconds, then inspect the LaunchAgent:
launchctl print "gui/$(id -u)/dev.herdr.VPS_NAME-reverse-ssh"It must show a new PID and state = running.
In Heeler, tap Run Checks Again next to Preflight or open an Agent. A restarted process without a working app connection is not sufficient proof.
Step 15: Harden Mac Authentication
Run on: macOS, only after Device Key access works
The VPS root user and every approved Jump Host key can reach the Mac SSH listener through the loopback tunnel. If macOS still offers password or keyboard-interactive authentication, those parties can attempt the Mac password.
Check the advertised methods without entering a password:
ssh \
-o BatchMode=yes \
-o PubkeyAuthentication=no \
-o PreferredAuthentications=none \
MAC_USER@127.0.0.1 trueIf the error lists password or keyboard-interactive, consider changing the
Mac SSH server to:
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yesDo this only while physically at the Mac and only after proving Device Key access and an independent recovery path. Validate the SSH configuration before restarting Remote Login.
Final Acceptance Checklist
- Mac Remote Login is enabled for the intended account.
- The Mac tunnel uses a dedicated key.
- The iOS Device Key is authorized on both the VPS and Mac.
- The VPS has separate tunnel and Jump accounts.
- Both VPS accounts reject shell, password, PTY, and unrelated forwarding.
- The provider firewall exposes only the VPS SSH service port.
- The VPS listener is exactly
127.0.0.1:TUNNEL_PORT. - VPS and Mac host-key fingerprints were independently verified.
- The LaunchAgent reports
state = running. - Killing the tunnel process produces a new PID.
- Heeler preflight succeeds.
- An Agent opens successfully through Attach.
Troubleshooting
| Symptom | Likely boundary | Check |
|---|---|---|
| Jump Host rejects the Device Key | iOS -> VPS | Device Key line in /home/herdr-jump/.ssh/authorized_keys |
remote port forwarding failed | Mac -> VPS tunnel | Existing listener, PermitListen, tunnel account key |
VPS shows no TUNNEL_PORT listener | Mac -> VPS tunnel | LaunchAgent state and error log |
VPS shows 0.0.0.0:TUNNEL_PORT | VPS security policy | Stop the tunnel; restore GatewayPorts no and explicit loopback binding |
| Mac asks for a password | VPS -> Mac | Device Key missing from the Mac or wrong Mac user |
| Mac host key changed | Inner SSH trust | Verify the Mac host key locally before accepting |
| VPS host key changed | Outer SSH trust | Verify through the provider console before accepting |
| App reaches Mac but not herdr | Mac application layer | herdr status, socket path, and AllowStreamLocalForwarding |
Automate Additional Desktop Clients
After the VPS and reverse tunnel are working, do not repeat the complete 15-step deployment for every additional Mac. Two repository scripts automate the client-specific work while preserving a separate, independently revocable key for every computer.
The automation deliberately remains a two-party approval flow:
- the new client creates its private key locally;
- an administrator enrolls only its public key from the Mac being exposed.
This avoids copying a VPS administrator private key or a shared client private key onto every computer.
One-time administrator prerequisite
Run on: the Mac being exposed
Make sure this Mac can reach a VPS administrator SSH destination whose host key
has already been verified. It may be a configured alias such as vps-admin or
an explicit destination such as admin@VPS_PUBLIC_IP.
The remote administrator must be root or have non-interactive sudo access.
The enrollment script refuses an unknown or changed VPS host key.
Configure a new Mac client
Run on: the new client Mac, from a checkout of this repository
scripts/configure-vps-ssh-client.sh \
--profile VPS_NAME \
--vps-host VPS_PUBLIC_IP \
--vps-port VPS_SSH_PORT \
--target-user MAC_USER \
--jump-user herdr-jump \
--tunnel-port TUNNEL_PORTThe script:
- creates
~/.ssh/VPS_NAME-clientif it does not exist; - prompts for a key passphrase;
- installs managed
VPS_NAME-jumpandVPS_NAME-macSSH aliases; - validates the generated OpenSSH configuration;
- prints the public-key fingerprint;
- copies only the public key to the macOS clipboard.
Use --no-passphrase only when the client cannot use an interactive
passphrase. The private key never needs to leave the new client.
Transfer the printed public key to the administrator through a trusted channel. The public key is not a secret, but its fingerprint should still be compared before approval.
Approve the client once
Run on: the Mac being exposed, from a checkout of this repository
If Universal Clipboard transferred the public key:
pbpaste | scripts/manage-vps-ssh-client.sh add \
--vps-admin VPS_ADMIN_SSH_DESTINATION \
--jump-user herdr-jump \
--tunnel-port TUNNEL_PORTOtherwise save the public key as a .pub file and run:
scripts/manage-vps-ssh-client.sh add \
--vps-admin VPS_ADMIN_SSH_DESTINATION \
--jump-user herdr-jump \
--tunnel-port TUNNEL_PORT \
--public-key-file NEW_CLIENT_PUBLIC_KEY_FILEThis one command:
- replaces any duplicate or unrestricted occurrence of the key on the VPS Jump account with a forwarding-only entry;
- replaces any duplicate or unrestricted occurrence on the target Mac with an exec-capable entry that disables agent and X11 forwarding;
- preserves unrelated authorized keys on both systems;
- enforces the expected file ownership and permissions.
The VPS update completes before the target Mac file is replaced. If the VPS step fails, the target Mac authorization remains unchanged.
Connect and verify
Run on: the new client Mac
ssh VPS_NAME-macOpenSSH asks about two host keys on the first connection. Compare the VPS fingerprint through its provider console and the target Mac fingerprint through System Settings or a local command before accepting either one.
Revoke one client
Run on: the Mac being exposed
Keep the old public key or export it before deleting the client private key:
scripts/manage-vps-ssh-client.sh revoke \
--vps-admin VPS_ADMIN_SSH_DESTINATION \
--jump-user herdr-jump \
--tunnel-port TUNNEL_PORT \
--public-key-file OLD_CLIENT_PUBLIC_KEY_FILERevocation removes the matching key from both authorization points. Other clients continue to work.
Adding Another VPS
Do not edit the working path in place:
- Create a fresh tunnel key for the new VPS.
- Configure new restricted accounts and a new VPS host key.
- Add the existing Device Key public line to the new Jump account.
- Create new Mac aliases and a second LaunchAgent.
- Validate the new loopback listener and automatic recovery.
- In Heeler, open the Host's route (or add a new route), change only the Jump Host fields, and tap Save on the Host.
- Test Preflight and Attach.
- Stop and remove the old path only after the new path works.
See Adding or Migrating to a New VPS for the full staged cutover and rollback procedure.