Nerdy Drunk

Drunk on technology

User Tools

Site Tools


aws:lambda:letsencrypt_wildcard

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

  • 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

  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.

  • 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

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 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 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'])
    }
aws/lambda/letsencrypt_wildcard.txt · Last modified: 2024/08/19 18:33 by tingalls