From f9785a860360d849e6733a26f358f50dfd4d6a80 Mon Sep 17 00:00:00 2001 From: Loren Yu Date: Fri, 23 Feb 2024 10:16:34 -0800 Subject: [PATCH] Add support for custom domains and HTTPS (#561) * Add domain config options to the network config objects in project-config module * Domain config has `hosted_zone`, `manage_dns`, and `certificate_configs` options * Certificate config has `source` option * Add domain module which creates Route53 hosted zone and ACM issued SSL certificates * Add `domain_name` and `enable_https` to environment config objects in app-config module * Update service module to create A record routing traffic from custom domain to load balancer * Update service module to attach SSL certificate to load balancer * Add instructions for setting up custom domains and HTTPS --- docs/infra/https-support.md | 40 +++++++++++ docs/infra/set-up-custom-domains.md | 73 ++++++++++++++++++++ docs/infra/set-up-network.md | 4 +- infra/app/app-config/dev.tf | 2 + infra/app/app-config/env-config/outputs.tf | 2 + infra/app/app-config/env-config/variables.tf | 18 +++++ infra/app/app-config/prod.tf | 2 + infra/app/app-config/staging.tf | 2 + infra/app/service/main.tf | 28 ++++++-- infra/modules/domain/certificates.tf | 59 ++++++++++++++++ infra/modules/domain/main.tf | 10 +++ infra/modules/domain/outputs.tf | 9 +++ infra/modules/domain/query-logs.tf | 43 ++++++++++++ infra/modules/domain/variables.tf | 51 ++++++++++++++ infra/modules/service/dns.tf | 13 ++++ infra/modules/service/load-balancer.tf | 39 +++++++++++ infra/modules/service/networking.tf | 8 +++ infra/modules/service/variables.tf | 18 +++++ infra/networks/main.tf | 8 +++ infra/networks/outputs.tf | 11 +++ infra/project-config/main.tf | 6 -- infra/project-config/networks.tf | 52 ++++++++++++++ 22 files changed, 486 insertions(+), 12 deletions(-) create mode 100644 docs/infra/https-support.md create mode 100644 docs/infra/set-up-custom-domains.md create mode 100644 infra/modules/domain/certificates.tf create mode 100644 infra/modules/domain/main.tf create mode 100644 infra/modules/domain/outputs.tf create mode 100644 infra/modules/domain/query-logs.tf create mode 100644 infra/modules/domain/variables.tf create mode 100644 infra/modules/service/dns.tf create mode 100644 infra/networks/outputs.tf create mode 100644 infra/project-config/networks.tf diff --git a/docs/infra/https-support.md b/docs/infra/https-support.md new file mode 100644 index 00000000..aee72d41 --- /dev/null +++ b/docs/infra/https-support.md @@ -0,0 +1,40 @@ +# HTTPS support + +Production systems will want to use HTTPS rather than HTTP to prevent man-in-the-middle attacks. This document describes how HTTPS is configured. This process will: + +1. Issue an SSL/TLS certificate using Amazon Certificate Manager (ACM) for each domain that we want to support HTTPS +2. Associate the certificate with the application's load balancer so that the load balancer can serve HTTPS requests intended for that domain + +## Requirements + +In order to set up HTTPS support you'll also need to have [set up custom domains](/docs/infra/set-up-custom-domains.md). This is because SSL/TLS certificates must be properly configured for the specific domain to support establishing secure connections. + +## 1. Set desired certificates in domain configuration + +For each custom domain you want to set up in the network, define a certificate configuration object and set the `source` to `issued`. You'll probably want at least one custom domain for each application/service in the network. The custom domain must be either the same as the hosted zone or a subdomain of the hosted zone. + +## 2. Update the network layer to issue the certificates + +Run the following command to issue SSL/TLS certificates for each custom domain you configured + +```bash +make infra-update-network NETWORK_NAME= +``` + +Run the following command to check the status of a certificate (replace `` using the output from the previous command): + +```bash +aws acm describe-certificate --certificate-arn --query Certificate.Status +``` + +## 4. Update `enable_https = true` in `app-config` + +Update `enable_https = true` in your application's `app-config` module. You should have already set `domain_name` as part of [setting up custom domain names](/docs/infra/set-up-custom-domains.md). + +## 5. Attach certificate to load balancer + +Run the following command to attach the SSL/TLS certificate to the load balancer + +```bash +make infra-update-app-service APP_NAME= ENVIRONMENT= +``` diff --git a/docs/infra/set-up-custom-domains.md b/docs/infra/set-up-custom-domains.md new file mode 100644 index 00000000..e4ff8474 --- /dev/null +++ b/docs/infra/set-up-custom-domains.md @@ -0,0 +1,73 @@ +# Custom domains + +Production systems will want to set up custom domains to route internet traffic to their application services rather than using AWS-generated hostnames for the load balancers or the CDN. This document describes how to configure custom domains. The custom domain setup process will: + +1. Create an [Amazon Route 53 hosted zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-working-with.html) to manage DNS records for a domain and subdomains +2. Create a DNS A (address) records to route traffic from a custom domain to the application's load balancer + +## Requirements + +Before setting up custom domains you'll need to have [set up the AWS account](./set-up-aws-account.md) + +## 1. Set hosted zone in domain configuration + +Update the value for the `hosted_zone` in the domain configuration. The custom domain configuration is defined as a `domain_config` object in the [network section of the project config module](/infra/project-config/networks.tf). A [hosted zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-working-with.html) represents a domain and all of its subdomains. For example, a hosted zone of `platform-test.navateam.com` includes `platform-test.navateam.com`, `cdn.platform-test.navateam.com`, `notifications.platform-test.navateam.com`, `foo.bar.platform-test.navateam.com`, etc. + +## 2. Update the network layer to create the hosted zone + +Run the following command to create the hosted zone specified in the domain configuration. + +```bash +make infra-update-network NETWORK_NAME= +``` + +## 3. Delegate DNS requests to the newly created hosted zone + +You most likely registered your domain outside of this project. Using whichever service you used to register the domain name (e.g. Namecheap, GoDaddy, Google Domains, etc.), add a DNS NS (nameserver) record. Set the "name" equal to the `hosted_zone` and set the value equal to the list of hosted zone name servers that was created in the previous step. You can see the list of servers by running + +```bash +terraform -chdir=infra/networks output -json hosted_zone_name_servers +``` + +Your NS record might look something like this: + +**Name**: + +```text +platform-test.navateam.com +``` + +**Value**: (Note the periods after each of the server addresses) + +```text +ns-1431.awsdns-50.org. +ns-1643.awsdns-13.co.uk. +ns-687.awsdns-21.net. +ns-80.awsdns-10.com. +``` + +Run the following command to verify that DNS requests are being served by the hosted zone nameservers using `nslookup`. + +```bash +nslookup -type=NS +``` + +## 4. Configure custom domain for your application + +Define the `domain_name` for each of the application environments in the `app-config` module. The `domain_name` must be either the same as the `hosted_zone` or a subdomain of the `hosted_zone`. For example, if your hosted zone is `platform-test.navateam.com`, then `platform-test.navateam.com` and `cdn.platform-test.navateam.com` are both valid values for `domain_name`. + +## 5. Create A (address) records to route traffice from the custom domain to your application's load balancer + +Run the following command to create the A record that routes traffic from the custom domain to the application's load balancer. + +```bash +make infra-update-app-service APP_NAME= ENVIRONMENT= +``` + +## 6. Repeat for each application + +If you have multiple applications in the same network, repeat steps 4 and 5 for each application. + +## Externally managed DNS + +If you plan to manage DNS records outside of the project, then set `network_configs[*].domain_config.manage_dns = false` in [the networks section of the project-config module](/infra/project-config/networks.tf). diff --git a/docs/infra/set-up-network.md b/docs/infra/set-up-network.md index 0e90a07d..bdd99638 100644 --- a/docs/infra/set-up-network.md +++ b/docs/infra/set-up-network.md @@ -11,7 +11,9 @@ The network setup process will configure and deploy network resources needed by Before setting up the network you'll need to have: 1. [Set up the AWS account](./set-up-aws-account.md) -2. Optionally adjust the configuration for the networks you want to have on your project in the [project-config module](/infra/project-config/main.tf). By default there are three networks defined, one for each application environment. If you have multiple apps and want your applications in separate networks, you may want to give the networks differentiating names (e.g. "foo-dev", "foo-prod", "bar-dev", "bar-prod", instead of just "dev", "prod"). +2. Optionally adjust the configuration for the networks you want to have on your project in the [project-config module](/infra/project-config/networks.tf). By default there are three networks defined, one for each application environment. If you have multiple apps and want your applications in separate networks, you may want to give the networks differentiating names (e.g. "foo-dev", "foo-prod", "bar-dev", "bar-prod", instead of just "dev", "prod"). + 1. Optionally, [configure custom domains](/docs/infra/set-up-custom-domains.md). You can also come back to setting up custom domains at a later time. + 2. Optionally, [configure HTTPS support](/docs/infra/https-support.md). You can also come back to setting up HTTPS support at a later time. 3. [Configure the app](/infra/app/app-config/main.tf). 1. Update `has_database` to `true` or `false` depending on whether or not your application has a database to integrate with. This setting determines whether or not to create VPC endpoints needed by the database layer. 2. Update `has_external_non_aws_service` to `true` or `false` depending on whether or not your application makes calls to an external non-AWS service. This setting determines whether or not to create NAT gateways, which allows the service in the private subnet to make requests to the internet. diff --git a/infra/app/app-config/dev.tf b/infra/app/app-config/dev.tf index 51dd733e..d18c7e7f 100644 --- a/infra/app/app-config/dev.tf +++ b/infra/app/app-config/dev.tf @@ -5,6 +5,8 @@ module "dev_config" { default_region = module.project_config.default_region environment = "dev" network_name = "dev" + domain_name = null + enable_https = false has_database = local.has_database has_incident_management_service = local.has_incident_management_service } diff --git a/infra/app/app-config/env-config/outputs.tf b/infra/app/app-config/env-config/outputs.tf index b0eb5e79..db4c07d3 100644 --- a/infra/app/app-config/env-config/outputs.tf +++ b/infra/app/app-config/env-config/outputs.tf @@ -17,6 +17,8 @@ output "network_name" { output "service_config" { value = { service_name = "${local.prefix}${var.app_name}-${var.environment}" + domain_name = var.domain_name + enable_https = var.enable_https region = var.default_region cpu = var.service_cpu memory = var.service_memory diff --git a/infra/app/app-config/env-config/variables.tf b/infra/app/app-config/env-config/variables.tf index 49f766b1..0dec37e8 100644 --- a/infra/app/app-config/env-config/variables.tf +++ b/infra/app/app-config/env-config/variables.tf @@ -21,6 +21,24 @@ variable "default_region" { type = string } +variable "domain_name" { + type = string + description = "The fully qualified domain name for the application" + default = null +} + +variable "enable_https" { + type = bool + description = "Whether to enable HTTPS for the application" + default = false +} + +variable "certificate_arn" { + type = string + description = "The ARN of the certificate to use for the application" + default = null +} + variable "has_database" { type = bool } diff --git a/infra/app/app-config/prod.tf b/infra/app/app-config/prod.tf index a8790aa7..c452531f 100644 --- a/infra/app/app-config/prod.tf +++ b/infra/app/app-config/prod.tf @@ -5,6 +5,8 @@ module "prod_config" { default_region = module.project_config.default_region environment = "prod" network_name = "prod" + domain_name = null + enable_https = false has_database = local.has_database has_incident_management_service = local.has_incident_management_service diff --git a/infra/app/app-config/staging.tf b/infra/app/app-config/staging.tf index 9a77c34f..8205c520 100644 --- a/infra/app/app-config/staging.tf +++ b/infra/app/app-config/staging.tf @@ -5,6 +5,8 @@ module "staging_config" { default_region = module.project_config.default_region environment = "staging" network_name = "staging" + domain_name = null + enable_https = false has_database = local.has_database has_incident_management_service = local.has_incident_management_service } diff --git a/infra/app/service/main.tf b/infra/app/service/main.tf index 9ac07995..99c33144 100644 --- a/infra/app/service/main.tf +++ b/infra/app/service/main.tf @@ -35,6 +35,8 @@ locals { database_config = local.environment_config.database_config storage_config = local.environment_config.storage_config incident_management_service_integration_config = local.environment_config.incident_management_service_integration + + network_config = module.project_config.network_configs[local.environment_config.network_name] } terraform { @@ -101,14 +103,30 @@ data "aws_security_groups" "aws_services" { } } +data "aws_acm_certificate" "certificate" { + count = local.service_config.enable_https ? 1 : 0 + domain = local.service_config.domain_name +} + +data "aws_route53_zone" "zone" { + count = local.service_config.domain_name != null ? 1 : 0 + name = local.network_config.domain_config.hosted_zone +} + module "service" { - source = "../../modules/service" - service_name = local.service_config.service_name + source = "../../modules/service" + service_name = local.service_config.service_name + image_repository_name = module.app_config.image_repository_name image_tag = local.image_tag - vpc_id = data.aws_vpc.network.id - public_subnet_ids = data.aws_subnets.public.ids - private_subnet_ids = data.aws_subnets.private.ids + + vpc_id = data.aws_vpc.network.id + public_subnet_ids = data.aws_subnets.public.ids + private_subnet_ids = data.aws_subnets.private.ids + + domain_name = local.service_config.domain_name + hosted_zone_id = local.service_config.domain_name != null ? data.aws_route53_zone.zone[0].zone_id : null + certificate_arn = local.service_config.enable_https ? data.aws_acm_certificate.certificate[0].arn : null cpu = local.service_config.cpu memory = local.service_config.memory diff --git a/infra/modules/domain/certificates.tf b/infra/modules/domain/certificates.tf new file mode 100644 index 00000000..d935414a --- /dev/null +++ b/infra/modules/domain/certificates.tf @@ -0,0 +1,59 @@ +locals { + # Filter configs for issued certificates. + # These certificates are managed by the project using AWS Certificate Manager. + issued_certificate_configs = { + for domain, config in var.certificate_configs : domain => config + if config.source == "issued" + } + + # Filter configs for imported certificates. + # These certificates are created outside of the project and imported. + imported_certificate_configs = { + for domain, config in var.certificate_configs : domain => config + if config.source == "imported" + } + + domain_validation_options = merge([ + for domain, config in local.issued_certificate_configs : + { + for dvo in aws_acm_certificate.issued[domain].domain_validation_options : + dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + ]...) +} + +# ACM certificate that will be used by the load balancer. +resource "aws_acm_certificate" "issued" { + for_each = local.issued_certificate_configs + + domain_name = each.key + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } +} + +# DNS records for certificate validation. +resource "aws_route53_record" "validation" { + for_each = local.domain_validation_options + + allow_overwrite = true + zone_id = aws_route53_zone.zone[0].zone_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.record] +} + +# Representation of successful validation of the ACM certificate. +resource "aws_acm_certificate_validation" "validation" { + for_each = local.imported_certificate_configs + + certificate_arn = aws_acm_certificate.issued[each.key].arn + validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn] +} diff --git a/infra/modules/domain/main.tf b/infra/modules/domain/main.tf new file mode 100644 index 00000000..931163f3 --- /dev/null +++ b/infra/modules/domain/main.tf @@ -0,0 +1,10 @@ +# Create a Route53 hosted zone for the domain. +# Individual address records will be created in the service layer by the services that +# need them (e.g. the load balancer or CDN). +# If DNS is managed elsewhere then this resource will not be created. +resource "aws_route53_zone" "zone" { + count = var.manage_dns ? 1 : 0 + name = var.name + + # checkov:skip=CKV2_AWS_38:TODO(https://github.com/navapbc/template-infra/issues/560) enable DNSSEC +} diff --git a/infra/modules/domain/outputs.tf b/infra/modules/domain/outputs.tf new file mode 100644 index 00000000..bf1615ec --- /dev/null +++ b/infra/modules/domain/outputs.tf @@ -0,0 +1,9 @@ +output "hosted_zone_name_servers" { + value = length(aws_route53_zone.zone) > 0 ? aws_route53_zone.zone[0].name_servers : [] +} + +output "certificate_arns" { + value = { + for domain in keys(var.certificate_configs) : domain => aws_acm_certificate.issued[domain].arn + } +} diff --git a/infra/modules/domain/query-logs.tf b/infra/modules/domain/query-logs.tf new file mode 100644 index 00000000..bc2e5c78 --- /dev/null +++ b/infra/modules/domain/query-logs.tf @@ -0,0 +1,43 @@ +# DNS query logging + +resource "aws_cloudwatch_log_group" "dns_query_logging" { + count = var.manage_dns ? 1 : 0 + + name = "/dns/${var.name}" + retention_in_days = 30 + + # checkov:skip=CKV_AWS_158:No need to manage KMS key for DNS query logs or audit access to these logs +} + +resource "aws_route53_query_log" "dns_query_logging" { + count = var.manage_dns ? 1 : 0 + + zone_id = aws_route53_zone.zone[0].zone_id + cloudwatch_log_group_arn = aws_cloudwatch_log_group.dns_query_logging[0].arn + + depends_on = [aws_cloudwatch_log_resource_policy.dns_query_logging[0]] +} + +# Allow Route53 to write logs to any log group under /dns/* +data "aws_iam_policy_document" "dns_query_logging" { + statement { + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + + resources = ["arn:aws:logs:*:*:log-group:/dns/*"] + + principals { + identifiers = ["route53.amazonaws.com"] + type = "Service" + } + } +} + +resource "aws_cloudwatch_log_resource_policy" "dns_query_logging" { + count = var.manage_dns ? 1 : 0 + + policy_document = data.aws_iam_policy_document.dns_query_logging.json + policy_name = "dns-query-logging" +} diff --git a/infra/modules/domain/variables.tf b/infra/modules/domain/variables.tf new file mode 100644 index 00000000..5532c852 --- /dev/null +++ b/infra/modules/domain/variables.tf @@ -0,0 +1,51 @@ +variable "name" { + type = string + description = "Fully qualified domain name" +} + +variable "manage_dns" { + type = bool + description = "Whether DNS is managed by the project (true) or managed externally (false)" +} + +variable "certificate_configs" { + type = map(object({ + source = string + private_key = optional(string) + certificate_body = optional(string) + })) + description = <