Create a Wireguard Mesh Network using Ansible

# Ansible
Create a Wireguard Mesh Network using Ansible

A while ago I refactored my personal infrastructure. While doing so I realized, that I had a few Servers and VPS with different providers. While looking at the costs, they were a lot cheaper then my current AWS setup (while having more resources). The only Problem: I cannot create a “VPC” across multiple hosting providers.

So here we are, using Wireguard to create a mesh network between the servers. So we have a Virtual Private Network (VPN) without the need of an centralized server. In addition to that, we’ll also need a way to create Client Configurations and because we can, we’ll automate it using Ansible.

The Environment

In this example here, we’ll work with the IP Address Range 10.42.0.0/23 for all the servers and clients. This gives us the IP Addresses from 10.42.0.1 to 10.42.1.254 which is enough to setup our environment and separate the client and servers on an IP-basis (maybe a bit overkill, but it makes the tutorial easier).

Type Machine Wireguard IP
Servers server-1 10.42.0.1
server-2 10.42.0.2
server-3 10.42.0.3
server-4 10.42.0.4
server-5 10.42.0.5
Clients client-1 10.42.1.1
client-2 10.42.1.2

Creating the inventory

We’ll start with the inventory. For that, we’ll have to consider 2 things:

  • I want the servers to have a custom (human readable) name for the interface and peers within the configuration.
  • The client configurations should always contain all hosts, so I can edit them individually for every client.

With that in mind, we can create a basic inventory.

inventory.yml

# [...]

mesh: # this is the host group in this example
    vars:
      wireguard_clients:
        - name: 'client 1'
            ip: 10.42.1.1
          - name: 'client 2'
            ip: 10.42.1.2
    hosts:
        server-1:
            # ansible_host: <IP>
            wireguard_name: "Webserver 1"
            wireguard_ip: 10.42.0.1
        server-2:
            # ansible_host: <IP>
            wireguard_name: "Webserver 2"
            wireguard_ip: 10.42.0.1
        server-3:
            # ansible_host: <IP>
            wireguard_name: "DB 1"
            wireguard_ip: 10.42.0.1
        server-4:
            # ansible_host: <IP>
            wireguard_name: "DB 2"
            wireguard_ip: 10.42.0.1
        server-5:
            # ansible_host: <IP>
            wireguard_name: "Loadbalancer 1"
            wireguard_ip: 10.42.0.1

local: # this is optional, I prefer to use localhost to generate the client keypairs
  hosts:
    localhost:
      ansible_connection: local

# [...]

Setup the role

You can as well just put the code in a playbook and are more or less good to go, but I personally would recommend using roles, makes it easier to reuse it in other projects and it makes it way easier to maintain.

Create role

If you want (like me) use a specific directory for your roles, you should define your roles_path within your ansible.cfg file.

ansible.cfg

[defaults]

inventory = inventory.yml
roles_path = roles/

; [...]

In my case, I created the role by myself. If you want to do that, you can just go ahead and create the following files:

└ files/
  └ wireguard/                     # here we're gonna put the client configs
└ roles/
  └ wireguard/
    └ defaults/
      └ main.yml                   # this defines our default values for our vars
      └ tasks/
        └ get_client_keys.yml        # the tasks to get the client keys
        └ main.yml                   # our tasks
      └ templates/
        └ client.conf.j2             # the client wg config template
        └ server.conf.j2             # the server wg config template
└ playbooks/
  └ setup_wireguard.yml            # our playbook

The easiest way to create your role would be to use ansible-galaxy. e.g. ansible-galaxy init wireguard

Default Variables

These variables can be overwritten everytime you want to use this role.

roles/wireguard/defaults/main.yml

wireguard_interface: wg0
wireguard_mask_bits: 23
wireguard_port: 51820
wireguard_hostgroup: mesh

Create the tasks

Now that we’ll have all our files in place and our default variables and inventory defined, we can go ahead and create the tasks to create the mesh network.

Install Wireguard

The installation of Wireguard will be different for every use case and environment or policies. But we’re gonna keep it easy and use official repositories as well as wg-quick to set this all up.

roles/wireguard/tasks/main.yml

# my machines are debian based, so I use 'apt' here
- name: Install wireguard
  become: yes
  apt:
    name: wireguard
    state: latest

# got some bugs, when not doing that while updating the network
- name: Stop wireguard service
  become: yes
  service:
    name: wg-quick@{{ wireguard_interface }}
    state: stopped

Generate Keypairs

roles/wireguard/tasks/main.yml

# [...]

- name: Generate wireguard key pairs
  become: yes
  shell: "wg genkey | tee /etc/wireguard/privatekey-{{ wireguard_interface }} | wg pubkey | tee /etc/wireguard/publickey-{{ wireguard_interface }}"
  #  Keep them consistent
  args:
    creates: "/etc/wireguard/privatekey-{{ wireguard_interface }}"

Register Server Keys

roles/wireguard/tasks/main.yml

# [...]

- name: Register private key
  become: yes
  shell: "cat /etc/wireguard/privatekey-{{ wireguard_interface }}"
  register: wireguard_private_key
  changed_when: false

