===== 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'])
}