diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397af32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Local .terraform directories +**/.terraform/* + +# Terraform lockfile +.terraform.lock.hcl + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log + +# Exclude all .tfvars files, which are likely to contain sentitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore CLI configuration files +.terraformrc +terraform.rc diff --git a/README.md b/README.md index 699802d..e3b01e3 100644 --- a/README.md +++ b/README.md @@ -1 +1,76 @@ -# terraform-aws-wireguard \ No newline at end of file +# Terraform AWS Wireguard + +This module creates EC2 instance with Wireguard inside. + + +How to use: + + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 0.13.5 | +| [aws](#requirement\_aws) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | ~> 3.0 | +| [template](#provider\_template) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_autoscaling_group.wireguard_asg](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_group) | resource | +| [aws_eip.wireguard](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip) | resource | +| [aws_iam_instance_profile.wireguard_profile](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource | +| [aws_iam_policy.wireguard_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.wireguard_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.wireguard_roleattach](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_launch_configuration.wireguard_launch_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_configuration) | resource | +| [aws_route53_record.wireguard](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_security_group.sg_wireguard](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_ami.ubuntu](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.ec2_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.wireguard_policy_doc](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [template_file.wg_client_data_json](https://registry.terraform.io/providers/hashicorp/template/latest/docs/data-sources/file) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [ami\_id](#input\_ami\_id) | The AWS AMI to use for the Wireguard server, defaults to the latest Ubuntu 20.04 AMI if not specified. | `any` | `null` | no | +| [asg\_desired\_capacity](#input\_asg\_desired\_capacity) | We may want more than one machine in a scaling group, but 1 is recommended. | `number` | `1` | no | +| [asg\_max\_size](#input\_asg\_max\_size) | We may want more than one machine in a scaling group, but 1 is recommended. | `number` | `1` | no | +| [asg\_min\_size](#input\_asg\_min\_size) | We may want more than one machine in a scaling group, but 1 is recommended. | `number` | `1` | no | +| [aws\_region](#input\_aws\_region) | n/a | `string` | n/a | yes | +| [env](#input\_env) | The name of environment for Wireguard. | `any` | n/a | yes | +| [instance\_type](#input\_instance\_type) | The machine type to launch, some machines may offer higher throughput for higher use cases. | `string` | `"t2.micro"` | no | +| [route53\_geo](#input\_route53\_geo) | Route53 Geolocation config. | `any` | `null` | no | +| [route53\_hosted\_zone\_id](#input\_route53\_hosted\_zone\_id) | Route53 Hosted zone ID. | `string` | `null` | no | +| [route53\_record\_name](#input\_route53\_record\_name) | Route53 Record name. | `string` | `null` | no | +| [ssh\_key\_id](#input\_ssh\_key\_id) | A SSH public key ID to add to the VPN instance. | `any` | n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | 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. | `list(string)` | n/a | yes | +| [target\_group\_arns](#input\_target\_group\_arns) | Running a scaling group behind an LB requires this variable, default null means it won't be included if not set. | `list(string)` | `null` | no | +| [use\_eip](#input\_use\_eip) | Whether to enable Elastic IP switching code in user-data on wg server startup. If true, eip\_id must also be set to the ID of the Elastic IP. | `bool` | `false` | no | +| [use\_route53](#input\_use\_route53) | Whether to use SSM to store Wireguard Server private key. | `bool` | `false` | no | +| [vpc\_id](#input\_vpc\_id) | The VPC ID in which Terraform will launch the resources. | `any` | n/a | yes | +| [wg\_clients](#input\_wg\_clients) | List of client objects with IP and public key. See Usage in README for details. | `list(object({ friendly_name = string, public_key = string, client_ip = string }))` | n/a | yes | +| [wg\_persistent\_keepalive](#input\_wg\_persistent\_keepalive) | Persistent Keepalive - useful for helping connection stability over NATs. | `number` | `25` | no | +| [wg\_server\_interface](#input\_wg\_server\_interface) | The default interface to forward network traffic to. | `string` | `"eth0"` | no | +| [wg\_server\_net](#input\_wg\_server\_net) | IP range for vpn server - make sure your Client ips are in this range but not the specific ip i.e. not .1 | `string` | `"10.0.0.1/24"` | no | +| [wg\_server\_port](#input\_wg\_server\_port) | Port for the vpn server. | `number` | `51820` | no | +| [wg\_server\_private\_key](#input\_wg\_server\_private\_key) | Wireguard server private key. | `string` | `null` | no | + +## Outputs + +No outputs diff --git a/iam.tf b/iam.tf new file mode 100644 index 0000000..293b362 --- /dev/null +++ b/iam.tf @@ -0,0 +1,52 @@ +data "aws_iam_policy_document" "ec2_assume_role" { + statement { + actions = [ + "sts:AssumeRole" + ] + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "wireguard_policy_doc" { + statement { + actions = [ + "ec2:AssociateAddress", + "ssm:GetParameter" + ] + + resources = ["*"] + } +} + +data "aws_caller_identity" "current" {} + +resource "aws_iam_policy" "wireguard_policy" { + name = "${var.env}-${var.aws_region}-tf-wireguard" + 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 +} + +resource "aws_iam_role" "wireguard_role" { + name = "${var.env}-${var.aws_region}-tf-wireguard" + description = "Terraform Managed. Role to allow Wireguard instance to attach EIP." + 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 + count = (var.use_eip ? 1 : 0) # only used for EIP mode +} + +resource "aws_iam_instance_profile" "wireguard_profile" { + name = "${var.env}-${var.aws_region}-tf-wireguard" + role = aws_iam_role.wireguard_role[0].name + count = (var.use_eip ? 1 : 0) # only used for EIP mode +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..08d445e --- /dev/null +++ b/main.tf @@ -0,0 +1,112 @@ +resource "aws_eip" "wireguard" { + vpc = true + tags = { + Name = "${var.env}-wireguard" + } +} + +resource "aws_route53_record" "wireguard" { + count = var.use_route53 ? 1 : 0 + allow_overwrite = true + set_identifier = "${var.env}-${var.aws_region}-wireguard" + zone_id = var.route53_hosted_zone_id + name = var.route53_record_name + type = "A" + ttl = "60" + records = [aws_eip.wireguard.public_ip] + + dynamic "geolocation_routing_policy" { + for_each = try(length(var.route53_geo.policy) > 0 ? var.route53_geo.policy : tomap(false), {}) + + content { + continent = geolocation_routing_policy.value.continent + } + } +} + +data "template_file" "wg_client_data_json" { + template = file("${path.module}/templates/client-data.tpl") + count = length(var.wg_clients) + + vars = { + friendly_name = var.wg_clients[count.index].friendly_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 + } +} + +data "aws_ami" "ubuntu" { + most_recent = true + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } + owners = ["099720109477"] # Canonical +} + +resource "aws_launch_configuration" "wireguard_launch_config" { + name_prefix = "${var.env}-wireguard-" + 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 = (var.use_eip ? aws_iam_instance_profile.wireguard_profile[0].name : null) + user_data = templatefile("${path.module}/templates/user-data.txt", { + wg_server_private_key = var.wg_server_private_key, + 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 = aws_eip.wireguard.id, + wg_server_interface = var.wg_server_interface + }) + security_groups = [aws_security_group.sg_wireguard.id] + associate_public_ip_address = var.use_eip + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_autoscaling_group" "wireguard_asg" { + name = aws_launch_configuration.wireguard_launch_config.name + launch_configuration = aws_launch_configuration.wireguard_launch_config.name + min_size = var.asg_min_size + desired_capacity = var.asg_desired_capacity + max_size = var.asg_max_size + vpc_zone_identifier = var.subnet_ids + health_check_type = "EC2" + termination_policies = ["OldestLaunchConfiguration", "OldestInstance"] + target_group_arns = var.target_group_arns + + lifecycle { + create_before_destroy = true + } + + tags = [ + { + key = "Name" + value = aws_launch_configuration.wireguard_launch_config.name + propagate_at_launch = true + }, + { + key = "Project" + value = "wireguard" + propagate_at_launch = true + }, + { + key = "env" + value = var.env + propagate_at_launch = true + }, + { + key = "tf-managed" + value = "True" + propagate_at_launch = true + }, + ] +} diff --git a/sg.tf b/sg.tf new file mode 100644 index 0000000..e0f4eb9 --- /dev/null +++ b/sg.tf @@ -0,0 +1,33 @@ +resource "aws_security_group" "sg_wireguard" { + name = "${var.env}-${var.aws_region}-wireguard" + description = "Terraform Managed. Allow Wireguard client traffic from internet." + vpc_id = var.vpc_id + + tags = { + Name = "${var.env}-${var.aws_region}-wireguard" + Project = "wireguard" + tf-managed = "True" + env = var.env + } + + ingress { + from_port = var.wg_server_port + to_port = var.wg_server_port + protocol = "udp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} diff --git a/templates/client-data.tpl b/templates/client-data.tpl new file mode 100644 index 0000000..7177777 --- /dev/null +++ b/templates/client-data.tpl @@ -0,0 +1,5 @@ +[Peer] +# friendly_name = ${friendly_name} +PublicKey = ${client_pub_key} +AllowedIPs = ${client_ip} +PersistentKeepalive = ${persistent_keepalive} \ No newline at end of file diff --git a/templates/user-data.txt b/templates/user-data.txt new file mode 100644 index 0000000..6f9c30a --- /dev/null +++ b/templates/user-data.txt @@ -0,0 +1,64 @@ +#!/bin/bash -v +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -o Dpkg::Options::="--force-confnew" +apt-get install -y \ + apt-transport-https \ + ca-certificates \ + build-essential \ + software-properties-common \ + unzip \ + curl \ + wget \ + gnupg \ + net-tools \ + jq \ + wireguard-dkms \ + wireguard-tools && \ + rm -rf /var/lib/apt/lists/* + +curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ +unzip awscliv2.zip && \ +rm -f awscliv2.zip && \ +./aws/install + +cat > /etc/wireguard/wg0.conf <<- EOF +[Interface] +Address = ${wg_server_net} +PrivateKey = ${wg_server_private_key} +ListenPort = ${wg_server_port} +PostUp = sysctl -w -q net.ipv4.ip_forward=1 +PostUp = iptables -P FORWARD DROP +PostUp = iptables -A FORWARD -i wg0 -j ACCEPT +PostUp = iptables -t nat -A POSTROUTING -o ENI -j MASQUERADE +PostDown = sysctl -w -q net.ipv4.ip_forward=0 +PostDown = iptables -P FORWARD ACCEPT +PostDown = iptables -D FORWARD -i wg0 -j ACCEPT +PostDown = iptables -t nat -D POSTROUTING -o ENI -j MASQUERADE + +${peers} +EOF + +export ENI=$(ip route get 8.8.8.8 | grep 8.8.8.8 | awk '{print $5}') +sed -i "s/ENI/$ENI/g" /etc/wireguard/wg0.conf + +# Use 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 + +chown -R root:root /etc/wireguard/ +chmod -R og-rwx /etc/wireguard/* +sysctl -p +systemctl enable wg-quick@wg0.service +systemctl start wg-quick@wg0.service + +until systemctl is-active --quiet wg-quick@wg0.service +do + sleep 1 +done + +ufw allow ssh +ufw allow ${wg_server_port}/udp +ufw --force enable \ No newline at end of file diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..6ab250d --- /dev/null +++ b/variables.tf @@ -0,0 +1,112 @@ +variable "aws_region" { + type = string +} + +variable "env" { + description = "The name of environment for Wireguard." +} + +variable "ssh_key_id" { + description = "A SSH public key ID to add to the VPN instance." +} + +variable "instance_type" { + default = "t2.micro" + description = "The machine type to launch, some machines may offer higher throughput for higher use cases." +} + +variable "asg_min_size" { + default = 1 + description = "We may want more than one machine in a scaling group, but 1 is recommended." +} + +variable "asg_desired_capacity" { + default = 1 + description = "We may want more than one machine in a scaling group, but 1 is recommended." +} + +variable "asg_max_size" { + default = 1 + description = "We may want more than one machine in a scaling group, but 1 is recommended." +} + +variable "vpc_id" { + description = "The VPC ID in which Terraform will launch the resources." +} + +variable "subnet_ids" { + type = list(string) + description = "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." +} + +variable "wg_clients" { + type = list(object({ friendly_name = string, public_key = string, client_ip = string })) + description = "List of client objects with IP and public key. See Usage in README for details." +} + +variable "wg_server_net" { + default = "10.0.0.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" +} + +variable "wg_server_port" { + default = 51820 + description = "Port for the vpn server." +} + +variable "wg_persistent_keepalive" { + default = 25 + description = "Persistent Keepalive - useful for helping connection stability over NATs." +} + +variable "use_eip" { + type = bool + default = false + description = "Whether to enable Elastic IP switching code in user-data on wg server startup. If true, eip_id must also be set to the ID of the Elastic IP." +} + +variable "target_group_arns" { + type = list(string) + default = null + description = "Running a scaling group behind an LB requires this variable, default null means it won't be included if not set." +} + +variable "wg_server_private_key" { + type = string + default = null + description = "Wireguard server private key." +} + +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 Wireguard server, defaults to the latest Ubuntu 20.04 AMI if not specified." +} + +variable "wg_server_interface" { + default = "eth0" + description = "The default interface to forward network traffic to." +} + +variable "use_route53" { + type = bool + default = false + description = "Whether to use SSM to store Wireguard Server private key." +} + +variable "route53_hosted_zone_id" { + type = string + default = null + description = "Route53 Hosted zone ID." +} + +variable "route53_record_name" { + type = string + default = null + description = "Route53 Record name." +} + +variable "route53_geo" { + type = any + default = null + description = "Route53 Geolocation config." +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..e4e790f --- /dev/null +++ b/versions.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 0.13.5" + + backend "s3" {} + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 3.0" + } + } +} + +provider "aws" { + region = var.aws_region +}