Skip to content

About the author of Daydream Drift

Tomasz Niezgoda (LinkedIn/tomaszniezgoda & GitHub/tniezg) is the author of this blog. It contains original content written with care.

Please link back to this website when referencing any of the materials.

Author:

Remote Development, Part 1: Localhost Sharing

Published

It's possible to obfuscate the use of a remote development machine for developers and web tools. With that, websites served at http://localhost works and looks the same but, in reality, computation is offloaded to another machine which is isolated from the local environment.

It's simple to do using a persistent SSH connection between the local machine and remote. Any time localhost is called, SSH will connect to the remote and fetch data from there instead.

For a truly persistent SSH connection, it's advised to use autossh. It will restart broken connections. An example of this, is when the local machine goes offline for too long. autossh works in the background and makes sure the SSH connection is always established. If the local machine has a reliable connection to the remote, though, setting ServerAliveInterval inside ~/.ssh/config may be enough.

All of the communication management can be enclosed in a script, for example (Python):

#!/usr/bin/env python3
import os, argparse, sys, subprocess, shlex, tempfile, signal
join = os.path.join
defaultDevelopmentMachineSSHHostKey = 'DEVELOPMENT_MACHINE_SSH_HOST'
defaultSSHHost = os.environ.get(defaultDevelopmentMachineSSHHostKey)
parser = argparse.ArgumentParser('Sets up a SSH local forward by connecting a localhost port to the same remote localhost port. This function is useful when using a remote machine for running a development environment and simulating servers running on the remote machine as if they were run locally.')
parser.add_argument(
	'ports',
	help='Ports to local forward. They must be available locally and on the remote machine. If no ports are provided and the -r option is specified, local forward will be stopped on all currently used ports. If no ports are provided and -r is not specified, local forward will be restarted with the current ports.',
	nargs='*',
	type=int
)
parser.add_argument(
	'--host',
	help='Host defined in local OpenSSH configuration (usually ~/.ssh/config) to use for local forward. If a different host is provided than last time running this script, all already forwarded ports will be transferred from the old host to the new one.',
	default=defaultSSHHost
)
parser.add_argument(
	'-r',
	'--reset',
	help='If this flag is provided, all currently forwarded ports will be removed and only the new ones specified as parameters to this script will be used. The default is to append new ports to current ones.',
	action='store_true'
)
args = parser.parse_args()
SSHPorts = args.ports
SSHHost = args.host
resetPorts = args.reset
if not SSHHost:
	raise AttributeError('Host must be specified using --host or as a global export under key {}.'.format(defaultDevelopmentMachineSSHHostKey))
# There are multiple ways of finding the existing pid of autossh. It is possible to write a pid to /var/run/ or some other temporary directory. It is also possible to find the pid using "ps aux | grep <pattern>" or pgrep -f <pattern>. This script uses a temporary directory. It is usually specified by $TMPDIR environment variable.
processTrackingDirectoryPath = join(tempfile.gettempdir(), 'ssh-local-forward')
try:
	os.mkdir(processTrackingDirectoryPath)
	trackingDirectoryExisted = False
except OSError:
	trackingDirectoryExisted = True
pidFilePath = join(processTrackingDirectoryPath, 'autossh.pid')
portsFilePath = join(processTrackingDirectoryPath, 'forwarded-ports')
if trackingDirectoryExisted:
	try:
		with open(pidFilePath, 'r') as pidFile:
			autosshPid = int(pidFile.read())
	except FileNotFoundError:
		autosshPid = None
	if resetPorts:
		# If resetPorts is true, there's no need to read current ports from the file. If there are any current ports used, they will no longer be used anyway.
		oldPorts = None
	else:
		try:
			with open(portsFilePath, 'r') as portsFile:
				oldPorts = list(map(lambda portString: int(portString.strip()), portsFile.readlines()))
		except FileNotFoundError:
			oldPorts = None
else:
	autosshPid = None
	oldPorts = None
if not oldPorts:
	newPorts = SSHPorts
else:
	newUniquePorts = list(filter(lambda port: not port in oldPorts, SSHPorts))
	newPorts = oldPorts + newUniquePorts
# Stop autossh and start it again with one more ports forwarded. This way, there will be only one process responsible for all port forwards to the same host and only two ports used for monitoring the server: the first one specified using -M in autossh and the other is -M + 1.
if autosshPid:
	os.kill(autosshPid, signal.SIGTERM.value)
# Save new ports list to file.
with open(portsFilePath, 'w') as portsFile:
	portsFile.write(os.linesep.join(map(str, newPorts)))
	portsFile.close()
if len(newPorts) > 0:
	# Only start autossh if there are any ports to forward.
	subprocess.run(
		'AUTOSSH_PIDFILE={2} autossh -M 20000 -f -N {1} {0}'.format(
			SSHHost,
			' '.join(map(lambda port: '-L {0}:localhost:{0}'.format(port), newPorts)),
			shlex.quote(pidFilePath)
		),
		check=True,
		shell=True
	)
if len(newPorts) == 0:
	print('Not forwarding ports.')
else:
	print('Forwarding ports {}.'.format(', '.join(map(str, newPorts))))

If the above script is saved as ./sshLocalForward, using it is as simple as ./sshLocalForward --host <remote host address> <port> [<another port>] [<another port>] .... To remove all existing forwards, run ./sshLocalForward -r. For convenience, it's possible to setup the DEVELOPMENT_MACHINE_SSH_HOST environment variable to equal --host's value and skip using --host when executing the script.

Example calls:

./sshLocalForward --host 123.345.678 3000 3500
./sshLocalForward -r
./sshLocalForward 3000 3500

The script forwards requests to exact ports specified, so others are still usable for other purposes. Rebooting the local operating system will stop all connections.

Extras

Skipping autossh

Below, is a SSH configuration which should send a keepalive ping to the remote server and keep localhost connections working, even when not used for a long time. It doesn't reconnect, though.

# Inside `~/.ssh/config` (if doesn't exist, create new file)

#### Option 1, for a single host
Host <host alias to use>
    ServerAliveInterval 300
    ServerAliveCountMax 2
#   ... other configuration for the particular host...

#### Option 2, for all hosts
Host *
    ServerAliveInterval 300
    ServerAliveCountMax 2

Troubleshooting

If connecting to a localhost port doesn't seem to work, the reason might be the remote provider blocking that port from being accessed from the internet. Ensuring the remote machine allows incoming connections may fix the issue.

Helpful Scripts

The latest version of the Python script for local forward and other scripts useful for remote development can be found here.

Extra reading