I'm in the process of migrating io7m.com to Hetzner.
My previous setup on Vultr consisted of a single bastion host through which it was necessary to tunnel with ssh in order to log in to any of the multitude of servers sitting behind it.
This has downsides. Anything that wants to access private services on the hidden servers to perform monitoring, backups, etc, has to know about all the magic required to tunnel through the bastion host. When I first set up those servers, Wireguard didn't exist. However, clearly Wireguard now exists, and so I've incorporated it into the new arrangement on Hetzner instead of having to painfully tunnel SSH all over the place.
The goal is to essentially have the servers that sit behind the bastion host
appear as if they were ordinary hosts on my office LAN, whilst also not being
accessible from the world outside my LAN. This is achievable with Wireguard,
but, unfortunately, it requires a slightly complicated setup along with some
easy-to-forget bits of NAT and forwarding. In my case, at least one of the NAT
rules is only necessary because of what I assume is an undocumented
security feature of Hetzner private networks, and we'll get to that later.
There's also a bit of MTU unpleasantness
that isn't required if you aren't using PPPoE
to access the internet.
I'm documenting the entire arrangement here so that I have at least a fighting chance of remembering how it works in six months time.
This is the basic arrangement:
On the left, workstation01
and backup01
are two machines in my office LAN
that want to be able to reach the otherwise unroutable server01
and server02
machines hosted on the Hetzner cloud. The router01
machine is, unsurprisingly,
the router for my office LAN, and router02
is the Hetzner-hosted bastion
host. All IP addresses here are fictional, but 70.0.0.1
is supposed to
represent the real publically-routable IPv4 address of router02
, and the other
addresses indicated in blue are the private IPv4 addresses of the other
machines. My office LAN is assumed to cover 10.20.0.0/16
, and the Hetzner
private network covers 10.10.0.0/16
.
This is also intended to be a strictly one-way arrangement. Machines on my
office LAN should be able to connect to server01
, server02
, etc, but no
machine in the Hetzner private network will ever have any reason to connect
in to my office LAN.
The first step is to set up Wireguard in a "server" style configuration on
router02
. The configuration file for Wireguard on router02
looks something
like this:
[Interface] Address = 10.10.0.3/32, 70.0.0.1 ListenPort = 51820 MTU = 1410 PrivateKey = ... [Peer] # Router01 AllowedIPs = 10.10.100.1/32 PreSharedKey = ... PublicKey = ...
This configuration specifies that Wireguard listens on UDP port 51820
on
10.10.0.3
and 70.0.0.1
for incoming peers. The first strange thing in
this configuration is that we only define one peer and specify that its allowed
IP address is 10.10.100.1
. Packets with a source address of anything else will
be discarded. This address doesn't match the address of anything on the office
LAN, so why are we doing this? This brings us to...
We specify a single peer on router02
for reasons of routing and configuration
sanity; we want to treat all connections coming from any of
workstation01
, backup01
, or router01
as if they were coming straight
from router01
, and we want router01
to appear as just another ordinary
host on the 10.10.0.0/16
Hetzner private network. Unsurprisingly,
we achieve this by performing NAT on router01
. router01
is running BSD
with pf
, and so a pf
rule like this suffices:
nat on $nic_wg1 from $nic_dmz:network to any -> ($nic_wg1)
That should be pretty straightforward to read: Any packet with a source
address matching the network of the NIC connected to the office LAN
(10.20.0.0/16
) will have its source address translated to the IP address of
the Wireguard interface (10.10.100.1
). The address 10.10.100.1
is
deliberately chosen to be one that we know won't conflict with anything we have
defined in the Hetzner private network.
We then use a Wireguard configuration on router01
that looks like this:
[Interface] PrivateKey = ... [Peer] AllowedIPs = 10.10.0.0/16 Endpoint = router02.io7m.com:51820 PreSharedKey = ... PublicKey = ... PersistentKeepalive = 1
We specify a client configuration that will attempt to connect to
router02.io7m.com
. We specify that the allowed IPs are 10.10.0.0/16
.
In this context, the AllowedIPs
directive indicates that any packets
that are placed onto the Wireguard interface that don't have a destination
address in this range will simply be discarded. Because router01
is a
router, and therefore will forward packets it receives, if either of
workstation01
or backup01
attempt to connect to, say, server01
, their
packets will ultimately be sent down the Wireguard network interface prior
to having their source addresses translated to 10.10.100.1
by the pf
NAT rule we configured above.
At this point, any of the machines on the office LAN can try to send packets
to server01
, server02
, etc, but those packets won't actually get there.
The reason for this is that router02
isn't currently configured to actually
route anything, and so those packets will be dropped.
The first necessary bit of configuration is to set a sysctl
on router02
to enable IPv4 forwarding:
# sysctl net.ipv4.ip_forward=1
At this point, any of the machines on the office LAN can try to send packets
to server01
, server02
, etc, but those packets still won't actually get
there. This is an entirely Hetzner-specific problem and/or feature depending
on your point of view, and it took quite a bit of debugging to work out what
was happening. It wouldn't happen on a physical LAN, to my knowledge.
It seems that there's some kind of anti-spoofing system at work in Hetzner private networks that the above setup will trip. Consider what happens here:
workstation01
sends a packet A
to server01
.A
has source address 10.20.0.1
and destination 10.10.0.1
.A
reaches router01
and undergoes NAT. The source address is
transformed to 10.10.100.1
.A
goes through the Wireguard tunnel and emerges on router02
.router02
sees the destination is 10.10.0.1
and so dutifully forwards
the packet to server01
.server01
mysteriously never sees packet A
.What I believe is happening is that an anti-spoofing system running somewhere
behind Hetzner's cloud network is (correctly) noting that packet A
's source
address of 10.10.100.1
doesn't correspond to anything on the network.
There are no servers defined in Hetzner cloud that have
that address as it's a fiction we've created with NAT to make our Wireguard
configuration less complex. The anti-spoofing system is then silently dropping
the packet as it's obviously malicious.
To correct this, we simply apply a second NAT on router02
such that we
transform packets to appear to be coming directly from router02
. The following
nft
ruleset suffices:
table ip filter { chain FORWARD { type filter hook forward priority filter; policy accept; iifname "wg0" oifname "eth1" accept iifname "eth1" oifname "wg0" accept } } table ip nat { chain POSTROUTING { type nat hook postrouting priority srcnat; policy accept; ip saddr 10.10.100.1 oifname "eth1" snat to 10.10.0.3 } }
We set up forwarding between the wg0
(Wireguard) and eth1
(Hetzner private
network) NICs, and we specify a NAT rule so packets with a source address
of 10.10.100.1
are transformed such that they get a source address of
10.10.0.3
.
After enabling these rules, workstation01
can send packets to server01
and get responses as expected. From server01
's perspective, it is receiving
packets that originated at router02
.
It's slightly amusing that we introduce a fictional address on router01
to
simplify the configuration, and then undo that fiction on router02
in order
to satisfy whatever security system Hetzner is using. This is what running out
of IPv4 address space gets us.