Wireguard VPN on AWS

Wireguard is great piece of tech and has been on a rise lately. What makes it so great? A few things actually: simplicity and performance, both of those points are the key ingredients for its success. Of course everyone has a different success criteria, but in general folks seem to accept this new type of VPN setup pretty well, ranging from hobbyists running Raspberry PIs at home, all the way up to companies deploying Wireguard on their entire fleet of machines.

Backstory

I had initially discovered Wireguard last year, when one of the client projects i was working on needed a simple and secure way of exposing an internal service to the internets, particularly to employees of the company. Basically, think of a business dashboard that regular folks (non-devs) need to open a few times throughout the day. A setup like that typically involves a set of firewall rules to whitelist IP addresses, however, that could get out of hand fairly quickly with clients changing IP addresses often, so the solution moving forward was to integrate a VPN server.

Wireguard worked very well in that scenario, so I ended up researching another project: using Wireguard as a VPN gateway for AWS. There are many ways to go about it, usually by using a product like AWS VPN Gateway, though I wanted to explore some other options.

Overview

Alright, our objective is to allow technical staff (i.e developers) access to AWS resources through a VPN tunnel managed by Wireguard. That includes ability to communicate with EC2 instances and other managed resources like RDS/Elasticache by their internal IP addresses (like 10.x.x.x) as well as being able to resolve AWS-provided DNS names like ip-10-x-x-x.ec2.internal or any user-defined internal DNS Zones locally.

Let's make a few assumptions about our AWS network setup:

  • Region: us-east-1
  • VPC CIDR: 10.0.0.0/16
  • Subnets (public): 10.0.1.0/24, 10.0.2.0/24, 10.0.3.0/24
  • Gateway network (Wireguard): 10.10.0.0/24
  • Gateway internal endpoint: 10.10.0.0
  • Gateway external endpoint: static IP (known after setup)

IMPORTANT: Make sure that Wireguard network CIDR does not overlap with the VPC CIDR!

VPC range and subnets should not really matter that much, but for demo purposes we are going to create resources in the first public subnet only. You can use any existing VPC, though it's better if you can create a separate one for testing purposes.

Instead of creating and configuring all bits and pieces manually we are going to use a few tools to automate the process. Automation is especially valuable on platforms like AWS due to amount of flexibility they provide, managing various configuration options via the console UI becomes tedious pretty fast. The tool set:

  • Terraform - to manage infrastructure components.
  • Ansible - to configure Wireguard and DNS.

DNS Configuration

There's a little tricky gotcha with DNS resolution in a split-tunnel Wireguard setup scenario: Wireguard configuration does not allow changing ports for DNS servers, which means that if we are to run a custom DNS server on the same EC2 instance, it must run on a standard 53 port.

However, the default DNS resolution on Ubuntu 18.04/20.04 LTS (and probably other Linux distros) is powered by systemd-resolved, that uses that port by default. We would need to disable it first in order to run our own DNS server (with custom configs). All this is taken care of in the Ansible playbook.

Requirements

Before we can start with configuration, it's important to get the environment setup first:

  • Ansible 2.x
  • Terraform 0.14.x
  • Wireguard Tools
  • AWS Account

You can check if your development environment has all these tools already installed:

ansible-playbook --version
terraform --version
wg --help

To install them with Homebrew on Mac (my main dev env):

brew install ansible
brew install terraform
brew install wireguard-tools

Get Started

For demo purposes we're going to create our infrastructure from scratch, which involves many steps, and to simplify the whole process i've put together a fully functional demo repository.

The repository will provide you with the building blocks in case if you want to expand the setup we're covering in this post (you can also learn a thing or two by using the automation tools).

Configuration

Let's start by cloning the repository:

git clone https://github.com/sosedoff/wireguard-aws-gateway
cd wireguard-aws-gateway

Next step is to create configuration files and private keys:

./scripts/init

You'll see an output like:

Generating a new SSH key: ./ansible/keys/ssh
Generating public/private rsa key pair.
Your identification has been saved in ./ansible/keys/ssh.
Your public key has been saved in ./ansible/keys/ssh.pub.
Generating a new Wireguard key: ./ansible/keys/wireguard
Generating a new Wireguard client key: ./ansible/keys/wireguard_client
Creating Terraform variables file: ./terraform/terraform.tfvars
Creating Ansible variables file: ./ansible/vars.yml

As a result, init script will create config files for Ansible and Terraform, as well as SSH key for our new EC2 instance, Wireguard server and a client key. We'll need to fill in our AWS credentials in order to run Terraform CLI.

You must replace AWS credentials in the terraform.tfvars file before moving onto next step!

Running Terraform

We're using Terraform to create all required infrastructure components in AWS.

Configuration has already been created for us by the init script, let's have a quick look at ./terraform/terraform.tfvars file:

aws_access_key        = "XXX"
aws_secret_key        = "XXX"
region                = "us-east-1"
vpc_cidr              = "10.0.0.0/16"
vpc_public_subnets    = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
gateway_network       = "10.10.0.0/24"
gateway_instance_type = "t3.micro"

If you have correctly configured Terraform in the previous steps and all variables satisfy your environment, you can start the provisioning script with the following command:

./scripts/provision

Behind the scenes the script will execute a few commands:

  • terraform init - Initialize Terraform modules and state
  • terraform fmt - Format any *.tf files so they're easy to read
  • terraform validate - Validate configuration
  • terraform apply - Plan and apply changes

