Merge pull request #1369 from castrapel/acme_validation_dns_provider_option

Acme validation dns provider option
This commit is contained in:
Curtis 2018-06-19 21:24:15 -07:00 committed by GitHub
commit b231521ff6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 70 additions and 29 deletions

View File

@ -54,15 +54,24 @@ def maybe_remove_wildcard(host):
return host.replace("*.", "") return host.replace("*.", "")
def start_dns_challenge(acme_client, account_number, host, dns_provider, order): 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 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)) current_app.logger.debug("Starting DNS challenge for {0}".format(host))
dns_challenges = find_dns_challenge(order.authorizations) dns_challenges = find_dns_challenge(order.authorizations)
change_ids = [] change_ids = []
host_to_validate = maybe_remove_wildcard(host)
host_to_validate = maybe_add_extension(host_to_validate, dns_provider_options)
for dns_challenge in find_dns_challenge(order.authorizations): for dns_challenge in find_dns_challenge(order.authorizations):
change_id = dns_provider.create_txt_record( change_id = dns_provider.create_txt_record(
dns_challenge.validation_domain_name(maybe_remove_wildcard(host)), 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 account_number
) )
@ -104,11 +113,13 @@ def request_certificate(acme_client, authorizations, csr, order):
authorization_resource, _ = acme_client.poll(authz) authorization_resource, _ = acme_client.poll(authz)
deadline = datetime.datetime.now() + datetime.timedelta(seconds=90) deadline = datetime.datetime.now() + datetime.timedelta(seconds=90)
try: try:
orderr = acme_client.finalize_order(order, deadline) orderr = acme_client.finalize_order(order, deadline)
except AcmeError: except AcmeError:
current_app.logger.error("Unable to resolve Acme order: {}".format(order), exc_info=True) current_app.logger.error("Unable to resolve Acme order: {}".format(order), exc_info=True)
raise raise
pem_certificate = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, pem_certificate = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM,
OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM,
orderr.fullchain_pem)).decode() orderr.fullchain_pem)).decode()
@ -158,24 +169,27 @@ def get_domains(options):
return domains return domains
def get_authorizations(acme_client, order, order_info, dns_provider): def get_authorizations(acme_client, order, order_info, dns_provider, dns_provider_options):
authorizations = [] authorizations = []
for domain in order_info.domains: for domain in order_info.domains:
authz_record = start_dns_challenge(acme_client, order_info.account_number, domain, dns_provider, order) authz_record = start_dns_challenge(acme_client, order_info.account_number, domain, dns_provider, order,
dns_provider_options)
authorizations.append(authz_record) authorizations.append(authz_record)
return authorizations return authorizations
def finalize_authorizations(acme_client, account_number, dns_provider, authorizations): def finalize_authorizations(acme_client, account_number, dns_provider, authorizations, dns_provider_options):
for authz_record in authorizations: for authz_record in authorizations:
complete_dns_challenge(acme_client, account_number, authz_record, dns_provider) complete_dns_challenge(acme_client, account_number, authz_record, dns_provider)
for authz_record in authorizations: for authz_record in authorizations:
dns_challenges = authz_record.dns_challenge 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: for dns_challenge in dns_challenges:
dns_provider.delete_txt_record( dns_provider.delete_txt_record(
authz_record.change_id, authz_record.change_id,
account_number, account_number,
dns_challenge.validation_domain_name(maybe_remove_wildcard(authz_record.host)), dns_challenge.validation_domain_name(host_to_validate),
dns_challenge.validation(acme_client.client.net.key) dns_challenge.validation(acme_client.client.net.key)
) )
@ -239,16 +253,17 @@ class ACMEIssuerPlugin(IssuerPlugin):
acme_client, registration = setup_acme_client(pending_cert.authority) acme_client, registration = setup_acme_client(pending_cert.authority)
order_info = authorization_service.get(pending_cert.external_id) order_info = authorization_service.get(pending_cert.external_id)
dns_provider = dns_provider_service.get(pending_cert.dns_provider_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) dns_provider_type = self.get_dns_provider(dns_provider.provider_type)
try: try:
authorizations = get_authorizations( authorizations = get_authorizations(
acme_client, order_info.account_number, order_info.domains, dns_provider_type) acme_client, order_info.account_number, order_info.domains, dns_provider_type, dns_provider_options)
except ClientError: except ClientError:
current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert.name), exc_info=True) current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert.name), exc_info=True)
return False return False
authorizations = finalize_authorizations( authorizations = finalize_authorizations(
acme_client, order_info.account_number, dns_provider_type, 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) pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, pending_cert.csr)
cert = { cert = {
'body': "\n".join(str(pem_certificate).splitlines()), 'body': "\n".join(str(pem_certificate).splitlines()),
@ -265,6 +280,7 @@ class ACMEIssuerPlugin(IssuerPlugin):
acme_client, registration = setup_acme_client(pending_cert.authority) acme_client, registration = setup_acme_client(pending_cert.authority)
order_info = authorization_service.get(pending_cert.external_id) order_info = authorization_service.get(pending_cert.external_id)
dns_provider = dns_provider_service.get(pending_cert.dns_provider_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) dns_provider_type = self.get_dns_provider(dns_provider.provider_type)
try: try:
order = acme_client.new_order(pending_cert.csr) order = acme_client.new_order(pending_cert.csr)
@ -272,7 +288,8 @@ class ACMEIssuerPlugin(IssuerPlugin):
raise Exception("The currently selected ACME CA endpoint does" raise Exception("The currently selected ACME CA endpoint does"
" not support issuing wildcard certificates.") " not support issuing wildcard certificates.")
authorizations = get_authorizations(acme_client, order, order_info, dns_provider_type) authorizations = get_authorizations(acme_client, order, order_info, dns_provider_type,
dns_provider_options)
pending.append({ pending.append({
"acme_client": acme_client, "acme_client": acme_client,
@ -281,6 +298,7 @@ class ACMEIssuerPlugin(IssuerPlugin):
"authorizations": authorizations, "authorizations": authorizations,
"pending_cert": pending_cert, "pending_cert": pending_cert,
"order": order, "order": order,
"dns_provider_options": dns_provider_options,
}) })
except (ClientError, ValueError, Exception): except (ClientError, ValueError, Exception):
current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert), exc_info=True) current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert), exc_info=True)
@ -296,6 +314,7 @@ class ACMEIssuerPlugin(IssuerPlugin):
entry["account_number"], entry["account_number"],
entry["dns_provider_type"], entry["dns_provider_type"],
entry["authorizations"], entry["authorizations"],
entry["dns_provider_options"],
) )
pem_certificate, pem_certificate_chain = request_certificate( pem_certificate, pem_certificate_chain = request_certificate(
entry["acme_client"], entry["acme_client"],
@ -333,6 +352,7 @@ class ACMEIssuerPlugin(IssuerPlugin):
create_immediately = issuer_options.get('create_immediately', False) create_immediately = issuer_options.get('create_immediately', False)
acme_client, registration = setup_acme_client(authority) acme_client, registration = setup_acme_client(authority)
dns_provider = issuer_options.get('dns_provider') dns_provider = issuer_options.get('dns_provider')
dns_provider_options = dns_provider.options
if not dns_provider: if not dns_provider:
raise InvalidConfiguration("DNS Provider setting is required for ACME certificates.") raise InvalidConfiguration("DNS Provider setting is required for ACME certificates.")
credentials = json.loads(dns_provider.credentials) credentials = json.loads(dns_provider.credentials)
@ -358,8 +378,9 @@ class ACMEIssuerPlugin(IssuerPlugin):
# Return id of the DNS Authorization # Return id of the DNS Authorization
return None, None, dns_authorization.id return None, None, dns_authorization.id
authorizations = get_authorizations(acme_client, account_number, domains, dns_provider_type) authorizations = get_authorizations(acme_client, account_number, domains, dns_provider_type,
finalize_authorizations(acme_client, account_number, dns_provider_type, authorizations) 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) pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr)
# TODO add external ID (if possible) # TODO add external ID (if possible)
return pem_certificate, pem_certificate_chain, None return pem_certificate, pem_certificate_chain, None

View File

@ -33,6 +33,29 @@ def find_zone_id(domain, client=None):
@sts_client('route53') @sts_client('route53')
def change_txt_record(action, zone_id, domain, value, client=None): def change_txt_record(action, zone_id, domain, value, client=None):
current_txt_records = []
try:
current_txt_records = client.list_resource_record_sets(
HostedZoneId=zone_id,
StartRecordName=domain,
StartRecordType='TXT',
MaxItems="1")["ResourceRecordSets"][0]["ResourceRecords"]
except Exception as e:
# Current Resource Record does not exist
if "NoSuchHostedZone" not in str(type(e)):
raise
# For some reason TXT records need to be
# manually quoted.
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.
# This allows us to support concurrent issuance.
current_txt_records = [
record for record in current_txt_records if not (record.get('Value') == '"{}"'.format(value))
]
action = "UPSERT"
response = client.change_resource_record_sets( response = client.change_resource_record_sets(
HostedZoneId=zone_id, HostedZoneId=zone_id,
ChangeBatch={ ChangeBatch={
@ -43,11 +66,7 @@ def change_txt_record(action, zone_id, domain, value, client=None):
"Name": domain, "Name": domain,
"Type": "TXT", "Type": "TXT",
"TTL": 300, "TTL": 300,
"ResourceRecords": [ "ResourceRecords": current_txt_records,
# For some reason TXT records need to be
# manually quoted.
{"Value": '"{}"'.format(value)}
],
} }
} }
] ]

