Setting up a Wireguard VPN at AWS using Terraform

Most of our resources at AWS aren’t publicly accessible via the Internet. Instead we placed them in a separate VPC to isolate them from any malicious access by an attacker or even accidental access by ourselves.

However from time to time we do want to access the resources directly:

  • During local development it may save us an enormous amount of time not having to build complex tunneling solutions within our application.
  • Certain systems should never be exposed via the public Internet but should only be reachable for dedicated and authenticated users.

My first approach was to use AWS’s internal VPN solution which turned out to be both complex to setup as well as pretty expensive to use.

So while looking for alternatives my colleague Lukas pointed me towards WireGuard which turned out to be exactly what I was looking for.

In this posting I will describe how to setup a WireGuard VPN at AWS completely from scratch, using Terraform as infrastructure as code framework.

The goal

This is a simplified high level diagram of how we structured our network at AWS:

Our VPC

All external requests from the Internet arrive at our load balancer from which they are routed to the actual application servers (which may have access to other resources, e.g. databases).

Conceptually all resources provisioned in the subnet Public are considered as the name suggests - public and must be configured in such a way that they are okay with receiving external traffic.

All resources provisioned in the subnet Resources are considered to be private. Only resources from within our VPC are allowed to access these resources. They are never directly reachable from the Internet.

Our goal for a VPN setup is to allow authenticated and authorized clients to access the resources inside the Resources subnet after establishing a VPN connection.

The setup

In order to achieve our goal we need to place a WireGuard VPN endpoint into the Public subnet, which will then proxy all traffic from our clients (e.g. a developer machine) into the Resources subnet.

Our target

For our VPN network itself, we need a subnet that is not already used by either the server or the client. Within this article we’ll use 172.16.16.0/20 as subnet CIDR but you can chose any CIDR that works for your network setup.

The WireGuard server itself will be known under the IP 172.16.16.0 within this network while we will use subsequent IPs (172.16.16.1, 172.16.16.2, …) for our clients (whic are named peers in the WireGuard terminology).

Basic infrastructure

On the server side we need a WireGuard instance that accepts requests from external clients, performs authentication and routes the traffic to the target resources.

For simplicity I will fire up a dedicated EC2 instance running Ubuntu at AWS and run WireGuard on this instance.

But for placing the EC2 instance into our network via Terraform we first need to get a reference into our (already existing) Public subnet:

# Lookup a reference to the owning VPC in which all our subnets are located
data "aws_vpc" "vpc" {
  filter {
    name = "tag:Name"
    values = [var.vpc_name] # Let's assume this is "foo"
  }
}

# Lookup the "Public" subnet in which our WireGuard instance should be placed
data "aws_subnet_ids" "public_subnet_ids" {
  filter {
    name = "tag:Name"
    values = [var.vpc_subnet_name]
  }
  vpc_id = data.aws_vpc.vpc.id
}

The WireGuard server

Now that we have the subnet we can create the EC2 instance on which we will later install the WireGuard server.

As we want to give our clients a stable endpoint to which the connection can be made, we will also need a persistent IP address which we will assign to the EC2 instance.

# Lookup the AMI instance that corresponds to a Ubuntu server
data "aws_ami" "ubuntu" {
  most_recent = true
  filter {
    name = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }
  owners = ["099720109477"] # Canonical
}

