diff --git a/README.md b/README.md index 776ffad..c632bfa 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ Before using this module, you'll need to generate a key pair for your server and | Variable Name | Type | Required | Description | |---------------------------------|----------------------------|-----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `subnet_ids` | `list` | Yes | A list of subnets for the Autoscaling Group to use for launching instances. May be a single subnet, but it must be an element in a list. | -| `ssh_key_id` | `string` | Yes | A SSH public key ID to add to the VPN instance. | | `vpc_id` | `string` | Yes | The VPC ID in which Terraform will launch the resources. | +| `ssh_key_id` | `string` | Optional | A SSH public key ID to add to the VPN instance. | | `env` | `string` | Optional - defaults to `prod` | The name of environment for WireGuard. Used to differentiate multiple deployments. | | `use_eip` | `bool` | Optional | Whether to attach an [Elastic IP](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html) address to the VPN server. Useful for avoiding changing IPs. | | `eip_id` | `string` | Optional | When `use_eip` is enabled, specify the ID of the Elastic IP to which the VPN server will attach. | @@ -36,10 +36,12 @@ Before using this module, you'll need to generate a key pair for your server and | `wg_server_private_key_param` | `string` | Optional - defaults to `/wireguard/wg-server-private-key` | The Parameter Store key to use for the VPN server Private Key. | | `ami_id` | `string` | Optional - defaults to `null` | AMI to use for the VPN server. Determined automatically if not specified. | | `ami_prefix` | `string` | Optional - defaults to `ubuntu/images/hvm-ssd/ubuntu` | Prefix to look for in AMI name when automatically choosing an image. | -| `ami_release` | `string` | Optional - defaults to `focal-20.04` | OS release to look for in AMI name when automatically choosing an image. | -| `ami_arch` | `string` | Optional - defaults to `amd64` | Architecture to look for in AMI name when automatically choosing an image. Ensure this is appropriate for your chosen instance_type. | +| `ami_release` | `string` | Optional - defaults to `jammy-22.04` | OS release to look for in AMI name when automatically choosing an image. | +| `ami_arch` | `string` | Optional - defaults to `arm64` | Architecture to look for in AMI name when automatically choosing an image. Ensure this is appropriate for your chosen instance_type. | | `ami_owner_id` | `string` | Optional - defaults to `099720109477` (amazon) | Look for an AMI with this owner account ID when automatically choosing an image. | -| `wg_server_interface` | `string` | Optional - defaults to eth0 | Server interface to route traffic to for installations forwarding traffic to private networks. | +| `wg_server_interface` | `string` | Optional | Server interface to route traffic to for installations forwarding traffic to private networks. | +| `install_ssm` | `bool` | Optional - defaults to true | Install AWS Session Manager repository and package. Attach the necessary policy `arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM` to the EC2 Instance Role. | +| `wg_allowed_cidr_blocks` | `list(string)` | Optional - defaults to ["0.0.0.0/0"] | Defines IP ranges WireGuard clients can access, limiting full internet access if desired. | ## Examples diff --git a/main.tf b/main.tf index d44ebf4..f96eb36 100644 --- a/main.tf +++ b/main.tf @@ -5,40 +5,11 @@ terraform { aws = { source = "hashicorp/aws" } - template = { - source = "hashicorp/template" - } - } -} - -data "template_file" "user_data" { - template = file("${path.module}/templates/user-data.txt") - - vars = { - wg_server_private_key = data.aws_ssm_parameter.wg_server_private_key.value - wg_server_net = var.wg_server_net - wg_server_port = var.wg_server_port - peers = join("\n", data.template_file.wg_client_data_json.*.rendered) - use_eip = var.use_eip ? "enabled" : "disabled" - eip_id = var.eip_id - wg_server_interface = var.wg_server_interface - } -} - -data "template_file" "wg_client_data_json" { - template = file("${path.module}/templates/client-data.tpl") - count = length(var.wg_clients) - - vars = { - client_name = var.wg_clients[count.index].name - client_pub_key = var.wg_clients[count.index].public_key - client_ip = var.wg_clients[count.index].client_ip - persistent_keepalive = var.wg_persistent_keepalive } } # Automatically find the latest version of our operating system image (e.g. Ubuntu) -data "aws_ami" "os" { +data "aws_ami" "ubuntu" { most_recent = true filter { name = "name" @@ -61,24 +32,59 @@ locals { security_groups_ids = compact(concat(var.additional_security_group_ids, local.sg_wireguard_external)) } -resource "aws_launch_configuration" "wireguard_launch_config" { - name_prefix = "wireguard-${var.env}-" - image_id = var.ami_id != null ? var.ami_id : data.aws_ami.os.id - instance_type = var.instance_type - key_name = var.ssh_key_id - iam_instance_profile = (var.use_eip ? aws_iam_instance_profile.wireguard_profile[0].name : null) - user_data = data.template_file.user_data.rendered - security_groups = local.security_groups_ids - associate_public_ip_address = var.use_eip +locals { + launch_name_prefix = "wireguard-${var.env}-" + wg_client_data = templatefile("${path.module}/templates/client-data.tftpl", { + users = var.wg_clients, + persistent_keepalive = var.wg_persistent_keepalive + }) +} - lifecycle { - create_before_destroy = true +resource "aws_launch_template" "wireguard_launch_config" { + name_prefix = local.launch_name_prefix + image_id = var.ami_id == null ? data.aws_ami.ubuntu.id : var.ami_id + instance_type = var.instance_type + key_name = var.ssh_key_id + iam_instance_profile { + arn = aws_iam_instance_profile.wireguard_profile.arn + } + + metadata_options { + http_tokens = "required" + } + + user_data = base64encode(templatefile("${path.module}/templates/user-data.tftpl", { + wg_server_private_key_param = var.wg_server_private_key_param + wg_server_net = var.wg_server_net + wg_server_port = var.wg_server_port + peers = local.wg_client_data + use_eip = var.use_eip ? "enabled" : "disabled" + install_ssm = var.install_ssm ? "enabled" : "disabled" + eip_id = var.eip_id + wg_server_interface = var.wg_server_interface + arch = var.ami_arch + wg_allowed_cidr_blocks = join(" ", var.wg_allowed_cidr_blocks) + })) + + network_interfaces { + associate_public_ip_address = var.use_eip + security_groups = local.security_groups_ids + } + + tag_specifications { + resource_type = "instance" + + tags = { + launch-template-name = local.launch_name_prefix + project = "wireguard" + env = var.env + tf-managed = "True" + } } } resource "aws_autoscaling_group" "wireguard_asg" { - name = aws_launch_configuration.wireguard_launch_config.name - launch_configuration = aws_launch_configuration.wireguard_launch_config.name + name = aws_launch_template.wireguard_launch_config.name min_size = var.asg_min_size desired_capacity = var.asg_desired_capacity max_size = var.asg_max_size @@ -87,19 +93,29 @@ resource "aws_autoscaling_group" "wireguard_asg" { termination_policies = ["OldestLaunchConfiguration", "OldestInstance"] target_group_arns = var.target_group_arns + launch_template { + id = aws_launch_template.wireguard_launch_config.id + version = aws_launch_template.wireguard_launch_config.latest_version + } + lifecycle { create_before_destroy = true } + instance_refresh { + strategy = "Rolling" + } + tag { - key = "Name" - value = aws_launch_configuration.wireguard_launch_config.name + key = "Name" + value = aws_launch_template.wireguard_launch_config.name propagate_at_launch = true } tag { - key = "env" - value = var.env + key = "env" + value = var.env propagate_at_launch = true } } + diff --git a/templates/client-data.tftpl b/templates/client-data.tftpl new file mode 100644 index 0000000..83e8cd1 --- /dev/null +++ b/templates/client-data.tftpl @@ -0,0 +1,6 @@ +%{ for user in users ~} +[Peer] +PublicKey = ${user.public_key} +AllowedIPs = ${user.client_ip} +PersistentKeepalive = ${persistent_keepalive} +%{ endfor ~} diff --git a/templates/client-data.tpl b/templates/client-data.tpl deleted file mode 100644 index f7640b8..0000000 --- a/templates/client-data.tpl +++ /dev/null @@ -1,4 +0,0 @@ -[Peer] -PublicKey = ${client_pub_key} -AllowedIPs = ${client_ip} -PersistentKeepalive = ${persistent_keepalive} diff --git a/templates/user-data.tftpl b/templates/user-data.tftpl new file mode 100644 index 0000000..9462bfd --- /dev/null +++ b/templates/user-data.tftpl @@ -0,0 +1,66 @@ +#!/bin/bash -v +apt-get update -y +DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -o Dpkg::Options::="--force-confnew" +apt-get install -y wireguard-tools awscli + +# Find interface if not defined +if [ -z "${wg_server_interface}" ]; then + INTERFACE=$(ip -o -4 route show to default | awk '{print $5}') +else + INTERFACE="${wg_server_interface}" +fi + +TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" -s) +REGION=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -fsq http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/[a-z]$//') +PRIVATE_KEY=$( + aws ssm get-parameter --name ${wg_server_private_key_param} \ + --region $${REGION} --query='Parameter.Value' \ + --output=text --with-decryption +) + +read -ra wg_allowed_cidr_blocks <<< "${wg_allowed_cidr_blocks}" + +POST_UP_RULES="" +POST_DOWN_RULES="" +for CIDR in "$${wg_allowed_cidr_blocks[@]}"; do + POST_UP_RULES+="iptables -t nat -A POSTROUTING -o $INTERFACE -d $CIDR -j MASQUERADE; " + POST_DOWN_RULES+="iptables -t nat -D POSTROUTING -o $INTERFACE -d $CIDR -j MASQUERADE; " +done + +cat > /etc/wireguard/wg0.conf <<- EOF +[Interface] +Address = ${wg_server_net} +PrivateKey = $${PRIVATE_KEY} +ListenPort = ${wg_server_port} +PostUp = $${POST_UP_RULES} iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT +PostDown = $${POST_DOWN_RULES} iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT + +${peers} +EOF + +# we go with the eip if it is provided +if [ "${use_eip}" != "disabled" ]; then + INSTANCE_ID=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -s http://169.254.169.254/latest/meta-data/instance-id) + aws --region $${REGION} ec2 associate-address --allocation-id ${eip_id} --instance-id $${INSTANCE_ID} +fi + +# Install the ssm if it is enabled (installed by default on Ubuntu Server22.04 +# LTS, 20.10 STR & 20.04, 18.04, and 16.04 LTS (with Snap)) +if [ "${install_ssm}" = "enabled" ]; then + # https://docs.aws.amazon.com/systems-manager/latest/userguide/agent-install-ubuntu-64-snap.html + systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service + systemctl start snap.amazon-ssm-agent.amazon-ssm-agent.service +fi + +# reduce MTU to prevent packet fragmentation with NAT +ip link set dev $${INTERFACE} mtu 1500 + +chown -R root:root /etc/wireguard/ +chmod -R og-rwx /etc/wireguard/* +sed -i 's/#net.ipv4.ip_forward=1/net.ipv4.ip_forward=1/' /etc/sysctl.conf +sysctl -p +ufw allow ssh +ufw allow ${wg_server_port}/udp +ufw --force enable +systemctl enable wg-quick@wg0.service +systemctl start wg-quick@wg0.service diff --git a/templates/user-data.txt b/templates/user-data.txt deleted file mode 100644 index bf3a8b3..0000000 --- a/templates/user-data.txt +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -v -apt-get update -y -DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -o Dpkg::Options::="--force-confnew" -apt-get install -y wireguard-dkms wireguard-tools awscli - -cat > /etc/wireguard/wg0.conf <<- EOF -[Interface] -Address = ${wg_server_net} -PrivateKey = ${wg_server_private_key} -ListenPort = ${wg_server_port} -PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ${wg_server_interface} -j MASQUERADE -PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ${wg_server_interface} -j MASQUERADE - -${peers} -EOF - -# we go with the eip if it is provided -if [ "${use_eip}" != "disabled" ]; then - export INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id) - export REGION=$(curl -fsq http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/[a-z]$//') - aws --region $${REGION} ec2 associate-address --allocation-id ${eip_id} --instance-id $${INSTANCE_ID} -fi - -# reduce MTU to prevent packet fragmentation with NAT -ip link set dev ${wg_server_interface} mtu 1500 - -chown -R root:root /etc/wireguard/ -chmod -R og-rwx /etc/wireguard/* -sed -i 's/#net.ipv4.ip_forward=1/net.ipv4.ip_forward=1/' /etc/sysctl.conf -sysctl -p -ufw allow ssh -ufw allow ${wg_server_port}/udp -ufw --force enable -systemctl enable wg-quick@wg0.service -systemctl start wg-quick@wg0.service diff --git a/variables.tf b/variables.tf index f52c71c..c283826 100644 --- a/variables.tf +++ b/variables.tf @@ -1,29 +1,36 @@ variable "ssh_key_id" { description = "A SSH public key ID to add to the VPN instance." + default = null + type = string } variable "instance_type" { - default = "t2.micro" + default = "t4g.micro" description = "The machine type to launch, some machines may offer higher throughput for higher use cases." + type = string } variable "asg_min_size" { default = 1 description = "We may want more than one machine in a scaling group, but 1 is recommended." + type = number } variable "asg_desired_capacity" { default = 1 description = "We may want more than one machine in a scaling group, but 1 is recommended." + type = number } variable "asg_max_size" { default = 1 description = "We may want more than one machine in a scaling group, but 1 is recommended." + type = number } variable "vpc_id" { description = "The VPC ID in which Terraform will launch the resources." + type = string } variable "subnet_ids" { @@ -39,16 +46,19 @@ variable "wg_clients" { variable "wg_server_net" { default = "192.168.2.1/24" description = "IP range for vpn server - make sure your Client ips are in this range but not the specific ip i.e. not .1" + type = string } variable "wg_server_port" { default = 51820 description = "Port for the vpn server." + type = number } variable "wg_persistent_keepalive" { default = 25 description = "Persistent Keepalive - useful for helping connection stability over NATs." + type = number } variable "use_eip" { @@ -68,6 +78,12 @@ variable "additional_security_group_ids" { description = "Additional security groups if provided, default empty." } +variable "wg_allowed_cidr_blocks" { + type = list(string) + default = ["0.0.0.0/0"] + description = "Defines IP ranges WireGuard clients can access, limiting full internet access if desired." +} + variable "target_group_arns" { type = list(string) default = null @@ -77,39 +93,54 @@ variable "target_group_arns" { variable "env" { default = "prod" description = "The name of environment for WireGuard. Used to differentiate multiple deployments." + type = string } variable "wg_server_private_key_param" { default = "/wireguard/wg-server-private-key" description = "The SSM parameter containing the WG server private key." + type = string } variable "ami_id" { default = null # we check for this and use a data provider since we can't use it here description = "The AWS AMI to use for the WG server, defaults to an Ubuntu AMI if not specified." + type = string } variable "ami_prefix" { default = "ubuntu/images/hvm-ssd/ubuntu" description = "Prefix to look for in AMI name when automatically choosing an image." + type = string } variable "ami_release" { - default = "focal-20.04" + default = "jammy-22.04" description = "OS release to look for in AMI name when automatically choosing an image." + type = string } variable "ami_arch" { - default = "amd64" + default = "arm64" description = "Architecture to look for in AMI name when automatically choosing an image. Ensure this is appropriate for your chosen instance_type." + type = string } variable "ami_owner_id" { default = "099720109477" description = "Look for an AMI with this owner account ID when automatically choosing an image." + type = string } variable "wg_server_interface" { - default = "eth0" description = "The default interface to forward network traffic to." + type = string + default = "" } + +variable "install_ssm" { + description = "Whether to install the Amazon SSM Agent on the EC2 instances" + type = bool + default = true +} + diff --git a/wireguard-iam.tf b/wireguard-iam.tf index 98e5172..ba555df 100644 --- a/wireguard-iam.tf +++ b/wireguard-iam.tf @@ -1,3 +1,7 @@ +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + data "aws_iam_policy_document" "ec2_assume_role" { statement { actions = [ @@ -21,29 +25,59 @@ data "aws_iam_policy_document" "wireguard_policy_doc" { } } -resource "aws_iam_policy" "wireguard_policy" { - name = "tf-wireguard-${var.env}" +data "aws_iam_policy_document" "wireguard_read_private_key" { + statement { + actions = [ + "ssm:GetParameter" + ] + resources = [ + format("arn:aws:ssm:%s:%s:parameter%s", + data.aws_region.current.name, + data.aws_caller_identity.current.account_id, + var.wg_server_private_key_param + ) + ] + } +} + +resource "aws_iam_policy" "wireguard_eip_policy" { + name = "tf-wireguard-${var.env}-eip" description = "Terraform Managed. Allows Wireguard instance to attach EIP." policy = data.aws_iam_policy_document.wireguard_policy_doc.json - count = (var.use_eip ? 1 : 0) # only used for EIP mode + count = (var.use_eip ? 1 : 0) +} + +resource "aws_iam_policy" "wireguard_ssm_private_key_policy" { + name = "tf-wireguard-${var.env}-ssm-private-key" + description = "Terraform Managed. Allows Wireguard instance to read SSM wireguard private key." + policy = data.aws_iam_policy_document.wireguard_read_private_key.json } resource "aws_iam_role" "wireguard_role" { name = "tf-wireguard-${var.env}" - description = "Terraform Managed. Role to allow Wireguard instance to attach EIP." + description = "Terraform Managed. Role with Wireguard instance permissions." path = "/" assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json - count = (var.use_eip ? 1 : 0) # only used for EIP mode } resource "aws_iam_role_policy_attachment" "wireguard_roleattach" { - role = aws_iam_role.wireguard_role[0].name - policy_arn = aws_iam_policy.wireguard_policy[0].arn + role = aws_iam_role.wireguard_role.name + policy_arn = aws_iam_policy.wireguard_eip_policy[0].arn count = (var.use_eip ? 1 : 0) # only used for EIP mode } +resource "aws_iam_role_policy_attachment" "ssm_policy_attachment" { + role = aws_iam_role.wireguard_role.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" + count = var.install_ssm ? 1 : 0 +} + +resource "aws_iam_role_policy_attachment" "ssm_private_key_policy_attachment" { + role = aws_iam_role.wireguard_role.name + policy_arn = aws_iam_policy.wireguard_ssm_private_key_policy.arn +} + resource "aws_iam_instance_profile" "wireguard_profile" { - name = "tf-wireguard-${var.env}" - role = aws_iam_role.wireguard_role[0].name - count = (var.use_eip ? 1 : 0) # only used for EIP mode + name = "tf-wireguard-${var.env}" + role = aws_iam_role.wireguard_role.name } diff --git a/wireguard-ssm.tf b/wireguard-ssm.tf deleted file mode 100644 index 0ac599d..0000000 --- a/wireguard-ssm.tf +++ /dev/null @@ -1,3 +0,0 @@ -data "aws_ssm_parameter" "wg_server_private_key" { - name = var.wg_server_private_key_param -}