View File

@ -52,7 +52,7 @@ class TestAcme(unittest.TestCase):
iterable = mock_find_dns_challenge.return_value iterable = mock_find_dns_challenge.return_value
iterator = iter(values) iterator = iter(values)
iterable.__iter__.return_value = iterator iterable.__iter__.return_value = iterator
result = plugin.start_dns_challenge(mock_acme, "accountid", "host", mock_dns_provider, mock_order) result = plugin.start_dns_challenge(mock_acme, "accountid", "host", mock_dns_provider, mock_order, {})
self.assertEqual(type(result), plugin.AuthorizationRecord) self.assertEqual(type(result), plugin.AuthorizationRecord)
@patch('acme.client.Client') @patch('acme.client.Client')
@ -171,7 +171,7 @@ class TestAcme(unittest.TestCase):
mock_order_info = Mock() mock_order_info = Mock()
mock_order_info.account_number = 1 mock_order_info.account_number = 1
mock_order_info.domains = ["test.fakedomain.net"] mock_order_info.domains = ["test.fakedomain.net"]
result = plugin.get_authorizations("acme_client", mock_order, mock_order_info, "dns_provider") result = plugin.get_authorizations("acme_client", mock_order, mock_order_info, "dns_provider", {})
self.assertEqual(result, ["test"]) self.assertEqual(result, ["test"])
@patch('lemur.plugins.lemur_acme.plugin.complete_dns_challenge', return_value="test") @patch('lemur.plugins.lemur_acme.plugin.complete_dns_challenge', return_value="test")
@ -187,7 +187,7 @@ class TestAcme(unittest.TestCase):
mock_dns_provider.delete_txt_record = Mock() mock_dns_provider.delete_txt_record = Mock()
mock_acme_client = Mock() mock_acme_client = Mock()
result = plugin.finalize_authorizations(mock_acme_client, "account_number", mock_dns_provider, mock_authz) result = plugin.finalize_authorizations(mock_acme_client, "account_number", mock_dns_provider, mock_authz, {})
self.assertEqual(result, mock_authz) self.assertEqual(result, mock_authz)
@patch('lemur.plugins.lemur_acme.plugin.current_app') @patch('lemur.plugins.lemur_acme.plugin.current_app')

