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