Repository: certbot/certbot

Issue:
Don't re-append Nginx redirect directive
When the user selects Reinstall > Secure and there's already a certbot-managed redirect directive, we just append another one anyway. Don't do that.


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
@@ -29,6 +29,36 @@
 
 logger = logging.getLogger(__name__)
 
+REDIRECT_BLOCK = [[
+    ['\n    ', 'if', ' ', '($scheme != "https") '],
+    [['\n        ', 'return', ' ', '301 https://$host$request_uri'],
+     '\n    ']
+], ['\n']]
+
+TEST_REDIRECT_BLOCK = [
+    [
+        ['if', '($scheme != "https")'],
+        [
+            ['return', '301 https://$host$request_uri']
+        ]
+    ],
+    ['#', ' managed by Certbot']
+]
+
+REDIRECT_COMMENT_BLOCK = [
+    ['\n    ', '#', ' Redirect non-https traffic to https'],
+    ['\n    ', '#', ' if ($scheme != "https") {'],
+    ['\n    ', '#', "     return 301 https://$host$request_uri;"],
+    ['\n    ', '#', " } # managed by Certbot"],
+    ['\n']
+]
+
+TEST_REDIRECT_COMMENT_BLOCK = [
+    ['#', ' Redirect non-https traffic to https'],
+    ['#', ' if ($scheme != "https") {'],
+    ['#', "     return 301 https://$host$request_uri;"],
+    ['#', " } # managed by Certbot"],
+]
 
 @zope.interface.implementer(interfaces.IAuthenticator, interfaces.IInstaller)
 @zope.interface.provider(interfaces.IPluginFactory)
@@ -482,6 +512,23 @@ def enhance(self, domain, enhancement, options=None):
             logger.warning("Failed %s for %s", enhancement, domain)
             raise
 
+    def _has_certbot_redirect(self, vhost):
+        return vhost.contains_list(TEST_REDIRECT_BLOCK)
+
+    def _has_certbot_redirect_comment(self, vhost):
+        return vhost.contains_list(TEST_REDIRECT_COMMENT_BLOCK)
+
+    def _add_redirect_block(self, vhost, active=True):
+        """Add redirect directive to vhost
+        """
+        if active:
+            redirect_block = REDIRECT_BLOCK
+        else:
+            redirect_block = REDIRECT_COMMENT_BLOCK
+
+        self.parser.add_server_directives(
+            vhost, redirect_block, replace=False)
+
     def _enable_redirect(self, domain, unused_options):
         """Redirect all equivalent HTTP traffic to ssl_vhost.
 
@@ -504,17 +551,20 @@ def _enable_redirect(self, domain, unused_options):
             logger.info("No matching insecure server blocks listening on port %s found.",
                 self.DEFAULT_LISTEN_PORT)
         else:
-            # Redirect plaintextish host to https
-            redirect_block = [[
-                ['\n    ', 'if', ' ', '($scheme != "https") '],
-                [['\n        ', 'return', ' ', '301 https://$host$request_uri'],
-                 '\n    ']
-            ], ['\n']]
-
-            self.parser.add_server_directives(
-                vhost, redirect_block, replace=False)
-            logger.info("Redirecting all traffic on port %s to ssl in %s",
-                self.DEFAULT_LISTEN_PORT, vhost.filep)
+            if self._has_certbot_redirect(vhost):
+                logger.info("Traffic on port %s already redirecting to ssl in %s",
+                    self.DEFAULT_LISTEN_PORT, vhost.filep)
+            elif vhost.has_redirect():
+                if not self._has_certbot_redirect_comment(vhost):
+                    self._add_redirect_block(vhost, active=False)
+                logger.info("The appropriate server block is already redirecting "
+                            "traffic. To enable redirect anyway, uncomment the "
+                            "redirect lines in %s.", vhost.filep)
+            else:
+                # Redirect plaintextish host to https
+                self._add_redirect_block(vhost, active=True)
+                logger.info("Redirecting all traffic on port %s to ssl in %s",
+                    self.DEFAULT_LISTEN_PORT, vhost.filep)
 
     def _enable_ocsp_stapling(self, domain, chain_path):
         """Include OCSP response in TLS handshake
diff --git a/certbot-nginx/certbot_nginx/obj.py b/certbot-nginx/certbot_nginx/obj.py
--- a/certbot-nginx/certbot_nginx/obj.py
+++ b/certbot-nginx/certbot_nginx/obj.py
@@ -3,6 +3,7 @@
 
 from certbot.plugins import common
 
