main tags rss
github x

Setting up WireGuard VPN at AWS with Terraform

All resources in AWS work inside private VPC. Sometimes you may need to access these resources from local computer (e.g. to interact with database). Some resources, like RDS, have the option to enable public access to them – but this is unsecure. Of course you can configure Security Group to allow access to public resource only from allowed IPs to make this setup a bit better, but still in this case all your colleagues must to have static IPs, which is not always true.

Objective

We need to provide a quick and easy solution to give access to internal AWS VPC resources for our team. In general, there are two ways to do this:

  1. Bastion / Jump Host – EC2 instance with SSH access for each member
  2. Private VPN – EC2 instance with VPN config for each member

In both cases we need to to setup a custom EC2 instance, so second variant seems more convenient to me.

Preparation

In addition to the traditional Terraform client, we will need WireGuard CLI tools to generate a key pair. You can install WireGuard tools on macOS via brew:

brew install wireguard-tools

Initial Terraform

First, as usual, we need to configure Terraform Provider and get VPC and Public Subnet data where we want to host our VPN server. On this step you can export VPC / Public Subnet from another layer or pass them via variables. I will access my VPC and Public Subnet by name.

terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.70" }
  }
}

provider "aws" {
  profile = "default"   # Change this to your AWS profile
  region  = "us-east-1" # Change this to your AWS region
}

# MARK: Getting VPC and Subnets

locals {
  vpc_name = "my_vpc" # Change this to yor VPC name
}

data "aws_vpc" "vpc" {
  filter {
    name   = "tag:Name"
    values = [local.vpc_name]
  }
}

data "aws_subnets" "public" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.vpc.id]
  }

  tags = { Tier = "Public" } # This is custom tag
}

WireGuard Server

To setup WireGuard server we need to know AMI ID of OS will be running on EC2 instance, prepare key pairs for the server and for client (peers), create a security group to control network access, create and assing Elastic IP to be sure that the server IP will be same.

Generate key pairs

First we need to create key pairs which can be generated with this command:

bash -c 'priv=$(wg genkey); pub=$(echo $priv | wg pubkey); printf "$priv\n$pub\n"'

In output first line is privkey, second is pubkey.

I will generate it twice, one for server, second for first client (peer). Then for each new peer you will need to generate new key pairs. I will same generated keys to secrets.yaml file, but you can use different ways to store secrets.

Generate secrets for server and first peer (do not use this keys in your setup):

 bash -c 'priv=$(wg genkey); pub=$(echo $priv | wg pubkey); printf "$priv\n$pub\n"'
0McsAe5Y/oupwJeju+94ZFl1bmp2KIX2bBQGk0cSWXQ=
KeV+BlZMfCgRhdfaHqpdPp23Nu/otir+6VR02DER7lU=

 bash -c 'priv=$(wg genkey); pub=$(echo $priv | wg pubkey); printf "$priv\n$pub\n"'
SK71fHXpTo2cGKvlLLTlu+0AqpwaNWG30rzGXFIryk0=
wf3j1JLhXw/u6iDZf9Qhq0lU34exF8mrHdGzz/u+xFU=

Then save generated keys to secrets.yaml

wg_server:
  privkey: 0McsAe5Y/oupwJeju+94ZFl1bmp2KIX2bBQGk0cSWXQ=
  pubkey: KeV+BlZMfCgRhdfaHqpdPp23Nu/otir+6VR02DER7lU=

wg_peers:
  - name: user1
    addr: 172.16.16.1
    privkey: SK71fHXpTo2cGKvlLLTlu+0AqpwaNWG30rzGXFIryk0=
    pubkey: wf3j1JLhXw/u6iDZf9Qhq0lU34exF8mrHdGzz/u+xFU=

Setup script

Then we need create WireGuard setup script which will be runned during EC2 creation. User data will be generated from this script and populated from secrets.yaml file. So create templates directory with two files in it: wg-init.tpl (general setup script) and wg-peer.tpl (peer info).

#!/bin/bash
sudo apt update && apt -y install net-tools wireguard

# https://github.com/vainkop/terraform-aws-wireguard/blob/master/templates/user-data.txt
sudo mkdir -p /etc/wireguard
sudo cat > /etc/wireguard/wg0.conf <<- EOF
[Interface]
PrivateKey = ${wg_privkey}
ListenPort = ${wg_port}
Address = ${wg_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

sudo wg-quick up wg0
sudo systemctl enable wg-quick@wg0
[Peer]
# ${peer_name}
PublicKey = ${peer_pubkey}
AllowedIPs = ${peer_addr}

Next we need to prepare user data script in terraform file from this templates.

# MARK: Config

locals {
  wg_cidr = "172.16.16.0/20" # CIDR of WireGuard Server
  wg_port = 51820            # Port of WireGuard Server
  secrets = yamldecode(file("secrets.yaml"))

  userdata = templatefile("templates/wg-init.tpl", {
    wg_cidr    = local.wg_cidr
    wg_port    = local.wg_port
    wg_privkey = local.secrets.wg_server.privkey
    wg_peers = join("\n", [
      for p in local.secrets.wg_peers :
      templatefile("templates/wg-peer.tpl", {
        peer_name   = p.name
        peer_pubkey = p.pubkey
        peer_addr   = p.addr
      })
    ])
  })
}

Server setup

Then we need to select Ubuntu AMI, create Security Group and Elastic IP.

# MARK: Server dependencies

data "aws_ami" "ubuntu" {
  owners      = ["099720109477"] # Canonical
  most_recent = true
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-arm64-server-*"]
  }
}

