Repository: certbot/certbot

Issue:
[nginx] Cert for two domains in one virtaulhost fails
When trying to obtain or renew a cert for two domains which is listed in `server_name` in one virtualhost the operation fails.
### Fails

**00-default.conf**
`server_name   _ example.com sub.example.com;`

```
# ./certbot-auto renew --dry-run
Saving debug log to /var/log/letsencrypt/letsencrypt.log

-------------------------------------------------------------------------------
Processing /etc/letsencrypt/renewal/example.com.conf
-------------------------------------------------------------------------------
Cert not due for renewal, but simulating renewal for dry run
Starting new HTTPS connection (1): acme-staging.api.letsencrypt.org
Renewing an existing certificate
Performing the following challenges:
tls-sni-01 challenge for example.com
tls-sni-01 challenge for sub.example.com
Cleaning up challenges
Attempting to renew cert from /etc/letsencrypt/renewal/example.com.conf produced an unexpected error: nginx restart failed:

nginx: [emerg] a duplicate default server for 0.0.0.0:443 in /etc/letsencrypt/le_tls_sni_01_cert_challenge.conf:36
. Skipping.
```
### Works

**00-default.conf**
`server_name   _ example.com;`

**sub.conf**
`server_name   sub.example.com;`
### Info

```
# ./certbot-auto --version
letsencrypt 0.9.3
# cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
```



Discussion:
cc @ohemorange 

As far as I know, it is impossible to have two certificates in a single server block in Nginx. Thus, if you want both example.com and sub.example.com to serve valid certificates, they need to be in two different server blocks, as it seems you have correctly set up above. This is working as expected, so I'm closing this issue. Thanks for your feedback, and please do let us know if you see follow-up bugs, or if this is not the problem you were experiencing.

@ohemorange It is the same cert, multi domain. Please reopen. It is definitely possible, I'm doing it right now..

@ohemorange, I think you misread the issue. They weren't trying to use two certificates in one server block, they were trying to obtain a single certificate for two domains.

With that said, I don't think that's what causes the problem. I was successfully able to obtain a certificate with two domains in one server block, however, I was also able to repo the problem by creating a server block for my desired domain which was also the default server for port 443. The exact server block I used was:

```
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    include snippets/snakeoil.conf;
    root /var/www/html;
    index index.html index.htm index.nginx-debian.html;
    server_name _ example.com;
    location / { 
        try_files $uri $uri/ =404;
    }   
}
```

What I believe happens is when we create `/etc/letsencrypt/le_tls_sni_01_cert_challenge.conf`, we simply copy the listen directive from the existing server block. The result is we have two server blocks with `listen 443 ssl default_server`, causing the error shown above.

We should add a test case for configurations like this to the compatibility test. After performing a simple grep, it doesn't look like we have one.


Resolving patch:
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
@@ -69,7 +69,8 @@ def fromstring(cls, str_addr):
 
         return cls(host, port, ssl, default)
 
-    def __str__(self):
+    def to_string(self, include_default=True):
+        """Return string representation of Addr"""
         parts = ''
         if self.tup[0] and self.tup[1]:
             parts = "%s:%s" % self.tup
@@ -78,13 +79,16 @@ def __str__(self):
         else:
             parts = self.tup[1]
 
-        if self.default:
+        if self.default and include_default:
             parts += ' default_server'
         if self.ssl:
             parts += ' ssl'
 
         return parts
 
+    def __str__(self):
+        return self.to_string()
+
     def __repr__(self):
         return "Addr(" + self.__str__() + ")"
 
diff --git a/certbot-nginx/certbot_nginx/tls_sni_01.py b/certbot-nginx/certbot_nginx/tls_sni_01.py
--- a/certbot-nginx/certbot_nginx/tls_sni_01.py
+++ b/certbot-nginx/certbot_nginx/tls_sni_01.py
@@ -47,7 +47,7 @@ def perform(self):
             return []
 
         addresses = []
-        default_addr = "{0} default_server ssl".format(
+        default_addr = "{0} ssl".format(
             self.configurator.config.tls_sni_01_port)
 
         for achall in self.achalls:
@@ -59,12 +59,10 @@ def perform(self):
                     achall.domain)
                 return None
 
-            for addr in vhost.addrs:
-                if addr.default:
-                    addresses.append([obj.Addr.fromstring(default_addr)])
-                    break
-            else:
+            if vhost.addrs:
                 addresses.append(list(vhost.addrs))
