In a previous article I described how to set up a VPN using WireGuard on a dedicated EC2 instance at AWS.

If you happen to run a Kubernetes cluster then the configuration becomes even simpler, as we don’t have to set up a dedicated EC2 instance but can build upon the infrastructure provided by Kubernetes.

Server installation

Our goal for this article is to run a WireGuard server as “just another pod” inside a Kubernetes cluster.

Luckily for us the team of LinuxServer.io has provided a Docker image with all the installation details already prepared to configure and deploy a WireGuard pod into a Kubernetes cluster.

While the Docker container can work out of the box without much additional configuration (all we need is a preconfigured wg0.conf configuration file), I prefer to manually configure the server keys as well as the clients in a dedicated configuration file, so the result will look a bit different from the basic configuration at LinuxServer.io.

WireGuard configuration file

The WireGuard configuration file looks very similar (if not identical) to the one from the example using a dedicated EC2 instance:

[Interface]
Address = 172.16.16.0/20
ListenPort = 51820
PrivateKey = OIviMX9BPHk1w/bvsXW0Qc2/mY3+HS3iS31aEtsn+Uc=
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ENI -j MASQUERADE
PostUp = sysctl -w -q net.ipv4.ip_forward=1
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ENI -j MASQUERADE
PostDown = sysctl -w -q net.ipv4.ip_forward=0

[Peer]
# Example Peer 1
PublicKey = AOIzLd2C71DtY8DWgUfuMllRNa0iR1O3tO2WbFO7ICU=
AllowedIPs = 172.16.16.10

Let’s break this down a bit:

  • The subnet that we want to use for our VPN clients is 172.16.16.0/20 which will give us a total of 4094 different IP addresses ranging from 172.16.16.1 to 172.16.31.255.
  • The private key of our server is OIviMX9BPHk1w/bvsXW0Qc2/mY3+HS3iS31aEtsn+Uc= and the corresponding public key (though not explicitly shown in the configuration) is CSB59ZuD/YVwKWRpVfRpzhirVxfAr36E5770/JDqDx4=.
  • The PostUp and PostDown commands are necessary to make sure the VPN host correctly forwards our packages.
  • We have one peer which configured via its public key AOIzLd2C71DtY8DWgUfuMllRNa0iR1O3tO2WbFO7ICU and assigned the internal IP 172.16.16.10 to that client.

Kubernetes deployment descriptors

Now we’re ready to deploy our WireGuard VPN server as a Kubernetes pod.

We’ll define the WireGuard configuration within a Kubernetes Secret which we’ll later mount as files into the pod:

---
apiVersion: v1
kind: Secret
metadata:
  name: wireguard
  namespace: example
type: Opaque
stringData:
  wg0.conf.template: |
    [Interface]
    Address = 172.16.16.0/20
    ListenPort = 51820
    PrivateKey = OIviMX9BPHk1w/bvsXW0Qc2/mY3+HS3iS31aEtsn+Uc=
    PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ENI -j MASQUERADE
    PostUp = sysctl -w -q net.ipv4.ip_forward=1
    PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ENI -j MASQUERADE
    PostDown = sysctl -w -q net.ipv4.ip_forward=0

    [Peer]
    # Example Peer 1
    PublicKey = AOIzLd2C71DtY8DWgUfuMllRNa0iR1O3tO2WbFO7ICU=
    AllowedIPs = 172.16.16.10

Note that we have named the entry wg0.conf.template and not wg0.conf as the name of the network interface through which our traffic needs to be routed to the rest of the network is not yet known to us. The ENI placeholder value in the PostUp and PostDown section needs to be replaced with the actual network interface name. We’ll do that in an init container defined in the actual Deployment.

The Deployment will now set up the actual pod that is running the WireGuard server:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wireguard
  namespace: example
spec:
  selector:
    matchLabels:
      name: wireguard
  template:
    metadata:
      labels:
        name: wireguard
    spec:
      initContainers:
        # The exact name of the network interface needs to be stored in the
        # wg0.conf WireGuard configuration file, so that the routes can be
        # created correctly.
        # The template file only contains the "ENI" placeholder, so when
        # bootstrapping the application we'll need to replace the placeholder
        # and create the actual wg0.conf configuration file.
        - name: "wireguard-template-replacement"
          image: "busybox"
          command: ["sh", "-c", "ENI=$(ip route get 8.8.8.8 | grep 8.8.8.8 | awk '{print $5}'); sed \"s/ENI/$ENI/g\" /etc/wireguard-secret/wg0.conf.template > /etc/wireguard/wg0.conf; chmod 400 /etc/wireguard/wg0.conf"]
          volumeMounts:
            - name: wireguard-config
              mountPath: /etc/wireguard/
            - name: wireguard-secret
              mountPath: /etc/wireguard-secret/

      containers:
        - name: "wireguard"
          image: "linuxserver/wireguard:latest"
          ports:
            - containerPort: 51820
          env:
            - name: "TZ"
              value: "Europe/Berlin"
            # Keep the PEERS environment variable to force server mode
            - name: "PEERS"
              value: "example"
            - name: "PEERDNS"
              value: "8.8.8.8"
          volumeMounts:
            - name: wireguard-config
              mountPath: /config/
              readOnly: true
          securityContext:
            privileged: true
            capabilities:
              add:
                - NET_ADMIN

      volumes:
        - name: wireguard-config
          emptyDir: {}
        - name: wireguard-secret
          secret:
            secretName: wireguard

