diff --git a/README.md b/README.md index 98281ee..f7480e3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,42 @@ You can deploy to any AWS region by changing `region=us-east-1` in the URL to yo The template is automatically updated via GitHub Actions when changes are merged to main for `infrastructure/cloudfront.yaml` +## Prerequisites for Custom Domain + +When using a custom domain (`UseCustomDomain=true`), ensure the following prerequisites are met: + +### Route 53 Hosted Zone + +The custom domain must have a **public** Route 53 hosted zone in the same AWS account, and **Route 53 must be authoritative** for the domain (the domain's NS records at the registrar must point to Route 53 nameservers). + +**What happens automatically:** +1. The template looks up the hosted zone for your domain (e.g., `example.com` for `flags.example.com`) +2. ACM creates an SSL certificate with DNS validation +3. ACM automatically creates DNS validation CNAME records in Route 53 +4. CloudFormation waits for certificate validation to complete +5. CloudFront distribution is created with the validated certificate +6. DNS A record is created pointing to the CloudFront distribution + +**Why DNS delegation matters:** +- ACM creates validation CNAME records in your Route 53 hosted zone +- For validation to succeed, public DNS queries must resolve to Route 53 (not CloudFlare, GoDaddy, etc.) +- If the domain is delegated elsewhere, ACM cannot see its own validation records and the certificate remains in `PENDING_VALIDATION` indefinitely + +**Verify Route 53 is authoritative for your domain:** +```bash +# Check public DNS nameservers for your domain +dig +short NS yourdomain.com + +# Get your Route 53 nameservers +aws route53 get-hosted-zone --id YOUR_HOSTED_ZONE_ID \ + --query "DelegationSet.NameServers" --output table + +# These should match! +``` + +**If they don't match: this will not work!** +Alternatively, you may update your domain registrar's NS records to point to the Route 53 nameservers shown in the above command. WARNING! This is a much larger change and should not be performed without understanding the full impact. + ## Configuration Options | Parameter | Default | Options | Description | @@ -68,13 +104,14 @@ The template is automatically updated via GitHub Actions when changes are merged ```bash aws cloudformation deploy \ - --template-file templates/cloudfront.yaml \ + --template-file infrastructure/templates/cloudfront.yaml \ --stack-name ld-cloudfront-proxy \ --parameter-overrides \ UseCustomDomain=true \ DomainName=flags.my-company-domain.com \ PriceClass=PriceClass_100 \ - --capabilities CAPABILITY_IAM + --capabilities CAPABILITY_IAM \ + --region us-east-1 ``` Your reverse proxy URL will be the DomainName specified in the above command, but you can also run the below command to get it: @@ -98,12 +135,13 @@ This will return your CloudFront domain (e.g., `flags.my-company-domain.com`) cd infrastructure aws cloudformation deploy \ - --template-file templates/cloudfront.yaml \ + --template-file infrastructure/templates/cloudfront.yaml \ --stack-name ld-cloudfront-proxy \ --parameter-overrides \ UseCustomDomain=false \ PriceClass=PriceClass_100 \ - EnableLogging=false + EnableLogging=false \ + --region us-east-1 ``` ### Get Your Proxy URL @@ -160,16 +198,18 @@ NOTE: You may need to restart your application. ### Automated Cleanup (Recommended) ```bash aws cloudformation deploy \ - --template-file templates/remove-cloudfront.yaml \ + --template-file infrastructure/templates/remove-cloudfront.yaml \ --stack-name cleanup-ld-cloudfront \ --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides StackNameToDelete=ld-cloudfront-proxy + --parameter-overrides \ + StackNameToDelete=ld-cloudfront-proxy \ + DomainName=flags.my-company-domain.com \ + CleanupDNS=true \ + CleanupCertificate=true \ + --region us-east-1 ``` -### Manual Cleanup -```bash -aws cloudformation delete-stack --stack-name ld-cloudfront-proxy -``` + **Deletion time:** ~15-20 minutes (CloudFront global propagation) diff --git a/infrastructure/templates/cloudfront.yaml b/infrastructure/templates/cloudfront.yaml index bf61918..40279f4 100644 --- a/infrastructure/templates/cloudfront.yaml +++ b/infrastructure/templates/cloudfront.yaml @@ -34,7 +34,6 @@ Conditions: UseLogging: !Equals [!Ref EnableLogging, 'true'] Resources: - # Lambda function to look up Route 53 hosted zone ID HostedZoneLookupRole: Type: AWS::IAM::Role Condition: UseCustomDomain @@ -72,7 +71,6 @@ Resources: ZipFile: | import boto3 import cfnresponse - import json def handler(event, context): try: @@ -80,35 +78,36 @@ Resources: cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) return - domain_name = event['ResourceProperties']['DomainName'] + domain_name = event['ResourceProperties']['DomainName'].rstrip('.') + r53 = boto3.client('route53') - # Extract the root domain from subdomain - # e.g., flags.example.com -> example.com - domain_parts = domain_name.split('.') - if len(domain_parts) >= 2: - root_domain = '.'.join(domain_parts[-2:]) + '.' - else: - root_domain = domain_name + '.' + labels = domain_name.split('.') + candidates = ['.'.join(labels[i:]) + '.' for i in range(len(labels)-1)] - route53 = boto3.client('route53') + chosen = None + paginator = r53.get_paginator('list_hosted_zones') + zones = [] + for page in paginator.paginate(): + zones.extend(page['HostedZones']) - # List all hosted zones - paginator = route53.get_paginator('list_hosted_zones') + public_zones = [z for z in zones if not z.get('Config', {}).get('PrivateZone', False)] - for page in paginator.paginate(): - for zone in page['HostedZones']: - if zone['Name'] == root_domain: - zone_id = zone['Id'].replace('/hostedzone/', '') - cfnresponse.send(event, context, cfnresponse.SUCCESS, { - 'HostedZoneId': zone_id, - 'RootDomain': root_domain - }) - return + for cand in candidates: + for z in public_zones: + if z['Name'] == cand: + chosen = z + break + if chosen: + break - # If no hosted zone found - cfnresponse.send(event, context, cfnresponse.FAILED, {}, - f"No Route 53 hosted zone found for domain: {root_domain}") + if not chosen: + raise Exception(f"No matching PUBLIC hosted zone found for {domain_name} (candidates: {candidates})") + zone_id = chosen['Id'].split('/')[-1] + cfnresponse.send(event, context, cfnresponse.SUCCESS, { + 'HostedZoneId': zone_id, + 'MatchedZoneName': chosen['Name'] + }) except Exception as e: print(f"Error: {str(e)}") cfnresponse.send(event, context, cfnresponse.FAILED, {}, str(e)) @@ -133,6 +132,7 @@ Resources: Tags: - Key: Name Value: !Sub '${AWS::StackName}-certificate' + DependsOn: HostedZoneLookup # CloudFront Response Headers Policy CfResponseHeadersPolicy: @@ -149,7 +149,6 @@ Resources: AccessControlExposeHeaders: { Items: ['ETag','Cache-Control','Content-Type'] } OriginOverride: true - # CloudFront Origin Request Policy CfOriginRequestPolicy: Type: AWS::CloudFront::OriginRequestPolicy Properties: @@ -187,7 +186,6 @@ Resources: QueryStringsConfig: QueryStringBehavior: "all" - # CloudFront Cache Policy - No Cache for Streaming CfNoCachePolicy: Type: AWS::CloudFront::CachePolicy Properties: @@ -204,7 +202,6 @@ Resources: HeadersConfig: { HeaderBehavior: "none" } QueryStringsConfig: { QueryStringBehavior: "all" } - # CloudFront Distribution CfDistribution: Type: AWS::CloudFront::Distribution Properties: @@ -347,7 +344,6 @@ Resources: Prefix: cloudfront-logs/ - !Ref AWS::NoValue - # Automatic DNS Record Creation DNSRecord: Type: AWS::Route53::RecordSet Condition: UseCustomDomain @@ -357,7 +353,7 @@ Resources: Type: A AliasTarget: DNSName: !GetAtt CfDistribution.DomainName - HostedZoneId: Z2FDTNDATAQYW2 # CloudFront hosted zone ID + HostedZoneId: Z2FDTNDATAQYW2 # AWS's hardcoded global Hosted Zone ID for ALL CloudFront distributions EvaluateTargetHealth: false Outputs: diff --git a/infrastructure/templates/remove-cloudfront.yaml b/infrastructure/templates/remove-cloudfront.yaml index 79fdf79..0bc2141 100644 --- a/infrastructure/templates/remove-cloudfront.yaml +++ b/infrastructure/templates/remove-cloudfront.yaml @@ -32,7 +32,6 @@ Conditions: ShouldCleanupCertificate: !And [!Condition HasDomainName, !Equals [!Ref CleanupCertificate, 'true']] Resources: - # IAM Role for the cleanup Lambda function CleanupLambdaRole: Type: AWS::IAM::Role Properties: @@ -79,7 +78,6 @@ Resources: - acm:DeleteCertificate Resource: '*' - # Lambda function that deletes the CloudFront stack CleanupLambdaFunction: Type: AWS::Lambda::Function Properties: @@ -99,7 +97,6 @@ Resources: """Delete Route 53 A records and ACM validation CNAME records for the domain""" route53 = boto3.client('route53') - # Find hosted zone for domain root_domain = '.'.join(domain_name.split('.')[-2:]) + '.' print(f"Looking for hosted zone: {root_domain}") @@ -118,7 +115,6 @@ Resources: print(f"No hosted zone found for {root_domain}") return - # List ALL records in hosted zone (handle pagination) paginator = route53.get_paginator('list_resource_record_sets') all_records = [] @@ -129,24 +125,32 @@ Resources: records_to_delete = [] - # Find records to delete for record in all_records: record_name = record['Name'].rstrip('.') - print(f"Checking record: {record_name} (type: {record['Type']})") + record_type = record['Type'] + print(f"Checking record: {record_name} (type: {record_type})") - # Delete A record for our domain - if record_name == domain_name and record['Type'] == 'A': + if record_name == domain_name and record_type == 'A': records_to_delete.append(('A', record)) print(f"Found A record to delete: {domain_name}") - # Delete ACM validation CNAME records (start with _ and contain our domain) - elif record['Type'] == 'CNAME' and record_name.startswith('_') and domain_name in record_name: - records_to_delete.append(('CNAME', record)) - print(f"Found ACM validation CNAME to delete: {record_name}") + elif record_type == 'CNAME' and record_name.startswith('_'): + is_acm_validation = False + + if domain_name in record_name: + is_acm_validation = True + elif record.get('ResourceRecords'): + for rr in record['ResourceRecords']: + if '.acm-validations.aws.' in rr.get('Value', ''): + is_acm_validation = True + break + + if is_acm_validation: + records_to_delete.append(('CNAME', record)) + print(f"Found ACM validation CNAME to delete: {record_name}") print(f"Total records to delete: {len(records_to_delete)}") - # Delete records in batch if any found if records_to_delete: changes = [] for record_type, record in records_to_delete: @@ -170,9 +174,8 @@ Resources: def cleanup_acm_certificates(domain_name): """Delete ACM certificates for the domain""" - acm = boto3.client('acm', region_name='us-east-1') # Certificates for CloudFront must be in us-east-1 + acm = boto3.client('acm', region_name='us-east-1') # CloudFront requires certificates in us-east-1 - # List all certificates paginator = acm.get_paginator('list_certificates') for page in paginator.paginate(): @@ -248,14 +251,12 @@ Resources: cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Message": "Cleanup completed"}) else: - # Update case cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Message": "No action required"}) except Exception as e: print(f"Error: {str(e)}") cfnresponse.send(event, context, cfnresponse.FAILED, {"Message": str(e)}) - # Custom resource that triggers the Lambda function TriggerCleanup: Type: AWS::CloudFormation::CustomResource Properties: