Add --preferred-profile and --required-profile (#10230)

Fixes #10194
This commit is contained in:
Jacob Hoffman-Andrews
2025-04-03 06:48:52 +09:00
committed by GitHub
parent 15024aabd3
commit 45626e88e2
6 changed files with 133 additions and 3 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
### Added ### Added
* * The --preferred-profile and --required-profile flags allow requesting a profile.
https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/
### Changed ### Changed
+10
View File
@@ -358,6 +358,16 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st
default=flag_default("strict_permissions"), default=flag_default("strict_permissions"),
help="Require that all configuration files are owned by the current " help="Require that all configuration files are owned by the current "
"user; only needed if your config is somewhere unsafe like /tmp/") "user; only needed if your config is somewhere unsafe like /tmp/")
helpful.add(
[None, "certonly", "renew", "run"],
"--required-profile", dest="required_profile",
default=flag_default("required_profile"), help=config_help("required_profile")
)
helpful.add(
[None, "certonly", "renew", "run"],
"--preferred-profile", dest="preferred_profile",
default=flag_default("preferred_profile"), help=config_help("preferred_profile")
)
helpful.add( helpful.add(
[None, "certonly", "renew", "run"], [None, "certonly", "renew", "run"],
"--preferred-chain", dest="preferred_chain", "--preferred-chain", dest="preferred_chain",
+11 -2
View File
@@ -471,8 +471,17 @@ class Client:
""" """
if not self.acme: if not self.acme:
raise errors.Error("ACME client is not set.") raise errors.Error("ACME client is not set.")
profile = None
available_profiles = self.acme.directory.meta.profiles
preferred_profile = self.config.preferred_profile
if self.config.required_profile is not None:
profile = self.config.required_profile
elif (preferred_profile and available_profiles and
preferred_profile in available_profiles):
profile = preferred_profile
try: try:
orderr = self.acme.new_order(csr_pem) orderr = self.acme.new_order(csr_pem, profile=profile)
except acme_errors.WildcardUnsupportedError: except acme_errors.WildcardUnsupportedError:
raise errors.Error("The currently selected ACME CA endpoint does" raise errors.Error("The currently selected ACME CA endpoint does"
" not support issuing wildcard certificates.") " not support issuing wildcard certificates.")
@@ -485,7 +494,7 @@ class Client:
deactivated, failed = self.auth_handler.deactivate_valid_authorizations(orderr) deactivated, failed = self.auth_handler.deactivate_valid_authorizations(orderr)
if deactivated: if deactivated:
logger.debug("Recreating order after authz deactivations") logger.debug("Recreating order after authz deactivations")
orderr = self.acme.new_order(csr_pem) orderr = self.acme.new_order(csr_pem, profile=profile)
if failed: if failed:
logger.warning("Certbot was unable to obtain fresh authorizations for every domain" logger.warning("Certbot was unable to obtain fresh authorizations for every domain"
". The dry run will continue, but results may not be accurate.") ". The dry run will continue, but results may not be accurate.")
+2
View File
@@ -70,6 +70,8 @@ CLI_DEFAULTS: Dict[str, Any] = dict( # pylint: disable=use-dict-literal
uir=None, # listed as False in help output uir=None, # listed as False in help output
staple=None, # listed as False in help output staple=None, # listed as False in help output
strict_permissions=False, strict_permissions=False,
required_profile=None,
preferred_profile=None,
preferred_chain=None, preferred_chain=None,
pref_challs=[], pref_challs=[],
validate_hooks=True, validate_hooks=True,
@@ -382,6 +382,92 @@ class ClientTest(ClientTestCommon):
mock_crypto_util.cert_and_chain_from_fullchain.assert_called_once_with( mock_crypto_util.cert_and_chain_from_fullchain.assert_called_once_with(
self.eg_order.fullchain_pem) self.eg_order.fullchain_pem)
@mock.patch("certbot._internal.client.crypto_util")
def test_obtain_certificate_no_profile_preference(self, mock_crypto_util):
csr = util.CSR(form="pem", file=None, data=CSR_SAN)
mock_crypto_util.generate_csr.return_value = csr
mock_crypto_util.generate_key.return_value = mock.sentinel.key
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
self.client.config.required_profile = None
self.client.config.preferred_profile = None
self._test_obtain_certificate_common(mock.sentinel.key, csr)
self.acme.new_order.assert_called_once_with(mock.ANY, profile=None)
@mock.patch("certbot._internal.client.crypto_util")
def test_obtain_certificate_required_profile(self, mock_crypto_util):
csr = util.CSR(form="pem", file=None, data=CSR_SAN)
mock_crypto_util.generate_csr.return_value = csr
mock_crypto_util.generate_key.return_value = mock.sentinel.key
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
self.client.config.required_profile = "exampleProfile"
self.client.config.preferred_profile = None
self._test_obtain_certificate_common(mock.sentinel.key, csr)
self.acme.new_order.assert_called_once_with(mock.ANY, profile="exampleProfile")
@mock.patch("certbot._internal.client.crypto_util")
def test_obtain_certificate_preferred_profile_exists(self, mock_crypto_util):
csr = util.CSR(form="pem", file=None, data=CSR_SAN)
mock_crypto_util.generate_csr.return_value = csr
mock_crypto_util.generate_key.return_value = mock.sentinel.key
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
self.client.config.required_profile = None
self.client.config.preferred_profile = "exampleProfile"
from acme.messages import Directory
self.acme.directory = Directory.from_json({
'meta': {
'profiles': {
'exampleProfile': 'here is some descriptive text, very informative',
}
}
})
self._test_obtain_certificate_common(mock.sentinel.key, csr)
self.acme.new_order.assert_called_once_with(mock.ANY, profile="exampleProfile")
@mock.patch("certbot._internal.client.crypto_util")
def test_obtain_certificate_preferred_profile_does_not_exist(self, mock_crypto_util):
csr = util.CSR(form="pem", file=None, data=CSR_SAN)
mock_crypto_util.generate_csr.return_value = csr
mock_crypto_util.generate_key.return_value = mock.sentinel.key
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
self.client.config.required_profile = None
self.client.config.preferred_profile = "thisProfileDoesNotExist"
from acme.messages import Directory
self.acme.directory = Directory.from_json({
'meta': {
'profiles': {
'example': 'profiles!',
}
}
})
self._test_obtain_certificate_common(mock.sentinel.key, csr)
self.acme.new_order.assert_called_once_with(mock.ANY, profile=None)
@mock.patch("certbot._internal.client.crypto_util")
def test_obtain_certificate_preferred_profile_no_profiles_exist(self, mock_crypto_util):
csr = util.CSR(form="pem", file=None, data=CSR_SAN)
mock_crypto_util.generate_csr.return_value = csr
mock_crypto_util.generate_key.return_value = mock.sentinel.key
self._set_mock_from_fullchain(mock_crypto_util.cert_and_chain_from_fullchain)
self.client.config.required_profile = None
self.client.config.preferred_profile = "thisProfileDoesNotExist"
from acme.messages import Directory
self.acme.directory = Directory.from_json({})
self._test_obtain_certificate_common(mock.sentinel.key, csr)
self.acme.new_order.assert_called_once_with(mock.ANY, profile=None)
@mock.patch("certbot._internal.client.crypto_util") @mock.patch("certbot._internal.client.crypto_util")
def test_obtain_certificate_partial_success(self, mock_crypto_util): def test_obtain_certificate_partial_success(self, mock_crypto_util):
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN) csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)
+22
View File
@@ -363,6 +363,28 @@ class NamespaceConfig:
""" """
return self.namespace.disable_renew_updates return self.namespace.disable_renew_updates
@property
def required_profile(self) -> Optional[str]:
"""Request the given profile name from the ACME server.
If the ACME server returns an error, issuance (or renewal) will fail.
For long-term reliability, setting preferred_profile instead may be
preferable because it allows fallback to a default. Use this setting
when renewal failure is preferable to fallback.
"""
return self.namespace.required_profile
@property
def preferred_profile(self) -> Optional[str]:
"""Request the given profile name from the ACME server, or fallback to default.
If the given profile name exists in the ACME directory, use it to request a
a certificate. Otherwise, fall back to requesting a certificate without a profile
(which means the CA will use its default profile). This allows renewals to
succeed even if the CA deprecates and removes a given profile.
"""
return self.namespace.preferred_profile
@property @property
def preferred_chain(self) -> Optional[str]: def preferred_chain(self) -> Optional[str]:
"""Set the preferred certificate chain. """Set the preferred certificate chain.