diff --git a/letsencrypt/client/plugins/nginx/configurator.py b/letsencrypt/client/plugins/nginx/configurator.py index 49d5a6dd0..e4cc05d40 100644 --- a/letsencrypt/client/plugins/nginx/configurator.py +++ b/letsencrypt/client/plugins/nginx/configurator.py @@ -23,6 +23,7 @@ from letsencrypt.client.plugins import common from letsencrypt.client.plugins.nginx import constants from letsencrypt.client.plugins.nginx import dvsni from letsencrypt.client.plugins.nginx import parser +from letsencrypt.client.plugins.nginx import obj class NginxConfigurator(common.Plugin): @@ -173,8 +174,12 @@ class NginxConfigurator(common.Plugin): matches = self._get_ranked_matches(target_name) if not matches: - # No matches at all :'( - pass + # No matches. Create a new vhost with this name in nginx.conf. + filep = self.parser.loc["root"] + new_block = [['server'], [['server_name', target_name]]] + self.parser.add_http_directives(filep, new_block) + vhost = obj.VirtualHost(filep, set([]), False, True, + set([target_name]), list(new_block[1])) elif matches[0]['rank'] in xrange(2, 6): # Wildcard match - need to find the longest one rank = matches[0]['rank'] @@ -185,7 +190,7 @@ class NginxConfigurator(common.Plugin): if vhost is not None: if not vhost.ssl: - self._make_server_ssl(vhost.filep, vhost.names) + self._make_server_ssl(vhost) return vhost @@ -260,23 +265,28 @@ class NginxConfigurator(common.Plugin): return all_names - def _make_server_ssl(self, filename, names): + def _make_server_ssl(self, vhost): """Makes a server SSL based on server_name and filename by adding a 'listen 443 ssl' directive to the server block. .. todo:: Maybe this should create a new block instead of modifying the existing one? - :param str filename: The absolute filename of the config file. - :param set names: The server names of the block to add SSL in + :param vhost: The vhost to add SSL to. + :type vhost: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` """ + ssl_block = [['listen', '443 ssl'], + ['ssl_certificate', + '/etc/ssl/certs/ssl-cert-snakeoil.pem'], + ['ssl_certificate_key', + '/etc/ssl/private/ssl-cert-snakeoil.key'], + ['include', self.parser.loc["ssl_options"]]] self.parser.add_server_directives( - filename, names, - [['listen', '443 ssl'], - ['ssl_certificate', '/etc/ssl/certs/ssl-cert-snakeoil.pem'], - ['ssl_certificate_key', '/etc/ssl/private/ssl-cert-snakeoil.key'], - ['include', self.parser.loc["ssl_options"]]]) + vhost.filep, vhost.names, ssl_block) + vhost.ssl = True + vhost.raw.extend(ssl_block) + vhost.addrs.add(obj.Addr('', '443', True, False)) def get_all_certs_keys(self): """Find all existing keys, certs from configuration. @@ -414,10 +424,11 @@ class NginxConfigurator(common.Plugin): nginx_version = tuple([int(i) for i in version_matches[0].split(".")]) - # nginx < 0.8.21 doesn't use default_server - if nginx_version < (0, 8, 21): + # nginx < 0.8.48 uses machine hostname as default server_name instead of + # the empty string + if nginx_version < (0, 8, 48): raise errors.LetsEncryptConfiguratorError( - "Nginx version must be 0.8.21+") + "Nginx version must be 0.8.48+") return nginx_version @@ -553,11 +564,18 @@ def nginx_restart(nginx_ctl): stdout, stderr = proc.communicate() if proc.returncode != 0: - # Enter recovery routine... - logging.error("Nginx Restart Failed!") - logging.error(stdout) - logging.error(stderr) - return False + # Maybe Nginx isn't running + nginx_proc = subprocess.Popen([nginx_ctl], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = nginx_proc.communicate() + + if nginx_proc.returncode != 0: + # Enter recovery routine... + logging.error("Nginx Restart Failed!") + logging.error(stdout) + logging.error(stderr) + return False except (OSError, ValueError): logging.fatal( diff --git a/letsencrypt/client/plugins/nginx/dvsni.py b/letsencrypt/client/plugins/nginx/dvsni.py index 7233d7c62..0e4f125f6 100644 --- a/letsencrypt/client/plugins/nginx/dvsni.py +++ b/letsencrypt/client/plugins/nginx/dvsni.py @@ -1,7 +1,12 @@ """NginxDVSNI""" +import itertools import logging +import os +from letsencrypt.client import errors from letsencrypt.client.plugins.apache.dvsni import ApacheDvsni +from letsencrypt.client.plugins.nginx import obj +from letsencrypt.client.plugins.nginx.nginxparser import dump class NginxDvsni(ApacheDvsni): @@ -29,35 +34,106 @@ class NginxDvsni(ApacheDvsni): """ def perform(self): - """Perform a DVSNI challenge on Nginx.""" + """Perform a DVSNI challenge on Nginx. + + :returns: list of :class:`letsencrypt.acme.challenges.DVSNIResponse` + :rtype: list + + """ if not self.achalls: return [] self.configurator.save() addresses = [] + default_addr = "443 default_server ssl" + for achall in self.achalls: vhost = self.configurator.choose_vhost(achall.domain) if vhost is None: logging.error( - "No nginx vhost exists with servername or alias of: %s", + "No nginx vhost exists with server_name matching: %s", achall.domain) - logging.error("No default 443 nginx vhost exists") - logging.error("Please specify servernames in the Nginx config") + logging.error("Please specify server_names in the Nginx config") return None + + for addr in vhost.addrs: + if addr.default: + addresses.append([obj.Addr.fromstring(default_addr)]) + break else: addresses.append(list(vhost.addrs)) - responses = [] + # Create challenge certs + responses = [self._setup_challenge_cert(x) for x in self.achalls] - # Create all of the challenge certs - # for achall in self.achalls: - # responses.append(self._setup_challenge_cert(achall)) - - # Setup the configuration - # self._mod_config(addresses) + # Set up the configuration + self._mod_config(addresses) # Save reversible changes self.configurator.save("SNI Challenge", True) return responses + + def _mod_config(self, ll_addrs): + """Modifies Nginx config to include challenge server blocks. + + :param list ll_addrs: list of lists of + :class:`letsencrypt.client.plugins.apache.obj.Addr` to apply + + :raises errors.LetsEncryptMisconfigurationError: + Unable to find a suitable HTTP block to include DVSNI hosts. + + """ + # Add the 'include' statement for the challenges if it doesn't exist + # already in the main config + included = False + directive = ['include', self.challenge_conf] + root = self.configurator.parser.loc["root"] + main = self.configurator.parser.parsed[root] + for entry in main: + if entry[0] == ['http']: + body = entry[1] + if directive not in body: + body.append(directive) + included = True + break + if not included: + raise errors.LetsEncryptMisconfigurationError( + 'LetsEncrypt could not find an HTTP block to include DVSNI ' + 'challenges in %s.' % root) + + config = [self._make_server_block(pair[0], pair[1]) + for pair in itertools.izip(self.achalls, ll_addrs)] + + self.configurator.reverter.register_file_creation( + True, self.challenge_conf) + + with open(self.challenge_conf, "w") as new_conf: + dump(config, new_conf) + + def _make_server_block(self, achall, addrs): + """Creates a server block for a DVSNI challenge. + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.client.achallenges.DVSNI` + + :param list addrs: addresses of challenged domain + :class:`list` of type :class:`~nginx.obj.Addr` + + :returns: server block for the challenge host + :rtype: list + + """ + document_root = os.path.join( + self.configurator.config.config_dir, "dvsni_page") + + block = [['listen', str(addr)] for addr in addrs] + + block.extend([['server_name', achall.nonce_domain], + ['include', self.configurator.parser.loc["ssl_options"]], + ['ssl_certificate', self.get_cert_file(achall)], + ['ssl_certificate_key', achall.key.file], + [['location', '/'], [['root', document_root]]]]) + + return [['server'], block] diff --git a/letsencrypt/client/plugins/nginx/obj.py b/letsencrypt/client/plugins/nginx/obj.py index acaacb3b0..b2db6522a 100644 --- a/letsencrypt/client/plugins/nginx/obj.py +++ b/letsencrypt/client/plugins/nginx/obj.py @@ -67,12 +67,20 @@ class Addr(ApacheAddr): return cls(host, port, ssl, default) def __str__(self): + parts = '' if self.tup[0] and self.tup[1]: - return "%s:%s" % self.tup + parts = "%s:%s" % self.tup elif self.tup[0]: - return self.tup[0] + parts = self.tup[0] else: - return self.tup[1] + parts = self.tup[1] + + if self.default: + parts += ' default_server' + if self.ssl: + parts += ' ssl' + + return parts def __eq__(self, other): if isinstance(other, self.__class__): @@ -89,7 +97,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods :ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`) :ivar set names: Server names/aliases of vhost (:class:`list` of :class:`str`) - :ivar array raw: The raw form of the parsed server block + :ivar list raw: The raw form of the parsed server block :ivar bool ssl: SSLEngine on in vhost :ivar bool enabled: Virtual host is enabled diff --git a/letsencrypt/client/plugins/nginx/parser.py b/letsencrypt/client/plugins/nginx/parser.py index 55a0b01e8..099a8e36d 100644 --- a/letsencrypt/client/plugins/nginx/parser.py +++ b/letsencrypt/client/plugins/nginx/parser.py @@ -33,6 +33,7 @@ class NginxParser(object): """Loads Nginx files into a parsed tree. """ + self.parsed = {} self._parse_recursively(self.loc["root"]) def _parse_recursively(self, filepath): @@ -165,7 +166,7 @@ class NginxParser(object): except IOError: logging.warn("Could not open file: %s", item) except pyparsing.ParseException: - logging.warn("Could not parse file: %s", item) + logging.debug("Could not parse file: %s", item) return trees def _set_locations(self, ssl_options): @@ -252,8 +253,9 @@ class NginxParser(object): def add_server_directives(self, filename, names, directives, replace=False): - """Add or replace directives in server blocks whose server_name set - is 'names'. If replace is True, this raises a misconfiguration error + """Add or replace directives in the first server block with names. + + ..note :: If replace is True, this raises a misconfiguration error if the directive does not already exist. ..todo :: Doesn't match server blocks whose server_name directives are @@ -265,14 +267,20 @@ class NginxParser(object): :param bool replace: Whether to only replace existing directives """ - if replace: - _do_for_subarray(self.parsed[filename], - lambda x: self._has_server_names(x, names), - lambda x: _replace_directives(x, directives)) - else: - _do_for_subarray(self.parsed[filename], - lambda x: self._has_server_names(x, names), - lambda x: x.extend(directives)) + _do_for_subarray(self.parsed[filename], + lambda x: self._has_server_names(x, names), + lambda x: _add_directives(x, directives, replace)) + + def add_http_directives(self, filename, directives): + """Adds directives to the first encountered HTTP block in filename. + + :param str filename: The absolute filename of the config file + :param list directives: The directives to add + + """ + _do_for_subarray(self.parsed[filename], + lambda x: x[0] == ['http'], + lambda x: _add_directives(x[1], [directives], False)) def get_all_certs_keys(self): """Gets all certs and keys in the nginx config. @@ -461,24 +469,28 @@ def _parse_server(server): return parsed_server -def _replace_directives(block, directives): - """Replaces directives in a block. If the directive doesn't exist in +def _add_directives(block, directives, replace=False): + """Adds or replaces directives in a block. If the directive doesn't exist in the entry already, raises a misconfiguration error. ..todo :: Find directives that are in included files. :param list block: The block to replace in :param list directives: The new directives. + """ - for directive in directives: - changed = False - if len(directive) == 0: - continue - for index, line in enumerate(block): - if len(line) > 0 and line[0] == directive[0]: - block[index] = directive - changed = True - if not changed: - raise errors.LetsEncryptMisconfigurationError( - 'LetsEncrypt expected directive for %s in the Nginx config ' - 'but did not find it.' % directive[0]) + if replace: + for directive in directives: + changed = False + if len(directive) == 0: + continue + for index, line in enumerate(block): + if len(line) > 0 and line[0] == directive[0]: + block[index] = directive + changed = True + if not changed: + raise errors.LetsEncryptMisconfigurationError( + 'LetsEncrypt expected directive for %s in the Nginx ' + 'config but did not find it.' % directive[0]) + else: + block.extend(directives) diff --git a/letsencrypt/client/plugins/nginx/tests/configurator_test.py b/letsencrypt/client/plugins/nginx/tests/configurator_test.py index 5aba8b1f8..e8800f163 100644 --- a/letsencrypt/client/plugins/nginx/tests/configurator_test.py +++ b/letsencrypt/client/plugins/nginx/tests/configurator_test.py @@ -91,7 +91,7 @@ class NginxConfiguratorTest(util.NginxTest): self.assertEqual(results[name], self.config.choose_vhost(name).names) for name in bad_results: - self.assertEqual(None, self.config.choose_vhost(name)) + self.assertEqual(set([name]), self.config.choose_vhost(name).names) def test_more_info(self): self.assertTrue('nginx.conf' in self.config.more_info()) @@ -259,6 +259,20 @@ class NginxConfiguratorTest(util.NginxTest): mocked.returncode = 0 self.assertTrue(self.config.restart()) + @mock.patch("letsencrypt.client.plugins.nginx.configurator." + "subprocess.Popen") + def test_nginx_restart_fail(self, mock_popen): + mocked = mock_popen() + mocked.communicate.return_value = ('', '') + mocked.returncode = 1 + self.assertFalse(self.config.restart()) + + @mock.patch("letsencrypt.client.plugins.nginx.configurator." + "subprocess.Popen") + def test_no_nginx_start(self, mock_popen): + mock_popen.side_effect = OSError("Can't find program") + self.assertRaises(SystemExit, self.config.restart) + @mock.patch("letsencrypt.client.plugins.nginx.configurator." "subprocess.Popen") def test_config_test(self, mock_popen): diff --git a/letsencrypt/client/plugins/nginx/tests/dvsni_test.py b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py index 5c76fb6ee..03c835677 100644 --- a/letsencrypt/client/plugins/nginx/tests/dvsni_test.py +++ b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py @@ -6,13 +6,16 @@ import shutil import mock from letsencrypt.acme import challenges -from letsencrypt.acme import messages2 from letsencrypt.client import achallenges +from letsencrypt.client import errors from letsencrypt.client import le_util +from letsencrypt.client.plugins.nginx.obj import Addr from letsencrypt.client.plugins.nginx.tests import util +from letsencrypt.client.tests import acme_util + class DvsniPerformTest(util.NginxTest): """Test the NginxDVSNI challenge.""" @@ -36,25 +39,29 @@ class DvsniPerformTest(util.NginxTest): self.achalls = [ achallenges.DVSNI( - challb=messages2.ChallengeBody( - chall=challenges.DVSNI( + challb=acme_util.chall_to_challb( + challenges.DVSNI( r="foo", - nonce="bar", - ), - uri="https://letsencrypt-ca.org/chall0_uri", - status=messages2.Status("pending"), - ), domain="www.example.com", key=auth_key), + nonce="bar" + ), "pending"), + domain="www.example.com", key=auth_key), achallenges.DVSNI( - challb=messages2.ChallengeBody( - chall=challenges.DVSNI( + challb=acme_util.chall_to_challb( + challenges.DVSNI( r="\xba\xa9\xda?