Repository navigation
DNS best practices for operationcode.org and operationcode.net #193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium bug, confidence medium, pass review Comment cites a future/unverifiable change date The comment asserts the MX replacement happened on 2026-11-01, which may be a wrong or future date. It is misleading about when the change was made, but has no runtime effect. Model output from untrusted input. Check it before you act on it. |
||
| 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"] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium bug, confidence low, pass review Query logging and DNSSEC changes in one apply may race The DNSSEC signing resource has no dependency on the KMS alias or the key policy being fully propagated. KMS policy eventual consistency can make the KSK creation fail on first apply. A retry normally resolves it. Model output from untrusted input. Check it before you act on it. |
||
| 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 <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/*"] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. low security, confidence medium, pass security Log policy grants Route53 write to all /aws/route53/* log groups The CloudWatch resource policy allows route53.amazonaws.com to write to any log group under /aws/route53/* in the account, not only the new operationcode.org group. It is limited by SourceAccount and hostedzone SourceArn conditions, so only this account's Route53 zones can use it. The risk is small, but scoping to the specific log group ARN would follow least privilege. Model output from untrusted input. Check it before you act on it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. low security, confidence low, pass security Route53 log policy allows writes to any /aws/route53/* log group The account-wide CloudWatch Logs resource policy lets Route53 write to any log group under /aws/route53/* for any hosted zone in the account. It is scoped by source account, so exposure is limited to this account's own zones. The policy could instead name the specific log group and zone, so this is a minor least-privilege gap. Model output from untrusted input. Check it before you act on it. |
||
|
|
||
| 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] | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
low bug, confidence low, pass review
Comment cites a specific date that may be wrong or future
The comment claims the MX was replaced on a specific date (2026-11-01). The date cannot be verified from the bundle and may be inaccurate, but it has no runtime effect.
Model output from untrusted input. Check it before you act on it.