New upstream version 0.22.0

parents
This diff is collapsed.
include LICENSE
include README
recursive-include docs *
Metadata-Version: 1.2
Name: certbot-dns-route53
Version: 0.22.0
Summary: Route53 DNS Authenticator plugin for Certbot
Home-page: https://github.com/certbot/certbot
Author: Certbot Project
Author-email: client-dev@letsencrypt.org
License: Apache License 2.0
Description-Content-Type: UNKNOWN
Description: UNKNOWN
Keywords: certbot,route53,aws
Platform: UNKNOWN
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Plugins
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 2
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Classifier: Topic :: System :: Installation/Setup
Classifier: Topic :: System :: Networking
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*
## 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. Create a virtual environment
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. 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`. Check out
[sample-aws-policy.json](examples/sample-aws-policy.json) for the necessary permissions.
To generate a certificate:
```
certbot certonly \
-n --agree-tos --email DEVOPS@COMPANY.COM \
--dns-route53 \
-d MY.DOMAIN.NAME
```
Metadata-Version: 1.2
Name: certbot-dns-route53
Version: 0.22.0
Summary: Route53 DNS Authenticator plugin for Certbot
Home-page: https://github.com/certbot/certbot
Author: Certbot Project
Author-email: client-dev@letsencrypt.org
License: Apache License 2.0
Description-Content-Type: UNKNOWN
Description: UNKNOWN
Keywords: certbot,route53,aws
Platform: UNKNOWN
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Plugins
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 2
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Classifier: Topic :: System :: Installation/Setup
Classifier: Topic :: System :: Networking
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*
LICENSE
MANIFEST.in
README.md
setup.cfg
setup.py
certbot_dns_route53/__init__.py
certbot_dns_route53/authenticator.py
certbot_dns_route53/dns_route53.py
certbot_dns_route53/dns_route53_test.py
certbot_dns_route53.egg-info/PKG-INFO
certbot_dns_route53.egg-info/SOURCES.txt
certbot_dns_route53.egg-info/dependency_links.txt
certbot_dns_route53.egg-info/entry_points.txt
certbot_dns_route53.egg-info/requires.txt
certbot_dns_route53.egg-info/top_level.txt
docs/.gitignore
docs/Makefile
docs/api.rst
docs/conf.py
docs/index.rst
docs/make.bat
docs/api/authenticator.rst
docs/api/dns_route53.rst
\ No newline at end of file
[certbot.plugins]
certbot-route53:auth = certbot_dns_route53.authenticator:Authenticator
dns-route53 = certbot_dns_route53.dns_route53:Authenticator
acme>=0.21.1
certbot>=0.21.1
boto3
mock
setuptools
zope.interface
"""
The `~certbot_dns_route53.dns_route53` plugin automates the process of
completing a ``dns-01`` challenge (`~acme.challenges.DNS01`) by creating, and
subsequently removing, TXT records using the Amazon Web Services Route 53 API.
Named Arguments
---------------
======================================== =====================================
``--dns-route53-propagation-seconds`` The number of seconds to wait for DNS
to propagate before asking the ACME
server to verify the DNS record.
(Default: 10)
======================================== =====================================
Credentials
-----------
Use of this plugin requires a configuration file containing Amazon Web Sevices
API credentials for an account with the following permissions:
* ``route53:ListHostedZones``
* ``route53:GetChange``
* ``route53:ChangeResourceRecordSets``
These permissions can be captured in an AWS policy like the one below. Amazon
provides `information about managing access <https://docs.aws.amazon.com/Route53
/latest/DeveloperGuide/access-control-overview.html>`_ and `information about
the required permissions <https://docs.aws.amazon.com/Route53/latest
/DeveloperGuide/r53-api-permissions-ref.html>`_
.. code-block:: json
:name: sample-aws-policy.json
:caption: Example AWS policy file:
{
"Version": "2012-10-17",
"Id": "certbot-dns-route53 sample policy",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ListHostedZones",
"route53:GetChange"
],
"Resource": [
"*"
]
},
{
"Effect" : "Allow",
"Action" : [
"route53:ChangeResourceRecordSets"
],
"Resource" : [
"arn:aws:route53:::hostedzone/YOURHOSTEDZONEID"
]
}
]
}
The `access keys <https://docs.aws.amazon.com/general/latest/gr
/aws-sec-cred-types.html#access-keys-and-secret-access-keys>`_ for an account
with these permissions must be supplied in one of the following ways, which are
discussed in more detail in the Boto3 library's documentation about `configuring
credentials <https://boto3.readthedocs.io/en/latest/guide/configuration.html
#best-practices-for-configuring-credentials>`_.
* Using the ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environment
variables.
* Using a credentials configuration file at the default location,
``~/.aws/config``.
* Using a credentials configuration file at a path supplied using the
``AWS_CONFIG_FILE`` environment variable.
.. code-block:: ini
:name: config.ini
:caption: Example credentials config file:
[default]
aws_access_key_id=AKIAIOSFODNN7EXAMPLE
aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
.. caution::
You should protect these API credentials as you would a password. Users who
can read this file can use these credentials to issue some types of API calls
on your behalf, limited by the permissions assigned to the account. Users who
can cause Certbot to run using these credentials can complete a ``dns-01``
challenge to acquire new certificates or revoke existing certificates for
domains these credentials are authorized to manage.
Examples
--------
.. code-block:: bash
:caption: To acquire a certificate for ``example.com``
certbot certonly \\
--dns-route53 \\
-d example.com
.. code-block:: bash
:caption: To acquire a single certificate for both ``example.com`` and
``www.example.com``
certbot certonly \\
--dns-route53 \\
-d example.com \\
-d www.example.com
.. code-block:: bash
:caption: To acquire a certificate for ``example.com``, waiting 30 seconds
for DNS propagation
certbot certonly \\
--dns-route53 \\
--dns-route53-propagation-seconds 30 \\
-d example.com
"""
"""Shim around `~certbot_dns_route53.dns_route53` for backwards compatibility."""
import warnings
import zope.interface
from certbot import interfaces
from certbot_dns_route53 import dns_route53
@zope.interface.implementer(interfaces.IAuthenticator)
@zope.interface.provider(interfaces.IPluginFactory)
class Authenticator(dns_route53.Authenticator):
"""Shim around `~certbot_dns_route53.dns_route53.Authenticator` for backwards compatibility."""
hidden = True
def __init__(self, *args, **kwargs):
warnings.warn("The 'authenticator' module was renamed 'dns_route53'",
DeprecationWarning)
super(Authenticator, self).__init__(*args, **kwargs)
"""Certbot Route53 authenticator plugin."""
import collections
import logging
import time
import boto3
import zope.interface
from botocore.exceptions import NoCredentialsError, ClientError
from certbot import errors
from certbot import interfaces
from certbot.plugins import dns_common
logger = logging.getLogger(__name__)
INSTRUCTIONS = (
"To use certbot-dns-route53, configure credentials as described at "
"https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials " # pylint: disable=line-too-long
"and add the necessary permissions for Route53 access.")
@zope.interface.implementer(interfaces.IAuthenticator)
@zope.interface.provider(interfaces.IPluginFactory)
class Authenticator(dns_common.DNSAuthenticator):
"""Route53 Authenticator
This authenticator solves a DNS01 challenge by uploading the answer to AWS
Route53.
"""
description = ("Obtain certificates using a DNS TXT record (if you are using AWS Route53 for "
"DNS).")
ttl = 10
def __init__(self, *args, **kwargs):
super(Authenticator, self).__init__(*args, **kwargs)
self.r53 = boto3.client("route53")
self._resource_records = collections.defaultdict(list)
def more_info(self): # pylint: disable=missing-docstring,no-self-use
return "Solve a DNS01 challenge using AWS Route53"
def _setup_credentials(self):
pass
def _perform(self, domain, validation_domain_name, validation):
try:
change_id = self._change_txt_record("UPSERT", validation_domain_name, validation)
self._wait_for_change(change_id)
except (NoCredentialsError, ClientError) as e:
logger.debug('Encountered error during perform: %s', e, exc_info=True)
raise errors.PluginError("\n".join([str(e), INSTRUCTIONS]))
def _cleanup(self, domain, validation_domain_name, validation):
try:
self._change_txt_record("DELETE", validation_domain_name, validation)
except (NoCredentialsError, ClientError) as e:
logger.debug('Encountered error during cleanup: %s', e, exc_info=True)
def _find_zone_id_for_domain(self, domain):
"""Find the zone id responsible a given FQDN.
That is, the id for the zone whose name is the longest parent of the
domain.
"""
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"]))
if not zones:
raise errors.PluginError(
"Unable to find a Route53 hosted zone for {0}".format(domain)
)
# 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, validation_domain_name, validation):
zone_id = self._find_zone_id_for_domain(validation_domain_name)
rrecords = self._resource_records[validation_domain_name]
challenge = {"Value": '"{0}"'.format(validation)}
if action == "DELETE":
# Remove the record being deleted from the list of tracked records
rrecords.remove(challenge)
if rrecords:
# Need to update instead, as we're not deleting the rrset
action = "UPSERT"
else:
# Create a new list containing the record to use with DELETE
rrecords = [challenge]
else:
rrecords.append(challenge)
response = self.r53.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch={
"Comment": "certbot-dns-route53 certificate validation " + action,
"Changes": [
{
"Action": action,
"ResourceRecordSet": {
"Name": validation_domain_name,
"Type": "TXT",
"TTL": self.ttl,
"ResourceRecords": rrecords,
}
}
]
}
)
return response["ChangeInfo"]["Id"]
def _wait_for_change(self, change_id):
"""Wait for a change to be propagated to all Route53 DNS servers.
https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html
"""
for unused_n in range(0, 120):
response = self.r53.get_change(Id=change_id)
if response["ChangeInfo"]["Status"] == "INSYNC":
return
time.sleep(5)
raise errors.PluginError(
"Timed out waiting for Route53 change. Current status: %s" %
response["ChangeInfo"]["Status"])
"""Tests for certbot_dns_route53.dns_route53.Authenticator"""
import unittest
import mock
from botocore.exceptions import NoCredentialsError, ClientError
from certbot import errors
from certbot.plugins import dns_test_common
from certbot.plugins.dns_test_common import DOMAIN
class AuthenticatorTest(unittest.TestCase, dns_test_common.BaseAuthenticatorTest):
# pylint: disable=protected-access
def setUp(self):
from certbot_dns_route53.dns_route53 import Authenticator
super(AuthenticatorTest, self).setUp()
self.config = mock.MagicMock()
self.auth = Authenticator(self.config, "route53")
def test_perform(self):
self.auth._change_txt_record = mock.MagicMock()
self.auth._wait_for_change = mock.MagicMock()
self.auth.perform([self.achall])
self.auth._change_txt_record.assert_called_once_with("UPSERT",
'_acme-challenge.' + DOMAIN,
mock.ANY)
self.assertEqual(self.auth._wait_for_change.call_count, 1)
def test_perform_no_credentials_error(self):
self.auth._change_txt_record = mock.MagicMock(side_effect=NoCredentialsError)
self.assertRaises(errors.PluginError,
self.auth.perform,
[self.achall])
def test_perform_client_error(self):
self.auth._change_txt_record = mock.MagicMock(
side_effect=ClientError({"Error": {"Code": "foo"}}, "bar"))
self.assertRaises(errors.PluginError,
self.auth.perform,
[self.achall])
def test_cleanup(self):
self.auth._attempt_cleanup = True
self.auth._change_txt_record = mock.MagicMock()
self.auth.cleanup([self.achall])
self.auth._change_txt_record.assert_called_once_with("DELETE",
'_acme-challenge.'+DOMAIN,
mock.ANY)
def test_cleanup_no_credentials_error(self):
self.auth._attempt_cleanup = True
self.auth._change_txt_record = mock.MagicMock(side_effect=NoCredentialsError)
self.auth.cleanup([self.achall])
def test_cleanup_client_error(self):
self.auth._attempt_cleanup = True
self.auth._change_txt_record = mock.MagicMock(
side_effect=ClientError({"Error": {"Code": "foo"}}, "bar"))
self.auth.cleanup([self.achall])
class ClientTest(unittest.TestCase):
# pylint: disable=protected-access
PRIVATE_ZONE = {
"Id": "BAD-PRIVATE",
"Name": "example.com",
"Config": {
"PrivateZone": True
}
}
EXAMPLE_NET_ZONE = {
"Id": "BAD-WRONG-TLD",
"Name": "example.net",
"Config": {
"PrivateZone": False
}
}
EXAMPLE_COM_ZONE = {
"Id": "EXAMPLE",
"Name": "example.com",
"Config": {
"PrivateZone": False
}
}
FOO_EXAMPLE_COM_ZONE = {
"Id": "FOO",
"Name": "foo.example.com",
"Config": {
"PrivateZone": False
}
}
def setUp(self):
from certbot_dns_route53.dns_route53 import Authenticator
super(ClientTest, self).setUp()
self.config = mock.MagicMock()
self.client = Authenticator(self.config, "route53")
def test_find_zone_id_for_domain(self):
self.client.r53.get_paginator = mock.MagicMock()
self.client.r53.get_paginator().paginate.return_value = [
{
"HostedZones": [
self.EXAMPLE_NET_ZONE,
self.EXAMPLE_COM_ZONE,
]
}
]
result = self.client._find_zone_id_for_domain("foo.example.com")
self.assertEqual(result, "EXAMPLE")
def test_find_zone_id_for_domain_pagination(self):
self.client.r53.get_paginator = mock.MagicMock()
self.client.r53.get_paginator().paginate.return_value = [
{
"HostedZones": [
self.PRIVATE_ZONE,
self.EXAMPLE_COM_ZONE,
]
},
{
"HostedZones": [
self.PRIVATE_ZONE,
self.FOO_EXAMPLE_COM_ZONE,
]
}
]
result = self.client._find_zone_id_for_domain("foo.example.com")
self.assertEqual(result, "FOO")
def test_find_zone_id_for_domain_no_results(self):
self.client.r53.get_paginator = mock.MagicMock()
self.client.r53.get_paginator().paginate.return_value = []
self.assertRaises(errors.PluginError,
self.client._find_zone_id_for_domain,
"foo.example.com")
def test_find_zone_id_for_domain_no_correct_results(self):
self.client.r53.get_paginator = mock.MagicMock()
self.client.r53.get_paginator().paginate.return_value = [
{
"HostedZones": [
self.PRIVATE_ZONE,
self.EXAMPLE_NET_ZONE,
]
},
]
self.assertRaises(errors.PluginError,
self.client._find_zone_id_for_domain,
"foo.example.com")
def test_change_txt_record(self):
self.client._find_zone_id_for_domain = mock.MagicMock()
self.client.r53.change_resource_record_sets = mock.MagicMock(
return_value={"ChangeInfo": {"Id": 1}})
self.client._change_txt_record("FOO", DOMAIN, "foo")
call_count = self.client.r53.change_resource_record_sets.call_count
self.assertEqual(call_count, 1)
def test_change_txt_record_delete(self):
self.client._find_zone_id_for_domain = mock.MagicMock()
self.client.r53.change_resource_record_sets = mock.MagicMock(
return_value={"ChangeInfo": {"Id": 1}})
validation = "some-value"
validation_record = {"Value": '"{0}"'.format(validation)}
self.client._resource_records[DOMAIN] = [validation_record]
self.client._change_txt_record("DELETE", DOMAIN, validation)
call_count = self.client.r53.change_resource_record_sets.call_count
self.assertEqual(call_count, 1)
call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1]
call_args_batch = call_args["ChangeBatch"]["Changes"][0]
self.assertEqual(call_args_batch["Action"], "DELETE")
self.assertEqual(
call_args_batch["ResourceRecordSet"]["ResourceRecords"],
[validation_record])
def test_change_txt_record_multirecord(self):
self.client._find_zone_id_for_domain = mock.MagicMock()
self.client._get_validation_rrset = mock.MagicMock()
self.client._resource_records[DOMAIN] = [
{"Value": "\"pre-existing-value\""},
{"Value": "\"pre-existing-value-two\""},
]
self.client.r53.change_resource_record_sets = mock.MagicMock(
return_value={"ChangeInfo": {"Id": 1}})
self.client._change_txt_record("DELETE", DOMAIN, "pre-existing-value")
call_count = self.client.r53.change_resource_record_sets.call_count
call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1]
call_args_batch = call_args["ChangeBatch"]["Changes"][0]
self.assertEqual(call_args_batch["Action"], "UPSERT")
self.assertEqual(
call_args_batch["ResourceRecordSet"]["ResourceRecords"],
[{"Value": "\"pre-existing-value-two\""}])
self.assertEqual(call_count, 1)
def test_wait_for_change(self):
self.client.r53.get_change = mock.MagicMock(
side_effect=[{"ChangeInfo": {"Status": "PENDING"}},
{"ChangeInfo": {"Status": "INSYNC"}}])
self.client._wait_for_change(1)
self.assertTrue(self.client.r53.get_change.called)
if __name__ == "__main__":
unittest.main() # pragma: no cover
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = certbot-dns-route53
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
=================
API Documentation
=================
.. toctree::
:glob:
api/**
:mod:`certbot_dns_route53.authenticator`
----------------------------------------
.. automodule:: certbot_dns_route53.authenticator
:members:
:mod:`certbot_dns_route53.dns_route53`
--------------------------------------
.. automodule:: certbot_dns_route53.dns_route53
:members:
# -*- coding: utf-8 -*-
#
# certbot-dns-route53 documentation build configuration file, created by
# sphinx-quickstart on Fri Jun 9 11:45:30 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out