Skip to content

Commit

Permalink
Add support for custom domains and HTTPS (#561)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
lorenyu authored Feb 23, 2024
1 parent 724206d commit f9785a8
Show file tree
Hide file tree
Showing 22 changed files with 486 additions and 12 deletions.
40 changes: 40 additions & 0 deletions docs/infra/https-support.md
Original file line number Diff line number Diff line change
@@ -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=<NETWORK_NAME>
```

Run the following command to check the status of a certificate (replace `<CERTIFICATE_ARN>` using the output from the previous command):

```bash
aws acm describe-certificate --certificate-arn <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=<APP_NAME> ENVIRONMENT=<ENVIRONMENT>
```
73 changes: 73 additions & 0 deletions docs/infra/set-up-custom-domains.md
Original file line number Diff line number Diff line change
@@ -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=<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 <HOSTED_ZONE>
```

## 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=<APP_NAME> ENVIRONMENT=<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).
4 changes: 3 additions & 1 deletion docs/infra/set-up-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions infra/app/app-config/dev.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions infra/app/app-config/env-config/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions infra/app/app-config/env-config/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions infra/app/app-config/prod.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions infra/app/app-config/staging.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
28 changes: 23 additions & 5 deletions infra/app/service/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions infra/modules/domain/certificates.tf
Original file line number Diff line number Diff line change
@@ -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]
}
10 changes: 10 additions & 0 deletions infra/modules/domain/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions infra/modules/domain/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
}
43 changes: 43 additions & 0 deletions infra/modules/domain/query-logs.tf
Original file line number Diff line number Diff line change
@@ -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"
}
Loading

0 comments on commit f9785a8

Please sign in to comment.