Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

How to SSH without exposing your server to the internet

profile picture
Author
Moritz Höppner
Published
2024-11-05
Time to read
11 minutes reading time

Introduction

If you're the only one who needs to SSH into your server, there is a simple way to avoid exposing the SSH server to the internet: First, block incoming traffic to the SSH port by default with a Hetzner Firewall. Second, connect via SSH with a simple shell script that executes the following steps:

  1. Determine the public IP address of the client.
  2. Add a firewall rule via the Hetzner API that allows incoming traffic to the SSH port from the address from step 1.
  3. Connect to the server.
  4. Reset the firewall rules changed in step 2.

In this tutorial, I will describe this approach in detail. It has some limitations, which I'll discuss below. In particular, it doesn't work well when you need to connect to your server from different client IP addresses at the same time.

Prerequisites

For this tutorial, you'll need:

Step 1 - Block incoming SSH traffic by default

Create a Hetzner Firewall and apply it to your server. This step is straight forward and explained in the official Hetzner docs.

If you create a new Firewall in the console, it has by default a rule that allows incoming traffic to port 22 from every IP address. Make sure to delete this rule. For the purposes of this tutorial, you don't need any rules at all; in other words: all incoming traffic can be blocked by default.

In the following steps, I will assume that the name of your Firewall is block-ssh-firewall.

The interesting part is how to connect once the Firewall is in place. I will now explain the necessary shell commands step by step so that you can follow just by typing them in your terminal. Shell commands are always preceded by $. In the end, I will combine these commands in a single shell script.

Step 2 - Determine the client's public IP address

There are several ways to get your own public IP address. One is to access ip.hetzner.com. You can also send a curl requests:

$ curl -4 https://ip.hetzner.com

The output of the command should be something like:

203.0.113.1

The flag -4 means that curl uses IPv4 to connect to the service. If you want to use SSH with IPv6, you'll need your IPv6 address and therefore should force curl to use IPv6 with the -6 option:

$ curl -6 https://ip.hetzner.com

Now you should see an IPv6 address, for example:

2001:db8:5678::1

Now let's save the IP addresses in shell variables so we can use them in later steps:

