Repository: certbot/certbot

Issue:
HTTP01 support for Apache
Part of #5409.


Discussion:

Resolving patch:
diff --git a/certbot-apache/certbot_apache/configurator.py b/certbot-apache/certbot_apache/configurator.py
--- a/certbot-apache/certbot_apache/configurator.py
+++ b/certbot-apache/certbot_apache/configurator.py
@@ -24,9 +24,10 @@
 from certbot_apache import augeas_configurator
 from certbot_apache import constants
 from certbot_apache import display_ops
-from certbot_apache import tls_sni_01
+from certbot_apache import http_01
 from certbot_apache import obj
 from certbot_apache import parser
+from certbot_apache import tls_sni_01
 
 from collections import defaultdict
 
@@ -163,6 +164,9 @@ def __init__(self, *args, **kwargs):
                               "ensure-http-header": self._set_http_header,
                               "staple-ocsp": self._enable_ocsp_stapling}
 
+        # This will be set during the perform function
+        self.http_doer = None
+
     @property
     def mod_ssl_conf(self):
         """Full absolute path to SSL configuration file."""
@@ -736,31 +740,43 @@ def prepare_server_https(self, port, temp=False):
 
         """
 
-        # If nonstandard port, add service definition for matching
-        if port != "443":
+        self.prepare_https_modules(temp)
+        self.ensure_listen(port, https=True)
+
+    def ensure_listen(self, port, https=False):
+        """Make sure that Apache is listening on the port. Checks if the
+        Listen statement for the port already exists, and adds it to the
+        configuration if necessary.
+
+        :param str port: Port number to check and add Listen for if not in
+            place already
+        :param bool https: If the port will be used for HTTPS
+
+        """
+
+        # If HTTPS requested for nonstandard port, add service definition
+        if https and port != "443":
             port_service = "%s %s" % (port, "https")
         else:
             port_service = port
 
-        self.prepare_https_modules(temp)
         # Check for Listen <port>
         # Note: This could be made to also look for ip:443 combo
         listens = [self.parser.get_arg(x).split()[0] for
                    x in self.parser.find_dir("Listen")]
 
-        # In case no Listens are set (which really is a broken apache config)
-        if not listens:
-            listens = ["80"]
-
         # Listen already in place
         if self._has_port_already(listens, port):
             return
 
         listen_dirs = set(listens)
 
+        if not listens:
+            listen_dirs.add(port_service)
+
         for listen in listens:
             # For any listen statement, check if the machine also listens on
-            # Port 443. If not, add such a listen statement.
+            # the given port. If not, add such a listen statement.
             if len(listen.split(":")) == 1:
                 # Its listening to all interfaces
                 if port not in listen_dirs and port_service not in listen_dirs:
@@ -772,11 +788,39 @@ def prepare_server_https(self, port, temp=False):
                 if "%s:%s" % (ip, port_service) not in listen_dirs and (
                    "%s:%s" % (ip, port_service) not in listen_dirs):
                     listen_dirs.add("%s:%s" % (ip, port_service))
-        self._add_listens(listen_dirs, listens, port)
+        if https:
+            self._add_listens_https(listen_dirs, listens, port)
+        else:
+            self._add_listens_http(listen_dirs, listens, port)
+
+    def _add_listens_http(self, listens, listens_orig, port):
+        """Helper method for ensure_listen to figure out which new
+        listen statements need adding for listening HTTP on port
 
-    def _add_listens(self, listens, listens_orig, port):
-        """Helper method for prepare_server_https to figure out which new
-        listen statements need adding
+        :param set listens: Set of all needed Listen statements
+        :param list listens_orig: List of existing listen statements
+        :param string port: Port number we're adding
+        """
+
+        new_listens = listens.difference(listens_orig)
+
+        if port in new_listens:
+            # We have wildcard, skip the rest
+            self.parser.add_dir(parser.get_aug_path(self.parser.loc["listen"]),
+                                "Listen", port)
+            self.save_notes += "Added Listen %s directive to %s\n" % (
+                port, self.parser.loc["listen"])
+        else:
+            for listen in new_listens:
+                self.parser.add_dir(parser.get_aug_path(
+                    self.parser.loc["listen"]), "Listen", listen.split(" "))
+                self.save_notes += ("Added Listen %s directive to "
+                                    "%s\n") % (listen,
+                                               self.parser.loc["listen"])
+
+    def _add_listens_https(self, listens, listens_orig, port):
+        """Helper method for ensure_listen to figure out which new
+        listen statements need adding for listening HTTPS on port
 
         :param set listens: Set of all needed Listen statements
         :param list listens_orig: List of existing listen statements
@@ -1855,7 +1899,7 @@ def more_info(self):
     ###########################################################################
     def get_chall_pref(self, unused_domain):  # pylint: disable=no-self-use
         """Return list of challenge preferences."""
-        return [challenges.TLSSNI01]
+        return [challenges.TLSSNI01, challenges.HTTP01]
 
     def perform(self, achalls):
         """Perform the configuration related challenge.