View File

@ -5,7 +5,7 @@
# pip-compile --no-index --output-file requirements-dev.txt requirements-dev.in # pip-compile --no-index --output-file requirements-dev.txt requirements-dev.in
# #
aspy.yaml==1.1.1 # via pre-commit aspy.yaml==1.1.1 # via pre-commit
cached-property==1.4.2 # via pre-commit cached-property==1.4.3 # via pre-commit
certifi==2018.4.16 # via requests certifi==2018.4.16 # via requests
cfgv==1.1.0 # via pre-commit cfgv==1.1.0 # via pre-commit
chardet==3.0.4 # via requests chardet==3.0.4 # via requests

View File

@ -5,7 +5,7 @@
# pip-compile --no-index --output-file requirements-docs.txt requirements-docs.in # pip-compile --no-index --output-file requirements-docs.txt requirements-docs.in
# #
acme==0.25.1 acme==0.25.1
alabaster==0.7.10 # via sphinx alabaster==0.7.11 # via sphinx
alembic-autogenerate-enums==0.0.2 alembic-autogenerate-enums==0.0.2
alembic==0.9.9 alembic==0.9.9
aniso8601==3.0.0 aniso8601==3.0.0

View File

@ -8,9 +8,9 @@ asn1crypto==0.24.0 # via cryptography
atomicwrites==1.1.5 # via pytest atomicwrites==1.1.5 # via pytest
attrs==18.1.0 # via pytest attrs==18.1.0 # via pytest
aws-xray-sdk==0.95 # via moto aws-xray-sdk==0.95 # via moto
boto3==1.7.38 # via moto boto3==1.7.41 # via moto
boto==2.48.0 # via moto boto==2.48.0 # via moto
botocore==1.10.38 # via boto3, moto, s3transfer botocore==1.10.41 # via boto3, moto, s3transfer
certifi==2018.4.16 # via requests certifi==2018.4.16 # via requests
cffi==1.11.5 # via cryptography cffi==1.11.5 # via cryptography
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
@ -19,10 +19,10 @@ cookies==2.2.1 # via moto, responses
coverage==4.5.1 coverage==4.5.1
cryptography==2.2.2 # via moto cryptography==2.2.2 # via moto
docker-pycreds==0.3.0 # via docker docker-pycreds==0.3.0 # via docker
docker==3.3.0 # via moto docker==3.4.0 # via moto
docutils==0.14 # via botocore docutils==0.14 # via botocore
factory-boy==2.11.1 factory-boy==2.11.1
faker==0.8.15 faker==0.8.16
flask==1.0.2 # via pytest-flask flask==1.0.2 # via pytest-flask
freezegun==0.3.10 freezegun==0.3.10
idna==2.7 # via cryptography, requests idna==2.7 # via cryptography, requests

