Skip to content

Commit

Permalink
✨ Add DNS validated certificate custom cloudformation resource
Browse files Browse the repository at this point in the history
  • Loading branch information
dflook committed May 26, 2018
0 parents commit c393fe6
Show file tree
Hide file tree
Showing 8 changed files with 667 additions and 0 deletions.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2018 Daniel Flook

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Cloudformation Custom CertificateManager::Certificate

The cloudformation AWS::CertificateManager::Certificate resource can only create email validated certificates.

This is a custom cloudformation Certificate resource which can additionally create DNS validated certificates.
This should never have had to be written. If and when amazon get their act together, this should no longer be needed.

## Usage

It should behave identically to AWS::CertificateManager::Certificate (see
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-certificatemanager-certificate.html).

The additional VerificationMethod property is supported which may be 'EMAIL' or 'DNS', as in the API documentation
(https://docs.aws.amazon.com/acm/latest/APIReference/API_RequestCertificate.html#ACM-RequestCertificate-request-ValidationMethod).

When using 'DNS' as the VerificationMethod the DomainValidation property becomes required. The DomainValidationOption
values no longer have a ValidationDomain but instead a HostedZoneId. The HostedZoneId should be the zone to create
the DNS validation records in.

Certificates may take up to 30 minutes to be issued. The Certificate resource remains CREATING until the certificate is
issued.

To use this custom resource, copy the CustomAcmCertificateLambda and CustomAcmCertificateLambdaExecutionRole resources
into your template. You can then create certificate resources of Type: AWS::CloudFormation::CustomResource using the
properties you expect. Remember to add a ServiceToken property to the resource which references the CustomAcmCertificateLambda arn.

## Examples

cloudformation.py is an example of using troposphere to create a template with a Certificate resource.
The cloudformation.json and cloudformation.yaml files are generated from this as examples which could be used directly.
181 changes: 181 additions & 0 deletions certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import time
import boto3
import hashlib
import json
import copy
import logging
from botocore.vendored import requests

acm = boto3.client('acm')

l = logging.getLogger()
l.setLevel(logging.INFO)

def send(event):
l.info(event)
requests.put(event['ResponseURL'], json=event)

def create_cert(props, i_token):
a = copy.copy(props)

del a['ServiceToken']

if 'Tags' in props:
del a['Tags']

if 'ValidationMethod' in props:
if props['ValidationMethod'] == 'DNS':

try:
hosted_zones = {v['DomainName']: v['HostedZoneId'] for v in props['DomainValidationOptions']}

for name in set([props['DomainName']] + props.get('SubjectAlternativeNames', [])):
if name not in hosted_zones:
raise RuntimeError('DomainValidationOptions missing for %s' % str(name))
except KeyError:
raise RuntimeError('DomainValidationOptions missing')

del a['DomainValidationOptions']

elif props['ValidationMethod'] == 'EMAIL':
del a['ValidationMethod']

arn = acm.request_certificate(
IdempotencyToken=i_token,
**a
)['CertificateArn']

if 'Tags' in props:
acm.add_tags_to_certificate(CertificateArn=arn, Tags=props['Tags'])

if 'ValidationMethod' in props and props['ValidationMethod'] == 'DNS':

all_records_created = False
while not all_records_created:

certificate = acm.describe_certificate(CertificateArn=arn)['Certificate']
l.info(certificate)

all_records_created = True
for v in certificate['DomainValidationOptions']:

if 'ValidationStatus' not in v or 'ResourceRecord' not in v:
all_records_created = False
continue

records = []
if v['ValidationStatus'] == 'PENDING_VALIDATION':
records.append({
'Action': 'UPSERT',
'ResourceRecordSet': {
'Name': v['ResourceRecord']['Name'],
'Type': v['ResourceRecord']['Type'],
'TTL': 60,
'ResourceRecords': [{
'Value': v['ResourceRecord']['Value']
}]
}
})

if records:
response = boto3.client('route53').change_resource_record_sets(
HostedZoneId=hosted_zones[v['DomainName']],
ChangeBatch={
'Comment': 'Domain validation for %s' % arn,
'Changes': records
}
)

l.info(response)

return arn


def replace_cert(event):
old = copy.copy(event['OldResourceProperties'])
if 'Tags' in old:
del old['Tags']

new = copy.copy(event['ResourceProperties'])
if 'Tags' in new:
del new['Tags']

return old != new


def wait_for_issuance(arn, context):
while (context.get_remaining_time_in_millis() / 1000) > 30:

certificate = acm.describe_certificate(CertificateArn=arn)['Certificate']
l.info(certificate)
if certificate['Status'] == 'ISSUED':
return True

time.sleep(20)

return False


def reinvoke(event, context):
time.sleep((context.get_remaining_time_in_millis() / 1000) - 30)

# Only continue to reinvoke for 8 iterations - at 300 sec timeout thats 40 mins
event['I'] = event.get('I', 0) + 1
if event['I'] > 8:
raise RuntimeError('Certificate not issued in time')

boto3.client('lambda').invoke(
FunctionName=context.invoked_function_arn,
InvocationType='Event',
Payload=json.dumps(event).encode()
)


def handler(e, c):
l.info(e)
try:
i_token = hashlib.new('md5', (e['RequestId'] + e['StackId']).encode()).hexdigest()
props = e['ResourceProperties']

if e['RequestType'] == 'Create':
e['PhysicalResourceId'] = 'None'
e['PhysicalResourceId'] = create_cert(props, i_token)

if wait_for_issuance(e['PhysicalResourceId'], c):
e['Status'] = 'SUCCESS'
return send(e)
else:
return reinvoke(e, c)

elif e['RequestType'] == 'Delete':
if e['PhysicalResourceId'] != 'None':
acm.delete_certificate(CertificateArn=e['PhysicalResourceId'])
e['Status'] = 'SUCCESS'
return send(e)

elif e['RequestType'] == 'Update':

if replace_cert(e):
e['PhysicalResourceId'] = create_cert(props, i_token)

if not wait_for_issuance(e['PhysicalResourceId'], c):
return reinvoke(e, c)
else:
if 'Tags' in e['OldResourceProperties']:
acm.remove_tags_from_certificate(CertificateArn=e['PhysicalResourceId'],
Tags=e['OldResourceProperties']['Tags'])

if 'Tags' in props:
acm.add_tags_to_certificate(CertificateArn=e['PhysicalResourceId'],
Tags=props['Tags'])

e['Status'] = 'SUCCESS'
return send(e)
else:
raise RuntimeError('Unknown RequestType')

except Exception as ex:
l.exception('')
e['Status'] = 'FAILED'
e['Reason'] = str(ex)
return send(e)
78 changes: 78 additions & 0 deletions certificate_min.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import time
import boto3
import hashlib
import json
import copy
import logging
from botocore.vendored import requests
acm=boto3.client('acm')
l=logging.getLogger()
l.setLevel(logging.INFO)
def send(event):
l.info(event);requests.put(event['ResponseURL'],json=event)
def create_cert(props,i_token):
a=copy.copy(props);del a['ServiceToken']
if'Tags'in props:del a['Tags']
if'ValidationMethod'in props:
if props['ValidationMethod']=='DNS':
try:
hosted_zones={v['DomainName']:v['HostedZoneId'] for v in (props['DomainValidationOptions'])}
for name in set([props['DomainName']]+props.get('SubjectAlternativeNames',[])):
if name not in hosted_zones:raise RuntimeError('DomainValidationOptions missing for %s'%str(name))
except KeyError:
raise RuntimeError('DomainValidationOptions missing')
del a['DomainValidationOptions']
elif props['ValidationMethod']=='EMAIL':del a['ValidationMethod']
arn=acm.request_certificate(IdempotencyToken=i_token,**a)['CertificateArn']
if'Tags'in props:acm.add_tags_to_certificate(CertificateArn=arn,Tags=props['Tags'])
if'ValidationMethod'in props and props['ValidationMethod']=='DNS':
all_records_created=False
while not all_records_created:
certificate=acm.describe_certificate(CertificateArn=arn)['Certificate'];l.info(certificate);all_records_created=True
for v in certificate['DomainValidationOptions']:
if'ValidationStatus'not in v or'ResourceRecord'not in v:
all_records_created=False;continue
records=[]
if v['ValidationStatus']=='PENDING_VALIDATION':records.append({'Action':'UPSERT','ResourceRecordSet':{'Name':v['ResourceRecord']['Name'],'Type':v['ResourceRecord']['Type'],'TTL':60,'ResourceRecords':[{'Value':v['ResourceRecord']['Value']}]}})
if records:
response=boto3.client('route53').change_resource_record_sets(HostedZoneId=hosted_zones[v['DomainName']],ChangeBatch={'Comment':'Domain validation for %s'%arn,'Changes':records});l.info(response)
return arn
def replace_cert(event):
old=copy.copy(event['OldResourceProperties'])
if'Tags'in old:del old['Tags']
new=copy.copy(event['ResourceProperties'])
if'Tags'in new:del new['Tags']
return old!=new
def wait_for_issuance(arn,context):
while context.get_remaining_time_in_millis()/1000>30:
certificate=acm.describe_certificate(CertificateArn=arn)['Certificate'];l.info(certificate)
if certificate['Status']=='ISSUED':return True
time.sleep(20)
return False
def reinvoke(event,context):
time.sleep(context.get_remaining_time_in_millis()/1000-30);event['I']=event.get('I',0)+1
if event['I']>8:raise RuntimeError('Certificate not issued in time')
boto3.client('lambda').invoke(FunctionName=context.invoked_function_arn,InvocationType='Event',Payload=json.dumps(event).encode())
def handler(e,c):
l.info(e)
try:
i_token=hashlib.new('md5',(e['RequestId']+e['StackId']).encode()).hexdigest();props=e['ResourceProperties']
if e['RequestType']=='Create':
e['PhysicalResourceId']='None';e['PhysicalResourceId']=create_cert(props,i_token)
if wait_for_issuance(e['PhysicalResourceId'],c):
e['Status']='SUCCESS';return send(e)
else:return reinvoke(e,c)
elif e['RequestType']=='Delete':
if e['PhysicalResourceId']!='None':acm.delete_certificate(CertificateArn=e['PhysicalResourceId'])
e['Status']='SUCCESS';return send(e)
elif e['RequestType']=='Update':
if replace_cert(e):
e['PhysicalResourceId']=create_cert(props,i_token)
if not wait_for_issuance(e['PhysicalResourceId'],c):return reinvoke(e,c)
else:
if'Tags'in e['OldResourceProperties']:acm.remove_tags_from_certificate(CertificateArn=e['PhysicalResourceId'],Tags=e['OldResourceProperties']['Tags'])
if'Tags'in props:acm.add_tags_to_certificate(CertificateArn=e['PhysicalResourceId'],Tags=props['Tags'])
e['Status']='SUCCESS';return send(e)
else:raise RuntimeError('Unknown RequestType')
except Exception as ex:
l.exception('');e['Status']='FAILED';e['Reason']=str(ex);return send(e)
Loading

0 comments on commit c393fe6

Please sign in to comment.