WireGuard is the new kid on the block for creating a secure and maintainable VPN wherever you are. Contrary, to OpenVPN, set-up is relatively easy: you don’t have a thousand nobs to configure, it handles reconnects well, and it also claims to be faster, although I never really ran into issues in that regard. The superlatives don’t stop.

As always, it turns out that the devil is in the details, and if your set-up is a bit different than everyone’s cup of tea then suddenly all the tutorials out there don’t really work for you anymore. I tried to set it up on my Raspberry Pi but I found that a combination of factors (not running Raspbian, not using iptables) made my life a bit more difficult. Every tutorial out there seems to assume that you do, but thanks to some wonderful resources I managed to figure out a way that works for me. In this post I’ll be going over how to install and configure WireGuard on a Raspberry Pi (running Arch Linux Arm) using nftables as our firewall.

As to why you would need a VPN? Well, you probably don’t but there are valid reasons to do want one and mine happens to be “let’s see if I can get this working.” And finally before we begin a quick disclaimer: this post is mainly an amalgamation of existing tutorials out there. Most importantly, the Arch Wiki on WireGuard and this article on setting up Wireguard with nftables were crucial in getting everything working for me. Tobias Kappé also gets a shout out for pointing me in the right direction and sharing parts of his configuration with me, which helped a lot.

Installing WireGuard

Part of the charm of WireGuard is that it has been baked into the Linux Kernel, so in theory you don’t have to install it. Unfortunately, at the time of writing the kernel version supplied by Arch Linux Arm (5.4.51; WireGuard was included from 5.6 onward) does not include it yet so we’ll have to add it. In addition, we don’t strictly need to install the utilities, but they do make our lives easier, so let’s install those as well.

# Change -headers package as needed
sudo pacman -S wireguard-dkms linux-raspberrypi4-headers wireguard-tools

After this is done, we can start setting up WireGuard on the server.

Configuring the WireGuard server

Okay, to clarify one thing, WireGuard doesn’t have a concept of hosts and clients. All machines are peers and are theoretically equally capable to do anything. We are going to pretend the distinction does exist because that makes explaining things easier. With that out of the way, we can set up our WireGuard interface. I’ll use systemd-networkd to create a persistent configuration, but there are other ways. NetworkManager can do it, and wireguard-tools itself includes the wg-quick utility. The configuration consists of two parts: setting up the network device and creating a network configuration. The device will handle the low-level actual data transfer, and the network configuration will actually route things over it.

Creating the network device

In this section, we’ll be creating the virtual network device for the WireGuard set-up. This device can be called anything, but convention is to use wg0 for your first WireGuard interface. Before we can set up anything, we’ll need to generate a private key for the server. We can do this (and capture the public key on the way) by using the wg tool:

wg genkey | tee wg0.privkey | wg pubkey > wg0.pubkey

With our new keys, we can actually create a network configuration file. Let’s start with the following example. It will set up a virtual device wg0 listening on UDP port 51871. More details can be found in the systemd.netdev man-page.

# File: /etc/systemd/network/99-wg0.netdev
[NetDev]
Name=wg0
Kind=wireguard
Description=WireGuard tunnel wg0

[WireGuard]
# The UDP port where we'll receive WireGuard packages
ListenPort=51871
# The contents of wg0.privkey
PrivateKey=VGhpcyBpcyBub3QgdGhlIGdyZWF0ZXN0IGtleSBpbiA=

Because this file contains your private key, you want to protect it properly. You can ensure only systemd-networkd can read it by setting the permissions to 640 and setting the owner to root:systemd-network.

We could restart systemd-networkd right now and see the results of our handiwork, but it doesn’t do too much until we actually configure the interface. So let’s get to that now.

Configuring the network device

