# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy of this # software and associated documentation files (the "Software"), to deal in the Software # without restriction, including without limitation the rights to use, copy, modify, # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- AWSTemplateFormatVersion: '2010-09-09' Description: Demo environment for 2018 re:Invent Keeping Secrets chalk talk. # Keeping Secrets WP.yaml # # This CloudFormation template creates infrastructure for demonstrating # AWS Security Services. You must install the Keeping Secrets VPC.yaml # stack before installing this stack. # # The template builds the following resources: # # - An S3 bucket for Logging # - A CloudTrail that logs to the S3 bucket # - An EC2 instance running the Amazon Linux 2 operating system # # - Some bash scripts on the WordPress instance # mariadb.oldway.sh - connect to the database with hard-coded passwords # mariadb.newway.sh - connect to the database with secrets manager # certbotapachestaging.sh - generates an SSL certificates from the # *staging* server of LetsEncrypt. These are fake certificates and will # be used for testing. They will trigger a browser warning. # # - A WordPress installation on the EC2 instance # - A private MariadB RDS database Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: 'Please supply values for parameters in this section' Parameters: - EMailAddress - WPDBName - WPDBSecretName - WPKeyPair - WPDomainName - Label: default: 'Additional Parameters (verify default values)' Parameters: - WPDBPort - EipAllocationId - WPSshCidr - KmsCmkArn - SnsTopicArn - WPAmazonLinux2AmiId ParameterLabels: EMailAddress: default: 'Enter e-mail address for Certbot & WordPress registration:' KmsCmkArn: default: 'Enter the Parmeter Store key of the KMS CMK ARN:' EipAllocationId: default: 'Enter the Parmeter Store key of the Elastic IP Allocation Id:' SnsTopicArn: default: 'Enter the Parameter Store key of the AWS SNS topic:' WPAmazonLinux2AmiId: default: 'Enter the Parameter Store key of the WordPress Amazon Linux 2 AMI (do not change):' WPDBName: default: 'Enter the name of the WordPress database:' WPDBPort: default: 'Enter the TCP port for the WordPress database endpoint:' WPDBSecretName: default: 'Enter the name of the WordPress database secret:' WPDomainName: default: 'Enter fully qualified domain name (e.g. www.example.com):' WPKeyPair: default: 'Choose a key pair:' WPSshCidr: default: 'Enter the CIDR Block to allow for SSH access:' Parameters: # EipAllocationId - Allocation ID of elastic IP # This is the allocation ID for the elastic IP whose IP address s bound # to the FQDN of the WordPress host in Route 53. We need to attach the # IP while the instance is beign initialized so that the Certbot http # validation challenge can be performed. We'll fetch this from Systems # Manager Parameter Store so we don't have to type it. EipAllocationId: Type : 'AWS::SSM::Parameter::Value' Default: 'keeping-secrets-eip-allocation-id' EMailAddress: Type : String # KmsCmkArn - Amazon Resource Name for the KMS CMK to use for encrypting # volumes for Amazon RDS and AWS EBS. KmsCmkArn: Type : 'AWS::SSM::Parameter::Value' Default: 'keeping-secrets-cmk-arn' # SnsTopicArn - Amazon Resource Name for the SNS topic to use for sending # messages about CloudTrail events SnsTopicArn: Type : 'AWS::SSM::Parameter::Value' Default: 'keeping-secrets-topic-arn' # WPDBName - The RDS database instance name for the MySQL database. # You would typically accept the default. WPDBName: Type: String MinLength: 1 MaxLength: 64 AllowedPattern: '[a-zA-Z][a-zA-Z0-9]*' ConstraintDescription: 'Up to 64 alphanumerics beginning with a letter' Default: wpdemo # WPAmazonLinux2AmiId - The AMI for Amazon Linux 2. Note that this uses the # reserved AWS space in Systems Manager Parameter Store. WPAmazonLinux2AmiId: Type: 'AWS::SSM::Parameter::Value' Default: '/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2' # WPDBPort - The TCP port for the mySQL RDS database. You should typically # accept the default. WPDBPort: Default: 3306 Type: Number MinValue: 1024 MaxValue: 65535 ConstraintDescription: 'Must be between 1024 and 65535' # WPDBSecretName - The secret name to hold the WordPress database credentials # You would typically accept the default. WPDBSecretName: Type: String MinLength: 1 MaxLength: 64 AllowedPattern: '[a-zA-Z][a-zA-Z0-9]*' ConstraintDescription: 'Up to 64 alphanumerics beginning with a letter' Default: wpdemo WPDomainName: Type: String AllowedPattern: '^(?!:\/\/)([a-zA-Z0-9-]+\.){0,5}[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z]{2,64}?$' # WPKeyPair - The EC2 keypair for the WordPress host. WPKeyPair: Type: AWS::EC2::KeyPair::KeyName # WPSshCidr: WPSshCidr: Type: String AllowedPattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$' Default: '127.0.0.1/32' Resources: # S3Bucket - General S3 bucket for CloudTrail, etc. # # Notes: # # (1) A lifecycle rule expires objects after 1 day to help reduce costs in case # the stack is not deleted. This does not delete the bucket itself. S3Bucket: DeletionPolicy: Retain Type: AWS::S3::Bucket Properties: LifecycleConfiguration: Rules: - Id: DeletionRule ExpirationInDays: '1' Status: Enabled Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-bucket' - Key: Project Value: !ImportValue ProjectTag # S3BucketPolicy - set up the policy on the S3 Bucket # # Configure the bucket policy to allow the following: # # (1) CloudTrail use S3BucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref S3Bucket PolicyDocument: Version: '2012-10-17' Statement: - Sid: AWSCloudTrailAclCheck Effect: Allow Principal: Service: cloudtrail.amazonaws.com Action: s3:GetBucketAcl Resource: !Join [ '', ['arn:aws:s3:::', Ref: S3Bucket ] ] - Sid: AWSCloudTrailWrite Effect: Allow Principal: Service: cloudtrail.amazonaws.com Action: s3:PutObject Resource: !Join [ '', [ 'arn:aws:s3:::', Ref: S3Bucket, '/AWSLogs/', !Ref 'AWS::AccountId', '/*' ] ] Condition: StringEquals: s3:x-amz-acl: bucket-owner-full-control # LambdaExecutionRole - Lambda excution role for our Lambda functions # # I am using the same Lambda execution role for all of the functions. # In a production scenario, I would use separate Lambda roles tailored to # each Lambda function to enforce the principle of least privilege. LambdaExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: '/' Policies: - PolicyName: root PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents Resource: arn:aws:logs:*:*:* - Effect: Allow Action: - ec2:DescribeVpcEndpointServices Resource: '*' - Effect: Allow Action: - sns:Publish Resource: arn:aws:sns:*:*:* - Effect: Allow Action: - secretsmanager:* Resource: arn:aws:secretsmanager:*:*:* # CTrailLogGroup - Log Group for all CloudTrail logs # # Creating the LogGroup in CloudFormation allows us to set the # retention time period for entries. CTrailLogGroup: Type: AWS::Logs::LogGroup Properties: RetentionInDays: 1 # CTrailLogGroupRole - Role for allowing CloudTrail to send logs to the # above log group. CTrailLogGroupRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - cloudtrail.amazonaws.com Action: - sts:AssumeRole Path: '/' Policies: - PolicyName: root PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${CTrailLogGroup}:log-stream:* # CTrail - CloudTrail for API Logging CTrail: Type: AWS::CloudTrail::Trail DependsOn: - S3BucketPolicy Properties: CloudWatchLogsLogGroupArn: !GetAtt CTrailLogGroup.Arn CloudWatchLogsRoleArn: !GetAtt CTrailLogGroupRole.Arn S3BucketName: !Ref S3Bucket IsLogging: True Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-trail' - Key: Project Value: !ImportValue ProjectTag # CTrailEventRuleFunction - Lambda function for processing CloudTrail Events # # Notes: # # (1) Decrypt events are filtered because they happen so frequently. # # (2) Only SNS e-mail targets receive the message. CTrailEventRuleFunction: Type: AWS::Lambda::Function Properties: Description: 'Handle selected API calls from CloudTrail/CloudWatch Events' Environment: Variables: SnsTopicArn: !Ref SnsTopicArn Handler: index.handler MemorySize: 128 Role: !GetAtt LambdaExecutionRole.Arn Runtime: 'python3.6' Timeout: 30 Code: ZipFile: | import boto3 import json import logging import os from botocore.exceptions import ClientError def handler(event, context): eventname = event['detail']['eventName'] snstopicarn = os.environ['SnsTopicArn'] snsclient = boto3.client('sns') logger=logging.getLogger(__name__) logger.setLevel(logging.DEBUG) logger.debug('CTrailEventRuleFunction Event: ' + json.dumps(event)) if eventname == 'Decrypt': return try: response = snsclient.publish( TargetArn = snstopicarn, Subject=("CloudTrail event received: " + eventname), Message=json.dumps({'default': json.dumps(event)}), MessageStructure='json') logger.debug('SNS Publish Response: ' + json.dumps(response)) except ClientError as e: logger.error('An error occured: ' + e.response['Error']['Code']) # CTrailEventRule - CloudWatch Events rule # # Catch security related events and process them. CTrailEventRule: Type: AWS::Events::Rule Properties: Description: 'Rule for monitoring CloudTrail APIs' EventPattern: detail: eventSource: - cloudtrail.amazonaws.com - secretsmanager.amazonaws.com - kms.amazonaws.com - cloudhsm.amazonaws.com State: ENABLED Targets: - Arn: !GetAtt CTrailEventRuleFunction.Arn Id: !Join [ '', [ Ref: 'AWS::StackName', '-eventrule' ]] # CloudTrailEventRuleLogGroup - CloudWatch Logs log group for CTrailEventRuleFunction # # Creating the LogGroup in CloudFormation allows us to set the # retention time period for entries. CloudTrailEventRuleLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Join [ '', [ '/aws/lambda/', Ref: CTrailEventRuleFunction ] ] RetentionInDays: 1 # LambdaPermissionRule - grant CloudWatch Events # rule permissions to invoke Lambda function. LambdaPermissionRule: Type: AWS::Lambda::Permission DependsOn: CloudTrailEventRuleLogGroup Properties: Action: lambda:InvokeFunction FunctionName: !Ref CTrailEventRuleFunction Principal: events.amazonaws.com SourceArn: !GetAtt CTrailEventRule.Arn # WPSG - The security group for http/https on the WordPress host. # # Note: Port 80 must be open to the internet for the Certbot validation # to work. WPSG: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: Enable SSH, HTTP and HTTPS SecurityGroupIngress: - IpProtocol: tcp FromPort: '22' ToPort: '22' CidrIp: !Ref WPSshCidr - IpProtocol: tcp FromPort: '80' ToPort: '80' CidrIp: 0.0.0.0/0 - IpProtocol: tcp FromPort: '443' ToPort: '443' CidrIp: 0.0.0.0/0 Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-sg' - Key: Project Value: !ImportValue ProjectTag VpcId: !ImportValue VpcId # WPSGSelfReference - We need to make the security group self- # referential to handle communication to and from Secrets Manager. # Doing this for the WordPress host allows us to test Secrets Manager # from the AWS CLI. WPSGSelfReference: Type: "AWS::EC2::SecurityGroupIngress" Properties: FromPort: 0 GroupId: !GetAtt WPSG.GroupId IpProtocol: tcp SourceSecurityGroupId: !GetAtt WPSG.GroupId ToPort: 65535 # WPRole - The instance role for the WordPress host. We only need # permissions for Secrets Manager and KMS for this demo. WPRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - ec2.amazonaws.com Action: - sts:AssumeRole ManagedPolicyArns: - 'arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM' - 'arn:aws:iam::aws:policy/AmazonSSMFullAccess' Path: '/' Policies: - PolicyName: !Join - '' - - !ImportValue NamePrefix - '-polcloudwatch' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - cloudwatch:PutMetricData Resource: '*' - PolicyName: !Join - '' - - !ImportValue NamePrefix - '-polcloudwatchlogs' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents - logs:DescribeLogStreams Resource: arn:aws:logs:*:*:* - PolicyName: !Join - '' - - !ImportValue NamePrefix - '-polsecretsmgr' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - secretsmanager:* Resource: '*' - PolicyName: !Join - '' - - !ImportValue NamePrefix - '-polkmsr' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - kms:* Resource: '*' - PolicyName: !Join - '' - - !ImportValue NamePrefix - '-polec2' PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - ec2:Describe* - ec2:AssociateAddress Resource: '*' # WPProfile - The instance profile for the WordPress host which just # contains the corresponding IAM role. WPProfile: Type: AWS::IAM::InstanceProfile Properties: Path: '/' Roles: - !Ref WPRole WPEncryptedVolume: Type: AWS::EC2::Volume Properties: AutoEnableIO: True AvailabilityZone: !ImportValue SecretsMgrSubnet01Az Encrypted: True KmsKeyId: !Ref KmsCmkArn Size: 100 Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-volume' - Key: Project Value: !ImportValue ProjectTag # WP - Our WordPress host. Note that we launch this with the WPProfile # instance profile so we get access to some AWS services. We also create the # take advantage of CloudFormation::Init to do some initialization create files, # install packages, and run commands. # # Files created for Secrets Manager: # # mariadb.oldway.sh - shows the old way of connecting to the database with # hardcoded credentials # # mariadb.newway.sh - shows the new way of connecting to the database with # AWS Secrets Manager # # displaysecretversions.sh - displays the versions of a secret # # Other files created: # # certbotstagingapache.sh - install LetsEncrypt using the Apache # installer against the LetsEncrypt staging server. # # monitorsecret.sh - polls AWS Secrets Manager for changes to the database # password and updates the WordPress configuration file if it has changed # # Packages installed: # # jq - for parsing the strings from Secrets Manager # python3 - for building the newest AWS CLI which has the most updated commands # # Additionally: # # (1) Install the LAMP (Linux-Apache-MariaDB-PHP) stack using # amazon-linux-extras. We just need the mysql client and this is an easy # way of getting it. # # (2) Build the latest AWS CLI which has the latest commands # # (3) Install the WP-CLI installer to make it easier to install WordPress # # (4) Install WordPress with Amazon RDS for MariaDB as the backend database` # # (5) Update the Systems Manager agent to the most recent version. # # (6) Associate the defined Elastic IP to the instance so Certbot can run. WP: Type: AWS::EC2::Instance DependsOn: - WPDBInstance Metadata: AWS::CloudFormation::Init: configSets: default: - WPSetup WPSetup: packages: yum: jq: [] python3: [] httpd: [] mariadb-server: [] php: [] php-gd: [] php-mbstring: [] php-mysqlnd: [] php-xml: [] php-xmlrpc: [] python2-certbot-apache.noarch: [] files: /home/ec2-user/mariadb.oldway.sh: content: !Sub | #/bin/bash # mariadb.oldway.sh # This is the old way of accessing a database, with hard-coded passwords. # This script will only work right after the CloudFormation script runs. # After you store and rotate the secret, you will need to use the # mariadb.newway.sh script. mysql \ -p${WPDBPassword.RandomString} \ -u ${WPDBUser.RandomString} \ -P ${WPDBPort} \ -h ${WPDBInstance.Endpoint.Address} mode: '755' owner: ec2-user group: ec2-user /home/ec2-user/mariadb.newway.sh: content: !Sub | #/bin/bash # This is the new way of accessing a database, with AWS Secrets Manager. secret=$(aws secretsmanager get-secret-value --secret-id ${WPDBSecretName} --region ${AWS::Region} | jq .SecretString | jq fromjson) user=$(echo $secret | jq -r .username) password=$(echo $secret | jq -r .password) endpoint=$(echo $secret | jq -r .host) port=$(echo $secret | jq -r .port) mysql \ -p$password \ -u $user \ -P $port \ -h $endpoint mode: '755' owner: ec2-user group: ec2-user /home/ec2-user/displaysecretversions.sh: content: !Sub | #/bin/bash # display the versions of a secret VERSIONIDS=($(aws secretsmanager list-secret-version-ids --secret-id ${WPDBSecretName} --region ${AWS::Region} --query '[Versions[*].[VersionId]]' --output text)) for V in ${!VERSIONIDS[@]} do SECRETINFO=`aws secretsmanager get-secret-value --secret-id ${WPDBSecretName} --region ${AWS::Region} --version-id $V` SECRETSTRING=`echo $SECRETINFO|jq -r .SecretString` USERNAME=`echo $SECRETSTRING|jq -r .username` PASSWORD=`echo $SECRETSTRING|jq -r .password` VERSIONSTAGES=`echo $SECRETINFO|jq -r .VersionStages | tr -d ' \n\[\]\"'` echo Version: $V echo echo username = $USERNAME echo password = $PASSWORD echo versionstages = $VERSIONSTAGES echo echo ---- echo done mode: '755' owner: ec2-user group: ec2-user /home/ec2-user/certbotstagingapache.sh: content: !Sub | #/bin/bash # certbotstagingapache.sh # # Get a staging certificate from LetsEncrypt and install it using the Apache installer. # Note that staging certificates are pulled from the "fake" cert hierarchy and are not # part of the browser's trusted store. # # Note: TCP port 80 must be open for this validation to work! sudo systemctl stop httpd sudo certbot \ --apache \ --staging \ -m ${EMailAddress} \ --agree-tos \ -n \ -d ${WPDomainName} sudo systemctl start httpd mode: '755' owner: ec2-user group: ec2-user /home/ec2-user/rdsmariadbsecretstring.sh: content: !Sub | #/bin/bash # rdsmariadbsecretstring.sh # Create a secretstring value for AWS Secrets Manager # for an Amazon RDS MariaDB database. If no arguments # are supplied, use the values that were created at the # time the stack was built, otherwise use the following # command line parameters (which you may want to quote). # # $1 = database user name # $2 = database password # $3 = database engine # $4 = database host (FQDN) # $5 = database port # $6 = database name # $7 = database instance identifier (the first part of $4) echo \ '{'\ '"'username'"':'"'$1'"',\ '"'password'"':'"'$2'"',\ '"'engine'"':'"'$3'"',\ '"'host'"':'"'$4'"',\ '"'port'"':'"'$5'"',\ '"'dbname'"':'"'$6'"',\ '"'dbInstanceIdentifier'"':'"'$7'"'\ '}' mode: '755' owner: ec2-user group: ec2-user /home/ec2-user/monitorsecret.sh: content: !Sub | #!/bin/bash # monitorsecret.sh - monitor database password and update wp_config.php # if it changes. # # Notes: # # WPCLI is used to make the change to the password. # # SECURITY NOTES: # # The WPCLI database password change displays the new password in the log # file. This is fine for a demo but remove this for non-demo situations. WPCLI=/usr/local/bin/wp WPDIR=/var/www/html LOGFILE=/home/ec2-user/secretchangelog LOOPSLEEPSECONDS=10 getdbpasswordfromsecret() { local SECRETSTRING local PASSWORD SECRETSTRING=$(aws secretsmanager get-secret-value --secret-id ${WPDBSecretName} --region ${AWS::Region} | jq .SecretString | jq fromjson) PASSWORD=$(echo $SECRETSTRING | jq -r .password) echo $PASSWORD } logmessage() { local TIMESTAMP TIMESTAMP=`date` echo $TIMESTAMP - "$1" >> $LOGFILE } OLDPASSWORD=$(getdbpasswordfromsecret) touch $LOGFILE chmod 660 $LOGFILE while true do CURRENTPASSWORD=$(getdbpasswordfromsecret) if [ "$OLDPASSWORD" != "$CURRENTPASSWORD" ] then TIMESTAMP=`date` logmessage "Secrets Manager database password has changed!" logmessage "Password change details are being logged for this demo!" logmessage "Do not log password change details in a non-demo environment!" WPCLIMSG=`$WPCLI --path=$WPDIR --no-color config set DB_PASSWORD "$CURRENTPASSWORD" 2>&1` #Remove the call to logmessage below in a non-demo environment! logmessage "$WPCLIMSG" OLDPASSWORD="$CURRENTPASSWORD" fi sleep $LOOPSLEEPSECONDS done mode: '755' owner: ec2-user group: ec2-user /usr/local/bin/wp: source: https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar mode: '755' /tmp/awscli-bundle.zip: source: https://s3.amazonaws.com/aws-cli/awscli-bundle.zip mode: '755' commands: cmd10mariadb: command: 'amazon-linux-extras install lamp-mariadb10.2-php7.2' cmd20unzipcli: command: 'unzip awscli-bundle.zip' cwd: '/tmp' cmd30installcli: command: './awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws' cwd: '/tmp' CreationPolicy: ResourceSignal: Timeout: PT15M Properties: AvailabilityZone: !ImportValue SecretsMgrSubnet01Az IamInstanceProfile: !Ref WPProfile ImageId: !Ref WPAmazonLinux2AmiId InstanceInitiatedShutdownBehavior: stop InstanceType: t2.small KeyName: !Ref WPKeyPair Monitoring: true NetworkInterfaces: - AssociatePublicIpAddress: true DeviceIndex: '0' GroupSet: - !Ref WPSG SubnetId: !ImportValue SecretsMgrSubnet01Id Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-host' - Key: Project Value: !ImportValue ProjectTag Tenancy: default Volumes: - VolumeId: !Ref WPEncryptedVolume Device: '/dev/sdf' UserData: Fn::Base64: !Sub | #!/bin/bash -xe HOMEDIR=/home/ec2-user EPELRPM=https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm WPCONFIG=/tmp/wordpress/wp-config.php HTTPDCONF=/etc/httpd/conf/httpd.conf WPCLI=/usr/local/bin/wp yum install -y deltarpm yum update -y # Mount the encrypted volume so the web site # installation will be on an encrypted file system. mkfs /dev/sdf mkdir -p /var/www/html mount /dev/sdf /var/www/html # Update the AWS Systems Manager Agent EC2_INSTANCE_ID=`wget -q -O - http://169.254.169.254/latest/meta-data/instance-id` aws ssm create-association \ --targets Key=instanceids,Values=$EC2_INSTANCE_ID \ --name AWS-UpdateSSMAgent \ --schedule-expression "cron(0 0 2 ? * SUN *)" \ --region ${AWS::Region} # Associate Elastic IP with instance so Certbot can run aws ec2 associate-address --allocation-id ${EipAllocationId} --instance-id $EC2_INSTANCE_ID --region ${AWS::Region} amazon-linux-extras install lamp-mariadb10.2-php7.2 echo Installing EPEL... cd /tmp wget -O epel.rpm -nv $EPELRPM yum install -y ./epel.rpm /opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource WP --region ${AWS::Region} mysql -p${WPDBPassword.RandomString} -u ${WPDBUser.RandomString} -P ${WPDBPort} -h ${WPDBInstance.Endpoint.Address} </,/<\/Directory>/s/AllowOverride None/AllowOverride All/' \ $HTTPDCONF cat <> $HTTPDCONF ServerName ${WPDomainName} DocumentRoot "/var/www/html" EOF usermod -a -G apache ec2-user chown -R apache:apache /var/www chmod 2775 /var/www && find /var/www -type d -exec chmod 2775 {} \; find /var/www -type d -exec chmod 2775 {} \; find /var/www -type f -exec chmod 0664 {} \; echo Starting Apache... sudo systemctl start httpd sudo systemctl enable httpd echo Installing LetsEncrypt certificate... /home/ec2-user/certbotstagingapache.sh echo Beginning monitoring of AWS Secrets Manager for changes... nohup /home/ec2-user/monitorsecret.sh& /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource WP --region ${AWS::Region} # RandomStrFunction - Generate a string of random characters # # This AWS Lambda function is used to generate a random string # of letters. We'll use the Python string module to do this. # You can change the composition of the string by changing the # methods that are used. RandomStrFunction: Type: AWS::Lambda::Function Properties: Description: 'Generate a random string of characters' Handler: index.handler MemorySize: 128 Role: !GetAtt LambdaExecutionRole.Arn Runtime: 'python3.6' Timeout: 30 Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-rndstrcunction' - Key: Project Value: !ImportValue ProjectTag Code: ZipFile: | import json import boto3 import cfnresponse import string import random def handler(event, context): if event['RequestType'] == 'Delete': responseData = {} cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData) return StringLength=int(event['ResourceProperties']['StringLength']) if StringLength <= 0: responseData = {} cfnresponse.send(event, context, cfnresponse.FAILED) else: responseData = {} chars=string.ascii_letters # change this to use other kinds of characters responseData['RandomString'] = ''.join(random.choice(chars) for _ in range(StringLength)) cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData) return # RandomStrFunctionLogGroup - CloudWatch Logs log group for RandomStrFunction # # Creating the LogGroup in CloudFormation allows us to set the # retention time period for entries. RandomStrFunctionLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Join [ '', [ '/aws/lambda/', Ref: RandomStrFunction ] ] RetentionInDays: 1 # WPAdminUser - The AWS Lambda-backed resource for generating a random # WordPress administrative user # # Parameters # # ServiceToken - a pointer to the AWS Lambda function # StringLength - the length of the random string to generate WPAdminUser: Type: Custom::WPAdminUser DependsOn: RandomStrFunctionLogGroup Properties: ServiceToken: !GetAtt RandomStrFunction.Arn StringLength: '16' # WPAdminPassword - The AWS Lambda-backed resource for generating a random # WordPress administrator password # # Parameters # # ServiceToken - a pointer to the AWS Lambda function # StringLength - the length of the random string to generate WPAdminPassword: Type: Custom::WPAdminPassword DependsOn: RandomStrFunctionLogGroup Properties: ServiceToken: !GetAtt RandomStrFunction.Arn StringLength: '32' # WPDBUser - The AWS Lambda-backed resource for generating a random # database user # # Parameters # # ServiceToken - a pointer to the AWS Lambda function # StringLength - the length of the random string to generate WPDBUser: Type: Custom::WPDBUser DependsOn: RandomStrFunctionLogGroup Properties: ServiceToken: !GetAtt RandomStrFunction.Arn StringLength: '16' # WPDBPassword - The AWS Lambda-backed resource for generating a random # database password # # Parameters # # ServiceToken - a pointer to the AWS Lambda function # StringLength - the length of the random string to generate WPDBPassword: Type: Custom::WPDBPassword DependsOn: RandomStrFunctionLogGroup Properties: ServiceToken: !GetAtt RandomStrFunction.Arn StringLength: '32' # SecretLambdaFunction - Create Secret for database SecretLambdaFunction: Type: AWS::Lambda::Function Properties: Description: 'Create secret for RDS database' Handler: index.handler MemorySize: 128 Role: !GetAtt LambdaExecutionRole.Arn Runtime: 'python3.6' Timeout: 30 Code: ZipFile: | import boto3 import json import logging import cfnresponse from botocore.exceptions import ClientError def handler(event, context): smclient = boto3.client('secretsmanager') logger=logging.getLogger(__name__) logger.setLevel(logging.DEBUG) responseData = {} SecretName = event['ResourceProperties']['SecretName'] logger.debug('SecretLambdaFunction Event: ' + json.dumps(event)) if event['RequestType'] == 'Create': SecretDBUsername = event['ResourceProperties']['SecretDBUsername'] SecretDBPassword = event['ResourceProperties']['SecretDBPassword'] SecretDBEngine = event['ResourceProperties']['SecretDBEngine'] SecretDBHost = event['ResourceProperties']['SecretDBHost'] SecretDBPort = event['ResourceProperties']['SecretDBPort'] SecretDBName = event['ResourceProperties']['SecretDBName'] SecretDBIdentifier = event['ResourceProperties']['SecretDBIdentifier'] SecretStringValue = ('{' + '"username":"' + SecretDBUsername + '",' + '"password":"' + SecretDBPassword + '",' + '"engine":"' + SecretDBEngine + '",' + '"host":"' + SecretDBHost + '",' + '"port":"' + SecretDBPort + '",' + '"dbname":"'+ SecretDBName + '",' + '"dbInstanceIdentifier":"' + SecretDBIdentifier + '"' + '}') logger.debug('SecretString: ' + SecretStringValue) try: response=smclient.create_secret( Name=SecretName, SecretString=SecretStringValue ) responseData['Status'] = cfnresponse.SUCCESS cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData) return except ClientError as e: logger.error('An error occured creating secret: ' + e.response['Error']['Code']) responseData['Status'] = cfnresponse.FAILED cfnresponse.send(event, context, cfnresponse.FAILED, responseData) return if event['RequestType'] == 'Delete': try: response=smclient.delete_secret( SecretId=SecretName, ForceDeleteWithoutRecovery = True ) responseData['Status'] = cfnresponse.SUCCESS cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData) return except ClientError as e: logger.error('An error occured deleting secret: ' + e.response['Error']['Code']) responseData['Status'] = cfnresponse.FAILED cfnresponse.send(event, context, cfnresponse.FAILED, responseData) return if event['RequestType'] == 'Update': responseData['Status'] = cfnresponse.FAILED cfnresponse.send(event, context, cfnresponse.FAILED, responseData) return # SecretLambdaLogGroup - CloudWatch Logs group for SecretLambdaFunction # # Creating the LogGroup in CloudFormation allows us to set the # retention time period for entries. SecretLambdaLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Join [ '', [ '/aws/lambda/', Ref: SecretLambdaFunction ] ] RetentionInDays: 1 WPDBSecret: Type: Custom::WPDBSecret DependsOn: SecretLambdaLogGroup Properties: ServiceToken: !GetAtt SecretLambdaFunction.Arn SecretName: !Ref WPDBSecretName SecretDBUsername: !GetAtt WPDBUser.RandomString SecretDBPassword: !GetAtt WPDBPassword.RandomString SecretDBEngine: mariadb SecretDBHost: !GetAtt WPDBInstance.Endpoint.Address SecretDBPort: !Ref WPDBPort SecretDBName: !Ref WPDBName SecretDBIdentifier: !Ref WPDBInstance # DBSG - The security group for the RDS database DBSG: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: Security group for the RDS database SecurityGroupIngress: - IpProtocol: tcp FromPort: !Ref WPDBPort ToPort: !Ref WPDBPort CidrIp: !Join - '' - - !ImportValue VpcCidrOctet1 - '.' - !ImportValue VpcCidrOctet2 - '.0.0/16' Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-dbsg' - Key: Project Value: !ImportValue ProjectTag VpcId: !ImportValue VpcId # DBSGSelfReference - We need to make the security group self- # referential to handle communication to and from Secrets Manager. DBSGSelfReference: Type: "AWS::EC2::SecurityGroupIngress" Properties: FromPort: 0 GroupId: !GetAtt DBSG.GroupId IpProtocol: tcp SourceSecurityGroupId: !GetAtt DBSG.GroupId ToPort: 65535 # DBSubnetGroup - Database subnet group # # We need this even for single AZ databases. DBSubnetGroup: Type: AWS::RDS::DBSubnetGroup Properties: DBSubnetGroupDescription: Subnet group for RDS database SubnetIds: - !ImportValue SecretsMgrSubnet01Id - !ImportValue SecretsMgrSubnet02Id Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-dbsubg' - Key: Project Value: !ImportValue ProjectTag # WPDBInstance - Our test database. # # Note that this is a private RDS database instance. The database resides # on subnets that we have created based on the presence of an AWS Secrets # Manager endpoint. WPDBInstance: Type: AWS::RDS::DBInstance DeletionPolicy: Delete Properties: AllocatedStorage: 5 AllowMajorVersionUpgrade: false AutoMinorVersionUpgrade: true AvailabilityZone: !ImportValue SecretsMgrSubnet01Az BackupRetentionPeriod: 0 DBInstanceClass: db.t2.small DBName: !Ref WPDBName DBSubnetGroupName: !Ref DBSubnetGroup Engine: 'mariadb' EngineVersion: '10.3' KmsKeyId: !Ref KmsCmkArn MasterUsername: !GetAtt WPDBUser.RandomString MasterUserPassword: !GetAtt WPDBPassword.RandomString MultiAZ: false Port: !Ref WPDBPort PubliclyAccessible: False StorageEncrypted: True StorageType: gp2 Tags: - Key: Name Value: !Join - '' - - !ImportValue NamePrefix - '-db' - Key: Project Value: !ImportValue ProjectTag VPCSecurityGroups: - !Ref DBSG SecretsManagerEndpoint: Type: 'AWS::EC2::VPCEndpoint' Properties: VpcEndpointType: Interface PrivateDnsEnabled: True SubnetIds: - !ImportValue SecretsMgrSubnet01Id - !ImportValue SecretsMgrSubnet02Id SecurityGroupIds: - !Ref DBSG - !Ref WPSG ServiceName: !Join - '' - - com.amazonaws. - !Ref 'AWS::Region' - .secretsmanager VpcId: !ImportValue VpcId SessionManagerEndpoint: Type: 'AWS::EC2::VPCEndpoint' Properties: VpcEndpointType: Interface PrivateDnsEnabled: True SubnetIds: - !ImportValue SessionMgrSubnet01Id - !ImportValue SessionMgrSubnet02Id SecurityGroupIds: - !Ref WPSG ServiceName: !Join - '' - - com.amazonaws. - !Ref 'AWS::Region' - .ssmmessages VpcId: !ImportValue VpcId Outputs: WPIP: Description: WP IP address Value: !GetAtt WP.PublicIp WPInstanceId: Description: WP instance ID Value: !Ref WP Export: Name: WPInstanceId WPSGId: Description: WP instance security group Value: !Ref WPSG Export: Name: WPSGId WPAdminUser: Description: WP administrator username Value: !GetAtt WPAdminUser.RandomString WPAdminPassword: Description: WP administrator password Value: !GetAtt WPAdminPassword.RandomString WPDBUser: Description: WP MariaDB master username Value: !GetAtt WPDBUser.RandomString WPDBPassword: Description: WP MariaDB master user password Value: !GetAtt WPDBPassword.RandomString WPDBEndpoint: Description: WP MariaDB endpoint Value: !GetAtt WPDBInstance.Endpoint.Address WPDBIdentifier: Description: WP MariaDB identifier Value: !Ref WPDBInstance WPDBPort: Description: WP MariaDB port Value: !Ref WPDBPort WPDBName: Description: WP MariaDB name Value: !Ref WPDBName