AWSTemplateFormatVersion: 2010-09-09 Transform: AWS::Serverless-2016-10-31 Description: Sets up root mail. (qs-1s3rsr7mr) Parameters: Domain: Type: String Subdomain: Type: String Outputs: DelegationTarget: Description: Nameservers for the hosted zone delegation Value: !Join [ ',', !GetAtt HostedZone.NameServers ] EmailGeneratorFunction: Description: Lambda function to verify email delegation and generate new email aliases Value: !Ref EmailGeneratorFunction Resources: EmailBucket: Type: AWS::S3::Bucket Properties: PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true EmailBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref EmailBucket PolicyDocument: !Sub | { "Version": "2012-10-17", "Statement": [ { "Action": "s3:PutObject", "Condition": { "StringEquals": { "aws:Referer": "${AWS::AccountId}" } }, "Effect": "Allow", "Principal": { "Service": "ses.amazonaws.com" }, "Resource": [ "arn:${AWS::Partition}:s3:::${EmailBucket}/RootMail/*" ], "Sid": "EnableSESReceive" } ] } EmailGeneratorFunction: Type: AWS::Serverless::Function Properties: # TODO: use environment variables instead of variables in !Sub Handler: index.handler InlineCode: !Sub | import json import uuid domain = "${Subdomain}.${Domain}" def handler(event, context): max = 64 - len(domain) - 1 - 5 alias = str(uuid.uuid4()) alias = alias[:max] if len(alias) < 36: log({ 'domain': domain, 'msg': 'UUID local part was reduced in length because your domain is too long for Control Tower (64 characters in total) - this increases the chance of collisions', 'level': 'warn', 'length': len(alias), 'max': 36, }) email = 'root+{alias}@{domain}'.format(alias = alias, domain = domain) return { 'email': email, } def log(msg): print(json.dumps(msg), flush=True) Runtime: python3.7 Timeout: 260 # the timeout effectivly limits retries to 2^(n+1) - 1 = 9 attempts with backup HostedZone: Type: AWS::Route53::HostedZone Properties: Name: !Sub ${Subdomain}.${Domain} HostedZoneConfig: Comment: Created by superwerker HostedZoneSSMParameter: Type: AWS::SSM::Parameter Properties: Name: /superwerker/domain_name_servers Value: !Join [',', !GetAtt HostedZone.NameServers] Type: StringList HostedZoneDKIMAndVerificationRecords: Type: AWS::CloudFormation::CustomResource Properties: ServiceToken: !GetAtt HostedZoneDKIMAndVerificationRecordsCustomResource.Arn Domain: !Sub ${Subdomain}.${Domain} HostedZoneDKIMAndVerificationRecordsCustomResource: Type: AWS::Serverless::Function Properties: Timeout: 200 Handler: index.handler Runtime: python3.7 Policies: - Version: 2012-10-17 Statement: - Effect: Allow Action: - ses:VerifyDomainDkim - ses:VerifyDomainIdentity Resource: '*' InlineCode: | import boto3 import cfnresponse ses = boto3.client("ses", region_name="eu-west-1") # this is fixed to eu-west-1 until SES supports receive more globally (see #23) CREATE = 'Create' DELETE = 'Delete' UPDATE = 'Update' def exception_handling(function): def catch(event, context): try: function(event, context) except Exception as e: print(e) print(event) cfnresponse.send(event, context, cfnresponse.FAILED, {}) return catch @exception_handling def handler(event, context): RequestType = event["RequestType"] Properties = event["ResourceProperties"] LogicalResourceId = event["LogicalResourceId"] PhysicalResourceId = event.get("PhysicalResourceId") Domain = Properties["Domain"] print('RequestType: {}'.format(RequestType)) print('PhysicalResourceId: {}'.format(PhysicalResourceId)) print('LogicalResourceId: {}'.format(LogicalResourceId)) id = PhysicalResourceId data = {} if RequestType == CREATE: print('Creating Domain verification and DKIM records: {}'.format(LogicalResourceId)) response = ses.verify_domain_identity( Domain=Domain, ) data["VerificationToken"] = response["VerificationToken"] response = ses.verify_domain_dkim( Domain=Domain, ) data["DkimTokens"] = response["DkimTokens"] cfnresponse.send(event, context, cfnresponse.SUCCESS, data, id) HostedZoneDKIMTokenRecord0: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZone Name: !Sub - "${Token}._domainkey.${Subdomain}.${Domain}" - { Token: !Select [ 0, !GetAtt HostedZoneDKIMAndVerificationRecords.DkimTokens ]} ResourceRecords: - !Sub - "${Token}.dkim.amazonses.com" - { Token: !Select [ 0, !GetAtt HostedZoneDKIMAndVerificationRecords.DkimTokens ]} TTL: 60 Type: CNAME HostedZoneDKIMTokenRecord1: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZone Name: !Sub - "${Token}._domainkey.${Subdomain}.${Domain}" - { Token: !Select [ 1, !GetAtt HostedZoneDKIMAndVerificationRecords.DkimTokens ]} ResourceRecords: - !Sub - "${Token}.dkim.amazonses.com" - { Token: !Select [ 1, !GetAtt HostedZoneDKIMAndVerificationRecords.DkimTokens ]} TTL: 60 Type: CNAME HostedZoneDKIMTokenRecord2: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZone Name: !Sub - "${Token}._domainkey.${Subdomain}.${Domain}" - { Token: !Select [ 2, !GetAtt HostedZoneDKIMAndVerificationRecords.DkimTokens ]} ResourceRecords: - !Sub - "${Token}.dkim.amazonses.com" - { Token: !Select [ 2, !GetAtt HostedZoneDKIMAndVerificationRecords.DkimTokens ]} TTL: 60 Type: CNAME HostedZoneMXRecord: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZone Name: !Sub ${Subdomain}.${Domain}. ResourceRecords: - 10 inbound-smtp.eu-west-1.amazonaws.com # this is fixed to eu-west-1 until SES supports receive more globally (see #23) TTL: 60 Type: MX HostedZoneVerificationTokenRecord: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZone Name: !Sub _amazonses.${Subdomain}.${Domain}. ResourceRecords: - !Sub "\"${HostedZoneDKIMAndVerificationRecords.VerificationToken}\"" TTL: 60 Type: TXT RootMailReady: Type: AWS::Serverless::Function Properties: Events: Schedule: Type: Schedule Properties: Schedule: rate(5 minutes) Handler: index.handler InlineCode: !Sub | import boto3 import itertools import json import time domain = "${Subdomain}.${Domain}" ses = boto3.client("ses", region_name="eu-west-1") # this is fixed to eu-west-1 until SES supports receive more globally (see #23) def backoff(msg, res, n): wait = pow(2, n) log({ 'level': 'info', 'msg': msg, 'res': res, 'round': n, 'waiting_in_seconds': wait, }) time.sleep(n) def handler(event, context): log({ 'event': event, 'level': 'debug', }) for n in itertools.count(start=1): res = ses.get_account_sending_enabled() if res.get('Enabled'): break else: backoff('sending not yet enabled', res, n) for n in itertools.count(start=1): res = ses.get_identity_verification_attributes( Identities=[ domain, ], ) if res.get('VerificationAttributes', {}).get(domain, {}).get('VerificationStatus') == 'Success': break else: backoff('verification not yet successful', res, n) for n in itertools.count(start=1): res = ses.get_identity_dkim_attributes( Identities=[ domain, ], ) if res.get('DkimAttributes', {}).get(domain, {}).get('DkimVerificationStatus') == 'Success': break else: backoff('DKIM verification not yet successful', res, n) for n in itertools.count(start=1): res = ses.get_identity_notification_attributes( Identities=[ domain, ], ) if res.get('NotificationAttributes', {}).get(domain, {}).get('ForwardingEnabled') == True: break else: backoff('forwarding not yet enabled', res, n) def log(msg): print(json.dumps(msg), flush=True) Runtime: python3.7 Policies: - Version: 2012-10-17 Statement: - Effect: Allow Action: - ses:GetIdentityVerificationAttributes - ses:GetAccountSendingEnabled - ses:GetIdentityDkimAttributes - ses:GetIdentityNotificationAttributes Resource: '*' Timeout: 260 # the timeout effectivly limits retries to 2^(n+1) - 1 = 9 attempts with backup RootMailReadyAlert: Type: AWS::CloudWatch::Alarm Properties: AlarmName: superwerker-RootMailReady ComparisonOperator: GreaterThanOrEqualToThreshold Dimensions: - Name: FunctionName Value: !Ref RootMailReady EvaluationPeriods: 1 MetricName: Errors Namespace: AWS/Lambda Period: 180 Statistic: Sum Threshold: 1 RootMailReadyHandle: Type: AWS::CloudFormation::WaitConditionHandle RootMailReadyHandleWaitCondition: Type: AWS::CloudFormation::WaitCondition Properties: Handle: !Ref RootMailReadyHandle Timeout: "28800" # 8 hours time to wire DNS RootMailReadyTrigger: Type: AWS::Serverless::Function Properties: Events: EmailHealth: Type: CloudWatchEvent Properties: Pattern: detail-type: - CloudWatch Alarm State Change source: - aws.cloudwatch detail: alarmName: - !Ref RootMailReadyAlert state: value: - OK Handler: index.handler Environment: Variables: SIGNAL_URL: !Ref RootMailReadyHandle InlineCode: |- import json import os import urllib3 import uuid def handler(event, context): encoded_body = json.dumps({ "Status": "SUCCESS", "Reason": "RootMail Setup completed", "UniqueId": str(uuid.uuid4()), "Data": "RootMail Setup completed" }) http = urllib3.PoolManager() http.request('PUT', os.environ['SIGNAL_URL'], body=encoded_body) Runtime: python3.7 Timeout: 10 SESReceiveStack: Type: AWS::CloudFormation::StackSet Properties: AdministrationRoleARN: !GetAtt StackSetAdministrationRole.Arn ExecutionRoleName: !Ref StackSetExecutionRole PermissionModel: SELF_MANAGED Capabilities: - CAPABILITY_IAM StackInstancesGroup: - DeploymentTargets: Accounts: - !Sub "${AWS::AccountId}" Regions: - eu-west-1 # this is fixed to eu-west-1 until SES supports receive more globally (see #23) StackSetName: !Sub ${AWS::StackName}-ReceiveStack TemplateBody: !Sub | Resources: SESReceiptRuleSetActivation: Type: AWS::CloudFormation::CustomResource Properties: ServiceToken: !GetAtt SESReceiptRuleSetActivationCustomResource.Arn SESReceiptRuleSetActivationCustomResourceRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: AllowSesAccess PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: # TODO: least privilege - ses:* Resource: "*" SESReceiptRuleSetActivationCustomResource: Type: AWS::Lambda::Function Properties: Timeout: 200 Handler: index.handler Runtime: python3.7 Role: !GetAtt SESReceiptRuleSetActivationCustomResourceRole.Arn Code: ZipFile: !Sub | # TODO: adopt function to new ADR for inline python lambdas import boto3 import cfnresponse ses = boto3.client("ses") CREATE = 'Create' DELETE = 'Delete' UPDATE = 'Update' def exception_handling(function): def catch(event, context): try: function(event, context) except Exception as e: print(e) print(event) cfnresponse.send(event, context, cfnresponse.FAILED, {}) return catch @exception_handling def handler(event, context): RequestType = event["RequestType"] Properties = event["ResourceProperties"] LogicalResourceId = event["LogicalResourceId"] PhysicalResourceId = event.get("PhysicalResourceId") print('RequestType: {}'.format(RequestType)) print('PhysicalResourceId: {}'.format(PhysicalResourceId)) print('LogicalResourceId: {}'.format(LogicalResourceId)) id = PhysicalResourceId rule_set_name = 'RootMail' rule_name = 'Receive' if RequestType == CREATE or RequestType == UPDATE: ses.create_receipt_rule_set( RuleSetName=rule_set_name ) ses.create_receipt_rule( RuleSetName=rule_set_name, Rule = { 'Name' : rule_name, 'Enabled' : True, 'TlsPolicy' : 'Require', 'ScanEnabled': True, 'Recipients': [ 'root@${Subdomain}.${Domain}', ], 'Actions': [ { 'S3Action' : { 'BucketName' : '${EmailBucket}', 'ObjectKeyPrefix': 'RootMail' }, }, { 'LambdaAction': { 'FunctionArn': '${!OpsSantaFunction.Arn}' } } ], } ) print('Activating SES ReceiptRuleSet: {}'.format(LogicalResourceId)) ses.set_active_receipt_rule_set( RuleSetName=rule_set_name, ) elif RequestType == DELETE: print('Deactivating SES ReceiptRuleSet: {}'.format(LogicalResourceId)) ses.set_active_receipt_rule_set() ses.delete_receipt_rule( RuleName=rule_name, RuleSetName=rule_set_name, ) ses.delete_receipt_rule_set( RuleSetName=rule_set_name ) cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, id) OpsSantaFunctionSESPermissions: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: !Ref OpsSantaFunction Principal: ses.amazonaws.com SourceAccount: "${AWS::AccountId}" OpsSantaFunctionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: OpsSantaFunctionRolePolicy PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - s3:GetObject Resource: ${EmailBucket.Arn}/RootMail/* - Effect: Allow Action: - ssm:CreateOpsItem Resource: "*" - Action: ssm:PutParameter Effect: Allow Resource: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/superwerker/* OpsSantaFunction: Type: AWS::Lambda::Function Properties: Timeout: 60 Handler: index.handler Runtime: python3.7 Role: !GetAtt OpsSantaFunctionRole.Arn Code: ZipFile: !Sub | import boto3 import email from email import policy import hashlib import json import re import datetime s3 = boto3.client('s3') ssm = boto3.client('ssm', region_name='${AWS::Region}') filtered_email_subjects = [ 'Your AWS Account is Ready - Get Started Now', 'Welcome to Amazon Web Services', ] def handler(event, context): log({ 'event': event, 'level': 'debug', }) for record in event['Records']: id = record['ses']['mail']['messageId'] key = 'RootMail/{key}'.format(key=id) receipt = record['ses']['receipt'] log({ 'id': id, 'level': 'debug', 'key': key, 'msg': 'processing mail', }) verdicts = { 'dkim': receipt['dkimVerdict']['status'], 'spam': receipt['spamVerdict']['status'], 'spf': receipt['spfVerdict']['status'], 'virus': receipt['virusVerdict']['status'], } for k, v in verdicts.items(): if not v == 'PASS': log({ 'class': k, 'id': id, 'key': key, 'level': 'warn', 'msg': 'verdict failed - ops santa item skipped', }) return response = s3.get_object( Bucket="${EmailBucket}", Key=key, ) msg = email.message_from_bytes(response["Body"].read(), policy=policy.default) title=msg["subject"] source=recipient=event["Records"][0]["ses"]["mail"]["destination"][0] if title == 'Amazon Web Services Password Assistance': description=msg.get_body('html').get_content() pw_reset_link = re.search(r'(https://signin.aws.amazon.com/resetpassword(.*?))(?=
)', description).group() rootmail_identifier = '/superwerker/rootmail/pw_reset_link/{}'.format(source.split('@')[0].split('root+')[1]) ssm.put_parameter( Name=rootmail_identifier, Value=pw_reset_link, Overwrite=True, Type='String', Tier='Advanced', Policies=json.dumps([ { "Type":"Expiration", "Version":"1.0", "Attributes":{ "Timestamp": (datetime.datetime.now() + datetime.timedelta(minutes = 10)).strftime('%Y-%m-%dT%H:%M:%SZ') # expire in 10 minutes } } ]) ) return # no ops item for now if title in filtered_email_subjects: log({ 'level': 'info', 'msg': 'filtered email', 'title': title, }) return description=msg.get_body(preferencelist=('plain', 'html')).get_content() title=title[:1020] + " ..." * (len(title) > 1020) description=description[:1020] + " ..." * (len(description) > 1020) source=source[:60] + ' ...' * (len(source) > 60) operational_data={ "/aws/dedup":{ "Value":json.dumps( { "dedupString":id, } ), "Type":"SearchableString", }, "/aws/resources":{ "Value":json.dumps([ { "arn":"${EmailBucket.Arn}/{key}".format(key=key), } ]), "Type":"SearchableString", }, } ssm.create_ops_item( Description=description, OperationalData=operational_data, Source=source, Title=title, ) def log(msg): print(json.dumps(msg), flush=True) StackSetExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: AWS: - !Sub "${AWS::AccountId}" Action: - sts:AssumeRole Path: / ManagedPolicyArns: - !Sub arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess StackSetAdministrationRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: cloudformation.amazonaws.com Action: - sts:AssumeRole Path: / Policies: - PolicyName: AssumeRole-AWSCloudFormationStackSetExecutionRole PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - sts:AssumeRole Resource: - !GetAtt StackSetExecutionRole.Arn Metadata: SuperwerkerVersion: 0.13.2 cfn-lint: config: ignore_checks: - E9007 - EIAMPolicyWildcardResource