$ my_ipv4=$(curl -4 https://ip.hetzner.com)
$ my_ipv6=$(curl -6 https://ip.hetzner.com)

Step 3 - Allow incoming SSH connections from the client

As explained in the API docs, you can set the rules for a Firewall with the ID 1234 by sending a POST request to https://api.hetzner.cloud/v1/firewalls/1234/actions/set_rules. But it is not possible to add one rule directly via the API — existing rules always get overwritten. That's why we need to get the existing rules first. While doing this, we can also determine the Firewall ID by providing its name.

For the following steps, save your API token in a variable:

$ API_TOKEN="your token"

Step 3.1 - Retrieve information about the Firewall

We send a GET request to retrieve information about all Firewalls with the name block-ssh-firewall (see API docs):

$ curl \
    --silent \
    -H "Authorization: Bearer $API_TOKEN" \
    "https://api.hetzner.cloud/v1/firewalls?name=block-ssh-firewall"

The output should look like this:

{
  "firewalls": [
    {
      "id": 12345678,
      "name": "block-ssh-firewall",
      "labels": {},
      "created": "2024-08-01T14:49:03+00:00",
      "rules": [
        {
          "direction": "in",
          "protocol": "icmp",
          "port": null,
          "source_ips": [
            "0.0.0.0/0",
            "::/0"
          ],
          "destination_ips": [],
          "description": null
        }
      ],
      "applied_to": [
        {
          "type": "server",
          "server": {
            "id": 87654321
          }
        }
      ]
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "per_page": 25,
      "previous_page": null,
      "next_page": null,
      "last_page": 1,
      "total_entries": 1
    }
  }
}

As I said before, your Firewall does not necessarily have any rules. But to make it more interesting, I added a rule that allows incoming ICMP traffic. We obviously don't want to override this rule, so we must include it in our call to the set_rules endpoint.

Therefore, we need two pieces of information from the JSON response: the value of id and the value of rules. You can extract those values with the command-line JSON parser jq.

First, save the output of the GET request in a variable:

$ firewalls=$(curl \
    --silent \
    -H "Authorization: Bearer $API_TOKEN" \
    "https://api.hetzner.cloud/v1/firewalls?name=block-ssh-firewall")

Since we query by name and firewall names must be unique, we can assume that the firewalls array in the response has exactly one element. We can extract this element with:

$ echo $firewalls | jq '.firewalls | first'

Now we can extract the values of the keys we are interested in by adding them to the jq filter:

$ firewall_id=$(echo $firewalls | jq '.firewalls | first .id')
$ original_rules=$(echo $firewalls | jq '.firewalls | first .rules')

Step 3.2 - Add a rule that allows SSH connections from the client

The rule that allows incoming SSH traffic from the IP addresses 203.0.113.1 and 2001:db8:5678::1 is represented by the following JSON object:

{
  "direction": "in",
  "port": "22",
  "protocol": "tcp",
  "source_ips": ["203.0.113.1/32", "2001:db8:5678::1/128"]
}

As you can see from the API documentation, the endpoint expects that source_ips contains blocks of IP addresses in CIDR notation. The suffix /32 means that 32 bits of the address before the suffix are fixed, /128 means that 128 bits of the address are fixed. Since IPv4 addresses have 32 bits and IPv6 addresses have 128 bits, our source_ips array contains two CIDR blocks with exactly one address, respectively.

Using the shell variables $my_ipv4 and $my_ipv6 from step 1, we can save this object in another variable:

$ allow_ssh_rule=$(cat << END
      {
      "direction": "in",
      "port": "22",
      "protocol": "tcp",
      "source_ips": ["$my_ipv4/32", "$my_ipv6/128"]
      }
END
)

This rule must be added to the array $original_rules. We use jq to do this. In its filter language, the + operator can be used to concatenate arrays:

$ new_rules=$(echo $original_rules | jq ". = . + [$allow_ssh_rule]")

Now we update the rules by calling the set_rules endpoint:

$ curl \
    -X POST \
    -H "Authorization: Bearer $API_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"rules\":$new_rules}" \
    "https://api.hetzner.cloud/v1/firewalls/$firewall_id/actions/set_rules"

You can verify in the Hetzner Cloud Console that the Firewall has a new rule that allows incoming traffic from your IP address to port 22.

Step 4 - Connect to the server

Nothing special here, simply call SSH like you normally would. For example:

ssh -i ~/.ssh/your_host holu@your_host

Step 5 - Reset the firewall rules

We can reset the firewall rules with a second call to the set_rules endpoint. We saved the original rules in the shell variable $original_rules, which we use now:

$ curl \
  -X POST \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"rules\":$original_rules}" \
  "https://api.hetzner.cloud/v1/firewalls/$firewall_id/actions/set_rules"

Step 6 - Combine previous steps into a shell script

Below, I combined the previous steps together with a few improvements in a single shell script. When you add the script, make sure it is executable:

$ touch ssh-hetzner.sh
$ chmod +x ssh-hetzner.sh

Maybe you want to use not only ssh to connect to your instance but also other programs like scp, ansible, capistrano etc. That's why I think it makes sense to pass the command itself as argument to the script. The same goes for the name of the Firewall. If you save the script as ssh-hetzner.sh, you can run it like this:

You must set the API_TOKEN environment variable before calling the script.

$ export API_TOKEN="your token"
$ ./ssh-hetzner.sh block-ssh-firewall ssh -i ~/.ssh/your_host holu@your_host

The script:

#!/bin/bash

function usage() {
    echo "\
Usage: $0 <firewall name> <command>
Example:
  $0 block-ssh-firewall ssh -i ~/.ssh/your_host holu@your_host\
" 1>&2
  exit 1
}

function handle_api_error() {
  if [ "$(echo "$1" | jq .error)" != "null" ]
  then
    echo $1 1>&2
    exit 2
  fi
}

if [ $# -lt 2 ]; then
  usage
fi

# Exit on error.
set -e

# Determine my public IPv4 and IPv6 addresses.
my_ipv4=$(curl -4 https://ip.hetzner.com)
my_ipv6=$(curl -6 https://ip.hetzner.com)

# Get the ID and the current rules of the passed firewall.
response=$(curl \
  --silent \
  -H "Authorization: Bearer $API_TOKEN" \
  "https://api.hetzner.cloud/v1/firewalls?name=$1")
handle_api_error "$response"

firewall_id=$(echo $response | jq ".firewalls[0].id")
original_rules=$(echo $response | jq ".firewalls[0].rules")

# Add to the current rules a new rule that allows incoming SSH traffic from my IP addresses.
allow_ssh_rule=$(cat << END
  {
    "direction": "in",
    "port": "22",
    "protocol": "tcp",
    "source_ips": ["$my_ipv4/32", "$my_ipv6/128"]
  }
END
)
new_rules=$(echo $original_rules | jq ". = . + [$allow_ssh_rule]")

# Make the firewall adopt the new ruleset.
response=$(curl \
  --silent \
  -X POST \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"rules\":$new_rules}" \
  "https://api.hetzner.cloud/v1/firewalls/$firewall_id/actions/set_rules")
handle_api_error "$response"

# Reset ruleset when the script exits (even upon receiving a SIGTERM or SIGINT signal).
function cleanup() {
  response=$(curl \
    --silent \
    -X POST \
    -H "Authorization: Bearer $API_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"rules\":$original_rules}" \
    "https://api.hetzner.cloud/v1/firewalls/$firewall_id/actions/set_rules")
  handle_api_error "$response"
}
trap "cleanup" EXIT

# Remove first argument, which is the firewall name.
shift

# Execute the other arguments as shell command.
$@

Limitations

  • Multiple connections at the same time

    You can't run the script above twice at the same time. If, for example, you want to run ansible while another SSH session is open, you should not run:

    $ ssh-hetzner.sh block-ssh-firewall ssh -i ~/.ssh/your_host holu@your_host

    And then, in another terminal:

    $ ssh-hetzner.sh block-ssh-firewall ansible-playbook -i inventory.ini playbook.yml

    If you do this, the API will complain that you try to add the same rule twice to the Firewall.

    Instead, you may allow SSH traffic until you press Enter with this command:

    $ hetzner_ssh.sh block-ssh-firewall read key

    Now you can ssh and ansible as usual:

    $ ssh -i ~/.ssh/your_host holu@your_host
    $ ansible-playbook -i inventory.ini playbook.yml

  • Connections from different clients at the same time

    You should not follow the described approach if there is a chance that you and some other client with a different IP address need SSH access at the same time. If this happens, you may leave the SSH port open for one of your IP addresses indefinitely:

    1. Client A connects to the server. Now SSH traffic from IP address A is allowed.
    2. Client B connects to the server. Now SSH traffic from IP addresses A and B are allowed.
    3. Client A closes the connection. Now no SSH traffic is allowed.
    4. Client B closes the connection. Now SSH traffic from IP address A is allowed.

Conclusion

You now have a script to automatically update the firewall each time you connect to the server. This means you no longer need to expose the SSH server to the internet, making it more secure.

License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get 20€ free credit!

Valid until: 31 December 2024 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more