Repository: certbot/certbot

Issue:
Handle case where mod_ssl is not available
As flagged by @joohoi, especially when using Certbot packaged as a snap, it is possible that `mod_ssl` is not available in Apache on the host system. We should check for this case early and error out with a useful error message if its not found.


Discussion:
I looked into this a bit using CentOS 6 for testing (because CentOS is the main system I'm aware of that doesn't ship the SSL module with Apache and CentOS 6 is the only version that doesn't rely on systemd to interact with Apache which breaks when running in Docker).

The relevant output looks like:
```
Created an SSL vhost at /etc/httpd/conf/httpd-le-ssl.conf
Cannot find an SSLCertificateFile directive in /files/etc/httpd/conf/httpd-le-ssl.conf/IfModule/VirtualHost. VirtualHost was not modified
Unable to find an SSLCertificateFile directive

IMPORTANT NOTES:
 - Unable to install the certificate
 - Congratulations! Your certificate and chain have been saved at: 
   /etc/letsencrypt/live/example.org/fullchain.pem
   Your key file has been saved at: 
   /etc/letsencrypt/live/example.org/privkey.pem
   Your cert will expire on 2020-07-20. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot again
   with the "certonly" option. To non-interactively renew *all* of
   your certificates, run "certbot renew"
```
This is caused by the code https://github.com/certbot/certbot/blob/6693e87500344f0bff19f14beb2121f66a19d143/certbot-apache/certbot_apache/_internal/configurator.py#L599-L605

We add dummy SSL directives and enclose them in an `IfModule mod_ssl.c` block. We then call `parser.find_dir` which by default excludes directives enclosed in `IfModule` blocks that aren't in `parser.modules` which later causes us to error out.

I personally don't really think we should move the error that much earlier in execution as our Apache plugin functions just fine as an authenticator without mod_ssl and we still can obtain and save your certificate. I think we should improve the error message when installing the cert though, probably just by checking for the SSL module in `parser.modules` and throwing a useful error message early in the execution of `deploy_cert`. This should be pretty easy.

Resolving patch:
diff --git a/certbot-apache/certbot_apache/_internal/configurator.py b/certbot-apache/certbot_apache/_internal/configurator.py
--- a/certbot-apache/certbot_apache/_internal/configurator.py
+++ b/certbot-apache/certbot_apache/_internal/configurator.py
@@ -595,6 +595,11 @@ def _deploy_cert(self, vhost, cert_path, key_path, chain_path, fullchain_path):
         # cert_key... can all be parsed appropriately
         self.prepare_server_https("443")
 
+        # If we haven't managed to enable mod_ssl by this point, error out
+        if "ssl_module" not in self.parser.modules:
+            raise errors.MisconfigurationError("Could not find ssl_module; "
+                "not installing certificate.")
+
         # Add directives and remove duplicates
         self._add_dummy_ssl_directives(vhost.path)
         self._clean_vhost(vhost)
@@ -609,21 +614,6 @@ def _deploy_cert(self, vhost, cert_path, key_path, chain_path, fullchain_path):
             path["chain_path"] = self.parser.find_dir(
                 "SSLCertificateChainFile", None, vhost.path)
 
-        # Handle errors when certificate/key directives cannot be found
-        if not path["cert_path"]:
-            logger.warning(
-                "Cannot find an SSLCertificateFile directive in %s. "
-                "VirtualHost was not modified", vhost.path)
-            raise errors.PluginError(
-                "Unable to find an SSLCertificateFile directive")
-        elif not path["cert_key"]:
-            logger.warning(
-                "Cannot find an SSLCertificateKeyFile directive for "
-                "certificate in %s. VirtualHost was not modified", vhost.path)
-            raise errors.PluginError(
-                "Unable to find an SSLCertificateKeyFile directive for "
-                "certificate")
-
         logger.info("Deploying Certificate to VirtualHost %s", vhost.filep)
 
         if self.version < (2, 4, 8) or (chain_path and not fullchain_path):


Test patch:
diff --git a/certbot-apache/tests/configurator_test.py b/certbot-apache/tests/configurator_test.py
--- a/certbot-apache/tests/configurator_test.py
+++ b/certbot-apache/tests/configurator_test.py
@@ -455,41 +455,6 @@ def find_args(path, directive):
             "SSLCertificateChainFile", "two/cert_chain.pem",
             self.vh_truth[1].path))
 
-    def test_deploy_cert_invalid_vhost(self):
-        """For test cases where the `ApacheConfigurator` class' `_deploy_cert`
-        method is called with an invalid vhost parameter. Currently this tests
-        that a PluginError is appropriately raised when important directives
-        are missing in an SSL module."""
-        self.config.parser.modules["ssl_module"] = None
-        self.config.parser.modules["mod_ssl.c"] = None
-        self.config.parser.modules["socache_shmcb_module"] = None
-
-        def side_effect(*args):
-            """Mocks case where an SSLCertificateFile directive can be found
-            but an SSLCertificateKeyFile directive is missing."""
-            if "SSLCertificateFile" in args:
-                return ["example/cert.pem"]
-            return []
-
-        mock_find_dir = mock.MagicMock(return_value=[])
-        mock_find_dir.side_effect = side_effect
-
-        self.config.parser.find_dir = mock_find_dir
-
-        # Get the default 443 vhost
-        self.config.assoc["random.demo"] = self.vh_truth[1]
-
-        self.assertRaises(
-            errors.PluginError, self.config.deploy_cert, "random.demo",
-            "example/cert.pem", "example/key.pem", "example/cert_chain.pem")
-
-        # Remove side_effect to mock case where both SSLCertificateFile
-        # and SSLCertificateKeyFile directives are missing
-        self.config.parser.find_dir.side_effect = None
-        self.assertRaises(
-            errors.PluginError, self.config.deploy_cert, "random.demo",
-            "example/cert.pem", "example/key.pem", "example/cert_chain.pem")
-
     def test_is_name_vhost(self):
         addr = obj.Addr.fromstring("*:80")
         self.assertTrue(self.config.is_name_vhost(addr))
@@ -1349,6 +1314,16 @@ def test_deploy_cert_not_parsed_path(self):
                 self.assertTrue(mock_add.called)
         shutil.rmtree(tmp_path)
 
+    def test_deploy_cert_no_mod_ssl(self):
+        # Create
+        ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0])
+        self.config.parser.modules["socache_shmcb_module"] = None
+        self.config.prepare_server_https = mock.Mock()
+
+        self.assertRaises(errors.MisconfigurationError, self.config.deploy_cert,
+            "encryption-example.demo", "example/cert.pem", "example/key.pem",
+            "example/cert_chain.pem", "example/fullchain.pem")
+
     @mock.patch("certbot_apache._internal.parser.ApacheParser.parsed_in_original")
     def test_choose_vhost_and_servername_addition_parsed(self, mock_parsed):
         ret_vh = self.vh_truth[8]