diff --git a/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py b/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py index 356eaa774..170104544 100644 --- a/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py +++ b/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py @@ -808,6 +808,25 @@ def test_revoke_multiple_lineages(context: IntegrationTestsContext) -> None: assert 'Not deleting revoked certificates due to overlapping archive dirs' in f.read() +def test_reconfigure(context: IntegrationTestsContext) -> None: + """Test the reconfigure verb""" + certname = context.get_domain() + context.certbot(['-d', certname]) + conf_path = join(context.config_dir, 'renewal', '{}.conf'.format(certname)) + + with misc.create_http_server(context.http_01_port) as webroot: + context.certbot(['reconfigure', '--cert-name', certname, + '-a', 'webroot', '--webroot-path', webroot]) + with open(conf_path, 'r') as f: + file_contents = f.read() + # Check changed value + assert 'authenticator = webroot' in file_contents, \ + 'Expected authenticator to be changed to webroot in renewal config' + # Check added value + assert f'webroot_path = {webroot}' in file_contents, \ + 'Expected new webroot path to be added to renewal config' + + def test_wildcard_certificates(context: IntegrationTestsContext) -> None: """Test wildcard certificate issuance.""" certname = context.get_domain('wild') diff --git a/certbot/CHANGELOG.md b/certbot/CHANGELOG.md index 225bb82e1..ab1bcc963 100644 --- a/certbot/CHANGELOG.md +++ b/certbot/CHANGELOG.md @@ -6,6 +6,7 @@ Certbot adheres to [Semantic Versioning](https://semver.org/). ### Added +* Allow a user to modify the configuration of a certificate without renewing it using the new `reconfigure` subcommand. See `certbot help reconfigure` for details. * `certbot show_account` now displays the [ACME Account Thumbprint](https://datatracker.ietf.org/doc/html/rfc8555#section-8.1). ### Changed @@ -201,7 +202,7 @@ More details about these changes can be found on our GitHub repo. ### Added -* Updated Windows installer to be signed and trusted in Windows +* Updated Windows installer to be signed and trusted in Windows ### Changed diff --git a/certbot/certbot/_internal/cli/__init__.py b/certbot/certbot/_internal/cli/__init__.py index 9013b8249..bb4f0eaa1 100644 --- a/certbot/certbot/_internal/cli/__init__.py +++ b/certbot/certbot/_internal/cli/__init__.py @@ -135,7 +135,7 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st ) helpful.add( [None, "run", "certonly", "manage", "delete", "certificates", - "renew", "enhance"], "--cert-name", dest="certname", + "renew", "enhance", "reconfigure"], "--cert-name", dest="certname", metavar="CERTNAME", default=flag_default("certname"), help="Certificate name to apply. This name is used by Certbot for housekeeping " "and in file paths; it doesn't affect the content of the certificate itself. " @@ -157,6 +157,17 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st " roll back those changes. It also calls --pre-hook and --post-hook commands" " if they are defined because they may be necessary to accurately simulate" " renewal. --deploy-hook commands are not called.") + helpful.add( + ["testing", "renew", "certonly", "reconfigure"], + "--run-deploy-hooks", action="store_true", dest="run_deploy_hooks", + default=flag_default("run_deploy_hooks"), + help="When performing a test run using `--dry-run` or `reconfigure`, run any applicable" + " deploy hooks. This includes hooks set on the command line, saved in the" + " certificate's renewal configuration file, or present in the renewal-hooks directory." + " To exclude direcory hooks, use --no-directory-hooks. The hook(s) will only" + " be run if the dry run succeeds, and will use the current active certificate, not" + " the temporary test certificate acquired during the dry run. This flag is recommended" + " when modifying the deploy hook using `reconfigure`.") helpful.add( ["register", "automation"], "--register-unsafely-without-email", action="store_true", default=flag_default("register_unsafely_without_email"), @@ -374,7 +385,7 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st default=flag_default("issuance_timeout"), help=config_help("issuance_timeout")) helpful.add( - "renew", "--pre-hook", + ["renew", "reconfigure"], "--pre-hook", help="Command to be run in a shell before obtaining any certificates." " Intended primarily for renewal, where it can be used to temporarily" " shut down a webserver that might conflict with the standalone" @@ -382,21 +393,21 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: List[st " obtained/renewed. When renewing several certificates that have" " identical pre-hooks, only the first will be executed.") helpful.add( - "renew", "--post-hook", + ["renew", "reconfigure"], "--post-hook", help="Command to be run in a shell after attempting to obtain/renew" " certificates. Can be used to deploy renewed certificates, or to" " restart any servers that were stopped by --pre-hook. This is only" " run if an attempt was made to obtain/renew a certificate. If" " multiple renewed certificates have identical post-hooks, only" " one will be run.") - helpful.add("renew", "--renew-hook", + helpful.add(["renew", "reconfigure"], "--renew-hook", action=_RenewHookAction, help=argparse.SUPPRESS) helpful.add( "renew", "--no-random-sleep-on-renew", action="store_false", default=flag_default("random_sleep_on_renew"), dest="random_sleep_on_renew", help=argparse.SUPPRESS) helpful.add( - "renew", "--deploy-hook", action=_DeployHookAction, + ["renew", "reconfigure"], "--deploy-hook", action=_DeployHookAction, help='Command to be run in a shell once for each successfully' ' issued certificate. For this command, the shell variable' ' $RENEWED_LINEAGE will point to the config live subdirectory' diff --git a/certbot/certbot/_internal/cli/cli_constants.py b/certbot/certbot/_internal/cli/cli_constants.py index 64b94a8ab..cbf7b6a03 100644 --- a/certbot/certbot/_internal/cli/cli_constants.py +++ b/certbot/certbot/_internal/cli/cli_constants.py @@ -38,6 +38,7 @@ manage certificates: certificates Display information about certificates you have from Certbot revoke Revoke a certificate (supply --cert-name or --cert-path) delete Delete a certificate (supply --cert-name) + reconfigure Update a certificate's configuration (supply --cert-name) manage your account: register Create an ACME account diff --git a/certbot/certbot/_internal/cli/group_adder.py b/certbot/certbot/_internal/cli/group_adder.py index 96d58824b..1bbbe0215 100644 --- a/certbot/certbot/_internal/cli/group_adder.py +++ b/certbot/certbot/_internal/cli/group_adder.py @@ -16,7 +16,7 @@ def _add_all_groups(helpful: "helpful.HelpfulArgumentParser") -> None: helpful.add_group("paths", description="Flags for changing execution paths & servers") helpful.add_group("manage", description="Various subcommands and flags are available for managing your certificates:", - verbs=["certificates", "delete", "renew", "revoke", "update_symlinks"]) + verbs=["certificates", "delete", "renew", "revoke", "update_symlinks", "reconfigure"]) # VERBS for verb, docs in VERB_HELP: diff --git a/certbot/certbot/_internal/cli/helpful.py b/certbot/certbot/_internal/cli/helpful.py index cf3615fd4..0dd72e53a 100644 --- a/certbot/certbot/_internal/cli/helpful.py +++ b/certbot/certbot/_internal/cli/helpful.py @@ -66,6 +66,7 @@ class HelpfulArgumentParser: "certificates": main.certificates, "delete": main.delete, "enhance": main.enhance, + "reconfigure": main.reconfigure, } # Get notification function for printing diff --git a/certbot/certbot/_internal/cli/plugins_parsing.py b/certbot/certbot/_internal/cli/plugins_parsing.py index d19825738..d01d9903b 100644 --- a/certbot/certbot/_internal/cli/plugins_parsing.py +++ b/certbot/certbot/_internal/cli/plugins_parsing.py @@ -22,9 +22,9 @@ def _plugins_parsing(helpful: "helpful.HelpfulArgumentParser", help="Name of the plugin that is both an authenticator and an installer." " Should not be used together with --authenticator or --installer. " "(default: Ask)") - helpful.add("plugins", "-a", "--authenticator", default=flag_default("authenticator"), - help="Authenticator plugin name.") - helpful.add("plugins", "-i", "--installer", default=flag_default("installer"), + helpful.add(["plugins", "reconfigure"], "-a", "--authenticator", + default=flag_default("authenticator"), help="Authenticator plugin name.") + helpful.add(["plugins", "reconfigure"], "-i", "--installer", default=flag_default("installer"), help="Installer plugin name (also used to find domains).") helpful.add(["plugins", "certonly", "run", "install"], "--apache", action="store_true", default=flag_default("apache"), @@ -38,7 +38,7 @@ def _plugins_parsing(helpful: "helpful.HelpfulArgumentParser", helpful.add(["plugins", "certonly"], "--manual", action="store_true", default=flag_default("manual"), help="Provide laborious manual instructions for obtaining a certificate") - helpful.add(["plugins", "certonly"], "--webroot", action="store_true", + helpful.add(["plugins", "certonly", "reconfigure"], "--webroot", action="store_true", default=flag_default("webroot"), help="Obtain certificates by placing files in a webroot directory.") helpful.add(["plugins", "certonly"], "--dns-cloudflare", action="store_true", diff --git a/certbot/certbot/_internal/cli/verb_help.py b/certbot/certbot/_internal/cli/verb_help.py index b15be4631..9f805035e 100644 --- a/certbot/certbot/_internal/cli/verb_help.py +++ b/certbot/certbot/_internal/cli/verb_help.py @@ -102,6 +102,11 @@ VERB_HELP = [ "opts": 'Options useful for the "show_account" subcommand:', "usage": "\n\n certbot show_account [options]\n\n" }), + ("reconfigure", { + "short": "Update renewal configuration for a certificate specified by --cert-name", + "opts": 'Common options that may be updated with the "reconfigure" subcommand:', + "usage": "\n\n certbot reconfigure --cert-name CERTNAME [options]\n\n" + }), ] diff --git a/certbot/certbot/_internal/constants.py b/certbot/certbot/_internal/constants.py index bd999bf6a..35ee5d54e 100644 --- a/certbot/certbot/_internal/constants.py +++ b/certbot/certbot/_internal/constants.py @@ -80,6 +80,7 @@ CLI_DEFAULTS: Dict[str, Any] = dict( # noqa eab_hmac_key=None, eab_kid=None, issuance_timeout=90, + run_deploy_hooks=False, # Subparsers num=None, diff --git a/certbot/certbot/_internal/hooks.py b/certbot/certbot/_internal/hooks.py index 76cda466a..0517e625a 100644 --- a/certbot/certbot/_internal/hooks.py +++ b/certbot/certbot/_internal/hooks.py @@ -166,7 +166,7 @@ def deploy_hook(config: configuration.NamespaceConfig, domains: List[str], """ if config.deploy_hook: _run_deploy_hook(config.deploy_hook, domains, - lineage_path, config.dry_run) + lineage_path, config.dry_run, config.run_deploy_hooks) def renew_hook(config: configuration.NamespaceConfig, domains: List[str], @@ -190,7 +190,7 @@ def renew_hook(config: configuration.NamespaceConfig, domains: List[str], executed_dir_hooks = set() if config.directory_hooks: for hook in list_hooks(config.renewal_deploy_hooks_dir): - _run_deploy_hook(hook, domains, lineage_path, config.dry_run) + _run_deploy_hook(hook, domains, lineage_path, config.dry_run, config.run_deploy_hooks) executed_dir_hooks.add(hook) if config.renew_hook: @@ -199,10 +199,11 @@ def renew_hook(config: configuration.NamespaceConfig, domains: List[str], config.renew_hook) else: _run_deploy_hook(config.renew_hook, domains, - lineage_path, config.dry_run) + lineage_path, config.dry_run, config.run_deploy_hooks) -def _run_deploy_hook(command: str, domains: List[str], lineage_path: str, dry_run: bool) -> None: +def _run_deploy_hook(command: str, domains: List[str], lineage_path: str, dry_run: bool, + run_deploy_hooks: bool) -> None: """Run the specified deploy-hook (if not doing a dry run). If dry_run is True, command is not run and a message is logged @@ -214,9 +215,10 @@ def _run_deploy_hook(command: str, domains: List[str], lineage_path: str, dry_ru :type domains: `list` of `str` :param str lineage_path: live directory path for the new cert :param bool dry_run: True iff Certbot is doing a dry run + :param bool run_deploy_hooks: True if deploy hooks should run despite Certbot doing a dry run """ - if dry_run: + if dry_run and not run_deploy_hooks: logger.info("Dry run: skipping deploy hook command: %s", command) return diff --git a/certbot/certbot/_internal/main.py b/certbot/certbot/_internal/main.py index 01ee2021d..78e6347f9 100644 --- a/certbot/certbot/_internal/main.py +++ b/certbot/certbot/_internal/main.py @@ -1,6 +1,7 @@ """Certbot main entry point.""" # pylint: disable=too-many-lines +import copy from contextlib import contextmanager import functools import logging.handlers @@ -1658,6 +1659,127 @@ def make_or_verify_needed_dirs(config: configuration.NamespaceConfig) -> None: util.make_or_verify_dir(hook_dir, strict=config.strict_permissions) +def _report_reconfigure_results(renewal_file: str, orig_renewal_conf: configobj.ConfigObj) -> None: + """Reports the outcome of certificate renewal reconfiguration to the user. + + :param renewal_file: Path to the cert's renewal file + :type renewal_file: str + + :param orig_renewal_conf: Loaded original renewal configuration + :type orig_renewal_conf: configobj.ConfigObj + + :returns: `None` + :rtype: None + + """ + try: + final_renewal_conf = configobj.ConfigObj( + renewal_file, encoding='utf-8', default_encoding='utf-8') + except configobj.ConfigObjError: + raise errors.CertStorageError( + f'error parsing {renewal_file}') + + orig_renewal_params = orig_renewal_conf['renewalparams'] + final_renewal_params = final_renewal_conf['renewalparams'] + + if final_renewal_params == orig_renewal_params: + success_message = '\nNo changes were made to the renewal configuration.' + else: + success_message = '\nSuccessfully updated configuration.' + \ + '\nChanges will apply when the certificate renews.' + + display_util.notify(success_message) + + +def reconfigure(config: configuration.NamespaceConfig, + plugins: plugins_disco.PluginsRegistry) -> None: + """Allow the user to set new configuration options for an existing certificate without + forcing renewal. This can be used for things like authenticator, installer, and hooks, + but not for the domains on the cert, since those are only saved in the cert. + + :param config: Configuration object + :type config: configuration.NamespaceConfig + + :param plugins: List of plugins + :type plugins: plugins_disco.PluginsRegistry + + :raises errors.Error: if the dry run fails + :raises errors.ConfigurationError: if certificate could not be loaded + + """ + + if config.domains: + raise errors.ConfigurationError("You have specified domains, but this function cannot " + "be used to modify the domains in a certificate. If you would like to do so, follow " + "the instructions at https://certbot.org/change-cert-domain. Otherwise, remove the " + "domains from the command to continue reconfiguring. You can specify which certificate " + "you want on the command line with flag --cert-name instead.") + # While we could technically allow domains to be used to specify the certificate in addition to + # --cert-name, there's enough complexity with matching certs to domains that it's not worth it, + # to say nothing of the difficulty in explaining what exactly this subcommand can modify + + + # To make sure that the requested changes work, do a dry run. While setting up the dry run, + # we will set all the needed fields in config, which will then be saved upon success. + config.dry_run = True + + if not config.certname: + certname_question = "Which certificate would you like to reconfigure?" + config.certname = cert_manager.get_certnames( + config, "reconfigure", allow_multiple=False, + custom_prompt=certname_question)[0] + + certname = config.certname + + try: + renewal_file = storage.renewal_file_for_certname(config, certname) + except errors.CertStorageError: + raise errors.ConfigurationError(f"An existing certificate with name {certname} could not " + "be found. Run `certbot certificates` to list available certificates.") + + # figure this out before we modify config + if config.deploy_hook and not config.run_deploy_hooks: + msg = ("You are attempting to set a --deploy-hook. Would you like Certbot to run deploy " + "hooks when it performs a dry run with the new settings? This will run all " + "relevant deploy hooks, including directory hooks, unless --no-directory-hooks " + "is set. This will use the current active certificate, and not the temporary test " + "certificate acquired during the dry run.") + config.run_deploy_hooks = display_util.yesno(msg,"Run deploy hooks", + "Do not run deploy hooks", default=False) + + # cache previous version for later comparison + try: + orig_renewal_conf = configobj.ConfigObj( + renewal_file, encoding='utf-8', default_encoding='utf-8') + except configobj.ConfigObjError: + raise errors.CertStorageError( + f"error parsing {renewal_file}") + + lineage_config = copy.deepcopy(config) + try: + renewal_candidate = renewal.reconstitute(lineage_config, renewal_file) + except Exception as e: # pylint: disable=broad-except + raise errors.ConfigurationError(f"Renewal configuration file {renewal_file} " + f"(cert: {certname}) produced an unexpected error: {e}.") + if not renewal_candidate: + raise errors.ConfigurationError("Could not load certificate. See logs for errors.") + + # this is where lineage_config gets fully filled out (e.g. --apache will set auth and installer) + installer, auth = plug_sel.choose_configurator_plugins(lineage_config, plugins, "certonly") + le_client = _init_le_client(lineage_config, auth, installer) + + # renews cert as dry run to test that the new values are ok + # at this point, renewal_candidate.configuration has the old values, but will use + # the values from lineage_config when doing the dry run + _get_and_save_cert(le_client, lineage_config, certname=certname, + lineage=renewal_candidate) + + # this function will update lineage.configuration with the new values, and save it to disk + renewal_candidate.save_new_config_values(lineage_config) + + _report_reconfigure_results(renewal_file, orig_renewal_conf) + + @contextmanager def make_displayer(config: configuration.NamespaceConfig ) -> Generator[Union[display_obj.NoninteractiveDisplay, diff --git a/certbot/certbot/_internal/renewal.py b/certbot/certbot/_internal/renewal.py index ea5e0435f..39f704f2f 100644 --- a/certbot/certbot/_internal/renewal.py +++ b/certbot/certbot/_internal/renewal.py @@ -53,7 +53,7 @@ CONFIG_ITEMS = set(itertools.chain( BOOL_CONFIG_ITEMS, INT_CONFIG_ITEMS, STR_CONFIG_ITEMS, ('pref_challs',))) -def _reconstitute(config: configuration.NamespaceConfig, +def reconstitute(config: configuration.NamespaceConfig, full_path: str) -> Optional[storage.RenewableCert]: """Try to instantiate a RenewableCert, updating config with relevant items. @@ -491,7 +491,7 @@ def handle_renewal_request(config: configuration.NamespaceConfig) -> None: # Note that this modifies config (to add back the configuration # elements from within the renewal configuration file). try: - renewal_candidate = _reconstitute(lineage_config, renewal_file) + renewal_candidate = reconstitute(lineage_config, renewal_file) except Exception as e: # pylint: disable=broad-except logger.error("Renewal configuration file %s (cert: %s) " "produced an unexpected error: %s. Skipping.", diff --git a/certbot/certbot/_internal/storage.py b/certbot/certbot/_internal/storage.py index 6c483734e..7e7c1e639 100644 --- a/certbot/certbot/_internal/storage.py +++ b/certbot/certbot/_internal/storage.py @@ -46,6 +46,8 @@ README = "README" CURRENT_VERSION = pkg_resources.parse_version(certbot.__version__) BASE_PRIVKEY_MODE = 0o600 +# pylint: disable=too-many-lines + def renewal_conf_files(config: configuration.NamespaceConfig) -> List[str]: """Build a list of all renewal configuration files. @@ -1245,6 +1247,19 @@ class RenewableCert(interfaces.RenewableCert): return target_version + def save_new_config_values(self, cli_config: configuration.NamespaceConfig) -> None: + """Save only the config information without writing the new cert. + + :param .NamespaceConfig cli_config: parsed command line + arguments + """ + self.cli_config = cli_config + symlinks = {kind: self.configuration[kind] for kind in ALL_FOUR} + # Update renewal config file + self.configfile = update_configuration( + self.lineagename, self.archive_dir, symlinks, cli_config) + self.configuration = config_with_defaults(self.configfile) + def truncate(self, num_prior_certs_to_keep: int = 5) -> None: """Delete unused historical certificate, chain and key items from the lineage. diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py index 58c4fdeed..4b416272f 100644 --- a/certbot/tests/main_test.py +++ b/certbot/tests/main_test.py @@ -14,6 +14,7 @@ from typing import List import unittest from unittest import mock +import configobj import josepy as jose import pytz @@ -530,6 +531,167 @@ class RevokeTest(test_util.TempDirTestCase): self._call() self.assertIs(mock_delete.called, False) + +class ReconfigureTest(test_util.TempDirTestCase): + """Tests for certbot._internal.main.reconfigure""" + + def setUp(self): + super().setUp() + self.get_utility_patch = test_util.patch_display_util() + self.mock_get_utility = self.get_utility_patch.start() + self.patchers = { + 'check_symlinks': mock.patch('certbot._internal.storage.RenewableCert._check_symlinks'), + 'cert_names': mock.patch('certbot._internal.storage.RenewableCert.names'), + 'pick_installer': mock.patch('certbot._internal.plugins.selection.pick_installer'), + 'pick_auth': mock.patch('certbot._internal.plugins.selection.pick_authenticator'), + 'find_init': mock.patch('certbot._internal.plugins.disco.PluginsRegistry.find_init'), + '_get_and_save_cert': mock.patch('certbot._internal.main._get_and_save_cert'), + '_init_le_client': mock.patch('certbot._internal.main._init_le_client'), + 'list_hooks': mock.patch('certbot._internal.hooks.list_hooks'), + } + self.mocks = {k: v.start() for k, v in self.patchers.items()} + self.mocks['cert_names'].return_value = ['example.com'] + + self.config_dir = os.path.join(self.tempdir, 'config') + renewal_configs_dir = os.path.join(self.config_dir, 'renewal') + if not os.path.exists(renewal_configs_dir): + filesystem.makedirs(renewal_configs_dir) + self.renewal_file = os.path.join(renewal_configs_dir, 'example.com.conf') + original_config = """ + version = 1.32.0 + archive_dir = /etc/letsencrypt/archive/example.com + cert = /etc/letsencrypt/live/example.com/cert.pem + privkey = /etc/letsencrypt/live/example.com/privkey.pem + chain = /etc/letsencrypt/live/example.com/chain.pem + fullchain = /etc/letsencrypt/live/example.com/fullchain.pem + + # Options used in the renewal process + [renewalparams] + account = ee43634db0aa4e6804f152be39990e6a + server = https://acme-staging-v02.api.letsencrypt.org/directory + authenticator = nginx + installer = nginx + key_type = rsa + """ + with open(self.renewal_file, 'w') as f: + f.write(original_config) + with open(self.renewal_file, 'r') as f: + self.original_config = configobj.ConfigObj(f, + encoding='utf-8', default_encoding='utf-8') + + + def tearDown(self): + super().tearDown() + self.get_utility_patch.stop() + for patch in self.patchers.values(): + patch.stop() + + def _call(self, passed_args): + full_args = passed_args + ['--config-dir', self.config_dir] + cli.set_by_cli.detector = None # required to reset set_by_cli state + plugins = disco.PluginsRegistry.find_all() + config = configuration.NamespaceConfig( + cli.prepare_and_parse_args(plugins, full_args)) + + from certbot._internal.main import reconfigure + reconfigure(config, plugins) + + with open(self.renewal_file, 'r') as f: + updated_conf = configobj.ConfigObj(f, encoding='utf-8', default_encoding='utf-8') + + return updated_conf + + def test_domains_set(self): + self.assertRaises(errors.ConfigurationError, + self._call, '--cert-name cert1 -d one.cert.com'.split()) + + @mock.patch('certbot._internal.cert_manager.get_certnames') + def test_asks_for_certname(self, mock_cert_manager): + mock_cert_manager.return_value = ['example.com'] + self._call('--nginx'.split()) + self.assertEqual(mock_cert_manager.call_count, 1) + + def test_update_configurator(self): + named_mock = mock.Mock() + named_mock.name = 'apache' + + self.mocks['pick_installer'].return_value = named_mock + self.mocks['pick_auth'].return_value = named_mock + self.mocks['find_init'].return_value = named_mock + + new_config = self._call('--cert-name example.com --apache'.split()) + self.assertEqual(new_config['renewalparams']['authenticator'], 'apache') + + @mock.patch('certbot._internal.hooks.validate_hooks') + def test_update_hooks(self, unused_validate_hooks): + self.assertNotIn('pre_hook', self.original_config) + # test set + new_config = self._call('--cert-name example.com --pre-hook'.split() + ['echo pre']) + self.assertEqual(new_config['renewalparams']['pre_hook'], 'echo pre') + # test update + new_config = self._call('--cert-name example.com --pre-hook'.split() + ['echo pre2']) + self.assertEqual(new_config['renewalparams']['pre_hook'], 'echo pre2') + + # test deploy hook is set even though we did a dry run + self.assertNotIn('renew_hook', self.original_config) + new_config = self._call('--cert-name example.com --deploy-hook'.split() + ['echo deploy']) + self.assertEqual(new_config['renewalparams']['renew_hook'], 'echo deploy') + + def test_dry_run_fails(self): + # set side effect of raising error + self.mocks['_get_and_save_cert'].side_effect = errors.Error + + try: + self._call('--cert-name example.com --apache'.split()) + except errors.Error: + pass + + # check that config isn't modified + with open(self.renewal_file, 'r') as f: + new_config = configobj.ConfigObj(f, encoding='utf-8', default_encoding='utf-8') + self.assertEqual(new_config['renewalparams']['authenticator'], 'nginx') + + @mock.patch('certbot._internal.main.display_util.notify') + def test_report_results(self, mock_notify): + # make sure report results works when config has a webroot map + original_config = """ + version = 2.0.0 + archive_dir = /etc/letsencrypt/archive/example.com + cert = /etc/letsencrypt/live/example.com/cert.pem + privkey = /etc/letsencrypt/live/example.com/privkey.pem + chain = /etc/letsencrypt/live/example.com/chain.pem + fullchain = /etc/letsencrypt/live/example.com/fullchain.pem + + # Options used in the renewal process + [renewalparams] + account = ee43634db0aa4e6804f152be39990e6a + server = https://acme-staging-v02.api.letsencrypt.org/directory + authenticator = webroot + installer = nginx + key_type = ecdsa + webroot_path = /var/www/html, + [[webroot_map]] + example.com = /var/www/html + """ + with open(self.renewal_file, 'w') as f: + f.write(original_config) + with open(self.renewal_file, 'r') as f: + self.original_config = configobj.ConfigObj(f, + encoding='utf-8', default_encoding='utf-8') + + named_mock = mock.Mock() + named_mock.name = 'nginx' + + self.mocks['pick_auth'].return_value = named_mock + self.mocks['find_init'].return_value = named_mock + + new_config = self._call('--cert-name example.com --nginx'.split()) + self.assertEqual(new_config['renewalparams']['authenticator'], 'nginx') + mock_notify.assert_called_with( + '\nSuccessfully updated configuration.'+ + '\nChanges will apply when the certificate renews.') + + class DeleteIfAppropriateTest(test_util.ConfigTestCase): """Tests for certbot._internal.main._delete_if_appropriate """ @@ -1492,7 +1654,7 @@ class MainTest(test_util.ConfigTestCase): def test_renew_reconstitute_error(self): # pylint: disable=protected-access - with mock.patch('certbot._internal.main.renewal._reconstitute') as mock_reconstitute: + with mock.patch('certbot._internal.main.renewal.reconstitute') as mock_reconstitute: mock_reconstitute.side_effect = Exception self._test_renew_common(assert_oc_called=False, error_expected=True) diff --git a/certbot/tests/renewal_test.py b/certbot/tests/renewal_test.py index ce6065091..3cb4ac056 100644 --- a/certbot/tests/renewal_test.py +++ b/certbot/tests/renewal_test.py @@ -168,7 +168,7 @@ class RenewalTest(test_util.ConfigTestCase): from certbot._internal import renewal lineage_config = copy.deepcopy(self.config) - renewal_candidate = renewal._reconstitute(lineage_config, rc_path) + renewal_candidate = renewal.reconstitute(lineage_config, rc_path) # This means that manual_public_ip_logging_ok was not modified in the config based on its # value in the renewal conf file self.assertIsInstance(lineage_config.manual_public_ip_logging_ok, mock.MagicMock)