Kubernetes the not so hard way with Ansible - WireGuard - (K8s v1.28)

This post is based on Kelsey Hightower’s Kubernetes The Hard Way - Provisioning Compute Resources. Since I don’t use AWS or Google Cloud I don’t have the feature set of this two platforms at hand. But one can work around the shortcoming’s with some tools and one of this tool is WireGuard.

In harden the instances I secured the VM instances. Since we don’t have networking features like AWS VPC or Google Cloud Engine VPC I create a secure network with WireGuard. All Kubernetes services will only listen on the WireGuard interface (wg0 by default) for incoming requests and all communication between the Kubernetes hosts will be secured by WireGuard VPN. I’ll configure the Ansible WireGuard role to create a fully meshed VPN which includes at least all Kubernetes hosts. But you can also include additional hosts and even your laptop on which you execute Ansible commands or later even more important the Kubernetes control utility kubectl.

I’ve prepared a Ansible role for installing WireGuard. You can again use

ansible-galaxy install githubixx.ansible_role_wireguard

to install the role or just clone it via git if you like.

By default port 51820 (protocol UDP) should be accessible from the outside. But you can adjust the port by changing the variable wireguard_port in group_vars, host_vars or whereever you specify your variables for a specific host or a hosts group. Also IP forwarding needs to be enabled e.g. via echo 1 > /proc/sys/net/ipv4/ip_forward. I decided not to implement this tasks in this Ansible role. IMHO that should be handled elsewhere. But you can use the wireguard_(preup|predown|postup|postdown) hooks for such kind of tasks. E.g.:

wireguard_preup:
  - echo 1 > /proc/sys/net/ipv4/ip_forward
  - ufw allow 51820/udp

This would enable IP forwarding and open port 51820 for UDP protocol before the WireGuard interfaces are created (in this case I assumed that UFW - Uncomplicated Firewall is used as an iptables fronted which is the case if you use harden_linux).

If you followed my blog series so far then you already know that my Ansible role harden_linux is also able to do this for you. See my previous blog post Kubernetes the not so hard way with Ansible - Harden the instances how to implement it. Besides changing sysctl entries (which you need to enable IP forwarding) it also manages firewall settings among other things. As a reminder the following settings should be set and deployed in case harden_linux role is used (if you use the default SSH port change 22222 to 22):

harden_linux_ufw_rules:
  - rule: "allow"
    to_port: "22222"
    protocol: "tcp"
  - rule: "allow"
    to_port: "51820"
    protocol: "udp"

harden_linux_sysctl_settings_user:
  "net.ipv4.ip_forward": 1
  "net.ipv6.conf.default.forwarding": 1
  "net.ipv6.conf.all.forwarding": 1

harden_linux_ufw_defaults_user:
  "^DEFAULT_FORWARD_POLICY": 'DEFAULT_FORWARD_POLICY="ACCEPT"'

For more information about other possible settings of the Ansible WireGuard role have a look at the README of that role.

You should already have a Ansible host_vars file for every host that is a member of your Kubernetes cluster and maybe also for your workstation you execute ansible-playbook and kubectl later e.g.:

k8s-010101.i.example.com # etcd #1
k8s-010102.i.example.com # K8s controller #1
k8s-010103.i.example.com # K8s worker #1
k8s-010201.i.example.com # etcd # 2
k8s-010202.i.example.com # K8s controller #2 
k8s-010203.i.example.com # K8s worker #2
k8s-010301.i.example.com # etcd #3
k8s-010302.i.example.com # K8s controller #3
k8s-010303.i.example.com # K8s worker #3

In Kubernetes the not so hard way with Ansible - Harden the instances I already showed you examples what settings you should set for every host so I won’t repeat everything here again. Just the example for the first controller node (which is k8s-010102 according to the list above and the file is host_vars/k8s-010102.i.example.com):

---
# Ansible
ansible_host: "k8s-010102.p.example.com"

# WireGuard
wireguard_address: "10.0.11.3/24"
wireguard_port: "51820"
wireguard_persistent_keepalive: "30"
wireguard_endpoint: "{{ ansible_host }}"

