Table of Contents

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.

Requirements

Deployment Rundown

  1. Create Route 53 Hosted Zone (or cloudflare zone and API Token)
  2. Create Systems Manager Parameter Store Secure String with cloudflare API Token
  3. Create S3 bucket
  4. Create SNS topic and subscribe to topic
  5. Create IAM EC2 policy and role
  6. Create IAM Lambda policy and role
  7. Verify the default VPC exists, create if needed
  8. Create Lambda function and assign IAM role
  9. Create scheduled CloudWatch Event and configure to pass needed input
  10. 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.

Diagram


https://nerdydrunk.info/_media/images:svg:lets_encrypt_wildcard_generator_lambda_function.svg

Lambda Function Operation

EC2 Instance Operation

Lambda Function Role Permissions

The following permissions are needed

Example Lambda role policy with minimal permissions.

EC2 Instance Role Permissions

The following permissions are needed

Example 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.

Lambda Function Code

When creating the Lambda function placement inside a VPC is not required, but assignment of a Lambda IAM role is required.

certgen.py
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'])
    }