From 9241bbd4d4b3f7d81af5477bf0ac6e98e210626e Mon Sep 17 00:00:00 2001 From: Lud Date: Sat, 18 Apr 2026 18:18:19 +0200 Subject: [PATCH 01/11] wip - first draft --- aws/alb-mtls/README.md | 44 ++++++++++++++++++++++++++ aws/alb-mtls/main.tf | 42 +++++++++++++++++++++++++ aws/alb-mtls/outputs.tf | 9 ++++++ aws/alb-mtls/variables.tf | 19 ++++++++++++ aws/alb-mtls/versions.tf | 8 +++++ aws/architecture.md | 29 ++++++++++++++++- aws/ecs/alb.tf | 8 +++++ aws/ecs/variables.tf | 9 ++++++ aws/stack/app/alb-mtls.tf | 8 +++++ aws/stack/app/cloudflare.tf | 9 +++--- aws/stack/app/ecs.tf | 5 +++ aws/stack/app/variables.tf | 29 +++++++++++++++++ azure/stack/setup/variables.tf | 2 +- cloudflare/tls.tf | 17 ++++++++++ cloudflare/variables.tf | 10 ++++++ cloudflare/versions.tf | 2 +- script/generate-mtls-certs.sh | 57 ++++++++++++++++++++++++++++++++++ 17 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 aws/alb-mtls/README.md create mode 100644 aws/alb-mtls/main.tf create mode 100644 aws/alb-mtls/outputs.tf create mode 100644 aws/alb-mtls/variables.tf create mode 100644 aws/alb-mtls/versions.tf create mode 100644 aws/stack/app/alb-mtls.tf create mode 100755 script/generate-mtls-certs.sh diff --git a/aws/alb-mtls/README.md b/aws/alb-mtls/README.md new file mode 100644 index 000000000..bd6df0d14 --- /dev/null +++ b/aws/alb-mtls/README.md @@ -0,0 +1,44 @@ +# AWS ALB mTLS (Trust Store) Module + +This module provisions an AWS ALB Trust Store to enable mTLS (mutual TLS) authentication on an ALB listener. This is particularly useful for implementing **Cloudflare Authenticated Origin Pulls**. + +## Overview + +AWS ALB Trust Stores (`aws_lb_trust_store`) are required for verifying incoming client certificates (such as the certificate Cloudflare presents when proxying traffic to your origin). + +**Important limitation:** Trust Stores cannot use AWS Certificate Manager (ACM). ACM only stores *server* certificates (what the ALB presents to clients). Trust Stores *must* read CA certificate bundles from S3. This module creates a minimal, locked-down S3 bucket to store the provided CA bundle and creates the Trust Store from it. + +## ⚠️ Rollout Warning + +When enabling mTLS on an *existing* ALB, **deployment order matters**: +Adding `mutual_authentication` with `mode = "verify"` to an active ALB listener **immediately rejects all requests without a valid client certificate**. + +The safe rollout strategy is: +1. Generate your CA and leaf certificates (e.g., using `script/generate-mtls-certs.sh`). +2. Deploy the **Cloudflare** side first (upload the leaf cert, enable Authenticated Origin Pulls for the zone/hostname). +3. Deploy the **AWS** side with `alb_mtls_mode = "passthrough"`. This allows traffic while logging the client certificate validation status. +4. Validate in CloudWatch / ALB access logs that Cloudflare requests include valid certificate info. +5. Switch the AWS config to `alb_mtls_mode = "verify"` to enforce protection. + +## Architecture + +```mermaid +sequenceDiagram + participant User + participant CF as Cloudflare Edge
(your account) + participant ALB as AWS ALB + participant ECS as ECS Containers + + User->>CF: HTTPS request to api.example.com + Note over CF: Cloudflare presents YOUR
custom leaf certificate + CF->>ALB: mTLS handshake (leaf cert signed by your CA) + ALB->>ALB: Verify cert chain against Trust Store
(your root CA only) + ALB->>ECS: Forward to target group + ECS-->>User: Response (via CF) + + Note over User,ALB: Attack scenario — blocked + User->>CF: Attacker routes evil.com → ALB IP + Note over CF: Cloudflare presents the
SHARED zone-level cert + CF->>ALB: mTLS handshake (wrong CA) + ALB--xCF: ❌ Trust Store rejects
(not signed by your CA) +``` diff --git a/aws/alb-mtls/main.tf b/aws/alb-mtls/main.tf new file mode 100644 index 000000000..2a510c974 --- /dev/null +++ b/aws/alb-mtls/main.tf @@ -0,0 +1,42 @@ +locals { + name = var.name != null ? var.name : "mtls" +} + +# S3 bucket — only exists because aws_lb_trust_store requires S3 +module "s3_bucket" { + source = "../s3-private" + + project = var.project + environment = var.environment + bucket_name = "${var.project}-${var.environment}-${local.name}" + + # ALB Trust Store only needs to read the file, no special features required + versioning = true + + # No user-uploaded files, only a static CA cert managed by Terraform. + # Disable malware scanning to avoid unnecessary cost and a potential + # race condition where the Trust Store cannot read an unscanned object. + file_malwarescanning = { + enabled = false + allow_downloading_unscanned_files = true + } +} + +resource "aws_s3_object" "ca_bundle" { + bucket = module.s3_bucket.id + key = "ca-certificates-bundle.pem" + content = var.ca_certificates_pem + content_type = "application/x-pem-file" +} + +# ALB Trust Store — references the CA bundle in S3 +resource "aws_lb_trust_store" "main" { + name = "${var.project}-${var.environment}-${local.name}" + ca_certificates_bundle_s3_bucket = module.s3_bucket.id + ca_certificates_bundle_s3_key = aws_s3_object.ca_bundle.key + tags = { + Name = "${var.project}-${var.environment}-${local.name}" + Project = var.project + Environment = var.environment + } +} diff --git a/aws/alb-mtls/outputs.tf b/aws/alb-mtls/outputs.tf new file mode 100644 index 000000000..4def04c87 --- /dev/null +++ b/aws/alb-mtls/outputs.tf @@ -0,0 +1,9 @@ +output "trust_store_arn" { + description = "ARN of the ALB Trust Store." + value = aws_lb_trust_store.main.arn +} + +output "trust_store_name" { + description = "Name of the ALB Trust Store." + value = aws_lb_trust_store.main.name +} diff --git a/aws/alb-mtls/variables.tf b/aws/alb-mtls/variables.tf new file mode 100644 index 000000000..a0ba6583a --- /dev/null +++ b/aws/alb-mtls/variables.tf @@ -0,0 +1,19 @@ +variable "project" { + type = string +} + +variable "environment" { + type = string +} + +variable "ca_certificates_pem" { + description = "PEM-encoded root CA certificate that signed the Cloudflare client certificate." + type = string + sensitive = true +} + +variable "name" { + description = "Name prefix for trust store resources. Defaults to mTLS." + type = string + default = "mtls" +} diff --git a/aws/alb-mtls/versions.tf b/aws/alb-mtls/versions.tf new file mode 100644 index 000000000..77ef34b40 --- /dev/null +++ b/aws/alb-mtls/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + } + } + required_version = ">= 1.0" +} diff --git a/aws/architecture.md b/aws/architecture.md index 225b1e1f4..cbf33f781 100644 --- a/aws/architecture.md +++ b/aws/architecture.md @@ -26,7 +26,7 @@ graph TD %% Connections Users -- "HTTPS" --> CF - CF -- "HTTPS" --> ALB + CF -- "HTTPS (mTLS optional)" --> ALB ALB -- "HTTP / HTTPS" --> ECS ECS -- "PostgreSQL (TCP 5432)" --> RDS @@ -292,3 +292,30 @@ flowchart TD TBAC -- "Allows `s3:GetObject`" --> CleanFile TBAC -- "Denies `s3:GetObject`" --> InfectedFile ``` + +## Secure Origin Access (mTLS) + +To ensure that only traffic proxied through Cloudflare can reach the Application Load Balancer (ALB), we enforce **mutual TLS (mTLS)** via Cloudflare Authenticated Origin Pulls. + +Cloudflare is configured to present a custom, per-zone client certificate during the origin pull. The ALB listener requires this client certificate and validates it against a Trust Store that contains the corresponding root CA bundle. This prevents attackers from bypassing Cloudflare by directly hitting the ALB's IP address. + +```mermaid +sequenceDiagram + participant User + participant CF as Cloudflare Edge
(your account) + participant ALB as AWS ALB + participant ECS as ECS Containers + + User->>CF: HTTPS request to api.example.com + Note over CF: Cloudflare presents YOUR
custom leaf certificate + CF->>ALB: mTLS handshake (leaf cert signed by your CA) + ALB->>ALB: Verify cert chain against Trust Store
(your root CA only) + ALB->>ECS: Forward to target group + ECS-->>User: Response (via CF) + + Note over User,ALB: Attack scenario — blocked + User->>CF: Attacker routes evil.com → ALB IP + Note over CF: Cloudflare presents the
SHARED zone-level cert + CF->>ALB: mTLS handshake (wrong CA) + ALB--xCF: ❌ Trust Store rejects
(not signed by your CA) +``` diff --git a/aws/ecs/alb.tf b/aws/ecs/alb.tf index 720e02440..431f744a0 100644 --- a/aws/ecs/alb.tf +++ b/aws/ecs/alb.tf @@ -60,6 +60,14 @@ resource "aws_alb_listener" "https" { target_group_arn = aws_alb_target_group.ecs.arn type = "forward" } + + dynamic "mutual_authentication" { + for_each = var.alb_mtls != null ? [var.alb_mtls] : [] + content { + mode = mutual_authentication.value.mode + trust_store_arn = mutual_authentication.value.trust_store_arn + } + } } # Using SNI to attach multiple certificates to the same load balancer diff --git a/aws/ecs/variables.tf b/aws/ecs/variables.tf index ca00f87e1..67ad5d3a6 100644 --- a/aws/ecs/variables.tf +++ b/aws/ecs/variables.tf @@ -247,3 +247,12 @@ variable "alb_access_logs" { }) default = null } + +variable "alb_mtls" { + description = "mTLS config for Cloudflare Authenticated Origin Pulls. Requires a Trust Store ARN." + type = object({ + mode = string # "verify" or "passthrough" + trust_store_arn = string + }) + default = null +} diff --git a/aws/stack/app/alb-mtls.tf b/aws/stack/app/alb-mtls.tf new file mode 100644 index 000000000..5595a7722 --- /dev/null +++ b/aws/stack/app/alb-mtls.tf @@ -0,0 +1,8 @@ +module "alb_mtls" { + source = "../../alb-mtls" + count = var.alb_mtls_ca_certificates_pem != null ? 1 : 0 + + project = var.project + environment = var.environment + ca_certificates_pem = var.alb_mtls_ca_certificates_pem +} diff --git a/aws/stack/app/cloudflare.tf b/aws/stack/app/cloudflare.tf index ce5fca4f6..b94a44e2a 100644 --- a/aws/stack/app/cloudflare.tf +++ b/aws/stack/app/cloudflare.tf @@ -12,8 +12,9 @@ module "cloudflare" { s3_cloudflare_records = var.s3_cloudflare_records # optional - bastion_enabled = true - tls_settings = var.tls_settings - hsts_settings = var.hsts_settings - bastion_public_dns = module.ecs.nlb_dns_name + bastion_enabled = true + tls_settings = var.tls_settings + hsts_settings = var.hsts_settings + bastion_public_dns = module.ecs.nlb_dns_name + authenticated_origin_pull = var.authenticated_origin_pull } diff --git a/aws/stack/app/ecs.tf b/aws/stack/app/ecs.tf index c87aabfa7..12180e996 100644 --- a/aws/stack/app/ecs.tf +++ b/aws/stack/app/ecs.tf @@ -65,6 +65,11 @@ module "ecs" { autoscale_metrics_map = var.autoscale_metrics_map cloudwatch_logs_retention_in_days = var.cloudwatch_logs_retention_in_days service_discovery_enabled = var.service_discovery_enabled + + alb_mtls = var.alb_mtls_ca_certificates_pem != null ? { + mode = var.alb_mtls_mode + trust_store_arn = module.alb_mtls[0].trust_store_arn + } : null } module "cloudwatch" { diff --git a/aws/stack/app/variables.tf b/aws/stack/app/variables.tf index 80eca651e..cc014e56c 100644 --- a/aws/stack/app/variables.tf +++ b/aws/stack/app/variables.tf @@ -720,3 +720,32 @@ variable "cloudwatch_logs_retention_in_days" { default = 90 } # =============== Cloudwatch ================ # + +# =============== mTLS / Authenticated Origin Pulls ================ # +variable "alb_mtls_ca_certificates_pem" { + description = "PEM-encoded root CA cert for ALB Trust Store. Enables mTLS when set." + type = string + default = null + sensitive = true +} + +variable "alb_mtls_mode" { + description = "mTLS mode: 'passthrough' (test) or 'verify' (enforce)." + type = string + default = "verify" + validation { + condition = contains(["verify", "passthrough"], var.alb_mtls_mode) + error_message = "Must be 'verify' or 'passthrough'." + } +} + +variable "authenticated_origin_pull" { + description = "Cloudflare AOP per-zone config. Leaf cert + key uploaded to Cloudflare." + type = object({ + enabled = bool + certificate = string + private_key = string + }) + default = null +} +# =============== mTLS / Authenticated Origin Pulls ================ # diff --git a/azure/stack/setup/variables.tf b/azure/stack/setup/variables.tf index 8b5aba4ce..dac25d046 100644 --- a/azure/stack/setup/variables.tf +++ b/azure/stack/setup/variables.tf @@ -36,7 +36,7 @@ variable "key_vault_config" { expired_in_days = optional(string, "90") notify_before_expiry = optional(string, "90") # User's object_ids who has access to the key vault - user_ids = optional(list(string), []) + user_ids = optional(list(string), []) }) default = {} diff --git a/cloudflare/tls.tf b/cloudflare/tls.tf index 2397ad372..a19169822 100644 --- a/cloudflare/tls.tf +++ b/cloudflare/tls.tf @@ -23,3 +23,20 @@ resource "cloudflare_zone_settings_override" "tls" { } } } + +# --- Authenticated Origin Pulls (per-zone, custom CA) --- + +resource "cloudflare_authenticated_origin_pulls_certificate" "custom" { + count = var.authenticated_origin_pull != null ? 1 : 0 + zone_id = data.cloudflare_zone.default.id + certificate = var.authenticated_origin_pull.certificate + private_key = var.authenticated_origin_pull.private_key + type = "per-zone" +} + +resource "cloudflare_authenticated_origin_pulls" "main" { + count = var.authenticated_origin_pull != null ? 1 : 0 + zone_id = data.cloudflare_zone.default.id + enabled = var.authenticated_origin_pull.enabled + authenticated_origin_pulls_certificate = cloudflare_authenticated_origin_pulls_certificate.custom[0].id +} diff --git a/cloudflare/variables.tf b/cloudflare/variables.tf index dccfcded4..e604c35a1 100644 --- a/cloudflare/variables.tf +++ b/cloudflare/variables.tf @@ -55,3 +55,13 @@ variable "s3_cloudflare_records" { })) default = {} } + +variable "authenticated_origin_pull" { + description = "Per-zone Authenticated Origin Pulls config. Certificate/key are the leaf cert uploaded to Cloudflare." + type = object({ + enabled = bool + certificate = string # PEM-encoded leaf certificate + private_key = string # PEM-encoded private key for the leaf cert + }) + default = null +} diff --git a/cloudflare/versions.tf b/cloudflare/versions.tf index f60d36283..8caf76760 100644 --- a/cloudflare/versions.tf +++ b/cloudflare/versions.tf @@ -2,7 +2,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "~> 4.0" + version = ">= 4.0" } } required_version = ">= 1.0" diff --git a/script/generate-mtls-certs.sh b/script/generate-mtls-certs.sh new file mode 100755 index 000000000..297290cb2 --- /dev/null +++ b/script/generate-mtls-certs.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -e + +# This script generates a custom root CA and a leaf certificate +# to be used for Cloudflare Authenticated Origin Pulls (mTLS). +# +# Usage: ./generate-mtls-certs.sh [domain-pattern] +# e.g. ./generate-mtls-certs.sh "*.my-page.com" + +HOSTNAME=${1:-*.my-page.com} +CERTS_DIR="mtls_certs" + +echo "===========================================================" +echo "Generating Certificates for mTLS (Authenticated Origin Pulls)" +echo "Domain Pattern: $HOSTNAME" +echo "===========================================================" + +mkdir -p "$CERTS_DIR" +cd "$CERTS_DIR" + +echo "1. Generating 4096-bit RSA private key for the Root CA..." +# Use -aes256 locally, but we might want script to be non-interactive +# If you want it secure with passphrase, remove the comments below. +# Here we generate an unencrypted key for easy automated usage if needed, +# or encrypted if prompted. We will use unencrypted for the root for simplicity in the script, +# but in production, a passphrase-protected key is recommended. +openssl genrsa -out rootca.key 4096 + +echo "2. Creating the CA root certificate..." +openssl req -x509 -new -nodes -key rootca.key -sha256 -days 1826 -out rootca.crt -subj "/C=US/ST=State/L=City/O=Company/CN=OAP-Root-CA" + +echo "3. Creating a Certificate Signing Request (CSR) for the hostname..." +openssl req -new -nodes -newkey rsa:4096 -keyout cert.key -out cert.csr -subj "/C=US/ST=State/L=City/O=Company/CN=$HOSTNAME" + +echo "4. Creating extensions file..." +cat > cert.v3.ext << 'EOF' +basicConstraints=CA:FALSE +EOF + +echo "5. Signing the certificate using the Root CA..." +openssl x509 -req -in cert.csr -CA rootca.crt -CAkey rootca.key -CAcreateserial -out cert.crt -days 730 -sha256 -extfile ./cert.v3.ext + +echo "===========================================================" +echo "Done! Certificates generated in the '$CERTS_DIR' directory:" +echo " - rootca.crt: Upload this to AWS (alb_mtls_ca_certificates_pem)" +echo " - cert.crt: Upload this to Cloudflare (authenticated_origin_pull.certificate)" +echo " - cert.key: Upload this to Cloudflare (authenticated_origin_pull.private_key)" +echo "===========================================================" +echo "" +echo "In your Terraform configuration, you can reference these files:" +echo "alb_mtls_ca_certificates_pem = file(\"../script/$CERTS_DIR/rootca.crt\")" +echo "authenticated_origin_pull = {" +echo " enabled = true" +echo " certificate = file(\"../script/$CERTS_DIR/cert.crt\")" +echo " private_key = file(\"../script/$CERTS_DIR/cert.key\")" +echo "}" +echo "===========================================================" From ae968763583e7e3fbba9588ec48417451e44e1a8 Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 10:30:22 +0200 Subject: [PATCH 02/11] mTLS cloudflare <> AWS & AWS DNSSEC --- .gitignore | 4 + aws/alb-mtls/README.md | 54 ++++++++++++- aws/alb-mtls/main.tf | 7 +- aws/alb-mtls/versions.tf | 3 +- aws/ecs/alb.tf | 2 +- aws/ecs/versions.tf | 2 +- aws/route53domains-dnssec/README.md | 50 ++++++++++++ aws/route53domains-dnssec/main.tf | 13 ++++ aws/route53domains-dnssec/outputs.tf | 4 + .../tests/basic.tftest.hcl | 32 ++++++++ aws/route53domains-dnssec/variables.tf | 39 ++++++++++ aws/route53domains-dnssec/versions.tf | 9 +++ aws/stack/app/README.md | 64 +++++++++++++++- aws/stack/app/dnssec.tf | 13 ++++ aws/stack/app/outputs.tf | 5 ++ aws/stack/app/tests/validate/main.tf | 53 +++++++++++++ aws/stack/app/variables.tf | 9 ++- aws/stack/app/versions.tf | 7 +- cloudflare/README.md | 16 ++++ cloudflare/tls.tf | 4 + cloudflare/variables.tf | 3 +- cloudflare/versions.tf | 2 +- script/generate-mtls-certs.sh | 56 ++++++++------ script/rotate-mtls-certificates.sh | 76 +++++++++++++++++++ 24 files changed, 488 insertions(+), 39 deletions(-) create mode 100644 aws/route53domains-dnssec/README.md create mode 100644 aws/route53domains-dnssec/main.tf create mode 100644 aws/route53domains-dnssec/outputs.tf create mode 100644 aws/route53domains-dnssec/tests/basic.tftest.hcl create mode 100644 aws/route53domains-dnssec/variables.tf create mode 100644 aws/route53domains-dnssec/versions.tf create mode 100644 aws/stack/app/dnssec.tf create mode 100644 aws/stack/app/tests/validate/main.tf create mode 100755 script/rotate-mtls-certificates.sh diff --git a/.gitignore b/.gitignore index 81665f552..19db24042 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ cloudwatch-snowflake/dist/tracker.zip .env + +# Local mTLS certificate material +script/mtls_certs/ +/mtls_certs/ diff --git a/aws/alb-mtls/README.md b/aws/alb-mtls/README.md index bd6df0d14..8dc546d1d 100644 --- a/aws/alb-mtls/README.md +++ b/aws/alb-mtls/README.md @@ -16,10 +16,56 @@ Adding `mutual_authentication` with `mode = "verify"` to an active ALB listener The safe rollout strategy is: 1. Generate your CA and leaf certificates (e.g., using `script/generate-mtls-certs.sh`). 2. Deploy the **Cloudflare** side first (upload the leaf cert, enable Authenticated Origin Pulls for the zone/hostname). -3. Deploy the **AWS** side with `alb_mtls_mode = "passthrough"`. This allows traffic while logging the client certificate validation status. -4. Validate in CloudWatch / ALB access logs that Cloudflare requests include valid certificate info. +3. Deploy the **AWS** side with `alb_mtls_mode = "passthrough"`. Passthrough does not validate the client certificate; it forwards the presented certificate chain to the target in `X-Amzn-Mtls-Clientcert`. +4. Confirm through a diagnostic target or ALB connection logs that Cloudflare presents the expected client certificate. Do not treat passthrough as an access-control boundary. 5. Switch the AWS config to `alb_mtls_mode = "verify"` to enforce protection. +In `verify` mode the ALB terminates an invalid or certificate-less TLS connection before it becomes an HTTP request. Keep Cloudflare IP allowlisting as defense in depth, and keep the Cloudflare SSL mode at **Full (strict)**. + +The CA private key is not required by Terraform or AWS, but it is required whenever a new Cloudflare client leaf certificate is issued. Keep it encrypted in a restricted administrator vault. The CA certificate is public material and is the only CA material uploaded to the ALB trust store. + +## CA storage + +`script/generate-mtls-certs.sh` encrypts `rootca.key` with AES-256. Set `MTLS_CA_PASSPHRASE` for non-interactive use; when run from a terminal, the script prompts for it instead: + +```shell +MTLS_CA_PASSPHRASE="$passphrase" ./script/generate-mtls-certs.sh "*.example.com" +``` + +For projects using the restricted `${project}/terraform/${environment}` Secrets Manager vault, store these values there: + +- `cloudflare_aop_ca_private_key_pem`: the encrypted contents of `rootca.key`. +- `cloudflare_aop_ca_private_key_passphrase`: the encryption passphrase. +- `cloudflare_aop_ca_certificate_pem`: the public `rootca.crt`, so CI can create renewed leaf certificates without another storage dependency. + +The encrypted key and its passphrase may share this vault when access is already limited to the administrators and CI role authorized to manage infrastructure-level credentials. Do not pass the CA private key or its passphrase through a Terraform resource, data source, variable, or output: Terraform would copy the value into state. Create the vault with Terraform, then populate and read these fields through the AWS CLI or Secrets Manager API outside Terraform. + +## Leaf certificate rotation + +Cloudflare does not replace an expired custom Authenticated Origin Pulls certificate automatically. Configure Cloudflare's zone-level AOP certificate expiration notification, which warns 30 and 14 days before expiry, and rotate well before the first warning becomes urgent. + +`script/rotate-mtls-certificates.sh` is a portable helper intended to be copied into a project's deployment repository or invoked from that repository's CI job: + +```shell +export MTLS_CA_PASSPHRASE="$passphrase" +./script/rotate-mtls-certificates.sh \ + "*.example.com" \ + /tmp/cloudflare-aop/rootca.key \ + /tmp/cloudflare-aop/rootca.crt \ + /tmp/cloudflare-aop/renewed +``` + +An administrator-triggered or scheduled CI rotation should: + +1. Assume a narrowly scoped deployment role and read the three CA values from `${project}/terraform/${environment}`. +2. Write the CA key and certificate to a private temporary directory, without logging their contents. +3. Run the rotation script to produce a new `cert.crt` and `cert.key` signed by the existing CA. +4. Persist the new leaf certificate and key wherever the normal Terraform CI job hydrates them, make those files available to the Terraform configuration, and run `terraform apply`. The Cloudflare certificate resource uses `create_before_destroy`, so it uploads and selects the new certificate before deleting the old one. +5. Verify proxied traffic succeeds and a direct request without a client certificate is rejected by the ALB. +6. Delete all temporary files. + +Automate leaf rotation only. Root CA rotation requires an overlapping ALB trust-store rollout and remains a deliberate operation. The rotation script refuses to issue a leaf whose requested lifetime exceeds the remaining CA lifetime. See [Cloudflare's AOP certificate management guidance](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/set-up/manage-certificates/). + ## Architecture ```mermaid @@ -37,8 +83,8 @@ sequenceDiagram ECS-->>User: Response (via CF) Note over User,ALB: Attack scenario — blocked - User->>CF: Attacker routes evil.com → ALB IP - Note over CF: Cloudflare presents the
SHARED zone-level cert + User->>CF: Attacker routes evil.com → ALB origin + Note over CF: Cloudflare presents a certificate
that was not signed by your CA CF->>ALB: mTLS handshake (wrong CA) ALB--xCF: ❌ Trust Store rejects
(not signed by your CA) ``` diff --git a/aws/alb-mtls/main.tf b/aws/alb-mtls/main.tf index 2a510c974..837a0a67e 100644 --- a/aws/alb-mtls/main.tf +++ b/aws/alb-mtls/main.tf @@ -31,9 +31,10 @@ resource "aws_s3_object" "ca_bundle" { # ALB Trust Store — references the CA bundle in S3 resource "aws_lb_trust_store" "main" { - name = "${var.project}-${var.environment}-${local.name}" - ca_certificates_bundle_s3_bucket = module.s3_bucket.id - ca_certificates_bundle_s3_key = aws_s3_object.ca_bundle.key + name = "${var.project}-${var.environment}-${local.name}" + ca_certificates_bundle_s3_bucket = module.s3_bucket.id + ca_certificates_bundle_s3_key = aws_s3_object.ca_bundle.key + ca_certificates_bundle_s3_object_version = aws_s3_object.ca_bundle.version_id tags = { Name = "${var.project}-${var.environment}-${local.name}" Project = var.project diff --git a/aws/alb-mtls/versions.tf b/aws/alb-mtls/versions.tf index 77ef34b40..54852eddd 100644 --- a/aws/alb-mtls/versions.tf +++ b/aws/alb-mtls/versions.tf @@ -1,7 +1,8 @@ terraform { required_providers { aws = { - source = "hashicorp/aws" + source = "hashicorp/aws" + version = ">= 5.47" } } required_version = ">= 1.0" diff --git a/aws/ecs/alb.tf b/aws/ecs/alb.tf index 431f744a0..177df665c 100644 --- a/aws/ecs/alb.tf +++ b/aws/ecs/alb.tf @@ -65,7 +65,7 @@ resource "aws_alb_listener" "https" { for_each = var.alb_mtls != null ? [var.alb_mtls] : [] content { mode = mutual_authentication.value.mode - trust_store_arn = mutual_authentication.value.trust_store_arn + trust_store_arn = mutual_authentication.value.mode == "verify" ? mutual_authentication.value.trust_store_arn : null } } } diff --git a/aws/ecs/versions.tf b/aws/ecs/versions.tf index 94400bc9f..54852eddd 100644 --- a/aws/ecs/versions.tf +++ b/aws/ecs/versions.tf @@ -2,7 +2,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 4.0" + version = ">= 5.47" } } required_version = ">= 1.0" diff --git a/aws/route53domains-dnssec/README.md b/aws/route53domains-dnssec/README.md new file mode 100644 index 000000000..ecfa84d90 --- /dev/null +++ b/aws/route53domains-dnssec/README.md @@ -0,0 +1,50 @@ +# Route 53 Domains DNSSEC Registration + +Registers a DNSSEC key from an external authoritative DNS provider, such as Cloudflare, with a domain purchased through Route 53 Domains. This module does not create or sign a Route 53 hosted zone. + +Route 53 Domains API operations only run in `us-east-1`, so pass an AWS provider configured for that region: + +```hcl +provider "aws" { + alias = "us-east-1" + region = "us-east-1" +} + +module "route53domains_dnssec" { + source = "github.com/dbl-works/terraform//aws/route53domains-dnssec?ref=main" + + providers = { + aws = aws.us-east-1 + } + + domain_name = "example.com" + dnssec_algorithm = module.cloudflare.dnssec_algorithm + dnssec_key_type = module.cloudflare.dnssec_key_type + dnssec_public_key = module.cloudflare.dnssec_public_key +} +``` + +## Existing manually registered keys + +Do not create a duplicate registrar key. Import the existing Route 53 Domains key before enabling management in a stack: + +```shell +terraform import 'module.route53domains_dnssec.aws_route53domains_delegation_signer_record.main' 'example.com,DNSSEC_KEY_ID' +``` + +The key ID is visible under Route 53 → Registered domains → the domain → DNSSEC keys, or through `aws route53domains get-domain-detail --region us-east-1 --domain-name example.com`. + +## Safe key replacement and removal + +DNSSEC changes require an overlap period because resolvers cache DS and DNSKEY records. The resource uses `create_before_destroy`, so Terraform adds a replacement key before requesting deletion of the old key. This guarantees ordering, but it does not wait between those operations. + +If a plan replaces the signing key, do not apply it as one immediate replacement. Temporarily manage the new key alongside the old key, confirm that both keys have reached the parent registry, and wait for the DS record TTL to expire before removing the old key. Route 53 recommends waiting up to three days after adding the new key. + +To disable DNSSEC or destroy the Cloudflare zone safely: + +1. Remove the registrar key first, for example by setting `route53domains_dnssec_enabled = false`, and apply. +2. Keep Cloudflare DNSSEC signing enabled while the old DS record can still be cached. +3. Wait for the DS TTL to expire; Route 53 recommends up to three days. +4. Only then disable Cloudflare DNSSEC or destroy the Cloudflare resources. + +Disabling signing while a DS record still exists can make the entire domain return `SERVFAIL` to validating resolvers. See the [Route 53 DNSSEC key replacement and removal guidance](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-configure-dnssec.html). diff --git a/aws/route53domains-dnssec/main.tf b/aws/route53domains-dnssec/main.tf new file mode 100644 index 000000000..5fda90f2a --- /dev/null +++ b/aws/route53domains-dnssec/main.tf @@ -0,0 +1,13 @@ +resource "aws_route53domains_delegation_signer_record" "main" { + domain_name = var.domain_name + + signing_attributes { + algorithm = var.dnssec_algorithm + flags = var.dnssec_key_type + public_key = var.dnssec_public_key + } + + lifecycle { + create_before_destroy = true + } +} diff --git a/aws/route53domains-dnssec/outputs.tf b/aws/route53domains-dnssec/outputs.tf new file mode 100644 index 000000000..2e40c4f64 --- /dev/null +++ b/aws/route53domains-dnssec/outputs.tf @@ -0,0 +1,4 @@ +output "dnssec_key_id" { + description = "Route 53 Domains identifier assigned to the delegation signer record." + value = aws_route53domains_delegation_signer_record.main.dnssec_key_id +} diff --git a/aws/route53domains-dnssec/tests/basic.tftest.hcl b/aws/route53domains-dnssec/tests/basic.tftest.hcl new file mode 100644 index 000000000..5edae722a --- /dev/null +++ b/aws/route53domains-dnssec/tests/basic.tftest.hcl @@ -0,0 +1,32 @@ +mock_provider "aws" {} + +run "registers_cloudflare_ksk_with_route53_domains" { + command = plan + + variables { + domain_name = "example.com" + dnssec_algorithm = 13 + dnssec_key_type = 257 + dnssec_public_key = "mdsswUyr3DPW132mOi8V9xESWEkK2G8gkJ7FZ0PQcqGd9M6FtdV7oNXgYw==" + } + + assert { + condition = aws_route53domains_delegation_signer_record.main.domain_name == "example.com" + error_message = "The Route 53 Domains delegation signer must target the registered domain." + } + + assert { + condition = aws_route53domains_delegation_signer_record.main.signing_attributes[0].algorithm == 13 + error_message = "The delegation signer must use Cloudflare's DNSSEC algorithm." + } + + assert { + condition = aws_route53domains_delegation_signer_record.main.signing_attributes[0].flags == 257 + error_message = "The delegation signer must register Cloudflare's key-signing key." + } + + assert { + condition = aws_route53domains_delegation_signer_record.main.signing_attributes[0].public_key == var.dnssec_public_key + error_message = "The delegation signer must register Cloudflare's public key without manual copying." + } +} diff --git a/aws/route53domains-dnssec/variables.tf b/aws/route53domains-dnssec/variables.tf new file mode 100644 index 000000000..231a77c46 --- /dev/null +++ b/aws/route53domains-dnssec/variables.tf @@ -0,0 +1,39 @@ +variable "domain_name" { + description = "Domain registered with Route 53 Domains." + type = string + + validation { + condition = length(trimspace(var.domain_name)) > 0 + error_message = "domain_name must not be empty." + } +} + +variable "dnssec_algorithm" { + description = "DNSSEC algorithm reported by the authoritative DNS provider." + type = number + + validation { + condition = var.dnssec_algorithm > 0 && floor(var.dnssec_algorithm) == var.dnssec_algorithm + error_message = "dnssec_algorithm must be a positive integer." + } +} + +variable "dnssec_key_type" { + description = "DNSSEC key flags reported by the authoritative DNS provider: 257 for KSK or 256 for ZSK." + type = number + + validation { + condition = contains([256, 257], var.dnssec_key_type) + error_message = "dnssec_key_type must be 257 (KSK) or 256 (ZSK)." + } +} + +variable "dnssec_public_key" { + description = "Base64-encoded DNSSEC public key reported by the authoritative DNS provider." + type = string + + validation { + condition = length(trimspace(var.dnssec_public_key)) > 0 + error_message = "dnssec_public_key must not be empty." + } +} diff --git a/aws/route53domains-dnssec/versions.tf b/aws/route53domains-dnssec/versions.tf new file mode 100644 index 000000000..390837856 --- /dev/null +++ b/aws/route53domains-dnssec/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.47" + } + } + required_version = ">= 1.6" +} diff --git a/aws/stack/app/README.md b/aws/stack/app/README.md index d99ac1328..418b6785f 100644 --- a/aws/stack/app/README.md +++ b/aws/stack/app/README.md @@ -17,7 +17,25 @@ This is our stack convention which brings all modules together, including: ## Getting Started 1. Refer to the [stack/setup module](stack/setup/README.md) on the pre-setup of stack modules -2. Add the following block to your terraform configurations +2. Configure the provider aliases used for cross-region resources. Route 53 Domains operations only run in `us-east-1`. + +```terraform +provider "aws" { + region = "eu-central-1" +} + +provider "aws" { + alias = "peer" + region = "eu-central-1" +} + +provider "aws" { + alias = "us-east-1" + region = "us-east-1" +} +``` + +3. Add the following block to your terraform configurations ```terraform resource "aws_alb_target_group" "facebook" { @@ -45,6 +63,12 @@ resource "aws_alb_target_group" "facebook" { module "stack" { source = "github.com/dbl-works/terraform//aws/stack/app?ref=main" + providers = { + aws = aws + aws.peer = aws.peer + aws.us-east-1 = aws.us-east-1 + } + project = "someproject" environment = "staging" @@ -66,6 +90,7 @@ module "stack" { # Optional region = "eu-central-1" # Region to deploy the resources skip_cloudflare = false # Skip the creation of cloudflare modules + route53domains_dnssec_enabled = true # Register Cloudflare's KSK with this Route 53-registered domain cdn_worker_script_name = "serve-cdn" app_worker_script_name = "serve-app" tls_settings = { @@ -265,6 +290,43 @@ module "stack" { } ``` +### Adopting an existing DNSSEC key + +`route53domains_dnssec_enabled` defaults to `false` so existing domains with manually registered DNSSEC keys do not attempt to create duplicates. Import the existing key before enabling it: + +```shell +terraform import 'module.stack.module.route53domains_dnssec[0].aws_route53domains_delegation_signer_record.main' 'example.com,DNSSEC_KEY_ID' +``` + +Find `DNSSEC_KEY_ID` under Route 53 → Registered domains → the domain → DNSSEC keys, or with: + +```shell +aws route53domains get-domain-detail --region us-east-1 --domain-name example.com +``` + +Do not remove the registrar key and disable Cloudflare DNSSEC in the same apply. Remove the registrar key first, keep Cloudflare signing enabled until the DS TTL has expired (Route 53 recommends up to three days), and only then disable or destroy Cloudflare DNSSEC. Key replacement also requires an overlap period; see the [Route 53 Domains DNSSEC module documentation](../../route53domains-dnssec/README.md#safe-key-replacement-and-removal). + +### Securing the Cloudflare-to-ALB connection + +Use a custom Cloudflare Authenticated Origin Pulls certificate so the ALB trusts your Cloudflare zone rather than Cloudflare's shared global client certificate: + +```terraform +alb_mtls_ca_certificates_pem = file("${path.root}/certificates/cloudflare-aop-root-ca.crt") +alb_mtls_mode = "passthrough" + +authenticated_origin_pull = { + enabled = true + certificate = file("${path.root}/certificates/cloudflare-aop-client.crt") + private_key = file("${path.root}/certificates/cloudflare-aop-client.key") +} +``` + +Deploy Cloudflare and ALB passthrough mode first, verify that Cloudflare presents the client certificate, and then change `alb_mtls_mode` to `"verify"`. Passthrough is diagnostic only; the ALB does not authenticate the certificate until verify mode is active. + +The Cloudflare provider stores the client private key in Terraform state. Use an encrypted remote backend with tightly restricted access. Keep the encrypted CA private key outside Terraform in the restricted administrator vault. + +The custom Cloudflare client certificate expires and must be rotated. Store the encrypted CA key and its passphrase in the restricted `${project}/terraform/${environment}` vault, configure Cloudflare's AOP expiration notification, and use `script/rotate-mtls-certificates.sh` from the project's CI workflow to issue a replacement leaf certificate before expiry. Do not pass the CA private key or passphrase through Terraform because that would copy them into state. See the [ALB mTLS module documentation](../../alb-mtls/README.md#leaf-certificate-rotation) for the complete rotation workflow. + ```terraform # output.tf diff --git a/aws/stack/app/dnssec.tf b/aws/stack/app/dnssec.tf new file mode 100644 index 000000000..f50205aca --- /dev/null +++ b/aws/stack/app/dnssec.tf @@ -0,0 +1,13 @@ +module "route53domains_dnssec" { + source = "../../route53domains-dnssec" + count = var.route53domains_dnssec_enabled && !var.skip_cloudflare ? 1 : 0 + + providers = { + aws = aws.us-east-1 + } + + domain_name = var.domain_name + dnssec_algorithm = tonumber(module.cloudflare[0].dnssec_algorithm) + dnssec_key_type = tonumber(module.cloudflare[0].dnssec_key_type) + dnssec_public_key = module.cloudflare[0].dnssec_public_key +} diff --git a/aws/stack/app/outputs.tf b/aws/stack/app/outputs.tf index bb6950af1..416b94ff0 100644 --- a/aws/stack/app/outputs.tf +++ b/aws/stack/app/outputs.tf @@ -98,6 +98,11 @@ output "dnssec_public_key" { value = module.cloudflare[*].dnssec_public_key } +output "route53domains_dnssec_key_id" { + description = "Route 53 Domains DNSSEC key ID when registrar-side DNSSEC management is enabled." + value = one(module.route53domains_dnssec[*].dnssec_key_id) +} + output "service_discovery_namespace_id" { value = module.ecs.service_discovery_namespace_id } diff --git a/aws/stack/app/tests/validate/main.tf b/aws/stack/app/tests/validate/main.tf new file mode 100644 index 000000000..6513d3fb4 --- /dev/null +++ b/aws/stack/app/tests/validate/main.tf @@ -0,0 +1,53 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 6.55.0" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "= 4.52.1" + } + } +} + +provider "aws" { + region = "eu-central-1" +} + +provider "aws" { + alias = "peer" + region = "eu-central-1" +} + +provider "aws" { + alias = "us-east-1" + region = "us-east-1" +} + +provider "cloudflare" {} + +module "stack" { + source = "../.." + + providers = { + aws = aws + aws.peer = aws.peer + aws.us-east-1 = aws.us-east-1 + } + + project = "validation" + environment = "test" + domain_name = "example.com" + kms_deletion_window_in_days = 7 + kms_app_arn = "arn:aws:kms:eu-central-1:123456789012:key/00000000-0000-0000-0000-000000000000" + vpc_cidr_block = "10.0.0.0/16" + + route53domains_dnssec_enabled = true + alb_mtls_ca_certificates_pem = "-----BEGIN CERTIFICATE-----\nvalidation-only\n-----END CERTIFICATE-----" + authenticated_origin_pull = { + enabled = true + certificate = "-----BEGIN CERTIFICATE-----\nvalidation-only\n-----END CERTIFICATE-----" + private_key = "validation-only-private-key" + } +} diff --git a/aws/stack/app/variables.tf b/aws/stack/app/variables.tf index cc014e56c..798515a37 100644 --- a/aws/stack/app/variables.tf +++ b/aws/stack/app/variables.tf @@ -27,6 +27,12 @@ variable "skip_cloudflare" { default = false } +variable "route53domains_dnssec_enabled" { + description = "Register Cloudflare's DNSSEC key with Route 53 Domains. Enable for new domains; import an existing manually registered key before enabling." + type = bool + default = false +} + variable "certificate_arn" { type = string default = null @@ -746,6 +752,7 @@ variable "authenticated_origin_pull" { certificate = string private_key = string }) - default = null + default = null + sensitive = true } # =============== mTLS / Authenticated Origin Pulls ================ # diff --git a/aws/stack/app/versions.tf b/aws/stack/app/versions.tf index 09ae9e15c..112e861eb 100644 --- a/aws/stack/app/versions.tf +++ b/aws/stack/app/versions.tf @@ -6,9 +6,10 @@ terraform { } aws = { - source = "hashicorp/aws" - version = ">= 4.0" - configuration_aliases = [aws.peer] + source = "hashicorp/aws" + version = ">= 5.47" + # Route 53 Domains only exposes its domain-registration API in us-east-1. + configuration_aliases = [aws.peer, aws.us-east-1] } } required_version = ">= 1.0" diff --git a/cloudflare/README.md b/cloudflare/README.md index 6109180e8..cd3d7b39b 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -55,6 +55,22 @@ module "cloudflare" { } ``` +## Authenticated Origin Pulls + +`authenticated_origin_pull` uploads a custom zone-level client certificate and enables Cloudflare to present it to an mTLS origin: + +```terraform +authenticated_origin_pull = { + enabled = true + certificate = file("${path.root}/certificates/cloudflare-aop-client.crt") + private_key = file("${path.root}/certificates/cloudflare-aop-client.key") +} +``` + +The origin must trust the CA that signed this leaf certificate. Do not upload the CA private key to Cloudflare, AWS, or Terraform. + +The Cloudflare provider stores the client private key in Terraform state even though Terraform masks the value in normal output. Use an encrypted remote backend with tightly restricted access. + ``` # versions.tf diff --git a/cloudflare/tls.tf b/cloudflare/tls.tf index a19169822..fde2aa646 100644 --- a/cloudflare/tls.tf +++ b/cloudflare/tls.tf @@ -32,6 +32,10 @@ resource "cloudflare_authenticated_origin_pulls_certificate" "custom" { certificate = var.authenticated_origin_pull.certificate private_key = var.authenticated_origin_pull.private_key type = "per-zone" + + lifecycle { + create_before_destroy = true + } } resource "cloudflare_authenticated_origin_pulls" "main" { diff --git a/cloudflare/variables.tf b/cloudflare/variables.tf index e604c35a1..7897a81f3 100644 --- a/cloudflare/variables.tf +++ b/cloudflare/variables.tf @@ -63,5 +63,6 @@ variable "authenticated_origin_pull" { certificate = string # PEM-encoded leaf certificate private_key = string # PEM-encoded private key for the leaf cert }) - default = null + default = null + sensitive = true } diff --git a/cloudflare/versions.tf b/cloudflare/versions.tf index 8caf76760..f60d36283 100644 --- a/cloudflare/versions.tf +++ b/cloudflare/versions.tf @@ -2,7 +2,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = ">= 4.0" + version = "~> 4.0" } } required_version = ">= 1.0" diff --git a/script/generate-mtls-certs.sh b/script/generate-mtls-certs.sh index 297290cb2..f1b02753c 100755 --- a/script/generate-mtls-certs.sh +++ b/script/generate-mtls-certs.sh @@ -1,36 +1,53 @@ #!/usr/bin/env bash -set -e +set -euo pipefail + +# Private keys created by this script must only be readable by their owner. +umask 077 # This script generates a custom root CA and a leaf certificate # to be used for Cloudflare Authenticated Origin Pulls (mTLS). -# +# # Usage: ./generate-mtls-certs.sh [domain-pattern] # e.g. ./generate-mtls-certs.sh "*.my-page.com" -HOSTNAME=${1:-*.my-page.com} -CERTS_DIR="mtls_certs" +CERTIFICATE_COMMON_NAME=${1:-*.my-page.com} +CERTS_DIR=${MTLS_CERTS_DIR:-mtls_certs} + +if [[ -z ${MTLS_CA_PASSPHRASE:-} ]]; then + if [[ ! -t 0 ]]; then + echo "MTLS_CA_PASSPHRASE must be set when running non-interactively." >&2 + exit 1 + fi + + read -r -s -p "Root CA passphrase: " MTLS_CA_PASSPHRASE + echo + read -r -s -p "Confirm root CA passphrase: " passphrase_confirmation + echo + + if [[ -z $MTLS_CA_PASSPHRASE || $MTLS_CA_PASSPHRASE != "$passphrase_confirmation" ]]; then + echo "The root CA passphrases must be non-empty and match." >&2 + exit 1 + fi + + export MTLS_CA_PASSPHRASE +fi echo "===========================================================" echo "Generating Certificates for mTLS (Authenticated Origin Pulls)" -echo "Domain Pattern: $HOSTNAME" +echo "Domain Pattern: $CERTIFICATE_COMMON_NAME" echo "===========================================================" mkdir -p "$CERTS_DIR" cd "$CERTS_DIR" -echo "1. Generating 4096-bit RSA private key for the Root CA..." -# Use -aes256 locally, but we might want script to be non-interactive -# If you want it secure with passphrase, remove the comments below. -# Here we generate an unencrypted key for easy automated usage if needed, -# or encrypted if prompted. We will use unencrypted for the root for simplicity in the script, -# but in production, a passphrase-protected key is recommended. -openssl genrsa -out rootca.key 4096 +echo "1. Generating encrypted 4096-bit RSA private key for the Root CA..." +openssl genrsa -aes256 -passout env:MTLS_CA_PASSPHRASE -out rootca.key 4096 echo "2. Creating the CA root certificate..." -openssl req -x509 -new -nodes -key rootca.key -sha256 -days 1826 -out rootca.crt -subj "/C=US/ST=State/L=City/O=Company/CN=OAP-Root-CA" +openssl req -x509 -new -key rootca.key -passin env:MTLS_CA_PASSPHRASE -sha256 -days 1826 -out rootca.crt -subj "/C=US/ST=State/L=City/O=Company/CN=AOP-Root-CA" echo "3. Creating a Certificate Signing Request (CSR) for the hostname..." -openssl req -new -nodes -newkey rsa:4096 -keyout cert.key -out cert.csr -subj "/C=US/ST=State/L=City/O=Company/CN=$HOSTNAME" +openssl req -new -nodes -newkey rsa:4096 -keyout cert.key -out cert.csr -subj "/C=US/ST=State/L=City/O=Company/CN=$CERTIFICATE_COMMON_NAME" echo "4. Creating extensions file..." cat > cert.v3.ext << 'EOF' @@ -38,20 +55,15 @@ basicConstraints=CA:FALSE EOF echo "5. Signing the certificate using the Root CA..." -openssl x509 -req -in cert.csr -CA rootca.crt -CAkey rootca.key -CAcreateserial -out cert.crt -days 730 -sha256 -extfile ./cert.v3.ext +openssl x509 -req -in cert.csr -CA rootca.crt -CAkey rootca.key -passin env:MTLS_CA_PASSPHRASE -CAcreateserial -out cert.crt -days 730 -sha256 -extfile ./cert.v3.ext echo "===========================================================" echo "Done! Certificates generated in the '$CERTS_DIR' directory:" echo " - rootca.crt: Upload this to AWS (alb_mtls_ca_certificates_pem)" echo " - cert.crt: Upload this to Cloudflare (authenticated_origin_pull.certificate)" echo " - cert.key: Upload this to Cloudflare (authenticated_origin_pull.private_key)" +echo " - rootca.key: Store this encrypted CA key in the restricted Terraform vault" echo "===========================================================" echo "" -echo "In your Terraform configuration, you can reference these files:" -echo "alb_mtls_ca_certificates_pem = file(\"../script/$CERTS_DIR/rootca.crt\")" -echo "authenticated_origin_pull = {" -echo " enabled = true" -echo " certificate = file(\"../script/$CERTS_DIR/cert.crt\")" -echo " private_key = file(\"../script/$CERTS_DIR/cert.key\")" -echo "}" +echo "Move the generated files to their documented secure locations before referencing them from Terraform." echo "===========================================================" diff --git a/script/rotate-mtls-certificates.sh b/script/rotate-mtls-certificates.sh new file mode 100755 index 000000000..d518ffebc --- /dev/null +++ b/script/rotate-mtls-certificates.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 + +if [[ $# -lt 3 || $# -gt 4 ]]; then + echo "Usage: $0 [output-directory]" >&2 + exit 1 +fi + +CERTIFICATE_COMMON_NAME=$1 +CA_PRIVATE_KEY_FILE=$2 +CA_CERTIFICATE_FILE=$3 +CERTS_DIR=${4:-mtls_certs} +LEAF_VALIDITY_DAYS=${MTLS_LEAF_VALIDITY_DAYS:-730} + +if [[ ! $LEAF_VALIDITY_DAYS =~ ^[1-9][0-9]*$ ]]; then + echo "MTLS_LEAF_VALIDITY_DAYS must be a positive integer." >&2 + exit 1 +fi + +if [[ ! -f $CA_PRIVATE_KEY_FILE || ! -f $CA_CERTIFICATE_FILE ]]; then + echo "The CA private key and certificate files must both exist." >&2 + exit 1 +fi + +if [[ -z ${MTLS_CA_PASSPHRASE:-} ]]; then + if [[ ! -t 0 ]]; then + echo "MTLS_CA_PASSPHRASE must be set when running non-interactively." >&2 + exit 1 + fi + + read -r -s -p "Root CA passphrase: " MTLS_CA_PASSPHRASE + echo + + if [[ -z $MTLS_CA_PASSPHRASE ]]; then + echo "The root CA passphrase must not be empty." >&2 + exit 1 + fi + + export MTLS_CA_PASSPHRASE +fi + +openssl pkey -in "$CA_PRIVATE_KEY_FILE" -passin env:MTLS_CA_PASSPHRASE -noout +openssl x509 -in "$CA_CERTIFICATE_FILE" -noout + +leaf_validity_seconds=$((LEAF_VALIDITY_DAYS * 86400)) +if ! openssl x509 -checkend "$leaf_validity_seconds" -noout -in "$CA_CERTIFICATE_FILE"; then + echo "The CA certificate expires before the requested leaf certificate. Rotate the CA first." >&2 + exit 1 +fi + +mkdir -p "$CERTS_DIR" + +echo "Generating a new Cloudflare Authenticated Origin Pulls leaf certificate..." +openssl req -new -nodes -newkey rsa:4096 -keyout "$CERTS_DIR/cert.key" -out "$CERTS_DIR/cert.csr" -subj "/C=US/ST=State/L=City/O=Company/CN=$CERTIFICATE_COMMON_NAME" + +cat > "$CERTS_DIR/cert.v3.ext" <<'EOF' +basicConstraints=CA:FALSE +EOF + +openssl x509 -req \ + -in "$CERTS_DIR/cert.csr" \ + -CA "$CA_CERTIFICATE_FILE" \ + -CAkey "$CA_PRIVATE_KEY_FILE" \ + -passin env:MTLS_CA_PASSPHRASE \ + -CAcreateserial \ + -out "$CERTS_DIR/cert.crt" \ + -days "$LEAF_VALIDITY_DAYS" \ + -sha256 \ + -extfile "$CERTS_DIR/cert.v3.ext" + +openssl verify -CAfile "$CA_CERTIFICATE_FILE" "$CERTS_DIR/cert.crt" + +echo "Leaf certificate written to '$CERTS_DIR/cert.crt'." +echo "Leaf private key written to '$CERTS_DIR/cert.key'." From c7a5d34a0825b2c27cc7b3df6f9da40946f7a250 Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 10:59:35 +0200 Subject: [PATCH 03/11] safely handle secret in terraform --- aws/alb-mtls/README.md | 85 ++++++++++++++++++++++++++++++++++++++++- aws/stack/app/README.md | 2 +- cloudflare/README.md | 2 +- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/aws/alb-mtls/README.md b/aws/alb-mtls/README.md index 8dc546d1d..6e7fc6cb9 100644 --- a/aws/alb-mtls/README.md +++ b/aws/alb-mtls/README.md @@ -38,7 +38,90 @@ For projects using the restricted `${project}/terraform/${environment}` Secrets - `cloudflare_aop_ca_private_key_passphrase`: the encryption passphrase. - `cloudflare_aop_ca_certificate_pem`: the public `rootca.crt`, so CI can create renewed leaf certificates without another storage dependency. -The encrypted key and its passphrase may share this vault when access is already limited to the administrators and CI role authorized to manage infrastructure-level credentials. Do not pass the CA private key or its passphrase through a Terraform resource, data source, variable, or output: Terraform would copy the value into state. Create the vault with Terraform, then populate and read these fields through the AWS CLI or Secrets Manager API outside Terraform. +The encrypted key and its passphrase may share this vault when access is already limited to the administrators and CI role authorized to manage infrastructure-level credentials. + +### Upload without storing the values in Terraform state + +Terraform 1.11 and AWS provider 5.99.1 introduced write-only arguments. `secret_string_wo` sends the local value to Secrets Manager but omits it from Terraform plan and state files. `sensitive` only redacts terminal output; it does not provide this state protection by itself. + +Add these paths to the consuming project's `.gitignore`: + +```gitignore +/.terraform-secrets.json +/certificates/cloudflare-aop/ +``` + +Create `.terraform-secrets.json` locally with mode `0600`: + +```json +{ + "cloudflare_aop_ca_private_key_passphrase": "replace-with-a-strong-passphrase" +} +``` + +Place `rootca.key` and `rootca.crt` in `certificates/cloudflare-aop/`, then create and populate the restricted vault: + +```terraform +terraform { + required_version = ">= 1.11" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.99.1" + } + } +} + +locals { + terraform_secret_upload = merge( + jsondecode(file("${path.root}/.terraform-secrets.json")), + { + cloudflare_aop_ca_private_key_pem = file("${path.root}/certificates/cloudflare-aop/rootca.key") + cloudflare_aop_ca_certificate_pem = file("${path.root}/certificates/cloudflare-aop/rootca.crt") + } + ) +} + +module "terraform_secrets" { + source = "github.com/dbl-works/terraform//aws/secrets?ref=main" + + project = var.project + environment = var.environment + application = "terraform" + create_kms_key = true + description = "Infrastructure-level credentials restricted to administrators and Terraform CI." +} + +resource "aws_secretsmanager_secret_version" "terraform" { + secret_id = module.terraform_secrets.id + + secret_string_wo = jsonencode(sensitive(local.terraform_secret_upload)) + secret_string_wo_version = 1 +} +``` + +Increment `secret_string_wo_version` whenever the local values are intentionally changed. Terraform cannot compare a write-only value with the previous version because that value is never stored in state. + +Terraform evaluates the local files whenever this configuration is planned. Keep this upload configuration in a dedicated bootstrap root that administrators run only when synchronizing the vault, rather than in every application's normal deployment root. Apply the upload once before adding a consumer that reads the secret ephemerally. + +### Read without storing the values in Terraform state + +Use the ephemeral Secrets Manager resource rather than the ordinary `data "aws_secretsmanager_secret_version"` data source. The ordinary data source marks `secret_string` as sensitive but still serializes it into state. + +```terraform +ephemeral "aws_secretsmanager_secret_version" "terraform" { + secret_id = module.terraform_secrets.id +} + +locals { + credentials_terraform = jsondecode( + ephemeral.aws_secretsmanager_secret_version.terraform.secret_string + ) +} +``` + +Values derived from this local remain ephemeral. Terraform permits them only in ephemeral-compatible contexts such as provider configuration, write-only resource arguments, ephemeral child-module inputs and outputs, or provisioner environments. Passing a value into an ordinary resource argument still requires Terraform to persist it and is therefore rejected. See HashiCorp's [sensitive and ephemeral data guidance](https://developer.hashicorp.com/terraform/language/manage-sensitive-data). ## Leaf certificate rotation diff --git a/aws/stack/app/README.md b/aws/stack/app/README.md index 418b6785f..1b455ffb3 100644 --- a/aws/stack/app/README.md +++ b/aws/stack/app/README.md @@ -325,7 +325,7 @@ Deploy Cloudflare and ALB passthrough mode first, verify that Cloudflare present The Cloudflare provider stores the client private key in Terraform state. Use an encrypted remote backend with tightly restricted access. Keep the encrypted CA private key outside Terraform in the restricted administrator vault. -The custom Cloudflare client certificate expires and must be rotated. Store the encrypted CA key and its passphrase in the restricted `${project}/terraform/${environment}` vault, configure Cloudflare's AOP expiration notification, and use `script/rotate-mtls-certificates.sh` from the project's CI workflow to issue a replacement leaf certificate before expiry. Do not pass the CA private key or passphrase through Terraform because that would copy them into state. See the [ALB mTLS module documentation](../../alb-mtls/README.md#leaf-certificate-rotation) for the complete rotation workflow. +The custom Cloudflare client certificate expires and must be rotated. Store the encrypted CA key and its passphrase in the restricted `${project}/terraform/${environment}` vault, configure Cloudflare's AOP expiration notification, and use `script/rotate-mtls-certificates.sh` from the project's CI workflow to issue a replacement leaf certificate before expiry. With Terraform 1.11 and a compatible AWS provider, use write-only arguments to upload the vault values and an ephemeral resource to read them without persisting them in state. See the [ALB mTLS module documentation](../../alb-mtls/README.md#upload-without-storing-the-values-in-terraform-state) for copy-pasteable setup and the complete rotation workflow. ```terraform # output.tf diff --git a/cloudflare/README.md b/cloudflare/README.md index cd3d7b39b..54aea0433 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -67,7 +67,7 @@ authenticated_origin_pull = { } ``` -The origin must trust the CA that signed this leaf certificate. Do not upload the CA private key to Cloudflare, AWS, or Terraform. +The origin must trust the CA that signed this leaf certificate. Do not upload the CA private key to Cloudflare or the ALB trust store. Store the encrypted key in the restricted Terraform Secrets Manager vault; see the [ALB mTLS documentation](../aws/alb-mtls/README.md#upload-without-storing-the-values-in-terraform-state) for a write-only Terraform setup that omits it from state. The Cloudflare provider stores the client private key in Terraform state even though Terraform masks the value in normal output. Use an encrypted remote backend with tightly restricted access. From 646a0f6873c88587f0b993e0ae73a925ebc406fb Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 11:34:39 +0200 Subject: [PATCH 04/11] Update ci.yml --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e538d8f2..9f53bff05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: strategy: matrix: module: + - alb-mtls - autoscaling/ecs - aws-transfer - aws-transfer/iam-role @@ -83,6 +84,7 @@ jobs: - multi-stack/setup - nat - rds + - route53domains-dnssec - s3 - s3-private - s3-public From 3d8896181f6f3fb40abcb8e9b4ed811e7a5ca741 Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 13:31:21 +0200 Subject: [PATCH 05/11] various bug fixes --- .github/workflows/ci.yml | 1 + aws/alb-mtls/main.tf | 5 +++++ aws/alb-mtls/variables.tf | 3 ++- aws/alb-mtls/versions.tf | 5 +++-- aws/ecs/variables.tf | 14 ++++++++++++-- aws/ecs/versions.tf | 5 +++-- aws/route53domains-dnssec/README.md | 4 ++-- aws/route53domains-dnssec/main.tf | 2 +- aws/route53domains-dnssec/tests/basic.tftest.hcl | 2 +- aws/route53domains-dnssec/variables.tf | 8 ++++---- aws/route53domains-dnssec/versions.tf | 8 +++++--- aws/stack/app/dnssec.tf | 2 +- aws/stack/app/outputs.tf | 9 +++++++++ aws/stack/app/variables.tf | 3 ++- aws/stack/app/versions.tf | 5 +++-- cloudflare/outputs.tf | 7 ++++++- script/generate-mtls-certs.sh | 2 ++ script/rotate-mtls-certificates.sh | 2 ++ 18 files changed, 64 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f53bff05..b8c635be3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,7 @@ jobs: # the stack/app module must have a provider block because the sub-module "vpc-peering" requires a non-standard "provider # see: https://github.com/hashicorp/terraform/issues/28490 if [[ "${{ matrix.module }}" == "stack/app" ]]; then echo "provider \"aws\" { alias = \"peer\" }" >> vpc-peering.tf; fi + if [[ "${{ matrix.module }}" == "stack/app" ]]; then echo "provider \"aws\" { alias = \"us-east-1\" }" >> dnssec.tf; fi if [[ "${{ matrix.module }}" == "stack/global" ]]; then echo "provider \"aws\" { alias = \"us-east-1\" }" >> versions.tf; fi if [[ "${{ matrix.module }}" == "vpc-peering" ]]; then echo "provider \"aws\" { alias = \"peer\" }" >> main.tf; fi terraform validate diff --git a/aws/alb-mtls/main.tf b/aws/alb-mtls/main.tf index 837a0a67e..8ecc19a01 100644 --- a/aws/alb-mtls/main.tf +++ b/aws/alb-mtls/main.tf @@ -27,6 +27,11 @@ resource "aws_s3_object" "ca_bundle" { key = "ca-certificates-bundle.pem" content = var.ca_certificates_pem content_type = "application/x-pem-file" + + # Referencing only module.s3_bucket.id would not wait for the bucket's + # versioning configuration, and an object created before versioning is + # enabled has no version_id for the trust store to pin. + depends_on = [module.s3_bucket] } # ALB Trust Store — references the CA bundle in S3 diff --git a/aws/alb-mtls/variables.tf b/aws/alb-mtls/variables.tf index a0ba6583a..731887565 100644 --- a/aws/alb-mtls/variables.tf +++ b/aws/alb-mtls/variables.tf @@ -6,10 +6,11 @@ variable "environment" { type = string } +# Not marked sensitive: a CA certificate is public material, and sensitivity +# would propagate into plan output, hiding the trust store and listener diffs. variable "ca_certificates_pem" { description = "PEM-encoded root CA certificate that signed the Cloudflare client certificate." type = string - sensitive = true } variable "name" { diff --git a/aws/alb-mtls/versions.tf b/aws/alb-mtls/versions.tf index 54852eddd..e64067a77 100644 --- a/aws/alb-mtls/versions.tf +++ b/aws/alb-mtls/versions.tf @@ -1,8 +1,9 @@ terraform { required_providers { aws = { - source = "hashicorp/aws" - version = ">= 5.47" + source = "hashicorp/aws" + # aws_lb_trust_store was added in 5.30; kept consistent with the ecs and route53domains-dnssec modules + version = ">= 5.34" } } required_version = ">= 1.0" diff --git a/aws/ecs/variables.tf b/aws/ecs/variables.tf index 67ad5d3a6..d4141403f 100644 --- a/aws/ecs/variables.tf +++ b/aws/ecs/variables.tf @@ -249,10 +249,20 @@ variable "alb_access_logs" { } variable "alb_mtls" { - description = "mTLS config for Cloudflare Authenticated Origin Pulls. Requires a Trust Store ARN." + description = "mTLS config for Cloudflare Authenticated Origin Pulls. A Trust Store ARN is required in verify mode." type = object({ mode = string # "verify" or "passthrough" - trust_store_arn = string + trust_store_arn = optional(string) }) default = null + + validation { + condition = var.alb_mtls == null ? true : contains(["verify", "passthrough"], var.alb_mtls.mode) + error_message = "alb_mtls.mode must be 'verify' or 'passthrough'." + } + + validation { + condition = var.alb_mtls == null ? true : var.alb_mtls.mode != "verify" || var.alb_mtls.trust_store_arn != null + error_message = "alb_mtls.trust_store_arn is required when mode is 'verify'." + } } diff --git a/aws/ecs/versions.tf b/aws/ecs/versions.tf index 54852eddd..98d464eef 100644 --- a/aws/ecs/versions.tf +++ b/aws/ecs/versions.tf @@ -1,8 +1,9 @@ terraform { required_providers { aws = { - source = "hashicorp/aws" - version = ">= 5.47" + source = "hashicorp/aws" + # aws_lb_listener mutual_authentication requires 5.30; passthrough-mode fix in 5.33 + version = ">= 5.34" } } required_version = ">= 1.0" diff --git a/aws/route53domains-dnssec/README.md b/aws/route53domains-dnssec/README.md index ecfa84d90..4070d6fef 100644 --- a/aws/route53domains-dnssec/README.md +++ b/aws/route53domains-dnssec/README.md @@ -18,8 +18,8 @@ module "route53domains_dnssec" { } domain_name = "example.com" - dnssec_algorithm = module.cloudflare.dnssec_algorithm - dnssec_key_type = module.cloudflare.dnssec_key_type + dnssec_algorithm = tonumber(module.cloudflare.dnssec_algorithm) + dnssec_flags = module.cloudflare.dnssec_flags dnssec_public_key = module.cloudflare.dnssec_public_key } ``` diff --git a/aws/route53domains-dnssec/main.tf b/aws/route53domains-dnssec/main.tf index 5fda90f2a..8827309a6 100644 --- a/aws/route53domains-dnssec/main.tf +++ b/aws/route53domains-dnssec/main.tf @@ -3,7 +3,7 @@ resource "aws_route53domains_delegation_signer_record" "main" { signing_attributes { algorithm = var.dnssec_algorithm - flags = var.dnssec_key_type + flags = var.dnssec_flags public_key = var.dnssec_public_key } diff --git a/aws/route53domains-dnssec/tests/basic.tftest.hcl b/aws/route53domains-dnssec/tests/basic.tftest.hcl index 5edae722a..38b4a9f19 100644 --- a/aws/route53domains-dnssec/tests/basic.tftest.hcl +++ b/aws/route53domains-dnssec/tests/basic.tftest.hcl @@ -6,7 +6,7 @@ run "registers_cloudflare_ksk_with_route53_domains" { variables { domain_name = "example.com" dnssec_algorithm = 13 - dnssec_key_type = 257 + dnssec_flags = 257 dnssec_public_key = "mdsswUyr3DPW132mOi8V9xESWEkK2G8gkJ7FZ0PQcqGd9M6FtdV7oNXgYw==" } diff --git a/aws/route53domains-dnssec/variables.tf b/aws/route53domains-dnssec/variables.tf index 231a77c46..77791212f 100644 --- a/aws/route53domains-dnssec/variables.tf +++ b/aws/route53domains-dnssec/variables.tf @@ -18,13 +18,13 @@ variable "dnssec_algorithm" { } } -variable "dnssec_key_type" { - description = "DNSSEC key flags reported by the authoritative DNS provider: 257 for KSK or 256 for ZSK." +variable "dnssec_flags" { + description = "DNSKEY flags reported by the authoritative DNS provider: 257 for KSK or 256 for ZSK." type = number validation { - condition = contains([256, 257], var.dnssec_key_type) - error_message = "dnssec_key_type must be 257 (KSK) or 256 (ZSK)." + condition = contains([256, 257], var.dnssec_flags) + error_message = "dnssec_flags must be 257 (KSK) or 256 (ZSK)." } } diff --git a/aws/route53domains-dnssec/versions.tf b/aws/route53domains-dnssec/versions.tf index 390837856..61a50fd04 100644 --- a/aws/route53domains-dnssec/versions.tf +++ b/aws/route53domains-dnssec/versions.tf @@ -1,9 +1,11 @@ terraform { required_providers { aws = { - source = "hashicorp/aws" - version = ">= 5.47" + source = "hashicorp/aws" + # aws_route53domains_delegation_signer_record was added in 5.34 + version = ">= 5.34" } } - required_version = ">= 1.6" + # mock_provider in tests/ requires Terraform 1.7 + required_version = ">= 1.7" } diff --git a/aws/stack/app/dnssec.tf b/aws/stack/app/dnssec.tf index f50205aca..101309ed4 100644 --- a/aws/stack/app/dnssec.tf +++ b/aws/stack/app/dnssec.tf @@ -8,6 +8,6 @@ module "route53domains_dnssec" { domain_name = var.domain_name dnssec_algorithm = tonumber(module.cloudflare[0].dnssec_algorithm) - dnssec_key_type = tonumber(module.cloudflare[0].dnssec_key_type) + dnssec_flags = module.cloudflare[0].dnssec_flags dnssec_public_key = module.cloudflare[0].dnssec_public_key } diff --git a/aws/stack/app/outputs.tf b/aws/stack/app/outputs.tf index 416b94ff0..fa7cdf7fc 100644 --- a/aws/stack/app/outputs.tf +++ b/aws/stack/app/outputs.tf @@ -94,6 +94,10 @@ output "dnssec_key_type" { value = module.cloudflare[*].dnssec_key_type } +output "dnssec_flags" { + value = module.cloudflare[*].dnssec_flags +} + output "dnssec_public_key" { value = module.cloudflare[*].dnssec_public_key } @@ -101,6 +105,11 @@ output "dnssec_public_key" { output "route53domains_dnssec_key_id" { description = "Route 53 Domains DNSSEC key ID when registrar-side DNSSEC management is enabled." value = one(module.route53domains_dnssec[*].dnssec_key_id) + + precondition { + condition = !(var.route53domains_dnssec_enabled && var.skip_cloudflare) + error_message = "route53domains_dnssec_enabled requires the Cloudflare module: the stack registers Cloudflare's signing key with Route 53 Domains, so there is no key to register when skip_cloudflare is true. To register another DNS provider's key, use the aws/route53domains-dnssec module directly." + } } output "service_discovery_namespace_id" { diff --git a/aws/stack/app/variables.tf b/aws/stack/app/variables.tf index 798515a37..77a273160 100644 --- a/aws/stack/app/variables.tf +++ b/aws/stack/app/variables.tf @@ -728,11 +728,12 @@ variable "cloudwatch_logs_retention_in_days" { # =============== Cloudwatch ================ # # =============== mTLS / Authenticated Origin Pulls ================ # +# Not marked sensitive: a CA certificate is public material, and sensitivity +# would hide the ALB listener's mutual_authentication diff in plan output. variable "alb_mtls_ca_certificates_pem" { description = "PEM-encoded root CA cert for ALB Trust Store. Enables mTLS when set." type = string default = null - sensitive = true } variable "alb_mtls_mode" { diff --git a/aws/stack/app/versions.tf b/aws/stack/app/versions.tf index 112e861eb..4cc4e2e39 100644 --- a/aws/stack/app/versions.tf +++ b/aws/stack/app/versions.tf @@ -6,8 +6,9 @@ terraform { } aws = { - source = "hashicorp/aws" - version = ">= 5.47" + source = "hashicorp/aws" + # minimum of the ecs, alb-mtls and route53domains-dnssec child modules + version = ">= 5.34" # Route 53 Domains only exposes its domain-registration API in us-east-1. configuration_aliases = [aws.peer, aws.us-east-1] } diff --git a/cloudflare/outputs.tf b/cloudflare/outputs.tf index 84866b830..9d5cd7a51 100644 --- a/cloudflare/outputs.tf +++ b/cloudflare/outputs.tf @@ -9,10 +9,15 @@ output "dnssec_algorithm" { } output "dnssec_key_type" { - description = "key type of the DNSSEC record, e.g. '257 (KSK)'" + description = "key type of the DNSSEC record as an algorithm mnemonic, e.g. 'ECDSAP256SHA256'" value = cloudflare_zone_dnssec.main.key_type } +output "dnssec_flags" { + description = "DNSKEY flags of the DNSSEC record, e.g. 257 for a KSK" + value = cloudflare_zone_dnssec.main.flags +} + output "dnssec_public_key" { description = "public key of the DNSSEC record" value = cloudflare_zone_dnssec.main.public_key diff --git a/script/generate-mtls-certs.sh b/script/generate-mtls-certs.sh index f1b02753c..4f1ddfd85 100755 --- a/script/generate-mtls-certs.sh +++ b/script/generate-mtls-certs.sh @@ -52,6 +52,8 @@ openssl req -new -nodes -newkey rsa:4096 -keyout cert.key -out cert.csr -subj "/ echo "4. Creating extensions file..." cat > cert.v3.ext << 'EOF' basicConstraints=CA:FALSE +keyUsage=critical,digitalSignature +extendedKeyUsage=clientAuth EOF echo "5. Signing the certificate using the Root CA..." diff --git a/script/rotate-mtls-certificates.sh b/script/rotate-mtls-certificates.sh index d518ffebc..7e2d62ed8 100755 --- a/script/rotate-mtls-certificates.sh +++ b/script/rotate-mtls-certificates.sh @@ -57,6 +57,8 @@ openssl req -new -nodes -newkey rsa:4096 -keyout "$CERTS_DIR/cert.key" -out "$CE cat > "$CERTS_DIR/cert.v3.ext" <<'EOF' basicConstraints=CA:FALSE +keyUsage=critical,digitalSignature +extendedKeyUsage=clientAuth EOF openssl x509 -req \ From 679423b0d86930d42e1426b0c796689a9af13f4e Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 13:42:58 +0200 Subject: [PATCH 06/11] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 19db24042..a7c8b0881 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ # Special\Personal dev setup /.idea +docs/ # os .DS_Store From a8c400bc9d53bac2f570867c213b71281d48ed28 Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 13:47:10 +0200 Subject: [PATCH 07/11] run tests on ci --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ aws/stack/app/tests/validate/main.tf | 8 ++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c635be3..2b27aa02b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,26 @@ jobs: cd ${{ matrix.module }} terraform fmt -check + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + + # terraform test with mock_provider requires Terraform >= 1.7 + - name: module tests (route53domains-dnssec) + run: | + cd aws/route53domains-dnssec + terraform init + terraform test + + # validates the stack as a consumer calls it (provider aliases, mTLS + DNSSEC inputs) + - name: stack/app consumer validation + run: | + cd aws/stack/app/tests/validate + terraform init + terraform validate + misc: strategy: matrix: diff --git a/aws/stack/app/tests/validate/main.tf b/aws/stack/app/tests/validate/main.tf index 6513d3fb4..7da397320 100644 --- a/aws/stack/app/tests/validate/main.tf +++ b/aws/stack/app/tests/validate/main.tf @@ -1,12 +1,16 @@ +# Consumer simulation: instantiates the stack the way a downstream project +# does — three-provider wiring, DNSSEC and mTLS enabled — so `terraform +# validate` catches caller-facing breakage (e.g. a missing provider alias) +# that validating the module in isolation cannot. Run by CI. terraform { required_providers { aws = { source = "hashicorp/aws" - version = "= 6.55.0" + version = "~> 6.0" } cloudflare = { source = "cloudflare/cloudflare" - version = "= 4.52.1" + version = "~> 4.0" } } } From 9db8a0e78b63364bed8dd19f00e5dfd17cfb3ebf Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 14:15:04 +0200 Subject: [PATCH 08/11] fix version for rds aurora --- .github/workflows/ci.yml | 3 +++ aws/stack/app/versions.tf | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b27aa02b..0abf771e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,9 @@ jobs: run: | cd aws cd ${{ matrix.module }} + # stack/app cannot be pinned to the matrix version: its aurora child module + # requires aws >= 6.0, so let terraform resolve the real constraints instead. + if [[ "${{ matrix.module }}" == "stack/app" ]]; then exit 0; fi FILE=versions.tf if test -f "$FILE"; then sed -i '/aws = {/,/}/ s/version = "[^"]*/version = "${{ matrix.aws-version }}/' $FILE diff --git a/aws/stack/app/versions.tf b/aws/stack/app/versions.tf index 4cc4e2e39..0be3c7620 100644 --- a/aws/stack/app/versions.tf +++ b/aws/stack/app/versions.tf @@ -7,8 +7,8 @@ terraform { aws = { source = "hashicorp/aws" - # minimum of the ecs, alb-mtls and route53domains-dnssec child modules - version = ">= 5.34" + # the aurora child module requires 6.x; all other child modules need >= 5.34 or lower + version = ">= 6.0" # Route 53 Domains only exposes its domain-registration API in us-east-1. configuration_aliases = [aws.peer, aws.us-east-1] } From f3a4f64ad5ba41cc66a6ee91d77f75ec4cacc6e4 Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 14:25:42 +0200 Subject: [PATCH 09/11] skip-ci --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0abf771e4..2cb499a9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,12 @@ concurrency: cancel-in-progress: true jobs: + should-run: + runs-on: ubuntu-latest + if: | + !contains(github.event.head_commit.message, 'skip-ci') + steps: + - run: echo "Running tests" # azure: # strategy: # matrix: @@ -119,7 +125,8 @@ jobs: if [[ "${{ matrix.module }}" == "stack/app" ]]; then exit 0; fi FILE=versions.tf if test -f "$FILE"; then - sed -i '/aws = {/,/}/ s/version = "[^"]*/version = "${{ matrix.aws-version }}/' $FILE + # tolerate column-aligned assignments, e.g. `version = "..."` + sed -i '/aws[[:space:]]*=[[:space:]]*{/,/}/ s/version[[:space:]]*=[[:space:]]*"[^"]*/version = "${{ matrix.aws-version }}/' $FILE echo "$FILE exists." else From 0d123010001d15ca08bc986f4a6d51b45a3fd3d9 Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 14:27:33 +0200 Subject: [PATCH 10/11] skip-ci --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cb499a9c..7caf7b2e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,9 @@ jobs: !contains(github.event.head_commit.message, 'skip-ci') steps: - run: echo "Running tests" + # azure: + # needs: should-run # strategy: # matrix: # module: @@ -47,6 +49,7 @@ jobs: # tofu fmt -check aws: + needs: should-run strategy: matrix: module: @@ -170,6 +173,7 @@ jobs: terraform fmt -check tests: + needs: should-run runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -190,6 +194,7 @@ jobs: terraform validate misc: + needs: should-run strategy: matrix: module: @@ -221,6 +226,7 @@ jobs: terraform fmt -check security: + needs: should-run name: Trivy Security Scan runs-on: ubuntu-latest From 68965353bb8d82ac89a1caae47a7bd160c9c5d63 Mon Sep 17 00:00:00 2001 From: Lud Date: Thu, 23 Jul 2026 14:32:04 +0200 Subject: [PATCH 11/11] Update ci.yml --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7caf7b2e9..a370605f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,10 @@ jobs: EOF fi + # restore canonical formatting (e.g. column alignment) after the rewrite, + # otherwise the fmt -check step fails on the file this step just edited + terraform fmt $FILE + cat $FILE - name: init