View File

@ -7,14 +7,14 @@
acme==0.25.1 acme==0.25.1
alembic-autogenerate-enums==0.0.2 alembic-autogenerate-enums==0.0.2
alembic==0.9.9 # via flask-migrate alembic==0.9.9 # via flask-migrate
aniso8601==3.0.0 # via flask-restful aniso8601==3.0.2 # via flask-restful
arrow==0.12.1 arrow==0.12.1
asn1crypto==0.24.0 # via cryptography asn1crypto==0.24.0 # via cryptography
asyncpool==1.0 asyncpool==1.0
bcrypt==3.1.4 # via flask-bcrypt, paramiko bcrypt==3.1.4 # via flask-bcrypt, paramiko
blinker==1.4 # via flask-mail, flask-principal, raven blinker==1.4 # via flask-mail, flask-principal, raven
boto3==1.7.38 boto3==1.7.41
botocore==1.10.38 # via boto3, s3transfer botocore==1.10.41 # via boto3, s3transfer
certifi==2018.4.16 certifi==2018.4.16
cffi==1.11.5 # via bcrypt, cryptography, pynacl cffi==1.11.5 # via bcrypt, cryptography, pynacl
click==6.7 # via flask click==6.7 # via flask
@ -52,7 +52,7 @@ ndg-httpsclient==0.5.0
paramiko==2.4.1 paramiko==2.4.1
pbr==4.0.4 # via mock pbr==4.0.4 # via mock
pem==17.1.0 pem==17.1.0
psycopg2==2.7.4 psycopg2==2.7.5
pyasn1-modules==0.2.1 # via python-ldap pyasn1-modules==0.2.1 # via python-ldap
pyasn1==0.4.3 # via ndg-httpsclient, paramiko, pyasn1-modules, python-ldap, requests pyasn1==0.4.3 # via ndg-httpsclient, paramiko, pyasn1-modules, python-ldap, requests
pycparser==2.18 # via cffi pycparser==2.18 # via cffi
@ -74,5 +74,6 @@ six==1.11.0
sqlalchemy-utils==0.33.3 sqlalchemy-utils==0.33.3
sqlalchemy==1.2.8 # via alembic, flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils sqlalchemy==1.2.8 # via alembic, flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils
tabulate==0.8.2 tabulate==0.8.2
tld==0.9
werkzeug==0.14.1 # via flask werkzeug==0.14.1 # via flask
xmltodict==0.11.0 xmltodict==0.11.0