+            else:
+                addresses.append([obj.Addr.fromstring(default_addr)])
 
         # Create challenge certs
         responses = [self._setup_challenge_cert(x) for x in self.achalls]
@@ -141,7 +139,7 @@ def _make_server_block(self, achall, addrs):
         document_root = os.path.join(
             self.configurator.config.work_dir, "tls_sni_01_page")
 
-        block = [['listen', ' ', str(addr)] for addr in addrs]
+        block = [['listen', ' ', addr.to_string(include_default=False)] for addr in addrs]
 
         block.extend([['server_name', ' ',
                        achall.response(achall.account_key).z_domain],
@@ -155,5 +153,4 @@ def _make_server_block(self, achall, addrs):
                       ['ssl_certificate_key', ' ', self.get_key_path(achall)],
                       [['location', ' ', '/'], [['root', ' ', document_root]]]] +
                      self.configurator.parser.loc["ssl_options"])
-
         return [['server'], 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
@@ -40,7 +40,7 @@ def test_prepare_no_install(self, mock_exe_exists):
 
     def test_prepare(self):
         self.assertEqual((1, 6, 2), self.config.version)
-        self.assertEqual(6, len(self.config.parser.parsed))
+        self.assertEqual(7, len(self.config.parser.parsed))
         # ensure we successfully parsed a file for ssl_options
         self.assertTrue(self.config.parser.loc["ssl_options"])
 
@@ -68,7 +68,7 @@ def test_get_all_names(self, mock_gethostbyaddr):
         names = self.config.get_all_names()
         self.assertEqual(names, set(
             ["155.225.50.69.nephoscale.net", "www.example.org", "another.alias",
-             "migration.com", "summer.com", "geese.com"]))
+             "migration.com", "summer.com", "geese.com", "sslon.com"]))
 
     def test_supported_enhancements(self):
         self.assertEqual(['redirect', 'staple-ocsp'],
@@ -242,6 +242,7 @@ def test_get_all_certs_keys(self):
         nginx_conf = self.config.parser.abs_path('nginx.conf')
         example_conf = self.config.parser.abs_path('sites-enabled/example.com')
         migration_conf = self.config.parser.abs_path('sites-enabled/migration.com')
+        sslon_conf = self.config.parser.abs_path('sites-enabled/sslon.com')
 
         # Get the default SSL vhost
         self.config.deploy_cert(
@@ -269,6 +270,7 @@ def test_get_all_certs_keys(self):
             ('example/fullchain.pem', 'example/key.pem', example_conf),
             ('/etc/nginx/fullchain.pem', '/etc/nginx/key.pem', nginx_conf),
             ('migration/fullchain.pem', 'migration/key.pem', migration_conf),
+            ('snakeoil.cert', 'snakeoil.key', sslon_conf),
         ]), self.config.get_all_certs_keys())
 
     @mock.patch("certbot_nginx.configurator.tls_sni_01.NginxTlsSni01.perform")
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
@@ -55,6 +55,16 @@ def test_str(self):
         self.assertEqual(str(self.addr5), "myhost")
         self.assertEqual(str(self.addr6), "80 default_server")
 
+    def test_to_string(self):
+        self.assertEqual(self.addr1.to_string(), "192.168.1.1")
+        self.assertEqual(self.addr2.to_string(), "192.168.1.1:* ssl")
+        self.assertEqual(self.addr3.to_string(), "192.168.1.1:80")
+        self.assertEqual(self.addr4.to_string(), "*:80 default_server ssl")
+        self.assertEqual(self.addr4.to_string(include_default=False), "*:80 ssl")
+        self.assertEqual(self.addr5.to_string(), "myhost")
+        self.assertEqual(self.addr6.to_string(), "80 default_server")
+        self.assertEqual(self.addr6.to_string(include_default=False), "80")
+
     def test_eq(self):
         from certbot_nginx.obj import Addr
         new_addr1 = Addr.fromstring("192.168.1.1 spdy")
diff --git a/certbot-nginx/certbot_nginx/tests/parser_test.py b/certbot-nginx/certbot_nginx/tests/parser_test.py
--- a/certbot-nginx/certbot_nginx/tests/parser_test.py
+++ b/certbot-nginx/certbot_nginx/tests/parser_test.py
@@ -48,7 +48,8 @@ def test_load(self):
                               ['foo.conf', 'nginx.conf', 'server.conf',
                                'sites-enabled/default',
                                'sites-enabled/example.com',
-                               'sites-enabled/migration.com']]),
+                               'sites-enabled/migration.com',
+                               'sites-enabled/sslon.com']]),
                          set(nparser.parsed.keys()))
         self.assertEqual([['server_name', 'somename  alias  another.alias']],
                          nparser.parsed[nparser.abs_path('server.conf')])