Let’s drill down a bit here:

  • The first (and only) entry in the initContainers is responsible for replacing the ENI placeholder value in the PostUp and PostDown sections of the WireGuard configuration files with the actual name of the network interface.
    • The entries from the Secret are mounted as files into the directory /etc/wireguard-secret/
    • While executing the init container the ENI placeholder value will be replaced by the actual name of the network interface. The resulting file will be stored into /etc/wireguard/wg0.conf which is the standard configuration file loaded by the WireGuard server.
  • The wireguard-config volume (which is mounted as /etc/wireguard/ in both the init container and the actual container running the server) uses an emptyDir volume, which is re-created every time the pod is restarted. It only exists so that both the init container and the application container can access the same resources (the configuration file).
  • Some additional tweaks are needed in the securityContext section so that our pod will be able to add new network interfaces and update the ipconfig firewall rules.

Now we can apply both the Secret and the Deployment and Kubernetes will launch the WireGuard server for us. We can verify this by logging in directly into the WireGuard container:

$ kubectl exec -n example -it deployment/wireguard -- bash
root@wireguard-b6bccf9b6-b2lbs:/# wg
interface: wg0
public key: CSB59ZuD/YVwKWRpVfRpzhirVxfAr36E5770/JDqDx4=
private key: (hidden)
listening port: 51820

peer: AOIzLd2C71DtY8DWgUfuMllRNa0iR1O3tO2WbFO7ICU=
allowed ips: 172.16.16.10/32

We can see that the server is correctly running on port 51820 and that our peer is prepared.

In order to expose the post 51820 outside the Kubernetes cluster so that our clients are able to access them we need to add a Service resource:

---
apiVersion: v1
kind: Service
metadata:
  name: wireguard
  namespace: example
spec:
  type: LoadBalancer
  ports:
    - name: wireguard
      port: 51820
      protocol: UDP
      targetPort: 51820
  selector:
    name: wireguard

The setup is pretty straightforward:

  • The UDP port 51820 is forwarded to the wireguard Pod in the same namespace (which we have created using the Deployment shown earlier).
  • Setting the type of the Service to LoadBalancer will make it available at the public IP address of our Kubernetes clusrter.

Let’s check that our service is up and running:

$ kubectl get services -n example
NAME                 TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)           AGE
...
wireguard            LoadBalancer   10.11.12.13     1.2.3.4       51820:31099/UDP   2m
...

The public IP 1.2.3.4 is now listening to the WireGuard port ``51820` forwarding it to the actual WireGuard server.

Our server setup is now complete and we have a running WireGuard VPN server.

Client installation

Now we need to prepare our WireGuard client so that it can connect to our server.

The following WireGuard client configuration will do the trick:

[Interface]
PrivateKey = oG2sa0u9qJGWGC8+vtXRsLtI0IxXKtaYzGlpPzqD91k=
Address = 172.16.16.10/20
DNS = 1.1.1.1

[Peer]
PublicKey = CSB59ZuD/YVwKWRpVfRpzhirVxfAr36E5770/JDqDx4=
AllowedIPs = 0.0.0.0/0
Endpoint = 1.2.3.4:51820
PersistentKeepalive = 25

Let’s look at the details:

  • [Interface] section
    • The PrivateKey in the [Interface] section corresponds to the PublicKey that has been configured in the server configuration file as peer, as well as the Address.
    • We explicitly define the DNS server to be used as 1.1.1.1 (the free Cloudflare DNS server) as our Kubernetes server doesn’t bring its own DNS server. You’re free to chose any other DNS server as well.
  • [Peer] section
    • By setting the AllowedIPs to 0.0.0.0/0 we force the complete traffic from the clients computer to be routed through the VPN server. If you only want the VPN to be active for certain IP addresses you can define other blocks here (as explained in the previous article).

After establishing the connection through the WireGuard client we can again connect to the WireGuard server and can see the WireGuard status:

$ kubectl exec -n example -it deployment/wireguard -- bash
root@wireguard-b6bccf9b6-b2lbs:/# wg
interface: wg0
public key: CSB59ZuD/YVwKWRpVfRpzhirVxfAr36E5770/JDqDx4=
private key: (hidden)
listening port: 51820

peer: AOIzLd2C71DtY8DWgUfuMllRNa0iR1O3tO2WbFO7ICU=
  endpoint: 10.42.0.1:27008
  allowed ips: 172.16.16.10/32
  latest handshake: 53 seconds ago
  transfer: 93.27 KiB received, 149.15 KiB sent

The peer has successfully connected and the VPN connection is established.

Conclusion

Moving the WireGuard VPN endpoint from an EC2 instance into a Kubernetes cluster simplifies the setup even more in case a Kubernetes cluster is already existing. WireGuard simply becomes another service to be hosted inside the cluster and the overhead of installing (and maintaining!) yet another EC2 instance is gone. Furthermore, we’re now free to install WireGuard in other Kubernetes scenarios that may not even be hosted at AWS.