From 1d7fd33f4d71ff7509a997b85be3f39b9573e65c Mon Sep 17 00:00:00 2001 From: Breland Miley Date: Sun, 31 Jan 2016 18:35:35 -0800 Subject: [PATCH 01/25] Initial commit --- .gitignore | 62 +++++++++++++++++ LICENSE | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1dbc687de --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Ipython Notebook +.ipynb_checkpoints diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..8dada3eda --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 36fdf99a1d5d30eb7902316f32f1d0f60c536c07 Mon Sep 17 00:00:00 2001 From: Miley Date: Sun, 31 Jan 2016 22:33:17 -0800 Subject: [PATCH 02/25] Initial commit, not safe to use --- LICENSE.txt | 1 + MANIFEST.in | 4 ++ README.md | 35 +++++++++++ letsencrypt_route53/__init__.py | 1 + letsencrypt_route53/authenticator.py | 93 ++++++++++++++++++++++++++++ sample-aws-policy.json | 36 +++++++++++ setup.cfg | 2 + setup.py | 62 +++++++++++++++++++ 8 files changed, 234 insertions(+) create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 letsencrypt_route53/__init__.py create mode 100644 letsencrypt_route53/authenticator.py create mode 100644 sample-aws-policy.json create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..7ff097c3c --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1 @@ +See LICENSE diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..4faaf21cd --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE.txt +include README.md +recursive-include docs * +recursive-include letsencrypt_route53/tests/testdata * diff --git a/README.md b/README.md new file mode 100644 index 000000000..18725f1f7 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +## Route53 plugin for Let's Encrypt client + + +### Before you start + +It's expected that the root hosted zone for the domain in question already exists in your account. + +### Setup + +1. Install the letsencrypt client [https://letsencrypt.readthedocs.org/en/latest/using.html#installation](https://letsencrypt.readthedocs.org/en/latest/using.html#installation) + + ``` + pip install letsencrypt + ``` + +1. Install the letsencrypt-s3front plugin + + ``` + pip install letsencrypt-s3front + ``` + +### How to use it + +To generate a certificate and install it in a CloudFront distribution: +``` +AWS_ACCESS_KEY_ID="your_key" \ +AWS_SECRET_ACCESS_KEY="your_secret" \ +letsencrypt --agree-tos -a letsencrypt-route53:auth \ +-d the_domain +``` + +Follow the screen prompts and you should end up with the certificate in your +distribution. It may take a couple minutes to update. + +To automate the renewal process without prompts (for example, with a monthly cron), you can add the letsencrypt parameters --renew-by-default --text diff --git a/letsencrypt_route53/__init__.py b/letsencrypt_route53/__init__.py new file mode 100644 index 000000000..6f519023b --- /dev/null +++ b/letsencrypt_route53/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Route53 plugin.""" diff --git a/letsencrypt_route53/authenticator.py b/letsencrypt_route53/authenticator.py new file mode 100644 index 000000000..280a5bb55 --- /dev/null +++ b/letsencrypt_route53/authenticator.py @@ -0,0 +1,93 @@ +"""Route53 Let's Encrypt authenticator plugin.""" +import os +import logging +import re +import subprocess + +import zope.component +import zope.interface + +import boto3 + +from acme import challenges + +from letsencrypt import errors +from letsencrypt import interfaces +from letsencrypt.plugins import common + + +logger = logging.getLogger(__name__) + +class Authenticator(common.Plugin): + zope.interface.implements(interfaces.IAuthenticator) + zope.interface.classProvides(interfaces.IPluginFactory) + + description = "Route53 Authenticator" + + def __init__(self, *args, **kwargs): + super(Authenticator, self).__init__(*args, **kwargs) + self._httpd = None + + def prepare(self): # pylint: disable=missing-docstring,no-self-use + pass # pragma: no cover + + def more_info(self): # pylint: disable=missing-docstring,no-self-use + return ("") + + def get_chall_pref(self, domain): + # pylint: disable=missing-docstring,no-self-use,unused-argument + return [challenges.DNS] + + def perform(self, achalls): # pylint: disable=missing-docstring + responses = [] + for achall in achalls: + responses.append(self._perform_single(achall)) + return responses + + def _perform_single(self, achall): + # provision the TXT record, using the domain name given. Assumes the hosted zone exits, else fails the challenge + response, validation = achall.response_and_validation() + r53 = boto3.client('route53') + logger.info("Doing validation for " + achall) + listResponse = r53.list_hosted_zones_by_name(DNSName=achall.chall.path[1:]) + matches = listResponse.HostedZones; + if matches.size != 0: + logger.error("Route53 returned " + mathces.size + " matching hosted zones. Expected exactly one. Auth canceled.") + return None + else: + r53.change_resource_record_sets(HostedZoneId=matches[0].Id, + ChangeBatch={ + 'Comment': 'Let\'s Entcrypt Change', + 'Changes': [ + { + 'Action': 'UPSERT', + 'ResourceRecordSet': { + 'Name': achall.chall.path[1:], + 'Type': 'TXT', + 'TTL': 300, + 'ResourceRecords': [ + { + 'Value': validation + }, + ] + } + }, + ] + }) + + if response.simple_verify( + achall.chall, achall.domain, + achall.account_key.public_key(), self.config.http01_port): + return response + else: + logger.error( + "Self-verify of challenge failed, authorization abandoned!") + return None + + def cleanup(self, achalls): + # pylint: disable=missing-docstring,no-self-use,unused-argument + #TODO:Cleanup recordĀ  + r53 = boto3.client('route53') + #for achall in achalls: + # r53.delete_object(Bucket=self.conf('s3-bucket'), Key=achall.chall.path[1:]) + return None diff --git a/sample-aws-policy.json b/sample-aws-policy.json new file mode 100644 index 000000000..1af66c85d --- /dev/null +++ b/sample-aws-policy.json @@ -0,0 +1,36 @@ +{ + "Version": "2012-10-17", + "Id": "letsencrypt-route53 sample policy", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:UploadServerCertificate", + "iam:UpdateServerCertificate", + "iam:DeleteServerCertificate" + ], + "Resource": [ + "*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "route53:List*", + "route53:Get*", + ], + "Resource": [ + "*" + ] + }, + { + "Effect" : "Allow", + "Action" : [ + "route53:ChangeResourceRecordSets" + ], + "Resource" : [ + "arn:aws:route53:::hostedzone/YOURHOSTEDZONEID" + ] + } + ] +} diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..b88034e41 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..5b2e1da06 --- /dev/null +++ b/setup.py @@ -0,0 +1,62 @@ +import sys + +from distutils.core import setup +from setuptools import find_packages + +version = '0.1.3' + +install_requires = [ + 'acme>=0.1.1', + 'letsencrypt>=0.1.1', + 'PyOpenSSL', + 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? + 'setuptools', # pkg_resources + 'zope.interface', + 'boto3' +] + +if sys.version_info < (2, 7): + install_requires.append('mock<1.1.0') +else: + install_requires.append('mock') + +docs_extras = [ + 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags + 'sphinx_rtd_theme', +] + +setup( + name='letsencrypt-route53', + version=version, + description="Route53 plugin for Let's Encrypt client", + url='https://github.com/mindstorms6/letsencrypt-route53', + author="Breland Miley", + author_email='breland@bdawg.org', + license='Apache2.0', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Plugins', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Security', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Networking', + 'Topic :: System :: Systems Administration', + 'Topic :: Utilities', + ], + packages=find_packages(), + include_package_data=True, + install_requires=install_requires, + keywords = ['letsencrypt', 'route53', 'aws'], + entry_points={ + 'letsencrypt.plugins': [ + 'auth = letsencrypt_s3front.authenticator:Authenticator' + ], + }, +) From bb982024f89d015dbc46e5f71027408a79791dd7 Mon Sep 17 00:00:00 2001 From: Miley Date: Sun, 31 Jan 2016 22:46:28 -0800 Subject: [PATCH 03/25] Fix entry point --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5b2e1da06..02d9e01d5 100644 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ setup( keywords = ['letsencrypt', 'route53', 'aws'], entry_points={ 'letsencrypt.plugins': [ - 'auth = letsencrypt_s3front.authenticator:Authenticator' + 'auth = letsencrypt_route53.authenticator:Authenticator' ], }, ) From c8e911e00b68decb116cbd246490d0bd463237fa Mon Sep 17 00:00:00 2001 From: mindstorms6 Date: Tue, 2 Feb 2016 07:45:51 +0000 Subject: [PATCH 04/25] Updates for authenticator, still WIP --- README.md | 2 +- letsencrypt_route53/authenticator.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 18725f1f7..e39616a52 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It's expected that the root hosted zone for the domain in question already exist 1. Install the letsencrypt-s3front plugin ``` - pip install letsencrypt-s3front + pip install letsencrypt-route53 ``` ### How to use it diff --git a/letsencrypt_route53/authenticator.py b/letsencrypt_route53/authenticator.py index 280a5bb55..95d24b0ab 100644 --- a/letsencrypt_route53/authenticator.py +++ b/letsencrypt_route53/authenticator.py @@ -36,7 +36,7 @@ class Authenticator(common.Plugin): def get_chall_pref(self, domain): # pylint: disable=missing-docstring,no-self-use,unused-argument - return [challenges.DNS] + return [challenges.DNS01] def perform(self, achalls): # pylint: disable=missing-docstring responses = [] @@ -48,8 +48,8 @@ class Authenticator(common.Plugin): # provision the TXT record, using the domain name given. Assumes the hosted zone exits, else fails the challenge response, validation = achall.response_and_validation() r53 = boto3.client('route53') - logger.info("Doing validation for " + achall) - listResponse = r53.list_hosted_zones_by_name(DNSName=achall.chall.path[1:]) + logger.info("Doing validation for " + response.domain) + listResponse = r53.list_hosted_zones_by_name(DNSName=response.domain) matches = listResponse.HostedZones; if matches.size != 0: logger.error("Route53 returned " + mathces.size + " matching hosted zones. Expected exactly one. Auth canceled.") @@ -62,7 +62,7 @@ class Authenticator(common.Plugin): { 'Action': 'UPSERT', 'ResourceRecordSet': { - 'Name': achall.chall.path[1:], + 'Name': achall.validation_domain_name(), 'Type': 'TXT', 'TTL': 300, 'ResourceRecords': [ From 80b491d11d40895cefd628b20cbd77fcc390d437 Mon Sep 17 00:00:00 2001 From: Miley Date: Mon, 8 Feb 2016 17:24:34 -0800 Subject: [PATCH 05/25] Readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e39616a52..6fc901580 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It's expected that the root hosted zone for the domain in question already exist pip install letsencrypt ``` -1. Install the letsencrypt-s3front plugin +1. Install the letsencrypt-route53 plugin ``` pip install letsencrypt-route53 From c4364f82fbbdd0dd1e15f6c5c56ac4e36b688212 Mon Sep 17 00:00:00 2001 From: Hugo Peixoto Date: Mon, 3 Oct 2016 20:08:08 +0100 Subject: [PATCH 06/25] Change package names --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 02d9e01d5..95580ea15 100644 --- a/setup.py +++ b/setup.py @@ -26,10 +26,10 @@ docs_extras = [ ] setup( - name='letsencrypt-route53', + name='hpeixoto-letsencrypt-route53', version=version, description="Route53 plugin for Let's Encrypt client", - url='https://github.com/mindstorms6/letsencrypt-route53', + url='https://github.com/lifeonmarspt/letsencrypt-route53', author="Breland Miley", author_email='breland@bdawg.org', license='Apache2.0', From 1a5f09f4cfd2b5a520ec1944d986469d5e5838a8 Mon Sep 17 00:00:00 2001 From: Hugo Peixoto Date: Mon, 3 Oct 2016 19:53:53 +0100 Subject: [PATCH 07/25] First working iteration --- letsencrypt_route53/authenticator.py | 119 +++++++++++++++++---------- setup.py | 4 +- 2 files changed, 78 insertions(+), 45 deletions(-) diff --git a/letsencrypt_route53/authenticator.py b/letsencrypt_route53/authenticator.py index 95d24b0ab..95ade5916 100644 --- a/letsencrypt_route53/authenticator.py +++ b/letsencrypt_route53/authenticator.py @@ -1,23 +1,21 @@ """Route53 Let's Encrypt authenticator plugin.""" -import os import logging -import re -import subprocess +import time -import zope.component import zope.interface import boto3 from acme import challenges -from letsencrypt import errors from letsencrypt import interfaces from letsencrypt.plugins import common logger = logging.getLogger(__name__) +TTL = 30 + class Authenticator(common.Plugin): zope.interface.implements(interfaces.IAuthenticator) zope.interface.classProvides(interfaces.IPluginFactory) @@ -44,50 +42,85 @@ class Authenticator(common.Plugin): responses.append(self._perform_single(achall)) return responses + def _find_zone(self, r53, domain): + return max( + ( + zone for zone in r53.list_hosted_zones()["HostedZones"] + if (domain+".").endswith("."+zone["Name"]) + ), + key=lambda zone: len(zone["Name"]), + ) + + def _perform_single(self, achall): # provision the TXT record, using the domain name given. Assumes the hosted zone exits, else fails the challenge - response, validation = achall.response_and_validation() r53 = boto3.client('route53') - logger.info("Doing validation for " + response.domain) - listResponse = r53.list_hosted_zones_by_name(DNSName=response.domain) - matches = listResponse.HostedZones; - if matches.size != 0: - logger.error("Route53 returned " + mathces.size + " matching hosted zones. Expected exactly one. Auth canceled.") - return None - else: - r53.change_resource_record_sets(HostedZoneId=matches[0].Id, - ChangeBatch={ - 'Comment': 'Let\'s Entcrypt Change', - 'Changes': [ - { - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'Name': achall.validation_domain_name(), - 'Type': 'TXT', - 'TTL': 300, - 'ResourceRecords': [ - { - 'Value': validation - }, - ] - } - }, - ] - }) + logger.info("Doing validation for " + achall.domain) + listResponse = r53.list_hosted_zones_by_name(DNSName=achall.domain) - if response.simple_verify( - achall.chall, achall.domain, - achall.account_key.public_key(), self.config.http01_port): - return response - else: - logger.error( - "Self-verify of challenge failed, authorization abandoned!") + try: + zone = self._find_zone(r53, achall.domain) + except ValueError as e: + logger.error("Unable to find matching Route53 zone for domain " + achall.domain) return None + response, validation = achall.response_and_validation() + self._excute_r53_action(r53, achall, zone, validation, 'UPSERT', wait_for_change=True) + + for _ in xrange(TTL*2): + if response.simple_verify( + achall.chall, + achall.domain, + achall.account_key.public_key(), + ): + break + logger.info("Waiting for DNS propagation...") + time.sleep(1) + else: + logger.error("Unable to verify domain " + achall.domain) + return None + + return response + def cleanup(self, achalls): - # pylint: disable=missing-docstring,no-self-use,unused-argument - #TODO:Cleanup recordĀ  + # pylint: disable=missing-docstring r53 = boto3.client('route53') - #for achall in achalls: - # r53.delete_object(Bucket=self.conf('s3-bucket'), Key=achall.chall.path[1:]) + for achall in achalls: + try: + zone = self._find_zone(r53, achall.domain) + except ValueError: + logger.warn("Unable to find zone for " + achall.domain + ". Skipping cleanup.") + continue + + _, validation = achall.response_and_validation() + self._excute_r53_action(r53, achall, zone, validation, 'DELETE') return None + + + def _excute_r53_action(self, r53, achall, zone, validation, action, wait_for_change=False): + response = r53.change_resource_record_sets( + HostedZoneId=zone["Id"], + ChangeBatch={ + 'Comment': 'Let\'s Encrypt ' + action, + 'Changes': [ + { + 'Action': action, + 'ResourceRecordSet': { + 'Name': achall.validation_domain_name(achall.domain), + 'Type': 'TXT', + 'TTL': TTL, + 'ResourceRecords': [ + { + 'Value': '"' + validation + '"', + }, + ], + }, + }, + ], + }, + ) + + if wait_for_change: + while r53.get_change(Id=response["ChangeInfo"]["Id"])["ChangeInfo"]["Status"] == "PENDING": + logger.info("Waiting for " + action + " to propagate...") + time.sleep(1) diff --git a/setup.py b/setup.py index 95580ea15..172791aba 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,8 @@ from setuptools import find_packages version = '0.1.3' install_requires = [ - 'acme>=0.1.1', - 'letsencrypt>=0.1.1', + 'acme>=0.9.0.dev0', + 'letsencrypt>=0.9.0.dev0', 'PyOpenSSL', 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? 'setuptools', # pkg_resources From ebd2007e82c4dbf2b7f2e3d9905d8a8555f4de44 Mon Sep 17 00:00:00 2001 From: Hugo Peixoto Date: Mon, 3 Oct 2016 20:06:38 +0100 Subject: [PATCH 08/25] Add instructions and rationale --- README.md | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6fc901580..e33b4e777 100644 --- a/README.md +++ b/README.md @@ -7,29 +7,33 @@ It's expected that the root hosted zone for the domain in question already exist ### Setup -1. Install the letsencrypt client [https://letsencrypt.readthedocs.org/en/latest/using.html#installation](https://letsencrypt.readthedocs.org/en/latest/using.html#installation) +1. Create a virtual environment - ``` - pip install letsencrypt - ``` +2. Make sure you have libssl-dev (or your regional equivalent) installed. -1. Install the letsencrypt-route53 plugin +3. Install by adding these to your requirements.txt file: - ``` - pip install letsencrypt-route53 - ``` +``` +--no-binary pycparser +-e git+https://github.com/certbot/certbot.git#egg=certbot +-e git+https://github.com/certbot/certbot.git#egg=acme&subdirectory=acme +hpeixoto-letsencrypt-route53 +``` + +We need DNS01 support in certbot, which is only available in master for now. +Additionally, pycparser suffers from +https://github.com/eliben/pycparser/issues/148, which is why we need to +recompile it, which depends on `libssl-dev`. ### How to use it -To generate a certificate and install it in a CloudFront distribution: -``` -AWS_ACCESS_KEY_ID="your_key" \ -AWS_SECRET_ACCESS_KEY="your_secret" \ -letsencrypt --agree-tos -a letsencrypt-route53:auth \ --d the_domain -``` +Make sure you have access to AWS's Route53 service, either through IAM roles or +via `.aws/credentials`. -Follow the screen prompts and you should end up with the certificate in your -distribution. It may take a couple minutes to update. - -To automate the renewal process without prompts (for example, with a monthly cron), you can add the letsencrypt parameters --renew-by-default --text +To generate a certificate: +``` +letsencrypt certonly \ + -n --agree-tos --email DEVOPS@COMPANY.COM \ + -a hpeixoto-letsencrypt-route53:auth \ + -d MY.DOMAIN.NAME +``` From 108903dd26f73f60a1e118a9523089d85070fc2d Mon Sep 17 00:00:00 2001 From: Hugo Peixoto Date: Mon, 3 Oct 2016 20:07:37 +0100 Subject: [PATCH 09/25] Bump version to 0.1.4 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 172791aba..20c18cde9 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import sys from distutils.core import setup from setuptools import find_packages -version = '0.1.3' +version = '0.1.4' install_requires = [ 'acme>=0.9.0.dev0', From 4538766c480dd2d54bcb990c0b9f7e78f89ea152 Mon Sep 17 00:00:00 2001 From: Paulo Koch Date: Tue, 4 Oct 2016 14:20:12 +0100 Subject: [PATCH 10/25] Make it work as certbot-route53 --- MANIFEST.in | 2 +- README.md | 14 ++++++------ .../__init__.py | 0 .../authenticator.py | 5 ++--- sample-aws-policy.json | 2 +- setup.py | 22 +++++++++---------- 6 files changed, 22 insertions(+), 23 deletions(-) rename {letsencrypt_route53 => certbot_route53}/__init__.py (100%) rename {letsencrypt_route53 => certbot_route53}/authenticator.py (96%) diff --git a/MANIFEST.in b/MANIFEST.in index 4faaf21cd..568ab3f2e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include LICENSE.txt include README.md recursive-include docs * -recursive-include letsencrypt_route53/tests/testdata * +recursive-include certbot_route53/tests/testdata * diff --git a/README.md b/README.md index e33b4e777..59af3615c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ It's expected that the root hosted zone for the domain in question already exist 1. Create a virtual environment 2. Make sure you have libssl-dev (or your regional equivalent) installed. +`pycparser` suffers from +https://github.com/eliben/pycparser/issues/148, which is why we need to +recompile it, which depends on `libssl-dev`. 3. Install by adding these to your requirements.txt file: @@ -17,13 +20,10 @@ It's expected that the root hosted zone for the domain in question already exist --no-binary pycparser -e git+https://github.com/certbot/certbot.git#egg=certbot -e git+https://github.com/certbot/certbot.git#egg=acme&subdirectory=acme -hpeixoto-letsencrypt-route53 +certbot-route53 ``` - We need DNS01 support in certbot, which is only available in master for now. -Additionally, pycparser suffers from -https://github.com/eliben/pycparser/issues/148, which is why we need to -recompile it, which depends on `libssl-dev`. + ### How to use it @@ -32,8 +32,8 @@ via `.aws/credentials`. To generate a certificate: ``` -letsencrypt certonly \ +certbot certonly \ -n --agree-tos --email DEVOPS@COMPANY.COM \ - -a hpeixoto-letsencrypt-route53:auth \ + -a certbot-route53:auth \ -d MY.DOMAIN.NAME ``` diff --git a/letsencrypt_route53/__init__.py b/certbot_route53/__init__.py similarity index 100% rename from letsencrypt_route53/__init__.py rename to certbot_route53/__init__.py diff --git a/letsencrypt_route53/authenticator.py b/certbot_route53/authenticator.py similarity index 96% rename from letsencrypt_route53/authenticator.py rename to certbot_route53/authenticator.py index 95ade5916..2b941a7e5 100644 --- a/letsencrypt_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -8,8 +8,8 @@ import boto3 from acme import challenges -from letsencrypt import interfaces -from letsencrypt.plugins import common +from certbot import interfaces +from certbot.plugins import common logger = logging.getLogger(__name__) @@ -56,7 +56,6 @@ class Authenticator(common.Plugin): # provision the TXT record, using the domain name given. Assumes the hosted zone exits, else fails the challenge r53 = boto3.client('route53') logger.info("Doing validation for " + achall.domain) - listResponse = r53.list_hosted_zones_by_name(DNSName=achall.domain) try: zone = self._find_zone(r53, achall.domain) diff --git a/sample-aws-policy.json b/sample-aws-policy.json index 1af66c85d..59c8fb7c7 100644 --- a/sample-aws-policy.json +++ b/sample-aws-policy.json @@ -1,6 +1,6 @@ { "Version": "2012-10-17", - "Id": "letsencrypt-route53 sample policy", + "Id": "certbot-route53 sample policy", "Statement": [ { "Effect": "Allow", diff --git a/setup.py b/setup.py index 20c18cde9..2aa0497f0 100644 --- a/setup.py +++ b/setup.py @@ -7,12 +7,13 @@ version = '0.1.4' install_requires = [ 'acme>=0.9.0.dev0', - 'letsencrypt>=0.9.0.dev0', + 'certbot>=0.9.0.dev0', 'PyOpenSSL', 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? 'setuptools', # pkg_resources 'zope.interface', - 'boto3' + 'boto3', + 'dnspython', ] if sys.version_info < (2, 7): @@ -26,12 +27,12 @@ docs_extras = [ ] setup( - name='hpeixoto-letsencrypt-route53', + name='certbot-route53', version=version, - description="Route53 plugin for Let's Encrypt client", - url='https://github.com/lifeonmarspt/letsencrypt-route53', - author="Breland Miley", - author_email='breland@bdawg.org', + description="Route53 plugin for certbot", + url='https://github.com/lifeonmarspt/certbot-route53', + author="Hugo Peixoto", + author_email='hugo@lifeonmars.pt', license='Apache2.0', classifiers=[ 'Development Status :: 3 - Alpha', @@ -41,7 +42,6 @@ setup( 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', @@ -53,10 +53,10 @@ setup( packages=find_packages(), include_package_data=True, install_requires=install_requires, - keywords = ['letsencrypt', 'route53', 'aws'], + keywords=['certbot', 'route53', 'aws'], entry_points={ - 'letsencrypt.plugins': [ - 'auth = letsencrypt_route53.authenticator:Authenticator' + 'certbot.plugins': [ + 'auth = certbot_route53.authenticator:Authenticator' ], }, ) From ebe5d0c4f28e9fc102a18265e28b7b9b9efbacc9 Mon Sep 17 00:00:00 2001 From: Waylon Flinn Date: Fri, 4 Nov 2016 20:26:34 -0500 Subject: [PATCH 11/25] add support for root domain --- certbot_route53/authenticator.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index 2b941a7e5..732080eeb 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -46,7 +46,7 @@ class Authenticator(common.Plugin): return max( ( zone for zone in r53.list_hosted_zones()["HostedZones"] - if (domain+".").endswith("."+zone["Name"]) + if (domain+".").endswith("."+zone["Name"]) or (domain+".") == (zone["Name"]) ), key=lambda zone: len(zone["Name"]), ) diff --git a/setup.py b/setup.py index 2aa0497f0..4b9b754a8 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import sys from distutils.core import setup from setuptools import find_packages -version = '0.1.4' +version = '0.1.5' install_requires = [ 'acme>=0.9.0.dev0', From 17c4c7f68efa54f63f7c448d372e98b14939f0ca Mon Sep 17 00:00:00 2001 From: Chow Loong Jin Date: Mon, 27 Feb 2017 15:27:32 +0800 Subject: [PATCH 12/25] Use zope decorators This makes it compatible with python3. --- certbot_route53/authenticator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index 2b941a7e5..915c0092d 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -16,9 +16,9 @@ logger = logging.getLogger(__name__) TTL = 30 +@zope.interface.implementer(interfaces.IAuthenticator) +@zope.interface.provider(interfaces.IPluginFactory) class Authenticator(common.Plugin): - zope.interface.implements(interfaces.IAuthenticator) - zope.interface.classProvides(interfaces.IPluginFactory) description = "Route53 Authenticator" From e9531dc80b75421b73f643b60c0780b6ee6fc92e Mon Sep 17 00:00:00 2001 From: Chow Loong Jin Date: Mon, 27 Feb 2017 16:57:33 +0800 Subject: [PATCH 13/25] Replace xrange with range --- certbot_route53/authenticator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index 915c0092d..7dbe8d80b 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -66,7 +66,7 @@ class Authenticator(common.Plugin): response, validation = achall.response_and_validation() self._excute_r53_action(r53, achall, zone, validation, 'UPSERT', wait_for_change=True) - for _ in xrange(TTL*2): + for _ in range(TTL*2): if response.simple_verify( achall.chall, achall.domain, From 1143ab7446ff33d5f1270235ef50165d6ea671c4 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Sat, 11 Mar 2017 10:30:39 -0800 Subject: [PATCH 14/25] Documentation and efficiency changes. These are from certbot/certbot#4174 Add more documentation, and help for NoCredentialsError. Allow multiple DNS records to be provisioned at once and waited for together. Fix doc strings to use "Certbot" instead of "Let's Encrypt." Set TTL to 0. Create a single boto3 session rather than one per API call. Use pagination in Route53 API in case there are many domains. Add a maximum wait time for update to propagate (10 minutes). --- certbot_route53/__init__.py | 2 +- certbot_route53/authenticator.py | 186 +++++++++++++++++-------------- 2 files changed, 103 insertions(+), 85 deletions(-) diff --git a/certbot_route53/__init__.py b/certbot_route53/__init__.py index 6f519023b..c91c79c22 100644 --- a/certbot_route53/__init__.py +++ b/certbot_route53/__init__.py @@ -1 +1 @@ -"""Let's Encrypt Route53 plugin.""" +"""Certbot Route53 plugin.""" diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index d383044c0..c27d28204 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -1,10 +1,12 @@ -"""Route53 Let's Encrypt authenticator plugin.""" +"""Certbot Route53 authenticator plugin.""" import logging import time +import datetime import zope.interface import boto3 +from botocore.exceptions import NoCredentialsError from acme import challenges @@ -14,112 +16,128 @@ from certbot.plugins import common logger = logging.getLogger(__name__) -TTL = 30 +INSTRUCTIONS = ( + "To use, create an IAM user and attach the AmazonRoute53FullAccess policy, then store " + "the access key ID and secret key in ~/.aws/credentials or in " + "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, as described at " + "https://boto3.readthedocs.io/en/latest/guide/configuration.html") @zope.interface.implementer(interfaces.IAuthenticator) @zope.interface.provider(interfaces.IPluginFactory) class Authenticator(common.Plugin): + """Route53 Authenticator - description = "Route53 Authenticator" + This authenticator solves a DNS01 challenge by uploading the answer to AWS + Route53. + """ + + description = ("Authenticate domain names using the DNS challenge type, " + "by automatically updating TXT records using AWS Route53. Works only " + "if you use AWS Route53 to host DNS for your domains. " + + INSTRUCTIONS) def __init__(self, *args, **kwargs): super(Authenticator, self).__init__(*args, **kwargs) - self._httpd = None + session = boto3.Session() + self.route53_client = session.client("route53") + # A list of (dns name, TXT value) tuples, for cleanup. + self.txt_records = [] def prepare(self): # pylint: disable=missing-docstring,no-self-use pass # pragma: no cover def more_info(self): # pylint: disable=missing-docstring,no-self-use - return ("") + return "Solve a DNS01 challenge using AWS Route53" def get_chall_pref(self, domain): # pylint: disable=missing-docstring,no-self-use,unused-argument return [challenges.DNS01] def perform(self, achalls): # pylint: disable=missing-docstring - responses = [] - for achall in achalls: - responses.append(self._perform_single(achall)) - return responses - - def _find_zone(self, r53, domain): - return max( - ( - zone for zone in r53.list_hosted_zones()["HostedZones"] - if (domain+".").endswith("."+zone["Name"]) or (domain+".") == (zone["Name"]) - ), - key=lambda zone: len(zone["Name"]), - ) - - - def _perform_single(self, achall): - # provision the TXT record, using the domain name given. Assumes the hosted zone exits, else fails the challenge - r53 = boto3.client('route53') - logger.info("Doing validation for " + achall.domain) - try: - zone = self._find_zone(r53, achall.domain) - except ValueError as e: - logger.error("Unable to find matching Route53 zone for domain " + achall.domain) - return None + change_ids = [self._create_single(achall) for achall in achalls] + for change_id in change_ids: + self._wait_for_change(change_id) + return [achall.response(achall.account_key) for achall in achalls] + except NoCredentialsError: + raise Exception("No AWS Route53 credentials found. " + INSTRUCTIONS) - response, validation = achall.response_and_validation() - self._excute_r53_action(r53, achall, zone, validation, 'UPSERT', wait_for_change=True) + def cleanup(self, achalls): # pylint: disable=missing-docstring + for name, value in self.txt_records: + self._delete_txt_record(name, value) - for _ in range(TTL*2): - if response.simple_verify( - achall.chall, - achall.domain, - achall.account_key.public_key(), - ): - break - logger.info("Waiting for DNS propagation...") - time.sleep(1) - else: - logger.error("Unable to verify domain " + achall.domain) - return None + def _create_single(self, achall): + """Create a TXT record, return a change_id""" + name, value = (achall.validation_domain_name(achall.domain), + achall.validation(achall.account_key)) + change_id = self._create_txt_record(name, value) + self.txt_records.append((name, value)) + return change_id - return response + def _find_zone_id_for_domain(self, domain): + paginator = self.route53_client.get_paginator("list_hosted_zones") + zones = [] + for page in paginator.paginate(): + for zone in page["HostedZones"]: + if ( + domain.endswith(zone["Name"]) or + (domain + ".").endswith(zone["Name"]) + ) and not zone["Config"]["PrivateZone"]: + zones.append((zone["Name"], zone["Id"])) - def cleanup(self, achalls): - # pylint: disable=missing-docstring - r53 = boto3.client('route53') - for achall in achalls: - try: - zone = self._find_zone(r53, achall.domain) - except ValueError: - logger.warn("Unable to find zone for " + achall.domain + ". Skipping cleanup.") - continue - - _, validation = achall.response_and_validation() - self._excute_r53_action(r53, achall, zone, validation, 'DELETE') - return None - - - def _excute_r53_action(self, r53, achall, zone, validation, action, wait_for_change=False): - response = r53.change_resource_record_sets( - HostedZoneId=zone["Id"], - ChangeBatch={ - 'Comment': 'Let\'s Encrypt ' + action, - 'Changes': [ - { - 'Action': action, - 'ResourceRecordSet': { - 'Name': achall.validation_domain_name(achall.domain), - 'Type': 'TXT', - 'TTL': TTL, - 'ResourceRecords': [ - { - 'Value': '"' + validation + '"', - }, - ], - }, - }, - ], - }, + if not zones: + raise ValueError( + "Unable to find a Route53 hosted zone for {}".format(domain) ) - if wait_for_change: - while r53.get_change(Id=response["ChangeInfo"]["Id"])["ChangeInfo"]["Status"] == "PENDING": - logger.info("Waiting for " + action + " to propagate...") - time.sleep(1) + # Order the zones that are suffixes for our desired to domain by + # length, this puts them in an order like: + # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"] + # And then we choose the first one, which will be the most specific. + zones.sort(key=lambda z: len(z[0]), reverse=True) + return zones[0][1] + + def _change_txt_record(self, action, zone_id, domain, value): + response = self.route53_client.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Comment": "certbot-route53 certificate validation " + action, + "Changes": [ + { + "Action": action, + "ResourceRecordSet": { + "Name": domain, + "Type": "TXT", + "TTL": 0, + "ResourceRecords": [ + # For some reason TXT records need to be + # manually quoted. + {"Value": '"{}"'.format(value)} + ], + } + } + ] + } + ) + return response["ChangeInfo"]["Id"] + + def _create_txt_record(self, host, value): + zone_id = self._find_zone_id_for_domain(host) + change_id = self._change_txt_record("UPSERT", zone_id, host, value) + return change_id + + def _delete_txt_record(self, host, value): + zone_id = self._find_zone_id_for_domain(host) + change_id = self._change_txt_record("DELETE", zone_id, host, value) + return change_id + + def _wait_for_change(self, change_id): + deadline = datetime.datetime.now() + datetime.timedelta(minutes=10) + while datetime.datetime.now() < deadline: + response = self.route53_client.get_change(Id=change_id) + if response["ChangeInfo"]["Status"] == "INSYNC": + return + time.sleep(5) + raise Exception( + "Timed out waiting for Route53 change. Current status: %s" % + response["ChangeInfo"]["Status"]) From b3a28869c89470943373e8b98417d7e45562ac7d Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Tue, 14 Mar 2017 17:50:51 -0700 Subject: [PATCH 15/25] Respond to review feedback. --- certbot_route53/authenticator.py | 53 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index c27d28204..c04299338 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -38,8 +38,6 @@ class Authenticator(common.Plugin): def __init__(self, *args, **kwargs): super(Authenticator, self).__init__(*args, **kwargs) - session = boto3.Session() - self.route53_client = session.client("route53") # A list of (dns name, TXT value) tuples, for cleanup. self.txt_records = [] @@ -64,25 +62,34 @@ class Authenticator(common.Plugin): def cleanup(self, achalls): # pylint: disable=missing-docstring for name, value in self.txt_records: - self._delete_txt_record(name, value) + self._change_txt_record("DELETE", name, value) def _create_single(self, achall): """Create a TXT record, return a change_id""" - name, value = (achall.validation_domain_name(achall.domain), - achall.validation(achall.account_key)) - change_id = self._create_txt_record(name, value) + name = achall.validation_domain_name(achall.domain) + value = achall.validation(achall.account_key) + change_id = self._change_txt_record("UPSERT", name, value) self.txt_records.append((name, value)) return change_id def _find_zone_id_for_domain(self, domain): - paginator = self.route53_client.get_paginator("list_hosted_zones") + """Find the zone id responsible a given FQDN. + + That is, the id for the zone whose name is the longest parent of the + domain. + + domain should not have a trailing dot. + """ + client = boto3.client("route53") + paginator = client.get_paginator("list_hosted_zones") zones = [] + target_labels = domain.split(".") for page in paginator.paginate(): for zone in page["HostedZones"]: - if ( - domain.endswith(zone["Name"]) or - (domain + ".").endswith(zone["Name"]) - ) and not zone["Config"]["PrivateZone"]: + if zone["Config"]["PrivateZone"]: + continue + candidate_labels = zone["Name"].rstrip(".").split(".") + if candidate_labels == target_labels[-len(candidate_labels):]: zones.append((zone["Name"], zone["Id"])) if not zones: @@ -97,8 +104,10 @@ class Authenticator(common.Plugin): zones.sort(key=lambda z: len(z[0]), reverse=True) return zones[0][1] - def _change_txt_record(self, action, zone_id, domain, value): - response = self.route53_client.change_resource_record_sets( + def _change_txt_record(self, action, domain, value): + zone_id = self._find_zone_id_for_domain(domain) + client = boto3.client("route53") + response = client.change_resource_record_sets( HostedZoneId=zone_id, ChangeBatch={ "Comment": "certbot-route53 certificate validation " + action, @@ -108,7 +117,7 @@ class Authenticator(common.Plugin): "ResourceRecordSet": { "Name": domain, "Type": "TXT", - "TTL": 0, + "TTL": 10, "ResourceRecords": [ # For some reason TXT records need to be # manually quoted. @@ -121,20 +130,10 @@ class Authenticator(common.Plugin): ) return response["ChangeInfo"]["Id"] - def _create_txt_record(self, host, value): - zone_id = self._find_zone_id_for_domain(host) - change_id = self._change_txt_record("UPSERT", zone_id, host, value) - return change_id - - def _delete_txt_record(self, host, value): - zone_id = self._find_zone_id_for_domain(host) - change_id = self._change_txt_record("DELETE", zone_id, host, value) - return change_id - def _wait_for_change(self, change_id): - deadline = datetime.datetime.now() + datetime.timedelta(minutes=10) - while datetime.datetime.now() < deadline: - response = self.route53_client.get_change(Id=change_id) + for n in range(0, 120): + client = boto3.client("route53") + response = client.get_change(Id=change_id) if response["ChangeInfo"]["Status"] == "INSYNC": return time.sleep(5) From 4a3aa8dd11b0efda7d5a6d59d7c823d7b3eb1be1 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Tue, 14 Mar 2017 17:57:47 -0700 Subject: [PATCH 16/25] Remove documentation about creating IAM users. --- certbot_route53/authenticator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index c04299338..fabf2dec4 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -17,10 +17,9 @@ from certbot.plugins import common logger = logging.getLogger(__name__) INSTRUCTIONS = ( - "To use, create an IAM user and attach the AmazonRoute53FullAccess policy, then store " - "the access key ID and secret key in ~/.aws/credentials or in " - "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, as described at " - "https://boto3.readthedocs.io/en/latest/guide/configuration.html") + "To use, configure credentials as described at " + "https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials " + "and add the necessary permissions for Route53 access") @zope.interface.implementer(interfaces.IAuthenticator) @zope.interface.provider(interfaces.IPluginFactory) From cb720b06182812c692c3073d5c4c007b5909273c Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Wed, 15 Mar 2017 11:32:47 -0700 Subject: [PATCH 17/25] Address review feedback. --- certbot_route53/authenticator.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index fabf2dec4..82f42281e 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -6,7 +6,7 @@ import datetime import zope.interface import boto3 -from botocore.exceptions import NoCredentialsError +from botocore.exceptions import NoCredentialsError, ClientError from acme import challenges @@ -16,10 +16,12 @@ from certbot.plugins import common logger = logging.getLogger(__name__) +TTL = 10 + INSTRUCTIONS = ( - "To use, configure credentials as described at " + "To use certbot-route53, configure credentials as described at " "https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials " - "and add the necessary permissions for Route53 access") + "and add the necessary permissions for Route53 access.") @zope.interface.implementer(interfaces.IAuthenticator) @zope.interface.provider(interfaces.IPluginFactory) @@ -37,8 +39,6 @@ class Authenticator(common.Plugin): def __init__(self, *args, **kwargs): super(Authenticator, self).__init__(*args, **kwargs) - # A list of (dns name, TXT value) tuples, for cleanup. - self.txt_records = [] def prepare(self): # pylint: disable=missing-docstring,no-self-use pass # pragma: no cover @@ -58,9 +58,13 @@ class Authenticator(common.Plugin): return [achall.response(achall.account_key) for achall in achalls] except NoCredentialsError: raise Exception("No AWS Route53 credentials found. " + INSTRUCTIONS) + except ClientError as e: + raise Exception(str(e) + "\n" + INSTRUCTIONS) def cleanup(self, achalls): # pylint: disable=missing-docstring - for name, value in self.txt_records: + for achall in achalls: + name = achall.validation_domain_name(achall.domain) + value = achall.validation(achall.account_key) self._change_txt_record("DELETE", name, value) def _create_single(self, achall): @@ -68,7 +72,6 @@ class Authenticator(common.Plugin): name = achall.validation_domain_name(achall.domain) value = achall.validation(achall.account_key) change_id = self._change_txt_record("UPSERT", name, value) - self.txt_records.append((name, value)) return change_id def _find_zone_id_for_domain(self, domain): @@ -76,13 +79,11 @@ class Authenticator(common.Plugin): That is, the id for the zone whose name is the longest parent of the domain. - - domain should not have a trailing dot. """ client = boto3.client("route53") paginator = client.get_paginator("list_hosted_zones") zones = [] - target_labels = domain.split(".") + target_labels = domain.rstrip(".").split(".") for page in paginator.paginate(): for zone in page["HostedZones"]: if zone["Config"]["PrivateZone"]: @@ -116,7 +117,7 @@ class Authenticator(common.Plugin): "ResourceRecordSet": { "Name": domain, "Type": "TXT", - "TTL": 10, + "TTL": TTL, "ResourceRecords": [ # For some reason TXT records need to be # manually quoted. @@ -130,8 +131,15 @@ class Authenticator(common.Plugin): return response["ChangeInfo"]["Id"] def _wait_for_change(self, change_id): + """Wait for TTL of any previous attempt to expire, then for INSYNC. + + Once Route53 returns INSYNC, challenge record is ready on all Route53 + DNS servers: + https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html + """ + time.sleep(TTL) + client = boto3.client("route53") for n in range(0, 120): - client = boto3.client("route53") response = client.get_change(Id=change_id) if response["ChangeInfo"]["Status"] == "INSYNC": return From d67de61ad8c41bdf9d55768616bbb1a320972c77 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Wed, 15 Mar 2017 11:38:26 -0700 Subject: [PATCH 18/25] Move sleep(TTL) into perform. This means we only do it once, even when there are many hostnames. --- certbot_route53/authenticator.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index 82f42281e..fe87f96ea 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -53,6 +53,11 @@ class Authenticator(common.Plugin): def perform(self, achalls): # pylint: disable=missing-docstring try: change_ids = [self._create_single(achall) for achall in achalls] + # Sleep for at least the TTL, to ensure that any records cached by + # the ACME server after previous validation attempts are gone. In + # most cases we'll need to wait at least this long for the Route53 + # records to propagate, so this doesn't delay us much. + time.sleep(TTL) for change_id in change_ids: self._wait_for_change(change_id) return [achall.response(achall.account_key) for achall in achalls] @@ -131,13 +136,9 @@ class Authenticator(common.Plugin): return response["ChangeInfo"]["Id"] def _wait_for_change(self, change_id): - """Wait for TTL of any previous attempt to expire, then for INSYNC. - - Once Route53 returns INSYNC, challenge record is ready on all Route53 - DNS servers: + """Wait for a change to be propagated to all Route53 DNS servers. https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html """ - time.sleep(TTL) client = boto3.client("route53") for n in range(0, 120): response = client.get_change(Id=change_id) From 1542bce2613b9e5c6664a63b65c4b3b0331ba629 Mon Sep 17 00:00:00 2001 From: Paulo Koch Date: Thu, 16 Mar 2017 14:01:11 +0000 Subject: [PATCH 19/25] Fix the sample policy --- sample-aws-policy.json | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/sample-aws-policy.json b/sample-aws-policy.json index 59c8fb7c7..0b4dcae41 100644 --- a/sample-aws-policy.json +++ b/sample-aws-policy.json @@ -5,19 +5,8 @@ { "Effect": "Allow", "Action": [ - "iam:UploadServerCertificate", - "iam:UpdateServerCertificate", - "iam:DeleteServerCertificate" - ], - "Resource": [ - "*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "route53:List*", - "route53:Get*", + "route53:ListHostedZones", + "route53:GetChange" ], "Resource": [ "*" From 3f7efbfa3c4dbcc1b2a191bf9cbdc56a1df0c424 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 17 Mar 2017 13:26:42 -0700 Subject: [PATCH 20/25] Sleep after wait; stack trace --- certbot_route53/authenticator.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index fe87f96ea..c53d5366a 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -53,18 +53,17 @@ class Authenticator(common.Plugin): def perform(self, achalls): # pylint: disable=missing-docstring try: change_ids = [self._create_single(achall) for achall in achalls] + for change_id in change_ids: + self._wait_for_change(change_id) # Sleep for at least the TTL, to ensure that any records cached by # the ACME server after previous validation attempts are gone. In # most cases we'll need to wait at least this long for the Route53 # records to propagate, so this doesn't delay us much. time.sleep(TTL) - for change_id in change_ids: - self._wait_for_change(change_id) return [achall.response(achall.account_key) for achall in achalls] - except NoCredentialsError: - raise Exception("No AWS Route53 credentials found. " + INSTRUCTIONS) - except ClientError as e: - raise Exception(str(e) + "\n" + INSTRUCTIONS) + except (NoCredentialsError, ClientError) as e: + e.args = ("\n".join([str(e), INSTRUCTIONS]),) + raise def cleanup(self, achalls): # pylint: disable=missing-docstring for achall in achalls: From 8850bd126ba811f2f498c9082e0da653e5031e24 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 17 Mar 2017 13:30:47 -0700 Subject: [PATCH 21/25] Final review feedback. --- certbot_route53/authenticator.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/certbot_route53/authenticator.py b/certbot_route53/authenticator.py index c53d5366a..855165455 100644 --- a/certbot_route53/authenticator.py +++ b/certbot_route53/authenticator.py @@ -39,6 +39,7 @@ class Authenticator(common.Plugin): def __init__(self, *args, **kwargs): super(Authenticator, self).__init__(*args, **kwargs) + self.r53 = boto3.client("route53") def prepare(self): # pylint: disable=missing-docstring,no-self-use pass # pragma: no cover @@ -52,7 +53,11 @@ class Authenticator(common.Plugin): def perform(self, achalls): # pylint: disable=missing-docstring try: - change_ids = [self._create_single(achall) for achall in achalls] + change_ids = [ + self._change_txt_record("UPSERT", achall) + for achall in achalls + ] + for change_id in change_ids: self._wait_for_change(change_id) # Sleep for at least the TTL, to ensure that any records cached by @@ -67,16 +72,7 @@ class Authenticator(common.Plugin): def cleanup(self, achalls): # pylint: disable=missing-docstring for achall in achalls: - name = achall.validation_domain_name(achall.domain) - value = achall.validation(achall.account_key) - self._change_txt_record("DELETE", name, value) - - def _create_single(self, achall): - """Create a TXT record, return a change_id""" - name = achall.validation_domain_name(achall.domain) - value = achall.validation(achall.account_key) - change_id = self._change_txt_record("UPSERT", name, value) - return change_id + self._change_txt_record("DELETE", achall) def _find_zone_id_for_domain(self, domain): """Find the zone id responsible a given FQDN. @@ -84,14 +80,14 @@ class Authenticator(common.Plugin): That is, the id for the zone whose name is the longest parent of the domain. """ - client = boto3.client("route53") - paginator = client.get_paginator("list_hosted_zones") + paginator = self.r53.get_paginator("list_hosted_zones") zones = [] target_labels = domain.rstrip(".").split(".") for page in paginator.paginate(): for zone in page["HostedZones"]: if zone["Config"]["PrivateZone"]: continue + candidate_labels = zone["Name"].rstrip(".").split(".") if candidate_labels == target_labels[-len(candidate_labels):]: zones.append((zone["Name"], zone["Id"])) @@ -108,10 +104,13 @@ class Authenticator(common.Plugin): zones.sort(key=lambda z: len(z[0]), reverse=True) return zones[0][1] - def _change_txt_record(self, action, domain, value): + def _change_txt_record(self, action, achall): + domain = achall.validation_domain_name(achall.domain) + value = achall.validation(achall.account_key) + zone_id = self._find_zone_id_for_domain(domain) - client = boto3.client("route53") - response = client.change_resource_record_sets( + + response = self.r53.change_resource_record_sets( HostedZoneId=zone_id, ChangeBatch={ "Comment": "certbot-route53 certificate validation " + action, From 1b65e17999b119369ff63fd9917d6e2fe2776b18 Mon Sep 17 00:00:00 2001 From: Paulo Koch Date: Fri, 17 Mar 2017 10:04:04 +0000 Subject: [PATCH 22/25] Tidy up installation --- LICENSE.txt | 1 - MANIFEST.in | 6 ++---- README.md | 27 ++++++++++++--------------- setup.cfg | 4 ++-- setup.py | 19 ++----------------- 5 files changed, 18 insertions(+), 39 deletions(-) delete mode 100644 LICENSE.txt diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 7ff097c3c..000000000 --- a/LICENSE.txt +++ /dev/null @@ -1 +0,0 @@ -See LICENSE diff --git a/MANIFEST.in b/MANIFEST.in index 568ab3f2e..9575a1c62 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,2 @@ -include LICENSE.txt -include README.md -recursive-include docs * -recursive-include certbot_route53/tests/testdata * +include LICENSE +include README diff --git a/README.md b/README.md index 59af3615c..7dab0b7f6 100644 --- a/README.md +++ b/README.md @@ -3,32 +3,29 @@ ### Before you start -It's expected that the root hosted zone for the domain in question already exists in your account. +It's expected that the root hosted zone for the domain in question already +exists in your account. ### Setup 1. Create a virtual environment -2. Make sure you have libssl-dev (or your regional equivalent) installed. -`pycparser` suffers from -https://github.com/eliben/pycparser/issues/148, which is why we need to -recompile it, which depends on `libssl-dev`. +2. Update its pip and setuptools (`VENV/bin/pip install -U setuptools pip`) +to avoid problems with cryptography's dependency on setuptools>=11.3. -3. Install by adding these to your requirements.txt file: - -``` ---no-binary pycparser --e git+https://github.com/certbot/certbot.git#egg=certbot --e git+https://github.com/certbot/certbot.git#egg=acme&subdirectory=acme -certbot-route53 -``` -We need DNS01 support in certbot, which is only available in master for now. +3. Make sure you have libssl-dev and libffi (or your regional equivalents) +installed. You might have to set compiler flags to pick things up (I have to +use `CPPFLAGS=-I/usr/local/opt/openssl/include +LDFLAGS=-L/usr/local/opt/openssl/lib` on my macOS to pick up brew's openssl, +for example). +4. Install this package. ### How to use it Make sure you have access to AWS's Route53 service, either through IAM roles or -via `.aws/credentials`. +via `.aws/credentials`. Check out +(sample-aws-policy.json)[sample-aws-policy.json]. To generate a certificate: ``` diff --git a/setup.cfg b/setup.cfg index b88034e41..3c6e79cf3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ -[metadata] -description-file = README.md +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py index 4b9b754a8..49b1ea467 100644 --- a/setup.py +++ b/setup.py @@ -6,24 +6,10 @@ from setuptools import find_packages version = '0.1.5' install_requires = [ - 'acme>=0.9.0.dev0', - 'certbot>=0.9.0.dev0', - 'PyOpenSSL', - 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? - 'setuptools', # pkg_resources + 'acme>=0.9.0', + 'certbot>=0.9.0', 'zope.interface', 'boto3', - 'dnspython', -] - -if sys.version_info < (2, 7): - install_requires.append('mock<1.1.0') -else: - install_requires.append('mock') - -docs_extras = [ - 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags - 'sphinx_rtd_theme', ] setup( @@ -51,7 +37,6 @@ setup( 'Topic :: Utilities', ], packages=find_packages(), - include_package_data=True, install_requires=install_requires, keywords=['certbot', 'route53', 'aws'], entry_points={ From 08932836f36d58ce175a926d0706171db21a036d Mon Sep 17 00:00:00 2001 From: Paulo Koch Date: Fri, 17 Mar 2017 10:06:42 +0000 Subject: [PATCH 23/25] Add my janky tester --- tester.pkoch-macos_sierra.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100755 tester.pkoch-macos_sierra.sh diff --git a/tester.pkoch-macos_sierra.sh b/tester.pkoch-macos_sierra.sh new file mode 100755 index 000000000..dbbaa2251 --- /dev/null +++ b/tester.pkoch-macos_sierra.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# I just wanted a place to dump the incantations I use for testing. +set -e + +brew install openssl libffi + +rm -rf scratch; mkdir scratch + +virtualenv scratch/venv -p /usr/local/bin/python2.7 +scratch/venv/bin/pip install -U pip setuptools + +CPPFLAGS=-I/usr/local/opt/openssl/include LDFLAGS=-L/usr/local/opt/openssl/lib scratch/venv/bin/pip install -e . + +scratch/venv/bin/certbot certonly -n --agree-tos --test-cert --email pkoch@lifeonmars.pt -a certbot-route53:auth -d pkoch.lifeonmars.pt --work-dir scratch --config-dir scratch --logs-dir scratch + +rm -rf scratch From ab0d5f830d83fbbae43c41039364b3fc5d06acc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20K=C3=B6ch?= Date: Wed, 5 Apr 2017 11:05:01 +0100 Subject: [PATCH 24/25] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 7dab0b7f6..cec9c295c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ ## Route53 plugin for Let's Encrypt client - ### Before you start It's expected that the root hosted zone for the domain in question already @@ -25,7 +24,7 @@ for example). Make sure you have access to AWS's Route53 service, either through IAM roles or via `.aws/credentials`. Check out -(sample-aws-policy.json)[sample-aws-policy.json]. +[sample-aws-policy.json](sample-aws-policy.json) for the necessary permissions. To generate a certificate: ``` From 16d9537c418ebc3d0d1ce946bb61e04e9bf4dac8 Mon Sep 17 00:00:00 2001 From: Zach Shepherd Date: Thu, 18 May 2017 16:44:05 -0700 Subject: [PATCH 25/25] Moved files to 'certbot-route53' --- .gitignore => certbot-route53/.gitignore | 0 LICENSE => certbot-route53/LICENSE | 0 MANIFEST.in => certbot-route53/MANIFEST.in | 0 README.md => certbot-route53/README.md | 0 {certbot_route53 => certbot-route53/certbot_route53}/__init__.py | 0 .../certbot_route53}/authenticator.py | 0 sample-aws-policy.json => certbot-route53/sample-aws-policy.json | 0 setup.cfg => certbot-route53/setup.cfg | 0 setup.py => certbot-route53/setup.py | 0 .../tester.pkoch-macos_sierra.sh | 0 10 files changed, 0 insertions(+), 0 deletions(-) rename .gitignore => certbot-route53/.gitignore (100%) rename LICENSE => certbot-route53/LICENSE (100%) rename MANIFEST.in => certbot-route53/MANIFEST.in (100%) rename README.md => certbot-route53/README.md (100%) rename {certbot_route53 => certbot-route53/certbot_route53}/__init__.py (100%) rename {certbot_route53 => certbot-route53/certbot_route53}/authenticator.py (100%) rename sample-aws-policy.json => certbot-route53/sample-aws-policy.json (100%) rename setup.cfg => certbot-route53/setup.cfg (100%) rename setup.py => certbot-route53/setup.py (100%) rename tester.pkoch-macos_sierra.sh => certbot-route53/tester.pkoch-macos_sierra.sh (100%) diff --git a/.gitignore b/certbot-route53/.gitignore similarity index 100% rename from .gitignore rename to certbot-route53/.gitignore diff --git a/LICENSE b/certbot-route53/LICENSE similarity index 100% rename from LICENSE rename to certbot-route53/LICENSE diff --git a/MANIFEST.in b/certbot-route53/MANIFEST.in similarity index 100% rename from MANIFEST.in rename to certbot-route53/MANIFEST.in diff --git a/README.md b/certbot-route53/README.md similarity index 100% rename from README.md rename to certbot-route53/README.md diff --git a/certbot_route53/__init__.py b/certbot-route53/certbot_route53/__init__.py similarity index 100% rename from certbot_route53/__init__.py rename to certbot-route53/certbot_route53/__init__.py diff --git a/certbot_route53/authenticator.py b/certbot-route53/certbot_route53/authenticator.py similarity index 100% rename from certbot_route53/authenticator.py rename to certbot-route53/certbot_route53/authenticator.py diff --git a/sample-aws-policy.json b/certbot-route53/sample-aws-policy.json similarity index 100% rename from sample-aws-policy.json rename to certbot-route53/sample-aws-policy.json diff --git a/setup.cfg b/certbot-route53/setup.cfg similarity index 100% rename from setup.cfg rename to certbot-route53/setup.cfg diff --git a/setup.py b/certbot-route53/setup.py similarity index 100% rename from setup.py rename to certbot-route53/setup.py diff --git a/tester.pkoch-macos_sierra.sh b/certbot-route53/tester.pkoch-macos_sierra.sh similarity index 100% rename from tester.pkoch-macos_sierra.sh rename to certbot-route53/tester.pkoch-macos_sierra.sh