Create WireGuard VPN with Ansible
Currently we are using Cisco AnyConnect for our VPN solution. It works well, but it is a bit of a pain to install and configure. We are retiring the environment that the ASA is located in and it’s time to retire this piece of equipment since it doesn’t fit into our current infrastructure or future plans.
We are a fully remote company and as a result all of our resources and assets are also in the cloud. That said we would like to control access to our resources and have some ability to quickly revoke access if needed. Since we may have multiple endpoints or environments we would need to connect to we would like it to be simple and flexible. Additionally we would like to reduce expenses as much as possible so open-source is certainly preferred.
That said the simple and honest choices were between OpenVPN and WireGuard. Discussions with the technical resources and managers quickly settled on WireGuard. WireGuard is simple, lightweight, easy to configure and troubleshoot and has wide acceptance in the market. We can have multiple configurations on a single system without conflict. It’s simple enough to deploy and manage that if we needed to have multiple environments it’s pretty straight forward.
But we can always do better!
Scenario
Here is the basic scenario:
- We are a small firm and we have a small presence in a co-located datacenter and we need to give our employees access to the datacenter resources. Management does not want us to use the resources provided by the datacenter to terminate the VPN due to cost, however we are able to spin up as many virtual machines as we want without incurring any additional cost.
Seems simple enough and this is what we’re going to work through. We have a bunch of remote users that need to connect to the datacenter and we don’t have any real central method of authentication here so using files is acceptable to this company’s risk profile.
WHY VPN?
So it’s the end of 2023 and I’m about to explain why a VPN is important. People don’t understand the costs or risks of being spied on, either in a corporate sense or personal sense either. This is worth its own post which I’ll link here when I get to it :)
- Corporate: We want to protect our customer’s data and prevent internet snoops1 from being able to read or prioritize our private data.
- Personal: We don’t want to be a larger target for advertising, data theft, man-in-the-middle attacks, etc. Using a VPN reduces the risks of a lot of these, even at home. Using a VPN will keep your ISP from being able to tell where you like to shop, what you like to watch, what you’re searching. Additionally, in some people’s lives it’s a matter of personal security.
- Government: I live in the United States where currently (Dec, 2023) we are not exceptionally concerned with government persecuting us for our political views however, if you’re in a country where this is a problem a VPN may be a life-line.
Planning
Generally when developing a playbook you’re going to complete a series of steps that would normally be completed on the commandline, (Linux assumptions here) so I like to run through those steps to build out my playbook(s) or role(s).
-
Configure Server
-
Install Software (WireGuard package)
-
Create server config file
-
Create WireGuard service
-
Configure service to start on boot
-
-
Create Client Configurations
-
Create list of users
-
Create client configuration
-
-
TEST
-
TEST
-
TEST
-
TEST
-
-
Start playbook development
As you can tell we are just running through the process right now to make sure everything works as planned. We aren’t building the playbook or writing any code just doing the job. Take copious amounts of notes while doing this and make sure you’ve gotten every step. That is going to be the template you write the playbook from.2
Doing
Create WireGuard Server
-
Install WireGuard and firewall
sudo apt install wireguard ufw -y
-
Create your WireGuard keys
Create the private key with
wg genkey
which will output the private key. This private key is the input for the next step which derives the public key.echo <key> | wg pubkey
wg genkey GO/b82NFwTXApR1CzO2MHtwYg0qWSpGgGgO//GgL5Xo= # Create and use your own keys! echo GO/b82NFwTXApR1CzO2MHtwYg0qWSpGgGgO//GgL5Xo= | wg pubkey 6Gj/JTnJjfFnqnAIA8l6pr718rfEGYK94G9RttzTUwE=
These are the private key and public key you will need for the server. The clients get the public key while the server retains the private key in the configuration file. These values will eventually be stored in the ansible vault so we can start creating that yml file now.
-
(Side Quest) Create vault.yml file
server: private_key: GO/b82NFwTXApR1CzO2MHtwYg0qWSpGgGgO//GgL5Xo= public_key: 6Gj/JTnJjfFnqnAIA8l6pr718rfEGYK94G9RttzTUwE=
-
Create the server config file
/etc/wireguard/wg0.conf
[Interface] Address = 172.20.100 .1/23 # Private Address IPv4 Address = fd0d:27ad:deda::1/64 # Private IPv6 SaveConfig = false PostUp = ufw route allow in on wg0 out on eth0 PostUp = iptables -t nat -I POSTROUTING -o eth0 -j MASQUERADE PostUp = ip6tables -t nat -I POSTROUTING -o eth0 -j MASQUERADE PreDown = ufw route delete allow in on wg0 out on eth0 PreDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE PreDown = ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE ListenPort = 51820 PrivateKey = GO/b82NFwTXApR1CzO2MHtwYg0qWSpGgGgO//GgL5Xo=
-
Update server networking so it can route and forward packets
sudo echo net.ipv4.ip_forward=1 >> /etc/sysctl.conf sudo echo net.ipv6.conf.all.forwarding=1 >> /etc/sysctl.conf sudo sysctl -p
-
Update firewall so it can accept VPN connections and SSH. This block allows the correct traffic through, resets the firewall service then prints out the current status.
sudo ufw allow 51820/udp sudo ufw allow OpenSSH sudo ufw disable sudo ufw enable sudo ufw status
-
Start and enable the WireGuard service
sudo systemctl enable wg-quick@wg0.service sudo systemctl start wg-quick@wg0.service sleep 10 sudo systemctl status wg-quick@wg0.service
At this point you should get a lot of output but it should be green and show a status of ‘Running’
YAY! We have a server running!
But it can’t accept any connections… And we have no clients…
Creating Clients
Part of what makes WireGuard so easy to use is one o the thngs that makes it difficlyt to adopt. Each configuratin is different and there is no method to distribute or update cient configurations after deployment. We will distribute these files using features of HashiCorp’s Vault.
Since we are using ansible to configure the server and it already has the keys for the server let’s leverage that to create the clients files.
-
Review the client config anatomomy
[Interface] PrivateKey = <CLIENT PRIVATE KEY> Address = <CLIENT STATIC VPN IP ADDRESS>/32 DNS = 9.9.9.11, 149.112.112.11, 2620:fe::11, 2620:fe::fe:11 # DNS Servers to use when connected [Peer] PublicKEy = <SERVER PUBLIC KEY> AllowedIPs = 0.0.0.0/0, ::/0 # IPs to route through VPN Endpoint = vpn.domain.com:51820 # VPN server DNS address
-
Server Updates for client - We need to add the [Peer] Section for each
# Entry for each peer on server PublicKey = <SERVER PUBLIC KEY> AllowedIPs = <CLIENT STATIC VPN IP ADDRESS>/32
Looking at this we need four pieces of information to make our configs:
- Client Private Key
- Client Public Key
- Server Public Key
- IP Address for client when connected to VPN
Since all this needs to be retained and can’t change we are going to store this information in ansible vault. The datastructure is simple:
users:
ex_user_001:
key: 6AGIjiJIHoas8Ew0vENLJvslbsZGo6d+rerRPK++lUI=
pubkey: R08mgG3FAfNOMn/qjSUMWO7L83XV7y5IFpjHRg6CKiY=
number: 148
ex_user_002:
key: MPl1exnEnE2vAfhUoUKy9BZnWAucUpYtk5HAaTJnDUc=
pubkey: 4q9IpJxBCZ6HRtWbHgogPlDY9G2LvUkmwQNupLmXrlQ=
number: 149
This gives us all the information we need to create all of our config entries for the clients. It’s tedious to create this for a bunch of users and keep it straight so I created a shell script to create the users. Execute the script to create the users we will copy the relevant entries into your vault file later.
cat /dev/null > new_clients.yml
echo "users:"
for i in {1..150}; do
KEY=$(wg genkey)
PUBKEY=$(echo $KEY | wg pubkey)
echo " user-$i": >> new_clients.yml
echo " key: $KEY" >> new_clients.yml
echo " pubkey: $PUBKEY" >> new_clients.yml
echo " number: $i" >> new_clients.yml
echo "" >> new_clients.yml
done
Create Ansible Playbook
Now we have all the steps we need to accomplish to make this work and all the data required.
- Create MOTD that tells people this is an Ansible Managed system
- Deploy WireGuard to the server
- Create Server Configurations
- Create Client configs that need to be distributed to clients
- Secondary Objective: Configure so that it can configure new server from scratch
Create the project directory structure
Create the project directory and the required files. We are also creating and populating the pwfile for ansible vault bucause I know you would never store this in a public location or repo if it wasn’t encrypted right.
export PROJECT_DIR=~/wg_project
mkdir -P $PROJECT_DIR/ansible
mkdir -p $PROJECT_DIR/ansible/handlers
mkdir -p $PROJECT_DIR/ansible/templates
mkdir -p $PROJECT_DIR/ansible/vars
touch $PROJECT_DIR/ansible/inventory.yml
touch $PROJECT_DIR/ansible/handlers/main.yml
touch $PROJECT_DIR/ansible/vars/vars.yml
echo "ansible/pwfile.txt" >> .gitignore
openssl rand -base64 32 > $PROJECT_DIR/ansible/pwfile.txt # Ansible Vault PW File
Build Data & Ansible Files
- Populate Vault file
Populate the values with what you have previously or generate new values. These values will be used by the templates to create the config files. This keeps us from needing to have the key values in an unsecured code repo.
wireguard_private_key: OPq2MSt6jd48rwz1Rl+xerrNGoD9x5nQwQm3aC15UlM= wireguard_public_key: Z1lsQcYzaGSrQhm8I9kDOezr+wWjPqJFS1JvuLgjM1A= users: cool-user: key: GALbYcYdUZAdj1Vimr/Dgd9+ig+O675jEas8/58GAUA= pubkey: PUNU2maOfqX+Z1gCj4BG7pdVjxw7maDK+U/lHQ1nrFM= number: 15 bob: key: CO2K6Z5hqCOIth5e6DXDzD9mPXhkNadi99ytPNxHR0g= pubkey: sTyfg//FYtNeTyUk78ZT0+16E8DhwoAeiPMWUzHa4WI= number: 46 jake: key: UF2zByFRLA63qj9aZ/ZGAdxrigWPasnfLjLojnPubnI= pubkey: dQACpWMf93+JO3oPJqoIchT6UOW8BLBTNe9g5koNUls= number: 47 ishmail: key: ECu4XIhX9HZdwkshU/lvGQcaCNt4/OBZO9xXfmUaMF4= pubkey: uo8iMNSFrQQ/Ynqp3FtBJuRsXgZwqx3luzU+VxMfwUM= number: 48 # Repeat for each user
- Define our inventory file
- We define the group of one so it can be incorporated into larger workflows later
all: hosts: wireguard-server: ansible_host: 172.20.200.15
-
Define the top matter for our playbook in
main.yml
.--- - name: Update and Install WireGuard on Localhost hosts: wireguard-server become: true gather_facts: true vars_files: - vars/vars.yml - vars/vault.yml
- This will only be executed on the WireGuard server(s) not clients as limited to the group
wireguard-server
in the inventory file - We are logging as not root server and will
become root
via sudo - We are going to collect inventory of the host(s)
- This will only be executed on the WireGuard server(s) not clients as limited to the group
-
Create the MOTD Template -
templates/motd.j2
================================================================================ This system is managed and configured via Ansible. The Ansible playbooks are located in the `ansible` directory of the repo. Repo: "{{ repo }}" Last Execution: "{{ ansible_date_time.year }}-{{ ansible_date_time.month }}-{{ ansible_date_time.day }} {{ ansible_date_time.hour }}:{{ ansible_date_time.minute }} UTC" ================================================================================
- Update the message as appropriate for your organization
- Ensure the
repo
variable is defined -vars/vars.yaml
- Last Execution fills in the values from Ansible
-
Execute the Ansible template to create the MOTD
tasks: - name: "Update MOTD" ansible.builtin.template: src: "motd.j2" dest: "/etc/motd" owner: root group: root mode: "0644"
- name: Defines the name of the task and allows it to be identified if needed
- ansible.builtin.template: this is the full reference to the module we are using
- src: (required) the filename in the
templates/
directory to use - owner: (required)
- group: (required)
- mode: (required) input as string and allow ansible to interpret - hence quotes
-
Install WireGuard and Firewall
- name: Install WireGuard and Firewall ansible.builtin.apt: state: present pkg: - wireguard - ufw update_cache: true
-
Create the template for the server config -
templates/wg.conf.j2
[Interface] Address = 172.20.100.1/23 Address = fd0d:27ad:abcd::1/64 SaveConfig = false PostUp = ufw route allow in on wg0 out on eth0 PostUp = iptables -t nat -I POSTROUTING -o eth0 -j MASQUERADE PostUp = ip6tables -t nat -I POSTROUTING -o eth0 -j MASQUERADE PreDown = ufw route delete allow in on wg0 out on eth0 PreDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE PreDown = ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE ListenPort = 51820 PrivateKey = {{ wireguard_private_key }} {% for user_name, user_data in users.items() %} [Peer] # {{ user_name }} PublicKey = {{ user_data.pubkey }} Address = 172.20.100.{{ user_data.number }}, fd0d:27ad:abcd::{{ user_data.number }}/128 {% endfor %}
- This is a copy of the configuration we had prior with the sensitive and repetitive variables swapped out
-
wireguard_private_key
is in the vault from a prior step - The data for the users was placed in the vault from a prior step and is looped through creating a [Peer] stanza for each
- user_name: is the id in the data structure in vault
- user_data.pubkey: pubkey in the vault
- user_data.number: defines the IP address of the client since it must be static
- Create Ansible step to create config from template
- name: Create WireGuard Server Config ansible.builtin.template: src: "wg0.conf.j2" dest: "/etc/wireguard/wg0.conf" owner: root group: root mode: "0600" # notify: Restart WireGuard Server
- Using the fine
templates/wg0.conf.j2
- Create the file based off the template in
/etc/wireguard/wg0.conf
- Using the fine
-
Create the Client Config Directory This needs to be treated reasonably securely - I’m putting it in the directory here as an example but it should really be retained in Vault or someplace similar.
- name: Create Client config directory ansible.builtin.file: dest: "/etc/wireguard/clients" owner: root group: root state: directory mode: "0600"
-
Create the template for the client configs -
templates/client.conf.j2
# Configuration for {{ item.key }} ONLY [Interface] PrivateKey = {{ item.value.key }} Address = 172.20.100.{{ item.value.number }}/32, fd0d:27ad:abcd::{{ item.value.number }}/128 [Peer] PublicKey = {{ wireguard_public_key}} AllowedIPs = 0.0.0.0/0, ::/0 Endpoint = vpn.domain.com:51820
This loops through the times and creates the file for each of them.
- Create Ansible step to create configs from template
- name: Create Client Configurations ansible.builtin.template: src: "client.conf.j2" dest: "/etc/wireguard/clients/{{ item.key }}.conf" owner: root group: root mode: "0600" loop: "{{ lookup('dict', users) }}" loop_control: label: "{{ item.key }}"
- This loops through every item of
users
- Creates an individual file for each
item
with the contents of the template
- This loops through every item of
-
Setting services to start on boot and starting them
- name: Enable wg-quick@wg0.service ansible.builtin.systemd: name: wg-quick@wg0.service enabled: yes - name: Start wg-quick@wg0.service ansible.builtin.systemd: name: wg-quick@wg0.service state: started
-
Check the services
- name: Check status of wg-quick@wg0.service ansible.builtin.command: cmd: systemctl status wg-quick@wg0.service register: service_status - name: Show service status ansible.builtin.debug: msg: "{{ service_status.stdout_lines }}"
Honestly - this is a personal preference that I’m getting into the habit of doing as a final check. This will eventually be put into OpenSearch but that’s a topic for another day. ;)
-
Define Handler(s)
handlers/main.yml
The purpose of the handler is to take some action when called. Multiple tasks can call the handler but it will only execute once when all the actions that call that handler are complete. This can be anything that needs to happen - here we are using it to restart the service.
--- - name: Restart_wireguard ansible.builtin.systemd: name: wg-quick@wg0.service # Replace wg0 with your WireGuard interface name if different state: restarted daemon_reload: true
-
Register Handler in playbook
${PROJECT_DIR}/main.yml
In the top matter of the file include the include lines. This tells Ansible where to look for the handlers.
handlers: - include: handlers/main.yml
Execute Ansible Playbook
- Encrypt the Vault
cd $PROJECT_DIR/ansible ansible-vault encrypt vars/vault.yml --vault-password-file=pwfilt.txt
- Execute Playbook
ansible-playbook -i inventory.yml playbook.yml --vault-password-file=pwfile.txt
-
Output3
ansible-playbook -i inventory.yml main.yml --vault-password-file=pwfile.txt --limit wireguard-test [DEPRECATION WARNING]: "include" is deprecated, use include_tasks/import_tasks instead. See https://docs.ansible.com/ansible-core/2.14/user_guide/playbooks_reuse_includes.html for details. This feature will be removed in version 2.16. Deprecation warnings can be disabled by setting deprecation_warnings=False in ansible.cfg. PLAY [Update and Install WireGuard on Localhost] ****************************************************************************************************************************************************************** skipping: no hosts matched PLAY [Configure WireGuard Server] ********************************************************************************************************************************************************************************* TASK [Gathering Facts] ******************************************************************************************************************************************************************************************** ok: [wireguard-test] TASK [Update MOTD] ************************************************************************************************************************************************************************************************ changed: [wireguard-test] TASK [Install WireGuard and Firewall] ***************************************************************************************************************************************************************************** ok: [wireguard-test] TASK [Create WireGuard Server Config] ***************************************************************************************************************************************************************************** ok: [wireguard-test] TASK [Create Client config directory] ***************************************************************************************************************************************************************************** ok: [wireguard-test] TASK [Create Client Configurations] ******************************************************************************************************************************************************************************* ok: [wireguard-test] => (item=temp_user_10) ok: [wireguard-test] => (item=temp_user_20) ok: [wireguard-test] => (item=temp_user_30) ok: [wireguard-test] => (item=temp_user_40) ok: [wireguard-test] => (item=temp_user_50) ok: [wireguard-test] => (item=temp_user_60) TASK [Enable wg-quick@wg0.service] ******************************************************************************************************************************************************************************** changed: [wireguard-test] TASK [Start wg-quick@wg0.service] ********************************************************************************************************************************************************************************* changed: [wireguard-test] TASK [Check status of wg-quick@wg0.service] *********************************************************************************************************************************************************************** changed: [wireguard-test] TASK [Show service status] **************************************************************************************************************************************************************************************** ok: [wireguard-test] => { "msg": [ "● wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0", " Loaded: loaded (/lib/systemd/system/wg-quick@.service; enabled; preset: enabled)", " Active: active (exited) since Tue 2023-12-26 15:12:18 UTC; 1s ago", " Docs: man:wg-quick(8)", " man:wg(8)", " https://www.wireguard.com/", " https://www.wireguard.com/quickstart/", " https://git.zx2c4.com/wireguard-tools/about/src/man/wg-quick.8", " https://git.zx2c4.com/wireguard-tools/about/src/man/wg.8", " Process: 4897 ExecStart=/usr/bin/wg-quick up wg0 (code=exited, status=0/SUCCESS)", " Main PID: 4897 (code=exited, status=0/SUCCESS)", " CPU: 152ms" ] } PLAY RECAP ******************************************************************************************************************************************************************************************************** wireguard-test : ok=10 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Conclusion
This process should successfully execute and configure your WireGuard server from start to finish. Initially, we manually configured the server to develop the Ansible playbook. While this approach was sufficient to get the server up and running, it lacked the scalability and replicability essential for efficient management. Without Ansible, each new user configuration would require manual setup, a time-consuming and error-prone process. Now, by simply making an entry into the vault and executing the playbook, everything is deployed in a standardized and consistent manner. This automation not only saves time but also ensures uniformity across different environments or machines. Imagine the complexity of explaining the addition of a new user over the phone using the manual method versus the streamlined process we have now established with Ansible. This approach significantly simplifies the management of the VPN server, making it more accessible and manageable.
– Cheers
Ansible for DevOps - If you’re just starting with Ansible this is the book you want. Get it from Leanpub and if there are any updates to the book you will also receive them. (Digital Only)
-
Corporate entities that handle your data such as ATT, Google, Amazon, Microsoft are known to read or analyze their customer’s un-encrypted data or network traffic. ↩︎
-
Do not use the keys in the examples. I intentionally change them so they won’t work if you copy and paste. Generating them is simple and I really want you to be secure ↩︎
-
I’m executing this against the testing server not the production wireguard servers. You are too, right? ↩︎