Repository: certbot/certbot
Pull request: Configuration File Update w/o Certificate Issuance
Fixes #8822

This PR allows a user to modify the configuration of a certificate without renewing it. This can be used to add or modify hooks, change the authenticator, or modify other renewal options. It will attempt a dry run in the process to verify that the new options work. 

Also creates a `reconfigure` help section:
```
$ certbot help reconfigure
usage: 

  certbot reconfigure --cert-name CERTNAME [options]

optional arguments:
  -h, --help            show this help message and exit
  -c CONFIG_FILE, --config CONFIG_FILE
                        path to config file (default: /etc/letsencrypt/cli.ini and ~/.config/letsencrypt/cli.ini)

reconfigure:
  Common options that may be updated with the "reconfigure" subcommand:

  --cert-name CERTNAME  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. To see certificate names, run 'certbot certificates'. When
                        creating a new certificate, specifies the new certificate's name. (default: the first provided domain or
                        the name of an existing certificate on your system for the same domains)
  --run-deploy-hooks    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`. (default: False)
  --pre-hook PRE_HOOK   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 plugin. This will
                        only be called if a certificate is actually to be obtained/renewed. When renewing several certificates
                        that have identical pre-hooks, only the first will be executed. (default: None)
  --post-hook POST_HOOK
                        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. (default: None)
  --deploy-hook DEPLOY_HOOK
                        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 (for example,
                        "/etc/letsencrypt/live/example.com") containing the new certificates and keys; the shell variable
                        $RENEWED_DOMAINS will contain a space-delimited list of renewed certificate domains (for example,
                        "example.com www.example.com") (default: None)
  -a AUTHENTICATOR, --authenticator AUTHENTICATOR
                        Authenticator plugin name. (default: None)
  -i INSTALLER, --installer INSTALLER
                        Installer plugin name (also used to find domains). (default: None)
  --webroot             Obtain certificates by placing files in a webroot directory. (default: False)
