< Back to Blog

Exploring Ansible via Setting Up a WireGuard VPN

By
|
March 4, 2021
Tutorial

Table of Contents

In my [previous blogpost](https://www.tangramvision.com/blog/what-they-dont-tell-you-about-setting-up-a-wireguard-vpn), we set up a WireGuard VPN server and client and learned about various configuration options for WireGuard, how to improve VPN server uptime, how to relay traffic, and more. Setting up a server and client like that is a lot of work! If the server dies or you want to set up a new server (maybe for a friend or family member this time), you have to go back to the walk-through and follow all the steps, remembering if you deviated from those instructions at any point.

There's a better way — automation! If you're only going to do a thing once (e.g. set up a VPN), investing in automation probably doesn't make sense. But if you anticipate doing a thing repeatedly, automating it frees up your time to learn and accomplish more in the future. You can also share your automation, empowering others to build and achieve more, faster.

Automation is the heart of computing, and many different automation tools and approaches have sprung up over time. For our project of automating VPN server setup, we can consider a variety of tools:

- Shell scripts
   - The simplest approach from a tooling perspective, writing shell scripts would involve running the commands from the [previous WireGuard tutorial blogpost](https://www.tangramvision.com/blog/what-they-dont-tell-you-about-setting-up-a-wireguard-vpn), using `ssh` for the commands that run on the server and `rsync` to copy configurations files to the server.
- SSH scripting libraries like [Capistrano](https://capistranorb.com/documentation/overview/what-is-capistrano/) or [Fabric](http://www.fabfile.org/)
   - If shell scripting isn't ideal, there are libraries that expose similar scripting functionality in a more ergonomic interface for developers familiar with higher-level languages like Ruby and Python.
- Infrastructure/configuration automation tools like [Puppet](https://puppet.com/), [Chef](https://www.chef.io/), or [Ansible](https://www.ansible.com/)
   - Tools in this category are even more specialized for automating server infrastructure and configuration, often including an ecosystem of packages and plugins to automatically set up or configure nearly anything you can think of.
- Infrastructure-as-code tools like [Terraform](https://www.terraform.io/)
   - Infrastructure-as-code (IaC) tools have a lot of overlap with the above category, but support provisioning cloud resources in a more first-class/native way.
- Containers like [Docker](https://www.docker.com/)
   - You could also run WireGuard in containers, deploying a server-configured container image to a cloud provider and running a client-configured container image locally to connect to the server. There are [a few](https://medium.com/@firizki/running-wireguard-on-docker-container-76355c43787c) [existing](https://hub.docker.com/r/linuxserver/wireguard) [examples](https://blog.jessfraz.com/post/installing-and-using-wireguard/) of this approach.

For this tutorial, I'm going to focus on the middle category above — infrastructure/configuration automation tools — and specifically, I'll focus on Ansible. There is a [great comparison of different tools in this area](https://blog.gruntwork.io/why-we-use-terraform-and-not-chef-puppet-ansible-saltstack-or-cloudformation-7989dad2865c) by Gruntwork and, even though that article favors Terraform, Ansible is still a useful general-purpose tool, especially if you're working with servers that aren't "in the cloud", such as a Raspberry Pi at home.

Let's get started with automating VPN setup with Ansible! By the end of this article, we'll be able to set up a VPN server and client with a single command. Similar to the previous blogpost, I'll use Ubuntu 20.04 and DigitalOcean droplets.

# Setting up Ansible

Ansible can be [installed via an OS package manager](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) like `apt`, but I prefer to use `pip` so I can get the latest updates and avoid cluttering system package management with third-party PPAs (Personal Package Archives). We'll also use `pyenv` (as suggested by [Hypermodern Python](https://medium.com/@cjolowicz/hypermodern-python-d44485d9d769#6e8a)) to make sure we're not breaking or cluttering the system Python installation. Install `pyenv` with the following:

```bash
# From https://github.com/pyenv/pyenv/wiki#suggested-build-environment
sudo apt-get update

sudo apt-get install --no-install-recommends make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev

curl https://pyenv.run | bash
```

It's a good habit when a tutorial gives you `curl <url> | bash` to open up that URL and see what it's going to do. In this case, you'll see that it'll download and execute a shell script on GitHub that will clone 6 repos from GitHub to your `~/.pyenv` folder and prompt you to add a few lines to your shell's initialization script.</url>

Follow the output prompt from above, which asks you to put lines like the below in your shell initialization script (e.g. `~/.bashrc` if you use the bash shell). Make sure to fill in your own username!

```bash
export PATH="/home/YOUR_USERNAME/.pyenv/bin:$PATH"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
```

Install a recent python version:

```bash
# List available python versions
pyenv install --list

# Install a specific version
pyenv install 3.9.2

# (Suggested) If you want to always use that version when running `python`
# in your terminal
pyenv global 3.9.2
```

If you want, you can also create a [virtualenv](https://virtualenv.pypa.io/en/latest/) to further isolate the Ansible installation, and make that virtualenv automatically activate when you're in a particular folder/repo. That would look like:

```bash
# (Optional)

# Feel free to pick a different virtualenv name than "ansible-tutorial"
pyenv virtualenv 3.9.2 ansible-tutorial

# Create a .python-version file that pyenv will find when your shell is in the
# same directory (or a sub-directory) and automatically activate the named
# virtualenv
pyenv local ansible-tutorial
```

Install the `ansible` pip package, which will install various command-line tools, including `ansible-playbook`, which we'll use to run a "playbook" of commands that will set up a VPN server and client for us.

```bash
pip install ansible

# Confirm installation worked
ansible --version
```

# Get a Server

To use Ansible for a VPN server, we need... a server! Ansible could provision a server from a cloud provider for us (and I'll touch on this briefly later), but we'll keep our playbook hardware-provider-agnostic for now, so you can run it as easily against a cloud server as a Raspberry Pi on your home network. I'm going to [create a $5/month DigitalOcean droplet](https://www.digitalocean.com/docs/droplets/how-to/create/) to test against, but you could also [use Vagrant](https://docs.ansible.com/ansible/latest/scenario_guides/guide_vagrant.html) (to test against a local VM) or any server you can SSH to.

Testing Ansible playbooks against VMs, rather than a bare-metal machine, comes with an advantage — after you've written the playbook, you can start a new, empty VM and test the whole playbook start to finish to ensure that it works consistently.

# Connecting to the Server with Ansible

Once you have your server or VM, take note of its IP address use it to create an `inventory.ini` file like the below:

```
[vpn]
vpn_server ansible_host=203.0.113.1 ansible_user=root
```

An [inventory file](https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html) tells Ansible what servers it can act upon and how to access them. Let's use the above inventory file as an example. When we run Ansible and target the `vpn` **group** of servers or the `vpn_server` **host**, it will try to connect to the server using a command like:

```bash
ssh root@203.0.113.1
```

So, if you can't SSH to the server, then Ansible won't be able to connect either!

Connecting to the server with an SSH key is strongly recommended! [Add your SSH key to your server](https://www.digitalocean.com/community/tutorials/how-to-set-up-ssh-keys-2) to connect without needing a password. If you must connect with a password, you can `sudo apt install sshpass` and then provide your SSH password when using Ansible by adding the `--ask-pass` flag to all ansible commands.

Let's test to make sure that Ansible can connect to the server:

```bash
ansible -i inventory.ini -m ping vpn
```

This runs the [ping Ansible module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/ping_module.html), targeting the `vpn` group of servers. You should see "pong" in the output, meaning that Ansible could connect to the server and the server has a Python installation that Ansible can use.

# Ansible's Built-in Variables and Facts

There are other useful Ansible modules that we can use with the `ansible` command:

- The [setup module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/setup_module.html) fetches [system information, also known as "facts"](https://docs.ansible.com/ansible/latest/user_guide/playbooks_vars_facts.html), about the server. You can use these facts as variables in Ansible commands and playbooks.
- The [debug module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/debug_module.html) can evaluate variables, which is useful for... well, debugging!

Try running both of these modules with your server so you can see what facts and information Ansible makes available:

```bash
ansible -i inventory.ini -m setup vpn
ansible -i inventory.ini -m debug -a "var=hostvars" vpn
```

This was one of the most confusing parts for me when learning Ansible — figuring out what all these built-in variables and facts (like `groups`, `inventory_dir`, and `ansible_distribution`) were and how to find them.

# Writing an Ansible Playbook

The `ansible` command lets you run [ad-hoc commands](https://docs.ansible.com/ansible/latest/user_guide/intro_adhoc.html) across groups of servers. This is powerful, but we probably shouldn't try to automate server setup and configuration in a single `ansible` command... probably. 🤔 Instead, we can organize multiple tasks in one or multiple YAML files, which we will run with the `ansible-playbook` command.

Let's write a `playbook.yml` file In the same folder as `inventory.ini`. Here are its contents:

```yaml
---
- name: setup vpn server
 hosts: vpn_server
 tasks:
 - name: ping
   ping:
 - name: show variables and facts
   debug: var=hostvars
```

If you're not familiar with [YAML](https://en.wikipedia.org/wiki/YAML), the above is equivalent to this JSON structure:

```json
[{'name': 'setup vpn server',
 'hosts': 'vpn_server',
 'tasks': [{'name': 'ping', 'ping': None},
           {'name': 'show variables and facts', 'debug': 'var=hostvars'}]}]
```

Breaking down the above:

- The top-level structure is a "play" in Ansible lexicon. Our play above has a `name`, a `hosts` [pattern](https://docs.ansible.com/ansible/latest/user_guide/intro_patterns.html#intro-patterns) which describes which servers the play will run against, and a list of `tasks`.
- We have 2 tasks, each has a `name` and the name of an Ansible module that will do something.

Run the playbook...

```bash
ansible-playbook -i inventory.ini playbook.yml
```

... and you'll see that it gathers facts from the server (just like the `ansible -m setup` command above did), and then runs the "ping" task and the "debug" task to show all the gathered facts and variables defined for `vpn_server`.

There are tons of [built-in Ansible modules](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/index.html#modules), even more [curated Ansible community modules](https://docs.ansible.com/ansible/latest/collections/index.html), and even more published to [Ansible Galaxy](https://galaxy.ansible.com/home) (an open repository for Ansible collections and roles).

# WireGuard Server Setup

There's much more to learn about Ansible! But let's stop here and apply what we've learned in order to set up a WireGuard server.

Referring to the steps we took in [the previous tutorial](https://www.tangramvision.com/blog/what-they-dont-tell-you-about-setting-up-a-wireguard-vpn), we want to:

1. Install the `wireguard` system package
2. Create public and private keys with correct permissions
3. Create the server's WireGuard configuration file
4. (Optionally) Enable IP forwarding for relaying traffic
5. Start the VPN

## Managing the Keys

As [hinted at in the previous tutorial](https://www.tangramvision.com/blog/what-they-dont-tell-you-about-setting-up-a-wireguard-vpn), if we want to repeatably deploy the VPN server without needing to reconfigure all VPN clients, we need to use the same private key every time.

Put another way: if we generated a private key while deploying the server and used the corresponding public key on various clients, and the server ends up dying, we *could* deploy it again by generating a new private key. However, all of our VPN clients would then need to update to the *new* public key to be able to connect to the *new* VPN server. This would be inconvenient!

Instead, we'll generate the server keys once by hand and use them in the playbook so they're consistent between every deploy. This means we won't include step #2 from above in the Ansible playbook.

Generate the keys with `wg genkey` and `wg pubkey` commands. You can output both with the following command:

```bash
privkey=$(wg genkey) sh -c 'echo "
   server_privkey: $privkey
   server_pubkey: $(echo $privkey | wg pubkey)"'
```

Copy the output lines and add them to a new `vars` mapping under the play in `playbook.yml`. Here's what mine looks like now (your keys will be different):

```yaml
---
- name: setup vpn server
 hosts: vpn_server
 vars:
   server_privkey: aBYk1JZyP8ck+FeaTjb3xi94U4Nv8V+gWoTW1hRLQlo=
   server_pubkey: 7/6f7bUT+2hWMEP5BxeK51PGuMuTnQ9pRpkxg5jUSTo=
 tasks:
 # ...
```

### Encrypting the Private Key

It's a good practice to AVOID having secrets in plaintext (like the VPN private key above). This is especially true if those secrets will be shared with anyone else, like via a git repo. Let's prevent this by using [Ansible Vault](https://docs.ansible.com/ansible/latest/user_guide/vault.html). Vault is a tool for encrypting secret values and using them in playbooks. Encrypt the private key with:

```yaml
ansible-vault encrypt_string --ask-vault-password --stdin-name server_privkey
```

You'll be prompted twice for a Vault encryption password, after which you'll paste your `privkey` value and hit `Ctrl+d` twice. If the command completed after a single `Ctrl+d`, try again and make sure you're not copy-pasting an invisible newline character at the end of the `privkey` value. Copy the output into your playbook, which will now look like:

```yaml
---
- name: setup vpn server
 hosts: vpn_server
 vars:
   server_privkey: !vault |
         $ANSIBLE_VAULT;1.1;AES256
         646438636565343063343631326136386239623935393637336539653636386135363
         663386639393232346534643163656363316234306439306566306534610a31326664
         363763663139383034636632343230376365333130333230373866353033326563303
         5636138373830633534373033303536303566663166616539360a3936353033663263
         336662663034376661616631343661333164363134373061343739633637623739306
         465653532383838393662396333623966343165366635353132396332313762343534
         65313761623964653532623839356633343838
   server_pubkey: 7/6f7bUT+2hWMEP5BxeK51PGuMuTnQ9pRpkxg5jUSTo=
 tasks:
 ...
```

Make sure to remember your encryption password (and save it in a password manager); you'll need to enter it every time you run the playbook.

## Installing and Configuring WireGuard

Next, we'll remove our testing `ping` and `debug` tasks and write tasks for steps 1, 3, 4, and 5 from the above list. These steps translate neatly into Ansible tasks in our updated `playbook.yml`:

```yaml
---
- name: setup vpn server
 hosts: vpn_server
 vars:
   server_privkey: !vault |
         $ANSIBLE_VAULT;1.1;AES256
         646438636565343063343631326136386239623935393637336539653636386135363
         663386639393232346534643163656363316234306439306566306534610a31326664
         363763663139383034636632343230376365333130333230373866353033326563303
         5636138373830633534373033303536303566663166616539360a3936353033663263
         336662663034376661616631343661333164363134373061343739633637623739306
         465653532383838393662396333623966343165366635353132396332313762343534
         65313761623964653532623839356633343838
   server_pubkey: 7/6f7bUT+2hWMEP5BxeK51PGuMuTnQ9pRpkxg5jUSTo=
 tasks:
 # https://docs.ansible.com/ansible/latest/collections/ansible/builtin/apt_module.html
 - name: install wireguard package
   apt:
     name: wireguard
     state: present
     update_cache: yes

 # https://docs.ansible.com/ansible/latest/collections/ansible/builtin/copy_module.html
 - name: create server wireguard config
   template:
     dest: /etc/wireguard/wg0.conf
     src: server_wg0.conf.j2
     owner: root
     group: root
     mode: '0600'

 # https://docs.ansible.com/ansible/latest/collections/ansible/posix/sysctl_module.html
 - name: enable and persist ip forwarding
   sysctl:
     name: net.ipv4.ip_forward
     value: "1"
     state: present
     sysctl_set: yes
     reload: yes

 # https://docs.ansible.com/ansible/latest/collections/ansible/builtin/systemd_module.html
 - name: start wireguard and enable on boot
   systemd:
     name: wg-quick@wg0
     enabled: yes
     state: started
```

Ok ok, yes, this is a bit like drawing an owl.

![Draw an owl in 2 steps meme](https://assets-global.website-files.com/5fff85e7f613e35edb5806ed/6041135acb917421717126e4_censored-owl2.jpg)
*Source: [https://29.media.tumblr.com/tumblr_l7iwzq98rU1qa1c9eo1_500.jpg](https://29.media.tumblr.com/tumblr_l7iwzq98rU1qa1c9eo1_500.jpg)*

...but usually an ansible playbook like the above can be written quickly. I follow a cycle:

1. Type "ansible module install package" into a search engine
2. Open the [docs.ansible.com](http://docs.ansible.com) result that looks most helpful
3. Read through available parameters and the (often helpful) examples at the bottom
4. Copy an example into my playbook and modify parameters as needed
5. Go back to step 1, searching for the next task (e.g. "ansible module template file")

I've included a comment line linking to the Ansible docs page for each module used in the `playbook.yml` above, in case you want to read about the parameters.

## Testing our First Attempt

Let's test our playbook.

```yaml
$ ansible-playbook -i inventory.ini --ask-vault-password playbook.yml
Vault password:

PLAY [setup vpn server] ********************************************************

TASK [Gathering Facts] *********************************************************
ok: [vpn_server]

TASK [install wireguard package] ***********************************************
changed: [vpn_server]

TASK [create server wireguard config] ******************************************
fatal: [vpn_server]: FAILED! => {"changed": false, "msg": "Could not find or access 'server_wg0.conf.j2'\nSearched in: ..."}

PLAY RECAP *********************************************************************
vpn_server                 : ok=2    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
```

Oh no! Installing WireGuard was successful, but creating the config failed. Ansible's error messages are usually helpful, and this one indicates that the template file (`server_wg0.conf.j2`) we're trying to use to create the server's configuration couldn't be found. Let's create it at `templates/server_wg0.conf.j2`:

```yaml
# {{ ansible_managed }}
[Interface]
Address = 10.0.1.1/24
ListenPort = 51820
PrivateKey = {{ server_privkey }}
```

A few notes about the above:

- Ansible automatically searches in relative paths like `templates/` and `files/` when running Ansible modules that have a `src` parameter. Our `template` task has a parameter `src: server_wg0.conf.j2`, so Ansible will search for it in the `templates/` folder.
- It's convention to suffix template files with `.j2`, to indicate that the file will be [templated with Jinja2](https://docs.ansible.com/ansible/latest/user_guide/playbooks_templating.html).
- In Jinja2, values inside double curly braces (`{{ variable }}`) will be replaced with the value of the variable. In this template, the `server_privkey` variable will be decrypted and its value inserted into the resulting file in place of `{{ server_privkey }}`.
- The `{{ ansible_managed }}` text is replaced with the string "Ansible managed". It's a good convention to put this in a comment at the top of templated files, because it signals to anyone reading the file on the server that the file is managed by Ansible — any edits they make could be overwritten when Ansible next runs, so they should find and make edits in the corresponding Ansible playbook and template files instead.

Let's run the test again:

```yaml
$ ansible-playbook -i inventory.ini --ask-vault-password playbook.yml
Vault password:

PLAY [setup vpn server] ********************************************************

TASK [Gathering Facts] *********************************************************
ok: [vpn_server]

TASK [install wireguard package] ***********************************************
ok: [vpn_server]

TASK [create server wireguard config] ******************************************
changed: [vpn_server]

TASK [enable and persist ip forwarding] ****************************************
changed: [vpn_server]

TASK [start wireguard and enable on boot] **************************************
changed: [vpn_server]

PLAY RECAP *********************************************************************
vpn_server                 : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```

It succeeded! The WireGuard interface is now running on the server.

Notice that the "install wireguard package" step shows `ok` instead of `changed` this time. The `apt` module (and most modules) detect that the server is already in the desired state (the `wireguard` package was installed last time we ran the playbook, so it satisfies `state=present`) and perform no actions. The task is [idempotent](https://docs.ansible.com/ansible/latest/user_guide/playbooks_intro.html#desired-state-and-idempotency), meaning you can run it repeatedly and the outcome is the same. Idempotent tasks make it easy to see what changed and what didn't each time a playbook is run.

# WireGuard Client Setup

Ansible can also operate on the local machine. To set up our local machine as a client, we want to:

1. Install the `wireguard` system package
2. Create public and private keys with correct permissions
3. Create the client's WireGuard configuration file, which must include the server's public key
4. Start the VPN

We also need to update the server's configuration file with a `[Peer]` section including the client's public key, so the client can connect to the server. The client's public key isn't known until after we create it — we could create client keys manually like we did for the server's keys, but then the playbook wouldn't be able to set up multiple clients without having to manually edit the keys for each client.

## Acting on Localhost

Because we're targeting a new host (`localhost`), we need to write a new play in `playbook.yml`. We can put it above the existing play (which targets `vpn_server`), so the client's keys are generated before the server config is templated.

```yaml
---
- name: setup vpn client
 hosts: localhost
 connection: local
 become: yes
 vars:
   # Use system python so apt package is available
   ansible_python_interpreter: "/usr/bin/env python"
 tasks:
   # Coming soon

- name: setup vpn server
 hosts: vpn
 # Rest of server vars/tasks here...
```

Lots of new things here!

- We target the local machine with using `[localhost](http://localhost)` for the hosts pattern.
- We "connect" locally by using the `local` [connection plugin](https://docs.ansible.com/ansible/latest/plugins/connection.html).
- The `become: yes` line indicates that the play will run as root, which we need to be able to install the `wireguard` package. Ansible will effectively run `sudo apt-get install wireguard`, rather than just `apt-get install wireguard` (which would fail). Because of this setting, we'll need to run the playbook with the `--ask-become-pass` flag. We didn't need this line for the server setup play, because we're already connecting as root via the `ansible_user=root` connection variable.
- With the `ansible_python_interpreter` var, we tell Ansible to use the system python (which includes the `apt` python package). Alternatively, we could [install that package](https://github.com/python-poetry/poetry/issues/1363) for our current python 3.9.2 installation. If you get a `No such file or directory` error, you may need to change the line from `python` to `python3`.

## Client Setup Tasks and Config

Writing the Ansible tasks for the client-side VPN setup is similar to the server side.

```yaml
---
- name: setup vpn clients
 hosts: localhost
 connection: local
 become: yes
 vars:
   # Use system python so apt package is available
   ansible_python_interpreter: "/usr/bin/env python"
 tasks:
 - name: install wireguard package
   apt:
     name: wireguard
     state: present
     update_cache: yes

 - name: generate private key
   shell:
     cmd: umask 077 && wg genkey | tee privatekey | wg pubkey > publickey
     chdir: /etc/wireguard
     creates: /etc/wireguard/publickey

 - name: get public key
   command: cat /etc/wireguard/publickey
   register: publickey_contents
   changed_when: False

 # Save pubkey as a fact, so we can use it to template wg0.conf for the server
 - name: set public key fact
   set_fact:
     pubkey: "{{ publickey_contents.stdout }}"

 - name: create client wireguard config
   template:
     dest: /etc/wireguard/wg0.conf
     src: client_wg0.conf.j2
     owner: root
     group: root
     mode: '0600'

- name: setup vpn server
 hosts: vpn_server
 # Rest of server vars/tasks here...
```

Breaking this down:

- Installing the `wireguard` package should look very familiar!
- We generate keys with the `shell` module so we can use pipes and file redirection. The keys are only generated if the `publickey` file doesn't already exist, thanks to the `creates` parameter.
- Next, we need to save the public key so we can add it as a `[Peer]` section in the server config. Normally, we'd use `{{ lookup('file', '/etc/wireguard/publickey') }}` to look up a value from a file, but the file lookup modules [seems not to respect `become: yes`](https://github.com/ansible/ansible/issues/8297#issuecomment-141109132); it tries to read the file without escalating to root privileges and fails as a result. So, we instead `cat` the file and save the resulting output as a fact.
- Finally, template the client config file. Its contents closely match the [previous tutorial's](https://www.tangramvision.com/blog/what-they-dont-tell-you-about-setting-up-a-wireguard-vpn), but we use the `ansible_host` IP address of the VPN server from `inventory.ini` to set the server's endpoint.

```yaml
[Interface]
# The address your computer will use on the VPN
Address = 10.0.0.8/32

# Load your privatekey from file
PostUp = wg set %i private-key /etc/wireguard/privatekey
# Also ping the vpn server to ensure the tunnel is initialized
PostUp = ping -c1 10.0.0.1

[Peer]
# VPN server's wireguard public key
PublicKey = {{ server_pubkey }}

# Public IP address of your VPN server (USE YOURS!)
# Use the floating IP address if you created one for your VPN server
Endpoint = {{ hostvars['vpn_server'].ansible_host }}:51820

# 10.0.0.0/24 is the VPN subnet
AllowedIPs = 10.0.0.0/24

# To also accept and send traffic to a VPC subnet at 10.110.0.0/20
# AllowedIPs = 10.0.0.0/24,10.110.0.0/20

# To accept traffic from and send traffic to any IP address through the VPN
# AllowedIPs = 0.0.0.0/0

# To keep a connection open from the server to this client
# (Use if you're behind a NAT, e.g. on a home network, and
# want peers to be able to connect to you.)
# PersistentKeepalive = 25
```

## Managing Variables

If we run the playbook now, it will fail with a `'server_pubkey' is undefined` error. That's because `server_pubkey` is defined for the play that targets the **server**, it's not available for the play targeting the **client**. We need to move the variable somewhere so that it's readable by the entire playbook. [Ansible looks for YAML files in a `group_vars/` folder](https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html#splitting-out-vars) where the filename matches server groups in the inventory file. So, we could create a `group_vars/vpn.yml` file and declare variables in it, which would be directly usable when running a play against any servers in the `vpn` group. We don't include `localhost` as a host in the `vpn` group (though we could). We'll instead use the special `group_vars/all.yml` file, which makes variables available to all hosts.

Move the server keys' variables from `playbook.yml` to `group_vars.all.yml`:

```yaml
---
server_privkey: !vault |
     $ANSIBLE_VAULT;1.1;AES256
     646438636565343063343631326136386239623935393637336539653636386135363
     663386639393232346534643163656363316234306439306566306534610a31326664
     363763663139383034636632343230376365333130333230373866353033326563303
     5636138373830633534373033303536303566663166616539360a3936353033663263
     336662663034376661616631343661333164363134373061343739633637623739306
     465653532383838393662396333623966343165366635353132396332313762343534
     65313761623964653532623839356633343838
server_pubkey: 7/6f7bUT+2hWMEP5BxeK51PGuMuTnQ9pRpkxg5jUSTo=
```

Your directory should now look like this:

```bash
.
├── group_vars
│   ├── all.yml
├── inventory.ini
├── playbook.yml
└── templates
   ├── client_wg0.conf.j2
   └── server_wg0.conf.j2
```

Run the playbook and the client should run all its tasks successfully:

```bash
ansible-playbook -i inventory.ini --ask-vault-password --ask-become-pass playbook.yml
```

The VPN client is now set up. The only remaining step for the client is to start the VPN after the server is running and configured to accept connections from the client (so the client's `PostUp` ping will succeed).

## Adding a Peer to the Server Config

Add a `[Peer]` section to the server template at `templates/server_wg0.conf.j2`:

```yaml
# {{ ansible_managed }}
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = {{ server_privkey }}

[Peer]
PublicKey = {{ hostvars['localhost'].pubkey }}
AllowedIPs = 10.0.0.8
```

We read the `{{ server_privkey }}` from `group_vars/all.yml` and we read `{{ hostvars['localhost'].pubkey }}` from the `set_fact` module that runs during the client-targeted play in the playbook.

## Reloading the Server Config

If we run the playbook, the config file on the server will be updated with the new `[Peer]` section, but the WireGuard interface is already running and configured based on the old file contents. We need to reload the configuration when it changes. [Handlers](https://docs.ansible.com/ansible/latest/user_guide/playbooks_handlers.html) are the Ansible-provided mechanism for this, and they trigger when a task referencing them *changes*. Handlers run at the end of the play in which they're notified, so many tasks could notify a "reload config" handler, but the handler would only run once at the end. Let's create a couple handlers in a `handlers` list after the `tasks` lists in `playbook.yml` and notify them from the `create client wireguard config` and `create server wireguard config` tasks:

```yaml
 # ...
 - name: create client wireguard config
   template:
     dest: /etc/wireguard/wg0.conf
     src: client_wg0.conf.j2
     owner: root
     group: root
     mode: '0600'
   notify: restart wireguard

 handlers:
 # Restarts WireGuard interface, loading any new config and running PostUp
 # commands in the process. Notify this handler on client config changes.
 - name: restart wireguard
   shell: wg-quick down wg0; wg-quick up wg0
   args:
     executable: /bin/bash

- name: setup vpn server
 hosts: vpn_server
 tasks:
 # ...
 - name: create server wireguard config
   template:
     dest: /etc/wireguard/wg0.conf
     src: wg0.conf.j2
     owner: root
     group: root
     mode: '0600'
   notify: reload wireguard config
 # ...

 handlers:
 # Reloads config without disrupting current peer sessions, but does not
 # re-run PostUp commands. Notify this handler on server config changes.
 - name: reload wireguard config
   shell: wg syncconf wg0
   args:
     executable: /bin/bash
# ...
```

The `template` Ansible module only performs an action and marks the task as *changed* if the config file changes — it is idempotent. Idempotence is valuable when used with handlers, because the handler will only run when the task changes. Notifying a handler on a task that isn't idempotent may result in the handler always running (e.g. a service is unnecessarily restarted everytime the playbook is run).

## Start the VPN Client

Add one final play to the end of the playbook to start the client VPN now that the server is configured to accept its connection:

```yaml
# ...
- name: start vpn on clients
 hosts: localhost
 connection: local
 become: yes
 tasks:
 - name: start vpn
   command: wg-quick up wg0
```

# Automation Complete!

Now we can run the whole playbook and — whether the server and client are brand-new or in some intermediate state — this single command will set up a WireGuard VPN server and client!

```bash
ansible-playbook -i inventory.ini --ask-vault-password --ask-become-pass playbook.yml
```

The complete Ansible code can be found at: [https://gitlab.com/tangram-vision-oss/tangram-visions-blog](https://gitlab.com/tangram-vision-oss/tangram-visions-blog)

There are many improvements that could be made:

- Provision a cloud server automatically, using an Ansible module such as [community.digitalocean.digital_ocean_droplet](https://docs.ansible.com/ansible/2.10/collections/community/digitalocean/digital_ocean_droplet_module.html).
- Automatically [update a floating IP address](https://docs.ansible.com/ansible/2.10/collections/community/digitalocean/digital_ocean_floating_ip_module.html) when provisioning a new cloud VPN server.
- Configure multiple clients automatically. One approach is to add a `vpn_clients` group to the inventory, define VPN IPs in the inventory (e.g. `vpn_ip=10.0.0.8`), and use those host variables in the config templates. When templating the server config, [loop](https://jinja.palletsprojects.com/en/2.11.x/templates/#for) over hostnames in the clients group, adding a new `[Peer]` block for each.
- Organize the playbook as [roles](https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html), one for the server and one for the client. Roles are more reusable and shareable than playbooks.
- Test and lint with [molecule](https://www.jeffgeerling.com/blog/2018/testing-your-ansible-roles-molecule) and [ansible-lint](https://ansible-lint.readthedocs.io/en/latest/).

Thanks for joining me on this Ansible-learning journey! If you have any suggestions or corrections, please let me know or [send us a tweet](https://www.twitter.com/tangramvision), and if you’re curious to learn more about how we improve perception sensors, visit us at [Tangram Vision](https://www.tangramvision.com/).

Share On:

You May Also Like:

Accelerating Perception

Tangram Vision helps perception teams develop and scale autonomy faster.