Merge pull request #1553 from castrapel/fix_le_renew

Allow auto-detection of DNS providers / Fix acme renewal flow
This commit is contained in:
Curtis 2018-08-13 15:22:45 -07:00 committed by GitHub
commit e050177c08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 482 additions and 301 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@
*.db
*.pid
*.enc
*.env
MANIFEST
test.conf
pip-log.txt

View File

@ -101,7 +101,7 @@ class Certificate(db.Model):
serial = Column(String(128))
cn = Column(String(128))
deleted = Column(Boolean, index=True)
dns_provider_id = Column(Integer(), ForeignKey('dns_providers.id', ondelete='cascade'), nullable=True)
dns_provider_id = Column(Integer(), ForeignKey('dns_providers.id', ondelete='CASCADE'), nullable=True)
not_before = Column(ArrowType)
not_after = Column(ArrowType)

View File

@ -10,6 +10,7 @@ from marshmallow import fields, validate, validates_schema, post_load, pre_load
from marshmallow.exceptions import ValidationError
from lemur.authorities.schemas import AuthorityNestedOutputSchema
from lemur.dns_providers.schemas import DnsProvidersNestedOutputSchema
from lemur.common import validators, missing
from lemur.common.fields import ArrowDateTime, Hex
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
@ -223,6 +224,7 @@ class CertificateOutputSchema(LemurOutputSchema):
notifications = fields.Nested(NotificationNestedOutputSchema, many=True)
replaces = fields.Nested(CertificateNestedOutputSchema, many=True)
authority = fields.Nested(AuthorityNestedOutputSchema)
dns_provider = fields.Nested(DnsProvidersNestedOutputSchema)
roles = fields.Nested(RoleNestedOutputSchema, many=True)
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
replaced_by = fields.Nested(CertificateNestedOutputSchema, many=True, attribute='replaced')

View File

@ -0,0 +1,27 @@
from flask_script import Manager
from lemur.constants import SUCCESS_METRIC_STATUS
from lemur.dns_providers.service import get_all_dns_providers, set_domains
from lemur.extensions import metrics
from lemur.plugins.base import plugins
manager = Manager(usage="Iterates through all DNS providers and sets DNS zones in the database.")
@manager.command
def get_all_zones():
"""
Retrieves all DNS providers from the database. Refreshes the zones associated with each DNS provider
"""
print("[+] Starting dns provider zone lookup and configuration.")
dns_providers = get_all_dns_providers()
acme_plugin = plugins.get("acme-issuer")
for dns_provider in dns_providers:
zones = acme_plugin.get_all_zones(dns_provider)
set_domains(dns_provider, zones)
status = SUCCESS_METRIC_STATUS
metrics.send('get_all_zones', 'counter', 1, metric_tags={'status': status})
print("[+] Done with dns provider zone lookup and configuration.")

View File

@ -1,5 +1,6 @@
from sqlalchemy import Column, Integer, String, text, Text
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.orm import relationship
from sqlalchemy_utils import ArrowType
from lemur.database import db
@ -22,6 +23,7 @@ class DnsProvider(db.Model):
status = Column(String(length=128), nullable=True)
options = Column(JSON, nullable=True)
domains = Column(JSON, nullable=True)
certificates = relationship("Certificate", backref='dns_provider', foreign_keys='Certificate.dns_provider_id')
def __init__(self, name, description, provider_type, credentials):
self.name = name

View File