- name: Register public key
  become: yes
  shell: "cat /etc/wireguard/publickey-{{ wireguard_interface }}"
  register: wireguard_public_key
  changed_when: false

Register Client Keys

roles/wireguard/tasks/get_client_keys.yml

---
- name: "get additional clients private key"
  vars:
    ansible_host: localhost
    ansible_connection: local
  shell: "cat {{ playbook_dir }}/../tmp/privatekey-{{ item.ip }}"
  register: wireguard_add_private_keys
  loop: "{{ wireguard_clients }}"

- name: "get additional clients public key"
  vars:
    ansible_host: localhost
    ansible_connection: local
  shell: "cat {{ playbook_dir }}/../tmp/publickey-{{ item.ip }}"
  register: wireguard_add_public_keys
  loop: "{{ wireguard_clients }}"

As you can see in the script above, we’re getting the already created keypairs of the clients. But we didn’t create them yet. Don’t worry, we’ll do that later on in our playbook.

First, we have to add the get_client_keys.yml file to our main tasks.

roles/wireguard/tasks/main.yml

# [...]

- name: Register keys for additional hosts
  include_tasks: get_client_keys.yml

Create the configs

roles/wireguard/tasks/main.yml

# [...]

- name: Create config
  become: yes
  template:
    src: wireguard.conf.j2
    dest: "/etc/wireguard/{{ wireguard_interface }}.conf"
    owner: root
    group: root
    mode: "600"

- name: Create client configs
  vars:
    ansible_host: localhost
    ansible_connection: local
  template:
    src: client.conf.j2
    dest: "{{ playbook_dir }}/../files/wireguard/{{ wireguard_interface }}-{{ item.item.ip }}.conf"
  loop: "{{ wireguard_add_private_keys.results | list }}"

Setting up the Templates

This process is pretty straight forward, a simple template creating the interface and the peers.

roles/wireguard/templates/server.conf.j2

[Interface]
# {{ wireguard_name|default('Interface') }}
ListenPort={{ wireguard_port }}
PrivateKey={{ wireguard_private_key.stdout }}
Address={{ wireguard_ip }}

{% for peer in groups[wireguard_hostgroup] %}
{% if peer != inventory_hostname %}
[Peer]
# {{ hostvars[peer].wireguard_name|default('Peer') }}
PublicKey={{ hostvars[peer].wireguard_public_key.stdout }}
Endpoint={{ hostvars[peer].ansible_host }}:{{ wireguard_port }}
AllowedIPs={{ hostvars[peer].wireguard_ip }}/{{ wireguard_mask_bits }}

{% endif %}
{% endfor %}
{% for peer in wireguard_add_public_keys.results %}
[Peer]
# {{ peer.item.name|default('Peer') }}
PublicKey={{ peer.stdout }}
AllowedIPs={{ peer.item.ip }}/{{ wireguard_mask_bits }}

{% endfor %}

roles/wireguard/templates/client.conf.j2

[Interface]
# {{ item.item.name|default('Interface') }}
ListenPort={{ wireguard_port }}
PrivateKey={{ item.stdout }}
Address={{ item.item.ip }}

{% for peer in groups[wireguard_hostgroup] %}
[Peer]
# {{ hostvars[peer].wireguard_name|default('Peer') }}
PublicKey={{ hostvars[peer].wireguard_public_key.stdout }}
Endpoint={{ hostvars[peer].ansible_host }}:{{ wireguard_port }}
AllowedIPs={{ hostvars[peer].wireguard_ip }}/{{ wireguard_mask_bits }}

{% endfor %}

Enable and start Wireguard

roles/wireguard/tasks/main.yml

- name: Enable and start wireguard
  become: yes
  service:
    name: wg-quick@{{ wireguard_interface }}
    enabled: True
    state: started

# just wireguard things, looks wild in the terminal, but works just fine
- name: Ping other servers
  shell: "ping -c 6 -w 3 {{ hostvars[item].wireguard_ip }}"
  ignore_errors: yes
  with_items: "{{ groups['server'] }}"

Create the playbook

Alright, almost made it. Just one little last step: the playbook to actually run it.

playbooks/setup_wireguard.yml

# Generate Wireguard Keypairs for clients and additional hosts
- hosts: local
  gather_facts: yes
  tasks:
    - name: generate keys for additional hosts
      shell: "wg genkey | tee {{ playbook_dir }}/../tmp/privatekey-{{ item.ip }} | wg pubkey | tee {{ playbook_dir }}/../tmp/publickey-{{ item.ip }}"
      args:
        creates: "{{ playbook_dir }}/../tmp/privatekey-{{ item.ip }}"
      with_items: "{{ wireguard_add_peers }}"

# Setup Wireguard and Restart
- hosts: server
  gather_facts: yes
  roles:
    - name: wireguard
      when: skip_setup is not defined or skip_setup == false

And now you’re done. That’s it.

Final Thoughts

There is probably better ways to do this, but for me this works just fine, flexible enough to get it to run with every use case I had till today.

Don’t forget to add a rule to your firewall to open up the Wireguard Port (in this case here 51820 ). You only need to open up the UDP port.