diff --git a/certbot/CHANGELOG.md b/certbot/CHANGELOG.md index 586fcd0c5..766ce6808 100644 --- a/certbot/CHANGELOG.md +++ b/certbot/CHANGELOG.md @@ -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 diff --git a/certbot/certbot/_internal/cli/__init__.py b/certbot/certbot/_internal/cli/__init__.py index 302a49e91..755df4519 100644 --- a/certbot/certbot/_internal/cli/__init__.py +++ b/certbot/certbot/_internal/cli/__init__.py @@ -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", diff --git a/certbot/certbot/_internal/client.py b/certbot/certbot/_internal/client.py index e35d7aa68..67d464462 100644 --- a/certbot/certbot/_internal/client.py +++ b/certbot/certbot/_internal/client.py @@ -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.") diff --git a/certbot/certbot/_internal/constants.py b/certbot/certbot/_internal/constants.py index 2391fe1ca..9694a60a3 100644 --- a/certbot/certbot/_internal/constants.py +++ b/certbot/certbot/_internal/constants.py @@ -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, diff --git a/certbot/certbot/_internal/tests/client_test.py b/certbot/certbot/_internal/tests/client_test.py index ad06533a5..faeedd31a 100644 --- a/certbot/certbot/_internal/tests/client_test.py +++ b/certbot/certbot/_internal/tests/client_test.py @@ -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) diff --git a/certbot/certbot/configuration.py b/certbot/certbot/configuration.py index 8f7f4b94a..15f949c0b 100644 --- a/certbot/certbot/configuration.py +++ b/certbot/certbot/configuration.py @@ -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.