Wireguard on K8s cluster with Helm

I wish more companies were adopting Wireguard. It's fast, simple and makes the job of setting up VPN networking a breeze. Akin to SSH protocol, minus bells and whistles and configuration burden. A couple of years ago I've covered the Wireguard on AWS topic, which works well for most of the use cases, however, this time we're going to talk about shipping Wireguard support into a Kubernetes cluster via Helm, hosted on DigitalOcean. Same setup will apply to pretty much any other cluster.

Networking

There are a couple of reasons why we'd want to reach out for a tool like Wireguard, (or any other VPN product for that matter):

  • Eliminate external access to cloud resources (VPCs, Databases, etc) where possible.
  • Secure private networking and DNS resolution.

First of all, if you've ever had to deal with resources hosted in cloud providers, like instances and databases, you're probably familiar with a concept of VPC and Security Groups. These things allow you to control access surface (aka firewall), and will require hole-punching for any external connections: you might want to connect to your database from a development machine, which requires your public IP address to be whitelisted.

If you're connected to your VPC via VPN, you don't have to worry about granting access to random IP addresses, and can limit access to VPN node(s) instead. As a bonus, we're able to use the internal DNS and connect to private resources by names instead of private IP addresses. This is a win-win scenario. Now, onto k8s.

Kubernetes

Regardless of your cloud provider DNS setup, Kubernetes (k8s) makes use of it's own DNS service running internally in the cluster. For example, if we deploy a web application myapp running on HTTP port 8080 in the default namespace, any pod or service in the cluster can directly communicate with it via http://myapp.default.svc.cluster.local:8080 endpoint.

This works great until you need to expose the service to external clients, for whatever reason. Sure, for quick access kubectl port-forward might be good enough, but anything else will require extra setup (load balancer, SSL termination, dns mapping, etc), and is probably an overkill unless you plan on publicly launching the service.

Given we're setting up Wireguard in k8s cluster, anyone with VPN access should be able to reach the private k8s DNS endpoints from their machines, programmatically or via browser. We could also add an extra layer of authentication, such as Google OAuth, but it's optional.

Cluster Setup

All you need is a working k8s cluster, helm cli and wg-tools (for key generation) installed locally. This post covers DOKS product, but getting things to work on other k8s flavors should be very similar. I really like to build stuff, so at some point I've come up with a helm-wireguard repo designed to:

  • Quickly setup Wireguard in any k8s cluster via Helm chart.
  • Allow running multiple instances of Wireguard, one per node.
  • Run a stand-alone DNS server for VPN clients.
  • Expose Prometheus Wireguard metrics endpoint for basic VPN usage monitoring.

A fair disclaimer is in order:

  • Helm-wireguard repo builds sosedoff/wireguard Docker image.
  • Target image is based on debian:bullseye-slim distribution.
  • Final image uses the standard Wireguard tooling.
  • Image includes a HTTP server to expose Wireguard health check and Prometheus metrics, built in Go.

First, we'll need to install the chart repo:

$ helm repo add wireguard https://raw.githubusercontent.com/sosedoff/helm-wireguard/main/repo/
$ helm repo update

Once complete, let's create a values.yaml file for the Helm chart:

network: 10.10.0.1/24
privateKey: <WG_PRIVATE_KEY>
publicKey: <WG_PUBLIC_KEY>

# By default we will use DaemonSet type of deployment to run wireguard server peer
# on each K8s node. To switch to regular deployment uncomment:
# replicas: 1
# deployKind: Deployment

loadbalancer:
  annotations:
    # These annotations are specific to DigitalOcean but should be similar in other cloud providers.
    service.beta.kubernetes.io/do-loadbalancer-name: "k8s-wireguard"
    service.beta.kubernetes.io/do-loadbalancer-size-unit: "1"
    service.beta.kubernetes.io/do-loadbalancer-healthcheck-port: "8080"
    service.beta.kubernetes.io/do-loadbalancer-healthcheck-protocol: "http"
    service.beta.kubernetes.io/do-loadbalancer-healthcheck-path: "/health"

# A list of peers with access to our VPN gateway
peers:
  user1:
    privateKey: PEER1_PRIVATE_KEY
    publicKey: PEER1_PUB_KEY
    src: 10.10.0.2/32
  user2:
    privateKey: PEER2_PRIVATE_KEY
    publicKey: PEER2_PUB_KEY
    src: 10.10.0.3/32

You could probably leave the network field unchanged as it won't be overlapping with any default VPCs ranges, or adjust it if necessary. As for the privateKey and publicKey values, we'll have to generate those (or you can supply existing ones if you have em):

# Generate the server peer keys
$ wg genkey > privatekey           # make a private key
$ wg pubkey < privatekey > pubkey  # generate a public key

# Copy these values into the config
$ cat privatekey # should replace WG_PRIVATE_KEY in values.yaml
$ cat pubkey     # should replace WG_PUBLIC_KEY in values.yaml

Use the same process to generate key pairs for peers in the values.yaml file.

Now, it's time to create k8s resources. Fire up Helm command:

$ helm upgrade wireguard wireguard/wireguard \
  --install \
  --atomic \
  --create-namespace \
  --namespace wireguard \
  --values ./values.yaml

We tell Helm to create a wireguard namespace and install all k8s resources using that. This whole procedure might take some time, approx 5 mins or so, and uses --atomic flag, which means that if the run fails, helm will roll back to a previous state and delete all existing resources. Add a --debug for the debug output.

Once the command is done running, we can check if things are running as expected:

