===== Let's Encrypt Wildcard Generator Lambda Function ===== This Lambda function will check if a Let's Encrypt certificate is older than 60 days. If it is older than 60 days, then an EC2 instance will be launched that will update the certificate and then terminate itself. {{tag>AWS Lambda Linux LetsEncrypt Python}} ==== Requirements ==== * Lambda role * EC2 instance role * Route 53 Hosted Zone for a public domain name * Cloudflare zone and API Token for public domain name (alternative) * S3 bucket to store the encrypted certificate * SNS topic to send notifications * Default VPC with Internet Gateway ==== Deployment Rundown ==== - Create Route 53 Hosted Zone (or cloudflare zone and API Token) - Create Systems Manager Parameter Store Secure String with cloudflare API Token - Create S3 bucket - Create SNS topic and subscribe to topic - Create IAM EC2 policy and role - Create IAM Lambda policy and role - Verify the default VPC exists, create if needed - Create Lambda function and assign IAM role - Create scheduled CloudWatch Event and configure to pass needed input - Test Lambda function with input that CloudWatch Event would pass ==== Known Issues / Limitations ==== Below is a list of known issues and limitations with this implementation. * This works for me and has only been tested in my environment * Minimal error checking is being used * I have only tested this in US-East-1 since that's where ACM certs used by CloudFront need to be * Multiple ACM certificates with the same name have not been accounted for * Age check of certificate is based on when the Systems Manager Parameter was updated, not on the certificate expiration date * Installation, deployment, and configuration are done manually * Lambda function is dependent on using an AWS default VPC in US-East-1 to launch the EC2 instance * Only supports cloudflare API Token, does NOT support cloudflare Global API Key ==== Diagram ==== {{ :images:svg:lets_encrypt_wildcard_generator_lambda_function.svg | Let's Encrypt Wildcard Generator Lambda Function }} https://nerdydrunk.info/_media/images:svg:lets_encrypt_wildcard_generator_lambda_function.svg ==== Lambda Function Operation ==== * [1] A scheduled CloudWatch Event triggers the Lambda function with required inputs * [2] Lambda function checks the age of a Systems Manger Parameter * [3a] If the parameter was updated less than 60 days ago the function logs that a certificate update isn't needed and exits * [3b] If the parameter was updated over 60 days ago a notification is send that a certificate renewal will be done and the function continues * [3b] If the parameter doesn't exist a notification is send that the certificate will be created and the function continues * [4] If the function continued an EC2 instance is launched with user data to run Let's Encrypt and the instance information is logged ==== EC2 Instance Operation ==== * EC2 instance is launched in the default VPC with the current Amazon AMI for Amazon Linux 2 and is set to terminate on shutdown * EC2 instance installs Python 3, pip, Certbot (via pip), and the Certbot Route 53 and cloudflare plugins (via pip) * [5a] If Route53 is used, EC2 instance runs Certbot for the given domain name and uses the Route 53 plugin for domain validation * [5b] If cloudflare is used, EC2 instance retrieves API Token from SSM Parameter and runs Certbot for the given domain name and uses the cloudflare plugin for domain validation * EC2 instance uses OpenSSL to generate a password and then uses the password to create a P12 file from the full certificate chain * [6] EC2 instance saves the password as an encrypted Systems Manger Parameter Secure String * [7] EC2 instance uploads the P12 file to the given S3 bucket * [8] EC2 instance checks if the certificate is in ACM * EC2 instance updates the existing certificate in ACM if it is already in ACM * EC2 instance imports the certificate to ACM if it isn't in ACM * [9] EC2 instance sends a notification that the certificate has been generated and stored * EC2 instance terminates itself by shutting down ==== Lambda Function Role Permissions ==== The following permissions are needed * Launch EC2 instance in default, or desired (code update required), VPC * Pass EC2 instance role to instance when launched * Get password parameter age from Systems Manager * Get Amazon AMI parameter value from Systems Manager * Publish to provided SNS topic Example [[aws:lambda:letsencrypt_wildcard:lambda_role_policy|Lambda role policy]] with minimal permissions. ==== EC2 Instance Role Permissions ==== The following permissions are needed * Update records in Route 53 hosted zone for Let's Encrypt domain validation * Put new password in encrypted Systems Manager Parameter Secure String * Get cloudflare API Token from encrypted Systems Manager Parameter Secure String * Copy P12 file to desired S3 bucket * Add certificate to ACM if it doesn't exist * Update certificate in ACM if it does exist * Publish to provided SNS topic Example [[aws:lambda:letsencrypt_wildcard:ec2_role_policy|EC2 role policy]] with minimal permissions. ==== CloudWatch Event ==== The CloudWatch Event can be scheduled to run once a week and must invoke the Lambda function with input as described below. For each line below the ''"LEFT":'' is the key / variable name used in the Lambda function and the '': "RIGHT"'' is the value that applies to your environment. The keys "IMPORT_ACM", "DNS_SERVICE", and "FORCE_RUN" are optional, all other keys are required. The key "DNS_SERVICE" can be either Route53 or cloudflare. { "EC2_ROLE_NAME": "certgen-role-e2", "DOMAIN_NAME": "DOMAIN.TLD", "CERTGEN_BUCKET": "certgenbucket", "SNS_TOPIC_ARN": "arn:aws:sns:us-east-1:123456789012:certgen", "IMPORT_ACM": "Yes", "DNS_SERVICE": "Route53", "FORCE_RUN": "No" } The values show above match up with the values used in the following example Lambda and EC2 role policies. * [[aws:lambda:letsencrypt_wildcard:lambda_role_policy|Lambda role policy]] * [[aws:lambda:letsencrypt_wildcard:ec2_role_policy|EC2 role policy]] ==== Lambda Function Code ==== When creating the Lambda function placement inside a VPC is not required, but assignment of a Lambda IAM role is required. import boto3 from datetime import datetime from datetime import timedelta def lambda_handler(event, context): region = 'us-east-1' days_old = 60 client_ssm = boto3.client('ssm', region_name=region) client_ec2 = boto3.client('ec2', region_name=region) client_sns = boto3.client('sns', region_name=region) #amzn2_ami_parameter = '/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2' amzn2_ami_parameter = '/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64' current_ami = client_ssm.get_parameter(Name=amzn2_ami_parameter)['Parameter']['Value'] #print(current_ami) ec2_role_name = event['EC2_ROLE_NAME'] domain_name = event['DOMAIN_NAME'] certgen_bucket = event['CERTGEN_BUCKET'] sns_topic_arn = event['SNS_TOPIC_ARN'] if 'DNS_SERVICE' in event: dns_service = event['DNS_SERVICE'] else: dns_service = 'Route53' if 'IMPORT_ACM' in event: if event['IMPORT_ACM'] == 'Yes': import_acm = 'Yes' else: import_acm = 'No' else: import_acm = 'No' user_data = '''#!/bin/bash sudo yum -y install python3 sudo python3 -m ensurepip --upgrade python3 -m pip install certbot-route53 certbot-dns-cloudflare --user #pip3 install certbot-route53 --user AWS_DEFAULT_REGION="{}" DOMAINNAME="{}" echo "$DOMAINNAME" > /root/certgen-$DOMAINNAME.log CERTGENBUCKETNAME="{}" SNSTOPICARN="{}" IMPORT_ACM="{}" CERT_REGION="us-east-1" echo "$CERTGENBUCKETNAME" >> /root/certgen-$DOMAINNAME.log if [[ $DNS_SERVICE == "cloudflare" ]]; then echo "dns service is cloudflare" >> /root/certgen-$DOMAINNAME.log echo "dns_cloudflare_api_token = $(aws --region $CERT_REGION ssm get-parameter --name /certgen/$DOMAINNAME/cloudflare --with-decryption --query 'Parameter.Value' --output text)" > /root/cloudflare.ini chmod 600 /root/cloudflare.ini /root/.local/bin/certbot --config-dir /root/certbot/config --work-dir /root/certbot/work --logs-dir /root/certbot/logs certonly --dns-cloudflare --dns-cloudflare-credentials /root/cloudflare.ini --dns-cloudflare-propagation-seconds 30 -d *.$DOMAINNAME,$DOMAINNAME -n --email hostmaster@$DOMAINNAME --no-eff-email --agree-tos >> /root/certgen-$DOMAINNAME.log fi if [[ $DNS_SERVICE == "Route53" ]]; then echo "dns service is Route53" >> /root/certgen-$DOMAINNAME.log /root/.local/bin/certbot --config-dir /root/certbot/config --work-dir /root/certbot/work --logs-dir /root/certbot/logs certonly --dns-route53 --dns-route53-propagation-seconds 30 -d *.$DOMAINNAME,$DOMAINNAME -n --email hostmaster@$DOMAINNAME --no-eff-email --agree-tos >> /root/certgen-$DOMAINNAME.log fi ls -l /root/certgen/config/live/$DOMAINNAME/ >> /root/certgen-$DOMAINNAME.log P12PASSWORD="$(openssl rand -base64 32)" openssl pkcs12 -export -inkey /root/certgen/config/live/$DOMAINNAME/privkey.pem -in /root/certgen/config/live/$DOMAINNAME/fullchain.pem -out /root/certgen/$DOMAINNAME.p12 -password pass:"$P12PASSWORD" ## To encrypt and backup certbot configuration # tar zcf - /root/certgen | openssl enc -e -aes256 -out /root/certgen.tar.gz -pass pass:"$P12PASSWORD" ## To decrypt certbot configuration backup file # openssl enc -d -aes256 -in /root/certgen.tar.gz -pass pass:"$P12PASSWORD" | tar zxv aws --region $CERT_REGION ssm put-parameter --name /certgen/$DOMAINNAME/p12password --value "$P12PASSWORD" --type SecureString --overwrite >> /root/certgen-$DOMAINNAME.log aws --region $CERT_REGION s3 cp /root/certgen/$DOMAINNAME.p12 s3://$CERTGENBUCKETNAME/$DOMAINNAME/$DOMAINNAME.p12 >> /root/certgen-$DOMAINNAME.log ## To backup encrypted certbot configuration file # aws --region $CERT_REGION s3 cp /root/certgen.tar.gz s3://$CERTGENBUCKETNAME/$DOMAINNAME/certgen-$DOMAINNAME.tar.gz >> /root/certgen-$DOMAINNAME.log if [[ $IMPORT_ACM == "Yes" ]]; then CERT_ARN=$(aws --region $CERT_REGION acm list-certificates --query 'CertificateSummaryList[?DomainName==`'*.$DOMAINNAME'`].CertificateArn' --output text) echo $CERT_ARN >> /root/certgen-$DOMAINNAME.log if [[ $CERT_ARN != "" ]]; then echo "Existing certificate for $DOMAINNAME was found with ARN $CERT_ARN. Updating." >> /root/certgen-$DOMAINNAME.log aws --region $CERT_REGION acm import-certificate \ --certificate file:///root/certgen/config/live/$DOMAINNAME/cert.pem \ --private-key file:///root/certgen/config/live/$DOMAINNAME/privkey.pem \ --certificate-chain file:///root/certgen/config/live/$DOMAINNAME/chain.pem \ --certificate-arn $CERT_ARN >> /root/certgen-$DOMAINNAME.log else echo "Existing certificate for $DOMAINNAME was not found. Importing." >> /root/certgen-$DOMAINNAME.log aws --region $CERT_REGION acm import-certificate \ --certificate file:///root/certgen/config/live/$DOMAINNAME/cert.pem \ --private-key file:///root/certgen/config/live/$DOMAINNAME/privkey.pem \ --certificate-chain file:///root/certgen/config/live/$DOMAINNAME/chain.pem >> /root/certgen-$DOMAINNAME.log fi fi aws --region $CERT_REGION s3 cp /root/certgen-$DOMAINNAME.log s3://$CERTGENBUCKETNAME/certgen-$DOMAINNAME.log aws --region us-east-1 sns publish --topic-arn $SNSTOPICARN --subject "Status $DOMAINNAME" --message file:///root/certgen-$DOMAINNAME.log sleep 60 sudo shutdown -h now '''.format(region, domain_name, certgen_bucket, sns_topic_arn, import_acm) temp_instance_paramaters = { 'ImageId': current_ami, 'InstanceType': 't3.micro', 'Monitoring': {'Enabled':False}, 'IamInstanceProfile': {'Name':ec2_role_name}, 'InstanceInitiatedShutdownBehavior': 'terminate', 'CreditSpecification': {'CpuCredits':'standard'}, 'MinCount': 1, 'MaxCount': 1, 'UserData': user_data } #print(event) #print(ec2_role_name) print(domain_name) print(certgen_bucket) #print(user_data) #print(temp_instance_paramaters) try: cert_password = client_ssm.get_parameter(Name='/certgen/{}/p12password'.format(domain_name)) #print(cert_password) parameter_date = cert_password['Parameter']['LastModifiedDate'].astimezone() current_date = datetime.utcnow().astimezone() expiration_date = current_date - timedelta(days = days_old) if expiration_date < parameter_date: print('Certificate for {} does not need to be updated'.format(domain_name)) if 'FOURCE_RUN' in event: if event['FOURCE_RUN'] == 'Yes': print('Force run requested, generating certificate anyways.') else: return { 'statusCode': 200, 'body': 'Certificate for {} does not need to be updated'.format(domain_name) } else: return { 'statusCode': 200, 'body': 'Certificate for {} does not need to be updated'.format(domain_name) } print('Certificate is about to expire and needs to be updated') client_sns.publish( TopicArn=sns_topic_arn, Subject='Renewing {} certificate'.format(domain_name), Message=''' Certificate for {} will expire in {} days. Launching instance to renew certificate. '''.format(domain_name, 90 - days_old) ) except: print('Certificate for domain {} was not found'.format(domain_name)) client_sns.publish( TopicArn=sns_topic_arn, Subject='Creating {} certificate'.format(domain_name), Message=''' Certificate for {} was not found. Launching instance to create certificate. '''.format(domain_name) ) temp_instance = client_ec2.run_instances(**temp_instance_paramaters) #print(temp_instance) print('Launched instance {}'.format(temp_instance['Instances'][0]['InstanceId'])) return { 'statusCode': 200, 'body': 'Launched instance {}'.format(temp_instance['Instances'][0]['InstanceId']) }