+REDIRECT_DIRECTIVES = ['return', 'rewrite']
 
 class Addr(common.Addr):
     r"""Represents an Nginx address, i.e. what comes after the 'listen'
@@ -149,3 +150,32 @@ def __eq__(self, other):
                     self.path == other.path)
 
         return False
+
+    def has_redirect(self):
+        """Determine if this vhost has a redirecting statement
+        """
+        for directive_name in REDIRECT_DIRECTIVES:
+            found = _find_directive(self.raw, directive_name)
+            if found is not None:
+                return True
+        return False
+
+    def contains_list(self, test):
+        """Determine if raw server block contains test list at top level
+        """
+        for i in xrange(0, len(self.raw) - len(test)):
+            if self.raw[i:i + len(test)] == test:
+                return True
+        return False
+
+def _find_directive(directives, directive_name):
+    """Find a directive of type directive_name in directives
+    """
+    if not directives or isinstance(directives, str) or len(directives) == 0:
+        return None
+
+    if directives[0] == directive_name:
+        return directives
+
+    matches = (_find_directive(line, directive_name) for line in directives)
+    return next((m for m in matches if m is not None), None)
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
@@ -496,7 +496,6 @@ def parse_server(server):
 
     return parsed_server
 
-
 def _add_directives(block, directives, replace):
     """Adds or replaces directives in a config block.
 


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
@@ -445,6 +445,64 @@ def test_redirect_enhance(self):
         generated_conf = self.config.parser.parsed[migration_conf]
         self.assertTrue(util.contains_at_depth(generated_conf, expected, 2))
 
+    @mock.patch('certbot_nginx.obj.VirtualHost.contains_list')
+    @mock.patch('certbot_nginx.obj.VirtualHost.has_redirect')
+    def test_certbot_redirect_exists(self, mock_has_redirect, mock_contains_list):
+        # Test that we add no redirect statement if there is already a
+        # redirect in the block that is managed by certbot
+        # Has a certbot redirect
+        mock_has_redirect.return_value = True
+        mock_contains_list.return_value = True
+        with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+            self.config.enhance("www.example.com", "redirect")
+            self.assertEqual(mock_logger.info.call_args[0][0],
+                "Traffic on port %s already redirecting to ssl in %s")
+
+    @mock.patch('certbot_nginx.obj.VirtualHost.contains_list')
+    @mock.patch('certbot_nginx.obj.VirtualHost.has_redirect')
+    def test_non_certbot_redirect_exists(self, mock_has_redirect, mock_contains_list):
+        # Test that we add a redirect as a comment if there is already a
+        # redirect-class statement in the block that isn't managed by certbot
+        example_conf = self.config.parser.abs_path('sites-enabled/example.com')
+
+        # Has a non-Certbot redirect, and has no existing comment
+        mock_contains_list.return_value = False
+        mock_has_redirect.return_value = True
+        with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+            self.config.enhance("www.example.com", "redirect")
+            self.assertEqual(mock_logger.info.call_args[0][0],
+                "The appropriate server block is already redirecting "
+                "traffic. To enable redirect anyway, uncomment the "
+                "redirect lines in %s.")
+            generated_conf = self.config.parser.parsed[example_conf]
+            expected = [
+                ['#', ' Redirect non-https traffic to https'],
+                ['#', ' if ($scheme != "https") {'],
+                ['#', '     return 301 https://$host$request_uri;'],
+                ['#', ' } # managed by Certbot']
+            ]
+            for line in expected:
+                self.assertTrue(util.contains_at_depth(generated_conf, line, 2))
+
+    @mock.patch('certbot_nginx.obj.VirtualHost.contains_list')
+    @mock.patch('certbot_nginx.obj.VirtualHost.has_redirect')
+    @mock.patch('certbot_nginx.configurator.NginxConfigurator._has_certbot_redirect_comment')
+    @mock.patch('certbot_nginx.configurator.NginxConfigurator._add_redirect_block')
+    def test_redirect_comment_exists(self, mock_add_redirect_block,
+        mock_has_cb_redirect_comment, mock_has_redirect, mock_contains_list):
+        # Test that we add nothing if there is a non-Certbot redirect and a
+        # preexisting comment
+        # Has a non-Certbot redirect and a comment
+        mock_has_redirect.return_value = True
+        mock_contains_list.return_value = False # self._has_certbot_redirect(vhost):
+        mock_has_cb_redirect_comment.return_value = True
+
+        # assert _add_redirect_block not called
+        with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+            self.config.enhance("www.example.com", "redirect")
+            self.assertFalse(mock_add_redirect_block.called)
+            self.assertTrue(mock_logger.info.called)
+
     def test_redirect_dont_enhance(self):
         # Test that we don't accidentally add redirect to ssl-only block
         with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