The network configuration (shown below) is actually nothing special, but there is one thing to highlight: the IP address. Every peer within your VPN should have a private IP address. There’s plenty of private ranges to choose from, but in this post I’ll use addresses in the range 10.91.0.0/16 because 1. it’s available and 2. it is unlikely to clash with private addresses used by your common household routers, which tend to use 192.128.0.0/16 or 10.0.0.0/15. You can also add an IPv6 address in addition to the IPv4 one, but that would just clutter the examples.

# File: /etc/systemd/network/99-wg0.network
[Match]
Name=wg0

[Network]
# Choose the first address in the range, and mark the subnet.
Address=10.91.0.1/16

After adding the network configuration, we can finally restart systemd-networkd and admire our progress:

$ sudo systemctl restart systemd-networkd
$ ip a | tail -4
4: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000
    link/none
    inet 10.91.0.1/16 brd 10.91.255.255 scope global wg0
       valid_lft forever preferred_lft forever
$ sudo wg
interface: wg0
  public key: dGhlIHdvcmxkIHRoaXMgaXMganVzdCBhIHRyaWJ1dGU=
  private key: (hidden)
  listening port: 51871

That’s it! We have a WireGuard interface now! Not that it’s useful on its own without a peer to connect to it, or even reachable without fixing some firewall rules. So, let’s do the absolute minimum there, open the necessary UDP port temporarily, and call it a day. We’ll get back to it later.

sudo nft add rule inet filter input udp dport 51871 accept

Using the VPN: adding the first client peer

Now that the server is ready for use, we can try connecting to it. As every node in the VPN is a peer, setting up a client is entirely similar to setting up the server, and so we’ll start by creating a network device and configuring a network device except this time we’ll use 10.91.0.2 as the IP address for originality.

With our two peers set-up, we can tell them about each other. We do this by modifying the .netdev files and adding a WireGuardPeer:

# Peer 1, file: /etc/systemd/network/99-wg0.netdev
# Original contents, shown for context but unmodified:
[NetDev]
Name=wg0
Kind=wireguard
Description=WireGuard tunnel wg0

[WireGuard]
ListenPort=51871
PrivateKey=VGhpcyBpcyBub3QgdGhlIGdyZWF0ZXN0IGtleSBpbiA=

# New additions:
[WireGuardPeer]
PublicKey=aHR0cHM6Ly95b3V0dS5iZS9kUXc0dzlXZ1hjUSMuLi4=
AllowedIPs=10.91.0.2

This new [WireGuardPeer] section configures a few things, not all of which are relevant, not all of which are shown in the example. More details can be found in the man page as always, but of interest to us are the following:

  • the PublicKey of the other peer, used for secure authorization between them,
  • the AllowedIPs IP addresses that should be routed through this peer, and most importantly,
  • Endpoint says where to find the peer. Once the other peer sends an authenticated packet, this will be updated to the correct address, but it is still required for an initial meeting point.

The VPN host peer doesn’t necessarily know where the client can be found (the client peer may be a laptop roaming the world) so the Endpoint is not mandatory. In our client configuration however, should include it. We also need to route all the VPN traffic through that peer. In the end, the client configuration may look something like this:

# Fragment of peer 2, file: /etc/systemd/network/99-wg0.netdev
[WireGuardPeer]
PublicKey=dGhlIHdvcmxkIHRoaXMgaXMganVzdCBhIHRyaWJ1dGU=
AllowedIPs=10.91.0.1/16
# May also be an IP-address
Endpoint=your-vpn.example.com:51871

Now, if we restart systemd-networkd on both our machines, we can now start testing out the connection. If you did everything right, it might look a little something like this:

# from the client peer
$ ping -c 5 10.91.0.1
PING 10.91.0.1 (10.91.0.1) 56(84) bytes of data.
64 bytes from 10.91.0.1: icmp_seq=1 ttl=64 time=3.16 ms
64 bytes from 10.91.0.1: icmp_seq=2 ttl=64 time=0.761 ms
64 bytes from 10.91.0.1: icmp_seq=3 ttl=64 time=0.755 ms
64 bytes from 10.91.0.1: icmp_seq=4 ttl=64 time=0.851 ms
64 bytes from 10.91.0.1: icmp_seq=5 ttl=64 time=0.713 ms

