Dynamically routing ssh to a local or remote address depending on your current Wi-Fi network

Aug 12, 2021

I have an Intel NUC at home that I use as an HTPC and a sort-of single-machine homelab (which seems to be the in vogue term computer nerds are using for “the machine(s) I run at home for fun”). As an HTPC, it’s connected to my TV and is how I do a lot of film and TV watching. As a homelab, it’s useful for projects I’d prefer not to run on my laptop - because, say, it may involve long-running tasks, and if I have to ensure my laptop is constantly on & connected, it defeats the purpose of having a laptop.

My development environment is pretty much Vim and Tmux regardless of where I’m working, so I can ssh to the server from my local network and work the same way I do on my laptop. To make this a bit easier, I added a block to the ~/.ssh/config on my laptop like:

Host htpc
  User myusername
  ForwardAgent yes
  Hostname htpc.local

This allows me to just ssh htpc rather than needing to type ssh myusername@htpc.local.

So having a computer that isn’t a laptop and just sits, at home, always running is useful, but the flip side, of course, is that if I’m away from home and want to check in on one of the projects I’m running on that machine, that’s a hassle, since the machine isn’t with me.

Step 1 was doing the obvious thing and opened up SSH connections from outside my local network. I’m not going to go through all the details of doing this safely here, as there’s lots of resources already online, but it is important to quickly mention that this is dangerous if you aren’t careful with your configuration. If you aren’t careful, you can easily make it possible for a bad actor to SSH into your home network from anywhere. Even if you don’t intend to open up your machine to SSH from the Internet, a lot of the same security-concious settings are good to apply. The key things I would recommend anyone configuring sshd pay attention to:

  • Restrict which users can log in via ssh at all, either via an explicit list of users (AllowUsers) or a group (AllowGroups).
  • Regardless of how you restrict which users can login, ensure root isn’t one of them. That means PermitRootLogin no in your sshd_config.
  • Only allow authentication via keys - never passwords. Configuration options for this include PasswordAuthentication no and ChallengeReponseAuthentication no (the second one is easy to forget!).
  • Use a non-standard sshd port (Port <number>). This one falls under “security by obscurity”, so don’t count on it as a real layer of defense: if you don’t secure everything else correctly, this probably won’t save you. But it’s still worth doing – if nothing else, it will dramatically reduce the number of bots that try to break in via port-scanning, which isn’t harmful if everything else is configured correctly, but can be annoying.

After doing all that, I need to make sure I can actually reach my home server from the Internet, so I added a port routing rule to my Wi-Fi router so that incoming connections on the custom port I chose for sshd are routed to my home server’s IP. (I also have a static IP allocation for the home server so its local IP address will never change.) Lastly, I set up a DNS record for a domain name I own to point at my external IP. You could also use a service like Dyn to register a domain and keep it up to date automatically: I didn’t bother since my external IP almost never changes so I don’t feel the need to automate this, and since being acquired by Oracle Dyn doesn’t seem like as good a service as it used to be.

With all that done, I added a new block to ~/.ssh/config so that when I’m away from home I can access my home server with ssh htpc-remote:

Host htpc
  User myusername
  ForwardAgent yes
  Port 12345
  Hostname htpc.local
Host htpc-remote
  User myusername
  ForwardAgent yes
  Port 12345
  Hostname myhtpc.example.com

That’s pretty convenient, but it does mean my muscle memory gets tripped up: I’m usually at home, so I’m used to just running ssh htpc, but now I have to remember to type ssh htpc-remote sometimes. I could just change the Host htpc config to Hostname myhptc.example.com, but I see no reason to deal with additional latency when the target machine is a few feet away. It’d be nicer if I could run ssh htpc and have it dynamically choose the best hostname. Everything up until now was prelude to the “one weird trick” that makes this possible: SSH’s Match directive. The Match directive allows elementary “if” logic in your ssh_config - you can match on things like the hostname being connected to, the local user running ssh, or the result of an arbitrary command with exec to alter the options applied to the connection. We’re going to use exec to make this work.

The first thing we need is a shell script which can detect if we’re on our home Wi-Fi. Here’s mine:

#!/bin/sh
#
# Exit 0 if I'm on my home network, 1 otherwise
set -e

network="MyHomeNetwork"
iwctl station wlan0 show | grep --quiet "Connected network.*$network"

I’ve saved this to ~/bin/is-home-wifi and chmod +x-ed it. The details of how you’d implement this depend on your operating system and how your Wi-Fi connection is managed. On recent versions of MacOS, networksetup -getairportnetwork en0 should include your current network name. On Linux, I leave this as an exercise to the reader: iwd is what I use to manage Wi-Fi, you’d need to adjust the commands if you use NetworkManager, or netctl, or something else. (If you use Linux and aren’t sure what network manager you use, it’s probably NetworkManager, I think it’s currently the most popular default choice for distributions like Ubuntu).

Now that we’ve got this script ready, we can make our final change to ~/.ssh/config:

Match host htpc !exec is-home-wifi
  Hostname myhtpc.example.com
Host htpc
  User myusername
  ForwardAgent yes
  Port 12345
  Hostname htpc.local

The Match line is operating this way:

  1. host htpc matches the host when we run ssh htpc.
  2. !exec is-home-wifi runs the is-home-wifi script we wrote: if it exits with status 0 (success), then the condition matches. ! negates the condition.

So in pseudo-code, this can line be read as if host == "htpc" and not home wifi.

When the condition matches, the next Hostname line indicates which hostname to actually connect to, overriding the default value configured under the Host htpc block. Intuitively you might think the Match block should go after the Host block, but this doesn’t work. I don’t fully understand how the SSH config is parsed and at what stage the Match is evaluated and applied, but if the block is put at the bottom, the host htpc condition no longer matches - host htpc.local does match, but then the Hostname change is no longer applied, although ssh -vvv output says the match was applied. If anyone can explain the vagaries of SSH config evaluation to me, I’d be interested to learn more.

With all of that, I can now run ssh htpc from anywhere, and be routed to the most appropriate hostname for my current network.