Repository: certbot/certbot

Issue:
Plugin selection can sometimes return an `Installer` which is not an `Installer`, resulting in crashes.
We have a very strange issue at https://community.letsencrypt.org/t/authenticator-object-has-no-attribute-restart-error-while-trying-to-renew-certificate/195970?u=_az where supposedly a `certonly --standalone` certificate renewal crashes with:

    Failed to renew certificate [mydomain.com](http://mydomain.com/) with error: 'Authenticator' object has no attribute 'restart'

I think some context is missing from that thread. The facts do not add up as of when I am creating this issue.

But more broadly, Certbot should not be accepting this input and crashing:

    $ sudo certbot certonly -d example.com -a standalone -i standalone
    Saving debug log to /var/log/letsencrypt/letsencrypt.log
    Requesting a certificate for example.com
    An unexpected error occurred:
    AttributeError: 'Authenticator' object has no attribute 'restart'
    Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.

By fixing this, we can probably fix/avoid whatever triggered the issue reported by the user.

We also need to be careful about not breaking any existing certificate workflows by e.g. erroring out. That is backwards-incompatible. Instead we should avoid invalid operations.

See [this comment](https://github.com/certbot/certbot/pull/9665#issuecomment-1517051564) for an explanation of what code path causes this issue.


Discussion:

Resolving patch:
diff --git a/certbot/certbot/_internal/plugins/selection.py b/certbot/certbot/_internal/plugins/selection.py
--- a/certbot/certbot/_internal/plugins/selection.py
+++ b/certbot/certbot/_internal/plugins/selection.py
@@ -112,8 +112,9 @@ def pick_plugin(config: configuration.NamespaceConfig, default: Optional[str],
                 "https://eff.org/letsencrypt-plugins for more detail on what "
                 "the plugins do and how to use them.")
 
-        filtered = plugins.visible().ifaces(ifaces)
+        filtered = plugins.visible()
 
+    filtered = filtered.ifaces(ifaces)
     filtered.init(config)
     filtered.prepare()
     prepared = filtered.available()


Test patch:
diff --git a/certbot/certbot/_internal/tests/main_test.py b/certbot/certbot/_internal/tests/main_test.py
--- a/certbot/certbot/_internal/tests/main_test.py
+++ b/certbot/certbot/_internal/tests/main_test.py
@@ -356,6 +356,17 @@ def test_dryrun_installer_doesnt_restart(self, mock_sel, mock_get_cert, mock_fin
         self._call('certonly --nginx -d example.com --dry-run'.split())
         mock_installer.restart.assert_not_called()
 
+    @mock.patch('certbot._internal.main._report_next_steps')
+    @mock.patch('certbot._internal.main._report_new_cert')
+    @mock.patch('certbot._internal.main._find_cert')
+    @mock.patch('certbot._internal.main._get_and_save_cert')
+    def test_invalid_installer(self, mock_get_cert, mock_find_cert,
+                               unused_report_new, unused_report_next):
+        mock_get_cert.return_value = mock.MagicMock()
+        mock_find_cert.return_value = (True, None)
+        self._call((f'certonly --webroot -w {tempfile.gettempdir()} ' +
+                    '-i standalone -d example.com').split())
+
 
 class FindDomainsOrCertnameTest(unittest.TestCase):
     """Tests for certbot._internal.main._find_domains_or_certname."""
diff --git a/certbot/certbot/_internal/tests/plugins/selection_test.py b/certbot/certbot/_internal/tests/plugins/selection_test.py
--- a/certbot/certbot/_internal/tests/plugins/selection_test.py
+++ b/certbot/certbot/_internal/tests/plugins/selection_test.py
@@ -110,6 +110,14 @@ def test_choose_plugin_none(self):
             mock_choose.return_value = None
             assert self._call() is None
 
+    def test_default_must_be_filtered(self):
+        # https://github.com/certbot/certbot/issues/9664
+        self.default = "foo"
+        filtered = mock.MagicMock()
+        self.reg.filter.return_value = filtered
+        self._call()
+        assert filtered.ifaces.call_count == 1
+
 
 class ChoosePluginTest(unittest.TestCase):
     """Tests for certbot._internal.plugins.selection.choose_plugin."""