@ -22,6 +22,15 @@ def get(dns_provider_id):
return provider
def get_all_dns_providers():
"""
Retrieves all dns providers within Lemur.
:return:
"""
return DnsProvider.query.all()
def get_friendly(dns_provider_id):
"""
Retrieves a dns provider by its lemur assigned ID.
@ -96,6 +105,15 @@ def get_types():
return provider_config
def set_domains(dns_provider, domains):
"""
Increments pending certificate attempt counter and updates it in the database.
"""
dns_provider.domains = domains
database.update(dns_provider)
return dns_provider
def create(data):
provider_name = data.get("name")

View File

@ -15,6 +15,7 @@ from flask_script import Manager, Command, Option, prompt_pass
from flask_migrate import Migrate, MigrateCommand, stamp
from flask_script.commands import ShowUrls, Clean, Server
from lemur.dns_providers.cli import manager as dns_provider_manager
from lemur.sources.cli import manager as source_manager
from lemur.policies.cli import manager as policy_manager
from lemur.reporting.cli import manager as report_manager
@ -539,6 +540,7 @@ def main():
manager.add_command("report", report_manager)
manager.add_command("policy", policy_manager)
manager.add_command("pending_certs", pending_certificate_manager)
manager.add_command("dns_providers", dns_provider_manager)
manager.run()

View File

@ -5,7 +5,7 @@ import dns.exception
import dns.name
import dns.query
import dns.resolver
from dyn.tm.errors import DynectGetError
from dyn.tm.errors import DynectCreateError
from dyn.tm.session import DynectSession
from dyn.tm.zones import Node, Zone, get_all_zones
from flask import current_app
@ -49,6 +49,7 @@ def wait_for_dns_change(change_id, account_number=None):
break
time.sleep(20)
if not status:
# TODO: Delete associated DNS text record here
raise Exception("Unable to query DNS token for fqdn {}.".format(fqdn))
return
@ -70,6 +71,15 @@ def get_zone_name(domain):
return zone_name
def get_zones(account_number):
get_dynect_session()
zones = get_all_zones()
zone_list = []
for zone in zones:
zone_list.append(zone.name)
return zone_list
def create_txt_record(domain, token, account_number):
get_dynect_session()
zone_name = get_zone_name(domain)
@ -77,21 +87,20 @@ def create_txt_record(domain, token, account_number):
node_name = '.'.join(domain.split('.')[:-zone_parts])
fqdn = "{0}.{1}".format(node_name, zone_name)
zone = Zone(zone_name)
try:
# Delete all stale ACME TXT records
delete_acme_txt_records(domain)
except DynectGetError as e:
if (
"No such zone." in e.message or
"Host is not in this zone" in e.message or
"Host not found in this zone" in e.message
):
current_app.logger.debug("Unable to delete ACME TXT records. They probably don't exist yet: {}".format(e))
zone.add_record(node_name, record_type='TXT', txtdata="\"{}\"".format(token), ttl=5)
zone.publish()
current_app.logger.debug("TXT record created: {0}, token: {1}".format(fqdn, token))
except DynectCreateError as e:
if "Cannot duplicate existing record data" in e.message:
current_app.logger.debug(
"Unable to add record. Domain: {}. Token: {}. "
"Record already exists: {}".format(domain, token, e), exc_info=True
)
else:
raise
zone.add_record(node_name, record_type='TXT', txtdata="\"{}\"".format(token), ttl=5)
zone.publish()
current_app.logger.debug("TXT record created: {0}".format(fqdn))
change_id = (fqdn, token)
return change_id

View File

@ -33,15 +33,6 @@ from lemur.plugins.bases import IssuerPlugin
from lemur.plugins.lemur_acme import cloudflare, dyn, route53
def find_dns_challenge(authorizations):
dns_challenges = []
for authz in authorizations:
for combo in authz.body.challenges:
if isinstance(combo.chall, challenges.DNS01):
dns_challenges.append(combo)
return dns_challenges
class AuthorizationRecord(object):
def __init__(self, host, authz, dns_challenge, change_id):
self.host = host
@ -50,192 +41,249 @@ class AuthorizationRecord(object):
self.change_id = change_id
def maybe_remove_wildcard(host):
return host.replace("*.", "")
class AcmeHandler(object):
def __init__(self):
self.dns_providers_for_domain = {}
self.all_dns_providers = dns_provider_service.get_all_dns_providers()
def find_dns_challenge(self, authorizations):
dns_challenges = []
for authz in authorizations:
for combo in authz.body.challenges:
if isinstance(combo.chall, challenges.DNS01):
dns_challenges.append(combo)
return dns_challenges
def maybe_add_extension(host, dns_provider_options):
if dns_provider_options and dns_provider_options.get("acme_challenge_extension"):
host = host + dns_provider_options.get("acme_challenge_extension")
return host
def maybe_remove_wildcard(self, host):
return host.replace("*.", "")
def maybe_add_extension(self, host, dns_provider_options):
if dns_provider_options and dns_provider_options.get("acme_challenge_extension"):
host = host + dns_provider_options.get("acme_challenge_extension")
return host
def start_dns_challenge(acme_client, account_number, host, dns_provider, order, dns_provider_options):
current_app.logger.debug("Starting DNS challenge for {0}".format(host))
def start_dns_challenge(self, acme_client, account_number, host, dns_provider, order, dns_provider_options):
current_app.logger.debug("Starting DNS challenge for {0}".format(host))
dns_challenges = find_dns_challenge(order.authorizations)
change_ids = []
dns_challenges = self.find_dns_challenge(order.authorizations)
change_ids = []
host_to_validate = maybe_remove_wildcard(host)
host_to_validate = maybe_add_extension(host_to_validate, dns_provider_options)
host_to_validate = self.maybe_remove_wildcard(host)
host_to_validate = self.maybe_add_extension(host_to_validate, dns_provider_options)
for dns_challenge in find_dns_challenge(order.authorizations):
change_id = dns_provider.create_txt_record(
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key),
account_number
)
change_ids.append(change_id)
return AuthorizationRecord(
host,
order.authorizations,
dns_challenges,
change_ids
)
def complete_dns_challenge(acme_client, account_number, authz_record, dns_provider):
current_app.logger.debug("Finalizing DNS challenge for {0}".format(authz_record.authz[0].body.identifier.value))
for change_id in authz_record.change_id:
dns_provider.wait_for_dns_change(change_id, account_number=account_number)
for dns_challenge in authz_record.dns_challenge:
response = dns_challenge.response(acme_client.client.net.key)
verified = response.simple_verify(
dns_challenge.chall,
authz_record.host,
acme_client.client.net.key.public_key()
)
if not verified:
raise ValueError("Failed verification")
time.sleep(5)
acme_client.answer_challenge(dns_challenge, response)
def request_certificate(acme_client, authorizations, csr, order):
for authorization in authorizations:
for authz in authorization.authz:
authorization_resource, _ = acme_client.poll(authz)
deadline = datetime.datetime.now() + datetime.timedelta(seconds=90)
try:
orderr = acme_client.finalize_order(order, deadline)
except AcmeError:
current_app.logger.error("Unable to resolve Acme order: {}".format(order), exc_info=True)
raise
pem_certificate = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM,
OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
orderr.fullchain_pem)).decode()
pem_certificate_chain = orderr.fullchain_pem[len(pem_certificate):].lstrip()
current_app.logger.debug("{0} {1}".format(type(pem_certificate), type(pem_certificate_chain)))
return pem_certificate, pem_certificate_chain
def setup_acme_client(authority):
if not authority.options:
raise InvalidAuthority("Invalid authority. Options not set")
options = {}
for option in json.loads(authority.options):
options[option["name"]] = option.get("value")
email = options.get('email', current_app.config.get('ACME_EMAIL'))
tel = options.get('telephone', current_app.config.get('ACME_TEL'))
directory_url = options.get('acme_url', current_app.config.get('ACME_DIRECTORY_URL'))
existing_key = options.get('acme_private_key', current_app.config.get('ACME_PRIVATE_KEY'))
existing_regr = options.get('acme_regr', current_app.config.get('ACME_REGR'))
if existing_key and existing_regr:
# Reuse the same account for each certificate issuance
key = jose.JWK.json_loads(existing_key)
regr = messages.RegistrationResource.json_loads(existing_regr)
current_app.logger.debug("Connecting with directory at {0}".format(directory_url))
net = ClientNetwork(key, account=regr)
client = BackwardsCompatibleClientV2(net, key, directory_url)
return client, {}
else:
# Create an account for each certificate issuance
key = jose.JWKRSA(key=generate_private_key('RSA2048'))
current_app.logger.debug("Connecting with directory at {0}".format(directory_url))
net = ClientNetwork(key, account=None)
client = BackwardsCompatibleClientV2(net, key, directory_url)
registration = client.new_account_and_tos(messages.NewRegistration.from_data(email=email))
current_app.logger.debug("Connected: {0}".format(registration.uri))
return client, registration
def get_domains(options):
"""
Fetches all domains currently requested
:param options:
:return:
"""
current_app.logger.debug("Fetching domains")
domains = [options['common_name']]
if options.get('extensions'):
for name in options['extensions']['sub_alt_names']['names']:
domains.append(name)
current_app.logger.debug("Got these domains: {0}".format(domains))
return domains
def get_authorizations(acme_client, order, order_info, dns_provider, dns_provider_options):
authorizations = []
for domain in order_info.domains:
authz_record = start_dns_challenge(acme_client, order_info.account_number, domain, dns_provider, order,
dns_provider_options)
authorizations.append(authz_record)
return authorizations
def finalize_authorizations(acme_client, account_number, dns_provider, authorizations, dns_provider_options):
for authz_record in authorizations:
complete_dns_challenge(acme_client, account_number, authz_record, dns_provider)
for authz_record in authorizations:
dns_challenges = authz_record.dns_challenge
host_to_validate = maybe_remove_wildcard(authz_record.host)
host_to_validate = maybe_add_extension(host_to_validate, dns_provider_options)
for dns_challenge in dns_challenges:
dns_provider.delete_txt_record(
authz_record.change_id,
account_number,
for dns_challenge in self.find_dns_challenge(order.authorizations):
change_id = dns_provider.create_txt_record(
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key)
dns_challenge.validation(acme_client.client.net.key),
account_number
)
change_ids.append(change_id)
return authorizations
return AuthorizationRecord(
host,
order.authorizations,
dns_challenges,
change_ids
)
def complete_dns_challenge(self, acme_client, authz_record):
current_app.logger.debug("Finalizing DNS challenge for {0}".format(authz_record.authz[0].body.identifier.value))
dns_providers = self.dns_providers_for_domain.get(authz_record.host)
if not dns_providers:
raise Exception("No DNS providers found for domain: {}".format(authz_record.host))
def cleanup_dns_challenges(acme_client, account_number, dns_provider, authorizations, dns_provider_options):
"""
Best effort attempt to delete DNS challenges that may not have been deleted previously. This is usually called
on an exception
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
for change_id in authz_record.change_id:
dns_provider_plugin.wait_for_dns_change(change_id, account_number=account_number)
:param acme_client:
:param account_number:
:param dns_provider:
:param authorizations:
:param dns_provider_options:
:return:
"""
for authz_record in authorizations:
dns_challenges = authz_record.dns_challenge
host_to_validate = maybe_remove_wildcard(authz_record.host)
host_to_validate = maybe_add_extension(host_to_validate, dns_provider_options)
for dns_challenge in dns_challenges:
try:
dns_provider.delete_txt_record(
authz_record.change_id,
account_number,
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key)
for dns_challenge in authz_record.dns_challenge:
response = dns_challenge.response(acme_client.client.net.key)
verified = response.simple_verify(
dns_challenge.chall,
authz_record.host,
acme_client.client.net.key.public_key()
)
except Exception:
# If this fails, it's most likely because the record doesn't exist or we're not authorized to modify it.
pass
if not verified:
raise ValueError("Failed verification")
time.sleep(5)
acme_client.answer_challenge(dns_challenge, response)
def request_certificate(self, acme_client, authorizations, order):
for authorization in authorizations:
for authz in authorization.authz:
authorization_resource, _ = acme_client.poll(authz)
deadline = datetime.datetime.now() + datetime.timedelta(seconds=90)
try:
orderr = acme_client.finalize_order(order, deadline)
except AcmeError:
current_app.logger.error("Unable to resolve Acme order: {}".format(order), exc_info=True)
raise
pem_certificate = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM,
OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
orderr.fullchain_pem)).decode()
pem_certificate_chain = orderr.fullchain_pem[len(pem_certificate):].lstrip()
current_app.logger.debug("{0} {1}".format(type(pem_certificate), type(pem_certificate_chain)))
return pem_certificate, pem_certificate_chain
def setup_acme_client(self, authority):
if not authority.options:
raise InvalidAuthority("Invalid authority. Options not set")
options = {}
for option in json.loads(authority.options):
options[option["name"]] = option.get("value")
email = options.get('email', current_app.config.get('ACME_EMAIL'))
tel = options.get('telephone', current_app.config.get('ACME_TEL'))
directory_url = options.get('acme_url', current_app.config.get('ACME_DIRECTORY_URL'))
existing_key = options.get('acme_private_key', current_app.config.get('ACME_PRIVATE_KEY'))
existing_regr = options.get('acme_regr', current_app.config.get('ACME_REGR'))
if existing_key and existing_regr:
# Reuse the same account for each certificate issuance
key = jose.JWK.json_loads(existing_key)
regr = messages.RegistrationResource.json_loads(existing_regr)
current_app.logger.debug("Connecting with directory at {0}".format(directory_url))
net = ClientNetwork(key, account=regr)
client = BackwardsCompatibleClientV2(net, key, directory_url)
return client, {}
else:
# Create an account for each certificate issuance
key = jose.JWKRSA(key=generate_private_key('RSA2048'))
current_app.logger.debug("Connecting with directory at {0}".format(directory_url))
net = ClientNetwork(key, account=None)
client = BackwardsCompatibleClientV2(net, key, directory_url)
registration = client.new_account_and_tos(messages.NewRegistration.from_data(email=email))
current_app.logger.debug("Connected: {0}".format(registration.uri))
return client, registration
def get_domains(self, options):
"""
Fetches all domains currently requested
:param options:
:return:
"""
current_app.logger.debug("Fetching domains")
domains = [options['common_name']]
if options.get('extensions'):
for name in options['extensions']['sub_alt_names']['names']:
domains.append(name)
current_app.logger.debug("Got these domains: {0}".format(domains))
return domains
def get_authorizations(self, acme_client, order, order_info):
authorizations = []
for domain in order_info.domains:
if not self.dns_providers_for_domain.get(domain):
raise Exception("No DNS providers found for domain: {}".format(domain))
for dns_provider in self.dns_providers_for_domain[domain]:
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
authz_record = self.start_dns_challenge(acme_client, account_number, domain,
dns_provider_plugin,
order,
dns_provider.options)
authorizations.append(authz_record)
return authorizations
def autodetect_dns_providers(self, domain):
"""
Get DNS providers associated with a domain when it has not been provided for certificate creation.
:param domain:
:return: dns_providers: List of DNS providers that have the correct zone.
"""
self.dns_providers_for_domain[domain] = []
for dns_provider in self.all_dns_providers:
for name in dns_provider.domains:
if domain.endswith(name):
self.dns_providers_for_domain[domain].append(dns_provider)
return self.dns_providers_for_domain
def finalize_authorizations(self, acme_client, authorizations):
for authz_record in authorizations:
self.complete_dns_challenge(acme_client, authz_record)
for authz_record in authorizations:
dns_challenges = authz_record.dns_challenge
for dns_challenge in dns_challenges:
dns_providers = self.dns_providers_for_domain.get(authz_record.host)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
host_to_validate = self.maybe_remove_wildcard(authz_record.host)
host_to_validate = self.maybe_add_extension(host_to_validate, dns_provider_options)
dns_provider_plugin.delete_txt_record(
authz_record.change_id,
account_number,
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key)
)
return authorizations
def cleanup_dns_challenges(self, acme_client, authorizations):
"""
Best effort attempt to delete DNS challenges that may not have been deleted previously. This is usually called
on an exception
:param acme_client:
:param account_number:
:param dns_provider:
:param authorizations:
:param dns_provider_options:
:return:
"""
for authz_record in authorizations:
dns_providers = self.dns_providers_for_domain.get(authz_record.host)
for dns_provider in dns_providers:
# Grab account number (For Route53)
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_challenges = authz_record.dns_challenge
host_to_validate = self.maybe_remove_wildcard(authz_record.host)
host_to_validate = self.maybe_add_extension(host_to_validate, dns_provider_options)
for dns_challenge in dns_challenges:
try:
dns_provider.delete_txt_record(
authz_record.change_id,
account_number,
dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key)
)
except Exception as e:
# If this fails, it's most likely because the record doesn't exist (It was already cleaned up)
# or we're not authorized to modify it.
pass
def get_dns_provider(self, type):
provider_types = {
'cloudflare': cloudflare,
'dyn': dyn,
'route53': route53,
}
provider = provider_types.get(type)
if not provider:
raise UnknownProvider("No such DNS provider: {}".format(type))
return provider
class ACMEIssuerPlugin(IssuerPlugin):
@ -279,6 +327,7 @@ class ACMEIssuerPlugin(IssuerPlugin):
def __init__(self, *args, **kwargs):
super(ACMEIssuerPlugin, self).__init__(*args, **kwargs)
self.acme = AcmeHandler()
def get_dns_provider(self, type):
provider_types = {
@ -291,22 +340,40 @@ class ACMEIssuerPlugin(IssuerPlugin):
raise UnknownProvider("No such DNS provider: {}".format(type))
return provider
def get_all_zones(self, dns_provider):
dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id")
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
return dns_provider_plugin.get_zones(account_number=account_number)
def get_ordered_certificate(self, pending_cert):
acme_client, registration = setup_acme_client(pending_cert.authority)
acme_client, registration = self.acme.setup_acme_client(pending_cert.authority)
order_info = authorization_service.get(pending_cert.external_id)
dns_provider = dns_provider_service.get(pending_cert.dns_provider_id)
dns_provider_options = dns_provider.options
dns_provider_type = self.get_dns_provider(dns_provider.provider_type)
if pending_cert.dns_provider_id:
dns_provider = dns_provider_service.get(pending_cert.dns_provider_id)
for domain in order_info.domains:
# Currently, we only support specifying one DNS provider per certificate, even if that
# certificate has multiple SANs that may belong to different providers.
self.acme.dns_providers_for_domain[domain] = [dns_provider]
else:
for domain in order_info.domains:
self.acme.autodetect_dns_providers(domain)
try:
authorizations = get_authorizations(
acme_client, order_info.account_number, order_info.domains, dns_provider_type, dns_provider_options)
order = acme_client.new_order(pending_cert.csr)
except WildcardUnsupportedError:
raise Exception("The currently selected ACME CA endpoint does"
" not support issuing wildcard certificates.")
try:
authorizations = self.acme.get_authorizations(acme_client, order, order_info)
except ClientError:
current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert.name), exc_info=True)
return False
authorizations = finalize_authorizations(
acme_client, order_info.account_number, dns_provider_type, authorizations, dns_provider_options)
pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, pending_cert.csr)
authorizations = self.acme.finalize_authorizations(acme_client, authorizations)
pem_certificate, pem_certificate_chain = self.acme.request_certificate(
acme_client, authorizations, order)
cert = {
'body': "\n".join(str(pem_certificate).splitlines()),
'chain': "\n".join(str(pem_certificate_chain).splitlines()),
@ -319,28 +386,32 @@ class ACMEIssuerPlugin(IssuerPlugin):
certs = []
for pending_cert in pending_certs:
try:
acme_client, registration = setup_acme_client(pending_cert.authority)
acme_client, registration = self.acme.setup_acme_client(pending_cert.authority)
order_info = authorization_service.get(pending_cert.external_id)
dns_provider = dns_provider_service.get(pending_cert.dns_provider_id)
dns_provider_options = dns_provider.options
dns_provider_type = self.get_dns_provider(dns_provider.provider_type)
if pending_cert.dns_provider_id:
dns_provider = dns_provider_service.get(pending_cert.dns_provider_id)
for domain in order_info.domains:
# Currently, we only support specifying one DNS provider per certificate, even if that
# certificate has multiple SANs that may belong to different providers.
self.acme.dns_providers_for_domain[domain] = [dns_provider]
else:
for domain in order_info.domains:
self.acme.autodetect_dns_providers(domain)
try:
order = acme_client.new_order(pending_cert.csr)
except WildcardUnsupportedError:
raise Exception("The currently selected ACME CA endpoint does"
" not support issuing wildcard certificates.")
authorizations = get_authorizations(acme_client, order, order_info, dns_provider_type,
dns_provider_options)
authorizations = self.acme.get_authorizations(acme_client, order, order_info)
pending.append({
"acme_client": acme_client,
"account_number": order_info.account_number,
"dns_provider_type": dns_provider_type,
"authorizations": authorizations,
"pending_cert": pending_cert,
"order": order,
"dns_provider_options": dns_provider_options,
})
except (ClientError, ValueError, Exception) as e:
current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert), exc_info=True)
@ -352,17 +423,13 @@ class ACMEIssuerPlugin(IssuerPlugin):
for entry in pending:
try:
entry["authorizations"] = finalize_authorizations(
entry["authorizations"] = self.acme.finalize_authorizations(
entry["acme_client"],
entry["account_number"],
entry["dns_provider_type"],
entry["authorizations"],
entry["dns_provider_options"],
)
pem_certificate, pem_certificate_chain = request_certificate(
pem_certificate, pem_certificate_chain = self.acme.request_certificate(
entry["acme_client"],
entry["authorizations"],
entry["pending_cert"].csr,
entry["order"]
)
@ -383,12 +450,9 @@ class ACMEIssuerPlugin(IssuerPlugin):
"last_error": e,
})
# Ensure DNS records get deleted
cleanup_dns_challenges(
self.acme.cleanup_dns_challenges(
entry["acme_client"],
entry["account_number"],
entry["dns_provider_type"],
entry["authorizations"],
entry["dns_provider_options"],
)
return certs
@ -402,21 +466,25 @@ class ACMEIssuerPlugin(IssuerPlugin):
"""
authority = issuer_options.get('authority')
create_immediately = issuer_options.get('create_immediately', False)
acme_client, registration = setup_acme_client(authority)
dns_provider = issuer_options.get('dns_provider')
dns_provider_options = dns_provider.options
if not dns_provider:
raise InvalidConfiguration("DNS Provider setting is required for ACME certificates.")
credentials = json.loads(dns_provider.credentials)
acme_client, registration = self.acme.setup_acme_client(authority)
dns_provider = issuer_options.get('dns_provider', {})
current_app.logger.debug("Using DNS provider: {0}".format(dns_provider.provider_type))
dns_provider_type = __import__(dns_provider.provider_type, globals(), locals(), [], 1)
account_number = credentials.get("account_id")
if dns_provider.provider_type == 'route53' and not account_number:
error = "Route53 DNS Provider {} does not have an account number configured.".format(dns_provider.name)
current_app.logger.error(error)
raise InvalidConfiguration(error)
domains = get_domains(issuer_options)
if dns_provider:
dns_provider_options = dns_provider.options
credentials = json.loads(dns_provider.credentials)
current_app.logger.debug("Using DNS provider: {0}".format(dns_provider.provider_type))
dns_provider_plugin = __import__(dns_provider.provider_type, globals(), locals(), [], 1)
account_number = credentials.get("account_id")
if dns_provider.provider_type == 'route53' and not account_number:
error = "Route53 DNS Provider {} does not have an account number configured.".format(dns_provider.name)
current_app.logger.error(error)
raise InvalidConfiguration(error)
else:
dns_provider = {}
dns_provider_options = None
account_number = None
domains = self.acme.get_domains(issuer_options)
if not create_immediately:
# Create pending authorizations that we'll need to do the creation
authz_domains = []
@ -426,14 +494,16 @@ class ACMEIssuerPlugin(IssuerPlugin):
else:
authz_domains.append(d.value)
dns_authorization = authorization_service.create(account_number, authz_domains, dns_provider.provider_type)
dns_authorization = authorization_service.create(account_number, authz_domains,
dns_provider.get("provider_type"))
# Return id of the DNS Authorization
return None, None, dns_authorization.id
authorizations = get_authorizations(acme_client, account_number, domains, dns_provider_type,
dns_provider_options)
finalize_authorizations(acme_client, account_number, dns_provider_type, authorizations, dns_provider_options)
pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr)
authorizations = self.acme.get_authorizations(acme_client, account_number, domains, dns_provider_plugin,
dns_provider_options)
self.acme.finalize_authorizations(acme_client, account_number, dns_provider_plugin, authorizations,
dns_provider_options)
pem_certificate, pem_certificate_chain = self.acme.request_certificate(acme_client, authorizations, csr)
# TODO add external ID (if possible)
return pem_certificate, pem_certificate_chain, None

