Repository: certbot/certbot

Issue:
trouble with Listen directives in CentOS 7 / ssl.conf
On a CentOS7 box, I had Listen directives in /etc/httpd/conf.d/ssl.conf binding to a specific IP and port 443:

```
Listen 195.8.117.80:443 https
Listen [2001:67c:1388:1001::6]:443 https
```

and other services running on other IP addresses bound to port 443.

Running letsencrypt-auto I was getting this in the httpd error_log:

```
(98)Address already in use: AH00072: make_sock: could not bind to address [::]:443
(98)Address already in use: AH00072: make_sock: could not bind to address 0.0.0.0:443
```

I stopped the other services temporarily but letsencrypt-auto still failed.

I commented the Listen lines and then the letsencrypt-auto command worked.

After running it, I noticed it had created new Listen directives at the bottom of /etc/httpd/conf/httpd.conf:

```
<IfModule mod_ssl.c>
Listen [2001:67c:1388:1001::6]:443
Listen 195.8.117.80:443
</IfModule>
```

so I didn't uncomment the Listen directives in /etc/httpd/conf.d/ssl.conf



Discussion:
This seems like a recurrence of #1994, though it's possible that the circumstances are different.

@dpocock, did you by any chance have two vhosts in a single configuration file here? Would you be able to post your entire configuration?

PS -- Hi Daniel! Thanks for the helpful bug report :)

This box runs a public demo of WebRTC for the Fedora community, let me know if you want anything else from the config as it is generally meant to be there for sharing.  It is a vanilla install of CentOS7 + EPEL7 with the reSIProcate packages (repro and reTurnServer also use port 443)

I cloned letsencrypt under /opt, last commit 248dc0962e676382740002ae3743e518d7870345

```
# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN 
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
    link/ether a6:ff:d9:19:f9:9e brd ff:ff:ff:ff:ff:ff
    inet 195.8.117.80/27 brd 195.8.117.95 scope global eth0
       valid_lft forever preferred_lft forever
    inet 195.8.117.81/27 brd 195.8.117.95 scope global secondary eth0
       valid_lft forever preferred_lft forever
    inet 195.8.117.82/27 brd 195.8.117.95 scope global secondary eth0
       valid_lft forever preferred_lft forever
    inet6 2001:67c:1388:1001::7/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 2001:67c:1388:1001::6/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::a4ff:d9ff:fe19:f99e/64 scope link 
       valid_lft forever preferred_lft forever

# netstat -nlp | grep 443
tcp        0      0 195.8.117.80:8443       0.0.0.0:*               LISTEN      6654/repro          
tcp        0      0 195.8.117.80:443        0.0.0.0:*               LISTEN      6463/httpd          
tcp        0      0 195.8.117.82:443        0.0.0.0:*               LISTEN      6408/reTurnServer   
tcp6       0      0 2001:67c:1388:1001:8443 :::*                    LISTEN      6654/repro          
tcp6       0      0 2001:67c:1388:1001::443 :::*                    LISTEN      6463/httpd          
tcp6       0      0 2001:67c:1388:1001::443 :::*                    LISTEN      6408/reTurnServer   

```

Here is `/etc/httpd/conf.d/fedrtc.org.conf`

```
<VirtualHost *:80>
    ServerName fedrtc.org
    DocumentRoot /var/www/fedrtc.org
    Redirect 301 / https://fedrtc.org/
    ErrorLog "logs/fedrtc.org/http_error_log"
    CustomLog "logs/fedrtc.org/http_access_log" combined
</VirtualHost>

<IfModule mod_ssl.c>
<VirtualHost *:443>
    SSLEngine on
    ServerName fedrtc.org
    DocumentRoot /var/www/fedrtc.org
    SSLCertificateFile /etc/letsencrypt/live/fedrtc.org/cert.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/fedrtc.org/privkey.pem
    ErrorLog "logs/fedrtc.org/https_error_log"
    CustomLog "logs/fedrtc.org/https_access_log" combined
    Header always add Strict-Transport-Security "max-age=15768000; includeSubDomains; preload"
    SSLCertificateChainFile /etc/letsencrypt/live/fedrtc.org/chain.pem
</VirtualHost>
</IfModule>

```

First of all, @dpocock thanks for a very detailed report! 

I poked around this, and it's actually not related to CentOS, but the way we use to evaluate if we need to add a Listen statement to configuration. Currently, if no global listen statement for port 443 is found in the configuration, the plugin attempts to add one. This of course will conflict with the one for specific IP already defined in the configuration.

I think we should trust the users ability to resolve the case in this issue, and refrain from adding a global Listen directive to Apache configuration, while possibly printing a line to error log about this issue. This is also relevant for people running something on port 443 on specific IP addresses on the host already. The con in this resolution would be inability to activate SSL on an additional IP not already running SSL on the host, however also this would need some work around it.