# Create a security group that allows access to the EC2 instance
resource "aws_security_group" "wireguard" {
  name = "${var.vpc_name}-vpn"
  description = "Communication to and from VPC endpoint"
  vpc_id = data.aws_vpc.vpc.id
  ingress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# Provision the actual EC2 instance based on the AMI selected above
resource "aws_instance" "wireguard" {
  ami = data.aws_ami.ubuntu.id
  instance_type = "t3a.nano"
  subnet_id = tolist(data.aws_subnet_ids.public_subnet_ids.ids)[0]
  vpc_security_group_ids = [aws_security_group.wireguard.id]
}

# Reserve a persistent IP address and associate the IP address
# with the EC2 instance
resource "aws_eip" "wireguard" {
  vpc = true
}
resource "aws_eip_association" "wireguard" {
  instance_id = aws_instance.wireguard.id
  allocation_id = aws_eip.wireguard.id
}

Now we have our EC2 Ubuntu instance. Let’s assume the IP address reserved at AWS is 3.120.216.133.

But no WireGuard is running on that instance yet. We don’t want to manually open a connection to the instance, manually install and manually start the WireGuard instance, so we use the user data functionality provided by each EC2 instance, which is basically defining a script that is run each and every time an instance is launched.

Within this script we’ll setup everything we need to run WireGuard.

As the script itself needs some variables to create a valid WireGuard configuration we’ll create it using a Terraform template file. This template file will create the /etc/wireguard/wg0.conf file on the EC2 instance.

Let’s assume that we have one client (dummy) and the server, we need two pairs of private/public keys: One for the server and one for the client. In reality you’ll want to create these keys separately and make them available during the configuration as external parameters, but for simplicity we’ll hard code them in this article:

variable "vpn_server_cidr" {
  default = "172.16.16.0/20"
}

variable "wg_server_port" {
  type = number
  default = 51820
}

variable "wg_server_private_key" {
  default = "2NeAmS4qLt97KDDkzJzLtaT2pOuygcgUtteCmB39q2M="
}

variable "wg_server_public_key" {
  default = "EpM0RIL+4iaHvsotrYR2gaIA/OEmZ0ZmQwfn0dHx6Qo="
}

variable "wg_peers" {
  type = list
  default = [{
    name = "dummy"
    public_key = "Ff4oI/aXC2RBBZmU3QVJFebc4Q1mJ+zY8pCqfnVKo3o="
    allowed_ips = "172.16.16.2"
  }]
}

Our two Terraform templates for creating the user data script now look like this:

# resources/wireguard-user-data-peers.tpl

[Peer]
# ${peer_name}
PublicKey = ${peer_public_key}
AllowedIPs = ${peer_allowed_ips}
# resources/wireguard-user-data.tpl

#! /bin/bash
echo "Installing Wireguard"
sudo apt update && apt -y install net-tools wireguard
echo "Wireguard installed"

# Inspiration taken from https://github.com/vainkop/terraform-aws-wireguard/blob/master/templates/user-data.txt
echo "Creating Wireguard configuration"
sudo mkdir -p /etc/wireguard
sudo cat > /etc/wireguard/wg0.conf <<- EOF
[Interface]
PrivateKey = ${wg_server_private_key}
ListenPort = ${wg_server_port}
Address = ${client_network_cidr}
PostUp = sysctl -w -q net.ipv4.ip_forward=1
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ENI -j MASQUERADE
PostDown = sysctl -w -q net.ipv4.ip_forward=0
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ENI -j MASQUERADE

${wg_peers}
EOF

# Make sure we replace the "ENI" placeholder with the actual network interface name
export ENI=$(ip route get 8.8.8.8 | grep 8.8.8.8 | awk '{print $5}')
sudo sed -i "s/ENI/$ENI/g" /etc/wireguard/wg0.conf

echo "Wireguard configuration created at /etc/wireguard/wg0.conf

# Launch the WireGuard server
sudo wg-quick up wg0
sudo systemctl enable wg-quick@wg0
echo "Wireguard launched"

The evaluation of these templates into single string works like this:

data "template_file" "wireguard_userdata_peers" {
  template = file("resources/wireguard-user-data-peers.tpl")
  count = length(var.wg_peers)
  vars = {
    peer_name = var.wg_peers[count.index].name
    peer_public_key = var.wg_peers[count.index].public_key
    peer_allowed_ips = var.wg_peers[count.index].allowed_ips
  }
}

data "template_file" "wireguard_userdata" {
  template = file("resources/wireguard-user-data.tpl")
  vars = {
    client_network_cidr = var.vpn_server_cidr
    wg_server_private_key = var.wg_server_private_key
    wg_server_public_key = var.wg_server_public_key
    wg_server_port = var.wg_server_port
    wg_peers = join("\n", data.template_file.wireguard_userdata_peers.*.rendered)
  }
}

Now that we have the user data evaluated we can add this to the declaration of our EC2 instance:

resource "aws_instance" "wireguard" {
  ami = data.aws_ami.ubuntu.id
  instance_type = "t3a.nano"
  subnet_id = aws_subnet.vpn.id
  vpc_security_group_ids = [aws_security_group.wireguard.id]
  user_data = data.template_file.wireguard_userdata.rendered
}

Once these changes are applied we now have a running WireGuard server on our EC2 instance.

Let’s verify this by opening a terminal into the created instance:

ubuntu@ip-10-0-243-64:~$ sudo wg
interface: wg0
  public key: EpM0RIL+4iaHvsotrYR2gaIA/OEmZ0ZmQwfn0dHx6Qo=
  private key: (hidden)
  listening port: 51820

peer: Ff4oI/aXC2RBBZmU3QVJFebc4Q1mJ+zY8pCqfnVKo3o=
  allowed ips: 172.16.16.2/32

The setup worked correctly and the server is running.

We can also check the configuration file that was created as part of the user data setup:

ubuntu@ip-10-0-243-64:~$ sudo cat /etc/wireguard/wg0.conf
[Interface]
PrivateKey = 2NeAmS4qLt97KDDkzJzLtaT2pOuygcgUtteCmB39q2M=
ListenPort = 51820
Address = 172.16.16.0/20
PostUp = sysctl -w -q net.ipv4.ip_forward=1
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE
PostDown = sysctl -w -q net.ipv4.ip_forward=0
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ens5 -j MASQUERADE

[Peer]
# dummy
PublicKey = Ff4oI/aXC2RBBZmU3QVJFebc4Q1mJ+zY8pCqfnVKo3o=
AllowedIPs = 172.16.16.2

The WireGuard client

Now that we have the server it’s time to setup our VPN client.

After downloading the client from https://www.wireguard.com/install/ we can setup a new connection using the following configuration:

[Interface]
Address = 172.16.16.2/20
PrivateKey = SE31aaL9sv2kM5hA5bT5xoQqXG3gOjTPDZQ2gUbLl1I=

[Peer]
Endpoint = 3.120.216.133:51820
PublicKey = EpM0RIL+4iaHvsotrYR2gaIA/OEmZ0ZmQwfn0dHx6Qo=
AllowedIPs = 10.0.0.0/16
PersistentKeepalive = 25

The two sections require a bit of explanation:

  • The Address attribute in the Interface section configures the IP address of our client within the subnet created by WireGuard. This address must match to the address that we have entered within the AllowedIPs section in the server configuration.
  • The PrivateKey attribute in the Interface section must contain the private key for the public key that is entered in the Peer sections within the server configuration. That’s how the server can verify the client is actually who it claims to be.
  • The Endpoint attribute in the Peer section references the WireGuard endpoint and port, which is the EC2 instance we have just created.
  • The PublicKey attribute in the the Peer section must contain the public key for the private key that is entered in the Interface section within the server configuration. That’s how the client can verify the server is who it claims to be.
  • The AllowedIPs attribute defines the IP ranges that the client should route via the VPN. In our case we only want to use the VPN for IP addresses within our private network at AWS (10.0.0.0/16). If you wish to route all traffic from the client through the VPN you can enter 10.0.0.0.

Testing our connection

After connecting the WireGuard client we can check the server again:

ubuntu@ip-10-0-243-64:~$ sudo wg
interface: wg0
  public key: EpM0RIL+4iaHvsotrYR2gaIA/OEmZ0ZmQwfn0dHx6Qo=
  private key: (hidden)
  listening port: 51820

peer: Ff4oI/aXC2RBBZmU3QVJFebc4Q1mJ+zY8pCqfnVKo3o=
  endpoint: 37.201.153.122:52083
  allowed ips: 172.16.16.2/32
  latest handshake: 2 minutes, 1 second ago
  transfer: 1.33 KiB received, 368 B sent

We can now see that one peer is connected successfully.

Now let’s see if we can reach one of the resources in one of our private subnets directly from our client (assuming it’s internal IP is 10.0.211.6):

$ ping 10.0.211.6
PING 10.0.211.6 (10.0.211.6): 56 data bytes
64 bytes from 10.0.211.6: icmp_seq=0 ttl=63 time=18.563 ms
64 bytes from 10.0.211.6: icmp_seq=1 ttl=63 time=17.981 ms 

$ traceroute 10.0.211.6
traceroute to 10.0.211.6 (10.0.211.6), 64 hops max, 52 byte packets
 1  * * *
 2  10.0.211.6 (10.0.211.6)  23.389 ms  16.804 ms  26.226 ms

We can see that a route to the resource inside the private VPC subnet is available and can see, that the instance is answering:

$ ssh 10.0.211.6
The authenticity of host '10.0.211.6 (10.0.211.6)' can't be established.
ED25519 key fingerprint is SHA256:4xx+qbPS5DVSWbQbT4J8BLNZ42NvOKaaOZS9X71dvGc.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])?

Now to check if this is really only possible via the VPN we disable the WireGuard client and see what happens now:

$ ping 10.0.211.6
PING 10.0.211.6 (10.0.211.6): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1

The ping correctly doesn’t return anything as we have lost the connection into the internal VPN.

Summary

WireGuard provides a very easy to setup solution for creating a VPN. Configuring the different peers still requires updating the configuration on the server but when managed with Terraform it becomes easy to managed and to support.

Acknowledgements

The following websites were essential in understanding the basics of WireGuard and assisting me in coming up with the Terraform configuration to setup the solution described in this article: