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

Fixes #10194
This commit is contained in:
Jacob Hoffman-Andrews 2025-04-02 14:48:52 -07:00 committed by GitHub
parent 15024aabd3
commit 45626e88e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 133 additions and 3 deletions

View file

@ -6,7 +6,8 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
### Added
*
* The --preferred-profile and --required-profile flags allow requesting a profile.
https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/
### Changed

View file

@ -358,6 +358,16 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st
default=flag_default("strict_permissions"),
help="Require that all configuration files are owned by the current "
"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(
[None, "certonly", "renew", "run"],
"--preferred-chain", dest="preferred_chain",

View file

@ -471,8 +471,17 @@ class Client:
"""
if not self.acme:
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:
orderr = self.acme.new_order(csr_pem)
orderr = self.acme.new_order(csr_pem, profile=profile)
except acme_errors.WildcardUnsupportedError:
raise errors.Error("The currently selected ACME CA endpoint does"
" not support issuing wildcard certificates.")
@ -485,7 +494,7 @@ class Client:
deactivated, failed = self.auth_handler.deactivate_valid_authorizations(orderr)
if deactivated:
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:
logger.warning("Certbot was unable to obtain fresh authorizations for every domain"
". The dry run will continue, but results may not be accurate.")

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
staple=None, # listed as False in help output
strict_permissions=False,
required_profile=None,
preferred_profile=None,
preferred_chain=None,
pref_challs=[],
validate_hooks=True,

View file

@ -382,6 +382,92 @@ class ClientTest(ClientTestCommon):
mock_crypto_util.cert_and_chain_from_fullchain.assert_called_once_with(
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")
def test_obtain_certificate_partial_success(self, mock_crypto_util):
csr = util.CSR(form="pem", file=mock.sentinel.csr_file, data=CSR_SAN)

View file

@ -363,6 +363,28 @@ class NamespaceConfig:
"""
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
def preferred_chain(self) -> Optional[str]:
"""Set the preferred certificate chain.