$ kubectl get pods -n wireguard
NAME                         READY   STATUS    RESTARTS   AGE
wireguard-794ddd6b55-tlmk2   1/1     Running   0          7d7h

$ kubectl logs wireguard-794ddd6b55-tlmk2 -n wireguard
entrypoint: enabling ip forwarding
net.ipv4.ip_forward = 1
net.ipv4.conf.all.forwarding = 1
net.ipv6.conf.all.forwarding = 1
2023/01/19 22:46:55 enabling wireguard interface wg0
2023/01/19 22:46:55 starting coredns
2023/01/19 22:46:55 [wg0] starting monitor
2023/01/19 22:46:55 starting prometheus metrics at: :9090
2023/01/19 22:46:55 starting web endpoint on port 8080
.:53

Most of the resources created are being k8s-native, like DaemonSet, Secret and others, however, the most important bit is the loadbalancer setting in the config. The LoadBalancer configuration will be picked up by DOKS agent (runs by default), and will trigger creation of a DO load balancer named k8s-wireguard.

Load balancer will allow traffic into k8s cluster node(s) and provide a static IP address. If you don't want to deal with IP addresses in Wireguard client configs, make sure to create a public DNS record like clustername.wg.mycorp.com. Load balancer settings are controlled entirely by annotations, so check em out.

To get the IP address of the loadbalancer, run:

$ kubectl get services -n wireguard
NAME           TYPE           CLUSTER-IP       EXTERNAL-IP       PORT(S)                              AGE
wireguard      ClusterIP      10.245.218.171   <none>            8080/TCP,51820/UDP,9090/TCP,53/UDP   7d7h
wireguard-lb   LoadBalancer   10.245.59.156    XXX.XXX.XXX.XXX   8080:30535/TCP,51820:32703/UDP       7d7h
# ^ load balancer resource created by DigitalOcean agent

External IP address is listed as XXX.XXX.XXX.XXX in our example. With the server part configuration complete, let's move onto the client setup.

Client Setup

Wireguard provides a set of official software packages for Linux/MacOS/Windows, so pick one that works for you, the configuration portion will be exactly the same for all of them.

Let's create a new Wireguard client config clustername-doks.conf:

[Interface]
PrivateKey = <YOUR_CLIENT_PRIVATE_KEY>
Address = 10.10.0.2/32
DNS = 10.10.0.1

[Peer]
PublicKey = <WG_SERVER_PUBKEY>
AllowedIPs = 10.10.0.0/16
Endpoint = XXX.XXX.XXX.XXX:51820
PersistentKeepalive = 15

Where YOUR_CLIENT_PRIVATE_KEY is the client peer private key you've generated earlier, as part of user1 or user2 in values.yaml file. The WG_SERVER_PUBKEY is the public key of the server peer, we've saved it locally in pubkey file. The XXX.XXX.XXX.XXX part is the public IPv4 of the DOKS loadbalancer obtained in the previous section, it should stay permanent unless the loadbalancer is destroyed. Import this config into the Wireguard application and start the tunnel.

Testing

With all our components in place we can finally put everything to test. It's important to mention that VPN we've configured does not route all traffic through the k8s cluster by default. This is controller by AllowedIPs in the client config, and essentially tells Wireguard client to only send packets destined to 10.10.0.0/16 subnet. To force everything through the VPN, swap the value to 0.0.0.0/0 instead.

In our current setup, DNS setting is changed to 10.10.0.1, which is a CoreDNS server run by helm-wireguard package. All that service does is forward DNS queries to the /etc/resolv.conf locally, effectively forwarding the queries to the k8s cluster DNS server. This allows us to resolve any fqdn in k8s cluster locally.

The helm-wireguard package exposes a few endpoints: one for general health checks by a loadbalancer, another for Prometheus metrics:

  • http://wireguard.wireguard.svc.cluster.local:8080/health
  • http://wireguard.wireguard.svc.cluster.local:9090/metrics

You should be able to open these up in a browser or use curl in terminal. Given everything is setup correctly, these endpoint should return a successful response.

In case if that does not work, use dig command to see if DNS resolution is being handled by another service. A successful query might looks something like:

$ dig wireguard.wireguard.svc.cluster.local
;; ANSWER SECTION:
wireguard.wireguard.svc.cluster.local. 5 IN A   10.245.218.171
;; Query time: 41 msec
;; SERVER: 10.10.0.1#53(10.10.0.1)

Note the responding server 10.10.0.1. This is correct and exactly what's being provided to the Wireguard client via DNS = 10.10.0.1 setting. Another local service might override DNS configuration, so you could try to run the query against the k8s DNS:

$ dig @10.10.0.1 wireguard.wireguard.svc.cluster.local

If that works and resolves the requested DNS entry correctly, you should start looking into how your system is setup, particularly around network / wifi setup.

With that being said, we are able to achieve our goal of accessing private k8s resources directly via VPN. The DNS server runs in k8s and should be able to resolve private DigitalOcean resource endpoints (Redis, Postgres, etc) as well as any private domains. But what's also important is the fact that we're now able to access said resources directly, given they allow connections from within the VPC itself.

Wrap

We've covered a lot of ground but k8s is a beast on its own, not to mention Wireguard. Setting everything up correctly might be tricky depending on how both k8s cluster and DO VPCs are configured, and might require a whole lot of troubleshooting. As a result, you'll be able to run a Wireguard VPN on DigitalOcean in a split-tunnel fashion and securely connect to any private resources hosted on DigitalOcean.

Project is hosted on Github, feel free to check it out!