You need to set wireguard_address for every node. Of course every node needs a different IP but make sure to use the same network like /24 in the example above. You also need to set wireguard_endpoint for every node that will be part of your Kubernetes cluster. Only the workstation will have a wireguard_endpoint set to "" (empty string). The reason for this is that the workstation wont have a WireGuard VPN endpoint where the other hosts would connect to. You just want to access the kube-apiserver from your workstation or your CD host (like Jenkins) and not the other way round. Note: You don’t need to include your workstation/laptop in the WireGuard VPN mesh. By default kube-apiserver (the service you need to “talk” to with kubectl e.g. if you want to create Kubernetes resources) only listens on the WireGuard interface. But you can adjust that later so that it also listens on your public interface and make the service available to your network e.g.

But you want to enable all Kubernetes hosts to communicate with each other. That’s what wireguard_endpoint is needed for. Besides other things the Ansible role will create a WireGuard configuration for every host. That configuration will include the information what WireGuard IP can be reached via what endpoint. You can find a complete example in the README of the WireGuard role but here is an example of the WireGuard configuration of the workstation:

[Interface]
Address = 10.0.11.254/24
PrivateKey = ....
ListenPort = 51820

[Peer]
PrivateKey = ....
AllowedIPs = 10.0.11.3/32
Endpoint = k8s-010201.p.example.com:51820 # K8s controller #1

[Peer]
PrivateKey = ....
AllowedIPs = 10.0.11.6/32
Endpoint = k8s-010202.p.example.com:51820 # K8s controller #2
...

The AllowedIPs parameter is also some kind of routing information and WireGuard will create routes accordingly. So on my workstation with the IP 10.0.11.254 there will be a route that tells WireGuard that if I want to connect to 10.0.11.3 (which is the first Kubernetes controller node) send the traffic to k8s-010201.p.example.com:51820.

Include the role into your playbook (k8s.yml) like in this example:

-
  hosts: vpn
  roles:
    -
      role: githubixx.wireguard
      tags: role-wireguard

vpn is a host group which contains all hosts that WireGuard should be installed on. So lets add the following to Ansible’s hosts file. E.g.:

vpn:
  hosts:
    k8s-01[01:03][01:03].i.example.com:
    k8s-01-ansible-ctrl.i.example.com:

Now you can deploy the role:

ansible-playbook --tags=role-wireguard k8s.yml

That limits the tasks which gets executed and in this case that’s just the ones from the WireGuard role.

If you use Ansible’s host facts caching as mentioned in part 2 make sure that you refresh the cache afterwards as there is now a new WireGuard interface that Ansible isn’t aware of yet. Use the following command to gather the new host facts (replace all with k8s_all if you haven’t installed WireGuard role on your workstation e.g.):

ansible -m setup all

To check if all WireGuard interfaces are up run this command:

ansible -m command -a "ip addr show dev wg0" k8s_all

This should produce a output like this:

k8s-010301.i.example.com | CHANGED | rc=0 >>
3: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000
    link/none 
    inet 10.0.11.8/24 scope global wg0
       valid_lft forever preferred_lft forever
k8s-010101.i.example.com | CHANGED | rc=0 >>
3: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000
    link/none 
    inet 10.0.11.2/24 scope global wg0
       valid_lft forever preferred_lft forever
...

This should also work:

ansible -m command -a "ping -c 1 k8s-010201.i.example.com" k8s-010101.i.example.com

k8s-010101.i.example.com | CHANGED | rc=0 >>
PING k8s-010201.i.example.com (10.0.11.5) 56(84) bytes of data.
64 bytes from 10.0.11.5 (10.0.11.5): icmp_seq=1 ttl=64 time=2.33 ms

--- k8s-010201.i.example.com ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 2.330/2.330/2.330/0.000 ms

And to check the state of all WireGuard interfaces (this produces a lot of output):

ansible -m command -a "wg show" k8s_all

or

ansible -m command -a "systemctl status wg-quick@wg0.service" k8s-010101.i.example.com

You should now be able to ping all WireGuard IP’s from your workstation (if you configured that). The same should be true for all hosts of course. You should be able to ping every Kubernetes host from every other Kubernetes node. You just can’t ping your workstation from the Kubernetes nodes as the workstation has no WireGuard endpoint (which was intended).

That’s it for part 3! In part 4 we’ll install a certificate authority which is needed for Kubernetes.