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:
- Determine the public IP address of the client.
- Add a firewall rule via the Hetzner API that allows incoming traffic to the SSH port from the address from step 1.
- Connect to the server.
- 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:
- A Hetzner Cloud API token in the Cloud Console
- A running Hetzner cloud server
- A *nix client (Linux, macOS or similar) from which you connect to the server
- The command-line JSON processor jq installed on the client
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
andansible
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:
- Client A connects to the server. Now SSH traffic from IP address A is allowed.
- Client B connects to the server. Now SSH traffic from IP addresses A and B are allowed.
- Client A closes the connection. Now no SSH traffic is allowed.
- 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.