@@ -1867,16 +1911,21 @@ def perform(self, achalls):
         """
         self._chall_out.update(achalls)
         responses = [None] * len(achalls)
-        chall_doer = tls_sni_01.ApacheTlsSni01(self)
+        self.http_doer = http_01.ApacheHttp01(self)
+        sni_doer = tls_sni_01.ApacheTlsSni01(self)
 
         for i, achall in enumerate(achalls):
             # Currently also have chall_doer hold associated index of the
             # challenge. This helps to put all of the responses back together
             # when they are all complete.
-            chall_doer.add_chall(achall, i)
-
-        sni_response = chall_doer.perform()
-        if sni_response:
+            if isinstance(achall.chall, challenges.HTTP01):
+                self.http_doer.add_chall(achall, i)
+            else:  # tls-sni-01
+                sni_doer.add_chall(achall, i)
+
+        http_response = self.http_doer.perform()
+        sni_response = sni_doer.perform()
+        if http_response or sni_response:
             # Must reload in order to activate the challenges.
             # Handled here because we may be able to load up other challenge
             # types
@@ -1886,14 +1935,18 @@ def perform(self, achalls):
             # of identifying when the new configuration is being used.
             time.sleep(3)
 
-            # Go through all of the challenges and assign them to the proper
-            # place in the responses return value. All responses must be in the
-            # same order as the original challenges.
-            for i, resp in enumerate(sni_response):
-                responses[chall_doer.indices[i]] = resp
+            self._update_responses(responses, http_response, self.http_doer)
+            self._update_responses(responses, sni_response, sni_doer)
 
         return responses
 
+    def _update_responses(self, responses, chall_response, chall_doer):
+        # Go through all of the challenges and assign them to the proper
+        # place in the responses return value. All responses must be in the
+        # same order as the original challenges.
+        for i, resp in enumerate(chall_response):
+            responses[chall_doer.indices[i]] = resp
+
     def cleanup(self, achalls):
         """Revert all challenges."""
         self._chall_out.difference_update(achalls)
@@ -1903,6 +1956,7 @@ def cleanup(self, achalls):
             self.revert_challenge_config()
             self.restart()
             self.parser.reset_modules()
+            self.http_doer.cleanup()
 
     def install_ssl_options_conf(self, options_ssl, options_ssl_digest):
         """Copy Certbot's SSL options file into the system's config dir if required."""
diff --git a/certbot-apache/certbot_apache/http_01.py b/certbot-apache/certbot_apache/http_01.py
new file mode 100644
--- /dev/null
+++ b/certbot-apache/certbot_apache/http_01.py
@@ -0,0 +1,112 @@
+"""A class that performs HTTP-01 challenges for Apache"""
+import logging
+import os
+import shutil
+import tempfile
+
+from certbot.plugins import common
+
+logger = logging.getLogger(__name__)
+
+class ApacheHttp01(common.TLSSNI01):
+    """Class that performs HTPP-01 challenges within the Apache configurator."""
+
+    CONFIG_TEMPLATE24 = """\
+Alias /.well-known/acme-challenge {0}
+
+<Directory {0} >
+    Require all granted
+</Directory>
+
+"""
+
+    CONFIG_TEMPLATE22 = """\
+Alias /.well-known/acme-challenge {0}
+
+<Directory {0} >
+    Order allow,deny
+    Allow from all
+</Directory>
+
+"""
+
+    def __init__(self, *args, **kwargs):
+        super(ApacheHttp01, self).__init__(*args, **kwargs)
+        self.challenge_conf = os.path.join(
+            self.configurator.conf("challenge-location"),
+            "le_http_01_challenge.conf")
+        self.challenge_dir = None
+
+    def perform(self):
+        """Perform all HTTP-01 challenges."""
+        if not self.achalls:
+            return []
+        # Save any changes to the configuration as a precaution
+        # About to make temporary changes to the config
+        self.configurator.save("Changes before challenge setup", True)
+
+        self.configurator.ensure_listen(str(
+            self.configurator.config.http01_port))
+        self.prepare_http01_modules()
+
+        responses = self._set_up_challenges()
+        self._mod_config()
+        # Save reversible changes
+        self.configurator.save("HTTP Challenge", True)
+
+        return responses
+
+    def cleanup(self):
+        """Cleanup the challenge directory."""
+        if self.challenge_dir:
+            shutil.rmtree(self.challenge_dir, ignore_errors=True)
+        self.challenge_dir = None
+
+    def prepare_http01_modules(self):
+        """Make sure that we have the needed modules available for http01"""
+
+        if self.configurator.conf("handle-modules"):
+            needed_modules = ["alias"]
+            if self.configurator.version < (2, 4):
+                needed_modules.append("authz_host")
+            else:
+                needed_modules.append("authz_core")
+            for mod in needed_modules:
+                if mod + "_module" not in self.configurator.parser.modules:
+                    self.configurator.enable_mod(mod, temp=True)
+
+    def _mod_config(self):
+        self.configurator.parser.add_include(
+            self.configurator.parser.loc["default"], self.challenge_conf)
+        self.configurator.reverter.register_file_creation(
+            True, self.challenge_conf)
+
+        if self.configurator.version < (2, 4):
+            config_template = self.CONFIG_TEMPLATE22
+        else:
+            config_template = self.CONFIG_TEMPLATE24
+        config_text = config_template.format(self.challenge_dir)
+
+        logger.debug("writing a config file with text:\n %s", config_text)
+        with open(self.challenge_conf, "w") as new_conf:
+            new_conf.write(config_text)
+
+    def _set_up_challenges(self):
+        self.challenge_dir = tempfile.mkdtemp()
+        os.chmod(self.challenge_dir, 0o755)
+
+        responses = []
+        for achall in self.achalls:
+            responses.append(self._set_up_challenge(achall))
+
+        return responses
+
+    def _set_up_challenge(self, achall):
+        response, validation = achall.response_and_validation()
+
+        name = os.path.join(self.challenge_dir, achall.chall.encode("token"))
+        with open(name, 'wb') as f:
+            f.write(validation.encode())
+        os.chmod(name, 0o644)
+
+        return response
diff --git a/certbot-apache/certbot_apache/override_debian.py b/certbot-apache/certbot_apache/override_debian.py
--- a/certbot-apache/certbot_apache/override_debian.py
+++ b/certbot-apache/certbot_apache/override_debian.py
@@ -140,5 +140,5 @@ def _enable_mod_debian(self, mod_name, temp):
                 "a2dismod are configured correctly for certbot.")
 
         self.reverter.register_undo_command(
-            temp, [self.conf("dismod"), mod_name])
+            temp, [self.conf("dismod"), "-f", mod_name])
         util.run_script([self.conf("enmod"), mod_name])


Test patch:
diff --git a/certbot-apache/certbot_apache/tests/configurator_test.py b/certbot-apache/certbot_apache/tests/configurator_test.py
--- a/certbot-apache/certbot_apache/tests/configurator_test.py
+++ b/certbot-apache/certbot_apache/tests/configurator_test.py
@@ -424,6 +424,43 @@ def test_add_name_vhost(self):
         self.assertTrue(self.config.parser.find_dir(
             "NameVirtualHost", "*:80"))
 
+    def test_add_listen_80(self):
+        mock_find = mock.Mock()
+        mock_add_dir = mock.Mock()
+        mock_find.return_value = []
+        self.config.parser.find_dir = mock_find
+        self.config.parser.add_dir = mock_add_dir
+        self.config.ensure_listen("80")
+        self.assertTrue(mock_add_dir.called)
+        self.assertTrue(mock_find.called)
+        self.assertEqual(mock_add_dir.call_args[0][1], "Listen")
+        self.assertEqual(mock_add_dir.call_args[0][2], "80")
+
+    def test_add_listen_80_named(self):
+        mock_find = mock.Mock()
+        mock_find.return_value = ["test1", "test2", "test3"]
+        mock_get = mock.Mock()
+        mock_get.side_effect = ["1.2.3.4:80", "[::1]:80", "1.1.1.1:443"]
+        mock_add_dir = mock.Mock()
+
+        self.config.parser.find_dir = mock_find
+        self.config.parser.get_arg = mock_get
+        self.config.parser.add_dir = mock_add_dir
+
+        self.config.ensure_listen("80")
+        self.assertEqual(mock_add_dir.call_count, 0)
+
+        # Reset return lists and inputs
+        mock_add_dir.reset_mock()
+        mock_get.side_effect = ["1.2.3.4:80", "[::1]:80", "1.1.1.1:443"]
+
+        # Test
+        self.config.ensure_listen("8080")
+        self.assertEqual(mock_add_dir.call_count, 3)
+        self.assertTrue(mock_add_dir.called)
+        self.assertEqual(mock_add_dir.call_args[0][1], "Listen")
+        self.assertEqual(mock_add_dir.call_args[0][2], ['1.2.3.4:8080'])
+
     def test_prepare_server_https(self):
         mock_enable = mock.Mock()
         self.config.enable_mod = mock_enable
@@ -435,7 +472,6 @@ def test_prepare_server_https(self):
         # This will test the Add listen
         self.config.parser.find_dir = mock_find
         self.config.parser.add_dir_to_ifmodssl = mock_add_dir
-
         self.config.prepare_server_https("443")
         # Changing the order these modules are enabled breaks the reverter
         self.assertEqual(mock_enable.call_args_list[0][0][0], "socache_shmcb")
@@ -676,23 +712,33 @@ def test_add_name_vhost_if_necessary(self):
         self.config._add_name_vhost_if_necessary(self.vh_truth[0])
         self.assertEqual(self.config.add_name_vhost.call_count, 2)
 
+    @mock.patch("certbot_apache.configurator.http_01.ApacheHttp01.perform")
     @mock.patch("certbot_apache.configurator.tls_sni_01.ApacheTlsSni01.perform")
     @mock.patch("certbot_apache.configurator.ApacheConfigurator.restart")
-    def test_perform(self, mock_restart, mock_perform):
+    def test_perform(self, mock_restart, mock_tls_perform, mock_http_perform):
         # Only tests functionality specific to configurator.perform
         # Note: As more challenges are offered this will have to be expanded
-        account_key, achall1, achall2 = self.get_achalls()
+        account_key, achalls = self.get_key_and_achalls()
+
+        all_expected = []
+        http_expected = []
+        tls_expected = []
+        for achall in achalls:
+            response = achall.response(account_key)
+            if isinstance(achall.chall, challenges.HTTP01):
+                http_expected.append(response)
+            else:
+                tls_expected.append(response)
+            all_expected.append(response)
 
-        expected = [
-            achall1.response(account_key),
-            achall2.response(account_key),
-        ]
+        mock_http_perform.return_value = http_expected
+        mock_tls_perform.return_value = tls_expected
 
-        mock_perform.return_value = expected
-        responses = self.config.perform([achall1, achall2])
+        responses = self.config.perform(achalls)
 
-        self.assertEqual(mock_perform.call_count, 1)
-        self.assertEqual(responses, expected)
+        self.assertEqual(mock_http_perform.call_count, 1)
+        self.assertEqual(mock_tls_perform.call_count, 1)
+        self.assertEqual(responses, all_expected)
 
         self.assertEqual(mock_restart.call_count, 1)
 
@@ -700,30 +746,38 @@ def test_perform(self, mock_restart, mock_perform):
     @mock.patch("certbot_apache.parser.ApacheParser._get_runtime_cfg")
     def test_cleanup(self, mock_cfg, mock_restart):
         mock_cfg.return_value = ""
-        _, achall1, achall2 = self.get_achalls()
+        _, achalls = self.get_key_and_achalls()
+        self.config.http_doer = mock.MagicMock()
 
-        self.config._chall_out.add(achall1)  # pylint: disable=protected-access
-        self.config._chall_out.add(achall2)  # pylint: disable=protected-access
-
-        self.config.cleanup([achall1])
-        self.assertFalse(mock_restart.called)
+        for achall in achalls:
+            self.config._chall_out.add(achall)  # pylint: disable=protected-access
 
-        self.config.cleanup([achall2])
-        self.assertTrue(mock_restart.called)
+        for i, achall in enumerate(achalls):
+            self.config.cleanup([achall])
+            if i == len(achalls) - 1:
+                self.assertTrue(mock_restart.called)
+                self.assertTrue(self.config.http_doer.cleanup.called)
+            else:
+                self.assertFalse(mock_restart.called)
+                self.assertFalse(self.config.http_doer.cleanup.called)
 
     @mock.patch("certbot_apache.configurator.ApacheConfigurator.restart")
     @mock.patch("certbot_apache.parser.ApacheParser._get_runtime_cfg")
     def test_cleanup_no_errors(self, mock_cfg, mock_restart):
         mock_cfg.return_value = ""
-        _, achall1, achall2 = self.get_achalls()
+        _, achalls = self.get_key_and_achalls()
+        self.config.http_doer = mock.MagicMock()
 
-        self.config._chall_out.add(achall1)  # pylint: disable=protected-access
+        for achall in achalls:
+            self.config._chall_out.add(achall)  # pylint: disable=protected-access
 
-        self.config.cleanup([achall2])
+        self.config.cleanup([achalls[-1]])
         self.assertFalse(mock_restart.called)
+        self.assertFalse(self.config.http_doer.cleanup.called)
 
-        self.config.cleanup([achall1, achall2])
+        self.config.cleanup(achalls)
         self.assertTrue(mock_restart.called)
+        self.assertTrue(self.config.http_doer.cleanup.called)
 
     @mock.patch("certbot.util.run_script")
     def test_get_version(self, mock_script):
@@ -1151,7 +1205,7 @@ def test_sift_rewrite_rule(self):
         not_rewriterule = "NotRewriteRule ^ ..."
         self.assertFalse(self.config._sift_rewrite_rule(not_rewriterule))
 
-    def get_achalls(self):
+    def get_key_and_achalls(self):
         """Return testing achallenges."""
         account_key = self.rsa512jwk
         achall1 = achallenges.KeyAuthorizationAnnotatedChallenge(
@@ -1166,8 +1220,12 @@ def get_achalls(self):
                     token=b"uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU"),
                 "pending"),
             domain="certbot.demo", account_key=account_key)
+        achall3 = achallenges.KeyAuthorizationAnnotatedChallenge(
+            challb=acme_util.chall_to_challb(
+                challenges.HTTP01(token=(b'x' * 16)), "pending"),
+            domain="example.org", account_key=account_key)
 
-        return account_key, achall1, achall2
+        return account_key, (achall1, achall2, achall3)
 
     def test_make_addrs_sni_ready(self):
         self.config.version = (2, 2)
diff --git a/certbot-apache/certbot_apache/tests/http_01_test.py b/certbot-apache/certbot_apache/tests/http_01_test.py
new file mode 100644
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/http_01_test.py
@@ -0,0 +1,152 @@
+"""Test for certbot_apache.http_01."""
+import mock
+import os
+import unittest
+
+from acme import challenges
+
+from certbot import achallenges
+
+from certbot.tests import acme_util
+
+from certbot_apache.tests import util
+
+
+NUM_ACHALLS = 3
+
+
+class ApacheHttp01TestMeta(type):
+    """Generates parmeterized tests for testing perform."""
+    def __new__(mcs, name, bases, class_dict):
+
+        def _gen_test(num_achalls, minor_version):
+            def _test(self):
+                achalls = self.achalls[:num_achalls]
+                self.config.version = (2, minor_version)
+                self.common_perform_test(achalls)
+            return _test
+
+        for i in range(1, NUM_ACHALLS + 1):
+            for j in (2, 4):
+                test_name = "test_perform_{0}_{1}".format(i, j)
+                class_dict[test_name] = _gen_test(i, j)
+        return type.__new__(mcs, name, bases, class_dict)
+
+
+class ApacheHttp01Test(util.ApacheTest):
+    """Test for certbot_apache.http_01.ApacheHttp01."""
+
+    __metaclass__ = ApacheHttp01TestMeta
+
+    def setUp(self, *args, **kwargs):
+        super(ApacheHttp01Test, self).setUp(*args, **kwargs)
+
+        self.account_key = self.rsa512jwk
+        self.achalls = []
+        for i in range(NUM_ACHALLS):
+            self.achalls.append(
+                achallenges.KeyAuthorizationAnnotatedChallenge(
+                    challb=acme_util.chall_to_challb(
+                        challenges.HTTP01(token=((chr(ord('a') + i) * 16))),
+                        "pending"),
+                    domain="example{0}.com".format(i),
+                    account_key=self.account_key))
+
+        modules = ["alias", "authz_core", "authz_host"]
+        for mod in modules:
+            self.config.parser.modules.add("mod_{0}.c".format(mod))
+            self.config.parser.modules.add(mod + "_module")
+
+        from certbot_apache.http_01 import ApacheHttp01
+        self.http = ApacheHttp01(self.config)
+
+    def test_empty_perform(self):
+        self.assertFalse(self.http.perform())
+
+    @mock.patch("certbot_apache.configurator.ApacheConfigurator.enable_mod")
+    def test_enable_modules_22(self, mock_enmod):
+        self.config.version = (2, 2)
+        self.config.parser.modules.remove("authz_host_module")
+        self.config.parser.modules.remove("mod_authz_host.c")
+
+        enmod_calls = self.common_enable_modules_test(mock_enmod)
+        self.assertEqual(enmod_calls[0][0][0], "authz_host")
+
+    @mock.patch("certbot_apache.configurator.ApacheConfigurator.enable_mod")
+    def test_enable_modules_24(self, mock_enmod):
+        self.config.parser.modules.remove("authz_core_module")
+        self.config.parser.modules.remove("mod_authz_core.c")
+
+        enmod_calls = self.common_enable_modules_test(mock_enmod)
+        self.assertEqual(enmod_calls[0][0][0], "authz_core")
+
+    def common_enable_modules_test(self, mock_enmod):
+        """Tests enabling mod_alias and other modules."""
+        self.config.parser.modules.remove("alias_module")
+        self.config.parser.modules.remove("mod_alias.c")
+
+        self.http.prepare_http01_modules()
+
+        self.assertTrue(mock_enmod.called)
+        calls = mock_enmod.call_args_list
+        other_calls = []
+        for call in calls:
+            if "alias" != call[0][0]:
+                other_calls.append(call)
+
+        # If these lists are equal, we never enabled mod_alias
+        self.assertNotEqual(calls, other_calls)
+        return other_calls
+
+    def common_perform_test(self, achalls):
+        """Tests perform with the given achalls."""
+        for achall in achalls:
+            self.http.add_chall(achall)
+
+        expected_response = [
+            achall.response(self.account_key) for achall in achalls]
+        self.assertEqual(self.http.perform(), expected_response)
+
+        self.assertTrue(os.path.isdir(self.http.challenge_dir))
+        self._has_min_permissions(self.http.challenge_dir, 0o755)
+        self._test_challenge_conf()
+
+        for achall in achalls:
+            self._test_challenge_file(achall)
+
+        challenge_dir = self.http.challenge_dir
+        self.http.cleanup()
+        self.assertFalse(os.path.exists(challenge_dir))
+
+    def _test_challenge_conf(self):
+        self.assertEqual(
+            len(self.config.parser.find_dir(
+                "Include", self.http.challenge_conf)), 1)
+
+        with open(self.http.challenge_conf) as f:
+            conf_contents = f.read()
+
+        alias_fmt = "Alias /.well-known/acme-challenge {0}"
+        alias = alias_fmt.format(self.http.challenge_dir)
+        self.assertTrue(alias in conf_contents)
+        if self.config.version < (2, 4):
+            self.assertTrue("Allow from all" in conf_contents)
+        else:
+            self.assertTrue("Require all granted" in conf_contents)
+
+    def _test_challenge_file(self, achall):
+        name = os.path.join(self.http.challenge_dir, achall.chall.encode("token"))
+        validation = achall.validation(self.account_key)
+
+        self._has_min_permissions(name, 0o644)
+        with open(name, 'rb') as f:
+            self.assertEqual(f.read(), validation.encode())
+
+    def _has_min_permissions(self, path, min_mode):
+        """Tests the given file has at least the permissions in mode."""
+        st_mode = os.stat(path).st_mode
+        self.assertEqual(st_mode, st_mode | min_mode)
+
+
+if __name__ == "__main__":
+    unittest.main()  # pragma: no cover
diff --git a/certbot-apache/certbot_apache/tests/tls_sni_01_test.py b/certbot-apache/certbot_apache/tests/tls_sni_01_test.py
--- a/certbot-apache/certbot_apache/tests/tls_sni_01_test.py
+++ b/certbot-apache/certbot_apache/tests/tls_sni_01_test.py
@@ -1,6 +1,6 @@
 """Test for certbot_apache.tls_sni_01."""
-import unittest
 import shutil
+import unittest
 
 import mock
 
diff --git a/certbot-apache/certbot_apache/tests/util.py b/certbot-apache/certbot_apache/tests/util.py
--- a/certbot-apache/certbot_apache/tests/util.py
+++ b/certbot-apache/certbot_apache/tests/util.py
@@ -103,6 +103,7 @@ def get_apache_configurator(  # pylint: disable=too-many-arguments, too-many-loc
         apache_challenge_location=config_path,
         backup_dir=backups,
         config_dir=config_dir,
+        http01_port=80,
         temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
         in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
         work_dir=work_dir)