Repository: certbot/certbot

Issue:
HTTP01 support for Nginx
Part of #5409.


Discussion:

Resolving patch:
diff --git a/certbot-nginx/certbot_nginx/configurator.py b/certbot-nginx/certbot_nginx/configurator.py
--- a/certbot-nginx/certbot_nginx/configurator.py
+++ b/certbot-nginx/certbot_nginx/configurator.py
@@ -26,6 +26,7 @@
 from certbot_nginx import nginxparser
 from certbot_nginx import parser
 from certbot_nginx import tls_sni_01
+from certbot_nginx import http_01
 
 
 logger = logging.getLogger(__name__)
@@ -208,7 +209,8 @@ def choose_vhost(self, target_name, create_if_no_match=False):
 
         :param str target_name: domain name
         :param bool create_if_no_match: If we should create a new vhost from default
-            when there is no match found
+            when there is no match found. If we can't choose a default, raise a
+            MisconfigurationError.
 
         :returns: ssl vhost associated with name
         :rtype: :class:`~certbot_nginx.obj.VirtualHost`
@@ -366,7 +368,7 @@ def _rank_matches_by_name_and_ssl(self, vhost_list, target_name):
         return sorted(matches, key=lambda x: x['rank'])
 
 
-    def choose_redirect_vhost(self, target_name, port):
+    def choose_redirect_vhost(self, target_name, port, create_if_no_match=False):
         """Chooses a single virtual host for redirect enhancement.
 
         Chooses the vhost most closely matching target_name that is
@@ -380,12 +382,19 @@ def choose_redirect_vhost(self, target_name, port):
 
         :param str target_name: domain name
         :param str port: port number
+        :param bool create_if_no_match: If we should create a new vhost from default
+            when there is no match found. If we can't choose a default, raise a
+            MisconfigurationError.
+
         :returns: vhost associated with name
         :rtype: :class:`~certbot_nginx.obj.VirtualHost`
 
         """
         matches = self._get_redirect_ranked_matches(target_name, port)
-        return self._select_best_name_match(matches)
+        vhost = self._select_best_name_match(matches)
+        if not vhost and create_if_no_match:
+            vhost = self._vhost_from_duplicated_default(target_name)
+        return vhost
 
     def _get_redirect_ranked_matches(self, target_name, port):
         """Gets a ranked list of plaintextish port-listening vhosts matching target_name
@@ -394,7 +403,7 @@ def _get_redirect_ranked_matches(self, target_name, port):
         Rank by how well these match target_name.
 
         :param str target_name: The name to match
-        :param str port: port number
+        :param str port: port number as a string
         :returns: list of dicts containing the vhost, the matching name, and
             the numerical rank
         :rtype: list
@@ -840,7 +849,7 @@ def rollback_checkpoints(self, rollback=1):
     ###########################################################################
     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]
 
     # Entry point in main.py for performing challenges
     def perform(self, achalls):
@@ -853,15 +862,20 @@ def perform(self, achalls):
         """
         self._chall_out += len(achalls)
         responses = [None] * len(achalls)
-        chall_doer = tls_sni_01.NginxTlsSni01(self)
+        sni_doer = tls_sni_01.NginxTlsSni01(self)
+        http_doer = http_01.NginxHttp01(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)
+            if isinstance(achall.chall, challenges.HTTP01):
+                http_doer.add_chall(achall, i)
+            else:  # tls-sni-01
+                sni_doer.add_chall(achall, i)
 
-        sni_response = chall_doer.perform()
+        sni_response = sni_doer.perform()
+        http_response = http_doer.perform()
         # Must restart in order to activate the challenges.
         # Handled here because we may be able to load up other challenge types
         self.restart()
@@ -869,8 +883,9 @@ def perform(self, achalls):
         # 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
+        for chall_response, chall_doer in ((sni_response, sni_doer), (http_response, http_doer)):
+            for i, resp in enumerate(chall_response):
+                responses[chall_doer.indices[i]] = resp
 
         return responses
 