I will bring this issue up in tonights client discussion, and will return to it shortly. The fix itself is pretty easy after we figure out what we would like to do.


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
@@ -625,50 +625,93 @@ def prepare_server_https(self, port, temp=False):
 
         """
 
+        # If nonstandard port, add service definition for matching
+        if 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"]
-        if port in listens:
+
+        # Listen already in place
+        if self._has_port_already(listens, port):
             return
+
+        listen_dirs = set(listens)
+
         for listen in listens:
             # For any listen statement, check if the machine also listens on
             # Port 443. If not, add such a listen statement.
             if len(listen.split(":")) == 1:
                 # Its listening to all interfaces
-                if port not in listens:
-                    if port == "443":
-                        args = [port]
-                    else:
-                        # Non-standard ports should specify https protocol
-                        args = [port, "https"]
-                    self.parser.add_dir_to_ifmodssl(
-                        parser.get_aug_path(
-                            self.parser.loc["listen"]), "Listen", args)
-                    self.save_notes += "Added Listen %s directive to %s\n" % (
-                        port, self.parser.loc["listen"])
-                    listens.append(port)
+                if port not in listen_dirs and port_service not in listen_dirs:
+                    listen_dirs.add(port_service)
             else:
                 # The Listen statement specifies an ip
                 _, ip = listen[::-1].split(":", 1)
                 ip = ip[::-1]
-                if "%s:%s" % (ip, port) not in listens:
-                    if port == "443":
-                        args = ["%s:%s" % (ip, port)]
-                    else:
-                        # Non-standard ports should specify https protocol
-                        args = ["%s:%s" % (ip, port), "https"]
-                    self.parser.add_dir_to_ifmodssl(
-                        parser.get_aug_path(
-                            self.parser.loc["listen"]), "Listen", args)
-                    self.save_notes += ("Added Listen %s:%s directive to "
-                                        "%s\n") % (ip, port,
-                                                   self.parser.loc["listen"])
-                    listens.append("%s:%s" % (ip, port))
+                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)
+
+    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
+        """
+
+        # Add service definition for non-standard ports
+        if port != "443":
+            port_service = "%s %s" % (port, "https")
+        else:
+            port_service = port
+
+        new_listens = listens.difference(listens_orig)
+
+        if port in new_listens or port_service in new_listens:
+            # We have wildcard, skip the rest
+            self.parser.add_dir_to_ifmodssl(
+                parser.get_aug_path(self.parser.loc["listen"]),
+                "Listen", port_service.split(" "))
+            self.save_notes += "Added Listen %s directive to %s\n" % (
+                port_service, self.parser.loc["listen"])
+        else:
+            for listen in new_listens:
+                self.parser.add_dir_to_ifmodssl(
+                    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 _has_port_already(self, listens, port):
+        """Helper method for prepare_server_https to find out if user
+        already has an active Listen statement for the port we need
+
+        :param list listens: List of listen variables
+        :param string port: Port in question
+        """
+
+        if port in listens:
+            return True
+        # Check if Apache is already listening on a specific IP
+        for listen in listens:
+            if len(listen.split(":")) > 1:
+                # Ugly but takes care of protocol def, eg: 1.1.1.1:443 https
+                if listen.split(":")[-1].split(" ")[0] == port:
+                    return True
 
     def prepare_https_modules(self, temp):
         """Helper method for prepare_server_https, taking care of enabling


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
@@ -497,13 +497,8 @@ def test_prepare_server_https_named_listen(self):
 
         # Test Listen statements with specific ip listeed
         self.config.prepare_server_https("443")
-        # Should only be 2 here, as the third interface
-        # already listens to the correct port
-        self.assertEqual(mock_add_dir.call_count, 2)
-
-        # Check argument to new Listen statements
-        self.assertEqual(mock_add_dir.call_args_list[0][0][2], ["1.2.3.4:443"])
-        self.assertEqual(mock_add_dir.call_args_list[1][0][2], ["[::1]:443"])
+        # Should be 0 as one interface already listens to 443
+        self.assertEqual(mock_add_dir.call_count, 0)
 
         # Reset return lists and inputs
         mock_add_dir.reset_mock()
@@ -519,6 +514,28 @@ def test_prepare_server_https_named_listen(self):
         self.assertEqual(mock_add_dir.call_args_list[2][0][2],
                          ["1.1.1.1:8080", "https"])
 
+        # mock_get.side_effect = ["1.2.3.4:80", "[::1]:80"]
+        # mock_find.return_value = ["test1", "test2", "test3"]
+        # self.config.parser.get_arg = mock_get
+        # self.config.prepare_server_https("8080", temp=True)
+        # self.assertEqual(self.listens, 0)
+
+    def test_prepare_server_https_needed_listen(self):
+        mock_find = mock.Mock()
+        mock_find.return_value = ["test1", "test2"]
+        mock_get = mock.Mock()
+        mock_get.side_effect = ["1.2.3.4:8080", "80"]
+        mock_add_dir = mock.Mock()
+        mock_enable = mock.Mock()
+
+        self.config.parser.find_dir = mock_find
+        self.config.parser.get_arg = mock_get
+        self.config.parser.add_dir_to_ifmodssl = mock_add_dir
+        self.config.enable_mod = mock_enable
+
+        self.config.prepare_server_https("443")
+        self.assertEqual(mock_add_dir.call_count, 1)
+
     def test_prepare_server_https_mixed_listen(self):
 
         mock_find = mock.Mock()