--- 10.91.0.1 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4043ms
rtt min/avg/max/mdev = 0.713/1.247/3.157/0.955 ms

Using the proxy as a VPN

With the tunnel set up, it’s now time to start configuring it as a VPN and send all of our traffic over it. The client part of this is easy, as we can simply adjust the AllowedIPs parameter in the configuration. If we set it to 0.0.0.0/0 all IPv4 traffic should be directed over the tunnel. However, if you actually attempt to use your connection now, you’ll notice that it doesn’t work. This is because we are still blocked by two things, which we’ll deal with now.

Enabling forwarding in the kernel

The default kernel on Arch Linux Arm does not allow IP forwarding by default. We can enable it using sysctl. You can make this change permanent by adding a file to /etc/sysctl.d and load it using sysctl --system, or a more specific command if you really need to.

# File: /etc/sysctl.d/99-forwarding.conf
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1

Adjusting the firewall

Note, this section assumes your nftables rules are structured like the examples section on the Arch wiki, with a table inet filter that contains a chain for input, output, and forward. If your structure is different, transpose this section as needed.

Unless you have a very good reason, having your system forward all packets indiscriminately is a bad idea, which is why most firewall configurations don’t allow it by default. However, forwarding packets is exactly what a VPN is supposed to do, so we’re going to adjust our firewall to allow it, but only from the VPN. In all, we need to modify the firewall to do quite a few things:

  1. open WireGuard’s listening port permanently,
  2. allow forwarding packets received via WireGuard, and
  3. rewrite forwarded packets so they look like they came from this server (masquerade)
#!/usr/bin/nft -f

# Define some variables for easy reference
define wan = eth0
define vpn = wg0
define vpn_net = 10.91.0.0/16

table inet filter {
  chain input {
    # New: allow access to WireGuard, see point 1.
    udp dport 51871 accept;
  }

  # chain output unchanged

  chain forward {
    # New: Forward all established and related traffic. Drop invalid traffic. Related to point 2.
    ct state established,related accept
    ct state invalid drop

    # New: Allow WireGuard traffic to access the internet via wan, related to point 2.
    iifname $vpn oifname $wan ct state new accept
  }
}

# New: add a router section for point 3.
table ip router {
    # Both need to be set even when one is empty.
    chain prerouting {
        type nat hook prerouting priority 0;
    }
    chain postrouting {
        type nat hook postrouting priority 100;

        # Masquerade WireGuard traffic.
        # All WireGuard traffic will look like it comes from the servers IP address.
        oifname $wan ip saddr $vpn_net masquerade
    }
}

Once we reload the firewall configuration, everything should just work™ and all the traffic should flow through the VPN tunnel, hidden from view until it comes out the other end and is clearly visible again.

We have now connected two peers to each other, but this approach scales to any number. Note that not every peer needs to know about every other peer, but only the ones they will directly connect to. To keep with the client and host terminology: the host needs to know about all of the clients, but the clients only need to know about the host.

Final remarks

I hope you’ve learned something from my struggle to get WireGuard to work on my Raspberry Pi what we can do with it. However, Linux is not the only context where you can use your newly created VPN. The official android app makes it super easy to use on your phone, and apparently other things that I don’t own support it too.

The methods shown here are just one of the ways in which you can set up your own private WireGuard tunnels. I don’t even use the same method across all my Linux machines, as each has their own requirements based on their use.

One thing I noticed to my pleasant surprise is that the tunnels tend to survive the frequent SD-card lock-ups causing the rest of the machine to become unresponsive. Which I know for certain because it happened while I was finishing up this post and I kept the use of my VPN.

Anyway, I hope this guide has been at least somewhat useful and that it helps someone with their unique requirements to set up their VPN properly too. If you find any mistakes in this article, feel free to drop me an email.