SSH is the default technology to connect to remote servers. Millions of servers can be reached via SSH. Everyone who has ever administrated a server with SSH exposed on the internet knows that there is a constant stream of login failures. Assuming the security of the SSH protocol and the correctnes of its implementation, this would be just a nuisance. However, software is hardly perfect, and even SSH is subject to security vulnerabilities. It is therefore advisable to use additional techniques to protect the SSH server. This article describes a solution that’s applicable for rented root servers where customers cannot add a hardware firewall or DMZ.

Assume an initial situation like this: A root server is exposed on the internet. The SSH service listens on its public IP address and firewall is configured to allow traffic to port 22, however, I’ll not show the firewall in this article. The network interface eth0 uses the server’s public IP address 198.51.100.13.

Default SSH setup

The goal of this article is to show how to arrive at a situation where SSH does not listen on the server’s public IP address. The firewall intercepts any request to 198.51.100.13:22. Remote access to the machine is only possible by opening a secure tunnel with wireguard.

SSH over wireguard

First set up wireguard. For this guide, I assume Debian 12 is your host system. The process can be easily adapted to the systems. Let’s start by installing wireguard and creating its network configuration.

$ apt install wireguard

Create private and public keys.

$ umask 077 && wg genkey > /etc/wireguard/privatekey
$ wg pubkey < /etc/wireguard/privatekey > /etc/wireguard/publickey

Create the wireguard configuration.

[Interface]
PrivateKey = PASTE HERE THE CONTENT OF /etc/wireguard/privatekey
ListenPort = 51820

[Peer]
PublicKey = PASTE HERE THE PUBLIC KEY OF YOUR CLIENT
AllowedIPs = 172.16.0.2
PersistentKeepalive = 15

# Add more clients if needed

Next, create the network interface.

# /etc/network/interfaces.d/wg0
auto wg0
iface wg0
	address 172.16.0.1/24
	pre-up ip link add wg0
	pre-up wg setconf wg0
	post-down ip link del wg0

And bring up the network interface.

# ifup wg0

If everything is fine, you should be able to see your new interface with $ wg.

Before changing the SSH configuration, ensure that the tunnel is configured properly and incoming connections are not blocked by the firewall. If the tunnel setup is incorrect, and you disable SSH for the public IP address, there is a danger of locking yourself out.

If the tunnel is set up correctly, let’s remove SSH from the public IP. By default, there is no ListenAddress directive in SSH’s configuration files and SSH listens on all available interfaces and addresses. By adding two explicit addresses, we disable listening on all other addresses, including the public IP.

# /etc/ssh/sshd_config.d/listen.conf
ListenAddress 127.0.0.1
ListenAddress 172.16.0.1

Take a deep breath. After restarting the SSH daemon, the setup is complete.

# service sshd restart

Now the only way to connect to the server is via wireguard tunnels as depicted in the following picture.

SSH over enabled tunnel

Bonus: Firewall

A suitable, minimal firewall configuration could look as follows. Please note, that this configuration does not include access to SSH via its public IP. Apply this configuration only after completing the above steps.

table inet filter {
	chain input {
		type filter hook input priority 0; policy drop;
		ct state invalid counter drop comment "early drop of invalid packets"
		ct state {established, related} counter accept comment "accept all connections related to connections made by us"
		iif lo accept comment "accept loopback"
		iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"
		ip protocol icmp counter accept comment "accept all ICMP types"
		ip6 nexthdr icmpv6 counter accept comment "accept all ICMP types"
		udp dport 51280 counter accept comment "Accept wireguard"
		counter comment "count dropped packets"
	}
	chain forward {
		type filter hook forward priority 0; policy accept;
		counter comment "count forwarded packets"
	}
	chain output {
		type filter hook output priority 0; policy accept;
		counter comment "count accepted packets"
	}
}