resource "aws_eip" "wireguard" {
  tags = { Name = "wireguard" }
}

resource "aws_security_group" "wireguard" {
  name        = "${local.vpc_name}-wireguard"
  description = "SG for WireGuard VPN Server"
  vpc_id      = data.aws_vpc.vpc.id
  ingress {
    from_port   = local.wg_port
    to_port     = local.wg_port
    protocol    = "udp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow WireGuard Traffic"
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Next we can setup a server and assing Elastic IP to it:

resource "aws_instance" "wireguard" {
  count                       = 1
  ami                         = data.aws_ami.ubuntu.id
  instance_type               = "t4g.nano"
  subnet_id                   = data.aws_subnets.public.ids[0]
  vpc_security_group_ids      = [aws_security_group.wireguard.id]
  user_data                   = local.userdata
  user_data_replace_on_change = true
  tags                        = { Name = "wireguard" }
}

resource "aws_eip_association" "wireguard" {
  instance_id   = aws_instance.wireguard[0].id
  allocation_id = aws_eip.wireguard.id
}

Client configuration

To access to WireGuard server we need to have WireGuard client app and valid client config with .conf extension.

We can generated this configs with Terraform Templates as well. So first, lets add new template file called client-conf.tpl:

[Interface]
Address = ${client_addr}/32
PrivateKey = ${client_privkey}
DNS = ${client_dns}

[Peer]
Endpoint = ${server_addr}
PublicKey = ${server_pubkey}
AllowedIPs = ${client_routes}
PersistentKeepalive = 25

And add this code to generate client config to out terraform file:

# MARK: Export client configuration

locals {
  # https://docs.aws.amazon.com/vpc/latest/userguide/AmazonDNS-concepts.html#AmazonDNS
  client_dns = ["169.254.169.253", "1.1.1.1", "1.0.0.1"]
  client_routes = [
    data.aws_vpc.vpc.cidr_block, # All IPs in the VPC
    "169.254.169.253/32",        # AWS DNS
    # "0.0.0.0/32",                # Route all traffic through VPN (uncomment if you want this)
  ]
}

resource "local_file" "peer_conf" {
  for_each = { for index, p in local.secrets.wg_peers : p.name => p }
  filename = "generated/${each.value.name}.conf"
  content = templatefile("templates/client-conf.tpl", {
    server_addr    = "${aws_eip.wireguard.public_ip}:${local.wg_port}",
    server_pubkey  = local.secrets.wg_server.pubkey,
    client_addr    = each.value.addr,
    client_privkey = each.value.privkey,
    client_dns     = join(",", local.client_dns),
    client_routes  = join(",", local.client_routes),
  })
}

Execute

Finally we can run terraform init & terraform apply. In less then a minute VPN server will be created and user client will be exported to generated/user1.conf. Then we can share this file with our teammates.

To check VPN connection working fine you can export .conf into WireGuard client. Then for example I check my RDS instance before VPN enabled and after.

 dig +short my-rds.4oexsiqydp8q.us-east-1.rds.amazonaws.com
ec2-34-226-xx-xx.compute-1.amazonaws.com.
34.226.xx.xx

 dig +short my-rds.4oexsiqydp8q.us-east-1.rds.amazonaws.com
ec2-34-226-xx-xx.compute-1.amazonaws.com.
10.10.20.80

Adding clients

Adding new client configuration is quite simple. We need to generate new key pair:

 bash -c 'priv=$(wg genkey); pub=$(echo $priv | wg pubkey); printf "$priv\n$pub\n"'
ALqgaUiWd0rtcLza2K143x5RwS0Y5zwh3YwHx/L7nV0=
u1yQmh/G7n+S8aCp0PRuRuAuacmqYNzHLCPOcl9aS28=

Then update secrets.yaml file:

wg_peers:
  # ...
  - name: user2
    addr: 172.16.16.2
    privkey: ALqgaUiWd0rtcLza2K143x5RwS0Y5zwh3YwHx/L7nV0=
    pubkey: u1yQmh/G7n+S8aCp0PRuRuAuacmqYNzHLCPOcl9aS28=

And run terraform apply again. Server will be re-created (!) with new configuration. Note that existing clients will be disconnected during deploy.

Summary

WireGuard provides easy solution for creating VPN server. We created cheap and fast setup to access to internal AWS VPC with ability to create different clients configurations, which can be shared with out team members.

PS. Source files of this article can be found here.