diff --git a/certbot-nginx/certbot_nginx/http_01.py b/certbot-nginx/certbot_nginx/http_01.py
new file mode 100644
--- /dev/null
+++ b/certbot-nginx/certbot_nginx/http_01.py
@@ -0,0 +1,106 @@
+"""A class that performs HTTP-01 challenges for Nginx"""
+
+import logging
+import os
+
+from acme import challenges
+
+from certbot.plugins import common
+
+
+logger = logging.getLogger(__name__)
+
+
+class NginxHttp01(common.ChallengePerformer):
+    """HTTP-01 authenticator for Nginx
+
+    :ivar configurator: NginxConfigurator object
+    :type configurator: :class:`~nginx.configurator.NginxConfigurator`
+
+    :ivar list achalls: Annotated
+        class:`~certbot.achallenges.KeyAuthorizationAnnotatedChallenge`
+        challenges
+
+    :param list indices: Meant to hold indices of challenges in a
+        larger array. NginxHttp01 is capable of solving many challenges
+        at once which causes an indexing issue within NginxConfigurator
+        who must return all responses in order.  Imagine NginxConfigurator
+        maintaining state about where all of the http-01 Challenges,
+        TLS-SNI-01 Challenges belong in the response array.  This is an
+        optional utility.
+
+    """
+
+    def perform(self):
+        """Perform a challenge on Nginx.
+
+        :returns: list of :class:`certbot.acme.challenges.HTTP01Response`
+        :rtype: list
+
+        """
+        if not self.achalls:
+            return []
+
+        responses = [x.response(x.account_key) for x in self.achalls]
+
+        # Set up the configuration
+        self._mod_config()
+
+        # Save reversible changes
+        self.configurator.save("HTTP Challenge", True)
+
+        return responses
+
+    def _add_bucket_directive(self):
+        """Modifies Nginx config to include server_names_hash_bucket_size directive."""
+        root = self.configurator.parser.config_root
+
+        bucket_directive = ['\n', 'server_names_hash_bucket_size', ' ', '128']
+
+        main = self.configurator.parser.parsed[root]
+        for line in main:
+            if line[0] == ['http']:
+                body = line[1]
+                found_bucket = False
+                posn = 0
+                for inner_line in body:
+                    if inner_line[0] == bucket_directive[1]:
+                        if int(inner_line[1]) < int(bucket_directive[3]):
+                            body[posn] = bucket_directive
+                        found_bucket = True
+                    posn += 1
+                if not found_bucket:
+                    body.insert(0, bucket_directive)
+                break
+
+    def _mod_config(self):
+        """Modifies Nginx config to handle challenges.
+
+        """
+        self._add_bucket_directive()
+
+        for achall in self.achalls:
+            self._mod_server_block(achall)
+
+    def _get_validation_path(self, achall):
+        return os.sep + os.path.join(challenges.HTTP01.URI_ROOT_PATH, achall.chall.encode("token"))
+
+    def _mod_server_block(self, achall):
+        """Modifies a server block to respond to a challenge.
+
+        :param achall: Annotated HTTP-01 challenge
+        :type achall:
+            :class:`certbot.achallenges.KeyAuthorizationAnnotatedChallenge`
+
+        """
+        vhost = self.configurator.choose_redirect_vhost(achall.domain,
+            '%i' % self.configurator.config.http01_port, create_if_no_match=True)
+        validation = achall.validation(achall.account_key)
+        validation_path = self._get_validation_path(achall)
+
+        location_directive = [[['location', ' ', '=', ' ', validation_path],
+                               [['default_type', ' ', 'text/plain'],
+                                ['return', ' ', '200', ' ', validation]]]]
+
+        self.configurator.parser.add_server_directives(vhost,
+            location_directive, replace=False)
diff --git a/certbot-nginx/certbot_nginx/parser.py b/certbot-nginx/certbot_nginx/parser.py
--- a/certbot-nginx/certbot_nginx/parser.py
+++ b/certbot-nginx/certbot_nginx/parser.py
@@ -524,7 +524,7 @@ def _is_ssl_on_directive(entry):
 def _add_directives(directives, replace, block):
     """Adds or replaces directives in a config block.
 
-    When replace=False, it's an error to try and add a directive that already
+    When replace=False, it's an error to try and add a nonrepeatable directive that already
     exists in the config block with a conflicting value.
 
     When replace=True and a directive with the same name already exists in the
@@ -545,7 +545,7 @@ def _add_directives(directives, replace, block):
 
 
 INCLUDE = 'include'
-REPEATABLE_DIRECTIVES = set(['server_name', 'listen', INCLUDE])
+REPEATABLE_DIRECTIVES = set(['server_name', 'listen', INCLUDE, 'location'])
 COMMENT = ' managed by Certbot'
 COMMENT_BLOCK = [' ', '#', COMMENT]
 


Test patch:
diff --git a/certbot-nginx/certbot_nginx/tests/configurator_test.py b/certbot-nginx/certbot_nginx/tests/configurator_test.py
--- a/certbot-nginx/certbot_nginx/tests/configurator_test.py
+++ b/certbot-nginx/certbot_nginx/tests/configurator_test.py
@@ -100,7 +100,7 @@ def test_enhance(self):
             errors.PluginError, self.config.enhance, 'myhost', 'unknown_enhancement')
 
     def test_get_chall_pref(self):
-        self.assertEqual([challenges.TLSSNI01],
+        self.assertEqual([challenges.TLSSNI01, challenges.HTTP01],
                          self.config.get_chall_pref('myhost'))
 
     def test_save(self):
@@ -291,9 +291,11 @@ def test_deploy_cert_add_explicit_listen(self):
                          parsed_migration_conf[0])
 
     @mock.patch("certbot_nginx.configurator.tls_sni_01.NginxTlsSni01.perform")
+    @mock.patch("certbot_nginx.configurator.http_01.NginxHttp01.perform")
     @mock.patch("certbot_nginx.configurator.NginxConfigurator.restart")
     @mock.patch("certbot_nginx.configurator.NginxConfigurator.revert_challenge_config")
-    def test_perform_and_cleanup(self, mock_revert, mock_restart, mock_perform):
+    def test_perform_and_cleanup(self, mock_revert, mock_restart, mock_http_perform,
+        mock_tls_perform):
         # Only tests functionality specific to configurator.perform
         # Note: As more challenges are offered this will have to be expanded
         achall1 = achallenges.KeyAuthorizationAnnotatedChallenge(
@@ -304,7 +306,7 @@ def test_perform_and_cleanup(self, mock_revert, mock_restart, mock_perform):
             ), domain="localhost", account_key=self.rsa512jwk)
         achall2 = achallenges.KeyAuthorizationAnnotatedChallenge(
             challb=messages.ChallengeBody(
-                chall=challenges.TLSSNI01(token=b"m8TdO1qik4JVFtgPPurJmg"),
+                chall=challenges.HTTP01(token=b"m8TdO1qik4JVFtgPPurJmg"),
                 uri="https://ca.org/chall1_uri",
                 status=messages.Status("pending"),
             ), domain="example.com", account_key=self.rsa512jwk)
@@ -314,10 +316,12 @@ def test_perform_and_cleanup(self, mock_revert, mock_restart, mock_perform):
             achall2.response(self.rsa512jwk),
         ]
 
-        mock_perform.return_value = expected
+        mock_tls_perform.return_value = expected[:1]
+        mock_http_perform.return_value = expected[1:]
         responses = self.config.perform([achall1, achall2])
 
-        self.assertEqual(mock_perform.call_count, 1)
+        self.assertEqual(mock_tls_perform.call_count, 1)
+        self.assertEqual(mock_http_perform.call_count, 1)
         self.assertEqual(responses, expected)
 
         self.config.cleanup([achall1, achall2])
diff --git a/certbot-nginx/certbot_nginx/tests/http_01_test.py b/certbot-nginx/certbot_nginx/tests/http_01_test.py
new file mode 100644
--- /dev/null
+++ b/certbot-nginx/certbot_nginx/tests/http_01_test.py
@@ -0,0 +1,113 @@
+"""Tests for certbot_nginx.http_01"""
+import unittest
+import shutil
+
+import mock
+import six
+
+from acme import challenges
+
+from certbot import achallenges
+
+from certbot.plugins import common_test
+from certbot.tests import acme_util
+
+from certbot_nginx.tests import util
+
+
+class HttpPerformTest(util.NginxTest):
+    """Test the NginxHttp01 challenge."""
+
+    account_key = common_test.AUTH_KEY
+    achalls = [
+        achallenges.KeyAuthorizationAnnotatedChallenge(
+            challb=acme_util.chall_to_challb(
+                challenges.HTTP01(token=b"kNdwjwOeX0I_A8DXt9Msmg"), "pending"),
+            domain="www.example.com", account_key=account_key),
+        achallenges.KeyAuthorizationAnnotatedChallenge(
+            challb=acme_util.chall_to_challb(
+                challenges.HTTP01(
+                    token=b"\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y"
+                          b"\x80\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945"
+                ), "pending"),
+            domain="ipv6.com", account_key=account_key),
+        achallenges.KeyAuthorizationAnnotatedChallenge(
+            challb=acme_util.chall_to_challb(
+                challenges.HTTP01(
+                    token=b"\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd"
+                          b"\xeb9\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4"
+                ), "pending"),
+            domain="www.example.org", account_key=account_key),
+        achallenges.KeyAuthorizationAnnotatedChallenge(
+            challb=acme_util.chall_to_challb(
+                challenges.HTTP01(token=b"kNdwjxOeX0I_A8DXt9Msmg"), "pending"),
+            domain="migration.com", account_key=account_key),
+    ]
+
+    def setUp(self):
+        super(HttpPerformTest, self).setUp()
+
+        config = util.get_nginx_configurator(
+            self.config_path, self.config_dir, self.work_dir, self.logs_dir)
+
+        from certbot_nginx import http_01
+        self.http01 = http_01.NginxHttp01(config)
+
+    def tearDown(self):
+        shutil.rmtree(self.temp_dir)
+        shutil.rmtree(self.config_dir)
+        shutil.rmtree(self.work_dir)
+
+    def test_perform0(self):
+        responses = self.http01.perform()
+        self.assertEqual([], responses)
+
+    @mock.patch("certbot_nginx.configurator.NginxConfigurator.save")
+    def test_perform1(self, mock_save):
+        self.http01.add_chall(self.achalls[0])
+        response = self.achalls[0].response(self.account_key)
+
+        responses = self.http01.perform()
+
+        self.assertEqual([response], responses)
+        self.assertEqual(mock_save.call_count, 1)
+
+    def test_perform2(self):
+        acme_responses = []
+        for achall in self.achalls:
+            self.http01.add_chall(achall)
+            acme_responses.append(achall.response(self.account_key))
+
+        sni_responses = self.http01.perform()
+
+        self.assertEqual(len(sni_responses), 4)
+        for i in six.moves.range(4):
+            self.assertEqual(sni_responses[i], acme_responses[i])
+
+    def test_mod_config(self):
+        self.http01.add_chall(self.achalls[0])
+        self.http01.add_chall(self.achalls[2])
+
+        self.http01._mod_config()  # pylint: disable=protected-access
+
+        self.http01.configurator.save()
+
+        self.http01.configurator.parser.load()
+
+        # vhosts = self.http01.configurator.parser.get_vhosts()
+
+        # for vhost in vhosts:
+        #     pass
+            # if the name matches
+            # check that the location block is in there and is correct
+
+            # if vhost.addrs == set(v_addr1):
+            #     response = self.achalls[0].response(self.account_key)
+            # else:
+            #     response = self.achalls[2].response(self.account_key)
+            #     self.assertEqual(vhost.addrs, set(v_addr2_print))
+            # self.assertEqual(vhost.names, set([response.z_domain.decode('ascii')]))
+
+
+if __name__ == "__main__":
+    unittest.main()  # pragma: no cover
diff --git a/certbot-nginx/certbot_nginx/tests/util.py b/certbot-nginx/certbot_nginx/tests/util.py
--- a/certbot-nginx/certbot_nginx/tests/util.py
+++ b/certbot-nginx/certbot_nginx/tests/util.py
@@ -64,6 +64,7 @@ def get_nginx_configurator(
                     in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
                     server="https://acme-server.org:443/new",
                     tls_sni_01_port=5001,
+                    http01_port=80
                 ),
                 name="nginx",
                 version=version)
diff --git a/certbot-nginx/tests/boulder-integration.conf.sh b/certbot-nginx/tests/boulder-integration.conf.sh
--- a/certbot-nginx/tests/boulder-integration.conf.sh
+++ b/certbot-nginx/tests/boulder-integration.conf.sh
@@ -49,10 +49,10 @@ http {
 
   server {
     # IPv4.
-    listen 8081;
+    listen 5002;
     # IPv6.
-    listen [::]:8081 default ipv6only=on;
-    server_name nginx.wtf;
+    listen [::]:5002 default ipv6only=on;
+    server_name nginx.wtf nginx2.wtf;
 
     root $root/webroot;
 
diff --git a/certbot-nginx/tests/boulder-integration.sh b/certbot-nginx/tests/boulder-integration.sh
--- a/certbot-nginx/tests/boulder-integration.sh
+++ b/certbot-nginx/tests/boulder-integration.sh
@@ -22,13 +22,20 @@ certbot_test_nginx () {
         "$@"
 }
 
-certbot_test_nginx --domains nginx.wtf run
-echo | openssl s_client -connect localhost:5001 \
-    | openssl x509 -out $root/nginx.pem
-diff -q $root/nginx.pem $root/conf/live/nginx.wtf/cert.pem
+test_deployment_and_rollback() {
+    # Arguments: certname
+    echo | openssl s_client -connect localhost:5001 \
+        | openssl x509 -out $root/nginx.pem
+    diff -q $root/nginx.pem "$root/conf/live/$1/cert.pem"
+
+    certbot_test_nginx rollback --checkpoints 9001
+    diff -q <(echo "$original") $nginx_conf
+}
 
-certbot_test_nginx rollback --checkpoints 9001
-diff -q <(echo "$original") $nginx_conf
+certbot_test_nginx --domains nginx.wtf run
+test_deployment_and_rollback nginx.wtf
+certbot_test_nginx --domains nginx2.wtf --preferred-challenges http
+test_deployment_and_rollback nginx2.wtf
 
 # note: not reached if anything above fails, hence "killall" at the
 # top