View File

@ -31,6 +31,16 @@ def find_zone_id(domain, client=None):
return zones[0][1]
@sts_client('route53')
def get_zones(client=None):
paginator = client.get_paginator("list_hosted_zones")
zones = []
for page in paginator.paginate():
for zone in page["HostedZones"]:
zones.append(zone["Name"][:-1]) # We need [:-1] to strip out the trailing dot.
return zones
@sts_client('route53')
def change_txt_record(action, zone_id, domain, value, client=None):
current_txt_records = []
@ -50,7 +60,13 @@ def change_txt_record(action, zone_id, domain, value, client=None):
raise
# For some reason TXT records need to be
# manually quoted.
current_txt_records.append({"Value": '"{}"'.format(value)})
seen = False
for record in current_txt_records:
for k, v in record.items():
if '"{}"'.format(value) == v:
seen = True
if not seen:
current_txt_records.append({"Value": '"{}"'.format(value)})
if action == "DELETE" and len(current_txt_records) > 1:
# If we want to delete one record out of many, we'll update the record to not include the deleted value instead.
@ -95,10 +111,17 @@ def create_txt_record(host, value, account_number):
def delete_txt_record(change_ids, account_number, host, value):
for change_id in change_ids:
zone_id, _ = change_id
change_txt_record(
"DELETE",
zone_id,
host,
value,
account_number=account_number
)
try:
change_txt_record(
"DELETE",
zone_id,
host,
value,
account_number=account_number
)
except Exception as e:
if "but it was not found" in e.response.get("Error", {}).get("Message"):
# We tried to delete a record that doesn't exist. We'll ignore this error.
pass
else:
raise

