diff --git a/infra/cloudformation/dev3-doenet-redirect-beta.params b/infra/cloudformation/dev3-doenet-redirect-beta.params new file mode 100644 index 000000000..91e65159c --- /dev/null +++ b/infra/cloudformation/dev3-doenet-redirect-beta.params @@ -0,0 +1,22 @@ +[ + { + "ParameterKey": "EnvironmentName", + "ParameterValue": "dev3" + }, + { + "ParameterKey": "RedirectName", + "ParameterValue": "beta" + }, + { + "ParameterKey": "RedirectSubdomains", + "ParameterValue": "true" + }, + { + "ParameterKey": "PublicHostedZoneName", + "ParameterValue": "dev3.doenet.org" + }, + { + "ParameterKey": "PublicHostedZoneId", + "ParameterValue": "Z0066937J8QHRAL9H84P" + } +] diff --git a/infra/cloudformation/dev3-doenet-redirect-www.params b/infra/cloudformation/dev3-doenet-redirect-www.params new file mode 100644 index 000000000..722c60591 --- /dev/null +++ b/infra/cloudformation/dev3-doenet-redirect-www.params @@ -0,0 +1,22 @@ +[ + { + "ParameterKey": "EnvironmentName", + "ParameterValue": "dev3" + }, + { + "ParameterKey": "RedirectName", + "ParameterValue": "www" + }, + { + "ParameterKey": "RedirectSubdomains", + "ParameterValue": "false" + }, + { + "ParameterKey": "PublicHostedZoneName", + "ParameterValue": "dev3.doenet.org" + }, + { + "ParameterKey": "PublicHostedZoneId", + "ParameterValue": "Z0066937J8QHRAL9H84P" + } +] diff --git a/infra/cloudformation/prod-doenet-redirect-beta.params b/infra/cloudformation/prod-doenet-redirect-beta.params new file mode 100644 index 000000000..1519bce7f --- /dev/null +++ b/infra/cloudformation/prod-doenet-redirect-beta.params @@ -0,0 +1,22 @@ +[ + { + "ParameterKey": "EnvironmentName", + "ParameterValue": "prod" + }, + { + "ParameterKey": "RedirectName", + "ParameterValue": "beta" + }, + { + "ParameterKey": "RedirectSubdomains", + "ParameterValue": "true" + }, + { + "ParameterKey": "PublicHostedZoneName", + "ParameterValue": "doenet.org" + }, + { + "ParameterKey": "PublicHostedZoneId", + "ParameterValue": "Z08210421B0UEN4PYID0X" + } +] diff --git a/infra/cloudformation/prod-doenet-redirect-www.params b/infra/cloudformation/prod-doenet-redirect-www.params new file mode 100644 index 000000000..c92180645 --- /dev/null +++ b/infra/cloudformation/prod-doenet-redirect-www.params @@ -0,0 +1,22 @@ +[ + { + "ParameterKey": "EnvironmentName", + "ParameterValue": "prod" + }, + { + "ParameterKey": "RedirectName", + "ParameterValue": "www" + }, + { + "ParameterKey": "RedirectSubdomains", + "ParameterValue": "false" + }, + { + "ParameterKey": "PublicHostedZoneName", + "ParameterValue": "doenet.org" + }, + { + "ParameterKey": "PublicHostedZoneId", + "ParameterValue": "Z08210421B0UEN4PYID0X" + } +] diff --git a/infra/cloudformation/prod-doenet-redirect.params b/infra/cloudformation/prod-doenet-redirect.params deleted file mode 100644 index 794546b38..000000000 --- a/infra/cloudformation/prod-doenet-redirect.params +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "ParameterKey": "EnvironmentName", - "ParameterValue": "prod" - }, - { - "ParameterKey": "SourceHostedZoneName", - "ParameterValue": "beta.doenet.org" - }, - { - "ParameterKey": "SourceHostedZoneId", - "ParameterValue": "Z0126382ZHTT46RB59N8" - }, - { - "ParameterKey": "TargetDomainName", - "ParameterValue": "doenet.org" - } -] diff --git a/infra/cloudformation/redirect.yml b/infra/cloudformation/redirect.yml index ce3f09dc8..4fcd0b04a 100644 --- a/infra/cloudformation/redirect.yml +++ b/infra/cloudformation/redirect.yml @@ -1,11 +1,16 @@ AWSTemplateFormatVersion: "2010-09-09" Description: >- - Redirect distribution: permanently (301) redirects a legacy host (e.g. - beta.doenet.org and *.beta.doenet.org) to a new domain (e.g. doenet.org), - preserving the subdomain label, path, and query string. Must be deployed to - us-east-1 so the ACM certificate can attach to CloudFront (and, because SSM - parameters are regional, this stack takes its hosted-zone values as literals - rather than /prod/PublicHostedZone* SSM refs, which live in us-east-2). + Redirect distribution: permanently (301) redirects a source host to the public + hosted zone apex, preserving path and query string. The source host is the + subdomain . (e.g. www.doenet.org, + beta.doenet.org) and the redirect target is the apex PublicHostedZoneName + (doenet.org). With RedirectSubdomains=true it also covers every subdomain + (*.) and carries the subdomain label over to the target + (media. -> media.). With RedirectSubdomains=false it redirects + just the single host. Must be deployed to us-east-1 so the ACM certificate can + attach to CloudFront; because SSM parameters are regional, this stack takes its + PublicHostedZone* values as literals rather than the /prod/PublicHostedZone* + SSM refs used elsewhere, which live in us-east-2. Parameters: EnvironmentName: @@ -13,55 +18,81 @@ Parameters: Description: The environment name (the SSM prefix path name) MaxLength: 10 - SourceHostedZoneName: + RedirectName: Type: String - Description: Legacy host to redirect FROM (e.g. beta.doenet.org) + Description: >- + The single DNS label to redirect FROM (e.g. "beta", "www"). The source host + is .. Also used for the CloudFront + function name and resource tags, which must be unique per account, so each + redirect stack needs a distinct value. + AllowedPattern: "[a-z0-9]([a-z0-9-]*[a-z0-9])?" + + PublicHostedZoneName: + Type: String + Description: >- + The public hosted zone name (e.g. doenet.org). This is both the redirect + target (the apex the source host is sent to) and the parent domain of the + source host. Passed as a literal because this stack runs in us-east-1. - SourceHostedZoneId: + PublicHostedZoneId: Type: String - Description: Route 53 hosted zone ID that owns SourceHostedZoneName + Description: >- + Route 53 hosted zone ID for PublicHostedZoneName. The redirect's records + (source apex/wildcard + cert validation) are written here, so a subdomain + redirect lives directly in the parent zone rather than a delegated subzone. - TargetDomainName: + RedirectSubdomains: Type: String - Description: Domain to redirect TO (e.g. doenet.org) + Description: >- + "true" to also redirect *., carrying the subdomain label to the + target; "false" to redirect only the single source host. + AllowedValues: ["true", "false"] + Default: "true" + +Conditions: + IncludeSubdomains: !Equals [!Ref RedirectSubdomains, "true"] Resources: - # Covers the legacy host (e.g. beta.doenet.org) and every subdomain - # (*.beta.doenet.org) such as media.beta.doenet.org. The wildcard does not - # match the apex, so both names are listed explicitly. Certificate: Type: AWS::CertificateManager::Certificate Properties: - DomainName: !Ref SourceHostedZoneName - SubjectAlternativeNames: - - !Sub "*.${SourceHostedZoneName}" + DomainName: !Sub "${RedirectName}.${PublicHostedZoneName}" + # A wildcard SAN is only valid when the source is a zone apex, so it is + # added only in subdomain mode. + SubjectAlternativeNames: !If + - IncludeSubdomains + - [!Sub "*.${RedirectName}.${PublicHostedZoneName}"] + - !Ref "AWS::NoValue" ValidationMethod: DNS # ACM uses one shared validation CNAME for a domain and its wildcard, so # only the apex is listed here; listing the wildcard too would make # CloudFormation write the identical Route 53 record twice in one change # batch and fail with InvalidChangeBatch. The single record validates both. DomainValidationOptions: - - DomainName: !Ref SourceHostedZoneName - HostedZoneId: !Ref SourceHostedZoneId + - DomainName: !Sub "${RedirectName}.${PublicHostedZoneName}" + HostedZoneId: !Ref PublicHostedZoneId # Runs on viewer-request and returns the 301 before the request is ever # proxied, so the distribution's origin is never contacted. Host-swaps the # source apex for the target while keeping any subdomain label, e.g. # beta.doenet.org/foo?x=1 -> https://doenet.org/foo?x=1 # media.beta.doenet.org/img.png -> https://media.doenet.org/img.png + # In single-host mode no subdomain hosts are routed here, so the carry branch + # never fires and it reduces to a plain host swap to the apex target. RedirectFunction: Type: AWS::CloudFront::Function Properties: - Name: !Sub "${EnvironmentName}-host-redirect" + # Names are unique per account, so they are keyed on the redirect label. + Name: !Sub "${EnvironmentName}-${RedirectName}-redirect" AutoPublish: true FunctionConfig: - Comment: 301-redirect a legacy host to the target domain, preserving subdomain/path/query + Comment: 301-redirect a source host to the target domain, preserving subdomain/path/query Runtime: cloudfront-js-2.0 FunctionCode: !Sub | function handler(event) { var request = event.request; - var source = '${SourceHostedZoneName}'; - var target = '${TargetDomainName}'; + var source = '${RedirectName}.${PublicHostedZoneName}'; + var target = '${PublicHostedZoneName}'; var host = request.headers.host ? request.headers.host.value : source; // Default to the apex; if the host is a subdomain of the source, @@ -115,16 +146,20 @@ Resources: Enabled: true HttpVersion: http2and3 IPV6Enabled: true - Comment: !Sub "Redirect ${SourceHostedZoneName} -> ${TargetDomainName}" + Comment: !Sub "Redirect ${RedirectName}.${PublicHostedZoneName} -> ${PublicHostedZoneName}" Aliases: - - !Ref SourceHostedZoneName - - !Sub "*.${SourceHostedZoneName}" + - !Sub "${RedirectName}.${PublicHostedZoneName}" + - !If [ + IncludeSubdomains, + !Sub "*.${RedirectName}.${PublicHostedZoneName}", + !Ref "AWS::NoValue", + ] Origins: # Placeholder only: the viewer-request function returns a 301 before any # request reaches an origin, so this is never contacted. Pointing it at # the target keeps it valid and self-documenting. - Id: PlaceholderOrigin - DomainName: !Ref TargetDomainName + DomainName: !Ref PublicHostedZoneName CustomOriginConfig: OriginProtocolPolicy: https-only HTTPSPort: 443 @@ -148,14 +183,14 @@ Resources: MinimumProtocolVersion: TLSv1.2_2021 Tags: - Key: Name - Value: !Sub "${EnvironmentName}-host-redirect-cloudfront" + Value: !Sub "${EnvironmentName}-${RedirectName}-redirect-cloudfront" # Z2FDTNDATAQYW2 is CloudFront's fixed hosted zone ID for alias targets. ApexRecordV4: Type: AWS::Route53::RecordSet Properties: - HostedZoneId: !Ref SourceHostedZoneId - Name: !Sub "${SourceHostedZoneName}." + HostedZoneId: !Ref PublicHostedZoneId + Name: !Sub "${RedirectName}.${PublicHostedZoneName}." Type: A AliasTarget: DNSName: !GetAtt RedirectDistribution.DomainName @@ -165,8 +200,8 @@ Resources: ApexRecordV6: Type: AWS::Route53::RecordSet Properties: - HostedZoneId: !Ref SourceHostedZoneId - Name: !Sub "${SourceHostedZoneName}." + HostedZoneId: !Ref PublicHostedZoneId + Name: !Sub "${RedirectName}.${PublicHostedZoneName}." Type: AAAA AliasTarget: DNSName: !GetAtt RedirectDistribution.DomainName @@ -175,9 +210,10 @@ Resources: WildcardRecordV4: Type: AWS::Route53::RecordSet + Condition: IncludeSubdomains Properties: - HostedZoneId: !Ref SourceHostedZoneId - Name: !Sub "*.${SourceHostedZoneName}." + HostedZoneId: !Ref PublicHostedZoneId + Name: !Sub "*.${RedirectName}.${PublicHostedZoneName}." Type: A AliasTarget: DNSName: !GetAtt RedirectDistribution.DomainName @@ -186,9 +222,10 @@ Resources: WildcardRecordV6: Type: AWS::Route53::RecordSet + Condition: IncludeSubdomains Properties: - HostedZoneId: !Ref SourceHostedZoneId - Name: !Sub "*.${SourceHostedZoneName}." + HostedZoneId: !Ref PublicHostedZoneId + Name: !Sub "*.${RedirectName}.${PublicHostedZoneName}." Type: AAAA AliasTarget: DNSName: !GetAtt RedirectDistribution.DomainName diff --git a/infra/dev3.aws b/infra/dev3.aws index e76372337..c10aecd74 100644 --- a/infra/dev3.aws +++ b/infra/dev3.aws @@ -16,11 +16,15 @@ STACKS['dev3-doenet']='service' STACKS['dev3-doenet-frontend']='s3-hosted-stackset' STACKS['dev3-doenet-media-cert']='cdn-cert' STACKS['dev3-doenet-media-cdn']='cdn' +STACKS['dev3-doenet-redirect-www']='redirect' +STACKS['dev3-doenet-redirect-beta']='redirect' STACKS['dev3-discourse-forums']='discourse-forums-ec2' declare -g -A STACK_REGION STACK_REGION['dev3-doenet-media-cert']='us-east-1' +STACK_REGION['dev3-doenet-redirect-www']='us-east-1' +STACK_REGION['dev3-doenet-redirect-beta']='us-east-1' declare -g -a STACK_ORDER STACK_ORDER=( @@ -33,6 +37,8 @@ STACK_ORDER=( dev3-doenet-media-cert dev3-doenet-media-cdn dev3-doenet-frontend + dev3-doenet-redirect-www + dev3-doenet-redirect-beta ) declare -g -a SAM_STACKS diff --git a/infra/prod.aws b/infra/prod.aws index 786bb3df1..f1ee0e347 100644 --- a/infra/prod.aws +++ b/infra/prod.aws @@ -18,15 +18,17 @@ STACKS['prod-doenet']='service' STACKS['prod-doenet-frontend']='s3-hosted-stackset' STACKS['prod-doenet-media-cert']='cdn-cert' STACKS['prod-doenet-media-cdn']='cdn' -STACKS['prod-doenet-redirect']='redirect' +STACKS['prod-doenet-redirect-www']='redirect' +STACKS['prod-doenet-redirect-beta']='redirect' STACKS['prod-discourse-forums']='discourse-forums-ec2' declare -g -A STACK_REGION -STACK_REGION['prod-doenet-media-cert']='us-east-1' # CloudFront requires its ACM certificate in us-east-1; this stack bundles the # cert with the distribution, so the whole stack is deployed there. -STACK_REGION['prod-doenet-redirect']='us-east-1' +STACK_REGION['prod-doenet-media-cert']='us-east-1' +STACK_REGION['prod-doenet-redirect-www']='us-east-1' +STACK_REGION['prod-doenet-redirect-beta']='us-east-1' declare -g -a STACK_ORDER STACK_ORDER=( @@ -40,7 +42,8 @@ STACK_ORDER=( prod-doenet-media-cert prod-doenet-media-cdn prod-doenet-frontend - prod-doenet-redirect + prod-doenet-redirect-www + prod-doenet-redirect-beta ) declare -g -a SAM_STACKS