Wireguard on a Linux Alpine with Docker
Tagged
alpine
, admin
For most of my infrastructure, I am now using Alpine Linux.
I like it because it only has a small number of moving parts. It’s easy to
know and master them, it is making my life easier :).
So, I decided to install one on my VPS. Like my distro I wanted it to
be simple and small. For all those reasons I went for Wireguard.
The fact that it is the new cool kid, may also have helped.
Last but not least, I want this VPN to run inside a docker container which is
running Alpine as well!
My cloud provider: Like everything here I wanted my cloud provider to be
simple to use. Also, I needed to be able to launch an Alpine Linux VPS. I tried
OVH (would not recommend), then Digital Ocean, but it was too expensive even
for the most simple setup. Finally, I discovered Scaleway.
I am using the smallest instance, plus an IPv4 address plus a 50GB SSD volume.
This cost me less than 5€ per month. Needless to say that I am pretty happy!
Setup of the host
To run Wireguard in a container we need to configure the underlying host.
Since containers share the host kernel you have to do some changes to make it work.
First, you have to install the kernel module:
# first check your kernel version
uname -r
# install wireguard kernel module
apk add wireguard-${your_kernel_version}
Once this is done, we also need to configure kernel parameters to allow IP forwarding.
I want the VPN to tunnel all traffic. I will forward IPv4 and IPv6.
# temporary set up
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
I want those changes to be permanent. I first looked at writing this to
/etc/sysctl.conf
. However, the change was not picked up at boot. This is
mostly due, as far as I get it, to the fact that the IPv6 support is a kernel
module that is loaded after the kernel config is called. I found some
reports here
and here.
I decided to use a local startup script from OpenRC. You only need to create
a shell script in /etc/local.d
and make it executable
(it also needs to have a .start
suffix).
#!/bin/sh
# 60-forward.start script, set up the IP forward kernel parameter
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
The last step here is to activate the local scripts at boot with this command:
rc-update add local default
.
Now that we have IP forwarding, it’s time to set up iptables.
Here the configuration is easy, you need to let iptables know about the forwarding
of packets for both IPv4 and IPv6:
iptables -P FORWARD ACCEPT
rc-update add iptables
/etc/init.d/iptables save
ip6tables -P FORWARD ACCEPT
rc-update add ip6tables
/etc/init.d/ip6tables save
Important: The saving of the forwarding rules may be disabled in your
configuration, ensure that IPFORWARD="yes"
is properly set in the files:
/etc/conf.d/iptables
and /etc/conf.d/ip6tables
Setup the container
This will be a bit more straightforward. We first need to create the container
Dockerfile:
FROM alpine:3.12
RUN apk add --no-cache wireguard-tools ip6tables
COPY server.sh /usr/local/bin/wireguard
EXPOSE 5555
CMD ["wireguard"]
For the container, I am using the same version as my host system. Since Wireguard
is using a kernel module on the host system I would like to avoid any incompatibility.
I am also installing iptables because the Wireguard script needs to add a
few more rules at startup and shutdown.
You would have noticed that I am using a custom script as the executable, this
is because I need extra configuration to be generated at startup. Here is the content:
#!/bin/sh
# 10.0.0.x, fd47:d1a9:8d26:c99b:xxxx:xxxx:xxxx:xxxx
cat > /etc/wireguard/wg0.conf << EOF
[Interface]
PrivateKey = $(wg genkey)
Address = 10.0.0.1/24, fd47:d1a9:8d26:c99b::/64
ListenPort = 5555
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
SaveConfig = true
EOF
wg-quick up wg0
watch wg
Let’s explain what I am doing:
- I am first putting in the comment the network blocks I am using for the VPN.
I am doing IPv4 and IPv6 so I describe both of them.
- Next, I am generating the config file. I want to create the
wg0
interface like the default one
from documentation. You can of course replace it with another naming convention.
- I am generating a private key for my peer. This is why I am using a script,
the key is generated at startup and is unique to every restart. You can change
the logic if it does not fit your need.
- Then, I configure the addresses blocks for IPv4 and IPv6. I am also putting the
port to listen to. It is the same as the Dockerfile.
- The
PostUp
and PostDown
sections are just copy-pasted from the Linode documentation
(most of my configuration is coming from there).
- Last line will save configuration to file, I am keeping this here for debug
purpose, and it’s not required for this to work. Also, notice that any change
to the file when Wireguard is running will be erased at shutdown since the program
is writing the current running state to the file.
Once the container is built (with a command like docker build -t wireguard .
)
we can start it with the following command:
docker run --rm --detach --name wireguard \
--cap-add=NET_ADMIN --cap-add=SYS_MODULE \
--network=host --volume /lib/modules:/lib/modules \
wireguard
This command may need a bit of clarification. First, I am running the container
with --rm
, --detach
and --name
. This is a kind of convention I am using
for most of my “production” containers. This allows me to always start with
a clean state. The --rm
and --name
are a good flags combination, I can always
connect to the same containers using the same commands which speed
up my operations and it properly delete my container at the end of the use so
I can easily re-use the name. The --detach
option is mostly here to not block
the shell and copy-paste those commands to a script file without effort.
Then, the capabilities, here I want to pass the bare minimum to the container.
I usually run with --privileged
when I don’t know the tooling and need to experiment.
However, in my “production” environment, containers run with the smallest
amount of privileges possible. You can check Docker documentation for a
short introduction on the various capabilities.
Synchronize configuration
Now our server is running, we need to configure the client and synchronize both
endpoints to make them communicate properly.
To do that I run the following command:
ssh <remote host> docker exec -i wireguard wg show wg0 public-key | \
sudo xargs ./setup.sh | \
xargs -rI{} ssh <remote host> docker exec -i wireguard \
wg set wg0 peer {} allowed-ips 10.0.0.2/32 allowed-ips fd47:d1a9:8d26:c99b::1/128
This will use the following script:
#!/bin/sh
public="${1}"
private="$(wg genkey)"
cat > /etc/wireguard/wg0.conf << EOF
[Interface]
PrivateKey = ${private}
Address = 10.0.0.2/15, fd47:d1a9:8d26:c99b::1/64
DNS = 8.8.8.8
[Peer]
PublicKey = ${public}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = <remote host>:5555
EOF
echo "${private}" | wg pubkey
Now let’s do a bit of explanation. In the first line of the shell command,
I retrieve the public part of the server key over the network using ssh.
It is then piped to the script. The script captures the input and store the
public key in the public
variable. Once this is done, it generates its own
key-pair for the client side and store it in the private
variable.
Now that we have all the keys, we generate the client config file. We set up
the IPv4 and IPv6 address of our current client endpoint as well as a DNS. Later
I will use my internal DNS with Hashicorp Consul, but for now
I am using Google’s one.
The second part of the configuration is the connection with the server. First,
we populate the public key of our remote host. Next line, we define the network
traffic we want to direct through Wireguard. Here, I redirect absolutely everything,
IPv4 and IPv6 traffic will go through our VPN. Last line, we indicate the endpoint
of our server. It is using port 5555
and you will have to indicate the IP address
or DNS name.
Once the configuration file is properly set up, we echo the public part of our client.
This is passed up to the shell process and pipe to our last command (the xargs
part).
It is a bit convoluted but I will try to explain what it does.
I use xargs to capture the script output, here it will get the public key
of my client peer. I assign this value to the {}
symbol, it will allow me to
inject the value in the middle of my command later on. The next part of the line
is the ssh
+ docker exec
combo. Since I am running my VPN on a remote host
inside a Docker container, this should make sense, otherwise check the previous
sections. The last part is the interesting one. I am calling the wg
binary
to add a peer to my wg0
interface. This interface will use the injected public
key and have an IPv4 and IPv6 address.
If on the remote host you run docker logs -f wireguard
, you should see something like that:
interface: wg0
public key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
private key: (hidden)
listening port: 5555
peer: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
endpoint: <your_ip_address>
allowed ips: 10.0.0.2/32, fd47:d1a9:8d26:c99b::1/128
Everything is ready! Just running wg-quick up wg0
should bring the VPN up and
you can browse the Internet from wherever you are ;)
Miscellaneous
Sometimes I want to echo the private key of a Wireguard
configuration. I want to read directly from the file to pipe to a potential
following command. Thus to avoid leaking the key to the bash history for example
(a shell recorder may also be present). Here is the more portable one-liner I found:
sed -n -e 's|PrivateKey = ||p' /etc/wireguard/wg0.conf