```

Problem statement:
Configuration File Update w/o Certificate Issuance
This is really more of a feature suggestion.

Over in the Let's Encrypt Community, we frequently see certbot users trying to make changes to their renewal configurations. Some try to edit the files manually, which often results in disaster. Others try to issue a certbot command to make the changes, which we usually recommend to avoid the former situation. The trouble is that in order to have the parameters actually written to a renewal configuration file, a certificate must actually be successfully issued, even if the user only wanted to reinstall their existing certificate! This leads to use of the dreaded `--force-renewal` parameter and the consequences thereof, which include, but are not limited to: hours of struggle, potential downtime due to hitting Let's Encrypt rate limits, and verbal lashings from some less polite community members that are often accompanied with an RTFM sentence.

It would be awesome if there were some way to run a certbot command to just update the renewal parameters *without* issuing a certificate. This could then be tested with `renew --dry-run`. The saving of the renewal parameters could even be contingent upon a successful dry run if deemed necessary.

It would be an added bonus if dry run would actually exercise the deployment hook so it could be tested without forced renewal or waiting two months.

Let's Encrypt Community: griffin

Hints from issue discussion:
See also 

- #5828 for modifying renewal parameters without actually renewing
- #5658 for invoking hooks on dry runs
It appears from the history both here and in the Let's Encrypt Community that demand for this functionally has remained strong for years. While I've been advocating for some time now the splitting of acquisition and installation functionality, perhaps there may be separate merit in creating a division between all aspects of configuration testing and live operation.
This is worth doing, but may be tricky because the code around it is a little complex.
So, I'm starting to look into this, and it would be helpful know what sort of changes people would want to be making. Is it what plugin they use for the cert? Hooks? Domains? (If it's domains, that's an even bigger reworking, as domains aren't currently saved in the renewal conf file at all, just in the cert, so we'd need a whole way to say "on the next cert, use these domains instead.") I see a change from nginx to webroot in one example, but I'd love to know what the most common asks are.
Some notes from a discussion about what to focus on here:

* Changing the domain list is not as important
* Changing the authenticator (like webroot path) is more important
* Might want to add renewal hook to existing cert, but could instead change authenticator to address the root problem
* Basically things we want to change are the ones that live in the renewal.conf

Commit message: add flag to run deploy hook despite doing a dry run, and recommend setting that to yes when running reconfigure and modifying the deploy hook

Patch to review:
diff --git a/certbot/certbot/_internal/cli/__init__.py b/certbot/certbot/_internal/cli/__init__.py
index d3f4a2cbeb1..dcd254e4e93 100644
--- a/certbot/certbot/_internal/cli/__init__.py
+++ b/certbot/certbot/_internal/cli/__init__.py
@@ -140,7 +140,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. "
@@ -162,6 +162,16 @@ 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-hook", action="store_true", dest="run_deploy_hook",
+        default=flag_default("run_deploy_hook"),
+        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. 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"),
@@ -379,7 +389,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"
@@ -387,21 +397,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 64b94a8ab94..cbf7b6a0380 100644
--- a/certbot/certbot/_internal/cli/cli_constants.py
+++ b/certbot/certbot/_internal/cli/cli_constants.py
@@ -38,6 +38,7 @@
     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 96d58824bed..1bbbe021544 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 cf3615fd456..0dd72e53a40 100644
--- a/certbot/certbot/_internal/cli/helpful.py
+++ b/certbot/certbot/_internal/cli/helpful.py
@@ -66,6 +66,7 @@ def __init__(self, args: List[str], plugins: Iterable[str],
             "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 d19825738bb..d01d9903ba1 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 b15be463149..744bda64885 100644
--- a/certbot/certbot/_internal/cli/verb_help.py
+++ b/certbot/certbot/_internal/cli/verb_help.py
@@ -102,6 +102,11 @@
         "opts": 'Options useful for the "show_account" subcommand:',
         "usage": "\n\n  certbot show_account [options]\n\n"
     }),
+    ("reconfigure", {
+        "short": "Update configuration information 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 bd999bf6a59..d072b0c0828 100644
--- a/certbot/certbot/_internal/constants.py
+++ b/certbot/certbot/_internal/constants.py
@@ -80,6 +80,7 @@
     eab_hmac_key=None,
     eab_kid=None,
     issuance_timeout=90,
+    run_deploy_hook=False,
 
     # Subparsers
     num=None,
diff --git a/certbot/certbot/_internal/hooks.py b/certbot/certbot/_internal/hooks.py
index 76cda466a18..11ce0902fa3 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_hook)
 
 
 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_hook)
             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_hook)
 
 
-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_hook: 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_hook: True if deploy hook should run despite Certbot doing a dry run
 
     """
-    if dry_run:
+    if dry_run and not run_deploy_hook:
         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 0712f196257..b71c4fdde69 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
@@ -1654,6 +1655,129 @@ 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']
+    orig_renewal_params_set = set(orig_renewal_params.items())
+    final_renewal_params = final_renewal_conf['renewalparams']
+    final_renewal_params_set = set(final_renewal_params.items())
+    changes = orig_renewal_params_set ^ final_renewal_params_set
+
+    if len(changes) == 0:
+        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, you "
+            "will need to renew the certificate instead of reconfiguring, which can be done "
+            "with the flag --force-renewal. 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_hook:
+        msg = "You are attempting to set a new deploy hook. Would you like Certbot to run the new "
+              "hook 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."
+        config.run_deploy_hook = display_util.yesno(msg,"Run deploy hook",
+            "Do not run deploy hook", 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 df5168ea240..3d963279306 100644
--- a/certbot/certbot/_internal/renewal.py
+++ b/certbot/certbot/_internal/renewal.py
@@ -53,7 +53,7 @@
     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.
 
@@ -490,7 +490,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 978295ccec5..b4f86cb9dd0 100644
--- a/certbot/certbot/_internal/storage.py
+++ b/certbot/certbot/_internal/storage.py
@@ -1243,3 +1243,17 @@ def save_successor(self, prior_version: int, new_cert: bytes, new_privkey: bytes
         self.configuration = config_with_defaults(self.configfile)
 
         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)
diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py
index e857f6c3311..ff749e00cf6 100644
--- a/certbot/tests/main_test.py
+++ b/certbot/tests/main_test.py
@@ -14,6 +14,7 @@
 import unittest
 from unittest import mock
 
+import configobj
 import josepy as jose
 import pytz
 
@@ -530,6 +531,127 @@ def test_revocation_with_prompt(self, mock_get_utility,
         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')
+
+
 class DeleteIfAppropriateTest(test_util.ConfigTestCase):
     """Tests for certbot._internal.main._delete_if_appropriate """
 
@@ -1492,7 +1614,7 @@ def test_renew_with_webroot_map(self):
 
     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 ce606509179..3cb4ac056d5 100644
--- a/certbot/tests/renewal_test.py
+++ b/certbot/tests/renewal_test.py
@@ -168,7 +168,7 @@ def test_remove_deprecated_config_elements(self, mock_set_by_cli, unused_mock_ge
 
         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)


Code review comments:
diff_hunk: @@ -102,6 +102,11 @@
         "opts": 'Options useful for the "show_account" subcommand:',
         "usage": "\n\n  certbot show_account [options]\n\n"
     }),
+    ("reconfigure", {
+        "short": "Update configuration information for a certificate specified by --cert-name",
path: certbot/certbot/_internal/cli/verb_help.py
text: @user1:
Do we call this "information" anywhere else? Maybe "renewal configuration" or just "configuration"?

```suggestion
        "short": "Update configuration for a certificate specified by --cert-name",
