Abstract
Secure Shell Protocol (SSH) is one of the most important communication protocols on the Internet; it is what allows for remote administration of a large majority of Unix and Unix-like operating systems across the open Internet. However, can also be used to create both forward and reverse tunnels to bind ports either to or from one machine to another. This might seem like something silly to do, but it can be extremely useful for adding a security layer to an insecure protocol or adding a layer of security when exposing local services to the Internet. While I have lightly touched on this topic in a previous blog post, this blog post is intended to dive a bit deeper into the topic to hopefully explain how to use the tunnels, and some use-cases that might benefit from them.
Setup
The setup for this project is fairly simple and only requires OpenSSH client and server. Another utility that might be useful for daemonizing the tunnel would be AutoSSH; the utility is best described from their GitHub description:
autossh is a program to start a copy of ssh and monitor it, restarting it as necessary should it die or stop passing traffic. The original idea and the mechanism were from rstunnel (Reliable SSH Tunnel). With version 1.2 the method changed: autossh now uses ssh to construct a loop of ssh forwardings (one from local to remote, one from remote to local), and then sends test data that it expects to get back. (The idea is thanks to Terrence Martin.)
AutoSSH will monitor the connection and try to re-open it upon failure. This is exactly what you would want for creating a service to make the tunnel persistent, however, we will come back to this utility later.
Creating Basic Tunnels
For setting up and testing these tunnels, we really just need two
machines (VMs, containers, or jails work just fine for this). One
machine will act as the “remote” and the other will act as the “local”;
they will need the OpenSSH server and client respectively. Then for the
tunnels to properly work, the following setting needs to be changed on
the server in the sshd_config file (usually located at
/etc/ssh/sshd_config).
# Change from this line
# GatewayPorts no
# To this line
GatewayPorts yes
and finally restart the sshd service.
# systemD Distros
systemctl restart sshd
# Void Linux
sv restart sshd
# FreeBSD and NetBSD
service sshd restart
# OpenBSD
rcctl restart sshd
Now our system is ready to securely forward ports.
Forward SSH Tunnel
Let’s look at a really basic example of tunneling a VNC connection to a local machine:
ssh -L 3000:localhost:5900 ${USER}@${HOST}
This command will set up an SSH connection and tunnel port 5900 on
the remote machine to port 3000 on the local machine. Then, if you want
to try to connect to the machine via VNC, you connect to
localhost:3000 rather than
${REMOTE_ADDRESS}:5900. Fairly simple, and a much better
way to connect to VNC over the opened Internet; however, it is not
limited to just VNC. Rather we can tunnel any TCP port on the remote
machine to a port on our local machine. From the ssh man
page:
-L [bind_address:]port:host:hostport -L [bind_address:]port:remote_socket -L local_socket:host:hostport -L local_socket:remote_socket Specifies that connections to the given TCP port or Unix socket on the local (client) host are to be forwarded to the given host and port, or Unix socket, on the remote side. This works by allocating a socket to listen to either a TCP port on the local side, optionally bound to the specified bind_address, or to a Unix socket. Whenever a connection is made to the local port or socket, the connection is forwarded over the secure channel, and a connection is made to either host port hostport, or the Unix socket remote_socket, from the remote machine.
The documentation is fairly clear about what is actually occurring
here, and the limitation of TCP ports makes sense since SSH is a TCP
only protocol. While it is possible to do UDP over SSH, it is more
complicated and not something that I am going to delve into on this
post. The part that was unclear, at least for me, is the order the ports
should go in on the command. In short, for the forward SSH tunnels (what
the -L flag is used for) it is supposed to be local port,
than remote port.
# Generalized example
ssh -L ${LOCAL_PORT}:localhost:${REMOTE_PORT} ${REMOTE_IP}
# Forwarding remote port 8080 to local port 3000
ssh -L 3000:localhost:8080 192.168.122.146
The forward tunnel’s use-case is to secure vulnerable or sensitive remote services. Some examples might be things like VNC, a WordPress admin portal, or any other service that a bit too sensitive or insecure to share over the opened Internet.
Reverse SSH Tunnel
Now let’s look at the reverse tunnels. Again, from the ssh man page:
-R [bind_address:]port:host:hostport -R [bind_address:]port:local_socket -R remote_socket:host:hostport -R remote_socket:local_socket -R [bind_address:]port Specifies that connections to the given TCP port or Unix socket on the remote (server) host are to be forwarded to the local side.
This works by allocating a socket to listen to either a TCP port or to a Unix socket on the remote side. Whenever a connection is made to this port or Unix socket, the connection is forwarded over the secure channel, and a connection is made from the local machine to either an explicit destination specified by host port hostport, or local_socket, or, if no explicit destination was specified, ssh will act as a SOCKS 4/5 proxy and forward connections to the destinations requested by the remote SOCKS client.
Again, the documentation is fairly clear on what is going on, and again the limitation of TCP connections makes sense as SSH is still a TCP protocol. The main difference is the order the ports will be declared in the command, it will go the remote port, than the local port.
# Generalized example
ssh -R ${REMOTE_PORT}:localhost:${LOCAL_PORT}
# Forwarding data from local port 8000 to the remote port 3000
ssh -R 3000:localhost:8000 ${USER}@192.168.122.99
This type of tunnel is going to be best used for something like securely exposing local services to the Internet. It can be thought of as an alternative to a service like a Cloudflare tunnel or a Tailscale Funnel, but are not cloud based services and can be fully self-managed.
Creating Tunnels as Services
While the previous commands are easy enough, there are a few things
that can be polished a bit. The first part is after the SSH tunnel is
established, we don’t really have a reason to have a shell. Thankfully,
the open SSH client already has an option for this, -N.
From the man page:
-N Do not execute a remote command. This is useful for just forwarding ports. Refer to the description of SessionType in ssh_config(5) for details.
Perfect, just add that flag to any of the previous commands, and it will only set up the tunnel, rather than starting a shell.
# This command
ssh -R 3000:localhost:8000 ${USER}@192.168.122.99
# Becomes this command
ssh -N -R 3000:localhost:8000 ${USER}@192.168.122.99
# Though order is not important, this command is equivalent
ssh -R 3000:localhost:8000 ${USER}@192.168.122.99 -N
The next issue is being able to set up these tunnels as services and
maintaining that connection. SSH doesn’t have a built in way to monitor
and maintain the connection. We could certainly use something like the
daemon
utility in FreeBSD, but if the connection died, it would not be able to
automatically recover.
This is where a tool like autossh comes in. From the man page:
autossh is a program to start a copy of ssh and monitor it, restarting it as necessary should it die or stop passing traffic.
The original idea and the mechanism were from rstunnel (Reliable SSH Tunnel). With version 1.2 the method changed: autossh now uses ssh to construct a loop of ssh forwardings (one from local to remote, one from remote to local), and then sends test data that it expects to get back. (The idea is thanks to Terrence Martin.)
The main thing that is different with autoSSH is that we also need to provide a port that is separate for monitoring the connection. This means the stated port will need to be able to receive traffic on whatever firewall is on the remote server. Here is an example of an autoSSH command:
# Monitor on port 6500, then normal SSH commands and flags after
autossh -M 6500 -N -R 3000:localhost:8000 192.168.122.146
Additionally, it has a flag for running in the background,
-f; so our previous command can be changed into:
# Monitor on port 6500, then normal SSH commands and flags after
autossh -f -M 6500 -N -R 3000:localhost:8000 192.168.122.146
Now we need to make sure that the service user can login without a
password using SSH keys. For this, simply generate the key using
ssh-keygen then copy the contents of the generated public
key into /home/${SERVICE_USER}/.ssh/authorized_keys; test
using SSH to confirm it worked. If you had to enter a password, it will
not work as there is not a good way to make a service do that.
Finally, we would just need to create a service. I put the following
in /etc/systemd/system/tunneld.service and it worked for my
testing:
[Unit]
Description=AutoSSH tunnel service
After=network.target
[Service]
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
Environment="AUTOSSH_GATETIME=0"
ExecStart=/usr/bin/autossh -M 6500 -i /home/${SERVICE_USER}/.ssh/id_ed25519 -N -R 8000:localhost:8096 ${SERVICE_USER}@192.168.122.146
Restart=always
[Install]
WantedBy=multi-user.targetObviously replace ${SERVICE_USER} and
${SERVICE_GROUP} with the relevant service user and group
for the service. After that, run the following commands to enable and
start the service:
# Run as root to enable service
systemctl enable tunneld
# Start service
systemctl start tunneld
Obviously creating services on other init systems are possible, but will be left as an exercise for the reader.