diff --git a/certbot-nginx/certbot_nginx/tests/obj_test.py b/certbot-nginx/certbot_nginx/tests/obj_test.py
--- a/certbot-nginx/certbot_nginx/tests/obj_test.py
+++ b/certbot-nginx/certbot_nginx/tests/obj_test.py
@@ -87,10 +87,43 @@ class VirtualHostTest(unittest.TestCase):
     def setUp(self):
         from certbot_nginx.obj import VirtualHost
         from certbot_nginx.obj import Addr
+        raw1 = [
+            ['listen', '69.50.225.155:9000'],
+            [['if', '($scheme != "https") '],
+                [['return', '301 https://$host$request_uri']]
+            ],
+            ['#', ' managed by Certbot']
+        ]
         self.vhost1 = VirtualHost(
             "filep",
             set([Addr.fromstring("localhost")]), False, False,
-            set(['localhost']), [], [])
+            set(['localhost']), raw1, [])
+        raw2 = [
+            ['listen', '69.50.225.155:9000'],
+            [['if', '($scheme != "https") '],
+                [['return', '301 https://$host$request_uri']]
+            ]
+        ]
+        self.vhost2 = VirtualHost(
+            "filep",
+            set([Addr.fromstring("localhost")]), False, False,
+            set(['localhost']), raw2, [])
+        raw3 = [
+            ['listen', '69.50.225.155:9000'],
+            ['rewrite', '^(.*)$ $scheme://www.domain.com$1 permanent;']
+        ]
+        self.vhost3 = VirtualHost(
+            "filep",
+            set([Addr.fromstring("localhost")]), False, False,
+            set(['localhost']), raw3, [])
+        raw4 = [
+            ['listen', '69.50.225.155:9000'],
+            ['server_name', 'return.com']
+        ]
+        self.vhost4 = VirtualHost(
+            "filp",
+            set([Addr.fromstring("localhost")]), False, False,
+            set(['localhost']), raw4, [])
 
     def test_eq(self):
         from certbot_nginx.obj import Addr
@@ -110,6 +143,47 @@ def test_str(self):
                                  'enabled: False'])
         self.assertEqual(stringified, str(self.vhost1))
 
+    def test_has_redirect(self):
+        self.assertTrue(self.vhost1.has_redirect())
+        self.assertTrue(self.vhost2.has_redirect())
+        self.assertTrue(self.vhost3.has_redirect())
+        self.assertFalse(self.vhost4.has_redirect())
+
+    def test_contains_list(self):
+        from certbot_nginx.obj import VirtualHost
+        from certbot_nginx.obj import Addr
+        from certbot_nginx.configurator import TEST_REDIRECT_BLOCK
+        test_needle = TEST_REDIRECT_BLOCK
+        test_haystack = [['listen', '80'], ['root', '/var/www/html'],
+            ['index', 'index.html index.htm index.nginx-debian.html'],
+            ['server_name', 'two.functorkitten.xyz'], ['listen', '443 ssl'],
+            ['#', ' managed by Certbot'],
+            ['ssl_certificate', '/etc/letsencrypt/live/two.functorkitten.xyz/fullchain.pem'],
+            ['#', ' managed by Certbot'],
+            ['ssl_certificate_key', '/etc/letsencrypt/live/two.functorkitten.xyz/privkey.pem'],
+            ['#', ' managed by Certbot'],
+            [['if', '($scheme != "https")'], [['return', '301 https://$host$request_uri']]],
+            ['#', ' managed by Certbot'], []]
+        vhost_haystack = VirtualHost(
+            "filp",
+            set([Addr.fromstring("localhost")]), False, False,
+            set(['localhost']), test_haystack, [])
+        test_bad_haystack = [['listen', '80'], ['root', '/var/www/html'],
+            ['index', 'index.html index.htm index.nginx-debian.html'],
+            ['server_name', 'two.functorkitten.xyz'], ['listen', '443 ssl'],
+            ['#', ' managed by Certbot'],
+            ['ssl_certificate', '/etc/letsencrypt/live/two.functorkitten.xyz/fullchain.pem'],
+            ['#', ' managed by Certbot'],
+            ['ssl_certificate_key', '/etc/letsencrypt/live/two.functorkitten.xyz/privkey.pem'],
+            ['#', ' managed by Certbot'],
+            [['if', '($scheme != "https")'], [['return', '302 https://$host$request_uri']]],
+            ['#', ' managed by Certbot'], []]
+        vhost_bad_haystack = VirtualHost(
+            "filp",
+            set([Addr.fromstring("localhost")]), False, False,
+            set(['localhost']), test_bad_haystack, [])
+        self.assertTrue(vhost_haystack.contains_list(test_needle))
+        self.assertFalse(vhost_bad_haystack.contains_list(test_needle))
 
 if __name__ == "__main__":
     unittest.main()  # pragma: no cover