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