Repository: ubergeek42/lambda-letsencrypt Branch: master Commit: 01e81577dabc Files: 18 Total size: 92.2 KB Directory structure: gitextract_17dpkt9h/ ├── .gitignore ├── LICENSE ├── Readme.md ├── Readme_S3.md ├── config.py.dist ├── docopt.py ├── installer/ │ ├── __init__.py │ ├── awslambda.py │ ├── cloudfront.py │ ├── elb.py │ ├── iam.py │ ├── iam_policy_template.json │ ├── route53.py │ ├── s3.py │ └── sns.py ├── lambda_function.py ├── simple_acme.py └── wizard.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store *.pyc venv/ # Don't include user config files config.py # Don't include any generated zip files *.zip ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 Keith Johnson 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. ================================================ FILE: Readme.md ================================================ # Lambda Lets-Encrypt Use [AWS Lambda](https://aws.amazon.com/lambda/) to manage SSL certificates for any site that uses [Amazon's CloudFront CDN](https://aws.amazon.com/cloudfront/). # Why do I want this? Rather than having to dedicate a machine to running the Lets-Encrypt client to maintain your certificate for your CloudFront distribution, you can let it all live on Amazon's infrastructure for cheap. You'll receive notification if anything goes wrong, and there's no hardware or virtual machines for you to manage. ## How do I use this? If you just want it to work and be done there is a wizard that will do all the work for you. Or if you're more of a power user and want to see what all is going on you can follow the steps to configure it manually. ### Automatic Wizard 1. Download this repo 2. Install the required dependency with `pip install boto3` 3. Save your AWS credentials: * install [`awscli`](https://aws.amazon.com/cli/) and run `aws configure`, **or** * manually create the file `~/.aws/credentials` with the following contents: ```ini [default] aws_access_key_id = YOUR_ACCESS_KEY aws_secret_access_key = YOUR_SECRET_KEY region = us-east-1 ; Replace with your region ``` 4. Run `python wizard.py` This will * ask you a few questions about your desired set up * create a configuration file * upload the lambda function for you * help you manually configure the lambda's daily scheduling (this can't be done automatically because there's no API yet) ### Manual Setup More docs coming soon. # How does it work? This works by running a Lambda function once per day which will check your certificate's expiration, and renew it if it is nearing expiration. Since Lambda is billed in 100ms increments and this only needs to run once a day for less than 10seconds each time the cost to run this is less than a penny per month(i.e. effectively free) ## But I only have a static S3 website, how do I use this? See the guide: [Configuring a static S3 website to use CloudFront](./Readme_S3.md) ## Reporting Bugs/Feature Requests The goal of this project is to make it as simple as possible for anyone to add encryption to their (cloudfront hosted) website. Anything that makes you uncertain should be [filed as an issue](https://github.com/ubergeek42/lambda-lets-encrypt/issues). ## Special Thanks I want to thank @diafygi for https://github.com/diafygi/acme-tiny, which I've borrowed some code for so as not to need any python-openssl dependencies(which isn't easily available in Lambda). ## Hacking ### Python Dependencies(for local development): * boto3 * python-dateutil ================================================ FILE: Readme_S3.md ================================================ # Configuring a static S3 website to use CloudFront To make your static website available over SSL you need to serve it through Amazon's CDN, CloudFront. This adds minimal cost and makes your site faster for your visitors as well. This document will detail how to set up your CloudFront Distribution. ![AWS S3 CloudFront Diagram](docs/images/s3-cloudfront.png) ## Create a CloudFront Distribution You need to create a CloudFront Distribution for your S3 website. Log in to the [AWS Console](http://console.aws.amazon.com) and then navigate to the [CloudFront management page](https://console.aws.amazon.com/cloudfront/home). Click on "Create a Distribution", then select "Web Distribution". Fill out the form paying attention to these fields: * `Origin Domain Name` - Make sure you use your S3 HTTP endpoint, e.g. `BUCKET_NAME.s3-website-us-east-1.amazonaws.com`. **NOTE**: Do *not* use the autocomplete dropdown to select your S3 bucket. If you do redirects or index documents will not work. * `Price Class` - You may choose to reduce your cost by only using Edge Locations in certain regions. Check the [CloudFront pricing page](https://aws.amazon.com/cloudfront/pricing/) for details. * `Alternate Domain Names(CNAMEs)` - Make sure you enter your real domain name here. * Leave the `SSL Certificate` setting alone. The Lambda function will take care of setting this appropriately. * **Default Root Object** - Set this to the page you want loaded when visiting your bare domain(e.g. `index.html`) * You may also want to adjust the cache settings to your liking, but this is all you need to get a working configuration Click on the "Create Distribution" button and then wait for the "State" field to change to "Deployed". ## Test that it works Try visiting the `CloudFront Domain Name` which has been assigned to your distribution, which will look something like `a1bcd123abc2.cloudfront.net`. Your static S3 site should load and you can verify it is working. ## Update your DNS settings to point to your new CloudFront Distribution Update your DNS settings to point to the `CloudFront Domain Name` your Distribution has been assigned(Same domain as the testing step). Basically you should be changing DNS to point to CloudFront instead of S3. ## Run the wizard Go back to the main docs and run the wizard to set up SSL for your new distribution with Lets-Encrypt. ================================================ FILE: config.py.dist ================================================ DIRECTORY_URL = 'https://acme-staging.api.letsencrypt.org' # DIRECTORY_URL = 'https://acme-v01.api.letsencrypt.org' # Number of bits to use for your Lets-Encrypt User Key # Leave alone if you don't know what this is USERKEY_BITS = 2048 # The AWS region your resources exist in AWS_REGION = 'us-east-1' # The SNS topic to send messages to(Set to None to disable) SNS_TOPIC_ARN = "$SNS_ARN" # S3 Bucket where we'll store the Lets-Encrypt user key and necessary files # These files will be stored in a subdomain S3CONFIGBUCKET = "$S3_CONFIG_BUCKET" # The number of bits for your certificate # Leave alone if you don't know what this is CERT_BITS = 2048 # The email you want to register with Lets-Encrypt # (Can be used for account recovery and things) EMAIL = "$NOTIFY_EMAIL" # The S3 Bucket to be used for Challenge Validation S3CHALLENGEBUCKET = "$S3_CHALLENGE_BUCKET" # This is the list of all domains you want to validate with Lets-Encrypt, as # well as the available validation methods DOMAINS = $DOMAINS # This is the list of CloudFront IDs and list of domains that will be present # on the ssl cert for the Distribution. SITES = $SITES ================================================ FILE: docopt.py ================================================ """Pythonic command-line interface parser that will make you smile. * http://docopt.org * Repository and issue-tracker: https://github.com/docopt/docopt * Licensed under terms of MIT license (see LICENSE-MIT) * Copyright (c) 2013 Vladimir Keleshev, vladimir@keleshev.com """ import sys import re __all__ = ['docopt'] __version__ = '0.6.1' class DocoptLanguageError(Exception): """Error in construction of usage-message by developer.""" class DocoptExit(SystemExit): """Exit in case user invoked program with incorrect arguments.""" usage = '' def __init__(self, message=''): SystemExit.__init__(self, (message + '\n' + self.usage).strip()) class Pattern(object): def __eq__(self, other): return repr(self) == repr(other) def __hash__(self): return hash(repr(self)) def fix(self): self.fix_identities() self.fix_repeating_arguments() return self def fix_identities(self, uniq=None): """Make pattern-tree tips point to same object if they are equal.""" if not hasattr(self, 'children'): return self uniq = list(set(self.flat())) if uniq is None else uniq for i, child in enumerate(self.children): if not hasattr(child, 'children'): assert child in uniq self.children[i] = uniq[uniq.index(child)] else: child.fix_identities(uniq) def fix_repeating_arguments(self): """Fix elements that should accumulate/increment values.""" either = [list(child.children) for child in transform(self).children] for case in either: for e in [child for child in case if case.count(child) > 1]: if type(e) is Argument or type(e) is Option and e.argcount: if e.value is None: e.value = [] elif type(e.value) is not list: e.value = e.value.split() if type(e) is Command or type(e) is Option and e.argcount == 0: e.value = 0 return self def transform(pattern): """Expand pattern into an (almost) equivalent one, but with single Either. Example: ((-a | -b) (-c | -d)) => (-a -c | -a -d | -b -c | -b -d) Quirks: [-a] => (-a), (-a...) => (-a -a) """ result = [] groups = [[pattern]] while groups: children = groups.pop(0) parents = [Required, Optional, OptionsShortcut, Either, OneOrMore] if any(t in map(type, children) for t in parents): child = [c for c in children if type(c) in parents][0] children.remove(child) if type(child) is Either: for c in child.children: groups.append([c] + children) elif type(child) is OneOrMore: groups.append(child.children * 2 + children) else: groups.append(child.children + children) else: result.append(children) return Either(*[Required(*e) for e in result]) class LeafPattern(Pattern): """Leaf/terminal node of a pattern tree.""" def __init__(self, name, value=None): self.name, self.value = name, value def __repr__(self): return '%s(%r, %r)' % (self.__class__.__name__, self.name, self.value) def flat(self, *types): return [self] if not types or type(self) in types else [] def match(self, left, collected=None): collected = [] if collected is None else collected pos, match = self.single_match(left) if match is None: return False, left, collected left_ = left[:pos] + left[pos + 1:] same_name = [a for a in collected if a.name == self.name] if type(self.value) in (int, list): if type(self.value) is int: increment = 1 else: increment = ([match.value] if type(match.value) is str else match.value) if not same_name: match.value = increment return True, left_, collected + [match] same_name[0].value += increment return True, left_, collected return True, left_, collected + [match] class BranchPattern(Pattern): """Branch/inner node of a pattern tree.""" def __init__(self, *children): self.children = list(children) def __repr__(self): return '%s(%s)' % (self.__class__.__name__, ', '.join(repr(a) for a in self.children)) def flat(self, *types): if type(self) in types: return [self] return sum([child.flat(*types) for child in self.children], []) class Argument(LeafPattern): def single_match(self, left): for n, pattern in enumerate(left): if type(pattern) is Argument: return n, Argument(self.name, pattern.value) return None, None @classmethod def parse(class_, source): name = re.findall('(<\S*?>)', source)[0] value = re.findall('\[default: (.*)\]', source, flags=re.I) return class_(name, value[0] if value else None) class Command(Argument): def __init__(self, name, value=False): self.name, self.value = name, value def single_match(self, left): for n, pattern in enumerate(left): if type(pattern) is Argument: if pattern.value == self.name: return n, Command(self.name, True) else: break return None, None class Option(LeafPattern): def __init__(self, short=None, long=None, argcount=0, value=False): assert argcount in (0, 1) self.short, self.long, self.argcount = short, long, argcount self.value = None if value is False and argcount else value @classmethod def parse(class_, option_description): short, long, argcount, value = None, None, 0, False options, _, description = option_description.strip().partition(' ') options = options.replace(',', ' ').replace('=', ' ') for s in options.split(): if s.startswith('--'): long = s elif s.startswith('-'): short = s else: argcount = 1 if argcount: matched = re.findall('\[default: (.*)\]', description, flags=re.I) value = matched[0] if matched else None return class_(short, long, argcount, value) def single_match(self, left): for n, pattern in enumerate(left): if self.name == pattern.name: return n, pattern return None, None @property def name(self): return self.long or self.short def __repr__(self): return 'Option(%r, %r, %r, %r)' % (self.short, self.long, self.argcount, self.value) class Required(BranchPattern): def match(self, left, collected=None): collected = [] if collected is None else collected l = left c = collected for pattern in self.children: matched, l, c = pattern.match(l, c) if not matched: return False, left, collected return True, l, c class Optional(BranchPattern): def match(self, left, collected=None): collected = [] if collected is None else collected for pattern in self.children: m, left, collected = pattern.match(left, collected) return True, left, collected class OptionsShortcut(Optional): """Marker/placeholder for [options] shortcut.""" class OneOrMore(BranchPattern): def match(self, left, collected=None): assert len(self.children) == 1 collected = [] if collected is None else collected l = left c = collected l_ = None matched = True times = 0 while matched: # could it be that something didn't match but changed l or c? matched, l, c = self.children[0].match(l, c) times += 1 if matched else 0 if l_ == l: break l_ = l if times >= 1: return True, l, c return False, left, collected class Either(BranchPattern): def match(self, left, collected=None): collected = [] if collected is None else collected outcomes = [] for pattern in self.children: matched, _, _ = outcome = pattern.match(left, collected) if matched: outcomes.append(outcome) if outcomes: return min(outcomes, key=lambda outcome: len(outcome[1])) return False, left, collected class Tokens(list): def __init__(self, source, error=DocoptExit): self += source.split() if hasattr(source, 'split') else source self.error = error @staticmethod def from_pattern(source): source = re.sub(r'([\[\]\(\)\|]|\.\.\.)', r' \1 ', source) source = [s for s in re.split('\s+|(\S*<.*?>)', source) if s] return Tokens(source, error=DocoptLanguageError) def move(self): return self.pop(0) if len(self) else None def current(self): return self[0] if len(self) else None def parse_long(tokens, options): """long ::= '--' chars [ ( ' ' | '=' ) chars ] ;""" long, eq, value = tokens.move().partition('=') assert long.startswith('--') value = None if eq == value == '' else value similar = [o for o in options if o.long == long] if tokens.error is DocoptExit and similar == []: # if no exact match similar = [o for o in options if o.long and o.long.startswith(long)] if len(similar) > 1: # might be simply specified ambiguously 2+ times? raise tokens.error('%s is not a unique prefix: %s?' % (long, ', '.join(o.long for o in similar))) elif len(similar) < 1: argcount = 1 if eq == '=' else 0 o = Option(None, long, argcount) options.append(o) if tokens.error is DocoptExit: o = Option(None, long, argcount, value if argcount else True) else: o = Option(similar[0].short, similar[0].long, similar[0].argcount, similar[0].value) if o.argcount == 0: if value is not None: raise tokens.error('%s must not have an argument' % o.long) else: if value is None: if tokens.current() in [None, '--']: raise tokens.error('%s requires argument' % o.long) value = tokens.move() if tokens.error is DocoptExit: o.value = value if value is not None else True return [o] def parse_shorts(tokens, options): """shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;""" token = tokens.move() assert token.startswith('-') and not token.startswith('--') left = token.lstrip('-') parsed = [] while left != '': short, left = '-' + left[0], left[1:] similar = [o for o in options if o.short == short] if len(similar) > 1: raise tokens.error('%s is specified ambiguously %d times' % (short, len(similar))) elif len(similar) < 1: o = Option(short, None, 0) options.append(o) if tokens.error is DocoptExit: o = Option(short, None, 0, True) else: # why copying is necessary here? o = Option(short, similar[0].long, similar[0].argcount, similar[0].value) value = None if o.argcount != 0: if left == '': if tokens.current() in [None, '--']: raise tokens.error('%s requires argument' % short) value = tokens.move() else: value = left left = '' if tokens.error is DocoptExit: o.value = value if value is not None else True parsed.append(o) return parsed def parse_pattern(source, options): tokens = Tokens.from_pattern(source) result = parse_expr(tokens, options) if tokens.current() is not None: raise tokens.error('unexpected ending: %r' % ' '.join(tokens)) return Required(*result) def parse_expr(tokens, options): """expr ::= seq ( '|' seq )* ;""" seq = parse_seq(tokens, options) if tokens.current() != '|': return seq result = [Required(*seq)] if len(seq) > 1 else seq while tokens.current() == '|': tokens.move() seq = parse_seq(tokens, options) result += [Required(*seq)] if len(seq) > 1 else seq return [Either(*result)] if len(result) > 1 else result def parse_seq(tokens, options): """seq ::= ( atom [ '...' ] )* ;""" result = [] while tokens.current() not in [None, ']', ')', '|']: atom = parse_atom(tokens, options) if tokens.current() == '...': atom = [OneOrMore(*atom)] tokens.move() result += atom return result def parse_atom(tokens, options): """atom ::= '(' expr ')' | '[' expr ']' | 'options' | long | shorts | argument | command ; """ token = tokens.current() result = [] if token in '([': tokens.move() matching, pattern = {'(': [')', Required], '[': [']', Optional]}[token] result = pattern(*parse_expr(tokens, options)) if tokens.move() != matching: raise tokens.error("unmatched '%s'" % token) return [result] elif token == 'options': tokens.move() return [OptionsShortcut()] elif token.startswith('--') and token != '--': return parse_long(tokens, options) elif token.startswith('-') and token not in ('-', '--'): return parse_shorts(tokens, options) elif token.startswith('<') and token.endswith('>') or token.isupper(): return [Argument(tokens.move())] else: return [Command(tokens.move())] def parse_argv(tokens, options, options_first=False): """Parse command-line argument vector. If options_first: argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ; else: argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ; """ parsed = [] while tokens.current() is not None: if tokens.current() == '--': return parsed + [Argument(None, v) for v in tokens] elif tokens.current().startswith('--'): parsed += parse_long(tokens, options) elif tokens.current().startswith('-') and tokens.current() != '-': parsed += parse_shorts(tokens, options) elif options_first: return parsed + [Argument(None, v) for v in tokens] else: parsed.append(Argument(None, tokens.move())) return parsed def parse_defaults(doc): defaults = [] for s in parse_section('options:', doc): # FIXME corner case "bla: options: --foo" _, _, s = s.partition(':') # get rid of "options:" split = re.split('\n[ \t]*(-\S+?)', '\n' + s)[1:] split = [s1 + s2 for s1, s2 in zip(split[::2], split[1::2])] options = [Option.parse(s) for s in split if s.startswith('-')] defaults += options return defaults def parse_section(name, source): pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', re.IGNORECASE | re.MULTILINE) return [s.strip() for s in pattern.findall(source)] def formal_usage(section): _, _, section = section.partition(':') # drop "usage:" pu = section.split() return '( ' + ' '.join(') | (' if s == pu[0] else s for s in pu[1:]) + ' )' def extras(help, version, options, doc): if help and any((o.name in ('-h', '--help')) and o.value for o in options): print(doc.strip("\n")) sys.exit() if version and any(o.name == '--version' and o.value for o in options): print(version) sys.exit() class Dict(dict): def __repr__(self): return '{%s}' % ',\n '.join('%r: %r' % i for i in sorted(self.items())) def docopt(doc, argv=None, help=True, version=None, options_first=False): """Parse `argv` based on command-line interface described in `doc`. `docopt` creates your command-line interface based on its description that you pass as `doc`. Such description can contain --options, , commands, which could be [optional], (required), (mutually | exclusive) or repeated... Parameters ---------- doc : str Description of your command-line interface. argv : list of str, optional Argument vector to be parsed. sys.argv[1:] is used if not provided. help : bool (default: True) Set to False to disable automatic help on -h or --help options. version : any object If passed, the object will be printed if --version is in `argv`. options_first : bool (default: False) Set to True to require options precede positional arguments, i.e. to forbid options and positional arguments intermix. Returns ------- args : dict A dictionary, where keys are names of command-line elements such as e.g. "--verbose" and "", and values are the parsed values of those elements. Example ------- >>> from docopt import docopt >>> doc = ''' ... Usage: ... my_program tcp [--timeout=] ... my_program serial [--baud=] [--timeout=] ... my_program (-h | --help | --version) ... ... Options: ... -h, --help Show this screen and exit. ... --baud= Baudrate [default: 9600] ... ''' >>> argv = ['tcp', '127.0.0.1', '80', '--timeout', '30'] >>> docopt(doc, argv) {'--baud': '9600', '--help': False, '--timeout': '30', '--version': False, '': '127.0.0.1', '': '80', 'serial': False, 'tcp': True} See also -------- * For video introduction see http://docopt.org * Full documentation is available in README.rst as well as online at https://github.com/docopt/docopt#readme """ argv = sys.argv[1:] if argv is None else argv usage_sections = parse_section('usage:', doc) if len(usage_sections) == 0: raise DocoptLanguageError('"usage:" (case-insensitive) not found.') if len(usage_sections) > 1: raise DocoptLanguageError('More than one "usage:" (case-insensitive).') DocoptExit.usage = usage_sections[0] options = parse_defaults(doc) pattern = parse_pattern(formal_usage(DocoptExit.usage), options) # [default] syntax for argument is disabled #for a in pattern.flat(Argument): # same_name = [d for d in arguments if d.name == a.name] # if same_name: # a.value = same_name[0].value argv = parse_argv(Tokens(argv), list(options), options_first) pattern_options = set(pattern.flat(Option)) for options_shortcut in pattern.flat(OptionsShortcut): doc_options = parse_defaults(doc) options_shortcut.children = list(set(doc_options) - pattern_options) #if any_options: # options_shortcut.children += [Option(o.short, o.long, o.argcount) # for o in argv if type(o) is Option] extras(help, version, argv, doc) matched, left, collected = pattern.fix().match(argv) if matched and left == []: # better error message if left? return Dict((a.name, a.value) for a in (pattern.flat() + collected)) raise DocoptExit() ================================================ FILE: installer/__init__.py ================================================ ================================================ FILE: installer/awslambda.py ================================================ import boto3 from botocore.exceptions import ClientError lambda_c = boto3.client('lambda') def create_function(name, iam_role, archive, handler='lambda_function.lambda_handler'): with open(archive, 'rb') as f: contents = f.read() try: lambda_c.create_function( FunctionName=name, Runtime='python2.7', Role=iam_role, Handler=handler, Code={ 'ZipFile': contents }, Description='Lambda Function for AWS Lets-Encrypt', Timeout=30, MemorySize=128, Publish=True ) except Exception as e: print(e) return False return True def list_distributions(): dl = cloudfront_c.list_distributions() ret = [] for dist in dl['DistributionList']['Items']: ret.append({ 'Id': dist['Id'], 'Comment': dist['Comment'], 'Aliases': dist['Aliases'].get('Items', []) }) return ret ================================================ FILE: installer/cloudfront.py ================================================ import boto3 from botocore.exceptions import ClientError cloudfront_c = boto3.client('cloudfront') def list_distributions(): dl = cloudfront_c.list_distributions() ret = [] if 'Items' not in dl['DistributionList']: return ret for dist in dl['DistributionList']['Items']: ret.append({ 'Id': dist['Id'], 'Comment': dist['Comment'], 'Aliases': dist['Aliases'].get('Items', []) }) return ret ================================================ FILE: installer/elb.py ================================================ import boto3 from botocore.exceptions import ClientError elb_c = boto3.client('elb') def list_elbs(): elbs = elb_c.describe_load_balancers() ret = [] for x in elbs['LoadBalancerDescriptions']: ret.append(x['LoadBalancerName']) return ret ================================================ FILE: installer/iam.py ================================================ import boto3 from botocore.exceptions import ClientError import json iam_c = boto3.client('iam') iam_r = boto3.resource('iam') def generate_policy_document(s3buckets=None, snstopicarn=None): policy_template = None with open('installer/iam_policy_template.json', 'r') as policy_file: policy_template = json.loads(policy_file.read()) bucketresources = [] for bucket in s3buckets: bucketresources.append("arn:aws:s3:::{}".format(bucket)) bucketresources.append("arn:aws:s3:::{}/*".format(bucket)) policy_template['Statement'][3]['Resource'] = bucketresources if snstopicarn: policy_template['Statement'][4]['Resource'] = [snstopicarn] else: # don't need sns statement if there's no topic del policy_template['Statement'][4] return json.dumps(policy_template, indent=4) def get_or_create_role(role_name): lambda_assume_role_policy_document = """{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }""" create_role = False role = iam_r.Role(role_name) try: role.load() except ClientError as e: if e.response['Error']['Code'] == 'NoSuchEntity': print("Role doesn't exist, attempting to create") create_role = True else: print("Some other error occurred checking for the role, please review the error message below") print(e) if create_role: # create the role here try: print("Creating Role '{}'".format(role_name)) role = iam_r.create_role( Path="/lambda-letsencrypt/", RoleName=role_name, AssumeRolePolicyDocument=lambda_assume_role_policy_document ) print("Role Created") except ClientError as e: print("Error creating role") print(e) return None return role def get_or_create_role_policy(role, policy_name, policy_document): create_role_policy = False role_policy = iam_r.RolePolicy(role.role_name, policy_name) try: role_policy.load() except ClientError as e: if e.response['Error']['Code'] == 'NoSuchEntity': print("Role policy doesn't exist, attempting to create") create_role_policy = True else: print("Some other error occurred checking for the role policy, please review the error message below") print(e) if create_role_policy: iam_c.put_role_policy( RoleName=role.role_name, PolicyName=policy_name, PolicyDocument=policy_document ) role_policy = iam_r.RolePolicy(role.role_name, policy_name) role_policy.load() return role_policy def update_role_policy(role_policy, policy_document): if role_policy.policy_document != policy_document: try: role_policy.put( PolicyDocument=policy_document ) return True except ClientError as e: print("An error occurred while updating the policy document") print(e) return False def configure(role_name, policy_document): policy_name = "lambda-letsencrypt-policy" role = get_or_create_role(role_name) role_policy = get_or_create_role_policy(role, policy_name, policy_document) update_role_policy(role_policy, policy_document) return role.arn def get_arn(role_name): role = iam_r.Role(role_name) try: role.load() except ClientError as e: return None return role.arn def list_roles(): roles = iam_c.list_roles() ret = [] for x in roles['Roles']: ret.append(x['RoleName']) return ret ================================================ FILE: installer/iam_policy_template.json ================================================ { "Version": "2012-10-17", "Statement": [ { "Sid": "lambdalogs", "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:*:*:*" }, { "Sid": "cloudfrontconfig", "Effect": "Allow", "Action": [ "cloudfront:GetDistributionConfig", "cloudfront:UpdateDistribution" ], "Resource": [ "*" ] }, { "Sid": "iamcert", "Effect": "Allow", "Action": [ "iam:DeleteServerCertificate", "iam:GetServerCertificate", "iam:ListServerCertificates", "iam:UpdateServerCertificate", "iam:UploadServerCertificate" ], "Resource": [ "*" ] }, { "Sid": "s3bucketpermissions", "Effect": "Allow", "Action": [ "s3:GetObject", "s3:GetObjectAcl", "s3:PutObject", "s3:PutObjectAcl", "s3:ListBucket", "s3:DeleteObject" ], "Resource": [] }, { "Sid": "snspublish", "Effect": "Allow", "Action": [ "sns:Publish" ], "Resource": [] } ] } ================================================ FILE: installer/route53.py ================================================ import boto3 from botocore.exceptions import ClientError route53_c = boto3.client('route53') def list_zones(): elbs = route53_c.list_hosted_zones() ret = [] for x in elbs['HostedZones']: ret.append({ 'Id': x['Id'], 'Name': x['Name'].rstrip('.') # remove trailing dots }) return ret def get_zone_id(zone): zone = zone.rstrip(".") # remove any possible trailing dots zones = list_zones() return next((z['Id'] for z in zones if z['Name'] == zone), None) ================================================ FILE: installer/s3.py ================================================ import boto3 from botocore.exceptions import ClientError import string s3_c = boto3.client('s3') s3_r = boto3.resource('s3') WEB_POLICY_DOC = string.Template("""\ { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "$arn/*" } ] } """) def s3_list_buckets(): buckets = s3_c.list_buckets() ret = [] for x in buckets['Buckets']: ret.append(x['Name']) return ret def create_bucket(bucket_name): bucket = s3_r.create_bucket( Bucket=bucket_name, ACL="private" ) return bucket def create_web_bucket(bucket_name): bucket = create_bucket(bucket_name) bucket_policy = bucket.Policy() bucket_arn = "arn:aws:s3:::{}".format(bucket_name) bucket_policy.put(Policy=WEB_POLICY_DOC.substitute(arn=bucket_arn)) webconfig = bucket.Website() webconfig.put( WebsiteConfiguration={ 'ErrorDocument': {'Key': '404.html'}, 'IndexDocument': {'Suffix': 'index.html'}, } ) return bucket ================================================ FILE: installer/sns.py ================================================ import boto3 from botocore.exceptions import ClientError def get_or_create_topic(email): topicname = "letsencrypt-lambda-notify" sns_r = boto3.resource('sns') sns_c = boto3.client('sns') # If the topic doesn't exist, this will create it, otherwise it returns # the existing topic. topic = sns_c.create_topic(Name=topicname) topic_arn = topic['TopicArn'] # subscribe the email to the topic sns_c.subscribe( TopicArn=topic_arn, Protocol='email', Endpoint=email ) return topic_arn ================================================ FILE: lambda_function.py ================================================ from __future__ import print_function import logging import datetime from time import strftime, gmtime, sleep from dateutil.tz import tzutc from simple_acme import AcmeUser, AcmeAuthorization, AcmeCert from functools import partial import urllib2 # aws imports import boto3 import botocore # Configure logging logging.basicConfig(level=logging.ERROR) logger = logging.getLogger("Lambda-LetsEncrypt") logger.setLevel(logging.DEBUG) import config as cfg ############################################################################### # No need to edit beyond this line ############################################################################### # Global Variables and AWS Resources s3 = boto3.resource('s3', region_name=cfg.AWS_REGION) cloudfront = boto3.client('cloudfront', region_name=cfg.AWS_REGION) iam = boto3.client('iam', region_name=cfg.AWS_REGION) sns = boto3.client('sns', region_name=cfg.AWS_REGION) elb = boto3.client('elb', region_name=cfg.AWS_REGION) route53 = boto3.client('route53', region_name=cfg.AWS_REGION) # Internal files to store user/authorization information USERFILE = 'letsencrypt_user.json' AUTHZRFILE = 'letsencrypt_authzr.json' # Functions for storing/retrieving/deleting files from our config bucket def save_file(site_id, filename, content): s3.Object(cfg.S3CONFIGBUCKET, site_id + "/" + filename).put(Body=content) def load_file(directory, filename): try: obj = s3.Object(cfg.S3CONFIGBUCKET, directory + "/" + filename).get() return obj['Body'].read() except botocore.exceptions.ClientError as e: if e.response['Error']['Code'] == 'NoSuchKey': return False return False # Verify the bucket exists def check_bucket(bucketname): try: s3.meta.client.head_bucket(Bucket=bucketname) exists = True except botocore.exceptions.ClientError as e: error_code = int(e.response['Error']['Code']) if error_code == 404: exists = False # TODO: handle other errors better exists = False return exists def get_user(): # Generate a user key to use with letsencrypt userfile = load_file('letsencrypt', USERFILE) user = None if userfile is not False: logger.info("User key exists, loading...") user = AcmeUser.unserialize(userfile) user.register(cfg.EMAIL) else: logger.info("Creating user and key") user = AcmeUser(keybits=cfg.USERKEY_BITS) user.create_key() user.register(cfg.EMAIL) save_file('letsencrypt', USERFILE, user.serialize()) return user def notify_email(subject, message): if cfg.SNS_TOPIC_ARN: logger.info("Sending notification") sns.publish( TopicArn=cfg.SNS_TOPIC_ARN, Subject="[Lambda-LetsEncrypt] {}".format(subject), Message=message ) def s3_challenge_solver(domain, token, keyauth, bucket=None, prefix=None): # logger.info("Writing file {} with content '{}.{}' for domain '{}'".format(token, token, keyauth, domain)) logger.info("Got prefix {}".format(prefix)) filename = "{}/.well-known/acme-challenge/{}".format(prefix, token) logger.info("Writing {} into S3 Bucket {}".format(filename, bucket)) expires = datetime.datetime.now() + datetime.timedelta(days=3) s3.Object(bucket, filename).put( Body=keyauth, Expires=expires ) return True def http_challenge_verifier(domain, token, keyauth): url = "http://{}/.well-known/acme-challenge/{}".format(domain, token) try: response = urllib2.urlopen(url) contents = response.read() code = response.getcode() except Exception as e: logger.warn("Failed to verify:") logger.warn(e) return False if code != 200: logger.warn("HTTP code {} returned, expected 200".format(code)) return False if contents != keyauth: logger.warn("Validation body didn't match, expected '{}', got '{}'".format(keyauth, contents)) return False return True def route53_challenge_solver(domain, token, keyauth, zoneid=None): route53.change_resource_record_sets( HostedZoneId=zoneid, ChangeBatch={ 'Comment': "Lamdba LetsEncrypt DNS Challenge Response", 'Changes': [{ 'Action': 'UPSERT', 'ResourceRecordSet': { 'Name': '_acme-challenge.{}'.format(domain), 'Type': 'TXT', 'TTL': 300, 'ResourceRecords': [{ 'Value': '"{}"'.format(keyauth) }] } }] } ) return True def route53_challenge_verifier(domain, token, keyauth): # TODO: this isn't implemented yet. # XXX: DNS propagation may make this somewhat time consuming. # try to resolve record '_acme-challenge.domain' and verify that the txt record value matches 'keyauth' pass def authorize_domain(user, domain): authzrfilename = 'authzr-{}.json'.format(domain) authzrfile = load_file(domain['DOMAIN'], authzrfilename) if authzrfile is not False: authzr = AcmeAuthorization.unserialize(user, authzrfile) else: authzr = AcmeAuthorization(user=user, domain=domain['DOMAIN']) status = authzr.authorize() # save the (new/updated) authorization response save_file(domain['DOMAIN'], authzrfilename, authzr.serialize()) logger.debug(authzr.serialize()) # see if we're done if status == 'pending': if 'http-01' in domain['VALIDATION_METHODS']: logger.info("Attempting challenge 'http-01'") authzr.complete_challenges( "http-01", partial(s3_challenge_solver, bucket=cfg.S3CHALLENGEBUCKET, prefix=domain['CLOUDFRONT_ID']), http_challenge_verifier ) if 'dns-01' in domain['VALIDATION_METHODS']: logger.info("Attempting challenge 'dns-01'") authzr.complete_challenges( "dns-01", partial(route53_challenge_solver, zoneid=domain['ROUTE53_ZONE_ID']), route53_challenge_verifier ) logger.info("Waiting for challenge to be confirmed for '{}'".format(domain['DOMAIN'])) return False elif status == 'valid': logger.info("Got domain authorization for: {}".format(domain['DOMAIN'])) return authzr else: # probably failed the challenge logger.warn("Some error happend with authz request for '{}'(review above messages)".format(domain['DOMAIN'])) logger.warn("Will retry again next time this runs") return False def iam_upload_cert(certname, cert, key, chain): # upload new cert try: newcert = iam.upload_server_certificate( Path="/cloudfront/", ServerCertificateName=certname, CertificateBody=cert, PrivateKey=key, CertificateChain=chain ) cert_id = newcert['ServerCertificateMetadata']['ServerCertificateId'] cert_arn = newcert['ServerCertificateMetadata']['Arn'] logger.info("Uploaded cert '{}' ({})".format(certname, cert_id)) return cert_id, cert_arn except botocore.exceptions.ClientError as e: logger.error("Error uploading iam cert:") logger.error(e) return False def iam_delete_cert(arn=None, cert_id=None): oldcert_name = None allcerts = iam.list_server_certificates( PathPrefix="/cloudfront/" ) for c in allcerts['ServerCertificateMetadataList']: if c['ServerCertificateId'] == cert_id or c['Arn'] == arn: oldcert_name = c['ServerCertificateName'] break if not oldcert_name: logger.warn('Unable to find old certificate to delete') return logger.info('Deleting old certificate {}'.format(oldcert_name)) retries = 5 while retries > 0: try: iam.delete_server_certificate(ServerCertificateName=oldcert_name) return except botocore.exceptions.ClientError as e: # we only retry if it said cert deleteconflict since it may take a few moments # for something to stop using the certificate(e.g. elb) if e.response['Error']['Code'] == 'DeleteConflict': logger.info("Cert in use while trying to delete, retrying...") sleep(5) continue logger.error("Unknown error occurred while deleting certificate") logger.error(e) notify_email( "Unable to delete certificate", """Lambda-LetsEncrypt failed to delete the certificate '{}'. You should manually do this yourself""".format(oldcert_name) ) break def iam_check_expiration(arn=None, cert_id=None): allcerts = iam.list_server_certificates(PathPrefix="/cloudfront/") expiration = None cert = None for c in allcerts['ServerCertificateMetadataList']: if c['ServerCertificateId'] == cert_id or c['Arn'] == arn: cert = c break if not cert: # no expiration found? return True expiration = cert['Expiration'] time_left = expiration - datetime.datetime.now(tz=tzutc()) if time_left.days < 10: logger.warn("Only {} days left on cert {}!".format(time_left.days, cert['ServerCertificateName'])) notify_email( 'Less than 10 days left on cert {}'.format(cert['ServerCertificateName']), """ There's less than 10 days left on your certificate for {}. This probably means the lambda function that is supposed to be handling the renewal is failing. Please check the logs for it. Attempting to renew now. """.format(cert['ServerCertificateName']) ) return True elif time_left.days < 30: logger.info("Only {} days remaining, will proceed with renewal for {}".format(time_left.days, cert['ServerCertificateName'])) return True else: logger.info("{} days remaining on cert, nothing to do for {}.".format(time_left.days, cert['ServerCertificateName'])) return False def is_elb_cert_expiring(site): return True try: load_balancers = elb.describe_load_balancers( LoadBalancerNames=[site['ELB_NAME']], ) except botocore.exceptions.ClientError as e: logger.error("Error getting information about Elastic Load Balancer '{}'".format(site['ELB_NAME'])) logger.error(e) return False currentcert_arn = None for lb in load_balancers['LoadBalancerDescriptions']: if lb['LoadBalancerName'] != site['ELB_NAME']: continue for listener in lb['ListenerDescriptions']: if listener['Listener']['LoadBalancerPort'] != site['ELB_PORT']: continue if 'SSLCertificateId' in listener['Listener']: currentcert_arn = listener['Listener']['SSLCertificateId'] if currentcert_arn is None: logger.info("No certificate exists for elb name {}".format(site['ELB_NAME'])) return iam_check_expiration(arn=currentcert_arn) def is_cf_cert_expiring(site): cf_config = cloudfront.get_distribution_config(Id=site['CLOUDFRONT_ID']) currentcert = cf_config['DistributionConfig']['ViewerCertificate'].get('IAMCertificateId', None) if currentcert is None: logger.info("No certificate exists for {}".format(site['CLOUDFRONT_ID'])) return True return iam_check_expiration(cert_id=currentcert) def is_domain_expiring(site): if 'CLOUDFRONT_ID' in site: return is_cf_cert_expiring(site) if 'ELB_NAME' in site: return is_elb_cert_expiring(site) logger.error("Can't detect site type(ELB or CLOUDFRONT)") return False def configure_cert(site, cert, key, chain): certname = "{}_{}".format(site_id(site), strftime("%Y%m%d_%H%M%S", gmtime())) cert_id, cert_arn = iam_upload_cert(certname, cert, key, chain) f = None if 'CLOUDFRONT_ID' in site: f = cloudfront_configure_cert if 'ELB_NAME' in site: f = elb_configure_cert if f is None: logger.error("Can't detect site type when configuring certificate(ELB or CLOUDFRONT)") ret = False retries = 5 while retries > 0: retries -= 1 try: ret = f(site, cert_id, cert_arn) break except botocore.exceptions.ClientError as e: # we only retry if it said cert not found if e.response['Error']['Code'] == 'CertificateNotFound': logger.info("Cert not found when trying to configure ELB, retrying...") sleep(5) continue logger.error("Unknown error occurred while updating certificate") logger.error(e) ret = False break return ret def elb_configure_cert(site, cert_id, cert_arn): # get the current certificate for the load balancer(if there is one) load_balancers = elb.describe_load_balancers( LoadBalancerNames=[site['ELB_NAME']], ) oldcert_arn = None for lb in load_balancers['LoadBalancerDescriptions']: if lb['LoadBalancerName'] != site['ELB_NAME']: continue for listener in lb['ListenerDescriptions']: if listener['Listener']['LoadBalancerPort'] != site['ELB_PORT']: continue if 'SSLCertificateId' in listener['Listener']: oldcert_arn = listener['Listener']['SSLCertificateId'] # if there wasn't an old cert, we need to configure the elb for HTTPS if oldcert_arn is None: logger.info("No listener exists for specified port, creating default") # create a load balancer policy logger.debug("Creating load balancer policy") elb.create_load_balancer_policy( LoadBalancerName=site['ELB_NAME'], PolicyName="lambda-letsencrypt-default-ssl-policy", PolicyTypeName="SSLNegotiationPolicyType", PolicyAttributes=[{ 'AttributeName': 'Reference-Security-Policy', 'AttributeValue': 'ELBSecurityPolicy-2015-05' }] ) # create a load balancer listener logger.debug("Creating load balancer listener") elb.create_load_balancer_listeners( LoadBalancerName=site['ELB_NAME'], Listeners=[{ 'Protocol': 'HTTPS', 'LoadBalancerPort': site['ELB_PORT'], 'InstanceProtocol': 'HTTP', 'InstancePort': 80, 'SSLCertificateId': cert_arn }] ) # associate policy with the listener logger.debug("Setting load balancer listener policy") elb.set_load_balancer_policies_of_listener( LoadBalancerName=site['ELB_NAME'], LoadBalancerPort=site['ELB_PORT'], PolicyNames=['lambda-letsencrypt-default-ssl-policy'] ) # Set up the new certificate elb.set_load_balancer_listener_ssl_certificate( LoadBalancerName=site['ELB_NAME'], LoadBalancerPort=site['ELB_PORT'], SSLCertificateId=cert_arn ) # Delete the old certificate if it existed if oldcert_arn: iam_delete_cert(arn=oldcert_arn) return True def cloudfront_configure_cert(site, cert_id, cert_arn): # get current cloudfront distribution settings cf_config = cloudfront.get_distribution_config(Id=site['CLOUDFRONT_ID']) oldcert_id = cf_config['DistributionConfig']['ViewerCertificate'].get('IAMCertificateId', None) # Make sure the default cloudfront cert isn't being used if 'CloudFrontDefaultCertificate' in cf_config['DistributionConfig']['ViewerCertificate']: del cf_config['DistributionConfig']['ViewerCertificate']['CloudFrontDefaultCertificate'] # update it to point to the new cert cf_config['DistributionConfig']['ViewerCertificate']['IAMCertificateId'] = cert_id cf_config['DistributionConfig']['ViewerCertificate']['Certificate'] = cert_id cf_config['DistributionConfig']['ViewerCertificate']['CertificateSource'] = 'iam' # make sure we use SNI only(otherwise the bill can be quite large, $600/month or so) cf_config['DistributionConfig']['ViewerCertificate']['MinimumProtocolVersion'] = 'TLSv1' cf_config['DistributionConfig']['ViewerCertificate']['SSLSupportMethod'] = 'sni-only' # actually update the distribution cloudfront.update_distribution( DistributionConfig=cf_config['DistributionConfig'], Id=site['CLOUDFRONT_ID'], IfMatch=cf_config['ETag'] ) # delete the old cert iam_delete_cert(cert_id=oldcert_id) return True def configure_cloudfront(domain, s3bucket): cf_config = cloudfront.get_distribution_config(Id=domain['CLOUDFRONT_ID']) changed = False # make sure we have the origin configured origins = cf_config['DistributionConfig']['Origins']['Items'] # check for the right origin challenge_origin = [x for x in origins if x['Id'] == 'lambda-letsencrypt-challenges'] if not challenge_origin: changed = True quantity = cf_config['DistributionConfig']['Origins'].get('Quantity', 0) cf_config['DistributionConfig']['Origins']['Quantity'] = quantity + 1 cf_config['DistributionConfig']['Origins']['Items'].append({ 'DomainName': '{}.s3.amazonaws.com'.format(s3bucket), 'Id': 'lambda-letsencrypt-challenges', 'OriginPath': "/{}".format(domain['CLOUDFRONT_ID']), 'S3OriginConfig': {u'OriginAccessIdentity': ''} }) # now check for the behavior rule behaviors = cf_config['DistributionConfig']['CacheBehaviors'].get('Items', []) challenge_behavior = [x for x in behaviors if x['PathPattern'] == '/.well-known/acme-challenge/*'] if not challenge_behavior: changed = True if 'Items' not in cf_config['DistributionConfig']['CacheBehaviors']: cf_config['DistributionConfig']['CacheBehaviors']['Items'] = [] cf_config['DistributionConfig']['CacheBehaviors']['Items'].append({ 'AllowedMethods': { 'CachedMethods': { 'Items': ['HEAD', 'GET'], 'Quantity': 2 }, 'Items': ['HEAD', 'GET'], 'Quantity': 2 }, 'DefaultTTL': 86400, 'ForwardedValues': { u'Cookies': {u'Forward': 'none'}, 'Headers': {'Quantity': 0}, 'QueryString': False }, 'MaxTTL': 31536000, 'MinTTL': 0, 'PathPattern': '/.well-known/acme-challenge/*', 'SmoothStreaming': False, 'TargetOriginId': 'lambda-letsencrypt-challenges', 'TrustedSigners': {u'Enabled': False, 'Quantity': 0}, 'ViewerProtocolPolicy': 'allow-all', 'Compress': False }) quantity = cf_config['DistributionConfig']['CacheBehaviors'].get('Quantity', 0) cf_config['DistributionConfig']['CacheBehaviors']['Quantity'] = quantity + 1 # make sure we use SNI and not dedicated IP($600/month) #ssl_method = cf_config['DistributionConfig']['ViewerCertificate'].get('SSLSupportMethod', None) #if ssl_method != 'sni-only': # changed = True # cf_config['DistributionConfig']['ViewerCertificate']['MinimumProtocolVersion'] = 'TLSv1' # cf_config['DistributionConfig']['ViewerCertificate']['SSLSupportMethod'] = 'sni-only' if changed: logger.info("Updating cloudfront distribution with additional origin for challenges") # now save that config back try: cloudfront.update_distribution( DistributionConfig=cf_config['DistributionConfig'], Id=domain['CLOUDFRONT_ID'], IfMatch=cf_config['ETag'] ) except Exception as e: logger.error("Error updating cloudfront distribution") logger.error(e) def site_name(site): if 'CLOUDFRONT_ID' in site: return "CloudFront Distribution '{}'".format(site['CLOUDFRONT_ID']) elif 'ELB_NAME' in site: return "ELB Name '{}'".format(site['ELB_NAME']) def site_id(site): if 'CLOUDFRONT_ID' in site: return "cfd-{}".format(site['CLOUDFRONT_ID']) elif 'ELB_NAME' in site: return 'elb-{}'.format(site['ELB_NAME']) def lambda_handler(event, context): action_needed = False # Do a few sanity checks if not check_bucket(cfg.S3CONFIGBUCKET): logger.error("S3 configuration bucket does not exist") # TODO: maybe send email? return False if not check_bucket(cfg.S3CHALLENGEBUCKET): logger.error("S3 challenge bucket does not exist") # TODO: maybe send email? return False # check the certificates we want issued for site in cfg.SITES: if not is_domain_expiring(site): site['skip'] = True continue action_needed = True # quit if there's nothing to do if not action_needed: return False # get our user key to use with lets-encrypt user = get_user() # validate domains my_domains = [] for domain in cfg.DOMAINS: # make sure cloudfront is configured properly for http-01 challenge validation if 'http-01' in domain['VALIDATION_METHODS']: configure_cloudfront(domain, cfg.S3CHALLENGEBUCKET) authzr = authorize_domain(user, domain) if authzr: my_domains.append(domain['DOMAIN']) for site in cfg.SITES: if 'skip' in site: continue # check that we are authed for all the domains for this site if not set(site['DOMAINS']).issubset(my_domains): logger.info("Can't get cert for {}, still waiting on domain authorizations".format(site_name(site))) continue try: # Now that we're authorized to get certs for the domain(s), lets generate # a private key and a csr, then use them to get a certificate logger.info("Generate CSR and get cert for {}".format(site_name(site))) pkey, csr = AcmeCert.generate_csr(cfg.CERT_BITS, site['DOMAINS']) cert, cert_chain = AcmeCert.get_cert(user, csr) # With our certificate in hand we can update the site configuration ret = configure_cert(site, cert, pkey, cert_chain) if ret: notify_email("Certificate issued", "The certificate for {} has been successfully updated".format(site_name(site))) else: notify_email("Error issuing cert", "There was some sort of error configuring the site({}) with the certificate.".format(site_name(site)) + "Please review the logs in cloudwatch.") except Exception as e: logger.warning(e) # Support running directly for testing if __name__ == '__main__': lambda_handler(None, None) ================================================ FILE: simple_acme.py ================================================ import base64 import binascii import config as cfg import copy import hashlib import json import logging import os import re import subprocess import tempfile import textwrap from urllib2 import urlopen # Configure logging logging.basicConfig(level=logging.ERROR) logger = logging.getLogger("Simple-ACME") logger.setLevel(logging.INFO) LE_NONCE = None # from: https://github.com/diafygi/acme-tiny # helper function base64 encode for jose spec def _b64(b): return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") # helper functions for making (un)signed requests def _get_request(url): try: resp = urlopen(url) return resp.getcode(), resp.read(), resp.info() except IOError as e: return e.getcode(), e.read(), e.info() def _send_signed_request(user, url, payload): global LE_NONCE payload64 = _b64(json.dumps(payload).encode('utf8')) protected = copy.deepcopy(user.jws_header) # Get a Nonce if we don't have one if LE_NONCE is None: LE_NONCE = urlopen(cfg.DIRECTORY_URL + "/directory").headers['Replay-Nonce'] protected["nonce"] = LE_NONCE LE_NONCE = None # Make sure we don't re-use a nonce protected64 = _b64(json.dumps(protected).encode('utf8')) signature = user.sign("{0}.{1}".format(protected64, payload64)) data = json.dumps({ "header": user.jws_header, "protected": protected64, "payload": payload64, "signature": _b64(signature), }) try: resp = urlopen(url, data.encode('utf8')) LE_NONCE = resp.info().getheader('Replay-Nonce', None) return resp.getcode(), resp.read(), resp.info() except IOError as e: LE_NONCE = e.info().getheader('Replay-Nonce', None) raise IOError("Unexpected response: {}".format(e.read())) class AcmeUser: def serialize(self): d = { 'key': self.key, 'keybits': self.keybits, 'url': self.url, 'agreement': self.agreement } return json.dumps(d) @staticmethod def unserialize(data): data = json.loads(data) u = AcmeUser( keybits=data['keybits'], key=data['key'], url=data['url'], agreement=data['agreement']) u._init_keydata() return u def __init__(self, keybits=2048, key=None, url=None, agreement=None): self.keybits = keybits self.key = key self.url = url self.agreement = agreement self._keydata_loaded = False def create_key(self): proc = subprocess.Popen(["openssl", "genrsa", str(self.keybits)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate() logger.debug("Stdout: ".format(out)) if proc.returncode != 0: raise IOError("OpenSSL Error: {0}".format(err)) self.key = out def _init_keydata(self): # parse account key to get public key proc = subprocess.Popen(["openssl", "rsa", "-in", "/dev/stdin", "-noout", "-text"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate(self.key) if proc.returncode != 0: raise IOError("OpenSSL Error: {0}".format(err)) pub_hex, pub_exp = re.search( r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", out.decode('utf8'), re.MULTILINE | re.DOTALL).groups() pub_exp = "{0:x}".format(int(pub_exp)) pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp self.pub_exp = pub_exp self.pub_hex = pub_hex self._keydata_loaded = True @property def pub_exp(self): if not self._keydata_loaded: self._init_keydata() return self.pub_exp @property def pub_hex(self): if not self._keydata_loaded: self._init_keydata() return self.pub_hex @property def jws_header(self): # Build the JWS header needed to sign requests jws_header = { "alg": "RS256", "jwk": { "e": _b64(binascii.unhexlify(self.pub_exp)), "kty": "RSA", "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", self.pub_hex))), }, } return jws_header @property def thumbprint(self): # thumbprint is used for validating challenges accountkey_json = json.dumps(self.jws_header['jwk'], sort_keys=True, separators=(',', ':')) return _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) def refresh_registration(self): # refresh registration details(and agreement if necessary) code, result, info = _send_signed_request( self, self.url, { "resource": "reg", "agreement": self.agreement, } ) # if the agreement has changed, autoaccept it and refresh the registration again links = info.getheader('Link') if re.search(r';rel="terms-of-service"', links): new_agreement = re.sub(r'.*<(.*)>;rel="terms-of-service".*', r'\1', links) if self.agreement != new_agreement: self.agreement = new_agreement self.refresh_registration() def register(self, email): if not self.url: code, result, info = _send_signed_request( self, cfg.DIRECTORY_URL + "/acme/new-reg", { "resource": "new-reg", "contact": [ "mailto:{}".format(email) ], }) self.url = info.getheader('Location') links = info.getheader('Link') if re.search(r';rel="terms-of-service"', links): self.agreement = re.sub(r'.*<(.*)>;rel="terms-of-service".*', r'\1', links) self.refresh_registration() def sign(self, data): # write key to tmp file f = tempfile.NamedTemporaryFile(delete=False) f.write(self.key) f.close() # sign the data proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", f.name], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) signature, err = proc.communicate(data.encode('utf8')) # delete temp key file # TODO: maybe overwrite this? os.unlink(f.name) if proc.returncode != 0: raise IOError("OpenSSL Error: {0}".format(err)) return signature class AcmeAuthorization: @staticmethod def unserialize(user, data): data = json.loads(data) authzr = AcmeAuthorization( user=user, domain=data['domain'], url=data['url'] ) return authzr def serialize(self): return json.dumps({ 'domain': self.domain, 'url': self.url }) def __init__(self, user, domain, url=None): self.user = user self.domain = domain self.url = url self.challenges = [] def authorize(self): if not self.url: code, result, info = _send_signed_request( self.user, cfg.DIRECTORY_URL + "/acme/new-authz", { "resource": "new-authz", "identifier": { "type": "dns", "value": self.domain } }) # save the url of this authorization so we can check it later self.url = info.getheader("Location") # get the data from our url code, result, info = _get_request(self.url) result = json.loads(result.decode('utf-8')) status = result['status'] if status == 'pending': self.challenges = result['challenges'] elif status == 'invalid': self.url = None # print out any error messages for c in result['challenges']: if 'error' in c: logger.debug(c['error']['detail']) return status def complete_challenges(self, challenge_type, func_challenge, func_verifier): """ calls func_challenge to complete any challenges matching the desired type """ challenges = [x for x in self.challenges if x['type'] == challenge_type] for challenge in challenges: token = challenge['token'] key_authorization = "{}.{}".format(token, self.user.thumbprint) # DNS validation uses a different value for validation if challenge_type == 'dns-01': hashed_keyauth = hashlib.sha256(key_authorization.encode("utf-8")).digest() hashed_keyauth = base64.urlsafe_b64encode(hashed_keyauth).decode('utf8').replace("=", "") ret = func_challenge(self.domain, token, hashed_keyauth) else: ret = func_challenge(self.domain, token, key_authorization) if not ret: logger.debug("Challenge completion handler failed...") continue # try to verify/validate it ret = func_verifier(self.domain, token, key_authorization) if not ret: logger.warn("Error checking validation for {}. Trying anyway.".format(self.domain)) # tell letsencrypt we finished the challenge code, result, info = _send_signed_request( self.user, challenge['uri'], { "resource": "challenge", "keyAuthorization": key_authorization }) result = json.loads(result) class AcmeCert: @staticmethod def _generate_private_key(keybits): proc = subprocess.Popen(["openssl", "genrsa", str(keybits)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate() logger.debug("Stdout: ".format(out)) if proc.returncode != 0: raise IOError("OpenSSL Error: {0}".format(err)) return out @staticmethod def generate_csr(keybits, domains): # first create a private key pkey = AcmeCert._generate_private_key(keybits) # construct the list of SANs to go in the config file san_str = "" for i, domain in enumerate(domains, start=1): san_str += "DNS.{} = {}\n".format(i, domain) # create temporary openssl conf file f = tempfile.NamedTemporaryFile(delete=False) f.write("""# openssl config file [req] req_extensions = v3_req distinguished_name = req_distinguished_name prompt = no [req_distinguished_name] countryName = US stateOrProvinceName = NA localityName = NA organizationalUnitName = NA commonName = {} emailAddress = NA [ v3_req ] basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names [alt_names] {}""".format(domains[0], san_str)) f.close() # now make the csr proc = subprocess.Popen(["openssl", "req", "-sha256", "-subj", "/", "-new", "-outform", "DER", "-key", "/dev/stdin", "-config", f.name], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate(pkey) if proc.returncode != 0: raise IOError("OpenSSL Error: {0}".format(err)) csr = out os.unlink(f.name) return pkey, csr @staticmethod def get_cert(user, csr_der): code, result, info = _send_signed_request( user, cfg.DIRECTORY_URL + "/acme/new-cert", { "resource": "new-cert", "csr": _b64(csr_der), }) cert = None cert_chain = None cert = "-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n".format( "\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64))) links = info.getheader('Link') if re.search(r';rel="up"', links): chain_cert_url = re.sub(r'.*<(.*)>;rel="up".*', r'\1', links) code, result, info = _get_request(chain_cert_url) proc = subprocess.Popen(["openssl", "x509", "-in", "/dev/stdin", "-inform", "DER", "-outform", "PEM"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) cert_chain, err = proc.communicate(result) if proc.returncode != 0: raise IOError("OpenSSL Error: {0}".format(err)) return cert, cert_chain ================================================ FILE: wizard.py ================================================ #!/usr/bin/env python """Lambda Lets-Encrypt Configuration/Setup Tool This is a wizard that will help you configure the Lambda function to automatically manage your SSL certifcates for CloudFront Distributions. Usage: setup.py setup.py (-h | --help) setup.py --version Options: -h --help Show this screen --version Show the version """ from __future__ import print_function import json import textwrap import time import zipfile from docopt import docopt from string import Template from installer import sns, cloudfront, iam, s3, awslambda, elb, route53 class colors: OKBLUE = '\033[94m' OKGREEN = '\033[92m' WARNING = '\033[93m' QUESTION = '\033[96m' FAIL = '\033[91m' ENDC = '\033[0m' BOLD = '\033[1m' UNDERLINE = '\033[4m' def write_str(string): lines = textwrap.wrap(textwrap.dedent(string), 80) for line in lines: print(line) def print_header(string): print() print(colors.OKGREEN, end='') write_str(string) print(colors.ENDC, end='') def get_input(prompt, allow_empty=True): from sys import version_info py3 = version_info[0] > 2 # creates boolean value for test that Python major version > 2 response = None while response is None or (not allow_empty and len(response) == 0): print(colors.QUESTION + "> " + prompt + colors.ENDC, end='') if py3: response = input() else: response = raw_input() return response def get_yn(prompt, default=True): if default is True: prompt += "[Y/n]? " default = True else: prompt += "[y/N]? " default = False ret = get_input(prompt, allow_empty=True) if len(ret) == 0: return default if ret.lower() == "y" or ret.lower() == "yes": return True return False def get_selection(prompt, options, prompt_after='Please select from the list above', allow_empty=False): if allow_empty: prompt_after += "(Empty for none)" prompt_after += ": " while True: print(prompt) for item in options: print('[{}] {}'.format(item['selector'], item['prompt'])) print() choice = get_input(prompt_after, allow_empty=True) # Allow for empty things if desired if len(choice) == 0 and allow_empty: return None # find and return their choice for x in options: if choice == str(x['selector']): return x['return'] print(colors.WARNING + 'Please enter a valid choice!' + colors.ENDC) def choose_s3_bucket(): bucket_list = s3.s3_list_buckets() options = [] for i, bucket in enumerate(bucket_list): options.append({ 'selector': i, 'prompt': bucket, 'return': bucket }) return get_selection("Select the S3 Bucket to use:", options, prompt_after="Which S3 Bucket?", allow_empty=False) def wizard_elb(global_config): print_header("ELB Configuration") write_str("""\ Now we'll detect your existing Elastic Load Balancers and allow you to configure them to use SSL. You must select the domain names you want on the certificate for each ELB.""") write_str("""\ Note that only DNS validation(via Route53) is supported for ELBs""") print() global_config['elb_sites'] = [] global_config['elb_domains'] = [] # Get the list of all Cloudfront Distributions elb_list = elb.list_elbs() elb_list_opts = [] for i, elb_name in enumerate(elb_list): elb_list_opts.append({ 'selector': i, 'prompt': elb_name, 'return': elb_name }) route53_list = route53.list_zones() route53_list_opts = [] for i, zone in enumerate(route53_list): route53_list_opts.append({ 'selector': i, 'prompt': "{} - {}".format(zone['Name'], zone['Id']), 'return': zone }) while True: lb = get_selection("Choose an ELB to configure SSL for(Leave blank for none)", elb_list_opts, prompt_after="Which ELB?", allow_empty=True) if lb is None: break lb_port = get_input("What port number will this certificate be for(HTTPS is 443) [443]?", allow_empty=True) if len(lb_port) == 0: lb_port = 443 domains = [] while True: if len(domains) > 0: print("Already selected: {}".format(",".join(domains))) zone = get_selection("Choose a Route53 Zone that points to this load balancer: ", route53_list_opts, prompt_after="Which zone?", allow_empty=True) # stop when they don't enter anything if zone is None: break # Only allow adding each domain once if zone['Name'] in domains: continue domains.append(zone['Name']) global_config['elb_domains'].append({ 'DOMAIN': zone['Name'], 'ROUTE53_ZONE_ID': zone['Id'], 'VALIDATION_METHODS': ['dns-01'] }) site = { 'ELB_NAME': lb, 'ELB_PORT': lb_port, 'DOMAINS': domains, } global_config['elb_sites'].append(site) def wizard_cf(global_config): print_header("CloudFront Configuration") global_config['cf_sites'] = [] global_config['cf_domains'] = [] # Get the list of all Cloudfront Distributions cf_dist_list = cloudfront.list_distributions() cf_dist_opts = [] for i, d in enumerate(cf_dist_list): cf_dist_opts.append({ 'selector': i, 'prompt': "{} - {} ({}) ".format(d['Id'], d['Comment'], ", ".join(d['Aliases'])), 'return': d }) write_str("""\ Now we'll detect your existing CloudFront Distributions and allow you to configure them to use SSL. Domain names will be automatically detected from the 'Aliases/CNAMEs' configuration section of each Distribution.""") print() write_str("""\ You will configure each Distribution fully before being presented with the list of Distributions again. You can configure as many Distributions as you like.""") while True: print() dist = get_selection("Select a CloudFront Distribution to configure with Lets-Encrypt(leave blank to finish)", cf_dist_opts, prompt_after="Which CloudFront Distribution?", allow_empty=True) if dist is None: break cnames = dist['Aliases'] write_str("The following domain names exist for the selected CloudFront Distribution:") write_str(" " + ", ".join(cnames)) write_str("Each domain in this list will be validated with Lets-Encrypt and added to the certificate assigned to this Distribution.") print() for dns_name in cnames: domain = { 'DOMAIN': dns_name, 'VALIDATION_METHODS': [] } print("Choose validation methods for the domain '{}'".format(dns_name)) route53_id = route53.get_zone_id(dns_name) if route53_id: write_str(colors.OKGREEN + "Route53 zone detected!" + colors.ENDC) validate_via_dns = get_yn("Validate using DNS", default=False) if validate_via_dns: domain['ROUTE53_ZONE_ID'] = route53_id domain['VALIDATION_METHODS'].append('dns-01') else: write_str(colors.WARNING + "No Route53 zone detected, DNS validation not possible." + colors.ENDC) validate_via_http = get_yn("Validate using HTTP", default=True) if validate_via_http: domain['CLOUDFRONT_ID'] = dist['Id'] domain['VALIDATION_METHODS'].append('http-01') global_config['cf_domains'].append(domain) site = { 'CLOUDFRONT_ID': dist['Id'], 'DOMAINS': cnames } global_config['cf_sites'].append(site) def wizard_sns(global_config): sns_email = None print_header("Notifications") write_str("""\ The lambda function can send notifications when a certificate is issued, errors occur, or other things that may need your attention. Notifications are optional.""") use_sns = True sns_email = get_input("Enter the email address for notifications(blank to disable): ", allow_empty=True) if len(sns_email) == 0: use_sns = False global_config['use_sns'] = use_sns global_config['sns_email'] = sns_email def wizard_s3_cfg_bucket(global_config): print_header("S3 Configuration Bucket") write_str('An S3 Bucket is required to store configuration. If you already have a bucket you want to use for this choose no and select it from the list. Otherwise let the wizard create one for you.') create_s3_cfg_bucket = get_yn("Create a bucket for configuration", True) if create_s3_cfg_bucket: s3_cfg_bucket = "lambda-letsencrypt-config-{}".format(global_config['ts']) else: s3_cfg_bucket = choose_s3_bucket() global_config['create_s3_cfg_bucket'] = create_s3_cfg_bucket global_config['s3_cfg_bucket'] = s3_cfg_bucket def wizard_iam(global_config): print_header("IAM Configuration") write_str("An IAM role must be created for this lambda function giving it access to CloudFront, Route53, S3, SNS(notifications), IAM(certificates), and CloudWatch(logs/alarms).") print() write_str("If you do not let the wizard create this role you will be asked to select an existing role to use.") create_iam_role = get_yn("Do you want to automatically create this role", True) if not create_iam_role: role_list = iam.list_roles() options = [] for i, role in enumerate(role_list): options.append({ 'selector': i, 'prompt': role, 'return': role }) iam_role_name = get_selection("Select the IAM Role:", options, prompt_after="Which IAM Role?", allow_empty=False) else: iam_role_name = "lambda-letsencrypt" global_config['create_iam_role'] = create_iam_role global_config['iam_role_name'] = iam_role_name def wizard_challenges(global_config): create_s3_challenge_bucket = False s3_challenge_bucket = None print_header("Lets-Encrypt Challenge Validation Settings") write_str("""This tool will handle validation of your domains automatically. There are two possible validation methods: HTTP and DNS.""") print() write_str("HTTP validation is only available for CloudFront sites. It requires an S3 bucket to store the challenge responses in. This bucket needs to be publicly accessible. Your CloudFront Distribution(s) will be reconfigured to use this bucket as an origin for challenge responses.") write_str("If you do not configure a bucket for this you will only be able to use DNS validation.") print() write_str("DNS validation requires your domain to be managed with Route53. This validation method is always available and requires no additional configuration.") write_str(colors.WARNING + "Note: DNS validation is currently only supported by the staging server." + colors.ENDC) print() write_str("Each domain you want to manage can be configured to validate using either of these methods.") print() use_http_challenges = get_yn("Do you want to configure HTTP validation", True) if use_http_challenges: create_s3_challenge_bucket = get_yn("Do you want to create a bucket for these challenges(Choose No to select an existing bucket)", True) if create_s3_challenge_bucket: s3_challenge_bucket = "lambda-letsencrypt-challenges-{}".format(global_config['ts']) else: s3_challenge_bucket = choose_s3_bucket() else: # only dns challenge support is available pass global_config['use_http_challenges'] = use_http_challenges global_config['create_s3_challenge_bucket'] = create_s3_challenge_bucket global_config['s3_challenge_bucket'] = s3_challenge_bucket def wizard_summary(global_config): gc = global_config print_header("**Summary**") print("Notification Email: {}".format(gc['sns_email'] or "(notifications disabled)")) print("S3 Config Bucket: {}".format(gc['s3_cfg_bucket']), end="") if (gc['create_s3_cfg_bucket']): print(" (to be created)") else: print(" (existing)") if gc['create_iam_role']: print("IAM Role Name: {} (to be created)".format(gc['iam_role_name'])) else: print("IAM Role Name: {} (existing)".format(gc['iam_role_name'])) print("Support HTTP Challenges: {}".format(gc['use_http_challenges'])) if gc['use_http_challenges']: print("S3 HTTP Challenge Bucket: {}".format(gc['s3_challenge_bucket']), end="") if (gc['create_s3_challenge_bucket']): print(" (to be created)") else: print(" (existing)") print("Domains To Manage With Lets-Encrypt") for d in gc['cf_domains']: print(" {} - [{}]".format(d['DOMAIN'], ",".join(d['VALIDATION_METHODS']))) for d in gc['elb_domains']: print(" {} - [{}]".format(d['DOMAIN'], ",".join(d['VALIDATION_METHODS']))) print("CloudFront Distributions To Manage:") for cf in gc['cf_sites']: print(" {} - [{}]".format(cf['CLOUDFRONT_ID'], ",".join(cf['DOMAINS']))) print("Elastic Load Balancers to Manage:") for lb in gc['elb_sites']: print(" {}:{} - [{}]".format(lb['ELB_NAME'], lb['ELB_PORT'], ",".join(lb['DOMAINS']))) def wizard_save_config(global_config): print_header("Making Requested Changes") templatevars = {} with open('config.py.dist', 'r') as template: configfile = Template(template.read()) templatevars['SNS_ARN'] = None templatevars['NOTIFY_EMAIL'] = None # Configure SNS if appropriate sns_arn = None if len(global_config['sns_email']) > 0: # Create SNS Topic if necessary print("Creating SNS Topic for Notifications ", end='') sns_arn = sns.get_or_create_topic(global_config['sns_email']) if sns_arn is False or sns_arn is None: print(colors.FAIL + u'\u2717' + colors.ENDC) else: print(colors.OKGREEN + u'\u2713' + colors.ENDC) templatevars['SNS_ARN'] = sns_arn templatevars['NOTIFY_EMAIL'] = global_config['sns_email'] # create config bucket if necessary if global_config['create_s3_cfg_bucket']: print("Creating S3 Configuration Bucket ", end='') s3.create_bucket(global_config['s3_cfg_bucket']) print(colors.OKGREEN + u'\u2713' + colors.ENDC) # create challenge bucket if necessary(needs to be configured as static website) if global_config['create_s3_challenge_bucket']: print("Creating S3 Challenge Bucket ", end='') s3.create_web_bucket(global_config['s3_challenge_bucket']) print(colors.OKGREEN + u'\u2713' + colors.ENDC) # create IAM role if required if global_config['create_iam_role']: global_config['iam_role_name'] = 'lambda-letsencrypt-test-role' policy_document = iam.generate_policy_document( s3buckets=[ global_config['s3_cfg_bucket'], global_config['s3_challenge_bucket'] ], snstopicarn=sns_arn ) iam_arn = iam.configure(global_config['iam_role_name'], policy_document) templatevars['S3_CONFIG_BUCKET'] = global_config['s3_cfg_bucket'] templatevars['S3_CHALLENGE_BUCKET'] = global_config['s3_challenge_bucket'] domains = global_config['cf_domains'] + global_config['elb_domains'] sites = global_config['cf_sites'] + global_config['elb_sites'] templatevars['DOMAINS'] = json.dumps(domains, indent=4) templatevars['SITES'] = json.dumps(sites, indent=4) # write out the config file config = configfile.substitute(templatevars) with open("config-wizard.py", 'w') as configfinal: print("Writing Configuration File ", end='') configfinal.write(config) print(colors.OKGREEN + u'\u2713' + colors.ENDC) print("Creating Zip File To Upload To Lambda") archive_success = True archive = zipfile.ZipFile('lambda-letsencrypt-dist.zip', mode='w') try: for f in ['lambda_function.py', 'simple_acme.py']: print(" Adding '{}'".format(f)) archive.write(f) print(" Adding 'config.py'") archive.write('config-wizard.py', 'config.py') except Exception as e: print(colors.FAIL + 'Zip File Creation Failed' + colors.ENDC) print(e) archive_success = False finally: print('Zip File Created Successfully') archive.close() # can't continue if this failed if not archive_success: return print("Configuring Lambda Function:") iam_arn = iam.get_arn(global_config['iam_role_name']) print(" IAM ARN: {}".format(iam_arn)) print(" Uploading Function ", end='') if awslambda.create_function("lambda-letsencrypt", iam_arn, 'lambda-letsencrypt-dist.zip'): print(colors.OKGREEN + u'\u2713' + colors.ENDC) else: print(colors.FAIL + u'\u2717' + colors.ENDC) return print_header("Schedule Lambda Function") write_str("I've done all I can for you now, there's one last step you have to take manually in order to schedule your lambda function to run once a day.") write_str("Log into your aws console and go to this page:") lambda_event_url = "https://console.aws.amazon.com/lambda/home#/functions/lambda-letsencrypt?tab=eventSources" print(colors.OKBLUE + lambda_event_url + colors.ENDC) print() write_str('Click on "Add event source". From the dropdown, choose "Scheduled Event". Enter the following:') write_str("Name: 'daily - rate(1 day)'") write_str("Description: 'Run every day'") write_str("Schedule Expression: 'rate(1 day)'") print() write_str("Choose to 'Enable Now', then click 'Submit'") print_header("Testing") write_str("You may want to test this before you set it to be recurring. Click on the 'Test' button in the AWS Console for the lambda-letsencrypt function. The data you provide to this function does not matter. Make sure to review the logs after it finishes and check for anything out of the ordinary.") print() write_str("It will take at least 2 runs before your certificates are issued, maybe 3 depending on how fast cloudfront responds. This is because it needs one try to configure cloudfront, one to submit the challenge and have it verified, and one final run to issue the certificate and configure the cloudfront distribution") def wizard(global_config): ts = int(time.time()) ts = 1000 global_config['ts'] = ts print_header("Lambda Lets-Encrypt Wizard") write_str("""\ This wizard will guide you through the process of setting up your existing CloudFront Distributions to use SSL certificates provided by Lets-Encrypt and automatically issued/maintained by an AWS Lambda function. These certificates are free of charge, and valid for 90 days. This wizard will also set up a Lambda function that is responsible for issuing and renewing these certificates automatically as they near their expiration date. The cost of the AWS services used to make this work are typically less than a penny per month. For full pricing details please refer to the docs. """) print() print(colors.WARNING + "WARNING: ") write_str("""\ Manual configuration is required at this time to configure the Lambda function to run on a daily basis to keep your certificate updated. If you do not follow the steps provided at the end of this wizard your Lambda function will *NOT* run. """) print(colors.ENDC) wizard_sns(global_config) wizard_iam(global_config) wizard_s3_cfg_bucket(global_config) wizard_challenges(global_config) wizard_cf(global_config) wizard_elb(global_config) cfg_menu = [] cfg_menu.append({'selector': 1, 'prompt': 'SNS', 'return': wizard_sns}) cfg_menu.append({'selector': 2, 'prompt': 'IAM', 'return': wizard_iam}) cfg_menu.append({'selector': 3, 'prompt': 'S3 Config', 'return': wizard_s3_cfg_bucket}) cfg_menu.append({'selector': 4, 'prompt': 'Challenges', 'return': wizard_challenges}) cfg_menu.append({'selector': 5, 'prompt': 'CloudFront', 'return': wizard_cf}) cfg_menu.append({'selector': 6, 'prompt': 'Elastic Load Balancers', 'return': wizard_cf}) cfg_menu.append({'selector': 9, 'prompt': 'Done', 'return': None}) finished = False while not finished: wizard_summary(global_config) finished = get_yn("Are these settings correct", True) if not finished: selection = get_selection("Which section do you want to change", cfg_menu, prompt_after="Which section to modify?", allow_empty=False) if selection: selection(global_config) wizard_save_config(global_config) if __name__ == "__main__": args = docopt(__doc__, version='Lambda Lets-Encrypt 1.0') global_config = {} wizard(global_config)