This article describes how to securely access a local Linux machine remotely via SSH. The primary audience are engineers and researchers working with dedicated hardware behind a NAT, router, or similar.

Researchers often work with dedicated machines that live in the lab or at home. This can be because they need to run experiments which require a lot of compute power or because they need to access hardware that is not available on a cloud server. Since these machines typically do not have public IP addresses, accessing them remotely can seem tricky. When a lab mate asked me, “how can I access my computer at home”, I failed to find a good online resource that describes how to do this in a convenient and secure manner. That’s why I decided to write this quick tutorial.

Goal

In our setup we assume a dedicated machine server that is located somewhere with some access to the Internet. While we are away, we want to access it from our laptop client. For your setup, substitute the hostnames server/client with the respective hostnames of your machines.

At the end of the article we will have the following setup:

  • Both machines are on the same Tailscale network.
  • The server will run an SSH service that can be accessed by the client from anywhere.
  • We use public key authentication for SSH for convenience and security.
  • Bonus: we access Jupyter notebooks running on the server from the client via an SSH tunnel.

I have written this guide for Ubuntu 22.10 and tested it on two virtual machines with fresh installations. However, it should work with others Linux distributions as well—these things don’t change frequently. The setup for the client should be very similar on macOS and I encourage you to look-up the respective documentations.

Setting up Tailscale

We first create a new account on tailscale.com choosing the identity provider we trust the most. Tailscale is a mesh VPN service that allows us to add machines to a Virtual Private Network (VPN) so that they can communicate with each other as if they had a direct connection, e.g. via LAN.

We then install the Tailscale applications on the server and the client machine. This is most easily done using the convenience script from their website which does the right thing by adding the Tailscale repository to our package manager and installing the tailscale package. On a fresh installation you might need to sudo apt install curl first. We run the following commands on both machines:

user@client$ curl -fsSL https://tailscale.com/install.sh | sh
user@server$ curl -fsSL https://tailscale.com/install.sh | sh

We then need to authenticate the machines with our account. By default, Tailscale will open a browser window where we can log in with the account we created earlier. Alternatively, we can use the Auth keys feature to authenticate the server without a browser.

user@client$ sudo tailscale up
# with auth key: sudo tailscale up --authkey ...
user@server$ sudo tailscale up
# with auth key: sudo tailscale up --authkey ...

No news is good news, and we can now verify in the web interface that both machines are connected. We should also be able to ping the server from the client (and the other way around).

user@client$ ping server -c2
PING server.tail3cb10.ts.net (100.101.49.144) 56(84) bytes of data.
64 bytes from server.tail3cb10.ts.net (100.101.49.144): icmp_seq=1 ttl=64 time=16.4 ms
64 bytes from server.tail3cb10.ts.net (100.101.49.144): icmp_seq=2 ttl=64 time=17.7 ms

--- server.tail3cb10.ts.net ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 16.441/17.088/17.736/0.647 ms

Setting up SSH

We assume that we already have an SSH key pair on the client machine. This is most likely the case if you have used SSH before, e.g. to authenticate with GitHub. If not, you can generate a new key pair with ssh-keygen -t ed25519 on the client making sure to set a secure password. There are more details in this guide.

We now install the SSH server on the server machine. Afterwards we update the configuration so that it only accepts public key authentication and does not provide password based login. This is more secure, as it prevents brute force attacks, and more convenient, as we do not have to enter a password for every login. Afterwards we restart the server so that our new configuration is loaded.

user@server$ sudo apt install openssh-server
user@server$ echo "PasswordAuthentication no" | sudo tee -a /etc/ssh/sshd_config
user@server$ echo "ChallengeResponseAuthentication no" | sudo tee -a /etc/ssh/sshd_config
user@server$ echo "PubkeyAuthentication yes" | sudo tee -a /etc/ssh/sshd_config
user@server$ sudo service ssh restart

We can now copy the public key from the client to the server. For this we first output the contents of the client’s public key file id_ed25519.pub. If you have used a different SSH key type before, you might need to substitute ed25519 with rsa or ecdsa.

user@client$ cat ~/.ssh/id_ed25519.pub # substitute with your public key file name

The output will start with the key type, followed by the encoded key data, and ends with the key name. We will need to append the entire output to the authorized_keys file on the server. Since this is the public key, this information is not very sensitive. You can use any method you find convenient, such as email or chat, to move it to the server. Once the file on the server is updated, the SSH service should automatically load the updated configuration.

user@server$ echo "ssh-ed25519 YoUrKeY...dAtAhErE user@client" | sudo tee -a ~/.ssh/authorized_keys

It works!

We can now log in to the server from the client giving us a remote shell on the server.

user@client$ ssh user@server
# ...
user@server$ hostname
server

Extra: accessing Jupyter notebooks through an SSH tunnel

Many researchers use Jupyter notebooks to interactively explore data and train models. In our setup we can run Jupyter on the server and access it from the client via an SSH tunnel.

For this we start our Jupyter notebook on the server as usual, but with the extra --no-browser flag. We also start it on a specific --port 8888 so that we can change it in case we conflict with another already open port on the server or client.

user@client$ ssh user@server
user@server$ jupyter notebook --no-browser --port 8888
[I 22:44:20.440 NotebookApp] Writing notebook server cookie secret to /home/user/.local/share/jupyter/runtime/notebook_cookie_secret
[I 22:44:20.702 NotebookApp] Serving notebooks from local directory: /home/user
[I 22:44:20.704 NotebookApp] Jupyter Notebook 6.4.8 is running at:
[I 22:44:20.704 NotebookApp] http://localhost:8888/?token=693e39fb126fdf887214deb5c221d02ffee56ba458ef74f8
[I 22:44:20.704 NotebookApp]  or http://127.0.0.1:8888/?token=693e39fb126fdf887214deb5c221d02ffee56ba458ef74f8
[I 22:44:20.704 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).
[C 22:44:20.707 NotebookApp] 
    
    To access the notebook, open this file in a browser:
        file:///home/user/.local/share/jupyter/runtime/nbserver-4637-open.html
    Or copy and paste one of these URLs:
        http://localhost:8888/?token=693e39fb126fdf887214deb5c221d02ffee56ba458ef74f8
     or http://127.0.0.1:8888/?token=693e39fb126fdf887214deb5c221d02ffee56ba458ef74f8
# let it continue running

In a new terminal window we can now create an SSH tunnel from the client to the server. This will forward all traffic from the client’s port 8888 to the server’s port 8888.

user@client$ ssh -NL 8888:localhost:8888 user@server
# let it continue running

On the client we then open a browser with the full notebook URL that we copy from the output (see above) on the server. The URL will be similar to http://localhost:8888/?token=... where the token is a long random string that is used for authentication.

Credits: cover photo by Annie Spratt on Unsplash.