mirror of
https://github.com/certbot/certbot.git
synced 2026-08-01 00:35:03 +02:00
Merge branch 'master' into pylint
# Conflicts: # acme/acme/client.py # acme/acme/crypto_util.py # acme/acme/standalone.py # certbot-apache/certbot_apache/configurator.py # certbot-apache/certbot_apache/parser.py # certbot-apache/certbot_apache/tests/tls_sni_01_test.py # certbot-apache/certbot_apache/tests/util.py # certbot-apache/certbot_apache/tls_sni_01.py # certbot-nginx/certbot_nginx/configurator.py # certbot-nginx/certbot_nginx/parser.py # certbot-nginx/certbot_nginx/tests/util.py # certbot/account.py # certbot/cert_manager.py # certbot/cli.py # certbot/configuration.py # certbot/main.py # certbot/ocsp.py # certbot/plugins/dns_common_lexicon.py # certbot/plugins/standalone.py # certbot/plugins/util.py # certbot/plugins/webroot.py # certbot/tests/auth_handler_test.py # certbot/tests/cert_manager_test.py # certbot/tests/display/util_test.py # certbot/tests/main_test.py # certbot/tests/util.py # certbot/util.py # tox.ini
This commit is contained in:
+145
-258
@@ -1,27 +1,23 @@
|
||||
"""ACME AuthHandler."""
|
||||
import collections
|
||||
import logging
|
||||
import time
|
||||
import datetime
|
||||
|
||||
import six
|
||||
import zope.component
|
||||
|
||||
from acme import challenges
|
||||
from acme import messages
|
||||
|
||||
# pylint: disable=unused-import, no-name-in-module
|
||||
from acme.magic_typing import Dict, List
|
||||
# pylint: enable=unused-import, no-name-in-module
|
||||
from certbot import achallenges
|
||||
from certbot import errors
|
||||
from certbot import error_handler
|
||||
from certbot import interfaces
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
AnnotatedAuthzr = collections.namedtuple("AnnotatedAuthzr", ["authzr", "achalls"])
|
||||
"""Stores an authorization resource and its active annotated challenges."""
|
||||
|
||||
|
||||
class AuthHandler(object):
|
||||
"""ACME Authorization Handler for a client.
|
||||
|
||||
@@ -29,7 +25,7 @@ class AuthHandler(object):
|
||||
:class:`~acme.challenges.Challenge` types
|
||||
:type auth: :class:`certbot.interfaces.IAuthenticator`
|
||||
|
||||
:ivar acme.client.BackwardsCompatibleClientV2 acme: ACME client API.
|
||||
:ivar acme.client.BackwardsCompatibleClientV2 acme_client: ACME client API.
|
||||
|
||||
:ivar account: Client's Account
|
||||
:type account: :class:`certbot.account.Account`
|
||||
@@ -38,236 +34,158 @@ class AuthHandler(object):
|
||||
type strings with the most preferred challenge listed first
|
||||
|
||||
"""
|
||||
def __init__(self, auth, acme, account, pref_challs):
|
||||
def __init__(self, auth, acme_client, account, pref_challs):
|
||||
self.auth = auth
|
||||
self.acme = acme
|
||||
self.acme = acme_client
|
||||
|
||||
self.account = account
|
||||
self.pref_challs = pref_challs
|
||||
|
||||
def handle_authorizations(self, orderr, best_effort=False):
|
||||
"""Retrieve all authorizations for challenges.
|
||||
|
||||
:param acme.messages.OrderResource orderr: must have
|
||||
authorizations filled in
|
||||
:param bool best_effort: Whether or not all authorizations are
|
||||
required (this is useful in renewal)
|
||||
|
||||
:returns: List of authorization resources
|
||||
:rtype: list
|
||||
|
||||
:raises .AuthorizationError: If unable to retrieve all
|
||||
authorizations
|
||||
|
||||
def handle_authorizations(self, orderr, best_effort=False, max_retries=30):
|
||||
"""
|
||||
aauthzrs = [AnnotatedAuthzr(authzr, [])
|
||||
for authzr in orderr.authorizations]
|
||||
Retrieve all authorizations, perform all challenges required to validate
|
||||
these authorizations, then poll and wait for the authorization to be checked.
|
||||
:param acme.messages.OrderResource orderr: must have authorizations filled in
|
||||
:param bool best_effort: if True, not all authorizations need to be validated (eg. renew)
|
||||
:param int max_retries: maximum number of retries to poll authorizations
|
||||
:returns: list of all validated authorizations
|
||||
:rtype: List
|
||||
|
||||
self._choose_challenges(aauthzrs)
|
||||
config = zope.component.getUtility(interfaces.IConfig)
|
||||
notify = zope.component.getUtility(interfaces.IDisplay).notification
|
||||
:raises .AuthorizationError: If unable to retrieve all authorizations
|
||||
"""
|
||||
authzrs = orderr.authorizations[:]
|
||||
if not authzrs:
|
||||
raise errors.AuthorizationError('No authorization to handle.')
|
||||
|
||||
# While there are still challenges remaining...
|
||||
while self._has_challenges(aauthzrs):
|
||||
with error_handler.ExitHandler(self._cleanup_challenges, aauthzrs):
|
||||
resp = self._solve_challenges(aauthzrs)
|
||||
logger.info("Waiting for verification...")
|
||||
# Retrieve challenges that need to be performed to validate authorizations.
|
||||
achalls = self._choose_challenges(authzrs)
|
||||
if not achalls:
|
||||
return authzrs
|
||||
|
||||
# Starting now, challenges will be cleaned at the end no matter what.
|
||||
with error_handler.ExitHandler(self._cleanup_challenges, achalls):
|
||||
# To begin, let's ask the authenticator plugin to perform all challenges.
|
||||
try:
|
||||
resps = self.auth.perform(achalls)
|
||||
|
||||
# If debug is on, wait for user input before starting the verification process.
|
||||
logger.info('Waiting for verification...')
|
||||
config = zope.component.getUtility(interfaces.IConfig)
|
||||
if config.debug_challenges:
|
||||
notify = zope.component.getUtility(interfaces.IDisplay).notification
|
||||
notify('Challenges loaded. Press continue to submit to CA. '
|
||||
'Pass "-v" for more info about challenges.', pause=True)
|
||||
except errors.AuthorizationError as error:
|
||||
logger.critical('Failure in setting up challenges.')
|
||||
logger.info('Attempting to clean up outstanding challenges...')
|
||||
raise error
|
||||
# All challenges should have been processed by the authenticator.
|
||||
assert len(resps) == len(achalls), 'Some challenges have not been performed.'
|
||||
|
||||
# Send all Responses - this modifies achalls
|
||||
self._respond(aauthzrs, resp, best_effort)
|
||||
# Inform the ACME CA server that challenges are available for validation.
|
||||
for achall, resp in zip(achalls, resps):
|
||||
self.acme.answer_challenge(achall.challb, resp)
|
||||
|
||||
# Just make sure all decisions are complete.
|
||||
self.verify_authzr_complete(aauthzrs)
|
||||
# Wait for authorizations to be checked.
|
||||
self._poll_authorizations(authzrs, max_retries, best_effort)
|
||||
|
||||
# Only return valid authorizations
|
||||
retVal = [aauthzr.authzr for aauthzr in aauthzrs
|
||||
if aauthzr.authzr.body.status == messages.STATUS_VALID]
|
||||
# Keep validated authorizations only. If there is none, no certificate can be issued.
|
||||
authzrs_validated = [authzr for authzr in authzrs
|
||||
if authzr.body.status == messages.STATUS_VALID]
|
||||
if not authzrs_validated:
|
||||
raise errors.AuthorizationError('All challenges have failed.')
|
||||
|
||||
if not retVal:
|
||||
raise errors.AuthorizationError(
|
||||
"Challenges failed for all domains")
|
||||
return authzrs_validated
|
||||
|
||||
return retVal
|
||||
def _poll_authorizations(self, authzrs, max_retries, best_effort):
|
||||
"""
|
||||
Poll the ACME CA server, to wait for confirmation that authorizations have their challenges
|
||||
all verified. The poll may occur several times, until all authorizations are checked
|
||||
(valid or invalid), or after a maximum of retries.
|
||||
"""
|
||||
authzrs_to_check = {index: (authzr, None)
|
||||
for index, authzr in enumerate(authzrs)}
|
||||
authzrs_failed_to_report = []
|
||||
# Give an initial second to the ACME CA server to check the authorizations
|
||||
sleep_seconds = 1
|
||||
for _ in range(max_retries):
|
||||
# Wait for appropriate time (from Retry-After, initial wait, or no wait)
|
||||
if sleep_seconds > 0:
|
||||
time.sleep(sleep_seconds)
|
||||
# Poll all updated authorizations.
|
||||
authzrs_to_check = {index: self.acme.poll(authzr) for index, (authzr, _)
|
||||
in authzrs_to_check.items()}
|
||||
# Update the original list of authzr with the updated authzrs from server.
|
||||
for index, (authzr, _) in authzrs_to_check.items():
|
||||
authzrs[index] = authzr
|
||||
|
||||
def _choose_challenges(self, aauthzrs):
|
||||
"""Retrieve necessary challenges to satisfy server."""
|
||||
logger.info("Performing the following challenges:")
|
||||
for aauthzr in aauthzrs:
|
||||
aauthzr_challenges = aauthzr.authzr.body.challenges
|
||||
# Gather failed authorizations
|
||||
authzrs_failed = [authzr for authzr, _ in authzrs_to_check.values()
|
||||
if authzr.body.status == messages.STATUS_INVALID]
|
||||
for authzr_failed in authzrs_failed:
|
||||
logger.warning('Challenge failed for domain %s',
|
||||
authzr_failed.body.identifier.value)
|
||||
# Accumulating all failed authzrs to build a consolidated report
|
||||
# on them at the end of the polling.
|
||||
authzrs_failed_to_report.extend(authzrs_failed)
|
||||
|
||||
# Extract out the authorization already checked for next poll iteration.
|
||||
# Poll may stop here because there is no pending authorizations anymore.
|
||||
authzrs_to_check = {index: (authzr, resp) for index, (authzr, resp)
|
||||
in authzrs_to_check.items()
|
||||
if authzr.body.status == messages.STATUS_PENDING}
|
||||
if not authzrs_to_check:
|
||||
# Polling process is finished, we can leave the loop
|
||||
break
|
||||
|
||||
# Be merciful with the ACME server CA, check the Retry-After header returned,
|
||||
# and wait this time before polling again in next loop iteration.
|
||||
# From all the pending authorizations, we take the greatest Retry-After value
|
||||
# to avoid polling an authorization before its relevant Retry-After value.
|
||||
retry_after = max(self.acme.retry_after(resp, 3)
|
||||
for _, resp in authzrs_to_check.values())
|
||||
sleep_seconds = (retry_after - datetime.datetime.now()).total_seconds()
|
||||
|
||||
# In case of failed authzrs, create a report to the user.
|
||||
if authzrs_failed_to_report:
|
||||
_report_failed_authzrs(authzrs_failed_to_report, self.account.key)
|
||||
if not best_effort:
|
||||
# Without best effort, having failed authzrs is critical and fail the process.
|
||||
raise errors.AuthorizationError('Some challenges have failed.')
|
||||
|
||||
if authzrs_to_check:
|
||||
# Here authzrs_to_check is still not empty, meaning we exceeded the max polling attempt.
|
||||
raise errors.AuthorizationError('All authorizations were not finalized by the CA.')
|
||||
|
||||
def _choose_challenges(self, authzrs):
|
||||
"""
|
||||
Retrieve necessary and pending challenges to satisfy server.
|
||||
NB: Necessary and already validated challenges are not retrieved,
|
||||
as they can be reused for a certificate issuance.
|
||||
"""
|
||||
pending_authzrs = [authzr for authzr in authzrs
|
||||
if authzr.body.status != messages.STATUS_VALID]
|
||||
achalls = [] # type: List[achallenges.AnnotatedChallenge]
|
||||
if pending_authzrs:
|
||||
logger.info("Performing the following challenges:")
|
||||
for authzr in pending_authzrs:
|
||||
authzr_challenges = authzr.body.challenges
|
||||
if self.acme.acme_version == 1:
|
||||
combinations = aauthzr.authzr.body.combinations
|
||||
combinations = authzr.body.combinations
|
||||
else:
|
||||
combinations = tuple((i,) for i in range(len(aauthzr_challenges)))
|
||||
combinations = tuple((i,) for i in range(len(authzr_challenges)))
|
||||
|
||||
path = gen_challenge_path(
|
||||
aauthzr_challenges,
|
||||
self._get_chall_pref(aauthzr.authzr.body.identifier.value),
|
||||
authzr_challenges,
|
||||
self._get_chall_pref(authzr.body.identifier.value),
|
||||
combinations)
|
||||
|
||||
aauthzr_achalls = self._challenge_factory(
|
||||
aauthzr.authzr, path)
|
||||
aauthzr.achalls.extend(aauthzr_achalls)
|
||||
achalls.extend(self._challenge_factory(authzr, path))
|
||||
|
||||
def _has_challenges(self, aauthzrs):
|
||||
"""Do we have any challenges to perform?"""
|
||||
return any(aauthzr.achalls for aauthzr in aauthzrs)
|
||||
if any(isinstance(achall.chall, challenges.TLSSNI01) for achall in achalls):
|
||||
logger.warning("TLS-SNI-01 is deprecated, and will stop working soon.")
|
||||
|
||||
def _solve_challenges(self, aauthzrs):
|
||||
"""Get Responses for challenges from authenticators."""
|
||||
resp = []
|
||||
all_achalls = self._get_all_achalls(aauthzrs)
|
||||
try:
|
||||
if all_achalls:
|
||||
resp = self.auth.perform(all_achalls)
|
||||
except errors.AuthorizationError:
|
||||
logger.critical("Failure in setting up challenges.")
|
||||
logger.info("Attempting to clean up outstanding challenges...")
|
||||
raise
|
||||
|
||||
assert len(resp) == len(all_achalls)
|
||||
|
||||
return resp
|
||||
|
||||
def _get_all_achalls(self, aauthzrs):
|
||||
"""Return all active challenges."""
|
||||
all_achalls = []
|
||||
for aauthzr in aauthzrs:
|
||||
all_achalls.extend(aauthzr.achalls)
|
||||
|
||||
return all_achalls
|
||||
|
||||
def _respond(self, aauthzrs, resp, best_effort):
|
||||
"""Send/Receive confirmation of all challenges.
|
||||
|
||||
.. note:: This method also cleans up the auth_handler state.
|
||||
|
||||
"""
|
||||
# TODO: chall_update is a dirty hack to get around acme-spec #105
|
||||
chall_update = dict()
|
||||
self._send_responses(aauthzrs, resp, chall_update)
|
||||
|
||||
# Check for updated status...
|
||||
self._poll_challenges(aauthzrs, chall_update, best_effort)
|
||||
|
||||
def _send_responses(self, aauthzrs, resps, chall_update):
|
||||
"""Send responses and make sure errors are handled.
|
||||
|
||||
:param aauthzrs: authorizations and the selected annotated challenges
|
||||
to try and perform
|
||||
:type aauthzrs: `list` of `AnnotatedAuthzr`
|
||||
:param resps: challenge responses from the authenticator where
|
||||
each response at index i corresponds to the annotated
|
||||
challenge at index i in the list returned by
|
||||
:func:`_get_all_achalls`
|
||||
:type resps: `collections.abc.Iterable` of
|
||||
:class:`~acme.challenges.ChallengeResponse` or `False` or
|
||||
`None`
|
||||
:param dict chall_update: parameter that is updated to hold
|
||||
aauthzr index to list of outstanding solved annotated challenges
|
||||
|
||||
"""
|
||||
active_achalls = []
|
||||
resps_iter = iter(resps)
|
||||
for i, aauthzr in enumerate(aauthzrs):
|
||||
for achall in aauthzr.achalls:
|
||||
# This line needs to be outside of the if block below to
|
||||
# ensure failed challenges are cleaned up correctly
|
||||
active_achalls.append(achall)
|
||||
|
||||
resp = next(resps_iter)
|
||||
# Don't send challenges for None and False authenticator responses
|
||||
if resp:
|
||||
self.acme.answer_challenge(achall.challb, resp)
|
||||
# TODO: answer_challenge returns challr, with URI,
|
||||
# that can be used in _find_updated_challr
|
||||
# comparisons...
|
||||
chall_update.setdefault(i, []).append(achall)
|
||||
|
||||
return active_achalls
|
||||
|
||||
def _poll_challenges(self, aauthzrs, chall_update,
|
||||
best_effort, min_sleep=3, max_rounds=30):
|
||||
"""Wait for all challenge results to be determined."""
|
||||
indices_to_check = set(chall_update.keys())
|
||||
comp_indices = set()
|
||||
rounds = 0
|
||||
|
||||
while indices_to_check and rounds < max_rounds:
|
||||
# TODO: Use retry-after...
|
||||
time.sleep(min_sleep)
|
||||
all_failed_achalls = set()
|
||||
for index in indices_to_check:
|
||||
comp_achalls, failed_achalls = self._handle_check(
|
||||
aauthzrs, index, chall_update[index])
|
||||
|
||||
if len(comp_achalls) == len(chall_update[index]):
|
||||
comp_indices.add(index)
|
||||
elif not failed_achalls:
|
||||
for achall, _ in comp_achalls:
|
||||
chall_update[index].remove(achall)
|
||||
# We failed some challenges... damage control
|
||||
else:
|
||||
if best_effort:
|
||||
comp_indices.add(index)
|
||||
logger.warning(
|
||||
"Challenge failed for domain %s",
|
||||
aauthzrs[index].authzr.body.identifier.value)
|
||||
else:
|
||||
all_failed_achalls.update(
|
||||
updated for _, updated in failed_achalls)
|
||||
|
||||
if all_failed_achalls:
|
||||
_report_failed_challs(all_failed_achalls)
|
||||
raise errors.FailedChallenges(all_failed_achalls)
|
||||
|
||||
indices_to_check -= comp_indices
|
||||
comp_indices.clear()
|
||||
rounds += 1
|
||||
|
||||
def _handle_check(self, aauthzrs, index, achalls):
|
||||
"""Returns tuple of ('completed', 'failed')."""
|
||||
completed = []
|
||||
failed = []
|
||||
|
||||
original_aauthzr = aauthzrs[index]
|
||||
updated_authzr, _ = self.acme.poll(original_aauthzr.authzr)
|
||||
aauthzrs[index] = AnnotatedAuthzr(updated_authzr, original_aauthzr.achalls)
|
||||
if updated_authzr.body.status == messages.STATUS_VALID:
|
||||
return achalls, []
|
||||
|
||||
# Note: if the whole authorization is invalid, the individual failed
|
||||
# challenges will be determined here...
|
||||
for achall in achalls:
|
||||
updated_achall = achall.update(challb=self._find_updated_challb(
|
||||
updated_authzr, achall))
|
||||
|
||||
# This does nothing for challenges that have yet to be decided yet.
|
||||
if updated_achall.status == messages.STATUS_VALID:
|
||||
completed.append((achall, updated_achall))
|
||||
elif updated_achall.status == messages.STATUS_INVALID:
|
||||
failed.append((achall, updated_achall))
|
||||
|
||||
return completed, failed
|
||||
|
||||
def _find_updated_challb(self, authzr, achall): # pylint: disable=no-self-use
|
||||
"""Find updated challenge body within Authorization Resource.
|
||||
|
||||
.. warning:: This assumes only one instance of type of challenge in
|
||||
each challenge resource.
|
||||
|
||||
:param .AuthorizationResource authzr: Authorization Resource
|
||||
:param .AnnotatedChallenge achall: Annotated challenge for which
|
||||
to get status
|
||||
|
||||
"""
|
||||
for authzr_challb in authzr.body.challenges:
|
||||
if type(authzr_challb.chall) is type(achall.challb.chall): # noqa
|
||||
return authzr_challb
|
||||
raise errors.AuthorizationError(
|
||||
"Target challenge not found in authorization resource")
|
||||
return achalls
|
||||
|
||||
def _get_chall_pref(self, domain):
|
||||
"""Return list of challenge preferences.
|
||||
@@ -291,43 +209,15 @@ class AuthHandler(object):
|
||||
chall_prefs.extend(plugin_pref)
|
||||
return chall_prefs
|
||||
|
||||
def _cleanup_challenges(self, aauthzrs, achalls=None):
|
||||
def _cleanup_challenges(self, achalls):
|
||||
"""Cleanup challenges.
|
||||
|
||||
:param aauthzrs: authorizations and their selected annotated
|
||||
challenges
|
||||
:type aauthzrs: `list` of `AnnotatedAuthzr`
|
||||
:param achalls: annotated challenges to cleanup
|
||||
:type achalls: `list` of :class:`certbot.achallenges.AnnotatedChallenge`
|
||||
|
||||
"""
|
||||
logger.info("Cleaning up challenges")
|
||||
if achalls is None:
|
||||
achalls = self._get_all_achalls(aauthzrs)
|
||||
if achalls:
|
||||
self.auth.cleanup(achalls)
|
||||
for achall in achalls:
|
||||
for aauthzr in aauthzrs:
|
||||
if achall in aauthzr.achalls:
|
||||
aauthzr.achalls.remove(achall)
|
||||
break
|
||||
|
||||
def verify_authzr_complete(self, aauthzrs):
|
||||
"""Verifies that all authorizations have been decided.
|
||||
|
||||
:param aauthzrs: authorizations and their selected annotated
|
||||
challenges
|
||||
:type aauthzrs: `list` of `AnnotatedAuthzr`
|
||||
|
||||
:returns: Whether all authzr are complete
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
for aauthzr in aauthzrs:
|
||||
authzr = aauthzr.authzr
|
||||
if (authzr.body.status != messages.STATUS_VALID and
|
||||
authzr.body.status != messages.STATUS_INVALID):
|
||||
raise errors.AuthorizationError("Incomplete authorizations")
|
||||
self.auth.cleanup(achalls)
|
||||
|
||||
def _challenge_factory(self, authzr, path):
|
||||
"""Construct Namedtuple Challenges
|
||||
@@ -423,7 +313,7 @@ def _find_smart_path(challbs, preferences, combinations):
|
||||
|
||||
# max_cost is now equal to sum(indices) + 1
|
||||
|
||||
best_combo = []
|
||||
best_combo = None
|
||||
# Set above completing all of the available challenges
|
||||
best_combo_cost = max_cost
|
||||
|
||||
@@ -478,7 +368,7 @@ def _report_no_chall_path(challbs):
|
||||
msg += (
|
||||
" You may need to use an authenticator "
|
||||
"plugin that can do challenges over DNS.")
|
||||
logger.fatal(msg)
|
||||
logger.critical(msg)
|
||||
raise errors.AuthorizationError(msg)
|
||||
|
||||
|
||||
@@ -514,22 +404,19 @@ _ERROR_HELP = {
|
||||
}
|
||||
|
||||
|
||||
def _report_failed_challs(failed_achalls):
|
||||
"""Notifies the user about failed challenges.
|
||||
def _report_failed_authzrs(failed_authzrs, account_key):
|
||||
"""Notifies the user about failed authorizations."""
|
||||
problems = {} # type: Dict[str, List[achallenges.AnnotatedChallenge]]
|
||||
failed_achalls = [challb_to_achall(challb, account_key, authzr.body.identifier.value)
|
||||
for authzr in failed_authzrs for challb in authzr.body.challenges
|
||||
if challb.error]
|
||||
|
||||
:param set failed_achalls: A set of failed
|
||||
:class:`certbot.achallenges.AnnotatedChallenge`.
|
||||
|
||||
"""
|
||||
problems = dict()
|
||||
for achall in failed_achalls:
|
||||
if achall.error:
|
||||
problems.setdefault(achall.error.typ, []).append(achall)
|
||||
problems.setdefault(achall.error.typ, []).append(achall)
|
||||
|
||||
reporter = zope.component.getUtility(interfaces.IReporter)
|
||||
for achalls in six.itervalues(problems):
|
||||
reporter.add_message(
|
||||
_generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY)
|
||||
for achalls in problems.values():
|
||||
reporter.add_message(_generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY)
|
||||
|
||||
|
||||
def _generate_failed_chall_msg(failed_achalls):
|
||||
|
||||
Reference in New Issue
Block a user