```

diff_hunk: @@ -1654,6 +1655,129 @@ 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']
+    orig_renewal_params_set = set(orig_renewal_params.items())
+    final_renewal_params = final_renewal_conf['renewalparams']
+    final_renewal_params_set = set(final_renewal_params.items())
+    changes = orig_renewal_params_set ^ final_renewal_params_set
+
+    if len(changes) == 0:
+        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, you "
+            "will need to renew the certificate instead of reconfiguring, which can be done "
+            "with the flag --force-renewal. 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_hook:
+        msg = "You are attempting to set a new deploy hook. Would you like Certbot to run the new "
path: certbot/certbot/_internal/main.py
text: @user1:
Very minor thing, if you like it: I've been in the habit of always using the flag name where possible, to make things more obvious to the user.

```suggestion
        msg = "You are attempting to set a new --deploy-hook. Would you like Certbot to run the new "
```

@author:
love this!

diff_hunk: @@ -1654,6 +1655,129 @@ 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']
+    orig_renewal_params_set = set(orig_renewal_params.items())
+    final_renewal_params = final_renewal_conf['renewalparams']
+    final_renewal_params_set = set(final_renewal_params.items())
path: certbot/certbot/_internal/main.py
text: @user1:
I have a crash here, after the changes get persisted to disk.

Presumably it has something to do with `--webroot-path` being a `Union[str, Sequence[Any], None]`.

    $ certbot certonly --standalone --http-01-port 8123 -d example.com
    $ certbot reconfigure --cert-name example.com --webroot -w /tmp

    Traceback (most recent call last):
      File "/Users/alex/devel/certbot/venv/bin/certbot", line 33, in <module>
        sys.exit(load_entry_point('certbot', 'console_scripts', 'certbot')())
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/Users/alex/devel/certbot/certbot/certbot/main.py", line 19, in main
        return internal_main.main(cli_args)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/Users/alex/devel/certbot/certbot/certbot/_internal/main.py", line 1861, in main
        return config.func(config, plugins)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/Users/alex/devel/certbot/certbot/certbot/_internal/main.py", line 1779, in reconfigure
        _report_reconfigure_results(renewal_file, orig_renewal_conf)
      File "/Users/alex/devel/certbot/certbot/certbot/_internal/main.py", line 1681, in _report_reconfigure_results
        final_renewal_params_set = set(final_renewal_params.items())
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    TypeError: unhashable type: 'list'  TypeError: unhashable type: 'list'

It would be good to get a test for this, and probably an integration test would be good too!


@author:
lol yes thanks for catching this in the early review, but yes, I still planned to add tests. Don't know that I would have thought to check this particular one or that I expected it to not Just Work there, so helpful nonetheless!

diff_hunk: @@ -1654,6 +1655,129 @@ 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']
+    orig_renewal_params_set = set(orig_renewal_params.items())
+    final_renewal_params = final_renewal_conf['renewalparams']
+    final_renewal_params_set = set(final_renewal_params.items())
+    changes = orig_renewal_params_set ^ final_renewal_params_set
+
+    if len(changes) == 0:
+        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, you "
+            "will need to renew the certificate instead of reconfiguring, which can be done "
+            "with the flag --force-renewal. 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_hook:
+        msg = "You are attempting to set a new deploy hook. Would you like Certbot to run the new "
+              "hook 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."
path: certbot/certbot/_internal/main.py
text: @user1:
Missing `(` `)` around multi-line string.

diff_hunk: @@ -1654,6 +1655,129 @@ 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']
+    orig_renewal_params_set = set(orig_renewal_params.items())
+    final_renewal_params = final_renewal_conf['renewalparams']
+    final_renewal_params_set = set(final_renewal_params.items())
+    changes = orig_renewal_params_set ^ final_renewal_params_set
+
+    if len(changes) == 0:
+        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, you "
+            "will need to renew the certificate instead of reconfiguring, which can be done "
+            "with the flag --force-renewal. 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_hook:
+        msg = "You are attempting to set a new deploy hook. Would you like Certbot to run the new "
+              "hook when it performs a dry run with the new settings? This will run all relevant "
path: certbot/certbot/_internal/main.py
text: @user1:
Do we need to do any explaining around which certificate the deploy hook is being invoked against? i.e. that this is semantically a dry-run and that we didn't persist  the test certificate to disk?

I'm not sure at the moment how obvious all of that is to the user, at the moment. 

@author:
Oh, that's the great point, I'll add that.