mirror of
https://github.com/certbot/certbot.git
synced 2026-07-30 18:04:31 +02:00
* Handle CAA failure on finalize_order during renewal (#9251) * Fix CAA error on renewal test * Attempt to fix failing test in CI * Retry errors with subproblems in obtain_certificate_from_csr with allow_subset_of_names Only retry if not all domains succeeded * Back out renewal changes * Fix linting error line too long * Update log message for more general case and only log on retry * Changelog entry * Add retry logic to order creation * Changelog entry wording * Fix acme error handling when no subproblems provided * Fix test name * Use summarize domain list to display list of failed domains * Tidy up incorrect client tests * Remove unused var and output all failed domains * Add logging to failed authorization case * use _retry_obtain_certificate for failed authorizations * Fix typo failing in CI * Retry logic comments * Preserve original error * Move changelog entry to latest version
This commit is contained in:
@@ -10,7 +10,7 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
*
|
* `--allow-subset-of-names` will now additionally retry in cases where domains are rejected while creating or finalizing orders. This requires subproblem support from the ACME server.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -438,7 +438,17 @@ class Client:
|
|||||||
csr = crypto_util.generate_csr(key, domains, self.config.csr_dir,
|
csr = crypto_util.generate_csr(key, domains, self.config.csr_dir,
|
||||||
self.config.must_staple, self.config.strict_permissions)
|
self.config.must_staple, self.config.strict_permissions)
|
||||||
|
|
||||||
orderr = self._get_order_and_authorizations(csr.data, self.config.allow_subset_of_names)
|
try:
|
||||||
|
orderr = self._get_order_and_authorizations(csr.data, self.config.allow_subset_of_names)
|
||||||
|
except messages.Error as error:
|
||||||
|
# Some domains may be rejected during order creation.
|
||||||
|
# Certbot can retry the operation without the rejected
|
||||||
|
# domains contained within subproblems.
|
||||||
|
if self.config.allow_subset_of_names:
|
||||||
|
successful_domains = self._successful_domains_from_error(error, domains)
|
||||||
|
if successful_domains != domains and len(successful_domains) != 0:
|
||||||
|
return self._retry_obtain_certificate(key, csr, domains, successful_domains)
|
||||||
|
raise
|
||||||
authzr = orderr.authorizations
|
authzr = orderr.authorizations
|
||||||
auth_domains = {a.body.identifier.value for a in authzr}
|
auth_domains = {a.body.identifier.value for a in authzr}
|
||||||
successful_domains = [d for d in domains if d in auth_domains]
|
successful_domains = [d for d in domains if d in auth_domains]
|
||||||
@@ -449,13 +459,20 @@ class Client:
|
|||||||
# domains contains a wildcard because the ACME spec forbids identifiers
|
# domains contains a wildcard because the ACME spec forbids identifiers
|
||||||
# in authzs from containing a wildcard character.
|
# in authzs from containing a wildcard character.
|
||||||
if self.config.allow_subset_of_names and successful_domains != domains:
|
if self.config.allow_subset_of_names and successful_domains != domains:
|
||||||
if not self.config.dry_run:
|
return self._retry_obtain_certificate(key, csr, domains, successful_domains)
|
||||||
os.remove(key.file)
|
|
||||||
os.remove(csr.file)
|
|
||||||
return self.obtain_certificate(successful_domains)
|
|
||||||
else:
|
else:
|
||||||
cert, chain = self.obtain_certificate_from_csr(csr, orderr)
|
try:
|
||||||
return cert, chain, key, csr
|
cert, chain = self.obtain_certificate_from_csr(csr, orderr)
|
||||||
|
return cert, chain, key, csr
|
||||||
|
except messages.Error as error:
|
||||||
|
# Some domains may be rejected during the very late stage of
|
||||||
|
# order finalization. Certbot can retry the operation without
|
||||||
|
# the rejected domains contained within subproblems.
|
||||||
|
if self.config.allow_subset_of_names:
|
||||||
|
successful_domains = self._successful_domains_from_error(error, domains)
|
||||||
|
if successful_domains != domains and len(successful_domains) != 0:
|
||||||
|
return self._retry_obtain_certificate(key, csr, domains, successful_domains)
|
||||||
|
raise
|
||||||
|
|
||||||
def _get_order_and_authorizations(self, csr_pem: bytes,
|
def _get_order_and_authorizations(self, csr_pem: bytes,
|
||||||
best_effort: bool) -> messages.OrderResource:
|
best_effort: bool) -> messages.OrderResource:
|
||||||
@@ -528,6 +545,27 @@ class Client:
|
|||||||
key.pem, chain,
|
key.pem, chain,
|
||||||
self.config)
|
self.config)
|
||||||
|
|
||||||
|
def _successful_domains_from_error(self, error: messages.Error, domains: List[str],
|
||||||
|
) -> List[str]:
|
||||||
|
if error.subproblems is not None:
|
||||||
|
failed_domains = [problem.identifier.value for problem in error.subproblems
|
||||||
|
if problem.identifier is not None]
|
||||||
|
successful_domains = [x for x in domains if x not in failed_domains]
|
||||||
|
return successful_domains
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _retry_obtain_certificate(self, key: util.Key,
|
||||||
|
csr: util.CSR, domains: List[str], successful_domains: List[str]
|
||||||
|
) -> Tuple[bytes, bytes, util.Key, util.CSR]:
|
||||||
|
failed_domains = [d for d in domains if d not in successful_domains]
|
||||||
|
domains_list = ", ".join(failed_domains)
|
||||||
|
display_util.notify("Unable to obtain a certificate with every requested "
|
||||||
|
f"domain. Retrying without: {domains_list}")
|
||||||
|
if not self.config.dry_run:
|
||||||
|
os.remove(key.file)
|
||||||
|
os.remove(csr.file)
|
||||||
|
return self.obtain_certificate(successful_domains)
|
||||||
|
|
||||||
def _choose_lineagename(self, domains: List[str], certname: Optional[str]) -> str:
|
def _choose_lineagename(self, domains: List[str], certname: Optional[str]) -> str:
|
||||||
"""Chooses a name for the new lineage.
|
"""Chooses a name for the new lineage.
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,193 @@ class ClientTest(ClientTestCommon):
|
|||||||
self.assertEqual(mock_remove.call_count, 2)
|
self.assertEqual(mock_remove.call_count, 2)
|
||||||
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 1)
|
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 1)
|
||||||
|
|
||||||
|
@mock.patch("certbot._internal.client.crypto_util")
|
||||||
|
@mock.patch("certbot.compat.os.remove")
|
||||||
|
def test_obtain_certificate_finalize_order_partial_success(self, mock_remove, mock_crypto_util):
|
||||||
|
from acme import messages
|
||||||
|
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
|
||||||
|
key = util.CSR(form="pem", file=mock.sentinel.key_file, data=CSR_SAN)
|
||||||
|
mock_crypto_util.generate_csr.return_value = csr
|
||||||
|
mock_crypto_util.generate_key.return_value = key
|
||||||
|
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
|
||||||
|
|
||||||
|
self._mock_obtain_certificate()
|
||||||
|
authzr = self._authzr_from_domains(self.eg_domains)
|
||||||
|
self.eg_order.authorizations = authzr
|
||||||
|
self.client.auth_handler.handle_authorizations.return_value = authzr
|
||||||
|
|
||||||
|
identifier = messages.Identifier(typ=messages.IDENTIFIER_FQDN, value='example.com')
|
||||||
|
subproblem = messages.Error.with_code('caa', detail='bar', title='title', identifier=identifier)
|
||||||
|
error_with_subproblems = messages.Error.with_code('malformed', detail='foo', title='title', subproblems=[subproblem])
|
||||||
|
self.client.acme.finalize_order.side_effect = [error_with_subproblems, mock.DEFAULT]
|
||||||
|
|
||||||
|
self.config.allow_subset_of_names = True
|
||||||
|
|
||||||
|
with test_util.patch_display_util():
|
||||||
|
result = self.client.obtain_certificate(self.eg_domains)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
(mock.sentinel.cert, mock.sentinel.chain, key, csr))
|
||||||
|
self.assertEqual(self.client.auth_handler.handle_authorizations.call_count, 2)
|
||||||
|
self.assertEqual(self.acme.finalize_order.call_count, 2)
|
||||||
|
|
||||||
|
successful_domains = [d for d in self.eg_domains if d != 'example.com']
|
||||||
|
self.assertEqual(mock_crypto_util.generate_key.call_count, 2)
|
||||||
|
mock_crypto_util.generate_csr.assert_has_calls([
|
||||||
|
mock.call(key, self.eg_domains, self.config.csr_dir, self.config.must_staple, self.config.strict_permissions),
|
||||||
|
mock.call(key, successful_domains, self.config.csr_dir, self.config.must_staple, self.config.strict_permissions)])
|
||||||
|
self.assertEqual(mock_remove.call_count, 2)
|
||||||
|
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 1)
|
||||||
|
|
||||||
|
@mock.patch("certbot._internal.client.crypto_util")
|
||||||
|
def test_obtain_certificate_finalize_order_no_retryable_domains(self, mock_crypto_util):
|
||||||
|
from acme import messages
|
||||||
|
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
|
||||||
|
key = util.CSR(form="pem", file=mock.sentinel.key_file, data=CSR_SAN)
|
||||||
|
mock_crypto_util.generate_csr.return_value = csr
|
||||||
|
mock_crypto_util.generate_key.return_value = key
|
||||||
|
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
|
||||||
|
|
||||||
|
self._mock_obtain_certificate()
|
||||||
|
authzr = self._authzr_from_domains(self.eg_domains)
|
||||||
|
self.eg_order.authorizations = authzr
|
||||||
|
self.client.auth_handler.handle_authorizations.return_value = authzr
|
||||||
|
|
||||||
|
identifier1 = messages.Identifier(typ=messages.IDENTIFIER_FQDN, value='example.com')
|
||||||
|
identifier2 = messages.Identifier(typ=messages.IDENTIFIER_FQDN, value='www.example.com')
|
||||||
|
subproblem1 = messages.Error.with_code('caa', detail='bar', title='title', identifier=identifier1)
|
||||||
|
subproblem2 = messages.Error.with_code('caa', detail='bar', title='title', identifier=identifier2)
|
||||||
|
error_with_subproblems = messages.Error.with_code('malformed', detail='foo', title='title', subproblems=[subproblem1, subproblem2])
|
||||||
|
self.client.acme.finalize_order.side_effect = error_with_subproblems
|
||||||
|
|
||||||
|
self.config.allow_subset_of_names = True
|
||||||
|
|
||||||
|
self.assertRaises(messages.Error, self.client.obtain_certificate, self.eg_domains)
|
||||||
|
self.assertEqual(self.client.auth_handler.handle_authorizations.call_count, 1)
|
||||||
|
self.assertEqual(self.acme.finalize_order.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.generate_key.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 0)
|
||||||
|
|
||||||
|
@mock.patch("certbot._internal.client.crypto_util")
|
||||||
|
def test_obtain_certificate_finalize_order_rejected_identifier_no_subproblems(self, mock_crypto_util):
|
||||||
|
from acme import messages
|
||||||
|
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
|
||||||
|
key = util.CSR(form="pem", file=mock.sentinel.key_file, data=CSR_SAN)
|
||||||
|
mock_crypto_util.generate_csr.return_value = csr
|
||||||
|
mock_crypto_util.generate_key.return_value = key
|
||||||
|
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
|
||||||
|
|
||||||
|
self._mock_obtain_certificate()
|
||||||
|
authzr = self._authzr_from_domains(self.eg_domains)
|
||||||
|
self.eg_order.authorizations = authzr
|
||||||
|
self.client.auth_handler.handle_authorizations.return_value = authzr
|
||||||
|
|
||||||
|
error = messages.Error.with_code('caa', detail='foo', title='title')
|
||||||
|
self.client.acme.finalize_order.side_effect = error
|
||||||
|
|
||||||
|
self.config.allow_subset_of_names = True
|
||||||
|
|
||||||
|
self.assertRaises(messages.Error, self.client.obtain_certificate,
|
||||||
|
self.eg_domains)
|
||||||
|
self.assertEqual(self.client.auth_handler.handle_authorizations.call_count, 1)
|
||||||
|
self.assertEqual(self.acme.finalize_order.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.generate_key.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 0)
|
||||||
|
|
||||||
|
@mock.patch("certbot._internal.client.crypto_util")
|
||||||
|
@mock.patch("certbot.compat.os.remove")
|
||||||
|
def test_obtain_certificate_get_order_partial_success(self, mock_remove, mock_crypto_util):
|
||||||
|
from acme import messages
|
||||||
|
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
|
||||||
|
key = util.CSR(form="pem", file=mock.sentinel.key_file, data=CSR_SAN)
|
||||||
|
mock_crypto_util.generate_csr.return_value = csr
|
||||||
|
mock_crypto_util.generate_key.return_value = key
|
||||||
|
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
|
||||||
|
|
||||||
|
self._mock_obtain_certificate()
|
||||||
|
authzr = self._authzr_from_domains(self.eg_domains)
|
||||||
|
self.eg_order.authorizations = authzr
|
||||||
|
self.client.auth_handler.handle_authorizations.return_value = authzr
|
||||||
|
|
||||||
|
identifier = messages.Identifier(typ=messages.IDENTIFIER_FQDN, value='example.com')
|
||||||
|
subproblem = messages.Error.with_code('caa', detail='bar', title='title', identifier=identifier)
|
||||||
|
error_with_subproblems = messages.Error.with_code('malformed', detail='foo', title='title', subproblems=[subproblem])
|
||||||
|
self.client.acme.new_order.side_effect = [error_with_subproblems, mock.DEFAULT]
|
||||||
|
|
||||||
|
self.config.allow_subset_of_names = True
|
||||||
|
|
||||||
|
with test_util.patch_display_util():
|
||||||
|
result = self.client.obtain_certificate(self.eg_domains)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
(mock.sentinel.cert, mock.sentinel.chain, key, csr))
|
||||||
|
self.assertEqual(self.client.auth_handler.handle_authorizations.call_count, 1)
|
||||||
|
self.assertEqual(self.acme.new_order.call_count, 2)
|
||||||
|
|
||||||
|
successful_domains = [d for d in self.eg_domains if d != 'example.com']
|
||||||
|
self.assertEqual(mock_crypto_util.generate_key.call_count, 2)
|
||||||
|
mock_crypto_util.generate_csr.assert_has_calls([
|
||||||
|
mock.call(key, self.eg_domains, self.config.csr_dir, self.config.must_staple, self.config.strict_permissions),
|
||||||
|
mock.call(key, successful_domains, self.config.csr_dir, self.config.must_staple, self.config.strict_permissions)])
|
||||||
|
self.assertEqual(mock_remove.call_count, 2)
|
||||||
|
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 1)
|
||||||
|
|
||||||
|
@mock.patch("certbot._internal.client.crypto_util")
|
||||||
|
def test_obtain_certificate_get_order_no_retryable_domains(self, mock_crypto_util):
|
||||||
|
from acme import messages
|
||||||
|
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
|
||||||
|
key = util.CSR(form="pem", file=mock.sentinel.key_file, data=CSR_SAN)
|
||||||
|
mock_crypto_util.generate_csr.return_value = csr
|
||||||
|
mock_crypto_util.generate_key.return_value = key
|
||||||
|
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
|
||||||
|
|
||||||
|
self._mock_obtain_certificate()
|
||||||
|
authzr = self._authzr_from_domains(self.eg_domains)
|
||||||
|
self.eg_order.authorizations = authzr
|
||||||
|
self.client.auth_handler.handle_authorizations.return_value = authzr
|
||||||
|
|
||||||
|
identifier1 = messages.Identifier(typ=messages.IDENTIFIER_FQDN, value='example.com')
|
||||||
|
identifier2 = messages.Identifier(typ=messages.IDENTIFIER_FQDN, value='www.example.com')
|
||||||
|
subproblem1 = messages.Error.with_code('caa', detail='bar', title='title', identifier=identifier1)
|
||||||
|
subproblem2 = messages.Error.with_code('caa', detail='bar', title='title', identifier=identifier2)
|
||||||
|
error_with_subproblems = messages.Error.with_code('malformed', detail='foo', title='title', subproblems=[subproblem1, subproblem2])
|
||||||
|
self.client.acme.new_order.side_effect = error_with_subproblems
|
||||||
|
|
||||||
|
self.config.allow_subset_of_names = True
|
||||||
|
|
||||||
|
self.assertRaises(messages.Error, self.client.obtain_certificate, self.eg_domains)
|
||||||
|
self.assertEqual(self.client.auth_handler.handle_authorizations.call_count, 0)
|
||||||
|
self.assertEqual(self.acme.new_order.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.generate_key.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 0)
|
||||||
|
|
||||||
|
@mock.patch("certbot._internal.client.crypto_util")
|
||||||
|
def test_obtain_certificate_get_order_rejected_identifier_no_subproblems(self, mock_crypto_util):
|
||||||
|
from acme import messages
|
||||||
|
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
|
||||||
|
key = util.CSR(form="pem", file=mock.sentinel.key_file, data=CSR_SAN)
|
||||||
|
mock_crypto_util.generate_csr.return_value = csr
|
||||||
|
mock_crypto_util.generate_key.return_value = key
|
||||||
|
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
|
||||||
|
|
||||||
|
self._mock_obtain_certificate()
|
||||||
|
authzr = self._authzr_from_domains(self.eg_domains)
|
||||||
|
self.eg_order.authorizations = authzr
|
||||||
|
self.client.auth_handler.handle_authorizations.return_value = authzr
|
||||||
|
|
||||||
|
error = messages.Error.with_code('caa', detail='foo', title='title')
|
||||||
|
self.client.acme.new_order.side_effect = error
|
||||||
|
|
||||||
|
self.config.allow_subset_of_names = True
|
||||||
|
|
||||||
|
self.assertRaises(messages.Error, self.client.obtain_certificate, self.eg_domains)
|
||||||
|
self.assertEqual(self.client.auth_handler.handle_authorizations.call_count, 0)
|
||||||
|
self.assertEqual(self.acme.new_order.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.generate_key.call_count, 1)
|
||||||
|
self.assertEqual(mock_crypto_util.cert_and_chain_from_fullchain.call_count, 0)
|
||||||
|
|
||||||
@mock.patch("certbot._internal.client.crypto_util")
|
@mock.patch("certbot._internal.client.crypto_util")
|
||||||
@mock.patch("certbot._internal.client.acme_crypto_util")
|
@mock.patch("certbot._internal.client.acme_crypto_util")
|
||||||
def test_obtain_certificate_dry_run(self, mock_acme_crypto, mock_crypto):
|
def test_obtain_certificate_dry_run(self, mock_acme_crypto, mock_crypto):
|
||||||
|
|||||||
Reference in New Issue
Block a user