Terraform will plan the changes to make to your AWS account and if everything looks good you'll be prompted with a confirmation message. Enter yes to continue.

Execution itself may take a few minutes, and the end result will look similar to:

Initializing modules...
Initializing the backend...
Initializing provider plugins...
...

Apply complete! Resources: X added, 0 changed, 0 destroyed.
Outputs:
gateway_public_ip = "123.456.789.000"

You will see a gateway_public_ip among the lines in the output. That's your public VPN gateway IP address, which we will use to connect to later. So far, we have our new VPC, EC2 instance and Elastic IP provisioned and ready to go.

Running Ansible

Ansible will configure the system, services and packages required to run Wireguard and DNS server on our EC2 instance.

Again, an example configuration has been created by the init script, so let's have a look:

gateway:
  # Server private/public wireguard keys.
  private_key: "XXX"
  public_key: "XXX"

  # Name of the tunnel network interface.
  tunnel: "wg0"

  # Name of the network interface to forward traffic to.
  # Usually it's `eth0` but has a different default on AWS.
  interface: "ens5"

  # VPN network range. Should not overlap with VPC CIDR!
  network: "10.10.0.0/24"

  # Wireguard listen address. This is the default.
  listen_port: "51820"

  # Optional keepalive setting for sensitive clients.
  persistent_keepalive: "60"

  # List of client peers you would like to connect with.
  # These keys should be provided by clients or generated with `wg` toolkit.
  peers:
    - name: user1
      public_key: "XXX"
      addr: "10.10.0.1"

coredns:
  # Version of the coredns binary
  version: "1.8.1"

  # AWS provides a resolver on address equals to VPC base range + 2.
  # If the VPC CIDR is 10.0.0.0/16 then resolver is available at 10.0.0.2.
  forward_to: "10.0.0.2"

  # If private zone name is provided all peers from the section above will
  # get their own DNS entries, like `user1.private`, etc.
  private_zone: "private"

Plenty of comments to read along, but the important bits are:

  • AWS EC2 instances use ens5 network interface, whereas other providers like DigitalOcean or Linode use eth0 as a default.
  • Network range 10.10.0.0/24 is reserved for Wireguard VPN, so make sure it does not overlap with your existing VPC CIDR.
  • Internal DNS resolution is done by forwarding unmatched DNS queries to the 10.0.0.2 address. Read more on DNS resolution.

To continue our journey, let's start the configuration process:

./scripts/configure

This scripts kicks off the ansible-playbook -i ./hosts provision.yml command under the hood, though it has a few extra steps to make sure the correct inventory hosts file is generated based on the Terraform outputs from the previous step.

Give it a few mins to run. Successful output might look like:

PLAY [all] *****************************************************************************************************************************************************

TASK [setup] ***************************************************************************************************************************************************
ok: [wireguard]

TASK [Update package cache] ************************************************************************************************************************************
changed: [wireguard]

TASK [Install basic packages] **********************************************************************************************************************************
ok: [wireguard]

TASK [Enable IPV4 forwarding] **********************************************************************************************************************************
ok: [wireguard]

TASK [Check if CoreDNS is installed] ***************************************************************************************************************************
ok: [wireguard]

Great! We've got everything configured. In case if something didn't work out on the first try (connection failed, etc, you know), you can just re-run the same command and it should finish up without much trouble.

As a result, we have so far:

  • VPN gateway running Wireguard at 123.456.789.000 on port 51820.
  • VPN network 10.10.0.0/24
  • DNS Server running on 10.10.0.0
  • Single client configured on 10.10.0.1
  • Forwarding traffic to AWS VPC 10.0.0.0/16

Client Configuration

VPN gateway is ready to go, now we need to configure our local client. To get the configuration, run the script:

./scripts/client-config

Example output:

[Interface]
PrivateKey = XXX
Address = 10.10.0.1/32
DNS = 10.10.0.0 # <- custom dns

[Peer]
PublicKey = XXX
AllowedIPs = 10.10.0.0/24, 10.0.0.0/16
Endpoint = 123.456.789.000:51820

Wireguard official website provides a list of installation instruction for different platforms. Pick your platform and configure the tunnel with configuration file from the script above.

Running Tests

Final step in this setup is to make sure our tunnel and DNS resolution works as expected.

First, activate your Wireguard VPN connection. Should be done by running wg-quick wg0 up on Linux machines or by simply clicking Connect on Windows/Mac GUI clients. We've got a simple script to verify the setup, simply run:

./scripts/verify

All the steps above should be successful and should always return the same ip: 10.0.x.x. If you happen to have any other resources in your VPC, you can ping them (or resolve their names) similarly.

The script also verifies that accessing domain records like user1.private works. Those will resolve to an address in the VPN range, like 10.10.0.1.

Conclusion

We've got ourselves a VPN gateway! So far, we're able to connect the the AWS private network and resolve internal DNS names locally without need to forward all local traffic to VPN (via 0.0.0.0/0 route) which makes it a pretty low overhead solution.

While the whole setup process might look menacing to you, in reality the bulk of work is actually taken up by the automation tasks. Wireguard itself is super easy to configure, however, having a reliable process to manage infrastructure, configuration and dynamic settings is definitely worth it in the long term. Especially when dealing with multiple environments with a different sets of options.

Check out full source code on Github.