View File

@ -1 +0,0 @@
from lemur.tests.conftest import * # noqa

View File

@ -7,8 +7,16 @@ from lemur.plugins.lemur_acme import plugin
class TestAcme(unittest.TestCase):
def setUp(self):
@patch('lemur.plugins.lemur_acme.plugin.dns_provider_service')
def setUp(self, mock_dns_provider_service):
self.ACMEIssuerPlugin = plugin.ACMEIssuerPlugin()
self.acme = plugin.AcmeHandler()
mock_dns_provider = Mock()
mock_dns_provider.name = "cloudflare"
mock_dns_provider.credentials = "{}"
mock_dns_provider.provider_type = "cloudflare"
self.acme.dns_providers_for_domain = {"www.test.com": [mock_dns_provider],
"test.fakedomain.net": [mock_dns_provider]}
@patch('lemur.plugins.lemur_acme.plugin.len', return_value=1)
def test_find_dns_challenge(self, mock_len):
@ -22,7 +30,7 @@ class TestAcme(unittest.TestCase):
mock_entry = Mock()
mock_entry.chall = c
mock_authz.body.resolved_combinations.append(mock_entry)
result = yield plugin.find_dns_challenge(mock_authz)
result = yield self.acme.find_dns_challenge(mock_authz)
self.assertEqual(result, mock_entry)
def test_authz_record(self):
@ -32,7 +40,7 @@ class TestAcme(unittest.TestCase):
@patch('acme.client.Client')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@patch('lemur.plugins.lemur_acme.plugin.len', return_value=1)
@patch('lemur.plugins.lemur_acme.plugin.find_dns_challenge')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.find_dns_challenge')
def test_start_dns_challenge(self, mock_find_dns_challenge, mock_len, mock_app, mock_acme):
assert mock_len
mock_order = Mock()
@ -42,7 +50,7 @@ class TestAcme(unittest.TestCase):
mock_entry = MagicMock()
from acme import challenges
c = challenges.DNS01()
mock_entry.chall = c
mock_entry.chall = TestAcme.test_complete_dns_challenge_fail
mock_authz.body.resolved_combinations.append(mock_entry)
mock_acme.request_domain_challenges = Mock(return_value=mock_authz)
mock_dns_provider = Mock()
@ -52,19 +60,20 @@ class TestAcme(unittest.TestCase):
iterable = mock_find_dns_challenge.return_value
iterator = iter(values)
iterable.__iter__.return_value = iterator
result = plugin.start_dns_challenge(mock_acme, "accountid", "host", mock_dns_provider, mock_order, {})
result = self.acme.start_dns_challenge(mock_acme, "accountid", "host", mock_dns_provider, mock_order, {})
self.assertEqual(type(result), plugin.AuthorizationRecord)
@patch('acme.client.Client')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
def test_complete_dns_challenge_success(self, mock_current_app, mock_acme):
@patch('lemur.plugins.lemur_acme.cloudflare.wait_for_dns_change')
def test_complete_dns_challenge_success(self, mock_wait_for_dns_change, mock_current_app, mock_acme):
mock_dns_provider = Mock()
mock_dns_provider.wait_for_dns_change = Mock(return_value=True)
mock_authz = Mock()
mock_authz.dns_challenge.response = Mock()
mock_authz.dns_challenge.response.simple_verify = Mock(return_value=True)
mock_authz.authz = []
mock_authz.host = "www.test.com"
mock_authz_record = Mock()
mock_authz_record.body.identifier.value = "test"
mock_authz.authz.append(mock_authz_record)
@ -73,11 +82,12 @@ class TestAcme(unittest.TestCase):
mock_authz.dns_challenge = []
dns_challenge = Mock()
mock_authz.dns_challenge.append(dns_challenge)
plugin.complete_dns_challenge(mock_acme, "accountid", mock_authz, mock_dns_provider)
self.acme.complete_dns_challenge(mock_acme, mock_authz)
@patch('acme.client.Client')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
def test_complete_dns_challenge_fail(self, mock_current_app, mock_acme):
@patch('lemur.plugins.lemur_acme.cloudflare.wait_for_dns_change')
def test_complete_dns_challenge_fail(self, mock_wait_for_dns_change, mock_current_app, mock_acme):
mock_dns_provider = Mock()
mock_dns_provider.wait_for_dns_change = Mock(return_value=True)
@ -85,6 +95,7 @@ class TestAcme(unittest.TestCase):
mock_authz.dns_challenge.response = Mock()
mock_authz.dns_challenge.response.simple_verify = Mock(return_value=False)
mock_authz.authz = []
mock_authz.host = "www.test.com"
mock_authz_record = Mock()
mock_authz_record.body.identifier.value = "test"
mock_authz.authz.append(mock_authz_record)
@ -95,13 +106,13 @@ class TestAcme(unittest.TestCase):
mock_authz.dns_challenge.append(dns_challenge)
self.assertRaises(
ValueError,
plugin.complete_dns_challenge(mock_acme, "accountid", mock_authz, mock_dns_provider)
self.acme.complete_dns_challenge(mock_acme, mock_authz)
)
@patch('acme.client.Client')
@patch('OpenSSL.crypto', return_value="mock_cert")
@patch('josepy.util.ComparableX509')
@patch('lemur.plugins.lemur_acme.plugin.find_dns_challenge')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.find_dns_challenge')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
def test_request_certificate(self, mock_current_app, mock_find_dns_challenge, mock_jose, mock_crypto, mock_acme):
mock_cert_response = Mock()
@ -115,13 +126,13 @@ class TestAcme(unittest.TestCase):
mock_acme.fetch_chain = Mock(return_value="mock_chain")
mock_crypto.dump_certificate = Mock(return_value=b'chain')
mock_order = Mock()
plugin.request_certificate(mock_acme, [], "mock_csr", mock_order)
self.acme.request_certificate(mock_acme, [], mock_order)
def test_setup_acme_client_fail(self):
mock_authority = Mock()
mock_authority.options = []
with self.assertRaises(Exception):
plugin.setup_acme_client(mock_authority)
self.acme.setup_acme_client(mock_authority)
@patch('lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@ -135,7 +146,7 @@ class TestAcme(unittest.TestCase):
mock_client.agree_to_tos = Mock(return_value=True)
mock_acme.return_value = mock_client
mock_current_app.config = {}
result_client, result_registration = plugin.setup_acme_client(mock_authority)
result_client, result_registration = self.acme.setup_acme_client(mock_authority)
assert result_client
assert result_registration
@ -144,7 +155,7 @@ class TestAcme(unittest.TestCase):
options = {
"common_name": "test.netflix.net"
}
result = plugin.get_domains(options)
result = self.acme.get_domains(options)
self.assertEqual(result, [options["common_name"]])
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@ -160,10 +171,10 @@ class TestAcme(unittest.TestCase):
}
}
}
result = plugin.get_domains(options)
result = self.acme.get_domains(options)
self.assertEqual(result, [options["common_name"], "test2.netflix.net", "test3.netflix.net"])
@patch('lemur.plugins.lemur_acme.plugin.start_dns_challenge', return_value="test")
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.start_dns_challenge', return_value="test")
def test_get_authorizations(self, mock_start_dns_challenge):
mock_order = Mock()
mock_order.body.identifiers = []
@ -172,10 +183,10 @@ class TestAcme(unittest.TestCase):
mock_order_info = Mock()
mock_order_info.account_number = 1
mock_order_info.domains = ["test.fakedomain.net"]
result = plugin.get_authorizations("acme_client", mock_order, mock_order_info, "dns_provider", {})
result = self.acme.get_authorizations("acme_client", mock_order, mock_order_info)
self.assertEqual(result, ["test"])
@patch('lemur.plugins.lemur_acme.plugin.complete_dns_challenge', return_value="test")
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.complete_dns_challenge', return_value="test")
def test_finalize_authorizations(self, mock_complete_dns_challenge):
mock_authz = []
mock_authz_record = MagicMock()
@ -188,7 +199,7 @@ class TestAcme(unittest.TestCase):
mock_dns_provider.delete_txt_record = Mock()
mock_acme_client = Mock()
result = plugin.finalize_authorizations(mock_acme_client, "account_number", mock_dns_provider, mock_authz, {})
result = self.acme.finalize_authorizations(mock_acme_client, mock_authz)
self.assertEqual(result, mock_authz)
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@ -210,7 +221,9 @@ class TestAcme(unittest.TestCase):
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@patch('lemur.plugins.lemur_acme.dyn.current_app')
@patch('lemur.plugins.lemur_acme.cloudflare.current_app')
def test_get_dns_provider(self, mock_current_app_cloudflare, mock_current_app_dyn, mock_current_app):
@patch('lemur.plugins.lemur_acme.plugin.dns_provider_service')
def test_get_dns_provider(self, mock_dns_provider_service, mock_current_app_cloudflare, mock_current_app_dyn,
mock_current_app):
provider = plugin.ACMEIssuerPlugin()
route53 = provider.get_dns_provider("route53")
assert route53
@ -219,13 +232,13 @@ class TestAcme(unittest.TestCase):
dyn = provider.get_dns_provider("dyn")
assert dyn
@patch('lemur.plugins.lemur_acme.plugin.setup_acme_client')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@patch('lemur.plugins.lemur_acme.plugin.authorization_service')
@patch('lemur.plugins.lemur_acme.plugin.dns_provider_service')
@patch('lemur.plugins.lemur_acme.plugin.get_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.finalize_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.request_certificate')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.get_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.finalize_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate')
def test_get_ordered_certificate(
self, mock_request_certificate, mock_finalize_authorizations, mock_get_authorizations,
mock_dns_provider_service, mock_authorization_service, mock_current_app, mock_acme):
@ -248,13 +261,13 @@ class TestAcme(unittest.TestCase):
}
)
@patch('lemur.plugins.lemur_acme.plugin.setup_acme_client')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@patch('lemur.plugins.lemur_acme.plugin.authorization_service')
@patch('lemur.plugins.lemur_acme.plugin.dns_provider_service')
@patch('lemur.plugins.lemur_acme.plugin.get_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.finalize_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.request_certificate')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.get_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.finalize_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate')
def test_get_ordered_certificates(
self, mock_request_certificate, mock_finalize_authorizations, mock_get_authorizations,
mock_dns_provider_service, mock_authorization_service, mock_current_app, mock_acme):
@ -275,12 +288,12 @@ class TestAcme(unittest.TestCase):
self.assertEqual(result[0]['cert'], {'body': 'pem_certificate', 'chain': 'chain', 'external_id': '1'})
self.assertEqual(result[1]['cert'], {'body': 'pem_certificate', 'chain': 'chain', 'external_id': '2'})
@patch('lemur.plugins.lemur_acme.plugin.setup_acme_client')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.setup_acme_client')
@patch('lemur.plugins.lemur_acme.plugin.dns_provider_service')
@patch('lemur.plugins.lemur_acme.plugin.current_app')
@patch('lemur.plugins.lemur_acme.plugin.get_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.finalize_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.request_certificate')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.get_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.finalize_authorizations')
@patch('lemur.plugins.lemur_acme.plugin.AcmeHandler.request_certificate')
@patch('lemur.plugins.lemur_acme.plugin.authorization_service')
def test_create_certificate(self, mock_authorization_service, mock_request_certificate,
mock_finalize_authorizations, mock_get_authorizations,

View File

@ -107,6 +107,17 @@
</ui-select>
</div>
</div>
<div class="form-group" ng-show="certificate.authority.plugin.slug == 'acme-issuer'">
<label class="control-label col-sm-2">
Note:
</label>
<div class="col-sm-10">
The selected authority uses the <a target="_blank" href="https://letsencrypt.org/how-it-works/">ACME protocol</a> and works differently than other authorities.
Your request will initially be created under the "pending certificates" section. Lemur will attempt to create the certificate for you,
and move the final certificate to the "certificates" section. Lemur performs validation by writing a DNS text record. You may choose a specific DNS provider,
or allow Lemur to automatically detect the correct provider for you. Requests may take up to ten minutes.
</div>
</div>
<div class="form-group" ng-show="certificate.authority.plugin.slug == 'acme-issuer'">
<label class="control-label col-sm-2">
DNS Provider:
@ -114,7 +125,7 @@
<div class="col-sm-10">
<select class="form-control" ng-model="certificate.dnsProvider" ng-options="item as item.name for item in dnsProviders.items track by item.id">
<option value="">- choose an entry. Neded for ACME Providers -</option>
<option value="">Automatically select for me</option>
</select>
</div>
</div>

View File

@ -6,7 +6,7 @@
#
aspy.yaml==1.1.1 # via pre-commit
cached-property==1.4.3 # via pre-commit
certifi==2018.4.16 # via requests
certifi==2018.8.13 # via requests
cfgv==1.1.0 # via pre-commit
chardet==3.0.4 # via requests
flake8==3.5.0

View File

@ -15,9 +15,9 @@ asyncpool==1.0
babel==2.6.0 # via sphinx
bcrypt==3.1.4
blinker==1.4
boto3==1.7.66
botocore==1.10.66
certifi==2018.4.16
boto3==1.7.75
botocore==1.10.75
certifi==2018.8.13
cffi==1.11.5
chardet==3.0.4
click==6.7
@ -50,7 +50,7 @@ lockfile==0.12.2
mako==1.0.7
markupsafe==1.0
marshmallow-sqlalchemy==0.14.0
marshmallow==2.15.3
marshmallow==2.15.4
mock==2.0.0
ndg-httpsclient==0.5.1
packaging==17.1 # via sphinx

View File

@ -8,10 +8,10 @@ asn1crypto==0.24.0 # via cryptography
atomicwrites==1.1.5 # via pytest
attrs==18.1.0 # via pytest
aws-xray-sdk==0.95 # via moto
boto3==1.7.71 # via moto
boto3==1.7.75 # via moto
boto==2.49.0 # via moto
botocore==1.10.71 # via boto3, moto, s3transfer
certifi==2018.4.16 # via requests
botocore==1.10.75 # via boto3, moto, s3transfer
certifi==2018.8.13 # via requests
cffi==1.11.5 # via cryptography
chardet==3.0.4 # via requests
click==6.7 # via flask
@ -19,12 +19,14 @@ cookies==2.2.1 # via moto, responses
coverage==4.5.1
cryptography==2.3 # via moto
docker-pycreds==0.3.0 # via docker
docker==3.4.1 # via moto
docker==3.5.0 # via moto
docutils==0.14 # via botocore
ecdsa==0.13 # via python-jose
factory-boy==2.11.1
faker==0.8.17
faker==0.9.0
flask==1.0.2 # via pytest-flask
freezegun==0.3.10
future==0.16.0 # via python-jose
idna==2.7 # via cryptography, requests
itsdangerous==0.24 # via flask
jinja2==2.10 # via flask, moto
@ -34,25 +36,27 @@ jsonpickle==0.9.6 # via aws-xray-sdk
markupsafe==1.0 # via jinja2
mock==2.0.0 # via moto
more-itertools==4.3.0 # via pytest
moto==1.3.3
moto==1.3.4
nose==1.3.7
pbr==4.2.0 # via mock
pluggy==0.7.1 # via pytest
py==1.5.4 # via pytest
pyaml==17.12.1 # via moto
pycparser==2.18 # via cffi
pycryptodome==3.6.5 # via python-jose
pyflakes==2.0.0
pytest-flask==0.10.0
pytest-mock==1.10.0
pytest==3.7.1
python-dateutil==2.6.1 # via botocore, faker, freezegun, moto
python-dateutil==2.7.3 # via botocore, faker, freezegun, moto
python-jose==2.0.2 # via moto
pytz==2018.5 # via moto
pyyaml==3.13 # via pyaml
requests-mock==1.5.2
requests==2.19.1 # via aws-xray-sdk, docker, moto, requests-mock, responses
responses==0.9.0 # via moto
s3transfer==0.1.13 # via boto3
six==1.11.0 # via cryptography, docker, docker-pycreds, faker, freezegun, mock, more-itertools, moto, pytest, python-dateutil, requests-mock, responses, websocket-client
six==1.11.0 # via cryptography, docker, docker-pycreds, faker, freezegun, mock, more-itertools, moto, pytest, python-dateutil, python-jose, requests-mock, responses, websocket-client
text-unidecode==1.2 # via faker
urllib3==1.23 # via requests
websocket-client==0.48.0 # via docker

View File

@ -13,9 +13,9 @@ asn1crypto==0.24.0 # via cryptography
asyncpool==1.0
bcrypt==3.1.4 # via flask-bcrypt, paramiko
blinker==1.4 # via flask-mail, flask-principal, raven
boto3==1.7.71
botocore==1.10.71 # via boto3, s3transfer
certifi==2018.4.16
boto3==1.7.75
botocore==1.10.75 # via boto3, s3transfer
certifi==2018.8.13
cffi==1.11.5 # via bcrypt, cryptography, pynacl
chardet==3.0.4 # via requests
click==6.7 # via flask