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.