@@ -72,7 +73,7 @@ def test_filedump(self):
         parsed = nparser._parse_files(nparser.abs_path(
             'sites-enabled/example.com.test'))
         self.assertEqual(3, len(glob.glob(nparser.abs_path('*.test'))))
-        self.assertEqual(3, len(
+        self.assertEqual(4, len(
             glob.glob(nparser.abs_path('sites-enabled/*.test'))))
         self.assertEqual([[['server'], [['listen', '69.50.225.155:9000'],
                                         ['listen', '127.0.0.1'],
@@ -136,7 +137,7 @@ def test_get_vhosts(self):
                                                   '*.www.example.com']),
                                  [], [2, 1, 0])
 
-        self.assertEqual(7, len(vhosts))
+        self.assertEqual(8, len(vhosts))
         example_com = [x for x in vhosts if 'example.com' in x.filep][0]
         self.assertEqual(vhost3, example_com)
         default = [x for x in vhosts if 'default' in x.filep][0]
@@ -304,8 +305,10 @@ def test_get_all_certs_keys(self):
                                       replace=False)
         c_k = nparser.get_all_certs_keys()
         migration_file = nparser.abs_path('sites-enabled/migration.com')
+        sslon_file = nparser.abs_path('sites-enabled/sslon.com')
         self.assertEqual(set([('foo.pem', 'bar.key', filep),
-                              ('cert.pem', 'cert.key', migration_file)
+                              ('cert.pem', 'cert.key', migration_file),
+                              ('snakeoil.cert', 'snakeoil.key', sslon_file)
                              ]), c_k)
 
     def test_parse_server_ssl(self):
diff --git a/certbot-nginx/certbot_nginx/tests/testdata/etc_nginx/sites-enabled/sslon.com b/certbot-nginx/certbot_nginx/tests/testdata/etc_nginx/sites-enabled/sslon.com
new file mode 100644
--- /dev/null
+++ b/certbot-nginx/certbot_nginx/tests/testdata/etc_nginx/sites-enabled/sslon.com
@@ -0,0 +1,6 @@
+server {
+	server_name sslon.com;
+	ssl on;
+	ssl_certificate snakeoil.cert;
+	ssl_certificate_key snakeoil.key;
+}
diff --git a/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py b/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py
--- a/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py
+++ b/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py
@@ -39,6 +39,10 @@ class TlsSniPerformTest(util.NginxTest):
                           "\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.TLSSNI01(token="kNdwjxOeX0I_A8DXt9Msmg"), "pending"),
+            domain="sslon.com", account_key=account_key),
     ]
 
     def setUp(self):
@@ -100,7 +104,7 @@ def test_perform2(self):
 
         sni_responses = self.sni.perform()
 
-        self.assertEqual(mock_setup_cert.call_count, 3)
+        self.assertEqual(mock_setup_cert.call_count, 4)
 
         for index, achall in enumerate(self.achalls):
             self.assertEqual(
@@ -112,8 +116,8 @@ def test_perform2(self):
         self.assertFalse(
             util.contains_at_depth(http, ['server_name', 'another.alias'], 3))
 
-        self.assertEqual(len(sni_responses), 3)
-        for i in xrange(3):
+        self.assertEqual(len(sni_responses), 4)
+        for i in xrange(4):
             self.assertEqual(sni_responses[i], acme_responses[i])
 
     def test_mod_config(self):
@@ -123,6 +127,7 @@ def test_mod_config(self):
         v_addr1 = [obj.Addr("69.50.225.155", "9000", True, False),
                    obj.Addr("127.0.0.1", "", False, False)]
         v_addr2 = [obj.Addr("myhost", "", False, True)]
+        v_addr2_print = [obj.Addr("myhost", "", False, False)]
         ll_addr = [v_addr1, v_addr2]
         self.sni._mod_config(ll_addr)  # pylint: disable=protected-access
 
@@ -142,7 +147,7 @@ def test_mod_config(self):
                 response = self.achalls[0].response(self.account_key)
             else:
                 response = self.achalls[2].response(self.account_key)
-                self.assertEqual(vhost.addrs, set(v_addr2))
+                self.assertEqual(vhost.addrs, set(v_addr2_print))
             self.assertEqual(vhost.names, set([response.z_domain]))
 
         self.assertEqual(len(vhs), 2)