diff --git a/terraform/dns_operationcode_net.tf b/terraform/dns_operationcode_net.tf new file mode 100644 index 0000000..dd87a0d --- /dev/null +++ b/terraform/dns_operationcode_net.tf @@ -0,0 +1,85 @@ +# Route53 zone: operationcode.net +# The domain sends no mail. It only redirects web traffic to operationcode.org, +# so it carries anti-spoofing records (null SPF, null MX, strict DMARC). + +# Hosted zone for operationcode.net (imported; never destroy) +resource "aws_route53_zone" "operationcode_net" { + name = "operationcode.net" + # Live zone has an empty comment; keep it empty to avoid drift (provider would otherwise default it) + comment = "" + + lifecycle { + prevent_destroy = true + } +} + +# ====================================================================== +# Apex and web +# ====================================================================== + +# S3 website endpoint (us-east-1): answers 301 to https://operationcode.org/ +resource "aws_route53_record" "net_apex_a" { + zone_id = aws_route53_zone.operationcode_net.zone_id + name = "operationcode.net" + type = "A" + + alias { + name = "s3-website-us-east-1.amazonaws.com." + zone_id = "Z3AQBSTGFYJSTF" + evaluate_target_health = true + } +} + +# www CNAME to the apex (live value has no trailing dot; keep it as is) +# Note: the S3 website endpoint returns 403 for the www Host header, so www does not redirect today +resource "aws_route53_record" "net_www" { + zone_id = aws_route53_zone.operationcode_net.zone_id + name = "www.operationcode.net" + type = "CNAME" + ttl = 300 + records = ["operationcode.net"] +} + +# ====================================================================== +# Anti-spoofing (domain sends no mail) +# ====================================================================== + +# Null SPF: No sender is authorized to use operationcode.net as its SPF identity. +resource "aws_route53_record" "net_apex_spf" { + zone_id = aws_route53_zone.operationcode_net.zone_id + name = "operationcode.net" + type = "TXT" + ttl = 3600 + records = ["v=spf1 -all"] +} + +# Null MX (RFC 7505): the domain accepts no mail +resource "aws_route53_record" "net_apex_mx" { + zone_id = aws_route53_zone.operationcode_net.zone_id + name = "operationcode.net" + type = "MX" + ttl = 3600 + records = ["0 ."] +} + +# Strict DMARC: reject all spoofed mail, send aggregate reports to tech@ +resource "aws_route53_record" "net_dmarc" { + zone_id = aws_route53_zone.operationcode_net.zone_id + name = "_dmarc.operationcode.net" + type = "TXT" + ttl = 3600 + records = ["v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; rua=mailto:tech@operationcode.org"] +} + +# DMARC report authorization (RFC 7489 section 7.1) +# The rua address is in operationcode.org, a different domain than the one being reported on. +# Receivers only send reports there if the .org zone publishes this record. +# It lives in this file (not dns_operationcode_org.tf) because it exists only to support the .net DMARC record. +# It is created in the operationcode.org zone, so it uses that zone's id. +resource "aws_route53_record" "net_dmarc_report_auth" { + zone_id = aws_route53_zone.operationcode.zone_id + name = "operationcode.net._report._dmarc.operationcode.org" + type = "TXT" + ttl = 3600 + records = ["v=DMARC1"] +} diff --git a/terraform/dns_operationcode_org.tf b/terraform/dns_operationcode_org.tf index f82d7b2..83a0dad 100644 --- a/terraform/dns_operationcode_org.tf +++ b/terraform/dns_operationcode_org.tf @@ -25,13 +25,20 @@ resource "aws_route53_record" "apex_a" { records = ["76.76.21.21"] } -# CA policy: restricts wildcard issuance to Amazon, reports to tech@ +# CA policy: restricts issuance to the CAs in use, reports to tech@ +# issue: amazon.com and amazontrust.com (ACM wildcard cert on the ALB: api, pybot, resources, api.staging) +# letsencrypt.org (Vercel certs for apex, www, storybook; renewals fail without it) +# issuewild: Amazon only (the only wildcard cert is ACM; no Let's Encrypt wildcard is served) +# Cloudways hosts (services family) serve a *.cloudwaysapps.com cert from SSL.com, not a cert for our names, so no CA is added for them resource "aws_route53_record" "apex_caa" { zone_id = aws_route53_zone.operationcode.zone_id name = "operationcode.org" type = "CAA" ttl = 300 records = [ + "0 issue \"amazon.com\"", + "0 issue \"amazontrust.com\"", + "0 issue \"letsencrypt.org\"", "0 issuewild \"amazontrust.com\"", "0 issuewild \"amazon.com\"", "0 iodef \"mailto:tech@operationcode.org\"", @@ -174,18 +181,13 @@ resource "aws_route53_record" "api_staging_aaaa" { # ====================================================================== # Google Workspace mail +# Replaced this zone's legacy ASPMX records with Google's current single MX on 2026-11-01. Google still supports the old values. resource "aws_route53_record" "apex_mx" { zone_id = aws_route53_zone.operationcode.zone_id name = "operationcode.org" type = "MX" ttl = 3600 - records = [ - "1 ASPMX.L.GOOGLE.COM.", - "5 ALT1.ASPMX.L.GOOGLE.COM.", - "5 ALT2.ASPMX.L.GOOGLE.COM.", - "10 ALT3.ASPMX.L.GOOGLE.COM.", - "10 ALT4.ASPMX.L.GOOGLE.COM.", - ] + records = ["1 smtp.google.com."] } # SPF (Google, Mailgun, Mandrill) and Google site verification. Keep the Mandrill include (production mail uses it). INVESTIGATE: Mailgun include is unused by code, confirm with the Mailgun account @@ -201,13 +203,14 @@ resource "aws_route53_record" "apex_txt" { ] } -# DMARC policy for the apex domain (monitoring only, p=none) +# DMARC policy for the apex domain (monitoring only, p=none, aggregate reports to tech@) +# Next steps: enable Google DKIM, then move to p=quarantine, then p=reject resource "aws_route53_record" "apex_dmarc" { zone_id = aws_route53_zone.operationcode.zone_id name = "_dmarc.operationcode.org" type = "TXT" - ttl = 60 - records = ["v=DMARC1; p=none;"] + ttl = 3600 + records = ["v=DMARC1; p=none; rua=mailto:tech@operationcode.org"] } # ====================================================================== @@ -319,7 +322,7 @@ resource "aws_route53_record" "mailchimp_dkim_k2" { zone_id = aws_route53_zone.operationcode.zone_id name = "k2._domainkey.operationcode.org" type = "CNAME" - ttl = 60 + ttl = 3600 records = ["dkim2.mcsv.net"] } @@ -328,7 +331,7 @@ resource "aws_route53_record" "mailchimp_dkim_k3" { zone_id = aws_route53_zone.operationcode.zone_id name = "k3._domainkey.operationcode.org" type = "CNAME" - ttl = 60 + ttl = 3600 records = ["dkim3.mcsv.net"] } @@ -461,3 +464,28 @@ resource "aws_route53_record" "test" { ttl = 60 records = ["services.operationcode.org."] } + +# ====================================================================== +# Null SPF for non-sending hosts +# ====================================================================== + +# v=spf1 -all on hosts with an address record, no other TXT, and no known mail sending. +# Excluded: Cloudways and DigitalOcean hosts (out of scope), apex, coders.* (sends via SES), CNAME names (TXT cannot coexist), o1.notify (SendGrid sending IP) +locals { + null_spf_hosts = toset([ + "www", + "api", + "pybot", + "resources", + "api.staging", + ]) +} + +resource "aws_route53_record" "null_spf" { + for_each = local.null_spf_hosts + zone_id = aws_route53_zone.operationcode.zone_id + name = "${each.key}.operationcode.org" + type = "TXT" + ttl = 3600 + records = ["v=spf1 -all"] +} diff --git a/terraform/dns_operationcode_org_security.tf b/terraform/dns_operationcode_org_security.tf new file mode 100644 index 0000000..d783ee1 --- /dev/null +++ b/terraform/dns_operationcode_org_security.tf @@ -0,0 +1,168 @@ +# DNSSEC signing and query logging for operationcode.org +# Zone: aws_route53_zone.operationcode (dns_operationcode_org.tf) +# Route53 requires the DNSSEC KMS key and the query log group in us-east-1. + +# ====================================================================== +# DNSSEC signing (stage 1 of 2: sign the zone; no DS record at the registrar yet) +# ====================================================================== + +# Key policy: +# - account root: full admin, so IAM policies control key management +# - Route53 DNSSEC service: read the public key and sign, only for hosted zones in this account +# - Route53 DNSSEC service: create grants, only for AWS resources +data "aws_iam_policy_document" "dnssec_kms" { + statement { + sid = "EnableIAMUserPermissions" + effect = "Allow" + actions = ["kms:*"] + resources = ["*"] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] + } + } + + statement { + sid = "AllowRoute53DNSSECService" + effect = "Allow" + actions = ["kms:DescribeKey", "kms:GetPublicKey", "kms:Sign"] + resources = ["*"] + + principals { + type = "Service" + identifiers = ["dnssec-route53.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = [data.aws_caller_identity.current.account_id] + } + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + values = ["arn:aws:route53:::hostedzone/*"] + } + } + + statement { + sid = "AllowRoute53DNSSECToCreateGrant" + effect = "Allow" + actions = ["kms:CreateGrant"] + resources = ["*"] + + principals { + type = "Service" + identifiers = ["dnssec-route53.amazonaws.com"] + } + + condition { + test = "Bool" + variable = "kms:GrantIsForAWSResource" + values = ["true"] + } + } +} + +# Asymmetric signing key for the KSK. Deleting it breaks resolution once the DS record is live; never destroy +resource "aws_kms_key" "dnssec" { + provider = aws.us_east_1 + description = "Route53 DNSSEC KSK for operationcode.org" + customer_master_key_spec = "ECC_NIST_P256" + key_usage = "SIGN_VERIFY" + deletion_window_in_days = 7 + policy = data.aws_iam_policy_document.dnssec_kms.json + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_kms_alias" "dnssec" { + provider = aws.us_east_1 + name = "alias/route53-dnssec-operationcode-org" + target_key_id = aws_kms_key.dnssec.key_id +} + +resource "aws_route53_key_signing_key" "operationcode" { + hosted_zone_id = aws_route53_zone.operationcode.id + key_management_service_arn = aws_kms_key.dnssec.arn + name = "operationcode_org_ksk" +} + +resource "aws_route53_hosted_zone_dnssec" "operationcode" { + hosted_zone_id = aws_route53_key_signing_key.operationcode.hosted_zone_id + + depends_on = [aws_route53_key_signing_key.operationcode] +} + +# Stage 2: DS record at the registrar (Route53 Domains, us-east-1). Keep commented out until: +# 1. signing is verified: `aws route53 get-dnssec --hosted-zone-id ` shows SIGNING and the +# KSK ACTIVE, and `dig +dnssec operationcode.org SOA` returns RRSIG records +# 2. the max TTL in the zone has passed since signing started (NS: 172800s = 48h; other records: 3600s) +# Rollback order: remove the DS record first, wait for the DS TTL at the .org parent (and the +# registrar change) to pass, then disable signing (aws_route53_hosted_zone_dnssec signing_status = "NOT_SIGNING"). +# Disabling signing while the DS record is live makes the domain fail resolution for validating resolvers. +# +# resource "aws_route53domains_delegation_signer_record" "operationcode" { +# provider = aws.us_east_1 +# domain_name = "operationcode.org" +# +# signing_attributes { +# algorithm = aws_route53_key_signing_key.operationcode.signing_algorithm_type +# flags = aws_route53_key_signing_key.operationcode.flag +# public_key = aws_route53_key_signing_key.operationcode.public_key +# } +# } + +# ====================================================================== +# Query logging +# ====================================================================== + +resource "aws_cloudwatch_log_group" "route53_query_operationcode" { + provider = aws.us_east_1 + name = "/aws/route53/operationcode.org" + retention_in_days = 30 +} + +# Account-wide policy (us-east-1 limit: 10 per region) that lets Route53 write any /aws/route53/* log group +data "aws_iam_policy_document" "route53_query_logging" { + statement { + sid = "Route53QueryLogging" + effect = "Allow" + actions = ["logs:CreateLogStream", "logs:PutLogEvents"] + resources = ["arn:aws:logs:us-east-1:${data.aws_caller_identity.current.account_id}:log-group:/aws/route53/*"] + + principals { + type = "Service" + identifiers = ["route53.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = [data.aws_caller_identity.current.account_id] + } + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + values = ["arn:aws:route53:::hostedzone/*"] + } + } +} + +resource "aws_cloudwatch_log_resource_policy" "route53_query_logging" { + provider = aws.us_east_1 + policy_name = "route53-query-logging" + policy_document = data.aws_iam_policy_document.route53_query_logging.json +} + +resource "aws_route53_query_log" "operationcode" { + zone_id = aws_route53_zone.operationcode.zone_id + cloudwatch_log_group_arn = aws_cloudwatch_log_group.route53_query_operationcode.arn + + depends_on = [aws_cloudwatch_log_resource_policy.route53_query_logging] +}