From 0473c67c4808a8f2a6ed3aa083b94040b67b75a8 Mon Sep 17 00:00:00 2001 From: sagi Date: Fri, 6 Nov 2015 22:31:30 +0000 Subject: [PATCH 001/181] Add HSTS header enhancement to Apache --- .../letsencrypt_apache/configurator.py | 70 ++++++++++++++++++- .../letsencrypt_apache/constants.py | 6 ++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d376fe4b6..ea6f80fae 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -122,7 +122,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.parser = None self.version = version self.vhosts = None - self._enhance_func = {"redirect": self._enable_redirect} + self._enhance_func = {"redirect": self._enable_redirect, + "hsts": self._enable_hsts} @property def mod_ssl_conf(self): @@ -681,7 +682,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ############################################################################ def supported_enhancements(self): # pylint: disable=no-self-use """Returns currently supported enhancements.""" - return ["redirect"] + return ["redirect", "hsts"] def enhance(self, domain, enhancement, options=None): """Enhance configuration. @@ -708,6 +709,71 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.warn("Failed %s for %s", enhancement, domain) raise + def _enable_hsts(self, ssl_vhost, unused_options): + """Enables the HSTS header on all HTTP responses. + + .. note:: HSTS defends against SSL stripping attacks. + + + Adds the Strict-Transport-Security header with max-age=31536000 (1 year) + and includeSubDomains (all subdomains are also set with HSTS). + + .. note:: This function saves the configuration + + :param ssl_vhost: Destination of traffic, an ssl enabled vhost + :type ssl_vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + + :param unused_options: Not currently used + :type unused_options: Not Available + + :returns: Success, general_vhost (HTTP vhost) + :rtype: (bool, :class:`~letsencrypt_apache.obj.VirtualHost`) + + :raises .errors.PluginError: If no viable HTTP host can be created or + used for the HSTS. + + """ + if "headers_module" not in self.parser.modules: + self.enable_mod("headers") + + # Check if HSTS header is already set + self._verify_no_hsts_header(ssl_vhost) + + # Add directives to server + self.parser.add_dir(ssl_vhost.path, "Header", constants.HSTS_ARGS) + self.save_notes += ("Adding HSTS header to every response from ssl " + "vhost in %s\n" % (ssl_vhost.filep)) + self.save() + logger.info("Adding HSTS header to every response from ssl vhost in %s", + ssl_vhost.filep) + + def _verify_no_hsts_header(self, ssl_vhost): + """Checks to see if existing HSTS settings is in place. + + Checks to see if virtualhost already contains a HSTS header + + :param vhost: vhost to check + :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + + :returns: boolean + :rtype: (bool) + + :raises errors.PluginError: When an HSTS header exists + + """ + header_path = self.parser.find_dir("Header", None, start=ssl_vhost.path) + if header_path: + # "Existing Header directive for virtualhost" + for match in header_path: + if match == "Strict-Transport-Security": + raise errors.PluginError("Existing HSTS header") + + for match, arg in itertools.izip(header_path, constants.HSTS_ARGS): + if self.aug.get(match) != arg: + raise errors.PluginError("Unknown Existing HSTS header") + raise errors.PluginError("Let's Encrypt has already enabled HSTS") + + def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index 1c17eacc3..05e1bb0e7 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -27,3 +27,9 @@ AUGEAS_LENS_DIR = pkg_resources.resource_filename( REWRITE_HTTPS_ARGS = [ "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"] """Apache rewrite rule arguments used for redirections to https vhost""" + +HSTS_ARGS = [ + "always", "set", "Strict-Transport-Security", + "\"max-age=31536000; includeSubDomains\""] +"""Apache header arguments for HSTS""" + From 93e2023f871927636077e59e026c8abaaf8b0369 Mon Sep 17 00:00:00 2001 From: sagi Date: Fri, 6 Nov 2015 22:32:02 +0000 Subject: [PATCH 002/181] Add HSTS enhancement basic tests --- .../tests/configurator_test.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 7c2137c45..adb96c2cd 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -507,6 +507,29 @@ class TwoVhost80Test(util.ApacheTest): errors.PluginError, self.config.enhance, "letsencrypt.demo", "unknown_enhancement") + @mock.patch("letsencrypt.le_util.run_script") + @mock.patch("letsencrypt.le_util.exe_exists") + def test_hsts(self, mock_exe, _): + self.config.parser.update_runtime_variables = mock.Mock() + self.config.parser.modules.add("mod_ssl.c") + mock_exe.return_value = True + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("letsencrypt.demo", "hsts") + + self.assertTrue("headers_module" in self.config.parser.modules) + + # Get the ssl vhost for letsencrypt.demo + ssl_vhost = self.config.assoc["letsencrypt.demo"] + + # These are not immediately available in find_dir even with save() and + # load(). They must be found in sites-available + hsts_header = self.config.parser.find_dir( + "Header", None, ssl_vhost.path) + # four args to HSTS header + self.assertEqual(len(hsts_header), 4) + + @mock.patch("letsencrypt.le_util.run_script") @mock.patch("letsencrypt.le_util.exe_exists") def test_redirect_well_formed_http(self, mock_exe, _): From 2988a09087b2e350185e1384558ede32da4b178b Mon Sep 17 00:00:00 2001 From: sagi Date: Sat, 7 Nov 2015 05:24:55 +0000 Subject: [PATCH 003/181] Make lint happy, delete trailing whitespaces --- letsencrypt-apache/letsencrypt_apache/configurator.py | 8 ++++---- letsencrypt-apache/letsencrypt_apache/constants.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index ea6f80fae..d8157c33a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -710,7 +710,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): raise def _enable_hsts(self, ssl_vhost, unused_options): - """Enables the HSTS header on all HTTP responses. + """Enables the HSTS header on all HTTP responses. .. note:: HSTS defends against SSL stripping attacks. @@ -735,10 +735,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ if "headers_module" not in self.parser.modules: self.enable_mod("headers") - + # Check if HSTS header is already set self._verify_no_hsts_header(ssl_vhost) - + # Add directives to server self.parser.add_dir(ssl_vhost.path, "Header", constants.HSTS_ARGS) self.save_notes += ("Adding HSTS header to every response from ssl " @@ -750,7 +750,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def _verify_no_hsts_header(self, ssl_vhost): """Checks to see if existing HSTS settings is in place. - Checks to see if virtualhost already contains a HSTS header + Checks to see if virtualhost already contains a HSTS header :param vhost: vhost to check :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index 05e1bb0e7..dac796c52 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -28,8 +28,8 @@ REWRITE_HTTPS_ARGS = [ "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"] """Apache rewrite rule arguments used for redirections to https vhost""" -HSTS_ARGS = [ - "always", "set", "Strict-Transport-Security", +HSTS_ARGS = [ + "always", "set", "Strict-Transport-Security", "\"max-age=31536000; includeSubDomains\""] """Apache header arguments for HSTS""" From 465efc96012d49f64172a512faaed29ec21b718f Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 7 Nov 2015 20:01:29 +0000 Subject: [PATCH 004/181] Custom acme.messages.Error (fixes #946). --- acme/acme/messages.py | 58 +++++++++++++++----------------------- acme/acme/messages_test.py | 47 ++++++++++++------------------ 2 files changed, 40 insertions(+), 65 deletions(-) diff --git a/acme/acme/messages.py b/acme/acme/messages.py index 9d4dcbf30..0b9ea8105 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -2,12 +2,13 @@ import collections from acme import challenges +from acme import errors from acme import fields from acme import jose from acme import util -class Error(jose.JSONObjectWithFields, Exception): +class Error(jose.JSONObjectWithFields, errors.Error): """ACME error. https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 @@ -17,55 +18,40 @@ class Error(jose.JSONObjectWithFields, Exception): :ivar unicode detail: """ - ERROR_TYPE_NAMESPACE = 'urn:acme:error:' - ERROR_TYPE_DESCRIPTIONS = { - 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', - 'badNonce': 'The client sent an unacceptable anti-replay nonce', - 'connection': 'The server could not connect to the client for DV', - 'dnssec': 'The server could not validate a DNSSEC signed domain', - 'malformed': 'The request message was malformed', - 'rateLimited': 'There were too many requests of a given type', - 'serverInternal': 'The server experienced an internal error', - 'tls': 'The server experienced a TLS error during DV', - 'unauthorized': 'The client lacks sufficient authorization', - 'unknownHost': 'The server could not resolve a domain name', - } + ERROR_TYPE_DESCRIPTIONS = dict( + ('urn:acme:error:' + name, description) for name, description in ( + ('badCSR', 'The CSR is unacceptable (e.g., due to a short key)'), + ('badNonce', 'The client sent an unacceptable anti-replay nonce'), + ('connection', 'The server could not connect to the client for DV'), + ('dnssec', 'The server could not validate a DNSSEC signed domain'), + ('malformed', 'The request message was malformed'), + ('rateLimited', 'There were too many requests of a given type'), + ('serverInternal', 'The server experienced an internal error'), + ('tls', 'The server experienced a TLS error during DV'), + ('unauthorized', 'The client lacks sufficient authorization'), + ('unknownHost', 'The server could not resolve a domain name'), + ) + ) typ = jose.Field('type') title = jose.Field('title', omitempty=True) detail = jose.Field('detail') - @typ.encoder - def typ(value): # pylint: disable=missing-docstring,no-self-argument - return Error.ERROR_TYPE_NAMESPACE + value - - @typ.decoder - def typ(value): # pylint: disable=missing-docstring,no-self-argument - # pylint thinks isinstance(value, Error), so startswith is not found - # pylint: disable=no-member - if not value.startswith(Error.ERROR_TYPE_NAMESPACE): - raise jose.DeserializationError('Missing error type prefix') - - without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):] - if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS: - raise jose.DeserializationError('Error type not recognized') - - return without_prefix - @property def description(self): """Hardcoded error description based on its type. + :returns: Description if standard ACME error or ``None``. :rtype: unicode """ - return self.ERROR_TYPE_DESCRIPTIONS[self.typ] + return self.ERROR_TYPE_DESCRIPTIONS.get(self.typ) def __str__(self): - if self.typ is not None: - return ' :: '.join([self.typ, self.description, self.detail]) - else: - return str(self.detail) + return ' :: '.join( + part for part in + (self.typ, self.description, self.detail, self.title) + if part is not None) class _Constant(jose.JSONDeSerializable, collections.Hashable): diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py index 6c1c4f596..5a7a71299 100644 --- a/acme/acme/messages_test.py +++ b/acme/acme/messages_test.py @@ -18,41 +18,30 @@ class ErrorTest(unittest.TestCase): def setUp(self): from acme.messages import Error - self.error = Error(detail='foo', typ='malformed', title='title') - self.jobj = {'detail': 'foo', 'title': 'some title'} - - def test_typ_prefix(self): - self.assertEqual('malformed', self.error.typ) - self.assertEqual( - 'urn:acme:error:malformed', self.error.to_partial_json()['type']) - self.assertEqual( - 'malformed', self.error.from_json(self.error.to_partial_json()).typ) - - def test_typ_decoder_missing_prefix(self): - from acme.messages import Error - self.jobj['type'] = 'malformed' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - self.jobj['type'] = 'not valid bare type' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - - def test_typ_decoder_not_recognized(self): - from acme.messages import Error - self.jobj['type'] = 'urn:acme:error:baz' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - - def test_description(self): - self.assertEqual( - 'The request message was malformed', self.error.description) + self.error = Error( + detail='foo', typ='urn:acme:error:malformed', title='title') + self.jobj = { + 'detail': 'foo', + 'title': 'some title', + 'type': 'urn:acme:error:malformed', + } + self.error_custom = Error(typ='custom', detail='bar') + self.jobj_cusom = {'type': 'custom', 'detail': 'bar'} def test_from_json_hashable(self): from acme.messages import Error hash(Error.from_json(self.error.to_json())) + def test_description(self): + self.assertEqual( + 'The request message was malformed', self.error.description) + self.assertTrue(self.error_custom.description is None) + def test_str(self): self.assertEqual( - 'malformed :: The request message was malformed :: foo', - str(self.error)) - self.assertEqual('foo', str(self.error.update(typ=None))) + 'urn:acme:error:malformed :: The request message was ' + 'malformed :: foo :: title', str(self.error)) + self.assertEqual('custom :: bar', str(self.error_custom)) class ConstantTest(unittest.TestCase): @@ -232,7 +221,7 @@ class ChallengeBodyTest(unittest.TestCase): from acme.messages import Error from acme.messages import STATUS_INVALID self.status = STATUS_INVALID - error = Error(typ='serverInternal', + error = Error(typ='urn:acme:error:serverInternal', detail='Unable to communicate with DNS server') self.challb = ChallengeBody( uri='http://challb', chall=self.chall, status=self.status, From 04136cfbf29223c6c1b8ef5ed9d5dcef7eef832d Mon Sep 17 00:00:00 2001 From: sagi Date: Sun, 8 Nov 2015 04:37:57 +0000 Subject: [PATCH 005/181] Generalized http-header enhancement --- .../letsencrypt_apache/configurator.py | 38 +++++++++---------- .../letsencrypt_apache/constants.py | 10 ++++- .../tests/configurator_test.py | 31 ++++++++++++++- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d8157c33a..9319d8022 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -123,7 +123,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.version = version self.vhosts = None self._enhance_func = {"redirect": self._enable_redirect, - "hsts": self._enable_hsts} + "http-header": self._set_http_header} @property def mod_ssl_conf(self): @@ -682,7 +682,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ############################################################################ def supported_enhancements(self): # pylint: disable=no-self-use """Returns currently supported enhancements.""" - return ["redirect", "hsts"] + return ["redirect", "http-header"] def enhance(self, domain, enhancement, options=None): """Enhance configuration. @@ -709,7 +709,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.warn("Failed %s for %s", enhancement, domain) raise - def _enable_hsts(self, ssl_vhost, unused_options): + def _set_http_header(self, ssl_vhost, header_name): + # TODO REWRITE COMMENT """Enables the HSTS header on all HTTP responses. .. note:: HSTS defends against SSL stripping attacks. @@ -736,18 +737,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if "headers_module" not in self.parser.modules: self.enable_mod("headers") - # Check if HSTS header is already set - self._verify_no_hsts_header(ssl_vhost) + # Check if selected header is already set + self._verify_no_http_header(ssl_vhost, header_name) # Add directives to server - self.parser.add_dir(ssl_vhost.path, "Header", constants.HSTS_ARGS) - self.save_notes += ("Adding HSTS header to every response from ssl " - "vhost in %s\n" % (ssl_vhost.filep)) + self.parser.add_dir(ssl_vhost.path, "Header", + constants.HEADER_ARGS[header_name]) + + self.save_notes += ("Adding %s header to ssl vhost in %s\n" % + (header_name, ssl_vhost.filep)) + self.save() - logger.info("Adding HSTS header to every response from ssl vhost in %s", + logger.info("Adding %s header to ssl vhost in %s", header_name, ssl_vhost.filep) - def _verify_no_hsts_header(self, ssl_vhost): + def _verify_no_http_header(self, ssl_vhost, header_name): + # TODO revise comment """Checks to see if existing HSTS settings is in place. Checks to see if virtualhost already contains a HSTS header @@ -765,15 +770,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if header_path: # "Existing Header directive for virtualhost" for match in header_path: - if match == "Strict-Transport-Security": - raise errors.PluginError("Existing HSTS header") - - for match, arg in itertools.izip(header_path, constants.HSTS_ARGS): - if self.aug.get(match) != arg: - raise errors.PluginError("Unknown Existing HSTS header") - raise errors.PluginError("Let's Encrypt has already enabled HSTS") - - + if self.aug.get(match) == header_name.lower(): + raise errors.PluginError("Existing %s header" % + (header_name)) + def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index dac796c52..63f67fc91 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -28,8 +28,14 @@ REWRITE_HTTPS_ARGS = [ "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"] """Apache rewrite rule arguments used for redirections to https vhost""" -HSTS_ARGS = [ - "always", "set", "Strict-Transport-Security", + +HSTS_ARGS = ["always", "set", "Strict-Transport-Security", "\"max-age=31536000; includeSubDomains\""] """Apache header arguments for HSTS""" +UIR_ARGS = ["always", "set", "Content-Security-Policy", + "upgrade-insecure-requests"] + +HEADER_ARGS = {"Strict-Transport-Security" : HSTS_ARGS, + "Upgrade-Insecure-Requests" : UIR_ARGS} + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index adb96c2cd..3832cf13e 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -15,6 +15,7 @@ from letsencrypt import errors from letsencrypt.tests import acme_util from letsencrypt_apache import configurator +from letsencrypt_apache import constants from letsencrypt_apache import obj from letsencrypt_apache.tests import util @@ -509,13 +510,14 @@ class TwoVhost80Test(util.ApacheTest): @mock.patch("letsencrypt.le_util.run_script") @mock.patch("letsencrypt.le_util.exe_exists") - def test_hsts(self, mock_exe, _): + def test_http_header_hsts(self, mock_exe, _): self.config.parser.update_runtime_variables = mock.Mock() self.config.parser.modules.add("mod_ssl.c") mock_exe.return_value = True # This will create an ssl vhost for letsencrypt.demo - self.config.enhance("letsencrypt.demo", "hsts") + self.config.enhance("letsencrypt.demo", "http-header", + "Strict-Transport-Security") self.assertTrue("headers_module" in self.config.parser.modules) @@ -526,6 +528,31 @@ class TwoVhost80Test(util.ApacheTest): # load(). They must be found in sites-available hsts_header = self.config.parser.find_dir( "Header", None, ssl_vhost.path) + + # four args to HSTS header + self.assertEqual(len(hsts_header), 4) + + @mock.patch("letsencrypt.le_util.run_script") + @mock.patch("letsencrypt.le_util.exe_exists") + def test_http_header_hsts_with_conflict(self, mock_exe, _): + mock_exe.return_value = True + self.config.parser.update_runtime_variables = mock.Mock() + self.config.parser.modules.add("mod_ssl.c") + + ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[3]) + self.config.parser.add_dir( + ssl_vhost.path, "Header", constants.HEADER_ARGS[ + "Strict-Transport-Security"]) + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance(self.vh_truth[3].name, "http-header", + "Strict-Transport-Security") + + # These are not immediately available in find_dir even with save() and + # load(). They must be found in sites-available + hsts_header = self.config.parser.find_dir( + "Header", None, ssl_vhost.path) + # four args to HSTS header self.assertEqual(len(hsts_header), 4) From ffe32c6ca40c493b70b024ee904523b25daabb37 Mon Sep 17 00:00:00 2001 From: sagi Date: Sun, 8 Nov 2015 15:21:36 +0000 Subject: [PATCH 006/181] Add tests and comments --- .../letsencrypt_apache/configurator.py | 30 ++++++++----------- .../letsencrypt_apache/constants.py | 6 ++-- .../tests/configurator_test.py | 27 +++++------------ 3 files changed, 24 insertions(+), 39 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 9319d8022..e3adfa927 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -710,28 +710,25 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): raise def _set_http_header(self, ssl_vhost, header_name): - # TODO REWRITE COMMENT - """Enables the HSTS header on all HTTP responses. + """Enables header header_name on ssl_vhost. - .. note:: HSTS defends against SSL stripping attacks. - - - Adds the Strict-Transport-Security header with max-age=31536000 (1 year) - and includeSubDomains (all subdomains are also set with HSTS). + If header_name is not already set, a new Header directive is placed in + ssl_vhost's configuration with arguments from: + constants.HTTP_HEADER[header_name] .. note:: This function saves the configuration :param ssl_vhost: Destination of traffic, an ssl enabled vhost :type ssl_vhost: :class:`~letsencrypt_apache.obj.VirtualHost` - :param unused_options: Not currently used - :type unused_options: Not Available + :param header_name: a header name, e.g: Strict-Transport-Security + :type str :returns: Success, general_vhost (HTTP vhost) :rtype: (bool, :class:`~letsencrypt_apache.obj.VirtualHost`) :raises .errors.PluginError: If no viable HTTP host can be created or - used for the HSTS. + set with header header_name. """ if "headers_module" not in self.parser.modules: @@ -744,7 +741,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.parser.add_dir(ssl_vhost.path, "Header", constants.HEADER_ARGS[header_name]) - self.save_notes += ("Adding %s header to ssl vhost in %s\n" % + self.save_notes += ("Adding %s header to ssl vhost in %s\n" % (header_name, ssl_vhost.filep)) self.save() @@ -752,10 +749,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ssl_vhost.filep) def _verify_no_http_header(self, ssl_vhost, header_name): - # TODO revise comment - """Checks to see if existing HSTS settings is in place. + """Checks to see if existing header_name header is in place. - Checks to see if virtualhost already contains a HSTS header + Checks to see if virtualhost already contains a header_name header :param vhost: vhost to check :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` @@ -763,17 +759,17 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :returns: boolean :rtype: (bool) - :raises errors.PluginError: When an HSTS header exists + :raises errors.PluginError: When header header_name exists """ header_path = self.parser.find_dir("Header", None, start=ssl_vhost.path) if header_path: # "Existing Header directive for virtualhost" for match in header_path: - if self.aug.get(match) == header_name.lower(): + if self.aug.get(match).lower() == header_name.lower(): raise errors.PluginError("Existing %s header" % (header_name)) - + def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index 63f67fc91..813eae582 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -34,8 +34,8 @@ HSTS_ARGS = ["always", "set", "Strict-Transport-Security", """Apache header arguments for HSTS""" UIR_ARGS = ["always", "set", "Content-Security-Policy", - "upgrade-insecure-requests"] + "upgrade-insecure-requests"] -HEADER_ARGS = {"Strict-Transport-Security" : HSTS_ARGS, - "Upgrade-Insecure-Requests" : UIR_ARGS} +HEADER_ARGS = {"Strict-Transport-Security": HSTS_ARGS, + "Upgrade-Insecure-Requests": UIR_ARGS} diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 3832cf13e..aa224d1b6 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -15,7 +15,6 @@ from letsencrypt import errors from letsencrypt.tests import acme_util from letsencrypt_apache import configurator -from letsencrypt_apache import constants from letsencrypt_apache import obj from letsencrypt_apache.tests import util @@ -532,29 +531,19 @@ class TwoVhost80Test(util.ApacheTest): # four args to HSTS header self.assertEqual(len(hsts_header), 4) - @mock.patch("letsencrypt.le_util.run_script") - @mock.patch("letsencrypt.le_util.exe_exists") - def test_http_header_hsts_with_conflict(self, mock_exe, _): - mock_exe.return_value = True - self.config.parser.update_runtime_variables = mock.Mock() + def test_http_header_hsts_twice(self): self.config.parser.modules.add("mod_ssl.c") - - ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[3]) - self.config.parser.add_dir( - ssl_vhost.path, "Header", constants.HEADER_ARGS[ - "Strict-Transport-Security"]) + # skip the enable mod + self.config.parser.modules.add("headers_module") # This will create an ssl vhost for letsencrypt.demo - self.config.enhance(self.vh_truth[3].name, "http-header", + self.config.enhance("encryption-example.demo", "http-header", "Strict-Transport-Security") - # These are not immediately available in find_dir even with save() and - # load(). They must be found in sites-available - hsts_header = self.config.parser.find_dir( - "Header", None, ssl_vhost.path) - - # four args to HSTS header - self.assertEqual(len(hsts_header), 4) + self.assertRaises( + errors.PluginError, + self.config.enhance, "encryption-example.demo", "http-header", + "Strict-Transport-Security") @mock.patch("letsencrypt.le_util.run_script") From de338c7309bb31244274b1e4f63b59f1f9b72c09 Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 9 Nov 2015 22:36:00 +0000 Subject: [PATCH 007/181] Add tests for Upgrade-Insecure-Requests --- .../tests/configurator_test.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index aa224d1b6..4dd1350ac 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -545,6 +545,45 @@ class TwoVhost80Test(util.ApacheTest): self.config.enhance, "encryption-example.demo", "http-header", "Strict-Transport-Security") + @mock.patch("letsencrypt.le_util.run_script") + @mock.patch("letsencrypt.le_util.exe_exists") + def test_http_header_uir(self, mock_exe, _): + self.config.parser.update_runtime_variables = mock.Mock() + self.config.parser.modules.add("mod_ssl.c") + mock_exe.return_value = True + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("letsencrypt.demo", "http-header", + "Upgrade-Insecure-Requests") + + self.assertTrue("headers_module" in self.config.parser.modules) + + # Get the ssl vhost for letsencrypt.demo + ssl_vhost = self.config.assoc["letsencrypt.demo"] + + # These are not immediately available in find_dir even with save() and + # load(). They must be found in sites-available + uir_header = self.config.parser.find_dir( + "Header", None, ssl_vhost.path) + + # four args to HSTS header + self.assertEqual(len(uir_header), 4) + + def test_http_header_uir_twice(self): + self.config.parser.modules.add("mod_ssl.c") + # skip the enable mod + self.config.parser.modules.add("headers_module") + + # This will create an ssl vhost for letsencrypt.demo + self.config.enhance("encryption-example.demo", "http-header", + "Upgrade-Insecure-Requests") + + self.assertRaises( + errors.PluginError, + self.config.enhance, "encryption-example.demo", "http-header", + "Upgrade-Insecure-Requests") + + @mock.patch("letsencrypt.le_util.run_script") @mock.patch("letsencrypt.le_util.exe_exists") From 18da7dfce2e9128b2e27c89935a9f3be4385a03e Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sun, 8 Nov 2015 14:19:58 -0600 Subject: [PATCH 008/181] Implement @pde's suggestions for Apache From this IRC log: 2015-11-02 16:31:29 @pdeee for >= 2.4.8: 2015-11-02 16:32:23 @pdeee add new SSLCertificateFile pointing to fullchain.pem 2015-11-02 16:33:10 @pdeee remove all preexisting SSLCertificateFile, SSLCertificateChainFile, SSLCACertificatePath, and possibly other fields subject to careful research :) 2015-11-02 16:33:21 @pdeee for < 2.4.8: 2015-11-02 16:34:03 @pdeee add SSLCertificateFile pointing to cert.pem 2015-11-02 16:34:42 @pdeee and SSLCertificateChainFile pointing to chain.pem 2015-11-02 16:34:50 xamnesiax gotcha 2015-11-02 16:34:55 @pdeee remove all preexisting/conflicting entries 2015-11-02 16:35:19 xamnesiax Am I correct to assume that this can all be done from deploy_certs in the apache configurator? 2015-11-02 16:36:32 xamnesiax deploy_cert * 2015-11-02 16:36:48 @pdeee I think so 2015-11-02 16:36:59 @pdeee again, jdkasten may wish to say more Pull strings out for find_dir A bit of logging Add version logging Logging, temporarily remove one branch of the conditional for testing Fix bad directive stringgrabbing code Fix directive removal logic Grab string from tree to be removed --- .../letsencrypt_apache/configurator.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d376fe4b6..173be4104 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -212,14 +212,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.info("Deploying Certificate to VirtualHost %s", vhost.filep) # Assign the final directives; order is maintained in find_dir - self.aug.set(path["cert_path"][-1], cert_path) - self.aug.set(path["cert_key"][-1], key_path) - if chain_path is not None: - if not path["chain_path"]: - self.parser.add_dir( - vhost.path, "SSLCertificateChainFile", chain_path) - else: - self.aug.set(path["chain_path"][-1], chain_path) + if self.version >= (2, 4, 8): + logger.debug("Apache version (%s) is >= 2.4.8", + ".".join(map(str,self.version))) + for directive in ["SSLCertificateKeyFile", "SSLCertificateChainFile", + "SSLCACertificatePath"]: + logging.debug("Trying to delete directive '%s'", directive) + directive_tree = self.parser.find_dir(directive, None, vhost.path) + logging.debug(directive_tree) + if directive_tree: + logger.debug("Removing directive %s", directive) + self.aug.remove(re.sub(r"/\w*$", "", directive_tree[-1])) + logging.debug("fullchain path: %s", fullchain_path) + self.aug.set(path["cert_path"][-1], fullchain_path) + elif self.version < (2, 4, 8): + logger.debug("Apache version (%s) is < 2.4.8", + ".".join(map(str,self.version))) # Save notes about the transaction that took place self.save_notes += ("Changed vhost at %s with addresses of %s\n" From 1d2ba931b37cfca5d37d123d124a785d63f53121 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sun, 8 Nov 2015 16:47:09 -0600 Subject: [PATCH 009/181] Improve the implementation of the suggestion Write the code to set directives Fix logging in _remove_existing_ssl_directives Fix logging statement --- .../letsencrypt_apache/configurator.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 173be4104..eb8268e33 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -213,21 +213,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Assign the final directives; order is maintained in find_dir if self.version >= (2, 4, 8): - logger.debug("Apache version (%s) is >= 2.4.8", - ".".join(map(str,self.version))) - for directive in ["SSLCertificateKeyFile", "SSLCertificateChainFile", - "SSLCACertificatePath"]: - logging.debug("Trying to delete directive '%s'", directive) - directive_tree = self.parser.find_dir(directive, None, vhost.path) - logging.debug(directive_tree) - if directive_tree: - logger.debug("Removing directive %s", directive) - self.aug.remove(re.sub(r"/\w*$", "", directive_tree[-1])) - logging.debug("fullchain path: %s", fullchain_path) self.aug.set(path["cert_path"][-1], fullchain_path) elif self.version < (2, 4, 8): - logger.debug("Apache version (%s) is < 2.4.8", - ".".join(map(str,self.version))) + self.aug.set(path["cert_path"][-1], cert_path) + self.aug.set(path["chain_path"][-1], chain_path) # Save notes about the transaction that took place self.save_notes += ("Changed vhost at %s with addresses of %s\n" @@ -583,6 +572,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Update Addresses self._update_ssl_vhosts_addrs(vh_p) + # Remove existing SSL directives + logging.info("Removing existing SSL directives") + self._remove_existing_ssl_directives(vh_p) + # Add directives self._add_dummy_ssl_directives(vh_p) @@ -651,6 +644,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return ssl_addrs + def _remove_existing_ssl_directives(self, vh_path): + for directive in ["SSLCertificateKeyFile", "SSLCertificateChainFile", + "SSLCACertificatePath", "SSLCertificateFile"]: + logger.debug("Trying to delete directive '%s'", directive) + directive_tree = self.parser.find_dir(directive, None, vh_path) + logger.debug("Parser found %s", directive_tree) + if directive_tree: + logger.debug("Removing directive %s", directive) + self.aug.remove(re.sub(r"/\w*$", "", directive_tree[-1])) + def _add_dummy_ssl_directives(self, vh_path): self.parser.add_dir(vh_path, "SSLCertificateFile", "insert_cert_file_path") From b26c13893864d15c3fbc7c646df003c50b1e9463 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sun, 8 Nov 2015 17:37:28 -0600 Subject: [PATCH 010/181] Wire in everything, remove cert_key Add debug. Will be removed. Logging --- .../letsencrypt_apache/configurator.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index eb8268e33..ecb6fe09a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -192,39 +192,51 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): path["cert_path"] = self.parser.find_dir( "SSLCertificateFile", None, vhost.path) - path["cert_key"] = self.parser.find_dir( - "SSLCertificateKeyFile", None, vhost.path) # Only include if a certificate chain is specified if chain_path is not None: path["chain_path"] = self.parser.find_dir( "SSLCertificateChainFile", None, vhost.path) - if not path["cert_path"] or not path["cert_key"]: + if not path["cert_path"]: # Throw some can't find all of the directives error" logger.warn( - "Cannot find a cert or key directive in %s. " + "Cannot find a cert directive in %s. " "VirtualHost was not modified", vhost.path) # Presumably break here so that the virtualhost is not modified raise errors.PluginError( - "Unable to find cert and/or key directives") + "Unable to find cert directive") logger.info("Deploying Certificate to VirtualHost %s", vhost.filep) # Assign the final directives; order is maintained in find_dir if self.version >= (2, 4, 8): + logger.debug("Apache version (%s) is >= 2.4.8", + ".".join(map(str,self.version))) + set_cert_path = fullchain_path + logger.debug(fullchain_path) + logger.debug(path["cert_path"][-1]) self.aug.set(path["cert_path"][-1], fullchain_path) elif self.version < (2, 4, 8): + logger.debug("Apache version (%s) is < 2.4.8", + ".".join(map(str,self.version))) + set_cert_path = cert_path self.aug.set(path["cert_path"][-1], cert_path) - self.aug.set(path["chain_path"][-1], chain_path) + if not path["chain_path"]: + self.parser.add_dir(vhost.path, + "SSLCertificateChainFile", chain_path) + else: + self.aug.set(path["chain_path"][-1], chain_path) + + with open("%s/sites-available/%s" % (self.parser.root, os.path.basename(vhost.filep))) as f: + logger.debug(f.read()) # Save notes about the transaction that took place self.save_notes += ("Changed vhost at %s with addresses of %s\n" - "\tSSLCertificateFile %s\n" - "\tSSLCertificateKeyFile %s\n" % + "\tSSLCertificateFile %s\n" % (vhost.filep, ", ".join(str(addr) for addr in vhost.addrs), - cert_path, key_path)) + set_cert_path)) if chain_path is not None: self.save_notes += "\tSSLCertificateChainFile %s\n" % chain_path @@ -573,7 +585,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self._update_ssl_vhosts_addrs(vh_p) # Remove existing SSL directives - logging.info("Removing existing SSL directives") + logger.info("Removing existing SSL directives") self._remove_existing_ssl_directives(vh_p) # Add directives @@ -657,8 +669,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def _add_dummy_ssl_directives(self, vh_path): self.parser.add_dir(vh_path, "SSLCertificateFile", "insert_cert_file_path") - self.parser.add_dir(vh_path, "SSLCertificateKeyFile", - "insert_key_file_path") self.parser.add_dir(vh_path, "Include", self.mod_ssl_conf) def _add_name_vhost_if_necessary(self, vhost): From e63fa279a489b552ff770bb9aaac4e7de17ba1f6 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Mon, 9 Nov 2015 17:04:21 -0600 Subject: [PATCH 011/181] Reintroduce cert_key, remove bad logging --- .../letsencrypt_apache/configurator.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index ecb6fe09a..d4a738925 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -192,20 +192,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): path["cert_path"] = self.parser.find_dir( "SSLCertificateFile", None, vhost.path) + path["cert_key"] = self.parser.find_dir( + "SSLCertificateKeyFile", None, vhost.path) # Only include if a certificate chain is specified if chain_path is not None: path["chain_path"] = self.parser.find_dir( "SSLCertificateChainFile", None, vhost.path) - if not path["cert_path"]: + if not path["cert_path"] or not path["cert_key"]: # Throw some can't find all of the directives error" logger.warn( - "Cannot find a cert directive in %s. " + "Cannot find a cert or key directive in %s. " "VirtualHost was not modified", vhost.path) # Presumably break here so that the virtualhost is not modified raise errors.PluginError( - "Unable to find cert directive") + "Unable to find cert and/or key directives") logger.info("Deploying Certificate to VirtualHost %s", vhost.filep) @@ -214,29 +216,27 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.debug("Apache version (%s) is >= 2.4.8", ".".join(map(str,self.version))) set_cert_path = fullchain_path - logger.debug(fullchain_path) - logger.debug(path["cert_path"][-1]) self.aug.set(path["cert_path"][-1], fullchain_path) + self.aug.set(path["cert_key"][-1], key_path) elif self.version < (2, 4, 8): logger.debug("Apache version (%s) is < 2.4.8", ".".join(map(str,self.version))) set_cert_path = cert_path self.aug.set(path["cert_path"][-1], cert_path) + self.aug.set(path["cert_key"][-1], key_path) if not path["chain_path"]: self.parser.add_dir(vhost.path, "SSLCertificateChainFile", chain_path) else: self.aug.set(path["chain_path"][-1], chain_path) - with open("%s/sites-available/%s" % (self.parser.root, os.path.basename(vhost.filep))) as f: - logger.debug(f.read()) - # Save notes about the transaction that took place self.save_notes += ("Changed vhost at %s with addresses of %s\n" - "\tSSLCertificateFile %s\n" % + "\tSSLCertificateFile %s\n" + "\tSSLCertificateKeyFile %s\n" % (vhost.filep, ", ".join(str(addr) for addr in vhost.addrs), - set_cert_path)) + set_cert_path, key_path)) if chain_path is not None: self.save_notes += "\tSSLCertificateChainFile %s\n" % chain_path @@ -669,6 +669,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def _add_dummy_ssl_directives(self, vh_path): self.parser.add_dir(vh_path, "SSLCertificateFile", "insert_cert_file_path") + self.parser.add_dir(vh_path, "SSLCertificateKeyFile", + "insert_key_file_path") self.parser.add_dir(vh_path, "Include", self.mod_ssl_conf) def _add_name_vhost_if_necessary(self, vhost): From 30c44ef1e274be6873c5a4adcda5269f25b7690e Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Mon, 9 Nov 2015 17:42:38 -0600 Subject: [PATCH 012/181] Fix lint errors --- letsencrypt-apache/letsencrypt_apache/configurator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d4a738925..5d5907895 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -214,13 +214,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Assign the final directives; order is maintained in find_dir if self.version >= (2, 4, 8): logger.debug("Apache version (%s) is >= 2.4.8", - ".".join(map(str,self.version))) + ".".join(str(i) for i in self.version)) set_cert_path = fullchain_path self.aug.set(path["cert_path"][-1], fullchain_path) self.aug.set(path["cert_key"][-1], key_path) elif self.version < (2, 4, 8): logger.debug("Apache version (%s) is < 2.4.8", - ".".join(map(str,self.version))) + ".".join(str(i) for i in self.version)) set_cert_path = cert_path self.aug.set(path["cert_path"][-1], cert_path) self.aug.set(path["cert_key"][-1], key_path) @@ -663,8 +663,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): directive_tree = self.parser.find_dir(directive, None, vh_path) logger.debug("Parser found %s", directive_tree) if directive_tree: - logger.debug("Removing directive %s", directive) - self.aug.remove(re.sub(r"/\w*$", "", directive_tree[-1])) + logger.debug("Removing directive %s", directive) + self.aug.remove(re.sub(r"/\w*$", "", directive_tree[-1])) def _add_dummy_ssl_directives(self, vh_path): self.parser.add_dir(vh_path, "SSLCertificateFile", From 188068906554b213853914cf27fb2875b7220e96 Mon Sep 17 00:00:00 2001 From: sagi Date: Tue, 10 Nov 2015 06:41:59 +0000 Subject: [PATCH 013/181] Add --hsts and --uir CLI flags --- letsencrypt/cli.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 5757783cd..1c08d27f6 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -868,6 +868,16 @@ def prepare_and_parse_args(plugins, args): "security", "--no-redirect", action="store_false", help="Do not automatically redirect all HTTP traffic to HTTPS for the newly " "authenticated vhost.", dest="redirect", default=None) + helpful.add( + "security", "--hsts", action="store_true", + help="Add the Strict-Transport-Security header to every HTTP response." + " Forcing browser to use always use SSL for the domain." + " Defends against SSL Stripping.", dest="hsts") + helpful.add( + "security", "--uir", action="store_true", + help="Add the \"Content-Security-Policy: upgrade-insecure-requests\"" + " header to every HTTP response. Forcing the browser to use" + " https:// for every http:// resource.", dest="uir") helpful.add( "security", "--strict-permissions", action="store_true", help="Require that all configuration files are owned by the current " From 1f6ef1f4b158018d732cbfc52144cfef221822fb Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Tue, 10 Nov 2015 15:54:54 -0600 Subject: [PATCH 014/181] Add tests for existing cert removal and newcert directives --- .../tests/configurator_test.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 7c2137c45..b44b8bdda 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -230,6 +230,38 @@ class TwoVhost80Test(util.ApacheTest): self.config.enable_site, obj.VirtualHost("asdf", "afsaf", set(), False, False)) + def test_deploy_cert_newssl(self): + self.config = util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, version=(2, 4, 16)) + + self.config.parser.modules.add("ssl_module") + self.config.parser.modules.add("mod_ssl.c") + + # Get the default 443 vhost + self.config.assoc["random.demo"] = self.vh_truth[1] + self.config.deploy_cert( + "random.demo", "example/cert.pem", "example/key.pem", + "example/cert_chain.pem", "example/fullchain.pem") + self.config.save() + + # Verify ssl_module was enabled. + self.assertTrue(self.vh_truth[1].enabled) + self.assertTrue("ssl_module" in self.config.parser.modules) + + loc_cert = self.config.parser.find_dir( + "sslcertificatefile", "example/fullchain.pem", self.vh_truth[1].path) + loc_key = self.config.parser.find_dir( + "sslcertificateKeyfile", "example/key.pem", self.vh_truth[1].path) + + # Verify one directive was found in the correct file + self.assertEqual(len(loc_cert), 1) + self.assertEqual(configurator.get_file_path(loc_cert[0]), + self.vh_truth[1].filep) + + self.assertEqual(len(loc_key), 1) + self.assertEqual(configurator.get_file_path(loc_key[0]), + self.vh_truth[1].filep) + def test_deploy_cert(self): self.config.parser.modules.add("ssl_module") self.config.parser.modules.add("mod_ssl.c") @@ -347,6 +379,21 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(len(self.config.vhosts), 5) + def test_remove_existing_ssl_directives(self): + # pylint: disable=protected-access + BOGUS_DIRECTIVES = ["SSLCertificateKeyFile", "SSLCertificateChainFile", + "SSLCACertificatePath", "SSLCertificateFile"] + for directive in BOGUS_DIRECTIVES: + self.config.parser.add_dir(self.vh_truth[0].path, directive, ["bogus"]) + self.config.save() + self.config._remove_existing_ssl_directives(self.vh_truth[0].path) + self.config.save() + + for directive in BOGUS_DIRECTIVES: + self.assertEqual( + self.config.parser.find_dir(directive, None, self.vh_truth[0].path), + []) + def test_make_vhost_ssl_extra_vhs(self): self.config.aug.match = mock.Mock(return_value=["p1", "p2"]) self.assertRaises( From 211c2bb33d668c13f8d2e89fb95c88806737d1dd Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Tue, 10 Nov 2015 19:41:30 -0600 Subject: [PATCH 015/181] Remove SSLCACertificatePath from removed directives SSLCACertificatePath is sometimes important to preserve. --- letsencrypt-apache/letsencrypt_apache/configurator.py | 2 +- .../letsencrypt_apache/tests/configurator_test.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 5d5907895..d8e929079 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -658,7 +658,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def _remove_existing_ssl_directives(self, vh_path): for directive in ["SSLCertificateKeyFile", "SSLCertificateChainFile", - "SSLCACertificatePath", "SSLCertificateFile"]: + "SSLCertificateFile"]: logger.debug("Trying to delete directive '%s'", directive) directive_tree = self.parser.find_dir(directive, None, vh_path) logger.debug("Parser found %s", directive_tree) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index b44b8bdda..f6cef0470 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -381,8 +381,8 @@ class TwoVhost80Test(util.ApacheTest): def test_remove_existing_ssl_directives(self): # pylint: disable=protected-access - BOGUS_DIRECTIVES = ["SSLCertificateKeyFile", "SSLCertificateChainFile", - "SSLCACertificatePath", "SSLCertificateFile"] + BOGUS_DIRECTIVES = ["SSLCertificateKeyFile", + "SSLCertificateChainFile", "SSLCertificateFile"] for directive in BOGUS_DIRECTIVES: self.config.parser.add_dir(self.vh_truth[0].path, directive, ["bogus"]) self.config.save() From 9ad38e9b37b57a542b7070396bef4c3d7985167b Mon Sep 17 00:00:00 2001 From: sagi Date: Wed, 11 Nov 2015 19:04:07 +0000 Subject: [PATCH 016/181] Pass args to enhance_config instead of just a redirect flag --- letsencrypt/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 1c08d27f6..e33c0770e 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -453,7 +453,7 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo domains, lineage.privkey, lineage.cert, lineage.chain, lineage.fullchain) - le_client.enhance_config(domains, args.redirect) + le_client.enhance_config(domains, args) if len(lineage.available_versions("cert")) == 1: display_ops.success_installation(domains) @@ -507,7 +507,7 @@ def install(args, config, plugins): le_client.deploy_certificate( domains, args.key_path, args.cert_path, args.chain_path, args.fullchain_path) - le_client.enhance_config(domains, args.redirect) + le_client.enhance_config(domains, args) def revoke(args, config, unused_plugins): # TODO: coop with renewal config From 17ef874c04be603bbf3db88bf90d8e4ad0929db1 Mon Sep 17 00:00:00 2001 From: sagi Date: Thu, 12 Nov 2015 02:15:42 +0000 Subject: [PATCH 017/181] change args to config in enhance_config --- letsencrypt/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index e33c0770e..36780d2bb 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -453,7 +453,7 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo domains, lineage.privkey, lineage.cert, lineage.chain, lineage.fullchain) - le_client.enhance_config(domains, args) + le_client.enhance_config(domains, config) if len(lineage.available_versions("cert")) == 1: display_ops.success_installation(domains) @@ -507,7 +507,7 @@ def install(args, config, plugins): le_client.deploy_certificate( domains, args.key_path, args.cert_path, args.chain_path, args.fullchain_path) - le_client.enhance_config(domains, args) + le_client.enhance_config(domains, config) def revoke(args, config, unused_plugins): # TODO: coop with renewal config From e787147eea3c533a43cf0200ccd00a65e2b87846 Mon Sep 17 00:00:00 2001 From: sagi Date: Thu, 12 Nov 2015 02:24:57 +0000 Subject: [PATCH 018/181] dissect namespace config in enhance_config --- letsencrypt/client.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 8a0ad6af4..bf99a55dd 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -350,7 +350,7 @@ class Client(object): logger.critical("Rollback successful; your server has " "been restarted with your old configuration") - def enhance_config(self, domains, redirect=None): + def enhance_config(self, domains, config=None): """Enhance the configuration. .. todo:: This needs to handle the specific enhancements offered by the @@ -359,6 +359,11 @@ class Client(object): :param list domains: list of domains to configure + :ivar namespace: Namespace typically produced by + :meth:`argparse.ArgumentParser.parse_args`. + :type namespace: :class:`argparse.Namespace` + + :param redirect: If traffic should be forwarded from HTTP to HTTPS. :type redirect: bool or None @@ -371,7 +376,7 @@ class Client(object): "configuration to enhance.") raise errors.Error("No installer available") - if redirect is None: + if config.redirect is None: redirect = enhancements.ask("redirect") # When support for more enhancements are added, the call to the From 68d956f6594124591e890f7f28890900de6c7792 Mon Sep 17 00:00:00 2001 From: sagi Date: Thu, 12 Nov 2015 03:04:23 +0000 Subject: [PATCH 019/181] make redirect work again --- letsencrypt/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index bf99a55dd..2b19176c2 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -371,12 +371,14 @@ class Client(object): client. """ + redirect = config.redirect + if self.installer is None: logger.warning("No installer is specified, there isn't any " "configuration to enhance.") raise errors.Error("No installer available") - if config.redirect is None: + if redirect is None: redirect = enhancements.ask("redirect") # When support for more enhancements are added, the call to the From b1e3c89048c458287df29fcf2fd596bb53d402e4 Mon Sep 17 00:00:00 2001 From: sagi Date: Thu, 12 Nov 2015 04:49:31 +0000 Subject: [PATCH 020/181] add a general apply_enhancement to replace redirect_to_ssl --- letsencrypt/client.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 2b19176c2..aa1718def 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -353,25 +353,19 @@ class Client(object): def enhance_config(self, domains, config=None): """Enhance the configuration. - .. todo:: This needs to handle the specific enhancements offered by the - installer. We will also have to find a method to pass in the chosen - values efficiently. - :param list domains: list of domains to configure :ivar namespace: Namespace typically produced by :meth:`argparse.ArgumentParser.parse_args`. :type namespace: :class:`argparse.Namespace` - - :param redirect: If traffic should be forwarded from HTTP to HTTPS. - :type redirect: bool or None - :raises .errors.Error: if no installer is specified in the client. """ redirect = config.redirect + hsts = config.hsts + uir = config.uir # Upgrade Insecure Requests if self.installer is None: logger.warning("No installer is specified, there isn't any " @@ -381,27 +375,27 @@ class Client(object): if redirect is None: redirect = enhancements.ask("redirect") - # When support for more enhancements are added, the call to the - # plugin's `enhance` function should be wrapped by an ErrorHandler if redirect: - self.redirect_to_ssl(domains) + self.apply_enhancement(domains, "redirect") - def redirect_to_ssl(self, domains): + def apply_enhancement(self, domains, enhancement, options=None): + # TODO change comment """Redirect all traffic from HTTP to HTTPS - :param vhost: list of ssl_vhosts - :type vhost: :class:`letsencrypt.interfaces.IInstaller` + :param domains: list of ssl_vhosts + :type str """ with error_handler.ErrorHandler(self.installer.recovery_routine): for dom in domains: try: - self.installer.enhance(dom, "redirect") + self.installer.enhance(dom, enhancement) except errors.PluginError: - logger.warn("Unable to perform redirect for %s", dom) + logger.warn("Unable to set enhancement %s for %s", + enhancement, dom) raise - self.installer.save("Add Redirects") + self.installer.save("Add enhancement %s" % (enhancement)) self.installer.restart() From 8185ea931c869ee5f916a6f7f7a45c3eb6bf6f12 Mon Sep 17 00:00:00 2001 From: sagi Date: Thu, 12 Nov 2015 05:08:30 +0000 Subject: [PATCH 021/181] make hsts and uri cli args actually work --- letsencrypt/client.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index aa1718def..81de32bbe 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -367,6 +367,7 @@ class Client(object): hsts = config.hsts uir = config.uir # Upgrade Insecure Requests + if self.installer is None: logger.warning("No installer is specified, there isn't any " "configuration to enhance.") @@ -378,6 +379,16 @@ class Client(object): if redirect: self.apply_enhancement(domains, "redirect") + if hsts: + self.apply_enhancement(domains, "http-header", + "Strict-Transport-Security") + if uir: + self.apply_enhancement(domains, "http-header", + "Upgrade-Insecure-Requests") + + if (redirect or hsts or uir): + self.installer.restart() + def apply_enhancement(self, domains, enhancement, options=None): # TODO change comment """Redirect all traffic from HTTP to HTTPS @@ -389,14 +400,13 @@ class Client(object): with error_handler.ErrorHandler(self.installer.recovery_routine): for dom in domains: try: - self.installer.enhance(dom, enhancement) + self.installer.enhance(dom, enhancement, options) except errors.PluginError: logger.warn("Unable to set enhancement %s for %s", enhancement, dom) raise self.installer.save("Add enhancement %s" % (enhancement)) - self.installer.restart() def validate_key_csr(privkey, csr=None): From 796eef802d7ccdd70c03192220c0c58979701cb7 Mon Sep 17 00:00:00 2001 From: sagi Date: Thu, 12 Nov 2015 05:20:10 +0000 Subject: [PATCH 022/181] add apply_enhancement comment --- letsencrypt/client.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 81de32bbe..65098bc18 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -390,11 +390,17 @@ class Client(object): self.installer.restart() def apply_enhancement(self, domains, enhancement, options=None): - # TODO change comment - """Redirect all traffic from HTTP to HTTPS + """Applies an enhacement on all domains. :param domains: list of ssl_vhosts - :type str + :type list of str + + :param enhancement: name of enhancement, e.g. http-header + :type str + + .. note:: when more options are need make options a list. + :param options: options to enhancement, e.g. Strict-Transport-Security + :type str """ with error_handler.ErrorHandler(self.installer.recovery_routine): From b76ef3a293d33e0481736f33b141c7715c8476b8 Mon Sep 17 00:00:00 2001 From: sagi Date: Thu, 12 Nov 2015 05:25:44 +0000 Subject: [PATCH 023/181] make lint happy --- letsencrypt/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 65098bc18..53874b7dd 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -386,14 +386,14 @@ class Client(object): self.apply_enhancement(domains, "http-header", "Upgrade-Insecure-Requests") - if (redirect or hsts or uir): + if redirect or hsts or uir: self.installer.restart() def apply_enhancement(self, domains, enhancement, options=None): - """Applies an enhacement on all domains. + """Applies an enhacement on all domains. :param domains: list of ssl_vhosts - :type list of str + :type list of str :param enhancement: name of enhancement, e.g. http-header :type str From f972cf19d37d6fc7197567699fbd1e3acfd73cac Mon Sep 17 00:00:00 2001 From: Nav Date: Thu, 12 Nov 2015 15:34:00 +0200 Subject: [PATCH 024/181] Adding storage logging --- letsencrypt/storage.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 52be94f68..87e6a5cce 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -7,6 +7,9 @@ import configobj import parsedatetime import pytz +import logging +import logging.handlers + from letsencrypt import constants from letsencrypt import crypto_util from letsencrypt import errors From 108757e3323bf236ac567559b1d8eb0230fb7a15 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Thu, 12 Nov 2015 17:45:33 -0600 Subject: [PATCH 025/181] Fall back to old cert method if fullchain isn't provided --- letsencrypt-apache/letsencrypt_apache/configurator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d8e929079..d891c39a9 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -212,13 +212,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.info("Deploying Certificate to VirtualHost %s", vhost.filep) # Assign the final directives; order is maintained in find_dir - if self.version >= (2, 4, 8): + if self.version >= (2, 4, 8) and fullchain_path is not None: logger.debug("Apache version (%s) is >= 2.4.8", ".".join(str(i) for i in self.version)) set_cert_path = fullchain_path self.aug.set(path["cert_path"][-1], fullchain_path) self.aug.set(path["cert_key"][-1], key_path) - elif self.version < (2, 4, 8): + else: # fall back to old SSL cert method logger.debug("Apache version (%s) is < 2.4.8", ".".join(str(i) for i in self.version)) set_cert_path = cert_path From 0af0beaeb7f83eff4fd17078f9df8688e08abeca Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Thu, 12 Nov 2015 22:27:05 -0600 Subject: [PATCH 026/181] Remove useless SSL removal on non-SSL vhosts --- letsencrypt-apache/letsencrypt_apache/configurator.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d891c39a9..154b19f5a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -584,10 +584,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Update Addresses self._update_ssl_vhosts_addrs(vh_p) - # Remove existing SSL directives - logger.info("Removing existing SSL directives") - self._remove_existing_ssl_directives(vh_p) - # Add directives self._add_dummy_ssl_directives(vh_p) From cec5bb8b8400f709bd64f21f52444b34f01dc417 Mon Sep 17 00:00:00 2001 From: Nav Date: Thu, 12 Nov 2015 21:31:00 +0200 Subject: [PATCH 027/181] Adding logging for _consistent --- letsencrypt/storage.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 87e6a5cce..ef4f502ac 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -16,6 +16,8 @@ from letsencrypt import errors from letsencrypt import error_handler from letsencrypt import le_util +logger = logging.getLogger(__name__) + ALL_FOUR = ("cert", "privkey", "chain", "fullchain") @@ -141,11 +143,13 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Each element must be referenced with an absolute path if any(not os.path.isabs(x) for x in (self.cert, self.privkey, self.chain, self.fullchain)): + logger.debug("Element is not reference with an absolute file") return False # Each element must exist and be a symbolic link if any(not os.path.islink(x) for x in (self.cert, self.privkey, self.chain, self.fullchain)): + logger.debug("Element is not a symbolic link") return False for kind in ALL_FOUR: link = getattr(self, kind) @@ -160,16 +164,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self.cli_config.archive_dir, self.lineagename) if not os.path.samefile(os.path.dirname(target), desired_directory): + #TODO: Split next line correctly + logger.debug("Element does not point within the cert " + "lineage's directory within the official " + "archive directory") return False # The link must point to a file that exists if not os.path.exists(target): + logger.debug("File does not exist") return False # The link must point to a file that follows the archive # naming convention pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) if not pattern.match(os.path.basename(target)): + logger.debug("Files does not follow the archive naming " + "convention") return False # It is NOT required that the link's target be a regular From 6760355a239068d8b744ecd785503c2df5f6559e Mon Sep 17 00:00:00 2001 From: Nav Date: Fri, 13 Nov 2015 11:44:10 +0200 Subject: [PATCH 028/181] Added more logging --- letsencrypt/storage.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index ef4f502ac..83aeb628b 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -265,6 +265,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes raise errors.CertStorageError("unknown kind of item") link = getattr(self, kind) if not os.path.exists(link): + logger.debug("File does not exist") return None target = os.readlink(link) if not os.path.isabs(target): @@ -289,11 +290,13 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) target = self.current_target(kind) if target is None or not os.path.exists(target): + logger.debug("File does not exist") target = "" matches = pattern.match(os.path.basename(target)) if matches: return int(matches.groups()[0]) else: + logger.debug("No matches") return None def version(self, kind, version): @@ -543,6 +546,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Renewals on the basis of revocation if self.ocsp_revoked(self.latest_common_version()): + logger.debug("Should renew, certificate is revoked") return True # Renewals on the basis of expiry time @@ -551,6 +555,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes "cert", self.latest_common_version())) now = pytz.UTC.fromutc(datetime.datetime.utcnow()) if expiry < add_time_interval(now, interval): + logger.debug("Should renew, certificate is expired") return True return False From d3806a926cc740497b84c8cd2f6f3cdfe0975b6d Mon Sep 17 00:00:00 2001 From: Jeff Hodges Date: Fri, 6 Nov 2015 16:58:40 -0800 Subject: [PATCH 029/181] use boulder's integration-test.py This prevents the integration tests from getting run before the boulder processes have finished booting in most cases. There's still some small races with debug ports going up before RPC ports, but this flushes the big ones (specifically, the WFE ports), and the boulder devs going to fix the rest in integration-test.py over time. This also makes boulder-start.sh a blocking operation. Now the TravisCI integration tests no longer requires boulder-start.sh, we can let the other priority of being easier for users to control (that is, basically, make it easy to Ctrl-C) take over. That plus the idea that self-daemonizing code is tricky to get right, especially over multiple platforms led me to not trying to get start.py to make itself asynchronous. Most of this change is code movement in order to allow developers to run boulder-start.sh once and boulder-integration.sh many times while also not duplicating that code in order to run the tests in TravisCI. I'm not a huge fan of both the letsencrypt's shell scripts and boulder's integration-test.py having hard-coded file dependencies in the other's repo. This, however, seemed like the smallest path to code that would spuriously break less. All the designs I was able to come up that were maybe smaller changes either had the "starts tests before the servers are up" problem or with a "each repo uses another repo's test code file" problem. Those problem on top of the "it's a bigger change" problem led me here. --- .travis.yml | 3 +-- tests/boulder-fetch.sh | 39 ++++++++++++++++++++++++++++++++++++ tests/boulder-start.sh | 40 +++---------------------------------- tests/travis-integration.sh | 19 ++++++++++++++++++ 4 files changed, 62 insertions(+), 39 deletions(-) create mode 100755 tests/boulder-fetch.sh create mode 100755 tests/travis-integration.sh diff --git a/.travis.yml b/.travis.yml index 96e28b1b0..8dde06ceb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -60,8 +60,7 @@ addons: - rsyslog install: "travis_retry pip install tox coveralls" -before_script: '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/boulder-start.sh' -script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || (source .tox/$TOXENV/bin/activate && ./tests/boulder-integration.sh))' +script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/travis-integration.sh)' after_success: '[ "$TOXENV" == "cover" ] && coveralls' diff --git a/tests/boulder-fetch.sh b/tests/boulder-fetch.sh new file mode 100755 index 000000000..a2c31b1d9 --- /dev/null +++ b/tests/boulder-fetch.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Download and run Boulder instance for integration testing + +# ugh, go version output is like: +# go version go1.4.2 linux/amd64 +GOVER=`go version | cut -d" " -f3 | cut -do -f2` + +# version comparison +function verlte { + #OS X doesn't support version sorting; emulate with sed + if [ `uname` == 'Darwin' ]; then + [ "$1" = "`echo -e \"$1\n$2\" | sed 's/\b\([0-9]\)\b/0\1/g' \ + | sort | sed 's/\b0\([0-9]\)/\1/g' | head -n1`" ] + else + [ "$1" = "`echo -e "$1\n$2" | sort -V | head -n1`" ] + fi +} + +if ! verlte 1.5 "$GOVER" ; then + echo "We require go version 1.5 or later; you have... $GOVER" + exit 1 +fi + +set -xe + +# `/...` avoids `no buildable Go source files` errors, for more info +# see `go help packages` +go get -d github.com/letsencrypt/boulder/... +cd $GOPATH/src/github.com/letsencrypt/boulder +# goose is needed for ./test/create_db.sh +wget https://github.com/jsha/boulder-tools/raw/master/goose.gz && \ + mkdir $GOPATH/bin && \ + zcat goose.gz > $GOPATH/bin/goose && \ + chmod +x $GOPATH/bin/goose +./test/create_db.sh +# listenbuddy is needed for ./start.py +go get github.com/jsha/listenbuddy +cd - + diff --git a/tests/boulder-start.sh b/tests/boulder-start.sh index 47c1b6278..acf8f0bbf 100755 --- a/tests/boulder-start.sh +++ b/tests/boulder-start.sh @@ -1,43 +1,9 @@ #!/bin/bash -# Download and run Boulder instance for integration testing - - -# ugh, go version output is like: -# go version go1.4.2 linux/amd64 -GOVER=`go version | cut -d" " -f3 | cut -do -f2` - -# version comparison -function verlte { - #OS X doesn't support version sorting; emulate with sed - if [ `uname` == 'Darwin' ]; then - [ "$1" = "`echo -e \"$1\n$2\" | sed 's/\b\([0-9]\)\b/0\1/g' \ - | sort | sed 's/\b0\([0-9]\)/\1/g' | head -n1`" ] - else - [ "$1" = "`echo -e "$1\n$2" | sort -V | head -n1`" ] - fi -} - -if ! verlte 1.5 "$GOVER" ; then - echo "We require go version 1.5 or later; you have... $GOVER" - exit 1 -fi - -set -xe export GOPATH="${GOPATH:-/tmp/go}" export PATH="$GOPATH/bin:$PATH" -# `/...` avoids `no buildable Go source files` errors, for more info -# see `go help packages` -go get -d github.com/letsencrypt/boulder/... +./tests/boulder-fetch.sh + cd $GOPATH/src/github.com/letsencrypt/boulder -# goose is needed for ./test/create_db.sh -wget https://github.com/jsha/boulder-tools/raw/master/goose.gz && \ - mkdir $GOPATH/bin && \ - zcat goose.gz > $GOPATH/bin/goose && \ - chmod +x $GOPATH/bin/goose -./test/create_db.sh -# listenbuddy is needed for ./start.py -go get github.com/jsha/listenbuddy -./start.py & -# Hopefully start.py bootstraps before integration test is started... +./start.py diff --git a/tests/travis-integration.sh b/tests/travis-integration.sh new file mode 100755 index 000000000..3b507bb86 --- /dev/null +++ b/tests/travis-integration.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -o errexit + +./tests/boulder-fetch.sh + +source .tox/$TOXENV/bin/activate + +export LETSENCRYPT_PATH=`pwd` + +cd $GOPATH/src/github.com/letsencrypt/boulder/ + +# boulder's integration-test.py has code that knows to start and wait for the +# boulder processes to start reliably and then will run the letsencrypt +# boulder-interation.sh on its own. The --letsencrypt flag says to run only the +# letsencrypt tests (instead of any other client tests it might run). We're +# going to want to define a more robust interaction point between the boulder +# and letsencrypt tests, but that will be better built off of this. +python test/integration-test.py --letsencrypt From 16659b54333c379f44270c62b4c6ab3791531623 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Fri, 13 Nov 2015 15:49:59 -0600 Subject: [PATCH 030/181] Add `minus` option to _remove_existing_ssl_directives() Add test case as well. --- .../letsencrypt_apache/configurator.py | 7 ++--- .../tests/configurator_test.py | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 154b19f5a..c52f3fc70 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -652,9 +652,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return ssl_addrs - def _remove_existing_ssl_directives(self, vh_path): - for directive in ["SSLCertificateKeyFile", "SSLCertificateChainFile", - "SSLCertificateFile"]: + def _remove_existing_ssl_directives(self, vh_path, minus={}): + directives_to_remove = list({"SSLCertificateKeyFile", "SSLCertificateChainFile", + "SSLCertificateFile"} - set(minus)) + for directive in directives_to_remove: logger.debug("Trying to delete directive '%s'", directive) directive_tree = self.parser.find_dir(directive, None, vh_path) logger.debug("Parser found %s", directive_tree) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index f6cef0470..0632db30a 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -386,6 +386,7 @@ class TwoVhost80Test(util.ApacheTest): for directive in BOGUS_DIRECTIVES: self.config.parser.add_dir(self.vh_truth[0].path, directive, ["bogus"]) self.config.save() + self.config._remove_existing_ssl_directives(self.vh_truth[0].path) self.config.save() @@ -394,6 +395,31 @@ class TwoVhost80Test(util.ApacheTest): self.config.parser.find_dir(directive, None, self.vh_truth[0].path), []) + def test_remove_existing_ssl_directives_minus_some(self): + # pylint: disable=protected-access + BOGUS_DIRECTIVES = ["SSLCertificateKeyFile", + "SSLCertificateChainFile", "SSLCertificateFile"] + MINUS_DIRECTIVES = ["SSLCertificateKeyFile", "SSLCertificateFile"] + for directive in BOGUS_DIRECTIVES: + self.config.parser.add_dir(self.vh_truth[0].path, directive, ["bogus"]) + self.config.save() + + self.config._remove_existing_ssl_directives(self.vh_truth[0].path, + minus=MINUS_DIRECTIVES) + self.config.save() + + for directive in BOGUS_DIRECTIVES: + if directive not in MINUS_DIRECTIVES: + self.assertEqual( + self.config.parser.find_dir(directive, None, self.vh_truth[0].path), + [], + msg="directive %s should have been removed" % directive) + else: + self.assertNotEqual( + self.config.parser.find_dir(directive, None, self.vh_truth[0].path), + [], + msg="directive %s should still exist" % directive) + def test_make_vhost_ssl_extra_vhs(self): self.config.aug.match = mock.Mock(return_value=["p1", "p2"]) self.assertRaises( From 9bf1b99b5bfcb5d7ab743b8b49068ba5bd8a463b Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Fri, 13 Nov 2015 17:16:50 -0600 Subject: [PATCH 031/181] Remove existing SSL directives for SSL vhosts --- letsencrypt-apache/letsencrypt_apache/configurator.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index c52f3fc70..ccb0ebc62 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -265,6 +265,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Try to find a reasonable vhost vhost = self._find_best_vhost(target_name) if vhost is not None: + if vhost.ssl: + # remove existing SSL directives (minus the ones we'll use anyway, + # since we want to preserve order) + self._remove_existing_ssl_directives( + vhost, + minus=['SSLCertificatePath', 'SSLCertificateKeyFile']) if not vhost.ssl: vhost = self.make_vhost_ssl(vhost) From 1014cf5d9e698eacfbc91907b0f11a11ce94620f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20L=C3=A9one?= Date: Wed, 11 Nov 2015 13:23:28 +0100 Subject: [PATCH 032/181] Dict can be litteral --- letsencrypt-apache/letsencrypt_apache/configurator.py | 8 ++------ letsencrypt-nginx/letsencrypt_nginx/parser.py | 7 +++---- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index f10f0c241..c811501a9 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -187,12 +187,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # cert_key... can all be parsed appropriately self.prepare_server_https("443") - path = {} - - path["cert_path"] = self.parser.find_dir( - "SSLCertificateFile", None, vhost.path) - path["cert_key"] = self.parser.find_dir( - "SSLCertificateKeyFile", None, vhost.path) + path = {"cert_path": self.parser.find_dir("SSLCertificateFile", None, vhost.path), + "cert_key": self.parser.find_dir("SSLCertificateKeyFile", None, vhost.path)} # Only include if a certificate chain is specified if chain_path is not None: diff --git a/letsencrypt-nginx/letsencrypt_nginx/parser.py b/letsencrypt-nginx/letsencrypt_nginx/parser.py index fb79703dc..de7ab772c 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/parser.py +++ b/letsencrypt-nginx/letsencrypt_nginx/parser.py @@ -448,10 +448,9 @@ def _parse_server(server): :rtype: dict """ - parsed_server = {} - parsed_server['addrs'] = set() - parsed_server['ssl'] = False - parsed_server['names'] = set() + parsed_server = {'addrs': set(), + 'ssl': False, + 'names': set()} for directive in server: if directive[0] == 'listen': From 361b67276ebea50c19ea3c291410f048ea34cfc4 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sat, 14 Nov 2015 11:26:42 -0600 Subject: [PATCH 033/181] Rewrite certificate install logic Tests are being written --- .../letsencrypt_apache/configurator.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index ccb0ebc62..44821b262 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -210,25 +210,27 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "Unable to find cert and/or key directives") logger.info("Deploying Certificate to VirtualHost %s", vhost.filep) + logger.debug("Apache version is %s", + ".".join(str(i) for i in self.version)) - # Assign the final directives; order is maintained in find_dir - if self.version >= (2, 4, 8) and fullchain_path is not None: - logger.debug("Apache version (%s) is >= 2.4.8", - ".".join(str(i) for i in self.version)) - set_cert_path = fullchain_path - self.aug.set(path["cert_path"][-1], fullchain_path) - self.aug.set(path["cert_key"][-1], key_path) - else: # fall back to old SSL cert method - logger.debug("Apache version (%s) is < 2.4.8", - ".".join(str(i) for i in self.version)) + if self.version < (2, 4, 8) or (chain_path and not fullchain_path): + # install SSLCertificateFile, SSLCertificateKeyFile, and SSLCertificateChainFile directives set_cert_path = cert_path self.aug.set(path["cert_path"][-1], cert_path) self.aug.set(path["cert_key"][-1], key_path) - if not path["chain_path"]: - self.parser.add_dir(vhost.path, - "SSLCertificateChainFile", chain_path) - else: - self.aug.set(path["chain_path"][-1], chain_path) + if chain_path is not None: + if not path["chain_path"]: + self.parser.add_dir(vhost.path, + "SSLCertificateChainFile", chain_path) + else: + self.aug.set(path["chain_path"][-1], chain_path) + else: + if not fullchain_path: + raise errors.PluginError("Please provide the --fullchain-path\ + option pointing to your full chain file") + set_cert_path = fullchain_path + self.aug.set(path["cert_path"][-1], fullchain_path) + self.aug.set(path["cert_key"][-1], key_path) # Save notes about the transaction that took place self.save_notes += ("Changed vhost at %s with addresses of %s\n" From 425bb98bed32c904ec696e9174556305d0fbb40b Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sat, 14 Nov 2015 11:40:51 -0600 Subject: [PATCH 034/181] Fix lint warnings --- letsencrypt-apache/letsencrypt_apache/configurator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 44821b262..4f4e61d9f 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -214,7 +214,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ".".join(str(i) for i in self.version)) if self.version < (2, 4, 8) or (chain_path and not fullchain_path): - # install SSLCertificateFile, SSLCertificateKeyFile, and SSLCertificateChainFile directives + # install SSLCertificateFile, SSLCertificateKeyFile, + # and SSLCertificateChainFile directives set_cert_path = cert_path self.aug.set(path["cert_path"][-1], cert_path) self.aug.set(path["cert_key"][-1], key_path) @@ -660,7 +661,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return ssl_addrs - def _remove_existing_ssl_directives(self, vh_path, minus={}): + def _remove_existing_ssl_directives(self, vh_path, minus=None): + minus = minus or {} directives_to_remove = list({"SSLCertificateKeyFile", "SSLCertificateChainFile", "SSLCertificateFile"} - set(minus)) for directive in directives_to_remove: From 691abdc377ec38226f25d17e8ee03b84c4578988 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sat, 14 Nov 2015 12:00:08 -0600 Subject: [PATCH 035/181] Fix for py26 (it doesn't have set literals) --- letsencrypt-apache/letsencrypt_apache/configurator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 4f4e61d9f..ed58b770a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -662,9 +662,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return ssl_addrs def _remove_existing_ssl_directives(self, vh_path, minus=None): - minus = minus or {} - directives_to_remove = list({"SSLCertificateKeyFile", "SSLCertificateChainFile", - "SSLCertificateFile"} - set(minus)) + minus = minus or set() + directives_to_remove = list(set(["SSLCertificateKeyFile", "SSLCertificateChainFile", + "SSLCertificateFile"]) - set(minus)) for directive in directives_to_remove: logger.debug("Trying to delete directive '%s'", directive) directive_tree = self.parser.find_dir(directive, None, vh_path) From a1e6db2144bd80bf35a667bd8e9c3193c133f7b2 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sat, 14 Nov 2015 14:27:38 -0600 Subject: [PATCH 036/181] Fix logic in which the --fullchain error would never be hit --- letsencrypt-apache/letsencrypt_apache/configurator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index ed58b770a..17617b1d2 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -213,7 +213,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.debug("Apache version is %s", ".".join(str(i) for i in self.version)) - if self.version < (2, 4, 8) or (chain_path and not fullchain_path): + if self.version < (2, 4, 8): # install SSLCertificateFile, SSLCertificateKeyFile, # and SSLCertificateChainFile directives set_cert_path = cert_path From e6113698f23d347b14c579ec65f61baddbeb7383 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sat, 14 Nov 2015 14:28:17 -0600 Subject: [PATCH 037/181] Test that no fullchain throws an error --- .../letsencrypt_apache/tests/configurator_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 0632db30a..d792ea59d 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -262,6 +262,20 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(configurator.get_file_path(loc_key[0]), self.vh_truth[1].filep) + def test_deploy_cert_newssl_no_fullchain(self): + self.config = util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, version=(2, 4, 16)) + + self.config.parser.modules.add("ssl_module") + self.config.parser.modules.add("mod_ssl.c") + + # Get the default 443 vhost + self.config.assoc["random.demo"] = self.vh_truth[1] + self.assertRaises(errors.PluginError, + lambda: self.config.deploy_cert( + "random.demo", "example/cert.pem", "example/key.pem", + "example/cert_chain.pem")) + def test_deploy_cert(self): self.config.parser.modules.add("ssl_module") self.config.parser.modules.add("mod_ssl.c") From 62f19496da76b7bf4ab5a86eda5acdbd5d705232 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sun, 15 Nov 2015 23:00:18 -0600 Subject: [PATCH 038/181] Rewrite vhost cleaning logic --- .../letsencrypt_apache/configurator.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 17617b1d2..e610010a0 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -268,19 +268,21 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Try to find a reasonable vhost vhost = self._find_best_vhost(target_name) if vhost is not None: - if vhost.ssl: - # remove existing SSL directives (minus the ones we'll use anyway, - # since we want to preserve order) - self._remove_existing_ssl_directives( - vhost, - minus=['SSLCertificatePath', 'SSLCertificateKeyFile']) if not vhost.ssl: vhost = self.make_vhost_ssl(vhost) self.assoc[target_name] = vhost return vhost - return self._choose_vhost_from_list(target_name) + vhost = self._choose_vhost_from_list(target_name) + if vhost.ssl: + # remove duplicated or conflicting ssl directives + self._deduplicate_directives(vhost.path, + ["SSLCertificateFile", "SSLCertificateKeyFile"]) + # remove all problematic directives + self._remove_directives(vhost.path, ["SSLCertificateChainFile"]) + + return vhost def _choose_vhost_from_list(self, target_name): # Select a vhost from a list @@ -661,17 +663,17 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return ssl_addrs - def _remove_existing_ssl_directives(self, vh_path, minus=None): - minus = minus or set() - directives_to_remove = list(set(["SSLCertificateKeyFile", "SSLCertificateChainFile", - "SSLCertificateFile"]) - set(minus)) - for directive in directives_to_remove: - logger.debug("Trying to delete directive '%s'", directive) - directive_tree = self.parser.find_dir(directive, None, vh_path) - logger.debug("Parser found %s", directive_tree) - if directive_tree: - logger.debug("Removing directive %s", directive) - self.aug.remove(re.sub(r"/\w*$", "", directive_tree[-1])) + def _deduplicate_directives(self, vh_path, directives): + for directive in directives: + while len(self.parser.find_dir(directive, None, vh_path, False)) > 1: + directive_path = self.parser.find_dir(directive, None, vh_path, False) + self.aug.remove(re.sub(r"/\w*$", "", directive_path[0])) + + def _remove_directives(self, vh_path, directives): + for directive in directives: + while len(self.parser.find_dir(directive, None, vh_path, False)) > 0: + directive_path = self.parser.find_dir(directive, None, vh_path, False) + self.aug.remove(re.sub(r"/\w*$", "", directive_path[0])) def _add_dummy_ssl_directives(self, vh_path): self.parser.add_dir(vh_path, "SSLCertificateFile", From 76320c2d3758f645310fb91b7d7292fb4edc3afb Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Sun, 15 Nov 2015 23:00:42 -0600 Subject: [PATCH 039/181] Test vhost cleaning --- .../tests/configurator_test.py | 90 ++++++++++++------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index d792ea59d..e70c797bc 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -122,6 +122,37 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual( self.vh_truth[1], self.config.choose_vhost("none.com")) + @mock.patch("letsencrypt_apache.display_ops.select_vhost") + def test_choose_vhost_cleans_vhost_ssl(self, mock_select): + for directive in ["SSLCertificateFile", "SSLCertificateKeyFile", + "SSLCertificateChainFile", "SSLCACertificatePath"]: + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, directive, ["bogus"]) + self.config.save() + mock_select.return_value = self.vh_truth[1] + + vhost = self.config.choose_vhost("none.com") + self.config.save() + + with open(vhost.filep) as f: + print f.read() + + loc_cert = self.config.parser.find_dir( + 'SSLCertificateFile', None, self.vh_truth[1].path, False) + loc_key = self.config.parser.find_dir( + 'SSLCertificateKeyFile', None, self.vh_truth[1].path, False) + loc_chain = self.config.parser.find_dir( + 'SSLCertificateChainFile', None, self.vh_truth[1].path, False) + loc_cacert = self.config.parser.find_dir( + 'SSLCACertificatePath', None, self.vh_truth[1].path, False) + + self.assertEqual(len(loc_cert), 1) + self.assertEqual(len(loc_key), 1) + + self.assertEqual(len(loc_chain), 0) + + self.assertEqual(len(loc_cacert), 10) + @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_select_vhost_non_ssl(self, mock_select): mock_select.return_value = self.vh_truth[0] @@ -393,46 +424,37 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(len(self.config.vhosts), 5) - def test_remove_existing_ssl_directives(self): + def test_deduplicate_directives(self): # pylint: disable=protected-access - BOGUS_DIRECTIVES = ["SSLCertificateKeyFile", - "SSLCertificateChainFile", "SSLCertificateFile"] - for directive in BOGUS_DIRECTIVES: - self.config.parser.add_dir(self.vh_truth[0].path, directive, ["bogus"]) + DIRECTIVE = "Foo" + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, DIRECTIVE, ["bar"]) self.config.save() - self.config._remove_existing_ssl_directives(self.vh_truth[0].path) + self.config._deduplicate_directives(self.vh_truth[1].path, [DIRECTIVE]) self.config.save() - for directive in BOGUS_DIRECTIVES: + self.assertEqual( + len(self.config.parser.find_dir( + DIRECTIVE, None, self.vh_truth[1].path, False)), + 1) + + def test_remove_directives(self): + # pylint: disable=protected-access + DIRECTIVES = ["Foo", "Bar"] + for directive in DIRECTIVES: + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, directive, ["baz"]) + self.config.save() + + self.config._remove_directives(self.vh_truth[1].path, DIRECTIVES) + self.config.save() + + for directive in DIRECTIVES: self.assertEqual( - self.config.parser.find_dir(directive, None, self.vh_truth[0].path), - []) - - def test_remove_existing_ssl_directives_minus_some(self): - # pylint: disable=protected-access - BOGUS_DIRECTIVES = ["SSLCertificateKeyFile", - "SSLCertificateChainFile", "SSLCertificateFile"] - MINUS_DIRECTIVES = ["SSLCertificateKeyFile", "SSLCertificateFile"] - for directive in BOGUS_DIRECTIVES: - self.config.parser.add_dir(self.vh_truth[0].path, directive, ["bogus"]) - self.config.save() - - self.config._remove_existing_ssl_directives(self.vh_truth[0].path, - minus=MINUS_DIRECTIVES) - self.config.save() - - for directive in BOGUS_DIRECTIVES: - if directive not in MINUS_DIRECTIVES: - self.assertEqual( - self.config.parser.find_dir(directive, None, self.vh_truth[0].path), - [], - msg="directive %s should have been removed" % directive) - else: - self.assertNotEqual( - self.config.parser.find_dir(directive, None, self.vh_truth[0].path), - [], - msg="directive %s should still exist" % directive) + len(self.config.parser.find_dir( + directive, None, self.vh_truth[1].path, False)), + 0) def test_make_vhost_ssl_extra_vhs(self): self.config.aug.match = mock.Mock(return_value=["p1", "p2"]) From 062b4722e238471d53bef20b54f573959b8b1801 Mon Sep 17 00:00:00 2001 From: Nav Date: Mon, 16 Nov 2015 15:01:45 +0200 Subject: [PATCH 040/181] Adding even more logging to storage.py --- letsencrypt/storage.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 83aeb628b..d1c8a8314 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -164,7 +164,6 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self.cli_config.archive_dir, self.lineagename) if not os.path.samefile(os.path.dirname(target), desired_directory): - #TODO: Split next line correctly logger.debug("Element does not point within the cert " "lineage's directory within the official " "archive directory") @@ -607,6 +606,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes cli_config.live_dir): if not os.path.exists(i): os.makedirs(i, 0700) + logger.debug("Creating CLI config directories") config_file, config_filename = le_util.unique_lineage_name( cli_config.renewal_configs_dir, lineagename) if not config_filename.endswith(".conf"): @@ -627,6 +627,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes "live directory exists for " + lineagename) os.mkdir(archive) os.mkdir(live_dir) + logger.debug("Archive and live directories created") relative_archive = os.path.join("..", "..", "archive", lineagename) # Put the data into the appropriate files on disk @@ -636,15 +637,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.symlink(os.path.join(relative_archive, kind + "1.pem"), target[kind]) with open(target["cert"], "w") as f: + logger.debug("Writing certificate") f.write(cert) with open(target["privkey"], "w") as f: + logger.debug("Writing private key") f.write(privkey) # XXX: Let's make sure to get the file permissions right here with open(target["chain"], "w") as f: + logger.debug("Writing chain") f.write(chain) with open(target["fullchain"], "w") as f: # assumes that OpenSSL.crypto.dump_certificate includes # ending newline character + logger.debug("Writing full chain") f.write(cert + chain) # Document what we've done in a new renewal config file @@ -659,6 +664,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes " in the renewal process"] # TODO: add human-readable comments explaining other available # parameters + logger.debug("Writing new config") new_config.write() return cls(new_config.filename, cli_config) @@ -712,13 +718,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.symlink(old_privkey, target["privkey"]) else: with open(target["privkey"], "w") as f: + logger.debug("Writing new private key") f.write(new_privkey) # Save everything else with open(target["cert"], "w") as f: + logger.debug("Writing certificate") f.write(new_cert) with open(target["chain"], "w") as f: + logger.debug("Writing chain") f.write(new_chain) with open(target["fullchain"], "w") as f: + logger.debug("Writing full chain") f.write(new_cert + new_chain) return target_version From ddf5b28f7db5f2fd312f2a9e6a901f3bd9a8e6f3 Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 16 Nov 2015 20:06:16 +0000 Subject: [PATCH 041/181] fix tests and make linter happy --- letsencrypt/client.py | 36 ++++++++++++++++++-------------- letsencrypt/tests/client_test.py | 28 ++++++++++++++++++++----- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e009400d2..cc1f2aadb 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -359,7 +359,7 @@ class Client(object): :param list domains: list of domains to configure - :ivar namespace: Namespace typically produced by + :ivar config: Namespace typically produced by :meth:`argparse.ArgumentParser.parse_args`. :type namespace: :class:`argparse.Namespace` @@ -367,29 +367,32 @@ class Client(object): client. """ - redirect = config.redirect - hsts = config.hsts - uir = config.uir # Upgrade Insecure Requests - if self.installer is None: logger.warning("No installer is specified, there isn't any " "configuration to enhance.") raise errors.Error("No installer available") - if redirect is None: + if config is None: redirect = enhancements.ask("redirect") + if redirect: + self.apply_enhancement(domains, "redirect") + else: + redirect = config.redirect + hsts = config.hsts + uir = config.uir # Upgrade Insecure Requests - if redirect: - self.apply_enhancement(domains, "redirect") + if redirect: + self.apply_enhancement(domains, "redirect") - if hsts: - self.apply_enhancement(domains, "http-header", - "Strict-Transport-Security") - if uir: - self.apply_enhancement(domains, "http-header", - "Upgrade-Insecure-Requests") + if hsts: + self.apply_enhancement(domains, "http-header", + "Strict-Transport-Security") + if uir: + self.apply_enhancement(domains, "http-header", + "Upgrade-Insecure-Requests") + msg = ("We were unable to restart web server") if redirect or hsts or uir: with error_handler.ErrorHandler(self._rollback_and_restart, msg): self.installer.restart() @@ -408,8 +411,9 @@ class Client(object): :type str """ - msg = ("We were unable to set up a redirect for your server, " - "however, we successfully installed your certificate.") + msg = ("We were unable to set up enhancement %s for your server, " + "however, we successfully installed your certificate." + % (enhancement)) with error_handler.ErrorHandler(self._recovery_routine_with_msg, msg): for dom in domains: try: diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index d396e25bc..8b84fd3e2 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -20,6 +20,15 @@ KEY = test_util.load_vector("rsa512_key.pem") CSR_SAN = test_util.load_vector("csr-san.der") +class ConfigHelper(object): + """Creates a dummy object to imitate a namespace object + + Example: cfg = ConfigHelper(redirect=True, hsts=False, uir=False) + will result in: cfg.redirect=True, cfg.hsts=False, etc. + """ + def __init__(self, **kwds): + self.__dict__.update(kwds) + class RegisterTest(unittest.TestCase): """Tests for letsencrypt.client.register.""" @@ -219,7 +228,7 @@ class ClientTest(unittest.TestCase): self.client.installer = installer self.client.enhance_config(["foo.bar"]) - installer.enhance.assert_called_once_with("foo.bar", "redirect") + installer.enhance.assert_called_once_with("foo.bar", "redirect", None) self.assertEqual(installer.save.call_count, 1) installer.restart.assert_called_once_with() @@ -236,8 +245,10 @@ class ClientTest(unittest.TestCase): self.client.installer = installer installer.enhance.side_effect = errors.PluginError + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) installer.recovery_routine.assert_called_once_with() self.assertEqual(mock_get_utility().add_message.call_count, 1) @@ -250,8 +261,10 @@ class ClientTest(unittest.TestCase): self.client.installer = installer installer.save.side_effect = errors.PluginError + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) installer.recovery_routine.assert_called_once_with() self.assertEqual(mock_get_utility().add_message.call_count, 1) @@ -264,8 +277,11 @@ class ClientTest(unittest.TestCase): self.client.installer = installer installer.restart.side_effect = [errors.PluginError, None] + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) + self.assertEqual(mock_get_utility().add_message.call_count, 1) installer.rollback_checkpoints.assert_called_once_with() self.assertEqual(installer.restart.call_count, 2) @@ -280,8 +296,10 @@ class ClientTest(unittest.TestCase): installer.restart.side_effect = errors.PluginError installer.rollback_checkpoints.side_effect = errors.ReverterError + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.PluginError, - self.client.enhance_config, ["foo.bar"], True) + self.client.enhance_config, ["foo.bar"], config) self.assertEqual(mock_get_utility().add_message.call_count, 1) installer.rollback_checkpoints.assert_called_once_with() self.assertEqual(installer.restart.call_count, 1) From 1098126b7b28b54b222b458737b0a6bcc6ac04df Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 16 Nov 2015 20:31:49 +0000 Subject: [PATCH 042/181] tests hsts, redirect and uir --- letsencrypt/tests/client_test.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 8b84fd3e2..c66ad1e08 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -232,6 +232,32 @@ class ClientTest(unittest.TestCase): self.assertEqual(installer.save.call_count, 1) installer.restart.assert_called_once_with() + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config_no_ask(self, mock_enhancements): + self.assertRaises(errors.Error, + self.client.enhance_config, ["foo.bar"]) + + mock_enhancements.ask.return_value = True + installer = mock.MagicMock() + self.client.installer = installer + + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_called_with("foo.bar", "redirect", None) + + config = ConfigHelper(redirect=False, hsts=True, uir=False) + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_called_with("foo.bar", "http-header", + "Strict-Transport-Security") + + config = ConfigHelper(redirect=False, hsts=False, uir=True) + self.client.enhance_config(["foo.bar"], config) + installer.enhance.assert_called_with("foo.bar", "http-header", + "Upgrade-Insecure-Requests") + + self.assertEqual(installer.save.call_count, 3) + self.assertEqual(installer.restart.call_count, 3) + def test_enhance_config_no_installer(self): self.assertRaises(errors.Error, self.client.enhance_config, ["foo.bar"]) From 17ea7bb316eef6e0cef8785e6e9c79fc06eb987f Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 16 Nov 2015 20:41:39 +0000 Subject: [PATCH 043/181] comment and simplify things --- letsencrypt/cli.py | 4 ++-- letsencrypt/client.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 3e8d4d833..8393d6dd0 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -913,12 +913,12 @@ def prepare_and_parse_args(plugins, args): "security", "--hsts", action="store_true", help="Add the Strict-Transport-Security header to every HTTP response." " Forcing browser to use always use SSL for the domain." - " Defends against SSL Stripping.", dest="hsts") + " Defends against SSL Stripping.", dest="hsts", default=False) helpful.add( "security", "--uir", action="store_true", help="Add the \"Content-Security-Policy: upgrade-insecure-requests\"" " header to every HTTP response. Forcing the browser to use" - " https:// for every http:// resource.", dest="uir") + " https:// for every http:// resource.", dest="uir", default=False) helpful.add( "security", "--strict-permissions", action="store_true", help="Require that all configuration files are owned by the current " diff --git a/letsencrypt/client.py b/letsencrypt/client.py index cc1f2aadb..e3e365bb8 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -410,6 +410,9 @@ class Client(object): :param options: options to enhancement, e.g. Strict-Transport-Security :type str + :raises .errors.Error: if no installer is specified in the + client. + """ msg = ("We were unable to set up enhancement %s for your server, " "however, we successfully installed your certificate." From 85d7b9406d4e0d1baa2f832f848608b04f873a71 Mon Sep 17 00:00:00 2001 From: Daniel Aleksandersen Date: Mon, 16 Nov 2015 23:37:17 +0100 Subject: [PATCH 044/181] Test dnf before yum yum may still be installed (by default in recent Fedoras) and will display a deprecation and migration message. On the other hand, dnf either is or isn't installed and the test will proceed as intended. (dnf is the modern replacement for yum.) --- bootstrap/_rpm_common.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh index 9f670da6e..e4219d06b 100755 --- a/bootstrap/_rpm_common.sh +++ b/bootstrap/_rpm_common.sh @@ -4,12 +4,13 @@ # - Fedora 22 (x64) # - Centos 7 (x64: on AWS EC2 t2.micro, DigitalOcean droplet) -if type yum 2>/dev/null -then - tool=yum -elif type dnf 2>/dev/null +if type dnf 2>/dev/null then tool=dnf +elif type yum 2>/dev/null +then + tool=yum + else echo "Neither yum nor dnf found. Aborting bootstrap!" exit 1 From 36842b7bbbd52f0086a706264f92b7ba6519ded7 Mon Sep 17 00:00:00 2001 From: Miquel Ruiz Date: Sat, 24 Oct 2015 13:25:04 +0100 Subject: [PATCH 045/181] Ask for email unless --allow-unsafe-registration Add new option that explicitly allows to not provide an email. Fixes #414 --- letsencrypt/cli.py | 14 ++++++++++++-- letsencrypt/display/ops.py | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index e9cb31a21..175354f5d 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -139,9 +139,9 @@ def _determine_account(args, config): elif len(accounts) == 1: acc = accounts[0] else: # no account registered yet - if args.email is None: + if args.email is None and not args.allow_unsafe_registration: args.email = display_ops.get_email() - if not args.email: # get_email might return "" + else: args.email = None def _tos_cb(regr): @@ -842,6 +842,16 @@ def prepare_and_parse_args(plugins, args): helpful.add( None, "-t", "--text", dest="text_mode", action="store_true", help="Use the text output instead of the curses UI.") + helpful.add( + None, "--allow-unsafe-registration", action="store_true", + help="Specifying this flag enables registering an account with no " + "email address. This is strongly discouraged, because in the " + "event of key loss or account compromise you will irrevocably " + "lose access to your account. You will also be unable to receive " + "notice about impending expiration of revocation of your " + "certificates. Updates to the Subscriber Agreement will still " + "affect you, and will be effective N days after posting an " + "update to the web site.") helpful.add(None, "-m", "--email", help=config_help("email")) # positional arg shadows --domains, instead of appending, and # --domains is useful, because it can be stored in config diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 37ce66b62..31913e708 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -124,7 +124,9 @@ def get_email(): """ while True: code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address (used for urgent notices and lost key recovery)") + "Enter email address (mandatory since no " + "--allow-unsafe-registration was provided)" + "(used for urgent notices and lost key recovery)") if code == display_util.OK: if le_util.safe_email(email): From 99f9f1b106cc8594feda849d78bdc52f221a9f82 Mon Sep 17 00:00:00 2001 From: Miquel Ruiz Date: Sun, 25 Oct 2015 10:17:25 +0000 Subject: [PATCH 046/181] Rename option and fix displayed info --- letsencrypt/cli.py | 4 ++-- letsencrypt/display/ops.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 175354f5d..a36a2ff4b 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -139,7 +139,7 @@ def _determine_account(args, config): elif len(accounts) == 1: acc = accounts[0] else: # no account registered yet - if args.email is None and not args.allow_unsafe_registration: + if args.email is None and not args.register_unsafely_without_email: args.email = display_ops.get_email() else: args.email = None @@ -843,7 +843,7 @@ def prepare_and_parse_args(plugins, args): None, "-t", "--text", dest="text_mode", action="store_true", help="Use the text output instead of the curses UI.") helpful.add( - None, "--allow-unsafe-registration", action="store_true", + None, "--register-unsafely-without-email", action="store_true", help="Specifying this flag enables registering an account with no " "email address. This is strongly discouraged, because in the " "event of key loss or account compromise you will irrevocably " diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 31913e708..37e18e6d8 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -124,9 +124,10 @@ def get_email(): """ while True: code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address (mandatory since no " - "--allow-unsafe-registration was provided)" - "(used for urgent notices and lost key recovery)") + "Enter email address " + "(used for urgent notices and lost key recovery)\n\n" + "If you really want to skip this, run the client with " + "--register-unsafely-without-email") if code == display_util.OK: if le_util.safe_email(email): From 37089b9eff18929ffbc99be0769a2916357baa35 Mon Sep 17 00:00:00 2001 From: Miquel Ruiz Date: Sun, 25 Oct 2015 10:18:06 +0000 Subject: [PATCH 047/181] Ensure cancelling without password exits --- letsencrypt/client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 8e053e926..959eb9917 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -100,6 +100,11 @@ def register(config, account_storage, tos_cb=None): if account_storage.find_all(): logger.info("There are already existing accounts for %s", config.server) if config.email is None: + if not config.register_unsafely_without_email: + msg = ("No email was provided and " + "--register-unsafely-without-email was not present.") + logger.warn(msg) + raise errors.Error(msg) logger.warn("Registering without email!") # Each new registration shall use a fresh new key From 77f2a29bfe8fa71fe3531c05cdb4296f66397631 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sun, 15 Nov 2015 23:16:05 -0800 Subject: [PATCH 048/181] Show the message about unsafe registration only conditionally - If the user enters a blank email, or one that doesn't check out --- letsencrypt/display/ops.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 37e18e6d8..5a51647a8 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -114,26 +114,36 @@ def pick_configurator( config, default, plugins, question, (interfaces.IAuthenticator, interfaces.IInstaller)) - -def get_email(): +def get_email(more=False, invalid=False): """Prompt for valid email address. + :param bool more: -- explain why the email is strongly advisable, but how to + skip it + "param bool invalid: -- true if the user just typed something, but it wasn't + a valid-looking email + :returns: Email or ``None`` if cancelled by user. :rtype: str """ - while True: - code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address " - "(used for urgent notices and lost key recovery)\n\n" - "If you really want to skip this, run the client with " - "--register-unsafely-without-email") + msg = "Enter email address (used for urgent notices and lost key recovery)" + if invalid: + msg = "There seem to be problems with that address. " + msg + if more: + msg += ('\n\nIf you really want to skip this, you can run the client with ' + '--register-unsafely-without-email but make sure you backup your ' + 'account key from /etc/letsencrypt/accounts\n\n') + code, email = zope.component.getUtility(interfaces.IDisplay).input(msg) - if code == display_util.OK: - if le_util.safe_email(email): - return email + if code == display_util.OK: + if le_util.safe_email(email): + return email else: - return None + # TODO catch the server's ACME invalid email address error, and + # make a similar call when that happens + return get_email(more=True, invalid=(email != "")) + else: + return None def choose_account(accounts): From 371e57fc51eb74d385575b7d3701067e617eaccd Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 16 Nov 2015 11:17:41 -0800 Subject: [PATCH 049/181] If the server rejects an email address, ask again rather than erroring This is essentially symmetrical with cases where the client itself can tell that what the user entered isn't an email address. --- letsencrypt/client.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 959eb9917..236a15a34 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -115,7 +115,7 @@ def register(config, account_storage, tos_cb=None): backend=default_backend()))) acme = acme_from_config_key(config, key) # TODO: add phone? - regr = acme.register(messages.NewRegistration.from_data(email=config.email)) + regr = perform_registration(acme, config) if regr.terms_of_service is not None: if tos_cb is not None and not tos_cb(regr): @@ -130,6 +130,21 @@ def register(config, account_storage, tos_cb=None): return acc, acme +def perform_registration(acme, config): + """ + Actually register new account, trying repeatedly if there are email + problems + + :returns: the same value as acme.register + """ + try: + regr = acme.register(messages.NewRegistration.from_data(email=config.email)) + except messages.Error, e: + if "MX record" in repr(e): + config.namespace.email = display_ops.get_email(more=True, invalid=True) + return perform_registration(acme, config) + else: + raise class Client(object): """ACME protocol client. From 2d07c017b2b922e65e78d1ea6ba8063de5575c7e Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 16 Nov 2015 12:29:03 -0800 Subject: [PATCH 050/181] Test cases for get_email --- letsencrypt/tests/display/ops_test.py | 32 ++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/letsencrypt/tests/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index 9d4a3a933..d06d24d4f 100644 --- a/letsencrypt/tests/display/ops_test.py +++ b/letsencrypt/tests/display/ops_test.py @@ -168,9 +168,9 @@ class GetEmailTest(unittest.TestCase): zope.component.provideUtility(mock_display, interfaces.IDisplay) @classmethod - def _call(cls): + def _call(cls, **kwargs): from letsencrypt.display.ops import get_email - return get_email() + return get_email(**kwargs) def test_cancel_none(self): self.input.return_value = (display_util.CANCEL, "foo@bar.baz") @@ -178,18 +178,38 @@ class GetEmailTest(unittest.TestCase): def test_ok_safe(self): self.input.return_value = (display_util.OK, "foo@bar.baz") - with mock.patch("letsencrypt.display.ops.le_util" - ".safe_email") as mock_safe_email: + with mock.patch("letsencrypt.display.ops.le_util.safe_email") as mock_safe_email: mock_safe_email.return_value = True self.assertTrue(self._call() is "foo@bar.baz") def test_ok_not_safe(self): self.input.return_value = (display_util.OK, "foo@bar.baz") - with mock.patch("letsencrypt.display.ops.le_util" - ".safe_email") as mock_safe_email: + with mock.patch("letsencrypt.display.ops.le_util.safe_email") as mock_safe_email: mock_safe_email.side_effect = [False, True] self.assertTrue(self._call() is "foo@bar.baz") + def test_more_and_invalid_flags(self): + more_txt = "--register-unsafely-without-email" + invalid_txt = "There seem to be problems" + base_txt = "Enter email" + self.input.return_value = (display_util.OK, "foo@bar.baz") + with mock.patch("letsencrypt.display.ops.le_util.safe_email") as mock_safe_email: + mock_safe_email.return_value = True + self._call() + msg = self.input.call_args[0][0] + self.assertTrue(more_txt not in msg) + self.assertTrue(invalid_txt not in msg) + self.assertTrue(base_txt in msg) + self._call(more=True) + msg = self.input.call_args[0][0] + self.assertTrue(more_txt in msg) + self.assertTrue(invalid_txt not in msg) + self._call(more=True, invalid=True) + msg = self.input.call_args[0][0] + self.assertTrue(more_txt in msg) + self.assertTrue(invalid_txt in msg) + self.assertTrue(base_txt in msg) + class ChooseAccountTest(unittest.TestCase): """Tests for letsencrypt.display.ops.choose_account.""" From c265fb5fb93c294f907a4a074d6aecc25f445463 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 16 Nov 2015 12:46:26 -0800 Subject: [PATCH 051/181] Fix bugs and test cases --- letsencrypt/cli.py | 2 -- letsencrypt/client.py | 2 +- letsencrypt/tests/cli_test.py | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index a36a2ff4b..f9de4d72f 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -141,8 +141,6 @@ def _determine_account(args, config): else: # no account registered yet if args.email is None and not args.register_unsafely_without_email: args.email = display_ops.get_email() - else: - args.email = None def _tos_cb(regr): if args.tos: diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 236a15a34..0ae0e26dd 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -138,7 +138,7 @@ def perform_registration(acme, config): :returns: the same value as acme.register """ try: - regr = acme.register(messages.NewRegistration.from_data(email=config.email)) + return acme.register(messages.NewRegistration.from_data(email=config.email)) except messages.Error, e: if "MX record" in repr(e): config.namespace.email = display_ops.get_email(more=True, invalid=True) diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 500ff074e..df4f67928 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -488,7 +488,8 @@ class DetermineAccountTest(unittest.TestCase): """Tests for letsencrypt.cli._determine_account.""" def setUp(self): - self.args = mock.MagicMock(account=None, email=None) + self.args = mock.MagicMock(account=None, email=None, + register_unsafely_without_email=False) self.config = configuration.NamespaceConfig(self.args) self.accs = [mock.MagicMock(id='x'), mock.MagicMock(id='y')] self.account_storage = account.AccountMemoryStorage() From c6bb119d43379f713cafff72b164073b97b66355 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 16 Nov 2015 17:50:42 -0800 Subject: [PATCH 052/181] Test perform_registration recursion --- letsencrypt/tests/client_test.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index d396e25bc..dec9a0950 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -47,10 +47,20 @@ class RegisterTest(unittest.TestCase): def test_it(self): with mock.patch("letsencrypt.client.acme_client.Client"): - with mock.patch("letsencrypt.account." - "report_new_account"): + with mock.patch("letsencrypt.account.report_new_account"): self._call() + @mock.patch("letsencrypt.account.report_new_account") + @mock.patch("letsencrypt.client.display_ops.get_email") + def test_email_retry(self, _rep, mock_get_email): + from acme import messages + msg = "No MX record for domain ofijfoisjfs.com" + mx_err = messages.Error(detail=msg, typ="malformed", title="title") + with mock.patch("letsencrypt.client.acme_client.Client") as mock_client: + mock_client.register.side_effect = mx_err + self._call() + self.assertEqual(mock_get_email.call_count, 1) + class ClientTest(unittest.TestCase): """Tests for letsencrypt.client.Client.""" From 1bba382c05aa988ad7eb48981ac5e2e14dd3e171 Mon Sep 17 00:00:00 2001 From: Felix Yan Date: Tue, 17 Nov 2015 10:47:20 +0800 Subject: [PATCH 053/181] letsencrypt-auto: Add instructions to use pacman on Arch --- letsencrypt-auto | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/letsencrypt-auto b/letsencrypt-auto index a3009fe52..64af92ebe 100755 --- a/letsencrypt-auto +++ b/letsencrypt-auto @@ -126,8 +126,17 @@ then echo "Bootstrapping dependencies for openSUSE-based OSes..." $SUDO $BOOTSTRAP/_suse_common.sh elif [ -f /etc/arch-release ] ; then - echo "Bootstrapping dependencies for Archlinux..." - $SUDO $BOOTSTRAP/archlinux.sh + if [ "$DEBUG" = 1 ] ; then + echo "Bootstrapping dependencies for Archlinux..." + $SUDO $BOOTSTRAP/archlinux.sh + else + echo "Please use pacman to install letsencrypt packages:" + echo "# pacman -S letsencrypt letsencrypt-nginx letsencrypt-apache letshelp-letsencrypt" + echo + echo "If you would like to use the virtualenv way, please run the script again with the" + echo "--debug flag." + exit 1 + fi elif [ -f /etc/manjaro-release ] ; then ExperimentalBootstrap "Manjaro Linux" manjaro.sh "$SUDO" elif [ -f /etc/gentoo-release ] ; then From 053697889d2b9bfd8a2c9c943ae601c4f1865c3d Mon Sep 17 00:00:00 2001 From: Daniel Aleksandersen Date: Tue, 17 Nov 2015 05:14:04 +0100 Subject: [PATCH 054/181] Add missing RPM requirement `redhat-rpm-config` provides the required `/usr/lib/rpm/redhat/redhat-hardened-cc1` to Fedora. --- bootstrap/_rpm_common.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh index 5aca13cd4..042332f4b 100755 --- a/bootstrap/_rpm_common.sh +++ b/bootstrap/_rpm_common.sh @@ -40,6 +40,7 @@ if ! $tool install -y \ augeas-libs \ openssl-devel \ libffi-devel \ + redhat-rpm-config \ ca-certificates then echo "Could not install additional dependencies. Aborting bootstrap!" From 58110a69f4820fcf70604c8c260caf03ad498fe8 Mon Sep 17 00:00:00 2001 From: sagi Date: Tue, 17 Nov 2015 07:23:19 +0000 Subject: [PATCH 055/181] more elegant enhance_config, add --no- flags to hsts and uir --- letsencrypt/cli.py | 11 ++++++++++- letsencrypt/client.py | 33 +++++++++++++++++--------------- letsencrypt/tests/client_test.py | 11 +++++++---- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 8393d6dd0..ce419b393 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -914,11 +914,20 @@ def prepare_and_parse_args(plugins, args): help="Add the Strict-Transport-Security header to every HTTP response." " Forcing browser to use always use SSL for the domain." " Defends against SSL Stripping.", dest="hsts", default=False) + helpful.add( + "security", "--no-hsts", action="store_false", + help="Do not automaticcally add the Strict-Transport-Security header" + " to every HTTP response.", dest="hsts", default=False) helpful.add( "security", "--uir", action="store_true", help="Add the \"Content-Security-Policy: upgrade-insecure-requests\"" " header to every HTTP response. Forcing the browser to use" - " https:// for every http:// resource.", dest="uir", default=False) + " https:// for every http:// resource.", dest="uir", default=None) + helpful.add( + "security", "--no-uir", action="store_false", + help=" Do not automatically set the \"Content-Security-Policy:" + " upgrade-insecure-requests\" header to every HTTP response.", + dest="uir", default=None) helpful.add( "security", "--strict-permissions", action="store_true", help="Require that all configuration files are owned by the current " diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e3e365bb8..be6fc7a22 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -354,13 +354,14 @@ class Client(object): with error_handler.ErrorHandler(self._rollback_and_restart, msg): # sites may have been enabled / final cleanup self.installer.restart() - def enhance_config(self, domains, config=None): + def enhance_config(self, domains, config): """Enhance the configuration. :param list domains: list of domains to configure :ivar config: Namespace typically produced by :meth:`argparse.ArgumentParser.parse_args`. + it must have the redirect, hsts and uir attributes. :type namespace: :class:`argparse.Namespace` :raises .errors.Error: if no installer is specified in the @@ -374,23 +375,25 @@ class Client(object): raise errors.Error("No installer available") if config is None: + logger.warning("No config is specified.") + raise errors.Error("No config available") + + redirect = config.redirect + hsts = config.hsts + uir = config.uir # Upgrade Insecure Requests + + if redirect is None: redirect = enhancements.ask("redirect") - if redirect: - self.apply_enhancement(domains, "redirect") - else: - redirect = config.redirect - hsts = config.hsts - uir = config.uir # Upgrade Insecure Requests - if redirect: - self.apply_enhancement(domains, "redirect") + if redirect: + self.apply_enhancement(domains, "redirect") - if hsts: - self.apply_enhancement(domains, "http-header", - "Strict-Transport-Security") - if uir: - self.apply_enhancement(domains, "http-header", - "Upgrade-Insecure-Requests") + if hsts: + self.apply_enhancement(domains, "http-header", + "Strict-Transport-Security") + if uir: + self.apply_enhancement(domains, "http-header", + "Upgrade-Insecure-Requests") msg = ("We were unable to restart web server") if redirect or hsts or uir: diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index c66ad1e08..4208027aa 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -220,22 +220,24 @@ class ClientTest(unittest.TestCase): @mock.patch("letsencrypt.client.enhancements") def test_enhance_config(self, mock_enhancements): + config = ConfigHelper(redirect=True, hsts=False, uir=False) self.assertRaises(errors.Error, - self.client.enhance_config, ["foo.bar"]) + self.client.enhance_config, ["foo.bar"], config) mock_enhancements.ask.return_value = True installer = mock.MagicMock() self.client.installer = installer - self.client.enhance_config(["foo.bar"]) + self.client.enhance_config(["foo.bar"], config) installer.enhance.assert_called_once_with("foo.bar", "redirect", None) self.assertEqual(installer.save.call_count, 1) installer.restart.assert_called_once_with() @mock.patch("letsencrypt.client.enhancements") def test_enhance_config_no_ask(self, mock_enhancements): + config = ConfigHelper(redirect=True, hsts=False, uir=False) self.assertRaises(errors.Error, - self.client.enhance_config, ["foo.bar"]) + self.client.enhance_config, ["foo.bar"], config) mock_enhancements.ask.return_value = True installer = mock.MagicMock() @@ -259,8 +261,9 @@ class ClientTest(unittest.TestCase): self.assertEqual(installer.restart.call_count, 3) def test_enhance_config_no_installer(self): + config = ConfigHelper(redirect=True, hsts=False, uir=False) self.assertRaises(errors.Error, - self.client.enhance_config, ["foo.bar"]) + self.client.enhance_config, ["foo.bar"], config) @mock.patch("letsencrypt.client.zope.component.getUtility") @mock.patch("letsencrypt.client.enhancements") From 9fd1b1f38ad65e346c79d118eed703c41a753695 Mon Sep 17 00:00:00 2001 From: Nav Date: Tue, 17 Nov 2015 11:06:12 +0200 Subject: [PATCH 056/181] Fixes #1176 --- letsencrypt/plugins/manual.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py index 07f06ccec..62d754e0b 100644 --- a/letsencrypt/plugins/manual.py +++ b/letsencrypt/plugins/manual.py @@ -90,6 +90,8 @@ s.serve_forever()" """ def add_parser_arguments(cls, add): add("test-mode", action="store_true", help="Test mode. Executes the manual command in subprocess.") + add("public-ip-logging-ok", action="store_true", + help="Automatically allows public IP logging.") def prepare(self): # pylint: disable=missing-docstring,no-self-use pass # pragma: no cover @@ -164,9 +166,10 @@ s.serve_forever()" """ if self._httpd.poll() is not None: raise errors.Error("Couldn't execute manual command") else: - if not zope.component.getUtility(interfaces.IDisplay).yesno( - self.IP_DISCLAIMER, "Yes", "No"): - raise errors.PluginError("Must agree to IP logging to proceed") + if not self.conf("public-ip-logging-ok"): + if not zope.component.getUtility(interfaces.IDisplay).yesno( + self.IP_DISCLAIMER, "Yes", "No"): + raise errors.PluginError("Must agree to IP logging to proceed") self._notify_and_wait(self.MESSAGE_TEMPLATE.format( validation=validation, response=response, From b20acaa3e1ad9957abf7eae2f2557956a23c3e86 Mon Sep 17 00:00:00 2001 From: Matthew Ames Date: Tue, 17 Nov 2015 09:27:15 +0000 Subject: [PATCH 057/181] Update configuration.py --- letsencrypt/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 4955655f3..b370c741f 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -157,6 +157,6 @@ def _check_config_domain_sanity(domains): # http://www.mkyong.com/regular-expressions/domain-name-regular-expression-example/ # Characters used, domain parts < 63 chars, tld > 1 < 7 chars # first and last char is not "-" - fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(? Date: Tue, 17 Nov 2015 11:50:32 +0200 Subject: [PATCH 058/181] Fixing manual authenticator tests --- letsencrypt/plugins/manual_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py index a9281902f..4afd44cac 100644 --- a/letsencrypt/plugins/manual_test.py +++ b/letsencrypt/plugins/manual_test.py @@ -23,7 +23,7 @@ class AuthenticatorTest(unittest.TestCase): def setUp(self): from letsencrypt.plugins.manual import Authenticator self.config = mock.MagicMock( - http01_port=8080, manual_test_mode=False) + http01_port=8080, manual_test_mode=False, manual_public_ip_logging_ok=False) self.auth = Authenticator(config=self.config, name="manual") self.achalls = [achallenges.KeyAuthorizationAnnotatedChallenge( challb=acme_util.HTTP01_P, domain="foo.com", account_key=KEY)] From 87035283462e050da0012bdea3483d6bc677c7b4 Mon Sep 17 00:00:00 2001 From: Matthew Ames Date: Tue, 17 Nov 2015 15:45:52 +0000 Subject: [PATCH 059/181] Update configuration.py --- letsencrypt/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index b370c741f..7274a1aea 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -155,7 +155,7 @@ def _check_config_domain_sanity(domains): "Punycode domains are not supported") # FQDN checks from # http://www.mkyong.com/regular-expressions/domain-name-regular-expression-example/ - # Characters used, domain parts < 63 chars, tld > 1 < 7 chars + # Characters used, domain parts < 63 chars, tld > 1 < 13 chars # first and last char is not "-" fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(? Date: Tue, 17 Nov 2015 23:41:26 +0000 Subject: [PATCH 060/181] Changed tld length to be anything over 1 character --- letsencrypt/configuration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 7274a1aea..f2d232c22 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -155,8 +155,8 @@ def _check_config_domain_sanity(domains): "Punycode domains are not supported") # FQDN checks from # http://www.mkyong.com/regular-expressions/domain-name-regular-expression-example/ - # Characters used, domain parts < 63 chars, tld > 1 < 13 chars + # Characters used, domain parts < 63 chars, tld > 1 char # first and last char is not "-" - fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(? Date: Mon, 16 Nov 2015 18:31:23 -0800 Subject: [PATCH 061/181] Avoid hacky --email "" case for integration tests --- tests/integration/_common.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/_common.sh b/tests/integration/_common.sh index dbc473728..71d745d93 100755 --- a/tests/integration/_common.sh +++ b/tests/integration/_common.sh @@ -23,7 +23,7 @@ letsencrypt_test () { --no-redirect \ --agree-dev-preview \ --agree-tos \ - --email "" \ + --register-unsafely-without-email \ --renew-by-default \ --debug \ -vvvvvvv \ From 6d497b8076a26830acc139117fcda789b9927df4 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 17 Nov 2015 12:50:18 -0800 Subject: [PATCH 062/181] Track recent boulder error change --- letsencrypt/client.py | 3 ++- letsencrypt/tests/client_test.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 0ae0e26dd..57ca8d3dd 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -140,7 +140,8 @@ def perform_registration(acme, config): try: return acme.register(messages.NewRegistration.from_data(email=config.email)) except messages.Error, e: - if "MX record" in repr(e): + err = repr(e) + if "MX record" in err or "Validation of contact mailto" in err: config.namespace.email = display_ops.get_email(more=True, invalid=True) return perform_registration(acme, config) else: diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index dec9a0950..45b27f989 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -54,7 +54,7 @@ class RegisterTest(unittest.TestCase): @mock.patch("letsencrypt.client.display_ops.get_email") def test_email_retry(self, _rep, mock_get_email): from acme import messages - msg = "No MX record for domain ofijfoisjfs.com" + msg = "Validation of contact mailto:sousaphone@improbablylongggstring.tld failed" mx_err = messages.Error(detail=msg, typ="malformed", title="title") with mock.patch("letsencrypt.client.acme_client.Client") as mock_client: mock_client.register.side_effect = mx_err From 981b9dd3bc13d756f83bf373434a1876e1d705ad Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 17 Nov 2015 13:43:49 -0800 Subject: [PATCH 063/181] Add test case for trying to register without email --- letsencrypt/tests/client_test.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 45b27f989..0a7d64a84 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -24,7 +24,7 @@ class RegisterTest(unittest.TestCase): """Tests for letsencrypt.client.register.""" def setUp(self): - self.config = mock.MagicMock(rsa_key_size=1024) + self.config = mock.MagicMock(rsa_key_size=1024, register_unsafely_without_email=False) self.account_storage = account.AccountMemoryStorage() self.tos_cb = mock.MagicMock() @@ -61,6 +61,9 @@ class RegisterTest(unittest.TestCase): self._call() self.assertEqual(mock_get_email.call_count, 1) + def test_needs_email(self): + self.config.email = None + self.assertRaises(errors.Error, self._call) class ClientTest(unittest.TestCase): """Tests for letsencrypt.client.Client.""" From 0ae1f3053207674dedf0542656463122b9faae09 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 17 Nov 2015 18:01:20 -0800 Subject: [PATCH 064/181] Don't install all Arch packages --- docs/using.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index 334e2e197..cf5336cd9 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -103,8 +103,7 @@ Operating System Packages .. code-block:: shell - sudo pacman -S letsencrypt letsencrypt-nginx letsencrypt-apache \ - letshelp-letsencrypt + sudo pacman -S letsencrypt **Other Operating Systems** From 1051c451663edcf06c070dabfdf50923a1fb5e3a Mon Sep 17 00:00:00 2001 From: Matthew Ames Date: Wed, 18 Nov 2015 07:18:20 +0000 Subject: [PATCH 065/181] TLDs cannot be longer than 63 characters --- letsencrypt/configuration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index f2d232c22..0ea539b5c 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -155,8 +155,8 @@ def _check_config_domain_sanity(domains): "Punycode domains are not supported") # FQDN checks from # http://www.mkyong.com/regular-expressions/domain-name-regular-expression-example/ - # Characters used, domain parts < 63 chars, tld > 1 char + # Characters used, domain parts < 63 chars, tld > 1 < 64 chars # first and last char is not "-" - fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(? Date: Wed, 18 Nov 2015 12:39:43 +0200 Subject: [PATCH 066/181] Improving content of logging messages --- letsencrypt/storage.py | 55 ++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index d1c8a8314..bc6e49124 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -143,13 +143,14 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Each element must be referenced with an absolute path if any(not os.path.isabs(x) for x in (self.cert, self.privkey, self.chain, self.fullchain)): - logger.debug("Element is not reference with an absolute file") + logger.debug("Element %s is not referenced with an " + "absolute file.", x) return False # Each element must exist and be a symbolic link if any(not os.path.islink(x) for x in (self.cert, self.privkey, self.chain, self.fullchain)): - logger.debug("Element is not a symbolic link") + logger.debug("Element %s is not a symbolic link.", x) return False for kind in ALL_FOUR: link = getattr(self, kind) @@ -164,22 +165,24 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self.cli_config.archive_dir, self.lineagename) if not os.path.samefile(os.path.dirname(target), desired_directory): - logger.debug("Element does not point within the cert " - "lineage's directory within the official " - "archive directory") + logger.debug("Element's link does not point within the " + "cert lineage's directory within the " + "official archive directory. Link: %s, " + "archive directory: %s.", + os.path.dirname(target), desired_directory) return False # The link must point to a file that exists if not os.path.exists(target): - logger.debug("File does not exist") + logger.debug("Link %s points to a file that does not exist.", target) return False # The link must point to a file that follows the archive # naming convention pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) if not pattern.match(os.path.basename(target)): - logger.debug("Files does not follow the archive naming " - "convention") + logger.debug("Link %s does not follow the archive naming " + "convention.", os.path.basename(target)) return False # It is NOT required that the link's target be a regular @@ -264,7 +267,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes raise errors.CertStorageError("unknown kind of item") link = getattr(self, kind) if not os.path.exists(link): - logger.debug("File does not exist") + logger.debug("Target %s of kind %s does not exist.", link, kind) return None target = os.readlink(link) if not os.path.isabs(target): @@ -289,13 +292,14 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) target = self.current_target(kind) if target is None or not os.path.exists(target): - logger.debug("File does not exist") + logger.debug("Current-version target for %s " + "does not exist at %s.", kind, target) target = "" matches = pattern.match(os.path.basename(target)) if matches: return int(matches.groups()[0]) else: - logger.debug("No matches") + logger.debug("No matches for target %s.", kind) return None def version(self, kind, version): @@ -545,7 +549,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Renewals on the basis of revocation if self.ocsp_revoked(self.latest_common_version()): - logger.debug("Should renew, certificate is revoked") + logger.debug("Should renew, certificate is revoked.") return True # Renewals on the basis of expiry time @@ -554,7 +558,9 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes "cert", self.latest_common_version())) now = pytz.UTC.fromutc(datetime.datetime.utcnow()) if expiry < add_time_interval(now, interval): - logger.debug("Should renew, certificate is expired") + logger.debug("Should renew, certificate " + "has expired since %s.", + expiry.strftime("%Y-%m-%d %H:%M:%S %Z")) return True return False @@ -606,7 +612,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes cli_config.live_dir): if not os.path.exists(i): os.makedirs(i, 0700) - logger.debug("Creating CLI config directories") + logger.debug("Creating CLI config directory %s.", i) config_file, config_filename = le_util.unique_lineage_name( cli_config.renewal_configs_dir, lineagename) if not config_filename.endswith(".conf"): @@ -627,7 +633,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes "live directory exists for " + lineagename) os.mkdir(archive) os.mkdir(live_dir) - logger.debug("Archive and live directories created") + logger.debug("Archive directory %s and live " + "directory %s created.", archive, live_dir) relative_archive = os.path.join("..", "..", "archive", lineagename) # Put the data into the appropriate files on disk @@ -637,19 +644,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.symlink(os.path.join(relative_archive, kind + "1.pem"), target[kind]) with open(target["cert"], "w") as f: - logger.debug("Writing certificate") + logger.debug("Writing certificate.") f.write(cert) with open(target["privkey"], "w") as f: - logger.debug("Writing private key") + logger.debug("Writing private key.") f.write(privkey) # XXX: Let's make sure to get the file permissions right here with open(target["chain"], "w") as f: - logger.debug("Writing chain") + logger.debug("Writing chain.") f.write(chain) with open(target["fullchain"], "w") as f: # assumes that OpenSSL.crypto.dump_certificate includes # ending newline character - logger.debug("Writing full chain") + logger.debug("Writing full chain.") f.write(cert + chain) # Document what we've done in a new renewal config file @@ -664,7 +671,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes " in the renewal process"] # TODO: add human-readable comments explaining other available # parameters - logger.debug("Writing new config") + logger.debug("Writing new config %s.", config_filename) new_config.write() return cls(new_config.filename, cli_config) @@ -718,17 +725,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.symlink(old_privkey, target["privkey"]) else: with open(target["privkey"], "w") as f: - logger.debug("Writing new private key") + logger.debug("Writing new private key.") f.write(new_privkey) # Save everything else with open(target["cert"], "w") as f: - logger.debug("Writing certificate") + logger.debug("Writing certificate.") f.write(new_cert) with open(target["chain"], "w") as f: - logger.debug("Writing chain") + logger.debug("Writing chain.") f.write(new_chain) with open(target["fullchain"], "w") as f: - logger.debug("Writing full chain") + logger.debug("Writing full chain.") f.write(new_cert + new_chain) return target_version From 24e5e3d8e59e9af4b706f76030a4d12dc115c384 Mon Sep 17 00:00:00 2001 From: Nav Date: Wed, 18 Nov 2015 12:49:06 +0200 Subject: [PATCH 067/181] Fixing tests broken by including the variable x in the logger debug messages --- letsencrypt/storage.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index bc6e49124..b04536c1c 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -141,17 +141,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes """ # Each element must be referenced with an absolute path - if any(not os.path.isabs(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - logger.debug("Element %s is not referenced with an " - "absolute file.", x) - return False + for x in (self.cert, self.privkey, self.chain, self.fullchain): + if not os.path.isabs(x): + logger.debug("Element %s is not referenced with an " + "absolute file.", x) + return False # Each element must exist and be a symbolic link - if any(not os.path.islink(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - logger.debug("Element %s is not a symbolic link.", x) - return False + for x in (self.cert, self.privkey, self.chain, self.fullchain): + if not os.path.islink(x): + logger.debug("Element %s is not a symbolic link.", x) + return False for kind in ALL_FOUR: link = getattr(self, kind) where = os.path.dirname(link) From 25e6502aac7fcb7370cc331bba451dbe8a95ef9d Mon Sep 17 00:00:00 2001 From: Nav Date: Wed, 18 Nov 2015 21:28:57 +0200 Subject: [PATCH 068/181] Adding paths to cert and chain logging messages --- letsencrypt/storage.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index b04536c1c..cc7ab4313 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -644,19 +644,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.symlink(os.path.join(relative_archive, kind + "1.pem"), target[kind]) with open(target["cert"], "w") as f: - logger.debug("Writing certificate.") + logger.debug("Writing certificate to %s.", target["cert"]) f.write(cert) with open(target["privkey"], "w") as f: - logger.debug("Writing private key.") + logger.debug("Writing private key to %s.", target["privkey"]) f.write(privkey) # XXX: Let's make sure to get the file permissions right here with open(target["chain"], "w") as f: - logger.debug("Writing chain.") + logger.debug("Writing chain to %s.", target["chain"]) f.write(chain) with open(target["fullchain"], "w") as f: # assumes that OpenSSL.crypto.dump_certificate includes # ending newline character - logger.debug("Writing full chain.") + logger.debug("Writing full chain to %s.", target["fullchain"]) f.write(cert + chain) # Document what we've done in a new renewal config file @@ -722,20 +722,21 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes old_privkey = os.readlink(old_privkey) else: old_privkey = "privkey{0}.pem".format(prior_version) + logger.debug("Writing symlink to old private key, %s.", old_privkey) os.symlink(old_privkey, target["privkey"]) else: with open(target["privkey"], "w") as f: - logger.debug("Writing new private key.") + logger.debug("Writing new private key to %s.", target["privkey"]) f.write(new_privkey) # Save everything else with open(target["cert"], "w") as f: - logger.debug("Writing certificate.") + logger.debug("Writing certificate to %s.", target["cert"]) f.write(new_cert) with open(target["chain"], "w") as f: - logger.debug("Writing chain.") + logger.debug("Writing chain to %s.", target["chain"]) f.write(new_chain) with open(target["fullchain"], "w") as f: - logger.debug("Writing full chain.") + logger.debug("Writing full chain to %s.", target["fullchain"]) f.write(new_cert + new_chain) return target_version From fb844a85a24223d05434f6cd127c636e92016c8b Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 18 Nov 2015 13:16:49 -0800 Subject: [PATCH 069/181] Use -p python2 when creating virtualenv --- tools/dev-release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/dev-release.sh b/tools/dev-release.sh index 6bbc6ced4..ffaf7881c 100755 --- a/tools/dev-release.sh +++ b/tools/dev-release.sh @@ -23,7 +23,7 @@ mv "dist.$version" "dist.$version.$(date +%s).bak" || true git tag --delete "$tag" || true tmpvenv=$(mktemp -d) -virtualenv --no-site-packages $tmpvenv +virtualenv --no-site-packages -p python2 $tmpvenv . $tmpvenv/bin/activate # update setuptools/pip just like in other places in the repo pip install -U setuptools From d564b8ff8eb3ec403ba54b4311b673ac1e7f4e18 Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Wed, 18 Nov 2015 22:41:39 +0100 Subject: [PATCH 070/181] Fix typos found by codespell Signed-off-by: Stefan Weil --- bootstrap/_suse_common.sh | 2 +- docs/ciphers.rst | 2 +- letsencrypt/client.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bootstrap/_suse_common.sh b/bootstrap/_suse_common.sh index 4b41bac36..46f9d693b 100755 --- a/bootstrap/_suse_common.sh +++ b/bootstrap/_suse_common.sh @@ -1,6 +1,6 @@ #!/bin/sh -# SLE12 dont have python-virtualenv +# SLE12 don't have python-virtualenv zypper -nq in -l git-core \ python \ diff --git a/docs/ciphers.rst b/docs/ciphers.rst index 12c403d09..49c0824a3 100644 --- a/docs/ciphers.rst +++ b/docs/ciphers.rst @@ -105,7 +105,7 @@ https://wiki.mozilla.org/Security/Server_Side_TLS and the version implemented by the Let's Encrypt client will be the version that was most current as of the release date of each client -version. Mozilla offers three seperate sets of cryptographic options, +version. Mozilla offers three separate sets of cryptographic options, which trade off security and compatibility differently. These are referred to as as the "Modern", "Intermediate", and "Old" configurations (in order from most secure to least secure, and least-backwards compatible diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 8e053e926..d7113ca25 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -430,7 +430,7 @@ class Client(object): except: # TODO: suggest letshelp-letsencypt here reporter.add_message( - "An error occured and we failed to restore your config and " + "An error occurred and we failed to restore your config and " "restart your server. Please submit a bug report to " "https://github.com/letsencrypt/letsencrypt", reporter.HIGH_PRIORITY) From 5829e258047d92ef6a464c587c897d7bee62c5a3 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Wed, 18 Nov 2015 14:26:01 -0800 Subject: [PATCH 071/181] Always use the specified GPG for signing everything. --- tools/dev-release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/dev-release.sh b/tools/dev-release.sh index ffaf7881c..bd86bff44 100755 --- a/tools/dev-release.sh +++ b/tools/dev-release.sh @@ -49,7 +49,7 @@ done sed -i "s/^__version.*/__version__ = '$version'/" letsencrypt/__init__.py git add -p # interactive user input -git -c commit.gpgsign=true commit -m "Release $version" +git commit --gpg-sign="$RELEASE_GPG_KEY" -m "Release $version" git tag --local-user "$RELEASE_GPG_KEY" \ --sign --message "Release $version" "$tag" From e5e7cef6d688e1699082dd8aed84853bc94655b3 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Wed, 18 Nov 2015 19:22:14 -0600 Subject: [PATCH 072/181] Fix conditional for fullchain_path edge cases --- letsencrypt-apache/letsencrypt_apache/configurator.py | 2 +- .../letsencrypt_apache/tests/configurator_test.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index e610010a0..8ec7cda8d 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -213,7 +213,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.debug("Apache version is %s", ".".join(str(i) for i in self.version)) - if self.version < (2, 4, 8): + if self.version < (2, 4, 8) or (chain_path and not fullchain_path): # install SSLCertificateFile, SSLCertificateKeyFile, # and SSLCertificateChainFile directives set_cert_path = cert_path diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index e70c797bc..96ac2c023 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -304,8 +304,7 @@ class TwoVhost80Test(util.ApacheTest): self.config.assoc["random.demo"] = self.vh_truth[1] self.assertRaises(errors.PluginError, lambda: self.config.deploy_cert( - "random.demo", "example/cert.pem", "example/key.pem", - "example/cert_chain.pem")) + "random.demo", "example/cert.pem", "example/key.pem")) def test_deploy_cert(self): self.config.parser.modules.add("ssl_module") From 707bb55c81a47c9305b39ff55ce3adf9c40d981f Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Wed, 18 Nov 2015 18:59:22 -0800 Subject: [PATCH 073/181] change to not make a permanent file if just doing dvsni --- letsencrypt-apache/letsencrypt_apache/configurator.py | 4 +++- letsencrypt-apache/letsencrypt_apache/dvsni.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index f10f0c241..64449302a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -234,7 +234,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not vhost.enabled: self.enable_site(vhost) - def choose_vhost(self, target_name): + def choose_vhost(self, target_name, dvsni=False): """Chooses a virtual host based on the given domain name. If there is no clear virtual host to be selected, the user is prompted @@ -255,6 +255,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Try to find a reasonable vhost vhost = self._find_best_vhost(target_name) if vhost is not None: + if dvsni: + return vhost if not vhost.ssl: vhost = self.make_vhost_ssl(vhost) diff --git a/letsencrypt-apache/letsencrypt_apache/dvsni.py b/letsencrypt-apache/letsencrypt_apache/dvsni.py index 2f9e9ed18..0dd411e4f 100644 --- a/letsencrypt-apache/letsencrypt_apache/dvsni.py +++ b/letsencrypt-apache/letsencrypt_apache/dvsni.py @@ -110,7 +110,7 @@ class ApacheDvsni(common.TLSSNI01): def get_dvsni_addrs(self, achall): """Return the Apache addresses needed for DVSNI.""" - vhost = self.configurator.choose_vhost(achall.domain) + vhost = self.configurator.choose_vhost(achall.domain, dvsni=True) # TODO: Checkout _default_ rules. dvsni_addrs = set() From b19c9d858cbbb29a4a3a81fcc5e8366d88987d15 Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Wed, 18 Nov 2015 21:12:53 -0600 Subject: [PATCH 074/181] Fix a few nits, coverage --- .../letsencrypt_apache/configurator.py | 2 ++ .../letsencrypt_apache/tests/configurator_test.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 8ec7cda8d..d80d27d1c 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -225,6 +225,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "SSLCertificateChainFile", chain_path) else: self.aug.set(path["chain_path"][-1], chain_path) + else: + raise errors.PluginError("--chain-path is required for your version of Apache") else: if not fullchain_path: raise errors.PluginError("Please provide the --fullchain-path\ diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 96ac2c023..9f30153c3 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -306,6 +306,19 @@ class TwoVhost80Test(util.ApacheTest): lambda: self.config.deploy_cert( "random.demo", "example/cert.pem", "example/key.pem")) + def test_deploy_cert_old_apache_no_chain(self): + self.config = util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, version=(2, 4, 7)) + + self.config.parser.modules.add("ssl_module") + self.config.parser.modules.add("mod_ssl.c") + + # Get the default 443 vhost + self.config.assoc["random.demo"] = self.vh_truth[1] + self.assertRaises(errors.PluginError, + lambda: self.config.deploy_cert( + "random.demo", "example/cert.pem", "example/key.pem")) + def test_deploy_cert(self): self.config.parser.modules.add("ssl_module") self.config.parser.modules.add("mod_ssl.c") From ca6a77bb1dfd8114123cab1837f86ff4693ee48a Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Wed, 18 Nov 2015 21:30:46 -0600 Subject: [PATCH 075/181] Fix tests Remove debugging print from tests Fix lint warnings --- .../letsencrypt_apache/tests/configurator_test.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 9f30153c3..58aac1216 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -131,12 +131,9 @@ class TwoVhost80Test(util.ApacheTest): self.config.save() mock_select.return_value = self.vh_truth[1] - vhost = self.config.choose_vhost("none.com") + self.config.choose_vhost("none.com") self.config.save() - with open(vhost.filep) as f: - print f.read() - loc_cert = self.config.parser.find_dir( 'SSLCertificateFile', None, self.vh_truth[1].path, False) loc_key = self.config.parser.find_dir( From 9205b9c9872f17b6b342a968516990fe2db71d66 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 19 Nov 2015 12:12:25 -0500 Subject: [PATCH 076/181] Remove remaining "DVSNI" wording, changing it to reference TLS-SNI-01, which it changed into. Close #1417. Also make _get_addrs() private, since it's called only internally. --- letsencrypt-apache/docs/api/dvsni.rst | 5 -- letsencrypt-apache/docs/api/tls_sni_01.rst | 5 ++ .../letsencrypt_apache/configurator.py | 12 ++-- .../tests/configurator_test.py | 12 ++-- .../{dvsni_test.py => tls_sni_01_test.py} | 16 +++--- .../{dvsni.py => tls_sni_01.py} | 55 ++++++++++--------- letsencrypt-nginx/docs/api/dvsni.rst | 5 -- letsencrypt-nginx/docs/api/tls_sni_01.rst | 5 ++ .../letsencrypt_nginx/configurator.py | 12 ++-- .../tests/configurator_test.py | 12 ++-- .../{dvsni_test.py => tls_sni_01_test.py} | 12 ++-- .../{dvsni.py => tls_sni_01.py} | 38 +++++++------ letsencrypt/plugins/common.py | 2 +- 13 files changed, 98 insertions(+), 93 deletions(-) delete mode 100644 letsencrypt-apache/docs/api/dvsni.rst create mode 100644 letsencrypt-apache/docs/api/tls_sni_01.rst rename letsencrypt-apache/letsencrypt_apache/tests/{dvsni_test.py => tls_sni_01_test.py} (91%) rename letsencrypt-apache/letsencrypt_apache/{dvsni.py => tls_sni_01.py} (78%) delete mode 100644 letsencrypt-nginx/docs/api/dvsni.rst create mode 100644 letsencrypt-nginx/docs/api/tls_sni_01.rst rename letsencrypt-nginx/letsencrypt_nginx/tests/{dvsni_test.py => tls_sni_01_test.py} (95%) rename letsencrypt-nginx/letsencrypt_nginx/{dvsni.py => tls_sni_01.py} (82%) diff --git a/letsencrypt-apache/docs/api/dvsni.rst b/letsencrypt-apache/docs/api/dvsni.rst deleted file mode 100644 index 945771db8..000000000 --- a/letsencrypt-apache/docs/api/dvsni.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt_apache.dvsni` -------------------------------- - -.. automodule:: letsencrypt_apache.dvsni - :members: diff --git a/letsencrypt-apache/docs/api/tls_sni_01.rst b/letsencrypt-apache/docs/api/tls_sni_01.rst new file mode 100644 index 000000000..ee1072e96 --- /dev/null +++ b/letsencrypt-apache/docs/api/tls_sni_01.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt_apache.tls_sni_01` +------------------------------- + +.. automodule:: letsencrypt_apache.tls_sni_01 + :members: diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index f10f0c241..ef7ff03c6 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -22,7 +22,7 @@ from letsencrypt.plugins import common from letsencrypt_apache import augeas_configurator from letsencrypt_apache import constants from letsencrypt_apache import display_ops -from letsencrypt_apache import dvsni +from letsencrypt_apache import tls_sni_01 from letsencrypt_apache import obj from letsencrypt_apache import parser @@ -1152,15 +1152,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ self._chall_out.update(achalls) responses = [None] * len(achalls) - apache_dvsni = dvsni.ApacheDvsni(self) + authenticator = tls_sni_01.ApacheTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have dvsni hold associated index + # Currently also have authenticator hold associated index # of the challenge. This helps to put all of the responses back # together when they are all complete. - apache_dvsni.add_chall(achall, i) + authenticator.add_chall(achall, i) - sni_response = apache_dvsni.perform() + sni_response = authenticator.perform() if sni_response: # Must restart in order to activate the challenges. # Handled here because we may be able to load up other challenge @@ -1171,7 +1171,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # 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[apache_dvsni.indices[i]] = resp + responses[authenticator.indices[i]] = resp return responses diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 0350a32ec..b86011e90 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -380,23 +380,23 @@ class TwoVhost80Test(util.ApacheTest): self.config._add_name_vhost_if_necessary(self.vh_truth[0]) self.assertTrue(self.config.save.called) - @mock.patch("letsencrypt_apache.configurator.dvsni.ApacheDvsni.perform") + @mock.patch("letsencrypt_apache.configurator.tls_sni_01.ApacheTlsSni01.perform") @mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.restart") - def test_perform(self, mock_restart, mock_dvsni_perform): + def test_perform(self, mock_restart, mock_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded account_key, achall1, achall2 = self.get_achalls() - dvsni_ret_val = [ + expected = [ achall1.response(account_key), achall2.response(account_key), ] - mock_dvsni_perform.return_value = dvsni_ret_val + mock_perform.return_value = expected responses = self.config.perform([achall1, achall2]) - self.assertEqual(mock_dvsni_perform.call_count, 1) - self.assertEqual(responses, dvsni_ret_val) + self.assertEqual(mock_perform.call_count, 1) + self.assertEqual(responses, expected) self.assertEqual(mock_restart.call_count, 1) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py b/letsencrypt-apache/letsencrypt_apache/tests/tls_sni_01_test.py similarity index 91% rename from letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py rename to letsencrypt-apache/letsencrypt_apache/tests/tls_sni_01_test.py index 911c2a36b..f4dff7734 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/tls_sni_01_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt_apache.dvsni.""" +"""Test for letsencrypt_apache.tls_sni_01.""" import unittest import shutil @@ -10,21 +10,21 @@ from letsencrypt_apache import obj from letsencrypt_apache.tests import util -class DvsniPerformTest(util.ApacheTest): - """Test the ApacheDVSNI challenge.""" +class TlsSniPerformTest(util.ApacheTest): + """Test the ApacheTlsSni01 challenge.""" auth_key = common_test.TLSSNI01Test.auth_key achalls = common_test.TLSSNI01Test.achalls def setUp(self): # pylint: disable=arguments-differ - super(DvsniPerformTest, self).setUp() + super(TlsSniPerformTest, self).setUp() config = util.get_apache_configurator( self.config_path, self.config_dir, self.work_dir) config.config.tls_sni_01_port = 443 - from letsencrypt_apache import dvsni - self.sni = dvsni.ApacheDvsni(config) + from letsencrypt_apache import tls_sni_01 + self.sni = tls_sni_01.ApacheTlsSni01(config) def tearDown(self): shutil.rmtree(self.temp_dir) @@ -121,7 +121,7 @@ class DvsniPerformTest(util.ApacheTest): names = vhost.get_names() self.assertTrue(names in z_domains) - def test_get_dvsni_addrs_default(self): + def test_get_addrs_default(self): self.sni.configurator.choose_vhost = mock.Mock( return_value=obj.VirtualHost( "path", "aug_path", set([obj.Addr.fromstring("_default_:443")]), @@ -130,7 +130,7 @@ class DvsniPerformTest(util.ApacheTest): self.assertEqual( set([obj.Addr.fromstring("*:443")]), - self.sni.get_dvsni_addrs(self.achalls[0])) + self.sni._get_addrs(self.achalls[0])) # pylint: disable=protected-access if __name__ == "__main__": diff --git a/letsencrypt-apache/letsencrypt_apache/dvsni.py b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py similarity index 78% rename from letsencrypt-apache/letsencrypt_apache/dvsni.py rename to letsencrypt-apache/letsencrypt_apache/tls_sni_01.py index 2f9e9ed18..38ca1d390 100644 --- a/letsencrypt-apache/letsencrypt_apache/dvsni.py +++ b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py @@ -1,4 +1,5 @@ -"""ApacheDVSNI""" +"""A TLS-SNI-01 authenticator for Apache""" + import os from letsencrypt.plugins import common @@ -7,22 +8,22 @@ from letsencrypt_apache import obj from letsencrypt_apache import parser -class ApacheDvsni(common.TLSSNI01): - """Class performs DVSNI challenges within the Apache configurator. +class ApacheTlsSni01(common.TLSSNI01): + """Class that performs TLS-SNI-01 challenges within the Apache configurator :ivar configurator: ApacheConfigurator object :type configurator: :class:`~apache.configurator.ApacheConfigurator` - :ivar list achalls: Annotated tls-sni-01 + :ivar list achalls: Annotated TLS-SNI-01 (`.KeyAuthorizationAnnotatedChallenge`) challenges. :param list indices: Meant to hold indices of challenges in a - larger array. ApacheDvsni is capable of solving many challenges + larger array. ApacheTlsSni01 is capable of solving many challenges at once which causes an indexing issue within ApacheConfigurator who must return all responses in order. Imagine ApacheConfigurator maintaining state about where all of the http-01 Challenges, - Dvsni Challenges belong in the response array. This is an optional - utility. + TLS-SNI-01 Challenges belong in the response array. This is an + optional utility. :param str challenge_conf: location of the challenge config file @@ -46,14 +47,14 @@ class ApacheDvsni(common.TLSSNI01): """ def __init__(self, *args, **kwargs): - super(ApacheDvsni, self).__init__(*args, **kwargs) + super(ApacheTlsSni01, self).__init__(*args, **kwargs) self.challenge_conf = os.path.join( self.configurator.conf("server-root"), - "le_dvsni_cert_challenge.conf") + "le_tls_sni_01_cert_challenge.conf") def perform(self): - """Perform a DVSNI challenge.""" + """Perform a TLS-SNI-01 challenge.""" if not self.achalls: return [] # Save any changes to the configuration as a precaution @@ -71,8 +72,8 @@ class ApacheDvsni(common.TLSSNI01): responses.append(self._setup_challenge_cert(achall)) # Setup the configuration - dvsni_addrs = self._mod_config() - self.configurator.make_addrs_sni_ready(dvsni_addrs) + addrs = self._mod_config() + self.configurator.make_addrs_sni_ready(addrs) # Save reversible changes self.configurator.save("SNI Challenge", True) @@ -84,16 +85,16 @@ class ApacheDvsni(common.TLSSNI01): Result: Apache config includes virtual servers for issued challs - :returns: All DVSNI addresses used + :returns: All TLS-SNI-01 addresses used :rtype: set """ - dvsni_addrs = set() + addrs = set() config_text = "\n" for achall in self.achalls: - achall_addrs = self.get_dvsni_addrs(achall) - dvsni_addrs.update(achall_addrs) + achall_addrs = self._get_addrs(achall) + addrs.update(achall_addrs) config_text += self._get_config_text(achall, achall_addrs) @@ -106,30 +107,30 @@ class ApacheDvsni(common.TLSSNI01): with open(self.challenge_conf, "w") as new_conf: new_conf.write(config_text) - return dvsni_addrs + return addrs - def get_dvsni_addrs(self, achall): - """Return the Apache addresses needed for DVSNI.""" + def _get_addrs(self, achall): + """Return the Apache addresses needed for TLS-SNI-01.""" vhost = self.configurator.choose_vhost(achall.domain) # TODO: Checkout _default_ rules. - dvsni_addrs = set() + addrs = set() default_addr = obj.Addr(("*", str( self.configurator.config.tls_sni_01_port))) for addr in vhost.addrs: if "_default_" == addr.get_addr(): - dvsni_addrs.add(default_addr) + addrs.add(default_addr) else: - dvsni_addrs.add( + addrs.add( addr.get_sni_addr(self.configurator.config.tls_sni_01_port)) - return dvsni_addrs + return addrs def _conf_include_check(self, main_config): - """Adds DVSNI challenge conf file into configuration. + """Add TLS-SNI-01 challenge conf file into configuration. - Adds DVSNI challenge include file if it does not already exist + Adds TLS-SNI-01 challenge include file if it does not already exist within mainConfig :param str main_config: file path to main user apache config file @@ -146,7 +147,7 @@ class ApacheDvsni(common.TLSSNI01): """Chocolate virtual server configuration text :param .KeyAuthorizationAnnotatedChallenge achall: Annotated - DVSNI challenge. + TLS-SNI-01 challenge. :param list ip_addrs: addresses of challenged domain :class:`list` of type `~.obj.Addr` @@ -157,7 +158,7 @@ class ApacheDvsni(common.TLSSNI01): """ ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( - self.configurator.config.work_dir, "dvsni_page/") + self.configurator.config.work_dir, "tls_sni_01_page/") # TODO: Python docs is not clear how mutliline string literal # newlines are parsed on different platforms. At least on # Linux (Debian sid), when source file uses CRLF, Python still diff --git a/letsencrypt-nginx/docs/api/dvsni.rst b/letsencrypt-nginx/docs/api/dvsni.rst deleted file mode 100644 index 4f5f9d7e3..000000000 --- a/letsencrypt-nginx/docs/api/dvsni.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt_nginx.dvsni` ------------------------------- - -.. automodule:: letsencrypt_nginx.dvsni - :members: diff --git a/letsencrypt-nginx/docs/api/tls_sni_01.rst b/letsencrypt-nginx/docs/api/tls_sni_01.rst new file mode 100644 index 000000000..2860231b5 --- /dev/null +++ b/letsencrypt-nginx/docs/api/tls_sni_01.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt_nginx.tls_sni_01` +------------------------------ + +.. automodule:: letsencrypt_nginx.tls_sni_01 + :members: diff --git a/letsencrypt-nginx/letsencrypt_nginx/configurator.py b/letsencrypt-nginx/letsencrypt_nginx/configurator.py index 29445a9d4..42ad34fec 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/configurator.py +++ b/letsencrypt-nginx/letsencrypt_nginx/configurator.py @@ -24,7 +24,7 @@ from letsencrypt import reverter from letsencrypt.plugins import common from letsencrypt_nginx import constants -from letsencrypt_nginx import dvsni +from letsencrypt_nginx import tls_sni_01 from letsencrypt_nginx import obj from letsencrypt_nginx import parser @@ -573,15 +573,15 @@ class NginxConfigurator(common.Plugin): """ self._chall_out += len(achalls) responses = [None] * len(achalls) - nginx_dvsni = dvsni.NginxDvsni(self) + authenticator = tls_sni_01.NginxTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have dvsni hold associated index + # Currently also have authenticator hold associated index # of the challenge. This helps to put all of the responses back # together when they are all complete. - nginx_dvsni.add_chall(achall, i) + authenticator.add_chall(achall, i) - sni_response = nginx_dvsni.perform() + sni_response = authenticator.perform() # Must restart in order to activate the challenges. # Handled here because we may be able to load up other challenge types self.restart() @@ -590,7 +590,7 @@ class NginxConfigurator(common.Plugin): # 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[nginx_dvsni.indices[i]] = resp + responses[authenticator.indices[i]] = resp return responses diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py index ff720ea85..56ad5110c 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py @@ -212,9 +212,9 @@ class NginxConfiguratorTest(util.NginxTest): ('/etc/nginx/fullchain.pem', '/etc/nginx/key.pem', nginx_conf), ]), self.config.get_all_certs_keys()) - @mock.patch("letsencrypt_nginx.configurator.dvsni.NginxDvsni.perform") + @mock.patch("letsencrypt_nginx.configurator.tls_sni_01.NginxTlsSni01.perform") @mock.patch("letsencrypt_nginx.configurator.NginxConfigurator.restart") - def test_perform(self, mock_restart, mock_dvsni_perform): + def test_perform(self, mock_restart, mock_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded achall1 = achallenges.KeyAuthorizationAnnotatedChallenge( @@ -230,16 +230,16 @@ class NginxConfiguratorTest(util.NginxTest): status=messages.Status("pending"), ), domain="example.com", account_key=self.rsa512jwk) - dvsni_ret_val = [ + expected = [ achall1.response(self.rsa512jwk), achall2.response(self.rsa512jwk), ] - mock_dvsni_perform.return_value = dvsni_ret_val + mock_perform.return_value = expected responses = self.config.perform([achall1, achall2]) - self.assertEqual(mock_dvsni_perform.call_count, 1) - self.assertEqual(responses, dvsni_ret_val) + self.assertEqual(mock_perform.call_count, 1) + self.assertEqual(responses, expected) self.assertEqual(mock_restart.call_count, 1) @mock.patch("letsencrypt_nginx.configurator.subprocess.Popen") diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/tls_sni_01_test.py similarity index 95% rename from letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py rename to letsencrypt-nginx/letsencrypt_nginx/tests/tls_sni_01_test.py index d32e3d98f..04fe01bc4 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/tls_sni_01_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt_nginx.dvsni.""" +"""Tests for letsencrypt_nginx.tls_sni_01""" import unittest import shutil @@ -16,8 +16,8 @@ from letsencrypt_nginx import obj from letsencrypt_nginx.tests import util -class DvsniPerformTest(util.NginxTest): - """Test the NginxDVSNI challenge.""" +class TlsSniPerformTest(util.NginxTest): + """Test the NginxTlsSni01 challenge.""" account_key = common_test.TLSSNI01Test.auth_key achalls = [ @@ -42,13 +42,13 @@ class DvsniPerformTest(util.NginxTest): ] def setUp(self): - super(DvsniPerformTest, self).setUp() + super(TlsSniPerformTest, self).setUp() config = util.get_nginx_configurator( self.config_path, self.config_dir, self.work_dir) - from letsencrypt_nginx import dvsni - self.sni = dvsni.NginxDvsni(config) + from letsencrypt_nginx import tls_sni_01 + self.sni = tls_sni_01.NginxTlsSni01(config) def tearDown(self): shutil.rmtree(self.temp_dir) diff --git a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py b/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py similarity index 82% rename from letsencrypt-nginx/letsencrypt_nginx/dvsni.py rename to letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py index 8fd705f08..c1bd434f6 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py @@ -1,4 +1,5 @@ -"""NginxDVSNI""" +"""A TLS-SNI-01 authenticator for Nginx""" + import itertools import logging import os @@ -13,31 +14,32 @@ from letsencrypt_nginx import nginxparser logger = logging.getLogger(__name__) -class NginxDvsni(common.TLSSNI01): - """Class performs DVSNI challenges within the Nginx configurator. +class NginxTlsSni01(common.TLSSNI01): + """TLS-SNI-01 authenticator for Nginx :ivar configurator: NginxConfigurator object :type configurator: :class:`~nginx.configurator.NginxConfigurator` - :ivar list achalls: Annotated :class:`~letsencrypt.achallenges.DVSNI` - challenges. + :ivar list achalls: Annotated + class:`~letsencrypt.achallenges.KeyAuthorizationAnnotatedChallenge` + challenges :param list indices: Meant to hold indices of challenges in a - larger array. NginxDvsni is capable of solving many challenges + larger array. NginxTlsSni01 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, - Dvsni Challenges belong in the response array. This is an optional - utility. + TLS-SNI-01 Challenges belong in the response array. This is an + optional utility. :param str challenge_conf: location of the challenge config file """ def perform(self): - """Perform a DVSNI challenge on Nginx. + """Perform a challenge on Nginx. - :returns: list of :class:`letsencrypt.acme.challenges.DVSNIResponse` + :returns: list of :class:`letsencrypt.acme.challenges.TLSSNI01Response` :rtype: list """ @@ -84,7 +86,8 @@ class NginxDvsni(common.TLSSNI01): :class:`letsencrypt_nginx.obj.Addr` to apply :raises .MisconfigurationError: - Unable to find a suitable HTTP block to include DVSNI hosts. + Unable to find a suitable HTTP block in which to include + authenticator hosts. """ # Add the 'include' statement for the challenges if it doesn't exist @@ -110,8 +113,8 @@ class NginxDvsni(common.TLSSNI01): break if not included: raise errors.MisconfigurationError( - 'LetsEncrypt could not find an HTTP block to include DVSNI ' - 'challenges in %s.' % root) + 'LetsEncrypt could not find an HTTP block to include ' + 'TLS-SNI-01 challenges in %s.' % root) config = [self._make_server_block(pair[0], pair[1]) for pair in itertools.izip(self.achalls, ll_addrs)] @@ -123,10 +126,11 @@ class NginxDvsni(common.TLSSNI01): nginxparser.dump(config, new_conf) def _make_server_block(self, achall, addrs): - """Creates a server block for a DVSNI challenge. + """Creates a server block for a challenge. - :param achall: Annotated DVSNI challenge. - :type achall: :class:`letsencrypt.achallenges.DVSNI` + :param achall: Annotated TLS-SNI-01 challenge + :type achall: + :class:`letsencrypt.achallenges.KeyAuthorizationAnnotatedChallenge` :param list addrs: addresses of challenged domain :class:`list` of type :class:`~nginx.obj.Addr` @@ -136,7 +140,7 @@ class NginxDvsni(common.TLSSNI01): """ document_root = os.path.join( - self.configurator.config.work_dir, "dvsni_page") + self.configurator.config.work_dir, "tls_sni_01_page") block = [['listen', str(addr)] for addr in addrs] diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 93daa90ff..d414dd146 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -136,7 +136,7 @@ class Addr(object): class TLSSNI01(object): - """Class that performs tls-sni-01 challenges.""" + """Abstract base for TLS-SNI-01 authenticators""" def __init__(self, configurator): self.configurator = configurator From 1d30bba0c2896dcc9b1e5f8767281444cc696836 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 19 Nov 2015 13:04:37 -0500 Subject: [PATCH 077/181] Correct pep8 errors across codebase. --- letsencrypt-nginx/letsencrypt_nginx/configurator.py | 7 ++++--- letsencrypt-nginx/letsencrypt_nginx/parser.py | 2 +- letsencrypt-nginx/letsencrypt_nginx/tests/util.py | 2 +- letsencrypt/cli.py | 2 +- letsencrypt/tests/cli_test.py | 6 +++--- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/letsencrypt-nginx/letsencrypt_nginx/configurator.py b/letsencrypt-nginx/letsencrypt_nginx/configurator.py index 42ad34fec..c1ac9db66 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/configurator.py +++ b/letsencrypt-nginx/letsencrypt_nginx/configurator.py @@ -379,11 +379,12 @@ class NginxConfigurator(common.Plugin): :param unused_options: Not currently used :type unused_options: Not Available """ - redirect_block = [[['if', '($scheme != "https")'], + redirect_block = [[ + ['if', '($scheme != "https")'], [['return', '301 https://$host$request_uri']] ]] - self.parser.add_server_directives(vhost.filep, vhost.names, - redirect_block) + self.parser.add_server_directives( + vhost.filep, vhost.names, redirect_block) logger.info("Redirecting all traffic to ssl in %s", vhost.filep) ###################################### diff --git a/letsencrypt-nginx/letsencrypt_nginx/parser.py b/letsencrypt-nginx/letsencrypt_nginx/parser.py index 705257c16..d17370748 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/parser.py +++ b/letsencrypt-nginx/letsencrypt_nginx/parser.py @@ -413,7 +413,7 @@ def _regex_match(target_name, name): return True else: return False - except re.error: # pragma: no cover + except re.error: # pragma: no cover # perl-compatible regexes are sometimes not recognized by python return False diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py index e60feb3d3..3d70f7ac7 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py @@ -50,7 +50,7 @@ def get_nginx_configurator( backups = os.path.join(work_dir, "backups") with mock.patch("letsencrypt_nginx.configurator.le_util." - "exe_exists") as mock_exe_exists: + "exe_exists") as mock_exe_exists: mock_exe_exists.return_value = True config = configurator.NginxConfigurator( diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index d641578ed..51d326f1f 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -381,7 +381,7 @@ def diagnose_configurator_problem(cfg_type, requested, plugins): raise errors.PluginSelectionError(msg) -def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable=too-many-branches +def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable=too-many-branches """ Figure out which configurator we're going to use diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 71b580cf0..b8fafc264 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -40,8 +40,8 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self.work_dir = os.path.join(self.tmp_dir, 'work') self.logs_dir = os.path.join(self.tmp_dir, 'logs') self.standard_args = ['--text', '--config-dir', self.config_dir, - '--work-dir', self.work_dir, '--logs-dir', self.logs_dir, - '--agree-dev-preview'] + '--work-dir', self.work_dir, '--logs-dir', + self.logs_dir, '--agree-dev-preview'] def tearDown(self): shutil.rmtree(self.tmp_dir) @@ -57,7 +57,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods args = self.standard_args + args with mock.patch('letsencrypt.cli.sys.stdout') as stdout: with mock.patch('letsencrypt.cli.sys.stderr') as stderr: - ret = cli.main(args[:]) # NOTE: parser can alter its args! + ret = cli.main(args[:]) # NOTE: parser can alter its args! return ret, stdout, stderr def _call_stdout(self, args): From 87a5fef90c886a26884942f2b752a6ee6b8e19e1 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 14:52:45 -0800 Subject: [PATCH 078/181] Install letsencrypt-apache --- docs/using.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/using.rst b/docs/using.rst index cf5336cd9..d5e7ab606 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -103,7 +103,7 @@ Operating System Packages .. code-block:: shell - sudo pacman -S letsencrypt + sudo pacman -S letsencrypt letsencrypt-apache **Other Operating Systems** From 02562c75a3688c1cebdc0567bbc381440cc7f204 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 14:57:31 -0800 Subject: [PATCH 079/181] Remove references to --manual and --webroot --- docs/using.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index f6fb82f52..3f04fc5fa 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -184,7 +184,7 @@ Webroot If you're running a webserver that you don't want to stop to use standalone, you can use the webroot plugin to obtain a cert by -including ``certonly`` and ``--webroot`` on the command line. In +including ``certonly`` and ``-a webroot`` on the command line. In addition, you'll need to specify ``--webroot-path`` with the root directory of the files served by your webserver. For example, ``--webroot-path /var/www/html`` or @@ -200,7 +200,7 @@ If you'd like to obtain a cert running ``letsencrypt`` on a machine other than your target webserver or perform the steps for domain validation yourself, you can use the manual plugin. While hidden from the UI, you can use the plugin to obtain a cert by specifying -``certonly`` and ``--manual`` on the command line. This requires you +``certonly`` and ``-a manual`` on the command line. This requires you to copy and paste commands into another terminal session. Nginx From 2e06939fecfc8adca85b1ad28c27dd390e7e33b0 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 21:15:54 -0800 Subject: [PATCH 080/181] Disable selection of misconfigured plugins --- letsencrypt/display/ops.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 37ce66b62..a240bf847 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -34,7 +34,14 @@ def choose_plugin(prepared, question): question, opts, help_label="More Info") if code == display_util.OK: - return prepared[index] + if plugin_ep.misconfigured: + util(interfaces.IDisplay).notification( + "The selected plugin encountered an error while parsing " + "your server configuration and cannot be used. The error " + "was: {0}".format(prepared[index].prepare()) + height=display_util.HEIGHT) + else: + return prepared[index] elif code == display_util.HELP: if prepared[index].misconfigured: msg = "Reported Error: %s" % prepared[index].prepare() From 279c0d9ddf49233d14f976852998cc07d8d562f4 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 21:16:44 -0800 Subject: [PATCH 081/181] Comma --- letsencrypt/display/ops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index a240bf847..663db9307 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -38,7 +38,7 @@ def choose_plugin(prepared, question): util(interfaces.IDisplay).notification( "The selected plugin encountered an error while parsing " "your server configuration and cannot be used. The error " - "was: {0}".format(prepared[index].prepare()) + "was: {0}".format(prepared[index].prepare()), height=display_util.HEIGHT) else: return prepared[index] From 2bdc60dfef139dd9bd4e319bb61aea758ead7c0a Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 21:21:42 -0800 Subject: [PATCH 082/181] Scoping rules are frustrating --- letsencrypt/display/ops.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 663db9307..5724cc542 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -34,14 +34,15 @@ def choose_plugin(prepared, question): question, opts, help_label="More Info") if code == display_util.OK: + plugin_ep = prepared[index] if plugin_ep.misconfigured: util(interfaces.IDisplay).notification( "The selected plugin encountered an error while parsing " "your server configuration and cannot be used. The error " - "was: {0}".format(prepared[index].prepare()), + "was: {0}".format(plugin_ep.prepare()), height=display_util.HEIGHT) else: - return prepared[index] + return plugin_ep elif code == display_util.HELP: if prepared[index].misconfigured: msg = "Reported Error: %s" % prepared[index].prepare() From 489e79d77763c07ac8ec0e0f19852f876e896594 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 22:13:04 -0800 Subject: [PATCH 083/181] spacing --- letsencrypt/display/ops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 5724cc542..224a701fb 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -39,7 +39,7 @@ def choose_plugin(prepared, question): util(interfaces.IDisplay).notification( "The selected plugin encountered an error while parsing " "your server configuration and cannot be used. The error " - "was: {0}".format(plugin_ep.prepare()), + "was:\n\n{0}".format(plugin_ep.prepare()), height=display_util.HEIGHT) else: return plugin_ep From 52361cc7305147ec624d3d225ace944c62ae981d Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 22:18:48 -0800 Subject: [PATCH 084/181] Added tests --- letsencrypt/tests/display/ops_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/letsencrypt/tests/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index 9d4a3a933..7427a1dc0 100644 --- a/letsencrypt/tests/display/ops_test.py +++ b/letsencrypt/tests/display/ops_test.py @@ -41,9 +41,11 @@ class ChoosePluginTest(unittest.TestCase): return choose_plugin(self.plugins, "Question?") @mock.patch("letsencrypt.display.ops.util") - def test_successful_choice(self, mock_util): - mock_util().menu.return_value = (display_util.OK, 0) - self.assertEqual(self.mock_apache, self._call()) + def test_selection(self, mock_util): + mock_util().menu.side_effect = [(display_util.OK, 0), + (display_util.OK, 1)] + self.assertEqual(self.mock_stand, self._call()) + self.assertEqual(mock_util().notification.call_count, 1) @mock.patch("letsencrypt.display.ops.util") def test_more_info(self, mock_util): From 350a07086f653158fc9592de7df1b625bffc7ec8 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 19 Nov 2015 22:35:28 -0800 Subject: [PATCH 085/181] Remove confirmation in text display --- letsencrypt/display/ops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 224a701fb..dc5904cda 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -40,7 +40,7 @@ def choose_plugin(prepared, question): "The selected plugin encountered an error while parsing " "your server configuration and cannot be used. The error " "was:\n\n{0}".format(plugin_ep.prepare()), - height=display_util.HEIGHT) + height=display_util.HEIGHT, pause=False) else: return plugin_ep elif code == display_util.HELP: From 793f2b4f9016bfe14933fe32beb0f7103f06d9ca Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Fri, 20 Nov 2015 11:42:08 -0800 Subject: [PATCH 086/181] fixed lint scoping issue --- letsencrypt-apache/letsencrypt_apache/configurator.py | 4 ++-- letsencrypt-apache/letsencrypt_apache/dvsni.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 64449302a..d88480d0a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -234,7 +234,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not vhost.enabled: self.enable_site(vhost) - def choose_vhost(self, target_name, dvsni=False): + def choose_vhost(self, target_name, temp=False): """Chooses a virtual host based on the given domain name. If there is no clear virtual host to be selected, the user is prompted @@ -255,7 +255,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Try to find a reasonable vhost vhost = self._find_best_vhost(target_name) if vhost is not None: - if dvsni: + if temp: return vhost if not vhost.ssl: vhost = self.make_vhost_ssl(vhost) diff --git a/letsencrypt-apache/letsencrypt_apache/dvsni.py b/letsencrypt-apache/letsencrypt_apache/dvsni.py index 0dd411e4f..3e1bc87b7 100644 --- a/letsencrypt-apache/letsencrypt_apache/dvsni.py +++ b/letsencrypt-apache/letsencrypt_apache/dvsni.py @@ -110,7 +110,7 @@ class ApacheDvsni(common.TLSSNI01): def get_dvsni_addrs(self, achall): """Return the Apache addresses needed for DVSNI.""" - vhost = self.configurator.choose_vhost(achall.domain, dvsni=True) + vhost = self.configurator.choose_vhost(achall.domain, temp=True) # TODO: Checkout _default_ rules. dvsni_addrs = set() From d737546dd709fe5a1f3b8b99ca973b4d3e08a2dc Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Fri, 20 Nov 2015 16:31:01 -0600 Subject: [PATCH 087/181] Split off cleaning into a method (fixes a subtle bug) --- .../letsencrypt_apache/configurator.py | 18 +++--- .../tests/configurator_test.py | 55 +++++++++---------- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d80d27d1c..ff95eef95 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -183,6 +183,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ vhost = self.choose_vhost(domain) + self._clean_vhost(vhost) # This is done first so that ssl module is enabled and cert_path, # cert_key... can all be parsed appropriately @@ -276,15 +277,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.assoc[target_name] = vhost return vhost - vhost = self._choose_vhost_from_list(target_name) - if vhost.ssl: - # remove duplicated or conflicting ssl directives - self._deduplicate_directives(vhost.path, - ["SSLCertificateFile", "SSLCertificateKeyFile"]) - # remove all problematic directives - self._remove_directives(vhost.path, ["SSLCertificateChainFile"]) - - return vhost + return self._choose_vhost_from_list(target_name) def _choose_vhost_from_list(self, target_name): # Select a vhost from a list @@ -665,6 +658,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return ssl_addrs + def _clean_vhost(self, vhost): + # remove duplicated or conflicting ssl directives + self._deduplicate_directives(vhost.path, + ["SSLCertificateFile", "SSLCertificateKeyFile"]) + # remove all problematic directives + self._remove_directives(vhost.path, ["SSLCertificateChainFile"]) + def _deduplicate_directives(self, vh_path, directives): for directive in directives: while len(self.parser.find_dir(directive, None, vh_path, False)) > 1: diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 58aac1216..d5ea540c5 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -122,34 +122,6 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual( self.vh_truth[1], self.config.choose_vhost("none.com")) - @mock.patch("letsencrypt_apache.display_ops.select_vhost") - def test_choose_vhost_cleans_vhost_ssl(self, mock_select): - for directive in ["SSLCertificateFile", "SSLCertificateKeyFile", - "SSLCertificateChainFile", "SSLCACertificatePath"]: - for _ in range(10): - self.config.parser.add_dir(self.vh_truth[1].path, directive, ["bogus"]) - self.config.save() - mock_select.return_value = self.vh_truth[1] - - self.config.choose_vhost("none.com") - self.config.save() - - loc_cert = self.config.parser.find_dir( - 'SSLCertificateFile', None, self.vh_truth[1].path, False) - loc_key = self.config.parser.find_dir( - 'SSLCertificateKeyFile', None, self.vh_truth[1].path, False) - loc_chain = self.config.parser.find_dir( - 'SSLCertificateChainFile', None, self.vh_truth[1].path, False) - loc_cacert = self.config.parser.find_dir( - 'SSLCACertificatePath', None, self.vh_truth[1].path, False) - - self.assertEqual(len(loc_cert), 1) - self.assertEqual(len(loc_key), 1) - - self.assertEqual(len(loc_chain), 0) - - self.assertEqual(len(loc_cacert), 10) - @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_select_vhost_non_ssl(self, mock_select): mock_select.return_value = self.vh_truth[0] @@ -433,6 +405,33 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(len(self.config.vhosts), 5) + def test_clean_vhost_ssl(self): + # pylint: disable=protected-access + for directive in ["SSLCertificateFile", "SSLCertificateKeyFile", + "SSLCertificateChainFile", "SSLCACertificatePath"]: + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, directive, ["bogus"]) + self.config.save() + + self.config._clean_vhost(self.vh_truth[1]) + self.config.save() + + loc_cert = self.config.parser.find_dir( + 'SSLCertificateFile', None, self.vh_truth[1].path, False) + loc_key = self.config.parser.find_dir( + 'SSLCertificateKeyFile', None, self.vh_truth[1].path, False) + loc_chain = self.config.parser.find_dir( + 'SSLCertificateChainFile', None, self.vh_truth[1].path, False) + loc_cacert = self.config.parser.find_dir( + 'SSLCACertificatePath', None, self.vh_truth[1].path, False) + + self.assertEqual(len(loc_cert), 1) + self.assertEqual(len(loc_key), 1) + + self.assertEqual(len(loc_chain), 0) + + self.assertEqual(len(loc_cacert), 10) + def test_deduplicate_directives(self): # pylint: disable=protected-access DIRECTIVE = "Foo" From 52d7baab415de7880b1b26839b0ffde8ad87088c Mon Sep 17 00:00:00 2001 From: Jonathan Herlin Date: Fri, 20 Nov 2015 23:55:20 +0100 Subject: [PATCH 088/181] Added space to avoid printing "domainsas" instead of the correct "domains as" --- letsencrypt/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index d641578ed..e2930c6a1 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -855,7 +855,7 @@ def prepare_and_parse_args(plugins, args): helpful.add(None, "-d", "--domains", dest="domains", metavar="DOMAIN", action="append", help="Domain names to apply. For multiple domains you can use " - "multiple -d flags or enter a comma separated list of domains" + "multiple -d flags or enter a comma separated list of domains " "as a parameter.") helpful.add( None, "--duplicate", dest="duplicate", action="store_true", From bb6736f36cfe68fc31595954965635d337df1d24 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 20 Nov 2015 16:09:39 -0800 Subject: [PATCH 089/181] Remove http01_port magic --- letsencrypt/cli.py | 5 +++-- letsencrypt/configuration.py | 9 --------- letsencrypt/constants.py | 1 + letsencrypt/tests/configuration_test.py | 5 ----- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index e2930c6a1..fb71369f1 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -898,8 +898,9 @@ def prepare_and_parse_args(plugins, args): "testing", "--tls-sni-01-port", type=int, default=flag_default("tls_sni_01_port"), help=config_help("tls_sni_01_port")) - helpful.add("testing", "--http-01-port", dest="http01_port", type=int, - help=config_help("http01_port")) + helpful.add( + "testing", "--http-01-port", type=int, dest="http01_port", + default=flag_default("http01_port"), help=config_help("http01_port")) helpful.add_group( "security", description="Security parameters & server settings") diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 0ea539b5c..a2a54d2d0 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -5,8 +5,6 @@ import re import zope.interface -from acme import challenges - from letsencrypt import constants from letsencrypt import errors from letsencrypt import interfaces @@ -80,13 +78,6 @@ class NamespaceConfig(object): return os.path.join( self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR) - @property - def http01_port(self): # pylint: disable=missing-docstring - if self.namespace.http01_port is not None: - return self.namespace.http01_port - else: - return challenges.HTTP01Response.PORT - class RenewerConfiguration(object): """Configuration wrapper for renewer.""" diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index f71bf0329..a402ce923 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -23,6 +23,7 @@ CLI_DEFAULTS = dict( work_dir="/var/lib/letsencrypt", logs_dir="/var/log/letsencrypt", no_verify_ssl=False, + http01_port=challenges.HTTP01Response.PORT, tls_sni_01_port=challenges.TLSSNI01Response.PORT, auth_cert_path="./cert.pem", diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py index c42b99081..a4f881d34 100644 --- a/letsencrypt/tests/configuration_test.py +++ b/letsencrypt/tests/configuration_test.py @@ -54,11 +54,6 @@ class NamespaceConfigTest(unittest.TestCase): self.assertEqual(self.config.key_dir, '/tmp/config/keys') self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') - def test_http01_port(self): - self.assertEqual(4321, self.config.http01_port) - self.namespace.http01_port = None - self.assertEqual(80, self.config.http01_port) - def test_absolute_paths(self): from letsencrypt.configuration import NamespaceConfig From ee5f91f55a7cc43a4952ef6c157f3df7fd5a141e Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 20 Nov 2015 16:15:50 -0800 Subject: [PATCH 090/181] Set domains to None to satisfy sanity checks --- .../letsencrypt_compatibility_test/util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py index 43070cf03..b635ee539 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py @@ -34,6 +34,8 @@ def create_le_config(parent_dir): os.mkdir(config["work_dir"]) os.mkdir(config["logs_dir"]) + config["domains"] = None + return argparse.Namespace(**config) # pylint: disable=star-args From 368f208b7fc9fdaef62e703b1f54680e6b779d7b Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 20 Nov 2015 18:26:44 -0800 Subject: [PATCH 091/181] Log not fail --- letsencrypt/plugins/manual.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py index 07f06ccec..72c5bc259 100644 --- a/letsencrypt/plugins/manual.py +++ b/letsencrypt/plugins/manual.py @@ -173,17 +173,12 @@ s.serve_forever()" """ uri=achall.chall.uri(achall.domain), ct=achall.CONTENT_TYPE, command=command)) - if response.simple_verify( + if not response.simple_verify( achall.chall, achall.domain, achall.account_key.public_key(), self.config.http01_port): - return response - else: - logger.error( - "Self-verify of challenge failed, authorization abandoned.") - if self.conf("test-mode") and self._httpd.poll() is not None: - # simply verify cause command failure... - return False - return None + logger.warning("Self-verify of challenge failed.") + + return response def _notify_and_wait(self, message): # pylint: disable=no-self-use # TODO: IDisplay wraps messages, breaking the command From 6b23fe160e25a1a5b4a48136fee9b0a03ac60d03 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 20 Nov 2015 18:34:35 -0800 Subject: [PATCH 092/181] Added tests --- letsencrypt/plugins/manual_test.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py index a9281902f..5bde1f909 100644 --- a/letsencrypt/plugins/manual_test.py +++ b/letsencrypt/plugins/manual_test.py @@ -61,7 +61,9 @@ class AuthenticatorTest(unittest.TestCase): self.assertTrue(self.achalls[0].chall.encode("token") in message) mock_verify.return_value = False - self.assertEqual([None], self.auth.perform(self.achalls)) + with mock.patch("letsencrypt.plugins.manual.logger") as mock_logger: + self.auth.perform(self.achalls) + mock_logger.warning.assert_called_once_with(mock.ANY) @mock.patch("letsencrypt.plugins.manual.zope.component.getUtility") @mock.patch("letsencrypt.plugins.manual.Authenticator._notify_and_wait") @@ -87,20 +89,6 @@ class AuthenticatorTest(unittest.TestCase): self.assertRaises( errors.Error, self.auth_test_mode.perform, self.achalls) - @mock.patch("letsencrypt.plugins.manual.socket.socket") - @mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True) - @mock.patch("acme.challenges.HTTP01Response.simple_verify", - autospec=True) - @mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True) - def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep, - mock_socket): - mock_popen.return_value.poll.side_effect = [None, 10] - mock_popen.return_value.pid = 1234 - mock_verify.return_value = False - self.assertEqual([False], self.auth_test_mode.perform(self.achalls)) - self.assertEqual(1, mock_sleep.call_count) - self.assertEqual(1, mock_socket.call_count) - def test_cleanup_test_mode_already_terminated(self): # pylint: disable=protected-access self.auth_test_mode._httpd = httpd = mock.Mock() From 27de932747f040375b18b16d3d4c26d42d3a45b7 Mon Sep 17 00:00:00 2001 From: Felix Yan Date: Sat, 21 Nov 2015 10:40:20 +0800 Subject: [PATCH 093/181] letsencrypt-auto: Remove nginx plugin and letshelp to keep the same behavior as the virtualenv way --- letsencrypt-auto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt-auto b/letsencrypt-auto index 64af92ebe..083de58c4 100755 --- a/letsencrypt-auto +++ b/letsencrypt-auto @@ -131,7 +131,7 @@ then $SUDO $BOOTSTRAP/archlinux.sh else echo "Please use pacman to install letsencrypt packages:" - echo "# pacman -S letsencrypt letsencrypt-nginx letsencrypt-apache letshelp-letsencrypt" + echo "# pacman -S letsencrypt letsencrypt-apache" echo echo "If you would like to use the virtualenv way, please run the script again with the" echo "--debug flag." From c35c4f3fbebd8cd487d14a7b9f589f76f5129ed8 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 17 Nov 2015 16:01:53 -0800 Subject: [PATCH 094/181] Extra docstring --- letsencrypt/client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 57ca8d3dd..486eef198 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -135,7 +135,13 @@ def perform_registration(acme, config): Actually register new account, trying repeatedly if there are email problems - :returns: the same value as acme.register + :param .IConfig config: Client configuration. + :param acme.client.Client client: ACME client object. + + :returns: Registration Resource. + :rtype: `acme.messages.RegistrationResource` + + :raises .UnexpectedUpdate: """ try: return acme.register(messages.NewRegistration.from_data(email=config.email)) From ec267cf215152091f2c3d7dd4966f9c5da1b704a Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Fri, 20 Nov 2015 14:58:37 -0800 Subject: [PATCH 095/181] "Compute" the minimum height needed to reasonably display input --- letsencrypt/display/util.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/letsencrypt/display/util.py b/letsencrypt/display/util.py index 0e9c76e38..7107bfb3b 100644 --- a/letsencrypt/display/util.py +++ b/letsencrypt/display/util.py @@ -114,7 +114,11 @@ class NcursesDisplay(object): `string` - input entered by the user """ - return self.dialog.inputbox(message, width=self.width) + sections = message.split("\n") + # each section takes at least one line, plus extras if it's longer than self.width + wordlines = [1+(len(section)/self.width) for section in sections] + height = 6+ sum(wordlines) + len(sections) + return self.dialog.inputbox(message, width=self.width, height=height) def yesno(self, message, yes_label="Yes", no_label="No"): """Display a Yes/No dialog box. From 1a4d7c144529c4c0986c4620167cde4aa2f3d4eb Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Fri, 20 Nov 2015 15:12:05 -0800 Subject: [PATCH 096/181] Lintmonster --- letsencrypt/client.py | 2 ++ letsencrypt/display/util.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 486eef198..3dbf9d337 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -130,6 +130,7 @@ def register(config, account_storage, tos_cb=None): return acc, acme + def perform_registration(acme, config): """ Actually register new account, trying repeatedly if there are email @@ -153,6 +154,7 @@ def perform_registration(acme, config): else: raise + class Client(object): """ACME protocol client. diff --git a/letsencrypt/display/util.py b/letsencrypt/display/util.py index 7107bfb3b..01a8cbc92 100644 --- a/letsencrypt/display/util.py +++ b/letsencrypt/display/util.py @@ -104,6 +104,7 @@ class NcursesDisplay(object): return code, int(tag) - 1 + def input(self, message): """Display an input box to the user. @@ -116,10 +117,11 @@ class NcursesDisplay(object): """ sections = message.split("\n") # each section takes at least one line, plus extras if it's longer than self.width - wordlines = [1+(len(section)/self.width) for section in sections] - height = 6+ sum(wordlines) + len(sections) + wordlines = [1 + (len(section)/self.width) for section in sections] + height = 6 + sum(wordlines) + len(sections) return self.dialog.inputbox(message, width=self.width, height=height) + def yesno(self, message, yes_label="Yes", no_label="No"): """Display a Yes/No dialog box. From 3fec57d8548d4cd200dcae69a5ea8ef2ec3c50f5 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 20 Nov 2015 17:26:29 -0800 Subject: [PATCH 097/181] Fixed test --- letsencrypt/tests/client_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 0a7d64a84..160dd55c1 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -57,7 +57,7 @@ class RegisterTest(unittest.TestCase): msg = "Validation of contact mailto:sousaphone@improbablylongggstring.tld failed" mx_err = messages.Error(detail=msg, typ="malformed", title="title") with mock.patch("letsencrypt.client.acme_client.Client") as mock_client: - mock_client.register.side_effect = mx_err + mock_client().register.side_effect = [mx_err, mock.MagicMock()] self._call() self.assertEqual(mock_get_email.call_count, 1) From c3e2c58272fd53fada865d5f0a708dc156c506a4 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Fri, 20 Nov 2015 18:57:57 -0800 Subject: [PATCH 098/181] Fix comment nits --- letsencrypt/display/ops.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 5a51647a8..33a69b2a3 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -117,9 +117,9 @@ def pick_configurator( def get_email(more=False, invalid=False): """Prompt for valid email address. - :param bool more: -- explain why the email is strongly advisable, but how to + :param bool more: explain why the email is strongly advisable, but how to skip it - "param bool invalid: -- true if the user just typed something, but it wasn't + :param bool invalid: true if the user just typed something, but it wasn't a valid-looking email :returns: Email or ``None`` if cancelled by user. From 2bc0c31f2ef23bdb13d4ef0e4484670e74897b15 Mon Sep 17 00:00:00 2001 From: Patrick Figel Date: Sat, 21 Nov 2015 01:39:03 +0100 Subject: [PATCH 099/181] Trim trailing whitespace during challenge self-verification fixes #1322 --- acme/acme/challenges.py | 8 ++++++-- acme/acme/challenges_test.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 976d7ab12..336e6c4e5 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -228,6 +228,9 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): """ + WHITESPACE_CUTSET = "\n\r\t " + """Whitespace characters which should be ignored at the end of the body.""" + def simple_verify(self, chall, domain, account_public_key, port=None): """Simple verify. @@ -273,10 +276,11 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): found_ct, chall.CONTENT_TYPE) return False - if self.key_authorization != http_response.text: + challenge_response = http_response.text.rstrip(self.WHITESPACE_CUTSET) + if self.key_authorization != challenge_response: logger.debug("Key authorization from response (%r) doesn't match " "HTTP response (%r)", self.key_authorization, - http_response.text) + challenge_response) return False return True diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index c4f3d6c61..7cf387ece 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -126,6 +126,16 @@ class HTTP01ResponseTest(unittest.TestCase): self.assertFalse(self.response.simple_verify( self.chall, "local", KEY.public_key())) + @mock.patch("acme.challenges.requests.get") + def test_simple_verify_whitespace_validation(self, mock_get): + from acme.challenges import HTTP01Response + mock_get.return_value = mock.MagicMock( + text=(self.chall.validation(KEY) + + HTTP01Response.WHITESPACE_CUTSET), headers=self.good_headers) + self.assertTrue(self.response.simple_verify( + self.chall, "local", KEY.public_key())) + mock_get.assert_called_once_with(self.chall.uri("local")) + @mock.patch("acme.challenges.requests.get") def test_simple_verify_bad_content_type(self, mock_get): mock_get().text = self.chall.token From 5667acc558b1ab1492d86d42bf46e8b4c3932e41 Mon Sep 17 00:00:00 2001 From: Nav Date: Sat, 21 Nov 2015 17:57:03 +0200 Subject: [PATCH 100/181] Refining importing libraries --- letsencrypt/storage.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index cc7ab4313..71550e855 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -1,5 +1,6 @@ """Renewable certificates storage.""" import datetime +import logging import os import re @@ -7,9 +8,6 @@ import configobj import parsedatetime import pytz -import logging -import logging.handlers - from letsencrypt import constants from letsencrypt import crypto_util from letsencrypt import errors From b42b5d0f08d82825f0a359e9ffd9692f4f0b2f5e Mon Sep 17 00:00:00 2001 From: Nav Date: Sat, 21 Nov 2015 18:07:59 +0200 Subject: [PATCH 101/181] Refining content of logging messages --- letsencrypt/storage.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 71550e855..7e2802b14 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -142,7 +142,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes for x in (self.cert, self.privkey, self.chain, self.fullchain): if not os.path.isabs(x): logger.debug("Element %s is not referenced with an " - "absolute file.", x) + "absolute path.", x) return False # Each element must exist and be a symbolic link @@ -166,21 +166,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes logger.debug("Element's link does not point within the " "cert lineage's directory within the " "official archive directory. Link: %s, " + "target directory: %s, " "archive directory: %s.", - os.path.dirname(target), desired_directory) + link, os.path.dirname(target), desired_directory) return False # The link must point to a file that exists if not os.path.exists(target): - logger.debug("Link %s points to a file that does not exist.", target) + logger.debug("Link %s points to file %s that does not exist.", + link, target) return False # The link must point to a file that follows the archive # naming convention pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) if not pattern.match(os.path.basename(target)): - logger.debug("Link %s does not follow the archive naming " - "convention.", os.path.basename(target)) + logger.debug("%s does not follow the archive naming " + "convention.", target) return False # It is NOT required that the link's target be a regular @@ -265,7 +267,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes raise errors.CertStorageError("unknown kind of item") link = getattr(self, kind) if not os.path.exists(link): - logger.debug("Target %s of kind %s does not exist.", link, kind) + logger.debug("Expected symlink %s for %s does not exist.", + link, kind) return None target = os.readlink(link) if not os.path.isabs(target): @@ -557,7 +560,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes now = pytz.UTC.fromutc(datetime.datetime.utcnow()) if expiry < add_time_interval(now, interval): logger.debug("Should renew, certificate " - "has expired since %s.", + "has been expired since %s.", expiry.strftime("%Y-%m-%d %H:%M:%S %Z")) return True return False @@ -610,7 +613,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes cli_config.live_dir): if not os.path.exists(i): os.makedirs(i, 0700) - logger.debug("Creating CLI config directory %s.", i) + logger.debug("Creating directory %s.", i) config_file, config_filename = le_util.unique_lineage_name( cli_config.renewal_configs_dir, lineagename) if not config_filename.endswith(".conf"): From 0017e4887045139baaff7f1ae538d037bc2b79c2 Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Sat, 21 Nov 2015 08:53:11 -0800 Subject: [PATCH 102/181] added docstring for temp --- letsencrypt-apache/letsencrypt_apache/configurator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index e6f7ed270..6edffcbe6 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -237,6 +237,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): with all available choices. :param str target_name: domain name + :param bool temp: whether or not self.make_vhost_ssl shouldn't be called :returns: ssl vhost associated with name :rtype: :class:`~letsencrypt_apache.obj.VirtualHost` From 19f348b4166771b2ce439217d4506f94f7c9e1e6 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Thu, 19 Nov 2015 03:53:20 -0800 Subject: [PATCH 103/181] First implementation of -w for multi-webroot specification * Will need tests and cleanup --- letsencrypt/cli.py | 43 +++++++++++++++------------------- letsencrypt/plugins/webroot.py | 19 ++++++++++++++- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index bd947e191..5015e5651 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -683,31 +683,8 @@ class HelpfulArgumentParser(object): parsed_args = self.parser.parse_args(self.args) parsed_args.func = self.VERBS[self.verb] - parsed_args.domains = self._parse_domains(parsed_args.domains) return parsed_args - def _parse_domains(self, domains): - """Helper function for parse_args() that parses domains from a - (possibly) comma separated list and returns list of unique domains. - - :param domains: List of domain flags - :type domains: `list` of `string` - - :returns: List of unique domains - :rtype: `list` of `string` - - """ - - uniqd = None - - if domains: - dlist = [] - for domain in domains: - dlist.extend([d.strip() for d in domain.split(",")]) - # Make sure we don't have duplicates - uniqd = [d for i, d in enumerate(dlist) if d not in dlist[:i]] - - return uniqd def determine_verb(self): """Determines the verb/subcommand provided by the user. @@ -824,6 +801,7 @@ class HelpfulArgumentParser(object): return dict([(t, t == chosen_topic) for t in self.help_topics]) + def prepare_and_parse_args(plugins, args): """Returns parsed command line arguments. @@ -861,7 +839,7 @@ def prepare_and_parse_args(plugins, args): #for subparser in parser_run, parser_auth, parser_install: # subparser.add_argument("domains", nargs="*", metavar="domain") helpful.add(None, "-d", "--domains", dest="domains", - metavar="DOMAIN", action="append", + metavar="DOMAIN", action=DomainFlagProcessor, help="Domain names to apply. For multiple domains you can use " "multiple -d flags or enter a comma separated list of domains " "as a parameter.") @@ -1044,6 +1022,8 @@ def _plugins_parsing(helpful, plugins): help='Provide laborious manual instructions for obtaining a cert') helpful.add("plugins", "--webroot", action="store_true", help='Obtain certs by placing files in a webroot directory.') + #helpful.add("plugins", "-w", action=WebrootAction, + # help='Obtain certs by placing files in a webroot directory.') # things should not be reorder past/pre this comment: # plugins_group should be displayed in --help before plugin @@ -1051,6 +1031,21 @@ def _plugins_parsing(helpful, plugins): helpful.add_plugin_args(plugins) +class DomainFlagProcessor(argparse.Action): + def __call__(self, parser, config, domain_arg, option_string=None): + """ + Process a new -d flag, helping the webroot plugin construct a map of + {domain : webrootpath} if -w / --webroot-path is in use + """ + if not config.domains: config.domains = [] + new_domains = [d.strip() for d in domain_arg.split(",") + if d not in config.domains] + config.domains.extend(new_domains) + + if config.webroot_path: + # Each domain has a webroot_path of the most recent -w flag + for d in new_domains: + config.webroot_map[d] = config.webroot_path[-1] def setup_log_file_handler(args, logfile, fmt): """Setup file debug logging.""" diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 42bfe312b..421429ff0 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -38,6 +38,7 @@ Use the following snippet in your ``server{...}`` stanza:: and reload your daemon. """ +import argparse import errno import logging import os @@ -53,6 +54,22 @@ from letsencrypt.plugins import common logger = logging.getLogger(__name__) +class WebrootPathProcessor(argparse.Action): + def __call__(self, parser, config, webroot, option_string=None): + """ + Keep a record of --webroot-path / -w flags during processing, so that + we know which apply to which -d flags + """ + if not config.webroot_path: + config.webroot_path = [] + # if any --domain flags preceded the first --webroot-path flag, + # apply that webroot path to those; subsequent entries in + # config.webroot_map are filled in by cli.DomainFlagProcessor + if config.domains: + config.webroot_map = dict([(d, webroot) for d in config.domains]) + else: + config.webroot_map = {} + config.webroot_path.append(webroot) class Authenticator(common.Plugin): """Webroot Authenticator.""" @@ -72,7 +89,7 @@ to serve all files under specified web root ({0}).""" @classmethod def add_parser_arguments(cls, add): - add("path", help="public_html / webroot path") + add("path", "-w", help="public_html / webroot path", action=WebrootPathAccumulator) def get_chall_pref(self, domain): # pragma: no cover # pylint: disable=missing-docstring,no-self-use,unused-argument From e1f0fcca8f19c60a51b445cae0a880c7bb2c4daf Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Thu, 19 Nov 2015 09:57:31 -0800 Subject: [PATCH 104/181] Move --webroot-path processing into cli.py Since it is now interdependent with --domains (This is much more elegant than trying to APIify the interaction) --- letsencrypt/cli.py | 26 ++++++++++++++++++++++++++ letsencrypt/plugins/webroot.py | 19 ++----------------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 5015e5651..914df7fb5 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1022,6 +1022,12 @@ def _plugins_parsing(helpful, plugins): help='Provide laborious manual instructions for obtaining a cert') helpful.add("plugins", "--webroot", action="store_true", help='Obtain certs by placing files in a webroot directory.') + + # This would normally be a flag within the webroot plugin, the webroot + # plugin, but because it is parsed in conjunction with --domains, it lives + # here + helpful.add("webroot", "-w", "--webroot-path", action=WebrootPathProcessor, + help="public_html / webroot path") #helpful.add("plugins", "-w", action=WebrootAction, # help='Obtain certs by placing files in a webroot directory.') @@ -1031,6 +1037,25 @@ def _plugins_parsing(helpful, plugins): helpful.add_plugin_args(plugins) + +class WebrootPathProcessor(argparse.Action): + def __call__(self, parser, config, webroot, option_string=None): + """ + Keep a record of --webroot-path / -w flags during processing, so that + we know which apply to which -d flags + """ + if not config.webroot_path: + config.webroot_path = [] + # if any --domain flags preceded the first --webroot-path flag, + # apply that webroot path to those; subsequent entries in + # config.webroot_map are filled in by cli.DomainFlagProcessor + if config.domains: + config.webroot_map = dict([(d, webroot) for d in config.domains]) + else: + config.webroot_map = {} + config.webroot_path.append(webroot) + + class DomainFlagProcessor(argparse.Action): def __call__(self, parser, config, domain_arg, option_string=None): """ @@ -1047,6 +1072,7 @@ class DomainFlagProcessor(argparse.Action): for d in new_domains: config.webroot_map[d] = config.webroot_path[-1] + def setup_log_file_handler(args, logfile, fmt): """Setup file debug logging.""" log_file_path = os.path.join(args.logs_dir, logfile) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 421429ff0..33e8699a2 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -54,22 +54,7 @@ from letsencrypt.plugins import common logger = logging.getLogger(__name__) -class WebrootPathProcessor(argparse.Action): - def __call__(self, parser, config, webroot, option_string=None): - """ - Keep a record of --webroot-path / -w flags during processing, so that - we know which apply to which -d flags - """ - if not config.webroot_path: - config.webroot_path = [] - # if any --domain flags preceded the first --webroot-path flag, - # apply that webroot path to those; subsequent entries in - # config.webroot_map are filled in by cli.DomainFlagProcessor - if config.domains: - config.webroot_map = dict([(d, webroot) for d in config.domains]) - else: - config.webroot_map = {} - config.webroot_path.append(webroot) + class Authenticator(common.Plugin): """Webroot Authenticator.""" @@ -89,7 +74,7 @@ to serve all files under specified web root ({0}).""" @classmethod def add_parser_arguments(cls, add): - add("path", "-w", help="public_html / webroot path", action=WebrootPathAccumulator) + pass def get_chall_pref(self, domain): # pragma: no cover # pylint: disable=missing-docstring,no-self-use,unused-argument From ffe6226edc17cf9657136cc178f569f5eb97b6d5 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Thu, 19 Nov 2015 09:58:35 -0800 Subject: [PATCH 105/181] Switch webroot.prepare() to use config.webroot_map --- letsencrypt/plugins/webroot.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 33e8699a2..5ad438a5f 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -85,24 +85,27 @@ to serve all files under specified web root ({0}).""" self.full_root = None def prepare(self): # pylint: disable=missing-docstring - path = self.conf("path") - if path is None: + path_map = self.conf("map") + self.full_roots = {} + + if not path_map: raise errors.PluginError("--{0} must be set".format( self.option_name("path"))) - if not os.path.isdir(path): - raise errors.PluginError( - path + " does not exist or is not a directory") - self.full_root = os.path.join(path, challenges.HTTP01.URI_ROOT_PATH) - - logger.debug("Creating root challenges validation dir at %s", - self.full_root) - try: - os.makedirs(self.full_root) - except OSError as exception: - if exception.errno != errno.EEXIST: + for name, path in path_map.items(): + if not os.path.isdir(path): raise errors.PluginError( - "Couldn't create root for http-01 " - "challenge responses: {0}", exception) + path + " does not exist or is not a directory") + self.full_roots[name] = os.path.join(path, challenges.HTTP01.URI_ROOT_PATH) + + logger.debug("Creating root challenges validation dir at %s", + self.full_roots[name]) + try: + os.makedirs(self.full_roots[name]) + except OSError as exception: + if exception.errno != errno.EEXIST: + raise errors.PluginError( + "Couldn't create root for {0} http-01 " + "challenge responses: {1}", name, exception) def perform(self, achalls): # pylint: disable=missing-docstring assert self.full_root is not None From f2f9d33e035c8e0b1c42412cac1be63746f1c9f5 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Thu, 19 Nov 2015 10:01:08 -0800 Subject: [PATCH 106/181] Update _path_for_achall Borrowing from @grubberr's changes at: https://github.com/letsencrypt/letsencrypt/pull/1284/files#diff-522ab130649a0ce14df40114d4ccd0b5L111 --- letsencrypt/plugins/webroot.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 5ad438a5f..1c5093b03 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -82,11 +82,10 @@ to serve all files under specified web root ({0}).""" def __init__(self, *args, **kwargs): super(Authenticator, self).__init__(*args, **kwargs) - self.full_root = None + self.full_roots = {} def prepare(self): # pylint: disable=missing-docstring path_map = self.conf("map") - self.full_roots = {} if not path_map: raise errors.PluginError("--{0} must be set".format( @@ -108,11 +107,15 @@ to serve all files under specified web root ({0}).""" "challenge responses: {1}", name, exception) def perform(self, achalls): # pylint: disable=missing-docstring - assert self.full_root is not None + assert self.full_root, "Webroot plugin appears to be missing webroot map" return [self._perform_single(achall) for achall in achalls] def _path_for_achall(self, achall): - return os.path.join(self.full_root, achall.chall.encode("token")) + path = self.full_roots[achall.domain] + if not path: + raise errors.PluginError("Cannot find path {0} for domain: {1}" + .format(path, achall.domain)) + return os.path.join(path, achall.chall.encode("token")) def _perform_single(self, achall): response, validation = achall.response_and_validation() From f48ef6ded9af20deb6db296c12498902052b4493 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Thu, 19 Nov 2015 10:09:10 -0800 Subject: [PATCH 107/181] lint --- letsencrypt/cli.py | 7 ++++--- letsencrypt/plugins/webroot.py | 6 ++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 914df7fb5..3dd53f011 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1038,7 +1038,7 @@ def _plugins_parsing(helpful, plugins): helpful.add_plugin_args(plugins) -class WebrootPathProcessor(argparse.Action): +class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring def __call__(self, parser, config, webroot, option_string=None): """ Keep a record of --webroot-path / -w flags during processing, so that @@ -1056,13 +1056,14 @@ class WebrootPathProcessor(argparse.Action): config.webroot_path.append(webroot) -class DomainFlagProcessor(argparse.Action): +class DomainFlagProcessor(argparse.Action): # pylint: disable=missing-docstring def __call__(self, parser, config, domain_arg, option_string=None): """ Process a new -d flag, helping the webroot plugin construct a map of {domain : webrootpath} if -w / --webroot-path is in use """ - if not config.domains: config.domains = [] + if not config.domains: + config.domains = [] new_domains = [d.strip() for d in domain_arg.split(",") if d not in config.domains] config.domains.extend(new_domains) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 1c5093b03..4e18f5ca2 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -38,7 +38,6 @@ Use the following snippet in your ``server{...}`` stanza:: and reload your daemon. """ -import argparse import errno import logging import os @@ -92,8 +91,7 @@ to serve all files under specified web root ({0}).""" self.option_name("path"))) for name, path in path_map.items(): if not os.path.isdir(path): - raise errors.PluginError( - path + " does not exist or is not a directory") + raise errors.PluginError(path + " does not exist or is not a directory") self.full_roots[name] = os.path.join(path, challenges.HTTP01.URI_ROOT_PATH) logger.debug("Creating root challenges validation dir at %s", @@ -107,7 +105,7 @@ to serve all files under specified web root ({0}).""" "challenge responses: {1}", name, exception) def perform(self, achalls): # pylint: disable=missing-docstring - assert self.full_root, "Webroot plugin appears to be missing webroot map" + assert self.full_roots, "Webroot plugin appears to be missing webroot map" return [self._perform_single(achall) for achall in achalls] def _path_for_achall(self, achall): From d5e92289fc4deab45de9c175da7a4f8a25d4989b Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 21 Nov 2015 01:42:39 -0800 Subject: [PATCH 108/181] Test case for webroot_map construction --- letsencrypt/tests/cli_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index d53b4700a..f2827ec09 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -339,6 +339,18 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods namespace = cli.prepare_and_parse_args(plugins, long_args) self.assertEqual(namespace.domains, ['example.com', 'another.net']) + webroot_args = ['--webroot', '-d', 'stray.example.com', '-w', + '/var/www/example', '-d', 'example.com,www.example.com', '-w', + '/var/www/superfluous', '-d', 'superfluo.us', '-d', 'www.superfluo.us'] + namespace = cli.prepare_and_parse_args(plugins, webroot_args) + open("/tmp/frogs", "w").write("%r" % namespace) + self.assertEqual(namespace.webroot_map, { + 'example.com' : '/var/www/example', + 'stray.example.com' : '/var/www/example', + 'www.example.com' : '/var/www/example', + 'www.superfluo.us' : '/var/www/superfluous', + 'superfluo.us' : '/var/www/superfluous'}) + @mock.patch('letsencrypt.crypto_util.notAfter') @mock.patch('letsencrypt.cli.zope.component.getUtility') def test_certonly_new_request_success(self, mock_get_utility, mock_notAfter): From 6a50a98ebe197ab5cef39e3f2134d0df9c29705e Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Fri, 20 Nov 2015 12:38:47 -0800 Subject: [PATCH 109/181] unoneline --- letsencrypt/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 3dd53f011..092b79577 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1062,8 +1062,7 @@ class DomainFlagProcessor(argparse.Action): # pylint: disable=missing-docstring Process a new -d flag, helping the webroot plugin construct a map of {domain : webrootpath} if -w / --webroot-path is in use """ - if not config.domains: - config.domains = [] + if not config.domains: config.domains = [] new_domains = [d.strip() for d in domain_arg.split(",") if d not in config.domains] config.domains.extend(new_domains) From 3cc8e7e0198ff783eaf725d2b1c227745d58ca27 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 21 Nov 2015 10:59:05 -0800 Subject: [PATCH 110/181] Webroot cli now passes some tests! --- letsencrypt/cli.py | 37 +++++++++++++++++++---------------- letsencrypt/tests/cli_test.py | 10 ++++++---- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 092b79577..0bab6d034 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -3,10 +3,12 @@ import argparse import atexit import functools +import json import logging import logging.handlers import os import pkg_resources +import string import sys import time import traceback @@ -670,7 +672,6 @@ class HelpfulArgumentParser(object): print usage sys.exit(0) self.visible_topics = self.determine_help_topics(self.help_arg) - #print self.visible_topics self.groups = {} # elements are added by .add_group() def parse_args(self): @@ -1023,20 +1024,22 @@ def _plugins_parsing(helpful, plugins): helpful.add("plugins", "--webroot", action="store_true", help='Obtain certs by placing files in a webroot directory.') - # This would normally be a flag within the webroot plugin, the webroot - # plugin, but because it is parsed in conjunction with --domains, it lives - # here - helpful.add("webroot", "-w", "--webroot-path", action=WebrootPathProcessor, - help="public_html / webroot path") - #helpful.add("plugins", "-w", action=WebrootAction, - # help='Obtain certs by placing files in a webroot directory.') - # things should not be reorder past/pre this comment: # plugins_group should be displayed in --help before plugin # specific groups (so that plugins_group.description makes sense) helpful.add_plugin_args(plugins) + # These would normally be a flag within the webroot plugin, but because + # they are parsed in conjunction with --domains, they live here for + # legibiility. helpful.add_plugin_ags must be called first to add the + # "webroot" topic + helpful.add("webroot", "-w", "--webroot-path", action=WebrootPathProcessor, + help="public_html / webroot path") + parse_dict = lambda s : dict(json.loads(s)) + helpful.add("webroot", "--webroot-map", default={}, type=parse_dict, + help="Mapping from domains to webroot paths") + class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring def __call__(self, parser, config, webroot, option_string=None): @@ -1062,15 +1065,15 @@ class DomainFlagProcessor(argparse.Action): # pylint: disable=missing-docstring Process a new -d flag, helping the webroot plugin construct a map of {domain : webrootpath} if -w / --webroot-path is in use """ - if not config.domains: config.domains = [] - new_domains = [d.strip() for d in domain_arg.split(",") - if d not in config.domains] - config.domains.extend(new_domains) + if not config.domains: + config.domains = [] - if config.webroot_path: - # Each domain has a webroot_path of the most recent -w flag - for d in new_domains: - config.webroot_map[d] = config.webroot_path[-1] + for d in map(string.strip, domain_arg.split(",")): + if d not in config.domains: + config.domains.append(d) + # Each domain has a webroot_path of the most recent -w flag + if config.webroot_path: + config.webroot_map[d] = config.webroot_path[-1] def setup_log_file_handler(args, logfile, fmt): diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index f2827ec09..e5780b8d2 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -236,7 +236,8 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self._call(['plugins'] + list(args)) @mock.patch('letsencrypt.cli.plugins_disco') - def test_plugins_no_args(self, mock_disco): + @mock.patch('letsencrypt.cli.HelpfulArgumentParser.determine_help_topics') + def test_plugins_no_args(self, _det, mock_disco): ifaces = [] plugins = mock_disco.PluginsRegistry.find_all() @@ -247,7 +248,8 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods stdout.write.called_once_with(str(filtered)) @mock.patch('letsencrypt.cli.plugins_disco') - def test_plugins_init(self, mock_disco): + @mock.patch('letsencrypt.cli.HelpfulArgumentParser.determine_help_topics') + def test_plugins_init(self, _det, mock_disco): ifaces = [] plugins = mock_disco.PluginsRegistry.find_all() @@ -261,10 +263,10 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods stdout.write.called_once_with(str(verified)) @mock.patch('letsencrypt.cli.plugins_disco') - def test_plugins_prepare(self, mock_disco): + @mock.patch('letsencrypt.cli.HelpfulArgumentParser.determine_help_topics') + def test_plugins_prepare(self, _det, mock_disco): ifaces = [] plugins = mock_disco.PluginsRegistry.find_all() - _, stdout, _, _ = self._call(['plugins', '--init', '--prepare']) plugins.visible.assert_called_once_with() plugins.visible().ifaces.assert_called_once_with(ifaces) From 5beccc080d176a0f6e58316422d6bcb0eac60d6b Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 21 Nov 2015 11:09:07 -0800 Subject: [PATCH 111/181] Test for CLI --webroot-map parsing --- letsencrypt/tests/cli_test.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index e5780b8d2..991446447 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -341,11 +341,12 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods namespace = cli.prepare_and_parse_args(plugins, long_args) self.assertEqual(namespace.domains, ['example.com', 'another.net']) + def test_parse_webroot(self): + plugins = disco.PluginsRegistry.find_all() webroot_args = ['--webroot', '-d', 'stray.example.com', '-w', '/var/www/example', '-d', 'example.com,www.example.com', '-w', '/var/www/superfluous', '-d', 'superfluo.us', '-d', 'www.superfluo.us'] namespace = cli.prepare_and_parse_args(plugins, webroot_args) - open("/tmp/frogs", "w").write("%r" % namespace) self.assertEqual(namespace.webroot_map, { 'example.com' : '/var/www/example', 'stray.example.com' : '/var/www/example', @@ -353,6 +354,10 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods 'www.superfluo.us' : '/var/www/superfluous', 'superfluo.us' : '/var/www/superfluous'}) + webroot_map_args = ['--webroot-map', '{"eg.com" : "/tmp"}'] + namespace = cli.prepare_and_parse_args(plugins, webroot_map_args) + self.assertEqual(namespace.webroot_map, {u"eg.com" : u"/tmp"}) + @mock.patch('letsencrypt.crypto_util.notAfter') @mock.patch('letsencrypt.cli.zope.component.getUtility') def test_certonly_new_request_success(self, mock_get_utility, mock_notAfter): From 544fe8d7086a8c9a093a350a1f814943dbe05240 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 21 Nov 2015 11:19:52 -0800 Subject: [PATCH 112/181] Fix webroot tests --- letsencrypt/plugins/webroot_test.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index aa8f16e38..261293005 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -23,7 +23,7 @@ class AuthenticatorTest(unittest.TestCase): """Tests for letsencrypt.plugins.webroot.Authenticator.""" achall = achallenges.KeyAuthorizationAnnotatedChallenge( - challb=acme_util.HTTP01_P, domain=None, account_key=KEY) + challb=acme_util.HTTP01_P, domain="thing.com", account_key=KEY) def setUp(self): from letsencrypt.plugins.webroot import Authenticator @@ -31,7 +31,8 @@ class AuthenticatorTest(unittest.TestCase): self.validation_path = os.path.join( self.path, ".well-known", "acme-challenge", "ZXZhR3hmQURzNnBTUmIyTEF2OUlaZjE3RHQzanV4R0orUEN0OTJ3citvQQ") - self.config = mock.MagicMock(webroot_path=self.path) + self.config = mock.MagicMock(webroot_path=self.path, + webroot_map={"thing.com":self.path}) self.auth = Authenticator(self.config, "webroot") self.auth.prepare() @@ -46,14 +47,16 @@ class AuthenticatorTest(unittest.TestCase): def test_add_parser_arguments(self): add = mock.MagicMock() self.auth.add_parser_arguments(add) - self.assertEqual(1, add.call_count) + self.assertEqual(0, add.call_count) # became 0 when we moved the args to cli.py! def test_prepare_bad_root(self): self.config.webroot_path = os.path.join(self.path, "null") + self.config.webroot_map["thing.com"] = self.config.webroot_path self.assertRaises(errors.PluginError, self.auth.prepare) def test_prepare_missing_root(self): self.config.webroot_path = None + self.config.webroot_map = {} self.assertRaises(errors.PluginError, self.auth.prepare) def test_prepare_full_root_exists(self): From a3b0588cea57d063a6e70b57cb4c1ad0efa1a614 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 21 Nov 2015 11:50:13 -0800 Subject: [PATCH 113/181] lintmonster --- letsencrypt/cli.py | 4 ++-- letsencrypt/plugins/webroot_test.py | 2 +- letsencrypt/tests/cli_test.py | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 0bab6d034..9c4c4a5f5 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1036,7 +1036,7 @@ def _plugins_parsing(helpful, plugins): # "webroot" topic helpful.add("webroot", "-w", "--webroot-path", action=WebrootPathProcessor, help="public_html / webroot path") - parse_dict = lambda s : dict(json.loads(s)) + parse_dict = lambda s: dict(json.loads(s)) helpful.add("webroot", "--webroot-map", default={}, type=parse_dict, help="Mapping from domains to webroot paths") @@ -1068,7 +1068,7 @@ class DomainFlagProcessor(argparse.Action): # pylint: disable=missing-docstring if not config.domains: config.domains = [] - for d in map(string.strip, domain_arg.split(",")): + for d in map(string.strip, domain_arg.split(",")): # pylint: disable=bad-builtin if d not in config.domains: config.domains.append(d) # Each domain has a webroot_path of the most recent -w flag diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index 261293005..902f74e9f 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -32,7 +32,7 @@ class AuthenticatorTest(unittest.TestCase): self.path, ".well-known", "acme-challenge", "ZXZhR3hmQURzNnBTUmIyTEF2OUlaZjE3RHQzanV4R0orUEN0OTJ3citvQQ") self.config = mock.MagicMock(webroot_path=self.path, - webroot_map={"thing.com":self.path}) + webroot_map={"thing.com": self.path}) self.auth = Authenticator(self.config, "webroot") self.auth.prepare() diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 991446447..853109636 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -348,15 +348,15 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods '/var/www/superfluous', '-d', 'superfluo.us', '-d', 'www.superfluo.us'] namespace = cli.prepare_and_parse_args(plugins, webroot_args) self.assertEqual(namespace.webroot_map, { - 'example.com' : '/var/www/example', - 'stray.example.com' : '/var/www/example', - 'www.example.com' : '/var/www/example', - 'www.superfluo.us' : '/var/www/superfluous', - 'superfluo.us' : '/var/www/superfluous'}) + 'example.com': '/var/www/example', + 'stray.example.com': '/var/www/example', + 'www.example.com': '/var/www/example', + 'www.superfluo.us': '/var/www/superfluous', + 'superfluo.us': '/var/www/superfluous'}) webroot_map_args = ['--webroot-map', '{"eg.com" : "/tmp"}'] namespace = cli.prepare_and_parse_args(plugins, webroot_map_args) - self.assertEqual(namespace.webroot_map, {u"eg.com" : u"/tmp"}) + self.assertEqual(namespace.webroot_map, {u"eg.com": u"/tmp"}) @mock.patch('letsencrypt.crypto_util.notAfter') @mock.patch('letsencrypt.cli.zope.component.getUtility') From 768c7cd9c09dc3161490934d3b69ace9c72937af Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sun, 22 Nov 2015 15:16:50 +0100 Subject: [PATCH 114/181] Fix webroot permissions Take them from the parent directory where the webroot is.Should fix issue #1389 --- letsencrypt/plugins/webroot.py | 11 +++++++++++ letsencrypt/plugins/webroot_test.py | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 4e18f5ca2..4686360a7 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -41,6 +41,7 @@ and reload your daemon. import errno import logging import os +import stat import zope.interface @@ -98,6 +99,16 @@ to serve all files under specified web root ({0}).""" self.full_roots[name]) try: os.makedirs(self.full_roots[name]) + # Set permissions as parent directory (GH #1389) + filemode = stat.S_IMODE(os.stat(path).st_mode) + os.chmod(self.full_roots[name]) + + # Make permissions valid for files, too + for root, dirs, files in os.walk(self.full_roots[name]): + for filename in files: + # No need for exec permissions + os.chmod(filename, filemode & ~stat.S_IEXEC) + except OSError as exception: if exception.errno != errno.EEXIST: raise errors.PluginError( diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index 902f74e9f..897c6993f 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -3,6 +3,7 @@ import os import shutil import tempfile import unittest +import stat import mock @@ -69,6 +70,17 @@ class AuthenticatorTest(unittest.TestCase): self.assertRaises(errors.PluginError, self.auth.prepare) os.chmod(self.path, 0o700) + def test_prepare_permissions(self): + + # Remove exec bit from permission check, so that it + # matches the file + parent_permissions = (stat.S_IMODE(os.stat(self.path)) & + ~stat.S_IEXEC) + + actual_permissions = stat.S_IMODE(os.stat(self.validation_path)) + + self.assertEqual(parent_permissions, actual_permissions) + def test_perform_cleanup(self): responses = self.auth.perform([self.achall]) self.assertEqual(1, len(responses)) From eb5e345c3ec25d82d7bb519e2d6fc82448dc6433 Mon Sep 17 00:00:00 2001 From: sagi Date: Sun, 22 Nov 2015 18:40:19 +0000 Subject: [PATCH 115/181] change vhost to ssl_vhost, add header_name explanation in comments. --- letsencrypt-apache/letsencrypt_apache/configurator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 189af25e0..b3b5df392 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -772,9 +772,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Checks to see if virtualhost already contains a header_name header - :param vhost: vhost to check + :param ssl_vhost: vhost to check :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + :param header_name: a header name, e.g: Strict-Transport-Security + :type str + :returns: boolean :rtype: (bool) From 699cdac8eacfec825d960e16edaa78ddb9267ba1 Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Mon, 23 Nov 2015 10:08:43 -0800 Subject: [PATCH 116/181] remove error if can't parse config --- letsencrypt-apache/letsencrypt_apache/parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt-apache/letsencrypt_apache/parser.py b/letsencrypt-apache/letsencrypt_apache/parser.py index ec5211ae4..b48551d00 100644 --- a/letsencrypt-apache/letsencrypt_apache/parser.py +++ b/letsencrypt-apache/letsencrypt_apache/parser.py @@ -101,7 +101,7 @@ class ApacheParser(object): try: matches.remove("DUMP_RUN_CFG") except ValueError: - raise errors.PluginError("Unable to parse runtime variables") + #raise errors.PluginError("Unable to parse runtime variables") for match in matches: if match.count("=") > 1: From 2738290b98ef289d0800e380e5b5b323b65f8f36 Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Mon, 23 Nov 2015 10:21:31 -0800 Subject: [PATCH 117/181] fix issue with empty matchers array --- letsencrypt-apache/letsencrypt_apache/parser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/letsencrypt-apache/letsencrypt_apache/parser.py b/letsencrypt-apache/letsencrypt_apache/parser.py index b48551d00..5c18208bb 100644 --- a/letsencrypt-apache/letsencrypt_apache/parser.py +++ b/letsencrypt-apache/letsencrypt_apache/parser.py @@ -101,6 +101,7 @@ class ApacheParser(object): try: matches.remove("DUMP_RUN_CFG") except ValueError: + return #raise errors.PluginError("Unable to parse runtime variables") for match in matches: From d4ee483662e345bfc9a6e106806e790facdb99b2 Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Mon, 23 Nov 2015 10:52:39 -0800 Subject: [PATCH 118/181] revert changes made to wrong branch --- letsencrypt-apache/letsencrypt_apache/parser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/parser.py b/letsencrypt-apache/letsencrypt_apache/parser.py index 5c18208bb..ec5211ae4 100644 --- a/letsencrypt-apache/letsencrypt_apache/parser.py +++ b/letsencrypt-apache/letsencrypt_apache/parser.py @@ -101,8 +101,7 @@ class ApacheParser(object): try: matches.remove("DUMP_RUN_CFG") except ValueError: - return - #raise errors.PluginError("Unable to parse runtime variables") + raise errors.PluginError("Unable to parse runtime variables") for match in matches: if match.count("=") > 1: From f2ccc228a3eaf5731d62169683539b005a3cc21c Mon Sep 17 00:00:00 2001 From: Liam Marshall Date: Mon, 23 Nov 2015 13:17:24 -0600 Subject: [PATCH 119/181] Remove code path that will never get hit --- letsencrypt-apache/letsencrypt_apache/configurator.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index ff95eef95..98cd5b407 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -221,11 +221,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.aug.set(path["cert_path"][-1], cert_path) self.aug.set(path["cert_key"][-1], key_path) if chain_path is not None: - if not path["chain_path"]: - self.parser.add_dir(vhost.path, - "SSLCertificateChainFile", chain_path) - else: - self.aug.set(path["chain_path"][-1], chain_path) + self.parser.add_dir(vhost.path, + "SSLCertificateChainFile", chain_path) else: raise errors.PluginError("--chain-path is required for your version of Apache") else: From f8a32160820a4fb0886c5091a4c825f9905a5bad Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 23 Nov 2015 20:11:47 +0000 Subject: [PATCH 120/181] change header_name to header_substring --- .../letsencrypt_apache/configurator.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index b3b5df392..65e759061 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -728,69 +728,69 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.warn("Failed %s for %s", enhancement, domain) raise - def _set_http_header(self, ssl_vhost, header_name): - """Enables header header_name on ssl_vhost. + def _set_http_header(self, ssl_vhost, header_substring): + """Enables header that is identified by header_substring on ssl_vhost. - If header_name is not already set, a new Header directive is placed in - ssl_vhost's configuration with arguments from: - constants.HTTP_HEADER[header_name] + If the header identified by header_substring is not already set, + a new Header directive is placed in ssl_vhost's configuration with + arguments from: constants.HTTP_HEADER[header_substring] .. note:: This function saves the configuration :param ssl_vhost: Destination of traffic, an ssl enabled vhost :type ssl_vhost: :class:`~letsencrypt_apache.obj.VirtualHost` - :param header_name: a header name, e.g: Strict-Transport-Security + :param header_substring: string that uniquely identifies a header. + e.g: Strict-Transport-Security, Upgrade-Insecure-Requests. :type str :returns: Success, general_vhost (HTTP vhost) :rtype: (bool, :class:`~letsencrypt_apache.obj.VirtualHost`) :raises .errors.PluginError: If no viable HTTP host can be created or - set with header header_name. + set with header header_substring. """ if "headers_module" not in self.parser.modules: self.enable_mod("headers") # Check if selected header is already set - self._verify_no_http_header(ssl_vhost, header_name) + self._verify_no_http_header(ssl_vhost, header_substring) # Add directives to server self.parser.add_dir(ssl_vhost.path, "Header", - constants.HEADER_ARGS[header_name]) + constants.HEADER_ARGS[header_substring]) self.save_notes += ("Adding %s header to ssl vhost in %s\n" % - (header_name, ssl_vhost.filep)) + (header_substring, ssl_vhost.filep)) self.save() - logger.info("Adding %s header to ssl vhost in %s", header_name, + logger.info("Adding %s header to ssl vhost in %s", header_substring, ssl_vhost.filep) - def _verify_no_http_header(self, ssl_vhost, header_name): - """Checks to see if existing header_name header is in place. - - Checks to see if virtualhost already contains a header_name header + def _verify_no_http_header(self, ssl_vhost, header_substring): + """Checks to see if an there is an existing Header directive that + contains the string header_substring. :param ssl_vhost: vhost to check :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` - :param header_name: a header name, e.g: Strict-Transport-Security + :param header_substring: a header name, e.g: Strict-Transport-Security :type str :returns: boolean :rtype: (bool) - :raises errors.PluginError: When header header_name exists + :raises errors.PluginError: When header header_substring exists """ header_path = self.parser.find_dir("Header", None, start=ssl_vhost.path) if header_path: # "Existing Header directive for virtualhost" for match in header_path: - if self.aug.get(match).lower() == header_name.lower(): + if self.aug.get(match).lower() == header_substring.lower(): raise errors.PluginError("Existing %s header" % - (header_name)) + (header_substring)) def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. From b75354add00bca1ff5bb074922e1d4893a2c10dd Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 23 Nov 2015 20:13:08 +0000 Subject: [PATCH 121/181] change verify_no_http_header to verify_no_matching_http_header --- letsencrypt-apache/letsencrypt_apache/configurator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 65e759061..e5e4edc80 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -755,7 +755,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.enable_mod("headers") # Check if selected header is already set - self._verify_no_http_header(ssl_vhost, header_substring) + self._verify_no_matching_http_header(ssl_vhost, header_substring) # Add directives to server self.parser.add_dir(ssl_vhost.path, "Header", @@ -768,7 +768,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.info("Adding %s header to ssl vhost in %s", header_substring, ssl_vhost.filep) - def _verify_no_http_header(self, ssl_vhost, header_substring): + def _verify_no_matching_http_header(self, ssl_vhost, header_substring): """Checks to see if an there is an existing Header directive that contains the string header_substring. From 7df7228a531daa6963909e4b2ade58f2f1a019c1 Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 23 Nov 2015 22:41:02 +0000 Subject: [PATCH 122/181] add regex to detect header_substring in header directive definition --- letsencrypt-apache/letsencrypt_apache/configurator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index e5e4edc80..6ef1fbee2 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -775,7 +775,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param ssl_vhost: vhost to check :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` - :param header_substring: a header name, e.g: Strict-Transport-Security + :param header_substring: string that uniquely identifies a header. + e.g: Strict-Transport-Security, Upgrade-Insecure-Requests. :type str :returns: boolean @@ -787,8 +788,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): header_path = self.parser.find_dir("Header", None, start=ssl_vhost.path) if header_path: # "Existing Header directive for virtualhost" + pat = '(?:[ "]|^)(%s)(?:[ "]|$)' % (header_substring.lower()) for match in header_path: - if self.aug.get(match).lower() == header_substring.lower(): + if re.search(pat, self.aug.get(match).lower()): raise errors.PluginError("Existing %s header" % (header_substring)) From f504b0622d2d20288ea479750a5166c0a3fa8788 Mon Sep 17 00:00:00 2001 From: Francois Marier Date: Wed, 11 Nov 2015 18:22:36 -0800 Subject: [PATCH 123/181] Add the nginxparser copyright statement to letsencrypt-nginx --- LICENSE.txt | 27 +-------------------------- letsencrypt-nginx/LICENSE.txt | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 2ed752521..1a89cd8d9 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -2,13 +2,6 @@ Let's Encrypt Python Client Copyright (c) Electronic Frontier Foundation and others Licensed Apache Version 2.0 -Incorporating code from nginxparser -Copyright (c) 2014 Fatih Erikli -Licensed MIT - - -Text of Apache License -====================== Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -184,22 +177,4 @@ Text of Apache License incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - -Text of MIT License -=================== -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + END OF TERMS AND CONDITIONS diff --git a/letsencrypt-nginx/LICENSE.txt b/letsencrypt-nginx/LICENSE.txt index 981c46c9f..02a1459be 100644 --- a/letsencrypt-nginx/LICENSE.txt +++ b/letsencrypt-nginx/LICENSE.txt @@ -12,6 +12,13 @@ See the License for the specific language governing permissions and limitations under the License. + Incorporating code from nginxparser + Copyright 2014 Fatih Erikli + Licensed MIT + + +Text of Apache License +====================== Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -188,3 +195,22 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +Text of MIT License +=================== +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 651dbd12cc67d618e9d76e32d2eb92bb65715496 Mon Sep 17 00:00:00 2001 From: Francois Marier Date: Mon, 23 Nov 2015 14:57:21 -0800 Subject: [PATCH 124/181] Clarify the part of letsencrypt that uses nginxparser --- LICENSE.txt | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index 1a89cd8d9..5965ec2ef 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -2,6 +2,13 @@ Let's Encrypt Python Client Copyright (c) Electronic Frontier Foundation and others Licensed Apache Version 2.0 +The nginx plugin incorporates code from nginxparser +Copyright (c) 2014 Fatih Erikli +Licensed MIT + + +Text of Apache License +====================== Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -177,4 +184,22 @@ Licensed Apache Version 2.0 incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - END OF TERMS AND CONDITIONS + +Text of MIT License +=================== +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 0c283b39efc0f7f4caaa4e78f9cd0492b1984e0d Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 23 Nov 2015 18:29:41 -0500 Subject: [PATCH 125/181] s/restart/reload --- .../letsencrypt_apache/configurator.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index c811501a9..d5d75120a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -96,7 +96,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): help="Path to the Apache 'a2enmod' binary.") add("init-script", default=constants.CLI_DEFAULTS["init_script"], help="Path to the Apache init script (used for server " - "reload/restart).") + "reload).") add("le-vhost-ext", default=constants.CLI_DEFAULTS["le_vhost_ext"], help="SSL vhost configuration extension.") add("server-root", default=constants.CLI_DEFAULTS["server_root"], @@ -974,7 +974,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return False def enable_site(self, vhost): - """Enables an available site, Apache restart required. + """Enables an available site, Apache reload required. .. note:: Does not make sure that the site correctly works or that all modules are enabled appropriately. @@ -1009,7 +1009,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def enable_mod(self, mod_name, temp=False): """Enables module in Apache. - Both enables and restarts Apache so module is active. + Both enables and reloads Apache so module is active. :param str mod_name: Name of the module to enable. (e.g. 'ssl') :param bool temp: Whether or not this is a temporary action. @@ -1051,7 +1051,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Modules can enable additional config files. Variables may be defined # within these new configuration sections. - # Restart is not necessary as DUMP_RUN_CFG uses latest config. + # Reload is not necessary as DUMP_RUN_CFG uses latest config. self.parser.update_runtime_variables(self.conf("ctl")) def _add_parser_mod(self, mod_name): @@ -1074,16 +1074,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): le_util.run_script([self.conf("enmod"), mod_name]) def restart(self): - """Restarts apache server. + """Reloads apache server. .. todo:: This function will be converted to using reload - :raises .errors.MisconfigurationError: If unable to restart due - to a configuration problem, or if the restart subprocess + :raises .errors.MisconfigurationError: If unable to reload due + to a configuration problem, or if the reload subprocess cannot be run. """ - return apache_restart(self.conf("init-script")) + return apache_reload(self.conf("init-script")) def config_test(self): # pylint: disable=no-self-use """Check the configuration of Apache for errors. @@ -1158,7 +1158,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): sni_response = apache_dvsni.perform() if sni_response: - # Must restart in order to activate the challenges. + # Must reload in order to activate the challenges. # Handled here because we may be able to load up other challenge # types self.restart() @@ -1201,42 +1201,42 @@ def _get_mod_deps(mod_name): return deps.get(mod_name, []) -def apache_restart(apache_init_script): - """Restarts the Apache Server. +def apache_reload(apache_init_script): + """Reloads the Apache Server. :param str apache_init_script: Path to the Apache init script. .. todo:: Try to use reload instead. (This caused timing problems before) .. todo:: On failure, this should be a recovery_routine call with another - restart. This will confuse and inhibit developers from testing code + reload. This will confuse and inhibit developers from testing code though. This change should happen after the ApacheConfigurator has been thoroughly tested. The function will need to be moved into the class again. Perhaps this version can live on... for testing purposes. - :raises .errors.MisconfigurationError: If unable to restart due to a - configuration problem, or if the restart subprocess cannot be run. + :raises .errors.MisconfigurationError: If unable to reload due to a + configuration problem, or if the reload subprocess cannot be run. """ try: - proc = subprocess.Popen([apache_init_script, "restart"], + proc = subprocess.Popen([apache_init_script, "reload"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) except (OSError, ValueError): logger.fatal( - "Unable to restart the Apache process with %s", apache_init_script) + "Unable to reload the Apache process with %s", apache_init_script) raise errors.MisconfigurationError( - "Unable to restart Apache process with %s" % apache_init_script) + "Unable to reload Apache process with %s" % apache_init_script) stdout, stderr = proc.communicate() if proc.returncode != 0: # Enter recovery routine... - logger.error("Apache Restart Failed!\n%s\n%s", stdout, stderr) + logger.error("Apache Reload Failed!\n%s\n%s", stdout, stderr) raise errors.MisconfigurationError( - "Error while restarting Apache:\n%s\n%s" % (stdout, stderr)) + "Error while reloading Apache:\n%s\n%s" % (stdout, stderr)) def get_file_path(vhost_path): From 9e52b8200dd686773424be91b4f28393c942a899 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 23 Nov 2015 18:34:42 -0500 Subject: [PATCH 126/181] Sleeping is easier than polling --- letsencrypt-apache/letsencrypt_apache/configurator.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index d5d75120a..512e1bdc6 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -8,6 +8,7 @@ import re import shutil import socket import subprocess +import time import zope.interface @@ -1163,6 +1164,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # types self.restart() + # TODO: Remove this dirty hack. We need to determine a reliable way + # of identifying when the new configuration is being used. + time.sleep(3) + # 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. From 72fcee42649f643fa9486a41524d92f339359e82 Mon Sep 17 00:00:00 2001 From: sagi Date: Mon, 23 Nov 2015 23:58:58 +0000 Subject: [PATCH 127/181] change Error to PluginError in comment --- letsencrypt/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e229d3c8d..e1b0c4b84 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -442,8 +442,9 @@ class Client(object): :param options: options to enhancement, e.g. Strict-Transport-Security :type str - :raises .errors.Error: if no installer is specified in the - client. + :raises .errors.PluginError: If Enhancement is not supported, or if + there is any other problem with the enhancement. + """ msg = ("We were unable to set up enhancement %s for your server, " From a1cf4357906529be282d6fb97e485b626f903346 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 23 Nov 2015 19:28:36 -0500 Subject: [PATCH 128/181] Revert "Remove references to --manual and --webroot" This reverts commit 02562c75a3688c1cebdc0567bbc381440cc7f204. --- docs/using.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index 3f04fc5fa..f6fb82f52 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -184,7 +184,7 @@ Webroot If you're running a webserver that you don't want to stop to use standalone, you can use the webroot plugin to obtain a cert by -including ``certonly`` and ``-a webroot`` on the command line. In +including ``certonly`` and ``--webroot`` on the command line. In addition, you'll need to specify ``--webroot-path`` with the root directory of the files served by your webserver. For example, ``--webroot-path /var/www/html`` or @@ -200,7 +200,7 @@ If you'd like to obtain a cert running ``letsencrypt`` on a machine other than your target webserver or perform the steps for domain validation yourself, you can use the manual plugin. While hidden from the UI, you can use the plugin to obtain a cert by specifying -``certonly`` and ``-a manual`` on the command line. This requires you +``certonly`` and ``--manual`` on the command line. This requires you to copy and paste commands into another terminal session. Nginx From f5c353217746beb7a50fa4a63c0d8e3dd1ba6d45 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 23 Nov 2015 19:44:00 -0500 Subject: [PATCH 129/181] Improve error message --- letsencrypt-apache/letsencrypt_apache/configurator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index c811501a9..9ccb3ca1f 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -586,7 +586,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): (ssl_fp, parser.case_i("VirtualHost"))) if len(vh_p) != 1: logger.error("Error: should only be one vhost in %s", avail_fp) - raise errors.PluginError("Only one vhost per file is allowed") + raise errors.PluginError("Currently, we only support " + "configurations with one vhost per file") else: # This simplifies the process vh_p = vh_p[0] From f908e8bdafd091e791a5d73b8c7b04143340a0d6 Mon Sep 17 00:00:00 2001 From: Patrick Figel Date: Tue, 24 Nov 2015 06:19:42 +0100 Subject: [PATCH 130/181] Detect SSL vhosts by port SSLEngine on can be set outside of . Treat any vhost using port 443 as a SSL vhost. fixes #1602 --- .../letsencrypt_apache/configurator.py | 6 ++++ .../tests/configurator_test.py | 14 ++++---- .../default-ssl-port-only.conf | 36 +++++++++++++++++++ .../letsencrypt_apache/tests/util.py | 6 +++- 4 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl-port-only.conf diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 16e8dd606..91a2e4925 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -445,6 +445,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if self.parser.find_dir("SSLEngine", "on", start=path, exclude=False): is_ssl = True + # "SSLEngine on" might be set outside of + # Treat vhosts with port 443 as ssl vhosts + for addr in addrs: + if addr.get_port() == "443": + is_ssl = True + filename = get_file_path(path) is_enabled = self.is_site_enabled(filename) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 442229e8d..f7c3ba1ba 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -103,7 +103,7 @@ class TwoVhost80Test(util.ApacheTest): """ vhs = self.config.get_virtual_hosts() - self.assertEqual(len(vhs), 5) + self.assertEqual(len(vhs), 6) found = 0 for vhost in vhs: @@ -114,7 +114,7 @@ class TwoVhost80Test(util.ApacheTest): else: raise Exception("Missed: %s" % vhost) # pragma: no cover - self.assertEqual(found, 5) + self.assertEqual(found, 6) @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_none_avail(self, mock_select): @@ -409,7 +409,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), self.config.is_name_vhost(ssl_vhost)) - self.assertEqual(len(self.config.vhosts), 6) + self.assertEqual(len(self.config.vhosts), 7) def test_clean_vhost_ssl(self): # pylint: disable=protected-access @@ -597,14 +597,14 @@ class TwoVhost80Test(util.ApacheTest): def test_get_all_certs_keys(self): c_k = self.config.get_all_certs_keys() - self.assertEqual(len(c_k), 1) + self.assertEqual(len(c_k), 2) cert, key, path = next(iter(c_k)) self.assertTrue("cert" in cert) self.assertTrue("key" in key) - self.assertTrue("default-ssl.conf" in path) + self.assertTrue("default-ssl" in path) def test_get_all_certs_keys_malformed_conf(self): - self.config.parser.find_dir = mock.Mock(side_effect=[["path"], []]) + self.config.parser.find_dir = mock.Mock(side_effect=[["path"], [], ["path"], []]) c_k = self.config.get_all_certs_keys() self.assertFalse(c_k) @@ -710,7 +710,7 @@ class TwoVhost80Test(util.ApacheTest): self.vh_truth[1].aliases = set(["yes.default.com"]) self.config._enable_redirect(self.vh_truth[1], "") # pylint: disable=protected-access - self.assertEqual(len(self.config.vhosts), 6) + self.assertEqual(len(self.config.vhosts), 7) def get_achalls(self): """Return testing achallenges.""" diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl-port-only.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl-port-only.conf new file mode 100644 index 000000000..5a50c536e --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl-port-only.conf @@ -0,0 +1,36 @@ + + + ServerAdmin webmaster@localhost + + DocumentRoot /var/www/html + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # A self-signed (snakeoil) certificate can be created by installing + # the ssl-cert package. See + # /usr/share/doc/apache2/README.Debian.gz for more info. + # If both key and certificate are stored in the same file, only the + # SSLCertificateFile directive is needed. + SSLCertificateFile /etc/apache2/certs/letsencrypt-cert_5.pem + SSLCertificateKeyFile /etc/apache2/ssl/key-letsencrypt_15.pem + + + #SSLOptions +FakeBasicAuth +ExportCertData +StrictRequire + + SSLOptions +StdEnvVars + + + SSLOptions +StdEnvVars + + + BrowserMatch "MSIE [2-6]" \ + nokeepalive ssl-unclean-shutdown \ + downgrade-1.0 force-response-1.0 + # MSIE 7 and newer should be able to use keepalive + BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown + + + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index a8bfe0e4b..1bc1fbe17 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -128,7 +128,11 @@ def get_vh_truth(temp_dir, config_name): os.path.join(prefix, "mod_macro-example.conf"), os.path.join(aug_pre, "mod_macro-example.conf/Macro/VirtualHost"), - set([obj.Addr.fromstring("*:80")]), False, True, modmacro=True) + set([obj.Addr.fromstring("*:80")]), False, True, modmacro=True), + obj.VirtualHost( + os.path.join(prefix, "default-ssl-port-only.conf"), + os.path.join(aug_pre, "default-ssl-port-only.conf/IfModule/VirtualHost"), + set([obj.Addr.fromstring("_default_:443")]), True, False), ] return vh_truth From c175ff955efcf3659d294827c6d49a55200a20b8 Mon Sep 17 00:00:00 2001 From: Patrick Figel Date: Tue, 24 Nov 2015 09:42:59 +0100 Subject: [PATCH 131/181] Remove Content-Type checks from http-01 Content-Type type restrictions were removed in ACME, see https://github.com/ietf-wg-acme/acme/commit/69ac2baade014796e5258a077e7600921cd1879d fixes #1595 --- acme/acme/challenges.py | 10 --------- acme/acme/challenges_test.py | 15 +++---------- acme/acme/standalone.py | 1 - letsencrypt/plugins/manual.py | 7 ++---- letsencrypt/plugins/webroot.py | 41 +--------------------------------- 5 files changed, 6 insertions(+), 68 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 336e6c4e5..1e456d325 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -269,13 +269,6 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): logger.debug("Received %s: %s. Headers: %s", http_response, http_response.text, http_response.headers) - found_ct = http_response.headers.get( - "Content-Type", chall.CONTENT_TYPE) - if found_ct != chall.CONTENT_TYPE: - logger.debug("Wrong Content-Type: found %r, expected %r", - found_ct, chall.CONTENT_TYPE) - return False - challenge_response = http_response.text.rstrip(self.WHITESPACE_CUTSET) if self.key_authorization != challenge_response: logger.debug("Key authorization from response (%r) doesn't match " @@ -292,9 +285,6 @@ class HTTP01(KeyAuthorizationChallenge): response_cls = HTTP01Response typ = response_cls.typ - CONTENT_TYPE = "text/plain" - """Only valid value for Content-Type if the header is included.""" - URI_ROOT_PATH = ".well-known/acme-challenge" """URI root path for the server provisioned resource.""" diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index 7cf387ece..a4e78ebe9 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -92,7 +92,6 @@ class HTTP01ResponseTest(unittest.TestCase): from acme.challenges import HTTP01 self.chall = HTTP01(token=(b'x' * 16)) self.response = self.chall.response(KEY) - self.good_headers = {'Content-Type': HTTP01.CONTENT_TYPE} def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -113,16 +112,14 @@ class HTTP01ResponseTest(unittest.TestCase): @mock.patch("acme.challenges.requests.get") def test_simple_verify_good_validation(self, mock_get): validation = self.chall.validation(KEY) - mock_get.return_value = mock.MagicMock( - text=validation, headers=self.good_headers) + mock_get.return_value = mock.MagicMock(text=validation) self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) mock_get.assert_called_once_with(self.chall.uri("local")) @mock.patch("acme.challenges.requests.get") def test_simple_verify_bad_validation(self, mock_get): - mock_get.return_value = mock.MagicMock( - text="!", headers=self.good_headers) + mock_get.return_value = mock.MagicMock(text="!") self.assertFalse(self.response.simple_verify( self.chall, "local", KEY.public_key())) @@ -131,17 +128,11 @@ class HTTP01ResponseTest(unittest.TestCase): from acme.challenges import HTTP01Response mock_get.return_value = mock.MagicMock( text=(self.chall.validation(KEY) + - HTTP01Response.WHITESPACE_CUTSET), headers=self.good_headers) + HTTP01Response.WHITESPACE_CUTSET)) self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) mock_get.assert_called_once_with(self.chall.uri("local")) - @mock.patch("acme.challenges.requests.get") - def test_simple_verify_bad_content_type(self, mock_get): - mock_get().text = self.chall.token - self.assertFalse(self.response.simple_verify( - self.chall, "local", KEY.public_key())) - @mock.patch("acme.challenges.requests.get") def test_simple_verify_connection_error(self, mock_get): mock_get.side_effect = requests.exceptions.RequestException diff --git a/acme/acme/standalone.py b/acme/acme/standalone.py index 3ddb21beb..02cc2daf5 100644 --- a/acme/acme/standalone.py +++ b/acme/acme/standalone.py @@ -133,7 +133,6 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): self.log_message("Serving HTTP01 with token %r", resource.chall.encode("token")) self.send_response(http_client.OK) - self.send_header("Content-type", resource.chall.CONTENT_TYPE) self.end_headers() self.wfile.write(resource.validation.encode()) return diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py index a590d83f9..793285e62 100644 --- a/letsencrypt/plugins/manual.py +++ b/letsencrypt/plugins/manual.py @@ -46,8 +46,6 @@ Make sure your web server displays the following content at {validation} -Content-Type header MUST be set to {ct}. - If you don't have HTTP server configured, you can run the following command on the target server (as root): @@ -75,7 +73,6 @@ printf "%s" {validation} > {achall.URI_ROOT_PATH}/{encoded_token} # run only once per server: $(command -v python2 || command -v python2.7 || command -v python2.6) -c \\ "import BaseHTTPServer, SimpleHTTPServer; \\ -SimpleHTTPServer.SimpleHTTPRequestHandler.extensions_map = {{'': '{ct}'}}; \\ s = BaseHTTPServer.HTTPServer(('', {port}), SimpleHTTPServer.SimpleHTTPRequestHandler); \\ s.serve_forever()" """ """Command template.""" @@ -142,7 +139,7 @@ s.serve_forever()" """ # TODO(kuba): pipes still necessary? validation=pipes.quote(validation), encoded_token=achall.chall.encode("token"), - ct=achall.CONTENT_TYPE, port=port) + port=port) if self.conf("test-mode"): logger.debug("Test mode. Executing the manual command: %s", command) # sh shipped with OS X does't support echo -n, but supports printf @@ -174,7 +171,7 @@ s.serve_forever()" """ self._notify_and_wait(self.MESSAGE_TEMPLATE.format( validation=validation, response=response, uri=achall.chall.uri(achall.domain), - ct=achall.CONTENT_TYPE, command=command)) + command=command)) if not response.simple_verify( achall.chall, achall.domain, diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 42bfe312b..879da2527 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -1,43 +1,4 @@ -"""Webroot plugin. - -Content-Type ------------- - -This plugin requires your webserver to use a specific `Content-Type` -header in the HTTP response. - -Apache2 -~~~~~~~ - -.. note:: Instructions written and tested for Debian Jessie. Other - operating systems might use something very similar, but you might - still need to readjust some commands. - -Create ``/etc/apache2/conf-available/letsencrypt.conf``, with -the following contents:: - - - - Header set Content-Type "text/plain" - - - -and then run ``a2enmod headers; a2enconf letsencrypt``; depending on the -output you will have to either ``service apache2 restart`` or ``service -apache2 reload``. - -nginx -~~~~~ - -Use the following snippet in your ``server{...}`` stanza:: - - location ~ /.well-known/acme-challenge/(.*) { - default_type text/plain; - } - -and reload your daemon. - -""" +"""Webroot plugin.""" import errno import logging import os From a71c3ed90cae9e31861c22dbd51e9b661737f29f Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Tue, 24 Nov 2015 10:13:46 +0100 Subject: [PATCH 132/181] Fix issues from review - Put chmod argument to os.chmod (oops) - Add permissions adjustments for challenge files, too --- letsencrypt/plugins/webroot.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 4686360a7..61eb87a88 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -101,13 +101,7 @@ to serve all files under specified web root ({0}).""" os.makedirs(self.full_roots[name]) # Set permissions as parent directory (GH #1389) filemode = stat.S_IMODE(os.stat(path).st_mode) - os.chmod(self.full_roots[name]) - - # Make permissions valid for files, too - for root, dirs, files in os.walk(self.full_roots[name]): - for filename in files: - # No need for exec permissions - os.chmod(filename, filemode & ~stat.S_IEXEC) + os.chmod(self.full_roots[name], filemode) except OSError as exception: if exception.errno != errno.EEXIST: @@ -132,6 +126,13 @@ to serve all files under specified web root ({0}).""" logger.debug("Attempting to save validation to %s", path) with open(path, "w") as validation_file: validation_file.write(validation.encode()) + + # Set permissions as parent directory (GH #1389) + parent_path = self.full_roots[achall.domain] + filemode = stat.S_IMODE(os.stat(parent_path).st_mode) + # Remove execution bit (not needed for this file) + os.chmod(path, filemode & ~stat.S_IEXEC) + return response def cleanup(self, achalls): # pylint: disable=missing-docstring From c7c1808ad1b29ec01b19057eaed9e5014cb9ff0d Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Tue, 24 Nov 2015 10:14:35 +0100 Subject: [PATCH 133/181] Add unit tests for webroot permissions handling Tested, pass. --- letsencrypt/plugins/webroot_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index 897c6993f..3fa7f2994 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -74,10 +74,11 @@ class AuthenticatorTest(unittest.TestCase): # Remove exec bit from permission check, so that it # matches the file - parent_permissions = (stat.S_IMODE(os.stat(self.path)) & + responses = self.auth.perform([self.achall]) + parent_permissions = (stat.S_IMODE(os.stat(self.path).st_mode) & ~stat.S_IEXEC) - actual_permissions = stat.S_IMODE(os.stat(self.validation_path)) + actual_permissions = stat.S_IMODE(os.stat(self.validation_path).st_mode) self.assertEqual(parent_permissions, actual_permissions) From a97a702210526b37d7c2ae84a76e2f3bc188ad6e Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 24 Nov 2015 16:04:00 -0500 Subject: [PATCH 134/181] Quikfix --- acme/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/setup.py b/acme/setup.py index a6551a023..a8375150d 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -17,7 +17,7 @@ install_requires = [ 'pyrfc3339', 'pytz', 'requests', - 'setuptools', # pkg_resources + 'setuptools==18.5', # pkg_resources 'six', 'werkzeug', ] From b3851edb73f4b56f516c8bff3dd6f2d37f331967 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 24 Nov 2015 13:58:38 -0800 Subject: [PATCH 135/181] Since --webroot-map is not elegant, do not document it --- letsencrypt/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 9c4c4a5f5..ffccd56b4 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1037,8 +1037,9 @@ def _plugins_parsing(helpful, plugins): helpful.add("webroot", "-w", "--webroot-path", action=WebrootPathProcessor, help="public_html / webroot path") parse_dict = lambda s: dict(json.loads(s)) + # --webroot-map still has some awkward properties, so it is undocumented helpful.add("webroot", "--webroot-map", default={}, type=parse_dict, - help="Mapping from domains to webroot paths") + help=argparse.SUPPRESS) class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring From bc6064addd34126708ec44aac4d209d4d5bff3da Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Tue, 24 Nov 2015 14:58:03 -0800 Subject: [PATCH 136/181] propogate temp and fix docstring --- .../letsencrypt_apache/configurator.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 6edffcbe6..c4305f724 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -236,8 +236,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): If there is no clear virtual host to be selected, the user is prompted with all available choices. + The returned vhost is guaranteed to have TLS enabled unless temp is + True. If temp is True, there is no such guarantee and the result is + not cached. + :param str target_name: domain name - :param bool temp: whether or not self.make_vhost_ssl shouldn't be called + :param bool temp: whether the vhost is only used temporarily :returns: ssl vhost associated with name :rtype: :class:`~letsencrypt_apache.obj.VirtualHost` @@ -260,9 +264,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.assoc[target_name] = vhost return vhost - return self._choose_vhost_from_list(target_name) + return self._choose_vhost_from_list(target_name, temp) - def _choose_vhost_from_list(self, target_name): + def _choose_vhost_from_list(self, target_name, temp=False): # Select a vhost from a list vhost = display_ops.select_vhost(target_name, self.vhosts) if vhost is None: @@ -275,7 +279,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): elif not vhost.ssl: addrs = self._get_proposed_addrs(vhost, "443") # TODO: Conflicts is too conservative - if not any(vhost.enabled and vhost.conflicts(addrs) for vhost in self.vhosts): + if not any(vhost.enabled and vhost.conflicts(addrs) for vhost in self.vhosts)\ + and not temp: vhost = self.make_vhost_ssl(vhost) else: logger.error( From 7467496984b444047866831240d8ba25b67102e7 Mon Sep 17 00:00:00 2001 From: sagi Date: Tue, 24 Nov 2015 23:33:21 +0000 Subject: [PATCH 137/181] change enhancement http-header to ensure-http-header --- .../letsencrypt_apache/configurator.py | 4 ++-- .../letsencrypt_apache/tests/configurator_test.py | 12 ++++++------ letsencrypt/client.py | 6 +++--- letsencrypt/tests/client_test.py | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 6ef1fbee2..4b66c5c6f 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -122,7 +122,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.version = version self.vhosts = None self._enhance_func = {"redirect": self._enable_redirect, - "http-header": self._set_http_header} + "ensure-http-header": self._set_http_header} @property def mod_ssl_conf(self): @@ -701,7 +701,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ############################################################################ def supported_enhancements(self): # pylint: disable=no-self-use """Returns currently supported enhancements.""" - return ["redirect", "http-header"] + return ["redirect", "ensure-http-header"] def enhance(self, domain, enhancement, options=None): """Enhance configuration. diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 36a3f13fa..8eb1e16e2 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -521,7 +521,7 @@ class TwoVhost80Test(util.ApacheTest): mock_exe.return_value = True # This will create an ssl vhost for letsencrypt.demo - self.config.enhance("letsencrypt.demo", "http-header", + self.config.enhance("letsencrypt.demo", "ensure-http-header", "Strict-Transport-Security") self.assertTrue("headers_module" in self.config.parser.modules) @@ -543,12 +543,12 @@ class TwoVhost80Test(util.ApacheTest): self.config.parser.modules.add("headers_module") # This will create an ssl vhost for letsencrypt.demo - self.config.enhance("encryption-example.demo", "http-header", + self.config.enhance("encryption-example.demo", "ensure-http-header", "Strict-Transport-Security") self.assertRaises( errors.PluginError, - self.config.enhance, "encryption-example.demo", "http-header", + self.config.enhance, "encryption-example.demo", "ensure-http-header", "Strict-Transport-Security") @mock.patch("letsencrypt.le_util.run_script") @@ -559,7 +559,7 @@ class TwoVhost80Test(util.ApacheTest): mock_exe.return_value = True # This will create an ssl vhost for letsencrypt.demo - self.config.enhance("letsencrypt.demo", "http-header", + self.config.enhance("letsencrypt.demo", "ensure-http-header", "Upgrade-Insecure-Requests") self.assertTrue("headers_module" in self.config.parser.modules) @@ -581,12 +581,12 @@ class TwoVhost80Test(util.ApacheTest): self.config.parser.modules.add("headers_module") # This will create an ssl vhost for letsencrypt.demo - self.config.enhance("encryption-example.demo", "http-header", + self.config.enhance("encryption-example.demo", "ensure-http-header", "Upgrade-Insecure-Requests") self.assertRaises( errors.PluginError, - self.config.enhance, "encryption-example.demo", "http-header", + self.config.enhance, "encryption-example.demo", "ensure-http-header", "Upgrade-Insecure-Requests") diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e1b0c4b84..3eaf9eaef 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -418,10 +418,10 @@ class Client(object): self.apply_enhancement(domains, "redirect") if hsts: - self.apply_enhancement(domains, "http-header", + self.apply_enhancement(domains, "ensure-http-header", "Strict-Transport-Security") if uir: - self.apply_enhancement(domains, "http-header", + self.apply_enhancement(domains, "ensure-http-header", "Upgrade-Insecure-Requests") msg = ("We were unable to restart web server") @@ -435,7 +435,7 @@ class Client(object): :param domains: list of ssl_vhosts :type list of str - :param enhancement: name of enhancement, e.g. http-header + :param enhancement: name of enhancement, e.g. ensure-http-header :type str .. note:: when more options are need make options a list. diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 340e88abe..578cd77ab 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -262,12 +262,12 @@ class ClientTest(unittest.TestCase): config = ConfigHelper(redirect=False, hsts=True, uir=False) self.client.enhance_config(["foo.bar"], config) - installer.enhance.assert_called_with("foo.bar", "http-header", + installer.enhance.assert_called_with("foo.bar", "ensure-http-header", "Strict-Transport-Security") config = ConfigHelper(redirect=False, hsts=False, uir=True) self.client.enhance_config(["foo.bar"], config) - installer.enhance.assert_called_with("foo.bar", "http-header", + installer.enhance.assert_called_with("foo.bar", "ensure-http-header", "Upgrade-Insecure-Requests") self.assertEqual(installer.save.call_count, 3) From 090a9a0e465611b0c4448eaedc97bf9c5b93791b Mon Sep 17 00:00:00 2001 From: sagi Date: Wed, 25 Nov 2015 01:56:49 +0000 Subject: [PATCH 138/181] add PluginEnhancementAlreadyPresent and use it --- .../letsencrypt_apache/configurator.py | 16 +++++++++++----- .../tests/configurator_test.py | 6 +++--- letsencrypt/cli.py | 2 +- letsencrypt/client.py | 3 +++ letsencrypt/errors.py | 4 ++++ 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 4b66c5c6f..a5a56f6c4 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -782,7 +782,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :returns: boolean :rtype: (bool) - :raises errors.PluginError: When header header_substring exists + :raises errors.PluginEnhancementAlreadyPresent When header + header_substring exists """ header_path = self.parser.find_dir("Header", None, start=ssl_vhost.path) @@ -791,8 +792,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): pat = '(?:[ "]|^)(%s)(?:[ "]|$)' % (header_substring.lower()) for match in header_path: if re.search(pat, self.aug.get(match).lower()): - raise errors.PluginError("Existing %s header" % - (header_substring)) + raise errors.PluginEnhancementAlreadyPresent( + "Existing %s header" % (header_substring)) def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. @@ -863,8 +864,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param vhost: vhost to check :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` - :raises errors.PluginError: When another redirection exists + :raises errors.PluginEnhancementAlreadyPresent: When the exact + letsencrypt redirection WriteRule exists in virtual host. + errors.PluginError: When there exists directives that may hint + other redirection. (TODO: We should not throw a PluginError, + but that's for an other PR.) """ rewrite_path = self.parser.find_dir( "RewriteRule", None, start=vhost.path) @@ -881,7 +886,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): rewrite_path, constants.REWRITE_HTTPS_ARGS): if self.aug.get(match) != arg: raise errors.PluginError("Unknown Existing RewriteRule") - raise errors.PluginError( + + raise errors.PluginEnhancementAlreadyPresent( "Let's Encrypt has already enabled redirection") def _create_redirect_vhost(self, ssl_vhost): diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 8eb1e16e2..a7714615e 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -547,7 +547,7 @@ class TwoVhost80Test(util.ApacheTest): "Strict-Transport-Security") self.assertRaises( - errors.PluginError, + errors.PluginEnhancementAlreadyPresent, self.config.enhance, "encryption-example.demo", "ensure-http-header", "Strict-Transport-Security") @@ -585,7 +585,7 @@ class TwoVhost80Test(util.ApacheTest): "Upgrade-Insecure-Requests") self.assertRaises( - errors.PluginError, + errors.PluginEnhancementAlreadyPresent, self.config.enhance, "encryption-example.demo", "ensure-http-header", "Upgrade-Insecure-Requests") @@ -631,7 +631,7 @@ class TwoVhost80Test(util.ApacheTest): self.config.parser.modules.add("rewrite_module") self.config.enhance("encryption-example.demo", "redirect") self.assertRaises( - errors.PluginError, + errors.PluginEnhancementAlreadyPresent, self.config.enhance, "encryption-example.demo", "redirect") def test_unknown_rewrite(self): diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 34551c97f..a30cb223d 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -930,7 +930,7 @@ def prepare_and_parse_args(plugins, args): " Defends against SSL Stripping.", dest="hsts", default=False) helpful.add( "security", "--no-hsts", action="store_false", - help="Do not automaticcally add the Strict-Transport-Security header" + help="Do not automatically add the Strict-Transport-Security header" " to every HTTP response.", dest="hsts", default=False) helpful.add( "security", "--uir", action="store_true", diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 3eaf9eaef..f7010e09d 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -454,6 +454,9 @@ class Client(object): for dom in domains: try: self.installer.enhance(dom, enhancement, options) + except errors.PluginEnhancementAlreadyPresent: + logger.warn("Enhancement %s was already set.", + enhancement) except errors.PluginError: logger.warn("Unable to set enhancement %s for %s", enhancement, dom) diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py index 0df544b0d..1358d1048 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -66,6 +66,10 @@ class PluginError(Error): """Let's Encrypt Plugin error.""" +class PluginEnhancementAlreadyPresent(Error): + """ Enhancement was already set """ + + class PluginSelectionError(Error): """A problem with plugin/configurator selection or setup""" From a58c939c8df2857939c1088a713b1ad1b6bf3a6f Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Wed, 25 Nov 2015 14:26:00 +0100 Subject: [PATCH 139/181] Change ownership of the validation paths as well Match them with the parent directory they're in. --- letsencrypt/plugins/webroot.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 61eb87a88..cd13d1810 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -100,8 +100,15 @@ to serve all files under specified web root ({0}).""" try: os.makedirs(self.full_roots[name]) # Set permissions as parent directory (GH #1389) - filemode = stat.S_IMODE(os.stat(path).st_mode) + # We don't use the parameters in makedirs because it + # may not always work + # https://stackoverflow.com/questions/5231901/permission-problems-when-creating-a-dir-with-os-makedirs-python + stat_path = os.stat(path) + filemode = stat.S_IMODE(stat_path.st_mode) os.chmod(self.full_roots[name], filemode) + # Set owner and group, too + os.chown(self.full_roots[name], stat_path.st_uid, + stat_path.st_gid) except OSError as exception: if exception.errno != errno.EEXIST: @@ -129,9 +136,11 @@ to serve all files under specified web root ({0}).""" # Set permissions as parent directory (GH #1389) parent_path = self.full_roots[achall.domain] - filemode = stat.S_IMODE(os.stat(parent_path).st_mode) + stat_parent_path = os.stat(parent_path) + filemode = stat.S_IMODE(stat_parent_path.st_mode) # Remove execution bit (not needed for this file) os.chmod(path, filemode & ~stat.S_IEXEC) + os.chown(path, stat_parent_path.st_uid, stat_parent_path.st_gid) return response From 2a5f539d9a830f0ace1a38a707e3dba2b232bc4f Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Wed, 25 Nov 2015 14:26:51 +0100 Subject: [PATCH 140/181] Add tests for testing gid and uid with the webroot plugin They pass. --- letsencrypt/plugins/webroot_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index 3fa7f2994..862921d1d 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -81,6 +81,11 @@ class AuthenticatorTest(unittest.TestCase): actual_permissions = stat.S_IMODE(os.stat(self.validation_path).st_mode) self.assertEqual(parent_permissions, actual_permissions) + parent_gid = os.stat(self.path).st_gid + parent_uid = os.stat(self.path).st_uid + + self.assertEqual(os.stat(self.validation_path).st_gid, parent_gid) + self.assertEqual(os.stat(self.validation_path).st_uid, parent_uid) def test_perform_cleanup(self): responses = self.auth.perform([self.achall]) From b2ca861a27b2eb9715644fd9dd9cf58f4d9493e0 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 25 Nov 2015 09:44:28 -0500 Subject: [PATCH 141/181] Revert "Quikfix" This reverts commit a97a702210526b37d7c2ae84a76e2f3bc188ad6e. --- acme/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/setup.py b/acme/setup.py index a8375150d..a6551a023 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -17,7 +17,7 @@ install_requires = [ 'pyrfc3339', 'pytz', 'requests', - 'setuptools==18.5', # pkg_resources + 'setuptools', # pkg_resources 'six', 'werkzeug', ] From 8147216f1a57cbf4ac8c60c61fff3c7faf12e2b0 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Wed, 25 Nov 2015 12:43:29 -0500 Subject: [PATCH 142/181] Fix some underline lengths in docs. --- letsencrypt-apache/docs/api/tls_sni_01.rst | 2 +- letsencrypt-nginx/docs/api/tls_sni_01.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/docs/api/tls_sni_01.rst b/letsencrypt-apache/docs/api/tls_sni_01.rst index ee1072e96..2c11a3394 100644 --- a/letsencrypt-apache/docs/api/tls_sni_01.rst +++ b/letsencrypt-apache/docs/api/tls_sni_01.rst @@ -1,5 +1,5 @@ :mod:`letsencrypt_apache.tls_sni_01` -------------------------------- +------------------------------------ .. automodule:: letsencrypt_apache.tls_sni_01 :members: diff --git a/letsencrypt-nginx/docs/api/tls_sni_01.rst b/letsencrypt-nginx/docs/api/tls_sni_01.rst index 2860231b5..f9f584b0c 100644 --- a/letsencrypt-nginx/docs/api/tls_sni_01.rst +++ b/letsencrypt-nginx/docs/api/tls_sni_01.rst @@ -1,5 +1,5 @@ :mod:`letsencrypt_nginx.tls_sni_01` ------------------------------- +----------------------------------- .. automodule:: letsencrypt_nginx.tls_sni_01 :members: From e75dc965596bfd8b52019bbfef6cdd681978fc89 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Wed, 25 Nov 2015 12:44:17 -0500 Subject: [PATCH 143/181] Stop calling things that don't implement IAuthenticator authenticators. --- .../letsencrypt_apache/configurator.py | 14 +++++++------- .../letsencrypt_apache/tls_sni_01.py | 2 +- .../letsencrypt_nginx/configurator.py | 14 +++++++------- letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py | 2 +- letsencrypt/plugins/common.py | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index ef7ff03c6..c7c9a98b5 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -1152,15 +1152,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ self._chall_out.update(achalls) responses = [None] * len(achalls) - authenticator = tls_sni_01.ApacheTlsSni01(self) + chall_doer = tls_sni_01.ApacheTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have authenticator hold associated index - # of the challenge. This helps to put all of the responses back - # together when they are all complete. - authenticator.add_chall(achall, i) + # 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) - sni_response = authenticator.perform() + sni_response = chall_doer.perform() if sni_response: # Must restart in order to activate the challenges. # Handled here because we may be able to load up other challenge @@ -1171,7 +1171,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # 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[authenticator.indices[i]] = resp + responses[chall_doer.indices[i]] = resp return responses diff --git a/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py index 38ca1d390..e1a7d2d53 100644 --- a/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py +++ b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py @@ -1,4 +1,4 @@ -"""A TLS-SNI-01 authenticator for Apache""" +"""A class that performs TLS-SNI-01 challenges for Apache""" import os diff --git a/letsencrypt-nginx/letsencrypt_nginx/configurator.py b/letsencrypt-nginx/letsencrypt_nginx/configurator.py index c1ac9db66..aaaf43c5f 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/configurator.py +++ b/letsencrypt-nginx/letsencrypt_nginx/configurator.py @@ -574,15 +574,15 @@ class NginxConfigurator(common.Plugin): """ self._chall_out += len(achalls) responses = [None] * len(achalls) - authenticator = tls_sni_01.NginxTlsSni01(self) + chall_doer = tls_sni_01.NginxTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have authenticator hold associated index - # of the challenge. This helps to put all of the responses back - # together when they are all complete. - authenticator.add_chall(achall, i) + # 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) - sni_response = authenticator.perform() + sni_response = chall_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() @@ -591,7 +591,7 @@ class NginxConfigurator(common.Plugin): # 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[authenticator.indices[i]] = resp + responses[chall_doer.indices[i]] = resp return responses diff --git a/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py b/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py index c1bd434f6..e59281c4c 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py @@ -1,4 +1,4 @@ -"""A TLS-SNI-01 authenticator for Nginx""" +"""A class that performs TLS-SNI-01 challenges for Nginx""" import itertools import logging diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index d414dd146..f18b1fb3b 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -136,7 +136,7 @@ class Addr(object): class TLSSNI01(object): - """Abstract base for TLS-SNI-01 authenticators""" + """Abstract base for TLS-SNI-01 challenge performers""" def __init__(self, configurator): self.configurator = configurator From ef131b9bb92ee469899a588f15c0060faaf609fb Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Wed, 25 Nov 2015 23:28:58 -0800 Subject: [PATCH 144/181] Specify how long after updating SA it takes effect. --- letsencrypt/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index a231f9db0..24a2baba3 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -853,7 +853,7 @@ def prepare_and_parse_args(plugins, args): "lose access to your account. You will also be unable to receive " "notice about impending expiration of revocation of your " "certificates. Updates to the Subscriber Agreement will still " - "affect you, and will be effective N days after posting an " + "affect you, and will be effective 14 days after posting an " "update to the web site.") helpful.add(None, "-m", "--email", help=config_help("email")) # positional arg shadows --domains, instead of appending, and From c48ee677df03f285fcffe1abfab484970a0e3b18 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Thu, 26 Nov 2015 16:59:06 -0800 Subject: [PATCH 145/181] Merge Augeas lens fix for backslashes in regexps https://github.com/hercules-team/augeas/issues/307 https://github.com/hercules-team/augeas/commit/155746c72f76937a21b1a035da5c56090a54ed13 --- letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug b/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug index 9b50a8f0e..30d8ca501 100644 --- a/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug +++ b/letsencrypt-apache/letsencrypt_apache/augeas_lens/httpd.aug @@ -59,7 +59,7 @@ let empty = Util.empty_dos let indent = Util.indent (* borrowed from shellvars.aug *) -let char_arg_dir = /[^\\ '"\t\r\n]|\\\\"|\\\\'/ +let char_arg_dir = /([^\\ '"\t\r\n]|[^\\ '"\t\r\n][^ '"\t\r\n]*[^\\ '"\t\r\n])|\\\\"|\\\\'/ let char_arg_sec = /[^ '"\t\r\n>]|\\\\"|\\\\'/ let cdot = /\\\\./ let cl = /\\\\\n/ From dcca05e537b12a774302425a1e4b420d464814c2 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Fri, 27 Nov 2015 10:30:23 -0800 Subject: [PATCH 146/181] py26reqs.txt needs to be path-relative Fixes: #1630 --- letsencrypt-auto | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/letsencrypt-auto b/letsencrypt-auto index 083de58c4..e9b7739d2 100755 --- a/letsencrypt-auto +++ b/letsencrypt-auto @@ -14,6 +14,10 @@ XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share} VENV_NAME="letsencrypt" VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"} VENV_BIN=${VENV_PATH}/bin +# The path to the letsencrypt-auto script. Everything that uses these might +# at some point be inlined... +LEA_PATH=`dirname "$0"` +BOOTSTRAP=${LEA_PATH}/bootstrap # This script takes the same arguments as the main letsencrypt program, but it # additionally responds to --verbose (more output) and --debug (allow support @@ -110,7 +114,6 @@ DeterminePythonVersion() { # later steps, causing "ImportError: cannot import name unpack_url" if [ ! -d $VENV_PATH ] then - BOOTSTRAP=`dirname $0`/bootstrap if [ ! -f $BOOTSTRAP/debian.sh ] ; then echo "Cannot find the letsencrypt bootstrap scripts in $BOOTSTRAP" exit 1 @@ -172,7 +175,7 @@ if [ "$VERBOSE" = 1 ] ; then echo $VENV_BIN/pip install -U setuptools $VENV_BIN/pip install -U pip - $VENV_BIN/pip install -r py26reqs.txt -U letsencrypt letsencrypt-apache + $VENV_BIN/pip install -r "$LEA_PATH"/py26reqs.txt -U letsencrypt letsencrypt-apache # nginx is buggy / disabled for now, but upgrade it if the user has # installed it manually if $VENV_BIN/pip freeze | grep -q letsencrypt-nginx ; then @@ -184,7 +187,7 @@ else $VENV_BIN/pip install -U pip > /dev/null printf . # nginx is buggy / disabled for now... - $VENV_BIN/pip install -r py26reqs.txt > /dev/null + $VENV_BIN/pip install -r "$LEA_PATH"/py26reqs.txt > /dev/null printf . $VENV_BIN/pip install -U letsencrypt > /dev/null printf . From 107cb995afa484d65ae118cefe93c78bf7a01388 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 28 Nov 2015 02:06:53 -0800 Subject: [PATCH 147/181] Reduce verbosity of error tracebacks Counteracting #1413 and going a little further. --- letsencrypt/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 2fae5fe3e..729979f39 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1150,9 +1150,9 @@ def _handle_exception(exc_type, exc_value, trace, args): else: # Tell the user a bit about what happened, without overwhelming # them with a full traceback - msg = ("An unexpected error occurred.\n" + - traceback.format_exception_only(exc_type, exc_value)[0] + - "Please see the ") + err = traceback.format_exception_only(exc_type, exc_value)[0] + _code, _sep, err = err.partition(":: ") + msg = "An unexpected error occurred:\n" + err + "Please see the " if args is None: msg += "logfile '{0}' for more details.".format(logfile) else: From dce0f6bf16762284ccf3e95083c11fa8df313c29 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 28 Nov 2015 02:09:12 -0800 Subject: [PATCH 148/181] Comment string manipulation --- letsencrypt/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 729979f39..566ed42c5 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1151,6 +1151,7 @@ def _handle_exception(exc_type, exc_value, trace, args): # Tell the user a bit about what happened, without overwhelming # them with a full traceback err = traceback.format_exception_only(exc_type, exc_value)[0] + # prune ACME error code, we have a human description _code, _sep, err = err.partition(":: ") msg = "An unexpected error occurred:\n" + err + "Please see the " if args is None: From 29c3cc8647d965e79147f684496f8a737205bfb7 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 28 Nov 2015 15:27:33 -0800 Subject: [PATCH 149/181] Only prune error message when non-verbose --- letsencrypt/cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 566ed42c5..cf1251f0c 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1151,8 +1151,14 @@ def _handle_exception(exc_type, exc_value, trace, args): # Tell the user a bit about what happened, without overwhelming # them with a full traceback err = traceback.format_exception_only(exc_type, exc_value)[0] - # prune ACME error code, we have a human description - _code, _sep, err = err.partition(":: ") + # Typical error from the ACME module: + # acme.messages.Error: urn:acme:error:malformed :: The request message was + # malformed :: Error creating new registration :: Validation of contact + # mailto:none@longrandomstring.biz failed: Server failure at resolver + if ("urn:acme" in err and ":: " in err + and args.verbose_count <= flag_default("verbose_count")): + # prune ACME error code, we have a human description + _code, _sep, err = err.partition(":: ") msg = "An unexpected error occurred:\n" + err + "Please see the " if args is None: msg += "logfile '{0}' for more details.".format(logfile) From 48104cded91d92e94e986a75602a403cfafd87d6 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Sat, 28 Nov 2015 17:09:24 -0800 Subject: [PATCH 150/181] Tests for error simplification --- letsencrypt/cli.py | 1 + letsencrypt/tests/cli_test.py | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index cf1251f0c..42e9e252e 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1148,6 +1148,7 @@ def _handle_exception(exc_type, exc_value, trace, args): if issubclass(exc_type, errors.Error): sys.exit(exc_value) else: + # Here we're passing a client or ACME error out to the client at the shell # Tell the user a bit about what happened, without overwhelming # them with a full traceback err = traceback.format_exception_only(exc_type, exc_value)[0] diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index e512668c5..b8c67696f 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -450,9 +450,14 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods @mock.patch('letsencrypt.cli.sys') def test_handle_exception(self, mock_sys): # pylint: disable=protected-access + from acme import messages + + args = mock.MagicMock() mock_open = mock.mock_open() + with mock.patch('letsencrypt.cli.open', mock_open, create=True): exception = Exception('detail') + args.verbose_count = 1 cli._handle_exception( Exception, exc_value=exception, trace=None, args=None) mock_open().write.assert_called_once_with(''.join( @@ -469,11 +474,23 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods mock_sys.exit.assert_any_call(''.join( traceback.format_exception_only(errors.Error, error))) - args = mock.MagicMock(debug=False) + exception = messages.Error(detail='alpha', typ='urn:acme:error:triffid', + title='beta') + args = mock.MagicMock(debug=False, verbose_count=-3) cli._handle_exception( - Exception, exc_value=Exception('detail'), trace=None, args=args) + messages.Error, exc_value=exception, trace=None, args=args) error_msg = mock_sys.exit.call_args_list[-1][0][0] self.assertTrue('unexpected error' in error_msg) + self.assertTrue('acme:error' not in error_msg) + self.assertTrue('alpha' in error_msg) + self.assertTrue('beta' in error_msg) + args = mock.MagicMock(debug=False, verbose_count=1) + cli._handle_exception( + messages.Error, exc_value=exception, trace=None, args=args) + error_msg = mock_sys.exit.call_args_list[-1][0][0] + self.assertTrue('unexpected error' in error_msg) + self.assertTrue('acme:error' in error_msg) + self.assertTrue('alpha' in error_msg) interrupt = KeyboardInterrupt('detail') cli._handle_exception( From 218379c2be8c5d7b9cb3e13d1dba57ece40dd9bc Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 29 Nov 2015 09:26:03 +0000 Subject: [PATCH 151/181] poll_and_ri: handle STATUS_INVALID, add max_attempts (fixes #1634) --- acme/acme/client.py | 30 ++++++++++++++++++------------ acme/acme/client_test.py | 24 +++++++++++++++++++----- acme/acme/errors.py | 28 ++++++++++++++++++++++++++++ acme/acme/errors_test.py | 21 +++++++++++++++++++++ 4 files changed, 86 insertions(+), 17 deletions(-) diff --git a/acme/acme/client.py b/acme/acme/client.py index 0e9319f9c..08d476783 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -246,9 +246,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes def retry_after(cls, response, default): """Compute next `poll` time based on response ``Retry-After`` header. - :param response: Response from `poll`. - :type response: `requests.Response` - + :param requests.Response response: Response from `poll`. :param int default: Default value (in seconds), used when ``Retry-After`` header is not present or invalid. @@ -323,22 +321,21 @@ class Client(object): # pylint: disable=too-many-instance-attributes body=jose.ComparableX509(OpenSSL.crypto.load_certificate( OpenSSL.crypto.FILETYPE_ASN1, response.content))) - def poll_and_request_issuance(self, csr, authzrs, mintime=5): + def poll_and_request_issuance( + self, csr, authzrs, mintime=5, max_attempts=10): """Poll and request issuance. This function polls all provided Authorization Resource URIs until all challenges are valid, respecting ``Retry-After`` HTTP headers, and then calls `request_issuance`. - .. todo:: add `max_attempts` or `timeout` - - :param csr: CSR. - :type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509` - + :param .ComparableX509 csr: CSR (`OpenSSL.crypto.X509Req` + wrapped in `.ComparableX509`) :param authzrs: `list` of `.AuthorizationResource` - :param int mintime: Minimum time before next attempt, used if ``Retry-After`` is not present in the response. + :param int max_attempts: Maximum number of attempts before + `PollError` with non-empty ``waiting`` is raised. :returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is the issued certificate (`.messages.CertificateResource.), @@ -348,6 +345,9 @@ class Client(object): # pylint: disable=too-many-instance-attributes as the input ``authzrs``. :rtype: `tuple` + :raises PollError: in case of timeout or if some authorization + was marked by the CA as invalid + """ # priority queue with datetime (based on Retry-After) as key, # and original Authorization Resource as value @@ -356,7 +356,8 @@ class Client(object): # pylint: disable=too-many-instance-attributes # recently updated one updated = dict((authzr, authzr) for authzr in authzrs) - while waiting: + while waiting and max_attempts: + max_attempts -= 1 # find the smallest Retry-After, and sleep if necessary when, authzr = heapq.heappop(waiting) now = datetime.datetime.now() @@ -371,11 +372,16 @@ class Client(object): # pylint: disable=too-many-instance-attributes updated[authzr] = updated_authzr # pylint: disable=no-member - if updated_authzr.body.status != messages.STATUS_VALID: + if updated_authzr.body.status not in ( + messages.STATUS_VALID, messages.STATUS_INVALID): # push back to the priority queue, with updated retry_after heapq.heappush(waiting, (self.retry_after( response, default=mintime), authzr)) + if not max_attempts or any(authzr.body.status == messages.STATUS_INVALID + for authzr in six.itervalues(updated)): + raise errors.PollError(waiting, updated) + updated_authzrs = tuple(updated[authzr] for authzr in authzrs) return self.request_issuance(csr, updated_authzrs), updated_authzrs diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index 2df7b5313..58f55b293 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -271,9 +271,9 @@ class ClientTest(unittest.TestCase): # result, increment clock clock.dt += datetime.timedelta(seconds=2) - if not authzr.retries: # no more retries + if len(authzr.retries) == 1: # no more retries done = mock.MagicMock(uri=authzr.uri, times=authzr.times) - done.body.status = messages.STATUS_VALID + done.body.status = authzr.retries[0] return done, [] # response (2nd result tuple element) is reduced to only @@ -289,7 +289,8 @@ class ClientTest(unittest.TestCase): mintime = 7 - def retry_after(response, default): # pylint: disable=missing-docstring + def retry_after(response, default): + # pylint: disable=missing-docstring # check that poll_and_request_issuance correctly passes mintime self.assertEqual(default, mintime) return clock.dt + datetime.timedelta(seconds=response) @@ -302,8 +303,10 @@ class ClientTest(unittest.TestCase): csr = mock.MagicMock() authzrs = ( - mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)), - mock.MagicMock(uri='b', times=[], retries=(5,)), + mock.MagicMock(uri='a', times=[], retries=( + 8, 20, 30, messages.STATUS_VALID)), + mock.MagicMock(uri='b', times=[], retries=( + 5, messages.STATUS_VALID)), ) cert, updated_authzrs = self.client.poll_and_request_issuance( @@ -327,6 +330,17 @@ class ClientTest(unittest.TestCase): ]) self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7)) + # CA sets invalid | TODO: move to a separate test + invalid_authzr = mock.MagicMock(times=[], retries=[messages.STATUS_INVALID]) + self.assertRaises( + errors.PollError, self.client.poll_and_request_issuance, + csr, authzrs=(invalid_authzr,), mintime=mintime) + + # exceeded max_attemps | TODO: move to a separate test + self.assertRaises( + errors.PollError, self.client.poll_and_request_issuance, + csr, authzrs, mintime=mintime, max_attempts=2) + def test_check_cert(self): self.response.headers['Location'] = self.certr.uri self.response.content = CERT_DER diff --git a/acme/acme/errors.py b/acme/acme/errors.py index 9a96ec43a..0385667c7 100644 --- a/acme/acme/errors.py +++ b/acme/acme/errors.py @@ -51,3 +51,31 @@ class MissingNonce(NonceError): return ('Server {0} response did not include a replay ' 'nonce, headers: {1}'.format( self.response.request.method, self.response.headers)) + + +class PollError(ClientError): + """Generic error when polling for authorization fails. + + This might be caused by either timeout (`waiting` will be non-empty) + or by some authorization being invalid. + + :ivar waiting: Priority queue with `datetime.datatime` (based on + ``Retry-After``) as key, and original `.AuthorizationResource` + as value. + :ivar updated: Mapping from original `.AuthorizationResource` + to the most recently updated one + + """ + def __init__(self, waiting, updated): + self.waiting = waiting + self.updated = updated + super(PollError, self).__init__() + + @property + def timeout(self): + """Was the error caused by timeout?""" + return bool(self.waiting) + + def __repr__(self): + return '{0}(waiting={1!r}, updated={2!r})'.format( + self.__class__.__name__, self.waiting, self.updated) diff --git a/acme/acme/errors_test.py b/acme/acme/errors_test.py index 3790d91ed..45b269a0b 100644 --- a/acme/acme/errors_test.py +++ b/acme/acme/errors_test.py @@ -1,4 +1,5 @@ """Tests for acme.errors.""" +import datetime import unittest import mock @@ -29,5 +30,25 @@ class MissingNonceTest(unittest.TestCase): self.assertTrue("{}" in str(self.error)) +class PollErrorTest(unittest.TestCase): + """Tests for acme.errors.PollError.""" + + def setUp(self): + from acme.errors import PollError + self.timeout = PollError( + waiting=[(datetime.datetime(2015, 11, 29), mock.sentinel.AR)], + updated={}) + self.invalid = PollError(waiting=[], updated={ + mock.sentinel.AR: mock.sentinel.AR2}) + + def test_timeout(self): + self.assertTrue(self.timeout.timeout) + self.assertFalse(self.invalid.timeout) + + def test_repr(self): + self.assertEqual('PollError(waiting=[], updated={sentinel.AR: ' + 'sentinel.AR2})', repr(self.invalid)) + + if __name__ == "__main__": unittest.main() # pragma: no cover From 224eb1cd1a94835a5dbd3c59154050c841d207a8 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 30 Nov 2015 16:50:26 -0800 Subject: [PATCH 152/181] Stop using init-script --- .../letsencrypt_apache/configurator.py | 61 +++++-------------- 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 5777d204d..1849c0095 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -1186,16 +1186,25 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): le_util.run_script([self.conf("enmod"), mod_name]) def restart(self): - """Reloads apache server. + """Runs a config test and reloads the Apache server. - .. todo:: This function will be converted to using reload - - :raises .errors.MisconfigurationError: If unable to reload due - to a configuration problem, or if the reload subprocess - cannot be run. + :raises .errors.MisconfigurationError: If either the config test + or reload fails. """ - return apache_reload(self.conf("init-script")) + self.config_test() + self._reload() + + def _reload(self): + """Reloads the Apache server. + + :raises .errors.MisconfigurationError: If reload fails + + """ + try: + le_util.run_script([self.conf("ctl"), "-k", "graceful"]) + except errors.SubprocessError as err: + raise errors.MisconfigurationError(str(err)) def config_test(self): # pylint: disable=no-self-use """Check the configuration of Apache for errors. @@ -1317,44 +1326,6 @@ def _get_mod_deps(mod_name): return deps.get(mod_name, []) -def apache_reload(apache_init_script): - """Reloads the Apache Server. - - :param str apache_init_script: Path to the Apache init script. - - .. todo:: Try to use reload instead. (This caused timing problems before) - - .. todo:: On failure, this should be a recovery_routine call with another - reload. This will confuse and inhibit developers from testing code - though. This change should happen after - the ApacheConfigurator has been thoroughly tested. The function will - need to be moved into the class again. Perhaps - this version can live on... for testing purposes. - - :raises .errors.MisconfigurationError: If unable to reload due to a - configuration problem, or if the reload subprocess cannot be run. - - """ - try: - proc = subprocess.Popen([apache_init_script, "reload"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - - except (OSError, ValueError): - logger.fatal( - "Unable to reload the Apache process with %s", apache_init_script) - raise errors.MisconfigurationError( - "Unable to reload Apache process with %s" % apache_init_script) - - stdout, stderr = proc.communicate() - - if proc.returncode != 0: - # Enter recovery routine... - logger.error("Apache Reload Failed!\n%s\n%s", stdout, stderr) - raise errors.MisconfigurationError( - "Error while reloading Apache:\n%s\n%s" % (stdout, stderr)) - - def get_file_path(vhost_path): """Get file path from augeas_vhost_path. From c72e122943a8276f4c73e1e256d244d99eeef30e Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 30 Nov 2015 17:56:42 -0800 Subject: [PATCH 153/181] add add_deprecated_argument --- letsencrypt/le_util.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index 25260d755..3d124e2f8 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -1,4 +1,5 @@ """Utilities for all Let's Encrypt.""" +import argparse import collections import errno import logging @@ -255,3 +256,23 @@ def safe_email(email): else: logger.warn("Invalid email address: %s.", email) return False + + +def add_deprecated_argument(add_argument, argument_name): + """Adds a deprecated argument with the name argument_name. + + Deprecated arguments are not shown in the help. If they are used on + the command line, a warning is shown stating that the argument is + deprecated and no other action is taken. + + :param callable add_argument: Function that adds arguments to an + argument parser/group. + :param str argument_name: Name of deprecated argument. + + """ + class ShowWarning(argparse.Action): + """Action to log a warning when an argument is used.""" + def __call__(self, unused1, unused2, unused3, option_string=None): + print "Use of {0} is deprecated".format(option_string) + + add_argument(argument_name, action=ShowWarning, help=argparse.SUPPRESS) From 2b87d6f700d8134cbdec10adb69c3ef97fe6ec54 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 30 Nov 2015 18:15:07 -0800 Subject: [PATCH 154/181] Do not accept -d first in the presence of multiple -w flags * informal testing suggested that many people found this behaviour confusing --- letsencrypt/cli.py | 8 ++++++++ letsencrypt/tests/cli_test.py | 11 +++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index ffccd56b4..cc458d7fe 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1043,6 +1043,10 @@ def _plugins_parsing(helpful, plugins): class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring + def __init__(self, *args, **kwargs): + self.domain_before_webroot = False + argparse.Action.__init__(self, *args, **kwargs) + def __call__(self, parser, config, webroot, option_string=None): """ Keep a record of --webroot-path / -w flags during processing, so that @@ -1055,8 +1059,12 @@ class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring # config.webroot_map are filled in by cli.DomainFlagProcessor if config.domains: config.webroot_map = dict([(d, webroot) for d in config.domains]) + self.domain_before_webroot = True else: config.webroot_map = {} + elif self.domain_before_webroot: + raise errors.Error("If you specify multiple webroot paths, one of " + "them must precede all domain flags") config.webroot_path.append(webroot) diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 853109636..9f6538eb8 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -343,17 +343,20 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods def test_parse_webroot(self): plugins = disco.PluginsRegistry.find_all() - webroot_args = ['--webroot', '-d', 'stray.example.com', '-w', - '/var/www/example', '-d', 'example.com,www.example.com', '-w', - '/var/www/superfluous', '-d', 'superfluo.us', '-d', 'www.superfluo.us'] + webroot_args = ['--webroot', '-w', '/var/www/example', + '-d', 'example.com,www.example.com', '-w', '/var/www/superfluous', + '-d', 'superfluo.us', '-d', 'www.superfluo.us'] namespace = cli.prepare_and_parse_args(plugins, webroot_args) self.assertEqual(namespace.webroot_map, { 'example.com': '/var/www/example', - 'stray.example.com': '/var/www/example', 'www.example.com': '/var/www/example', 'www.superfluo.us': '/var/www/superfluous', 'superfluo.us': '/var/www/superfluous'}) + webroot_args = ['-d', 'stray.example.com'] + webroot_args + with self.assertRaises(errors.Error): + cli.prepare_and_parse_args(plugins, webroot_args) + webroot_map_args = ['--webroot-map', '{"eg.com" : "/tmp"}'] namespace = cli.prepare_and_parse_args(plugins, webroot_map_args) self.assertEqual(namespace.webroot_map, {u"eg.com": u"/tmp"}) From 328f8cdc5b65f4fe43082faee8e34fe4cba2d98d Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 30 Nov 2015 18:24:40 -0800 Subject: [PATCH 155/181] Document --webroot-path --- letsencrypt/cli.py | 5 ++++- letsencrypt/plugins/webroot.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index cc458d7fe..cbdd5465a 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1035,7 +1035,10 @@ def _plugins_parsing(helpful, plugins): # legibiility. helpful.add_plugin_ags must be called first to add the # "webroot" topic helpful.add("webroot", "-w", "--webroot-path", action=WebrootPathProcessor, - help="public_html / webroot path") + help="public_html / webroot path. This can be specified multiple times to " + "handle different domains; each domain will have the webroot path that" + " precededed it. For instance: `-w /var/www/example -d example.com -d " + "www.example.com -w /var/www/thing -d thing.net -d m.thing.net`") parse_dict = lambda s: dict(json.loads(s)) # --webroot-map still has some awkward properties, so it is undocumented helpful.add("webroot", "--webroot-map", default={}, type=parse_dict, diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 4e18f5ca2..8be5d80cd 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -73,6 +73,8 @@ to serve all files under specified web root ({0}).""" @classmethod def add_parser_arguments(cls, add): + # --webroot-path and --webroot-map are added in cli.py because they + # are parsed in conjunction with --domains pass def get_chall_pref(self, domain): # pragma: no cover From 77778e85cce1ffab4beef22386316b11738d09af Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 30 Nov 2015 18:24:52 -0800 Subject: [PATCH 156/181] Restore --domain compatibility It probably should never have lapsed... --- letsencrypt/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index cbdd5465a..530cba638 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -839,7 +839,7 @@ def prepare_and_parse_args(plugins, args): # --domains is useful, because it can be stored in config #for subparser in parser_run, parser_auth, parser_install: # subparser.add_argument("domains", nargs="*", metavar="domain") - helpful.add(None, "-d", "--domains", dest="domains", + helpful.add(None, "-d", "--domains", "--domain", dest="domains", metavar="DOMAIN", action=DomainFlagProcessor, help="Domain names to apply. For multiple domains you can use " "multiple -d flags or enter a comma separated list of domains " From 4a2e40c365ec4fa5bb6d19aa0d997ede6f0a0820 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 30 Nov 2015 18:55:28 -0800 Subject: [PATCH 157/181] Added add_deprecated_argument tests --- letsencrypt/le_util.py | 12 ++++++---- letsencrypt/tests/le_util_test.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index 3d124e2f8..d127c9d64 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -6,8 +6,9 @@ import logging import os import platform import re -import subprocess import stat +import subprocess +import sys from letsencrypt import errors @@ -258,7 +259,7 @@ def safe_email(email): return False -def add_deprecated_argument(add_argument, argument_name): +def add_deprecated_argument(add_argument, argument_name, nargs): """Adds a deprecated argument with the name argument_name. Deprecated arguments are not shown in the help. If they are used on @@ -268,11 +269,14 @@ def add_deprecated_argument(add_argument, argument_name): :param callable add_argument: Function that adds arguments to an argument parser/group. :param str argument_name: Name of deprecated argument. + :param nargs: Value for nargs when adding the argument to argparse. """ class ShowWarning(argparse.Action): """Action to log a warning when an argument is used.""" def __call__(self, unused1, unused2, unused3, option_string=None): - print "Use of {0} is deprecated".format(option_string) + sys.stderr.write( + "Use of {0} is deprecated\n".format(option_string)) - add_argument(argument_name, action=ShowWarning, help=argparse.SUPPRESS) + add_argument(argument_name, action=ShowWarning, + help=argparse.SUPPRESS, nargs=nargs) diff --git a/letsencrypt/tests/le_util_test.py b/letsencrypt/tests/le_util_test.py index ed976f72d..87894f837 100644 --- a/letsencrypt/tests/le_util_test.py +++ b/letsencrypt/tests/le_util_test.py @@ -1,8 +1,10 @@ """Tests for letsencrypt.le_util.""" +import argparse import errno import os import shutil import stat +import StringIO import tempfile import unittest @@ -284,5 +286,42 @@ class SafeEmailTest(unittest.TestCase): self.assertFalse(self._call(addr), "%s failed." % addr) +class AddDeprecatedArgumentTest(unittest.TestCase): + """Test add_deprecated_argument.""" + def setUp(self): + self.parser = argparse.ArgumentParser() + + def _call(self, argument_name, nargs): + from letsencrypt.le_util import add_deprecated_argument + + add_deprecated_argument(self.parser.add_argument, argument_name, nargs) + + def test_warning_no_arg(self): + self._call("--old-option", 0) + stderr = self._get_argparse_warnings(["--old-option"]) + self.assertTrue("--old-option is deprecated" in stderr) + + def test_warning_with_arg(self): + self._call("--old-option", 1) + stderr = self._get_argparse_warnings(["--old-option", "42"]) + self.assertTrue("--old-option is deprecated" in stderr) + + def _get_argparse_warnings(self, args): + stderr = StringIO.StringIO() + with mock.patch("letsencrypt.le_util.sys.stderr", new=stderr): + self.parser.parse_args(args) + return stderr.getvalue() + + def test_help(self): + self._call("--old-option", 2) + stdout = StringIO.StringIO() + with mock.patch("letsencrypt.le_util.sys.stdout", new=stdout): + try: + self.parser.parse_args(["-h"]) + except SystemExit: + pass + self.assertTrue("--old-option" not in stdout.getvalue()) + + if __name__ == "__main__": unittest.main() # pragma: no cover From 0d6728f9cf142367885a95e3f19e65cf0b19a7e0 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 30 Nov 2015 18:57:48 -0800 Subject: [PATCH 158/181] Punctuation and deprecation --- letsencrypt-apache/letsencrypt_apache/configurator.py | 4 +--- letsencrypt/le_util.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 1849c0095..fa12ccf03 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -95,13 +95,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): help="Path to the Apache 'a2enmod' binary.") add("dismod", default=constants.CLI_DEFAULTS["dismod"], help="Path to the Apache 'a2enmod' binary.") - add("init-script", default=constants.CLI_DEFAULTS["init_script"], - help="Path to the Apache init script (used for server " - "reload).") add("le-vhost-ext", default=constants.CLI_DEFAULTS["le_vhost_ext"], help="SSL vhost configuration extension.") add("server-root", default=constants.CLI_DEFAULTS["server_root"], help="Apache server root directory.") + le_util.add_deprecated_argument(add, "init-script", 1) def __init__(self, *args, **kwargs): """Initialize an Apache Configurator. diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index d127c9d64..7869fc9a5 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -276,7 +276,7 @@ def add_deprecated_argument(add_argument, argument_name, nargs): """Action to log a warning when an argument is used.""" def __call__(self, unused1, unused2, unused3, option_string=None): sys.stderr.write( - "Use of {0} is deprecated\n".format(option_string)) + "Use of {0} is deprecated.\n".format(option_string)) add_argument(argument_name, action=ShowWarning, help=argparse.SUPPRESS, nargs=nargs) From e4cf64c30ecd6c1a7cadc052ffc22d58f7bd38c5 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 30 Nov 2015 19:13:50 -0800 Subject: [PATCH 159/181] Fix Apache tests --- .../letsencrypt_apache/configurator.py | 1 - .../letsencrypt_apache/constants.py | 1 - .../tests/configurator_test.py | 21 +++++-------------- .../letsencrypt_apache/tests/util.py | 6 +----- 4 files changed, 6 insertions(+), 23 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index fa12ccf03..319082934 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -7,7 +7,6 @@ import os import re import shutil import socket -import subprocess import time import zope.interface diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index 813eae582..202fc3e21 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -7,7 +7,6 @@ CLI_DEFAULTS = dict( ctl="apache2ctl", enmod="a2enmod", dismod="a2dismod", - init_script="/etc/init.d/apache2", le_vhost_ext="-le-ssl.conf", ) """CLI defaults.""" diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 0b6170e1d..d232a1dc4 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -563,24 +563,13 @@ class TwoVhost80Test(util.ApacheTest): mock_script.side_effect = errors.SubprocessError("Can't find program") self.assertRaises(errors.PluginError, self.config.get_version) - @mock.patch("letsencrypt_apache.configurator.subprocess.Popen") - def test_restart(self, mock_popen): - """These will be changed soon enough with reload.""" - mock_popen().returncode = 0 - mock_popen().communicate.return_value = ("", "") - + @mock.patch("letsencrypt_apache.configurator.le_util.run_script") + def test_restart(self, _): self.config.restart() - @mock.patch("letsencrypt_apache.configurator.subprocess.Popen") - def test_restart_bad_process(self, mock_popen): - mock_popen.side_effect = OSError - - self.assertRaises(errors.MisconfigurationError, self.config.restart) - - @mock.patch("letsencrypt_apache.configurator.subprocess.Popen") - def test_restart_failure(self, mock_popen): - mock_popen().communicate.return_value = ("", "") - mock_popen().returncode = 1 + @mock.patch("letsencrypt_apache.configurator.le_util.run_script") + def test_restart_bad_process(self, mock_run_script): + mock_run_script.side_effect = [None, errors.SubprocessError] self.assertRaises(errors.MisconfigurationError, self.config.restart) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index 1bc1fbe17..0c60373f2 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -75,11 +75,7 @@ def get_apache_configurator( in_progress_dir=os.path.join(backups, "IN_PROGRESS"), work_dir=work_dir) - with mock.patch("letsencrypt_apache.configurator." - "subprocess.Popen") as mock_popen: - # This indicates config_test passes - mock_popen().communicate.return_value = ("Fine output", "No problems") - mock_popen().returncode = 0 + with mock.patch("letsencrypt_apache.configurator.le_util.run_script"): with mock.patch("letsencrypt_apache.configurator.le_util." "exe_exists") as mock_exe_exists: mock_exe_exists.return_value = True From bbc9cf3b6edffb409ec6e2dd2022e96c3a758d4a Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 30 Nov 2015 19:49:23 -0800 Subject: [PATCH 160/181] Start a library of apache2 conf files for tests With examples of passing and failing conf files sourced from our github tickets. --- tests/apache-conf-files/NEEDED.txt | 6 + .../failing/drupal-htaccess-1531.conf | 149 +++++ .../apache-conf-files/failing/ipv6-1143.conf | 9 + .../apache-conf-files/failing/ipv6-1143b.conf | 21 + .../failing/multivhost-1093.conf | 295 +++++++++ .../failing/multivhost-1093b.conf | 593 ++++++++++++++++++ .../apache-conf-files/passing/README.modules | 5 + .../passing/anarcat-1531.conf | 14 + .../passing/finalize-1243.apache2.conf.txt | 222 +++++++ .../passing/finalize-1243.conf | 67 ++ .../passing/modmacro-1385.conf | 33 + .../passing/owncloud-1264.conf | 13 + .../passing/roundcube-1222.conf | 61 ++ .../passing/semacode-1598.conf | 44 ++ 14 files changed, 1532 insertions(+) create mode 100644 tests/apache-conf-files/NEEDED.txt create mode 100644 tests/apache-conf-files/failing/drupal-htaccess-1531.conf create mode 100644 tests/apache-conf-files/failing/ipv6-1143.conf create mode 100644 tests/apache-conf-files/failing/ipv6-1143b.conf create mode 100644 tests/apache-conf-files/failing/multivhost-1093.conf create mode 100644 tests/apache-conf-files/failing/multivhost-1093b.conf create mode 100644 tests/apache-conf-files/passing/README.modules create mode 100644 tests/apache-conf-files/passing/anarcat-1531.conf create mode 100644 tests/apache-conf-files/passing/finalize-1243.apache2.conf.txt create mode 100644 tests/apache-conf-files/passing/finalize-1243.conf create mode 100644 tests/apache-conf-files/passing/modmacro-1385.conf create mode 100644 tests/apache-conf-files/passing/owncloud-1264.conf create mode 100644 tests/apache-conf-files/passing/roundcube-1222.conf create mode 100644 tests/apache-conf-files/passing/semacode-1598.conf diff --git a/tests/apache-conf-files/NEEDED.txt b/tests/apache-conf-files/NEEDED.txt new file mode 100644 index 000000000..b51956b0c --- /dev/null +++ b/tests/apache-conf-files/NEEDED.txt @@ -0,0 +1,6 @@ +Issues for which some kind of test case should be constructable, but we do not +currently have one: + +https://github.com/letsencrypt/letsencrypt/issues/1213 +https://github.com/letsencrypt/letsencrypt/issues/1602 + diff --git a/tests/apache-conf-files/failing/drupal-htaccess-1531.conf b/tests/apache-conf-files/failing/drupal-htaccess-1531.conf new file mode 100644 index 000000000..a1aab7a39 --- /dev/null +++ b/tests/apache-conf-files/failing/drupal-htaccess-1531.conf @@ -0,0 +1,149 @@ +# +# Apache/PHP/Drupal settings: +# + +# Protect files and directories from prying eyes. + + Order allow,deny + + +# Don't show directory listings for URLs which map to a directory. +Options -Indexes + +# Follow symbolic links in this directory. +Options +FollowSymLinks + +# Make Drupal handle any 404 errors. +ErrorDocument 404 /index.php + +# Set the default handler. +DirectoryIndex index.php index.html index.htm + +# Override PHP settings that cannot be changed at runtime. See +# sites/default/default.settings.php and drupal_environment_initialize() in +# includes/bootstrap.inc for settings that can be changed at runtime. + +# PHP 5, Apache 1 and 2. + + php_flag magic_quotes_gpc off + php_flag magic_quotes_sybase off + php_flag register_globals off + php_flag session.auto_start off + php_value mbstring.http_input pass + php_value mbstring.http_output pass + php_flag mbstring.encoding_translation off + + +# Requires mod_expires to be enabled. + + # Enable expirations. + ExpiresActive On + + # Cache all files for 2 weeks after access (A). + ExpiresDefault A1209600 + + + # Do not allow PHP scripts to be cached unless they explicitly send cache + # headers themselves. Otherwise all scripts would have to overwrite the + # headers set by mod_expires if they want another caching behavior. This may + # fail if an error occurs early in the bootstrap process, and it may cause + # problems if a non-Drupal PHP file is installed in a subdirectory. + ExpiresActive Off + + + +# Various rewrite rules. + + RewriteEngine on + + # Set "protossl" to "s" if we were accessed via https://. This is used later + # if you enable "www." stripping or enforcement, in order to ensure that + # you don't bounce between http and https. + RewriteRule ^ - [E=protossl] + RewriteCond %{HTTPS} on + RewriteRule ^ - [E=protossl:s] + + # Make sure Authorization HTTP header is available to PHP + # even when running as CGI or FastCGI. + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Block access to "hidden" directories whose names begin with a period. This + # includes directories used by version control systems such as Subversion or + # Git to store control files. Files whose names begin with a period, as well + # as the control files used by CVS, are protected by the FilesMatch directive + # above. + # + # NOTE: This only works when mod_rewrite is loaded. Without mod_rewrite, it is + # not possible to block access to entire directories from .htaccess, because + # is not allowed here. + # + # If you do not have mod_rewrite installed, you should remove these + # directories from your webroot or otherwise protect them from being + # downloaded. + RewriteRule "(^|/)\." - [F] + + # If your site can be accessed both with and without the 'www.' prefix, you + # can use one of the following settings to redirect users to your preferred + # URL, either WITH or WITHOUT the 'www.' prefix. Choose ONLY one option: + # + # To redirect all users to access the site WITH the 'www.' prefix, + # (http://example.com/... will be redirected to http://www.example.com/...) + # uncomment the following: + # RewriteCond %{HTTP_HOST} . + # RewriteCond %{HTTP_HOST} !^www\. [NC] + # RewriteRule ^ http%{ENV:protossl}://www.%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + # + # To redirect all users to access the site WITHOUT the 'www.' prefix, + # (http://www.example.com/... will be redirected to http://example.com/...) + # uncomment the following: + # RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + # RewriteRule ^ http%{ENV:protossl}://%1%{REQUEST_URI} [L,R=301] + + # Modify the RewriteBase if you are using Drupal in a subdirectory or in a + # VirtualDocumentRoot and the rewrite rules are not working properly. + # For example if your site is at http://example.com/drupal uncomment and + # modify the following line: + # RewriteBase /drupal + # + # If your site is running in a VirtualDocumentRoot at http://example.com/, + # uncomment the following line: + # RewriteBase / + + # Pass all requests not referring directly to files in the filesystem to + # index.php. Clean URLs are handled in drupal_environment_initialize(). + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} !=/favicon.ico + RewriteRule ^ index.php [L] + + # Rules to correctly serve gzip compressed CSS and JS files. + # Requires both mod_rewrite and mod_headers to be enabled. + + # Serve gzip compressed CSS files if they exist and the client accepts gzip. + RewriteCond %{HTTP:Accept-encoding} gzip + RewriteCond %{REQUEST_FILENAME}\.gz -s + RewriteRule ^(.*)\.css $1\.css\.gz [QSA] + + # Serve gzip compressed JS files if they exist and the client accepts gzip. + RewriteCond %{HTTP:Accept-encoding} gzip + RewriteCond %{REQUEST_FILENAME}\.gz -s + RewriteRule ^(.*)\.js $1\.js\.gz [QSA] + + # Serve correct content types, and prevent mod_deflate double gzip. + RewriteRule .css.gz$ - [T=text/css,E=no-gzip:1] + RewriteRule .js.gz$ - [T=text/javascript,E=no-gzip:1] + + + # Serve correct encoding type. + Header set Content-Encoding gzip + # Force proxies to cache gzipped & non-gzipped css/js files separately. + Header append Vary Accept-Encoding + + + + +# Add headers to all responses. + + # Disable content sniffing, since it's an attack vector. + Header always set X-Content-Type-Options nosniff + diff --git a/tests/apache-conf-files/failing/ipv6-1143.conf b/tests/apache-conf-files/failing/ipv6-1143.conf new file mode 100644 index 000000000..ab4ed412e --- /dev/null +++ b/tests/apache-conf-files/failing/ipv6-1143.conf @@ -0,0 +1,9 @@ + +DocumentRoot /xxxx/ +ServerName noodles.net.nz +ServerAlias www.noodles.net.nz +CustomLog ${APACHE_LOG_DIR}/domlogs/noodles.log combined + + AllowOverride All + + diff --git a/tests/apache-conf-files/failing/ipv6-1143b.conf b/tests/apache-conf-files/failing/ipv6-1143b.conf new file mode 100644 index 000000000..25655a07c --- /dev/null +++ b/tests/apache-conf-files/failing/ipv6-1143b.conf @@ -0,0 +1,21 @@ + + +DocumentRoot /xxxx/ +ServerName noodles.net.nz +ServerAlias www.noodles.net.nz +CustomLog ${APACHE_LOG_DIR}/domlogs/noodles.log combined + + AllowOverride All + + + SSLEngine on + + SSLHonorCipherOrder On + SSLProtocol all -SSLv2 -SSLv3 + SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH +aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS" + + SSLCertificateFile /xxxx/noodles.net.nz.crt + SSLCertificateKeyFile /xxxx/noodles.net.nz.key + + Header set Strict-Transport-Security "max-age=31536000; preload" + diff --git a/tests/apache-conf-files/failing/multivhost-1093.conf b/tests/apache-conf-files/failing/multivhost-1093.conf new file mode 100644 index 000000000..444f0dade --- /dev/null +++ b/tests/apache-conf-files/failing/multivhost-1093.conf @@ -0,0 +1,295 @@ + + AllowOverride None + Require all denied + + + + DocumentRoot /var/www/sjau.ch/web + + ServerName sjau.ch + ServerAlias www.sjau.ch + ServerAdmin webmaster@sjau.ch + + ErrorLog /var/log/ispconfig/httpd/sjau.ch/error.log + + Alias /error/ "/var/www/sjau.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client1/web2/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web2 client1 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web2 client1 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client1/web2/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/sjau.ch/web + + ServerName sjau.ch + ServerAlias www.sjau.ch + ServerAdmin webmaster@sjau.ch + + ErrorLog /var/log/ispconfig/httpd/sjau.ch/error.log + + Alias /error/ "/var/www/sjau.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client1/web2/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web2 client1 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web2/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web2 client1 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client1/web2/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + diff --git a/tests/apache-conf-files/failing/multivhost-1093b.conf b/tests/apache-conf-files/failing/multivhost-1093b.conf new file mode 100644 index 000000000..0388abc2c --- /dev/null +++ b/tests/apache-conf-files/failing/multivhost-1093b.conf @@ -0,0 +1,593 @@ + + AllowOverride None + Require all denied + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + SSLEngine on + SSLProtocol All -SSLv2 -SSLv3 + SSLCertificateFile /var/www/clients/client4/web17/ssl/ensemen.ch.crt + SSLCertificateKeyFile /var/www/clients/client4/web17/ssl/ensemen.ch.key + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + + + DocumentRoot /var/www/ensemen.ch/web + + ServerName ensemen.ch + ServerAlias www.ensemen.ch + ServerAdmin webmaster@ensemen.ch + + ErrorLog /var/log/ispconfig/httpd/ensemen.ch/error.log + + Alias /error/ "/var/www/ensemen.ch/web/error/" + ErrorDocument 400 /error/400.html + ErrorDocument 401 /error/401.html + ErrorDocument 403 /error/403.html + ErrorDocument 404 /error/404.html + ErrorDocument 405 /error/405.html + ErrorDocument 500 /error/500.html + ErrorDocument 502 /error/502.html + ErrorDocument 503 /error/503.html + + + SSLEngine on + SSLProtocol All -SSLv2 -SSLv3 + SSLCertificateFile /var/www/clients/client4/web17/ssl/ensemen.ch.crt + SSLCertificateKeyFile /var/www/clients/client4/web17/ssl/ensemen.ch.key + + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + # Clear PHP settings of this website + + SetHandler None + + Options +FollowSymLinks + AllowOverride All + Require all granted + + + + + Options +ExecCGI + + RubyRequire apache/ruby-run + #RubySafeLevel 0 + AddType text/html .rb + AddType text/html .rbx + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + SetHandler ruby-object + RubyHandler Apache::RubyRun.instance + + + + + + + + SetHandler mod_python + + PythonHandler mod_python.publisher + PythonDebug On + + + + # cgi enabled + + Require all granted + + ScriptAlias /cgi-bin/ /var/www/clients/client4/web17/cgi-bin/ + + SetHandler cgi-script + + # suexec enabled + + SuexecUserGroup web17 client4 + + # php as fast-cgi enabled + # For config options see: http://httpd.apache.org/mod_fcgid/mod/mod_fcgid.html + + IdleTimeout 300 + ProcessLifeTime 3600 + # MaxProcessCount 1000 + DefaultMinClassProcessCount 0 + DefaultMaxClassProcessCount 100 + IPCConnectTimeout 3 + IPCCommTimeout 600 + BusyTimeout 3600 + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + SetHandler fcgid-script + + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php3 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php4 + FCGIWrapper /var/www/php-fcgi-scripts/web17/.php-fcgi-starter .php5 + Options +ExecCGI + AllowOverride All + Require all granted + + + + # add support for apache mpm_itk + + AssignUserId web17 client4 + + + + # Do not execute PHP files in webdav directory + + + SecRuleRemoveById 960015 + SecRuleRemoveById 960032 + + + SetHandler None + + + DavLockDB /var/www/clients/client4/web17/tmp/DavLock + # DO NOT REMOVE THE COMMENTS! + # IF YOU REMOVE THEM, WEBDAV WILL NOT WORK ANYMORE! + # WEBDAV BEGIN + # WEBDAV END + + + + diff --git a/tests/apache-conf-files/passing/README.modules b/tests/apache-conf-files/passing/README.modules new file mode 100644 index 000000000..9c5853061 --- /dev/null +++ b/tests/apache-conf-files/passing/README.modules @@ -0,0 +1,5 @@ +Modules required to parse these conf files: + +ssl +rewrite +macro diff --git a/tests/apache-conf-files/passing/anarcat-1531.conf b/tests/apache-conf-files/passing/anarcat-1531.conf new file mode 100644 index 000000000..73a9b746c --- /dev/null +++ b/tests/apache-conf-files/passing/anarcat-1531.conf @@ -0,0 +1,14 @@ + + ServerAdmin root@localhost + ServerName anarcat.wiki.orangeseeds.org:80 + + + UserDir disabled + + RewriteEngine On + RewriteRule ^/(.*) http\:\/\/anarc\.at\/$1 [L,R,NE] + + ErrorLog /var/log/apache2/1531error.log + LogLevel warn + CustomLog /var/log/apache2/1531access.log combined + diff --git a/tests/apache-conf-files/passing/finalize-1243.apache2.conf.txt b/tests/apache-conf-files/passing/finalize-1243.apache2.conf.txt new file mode 100644 index 000000000..73dc64223 --- /dev/null +++ b/tests/apache-conf-files/passing/finalize-1243.apache2.conf.txt @@ -0,0 +1,222 @@ +# This is the main Apache server configuration file. It contains the +# configuration directives that give the server its instructions. +# See http://httpd.apache.org/docs/2.4/ for detailed information about +# the directives and /usr/share/doc/apache2/README.Debian about Debian specific +# hints. +# +# +# Summary of how the Apache 2 configuration works in Debian: +# The Apache 2 web server configuration in Debian is quite different to +# upstream's suggested way to configure the web server. This is because Debian's +# default Apache2 installation attempts to make adding and removing modules, +# virtual hosts, and extra configuration directives as flexible as possible, in +# order to make automating the changes and administering the server as easy as +# possible. + +# It is split into several files forming the configuration hierarchy outlined +# below, all located in the /etc/apache2/ directory: +# +# /etc/apache2/ +# |-- apache2.conf +# | `-- ports.conf +# |-- mods-enabled +# | |-- *.load +# | `-- *.conf +# |-- conf-enabled +# | `-- *.conf +# `-- sites-enabled +# `-- *.conf +# +# +# * apache2.conf is the main configuration file (this file). It puts the pieces +# together by including all remaining configuration files when starting up the +# web server. +# +# * ports.conf is always included from the main configuration file. It is +# supposed to determine listening ports for incoming connections which can be +# customized anytime. +# +# * Configuration files in the mods-enabled/, conf-enabled/ and sites-enabled/ +# directories contain particular configuration snippets which manage modules, +# global configuration fragments, or virtual host configurations, +# respectively. +# +# They are activated by symlinking available configuration files from their +# respective *-available/ counterparts. These should be managed by using our +# helpers a2enmod/a2dismod, a2ensite/a2dissite and a2enconf/a2disconf. See +# their respective man pages for detailed information. +# +# * The binary is called apache2. Due to the use of environment variables, in +# the default configuration, apache2 needs to be started/stopped with +# /etc/init.d/apache2 or apache2ctl. Calling /usr/bin/apache2 directly will not +# work with the default configuration. + + +# Global configuration +# + +# +# ServerRoot: The top of the directory tree under which the server's +# configuration, error, and log files are kept. +# +# NOTE! If you intend to place this on an NFS (or otherwise network) +# mounted filesystem then please read the Mutex documentation (available +# at ); +# you will save yourself a lot of trouble. +# +# Do NOT add a slash at the end of the directory path. +# +#ServerRoot "/etc/apache2" + +# +# The accept serialization lock file MUST BE STORED ON A LOCAL DISK. +# +Mutex file:${APACHE_LOCK_DIR} default + +# +# PidFile: The file in which the server should record its process +# identification number when it starts. +# This needs to be set in /etc/apache2/envvars +# +PidFile ${APACHE_PID_FILE} + +# +# Timeout: The number of seconds before receives and sends time out. +# +Timeout 300 + +# +# KeepAlive: Whether or not to allow persistent connections (more than +# one request per connection). Set to "Off" to deactivate. +# +KeepAlive On + +# +# MaxKeepAliveRequests: The maximum number of requests to allow +# during a persistent connection. Set to 0 to allow an unlimited amount. +# We recommend you leave this number high, for maximum performance. +# +MaxKeepAliveRequests 100 + +# +# KeepAliveTimeout: Number of seconds to wait for the next request from the +# same client on the same connection. +# +KeepAliveTimeout 5 + + +# These need to be set in /etc/apache2/envvars +User ${APACHE_RUN_USER} +Group ${APACHE_RUN_GROUP} + +# +# HostnameLookups: Log the names of clients or just their IP addresses +# e.g., www.apache.org (on) or 204.62.129.132 (off). +# The default is off because it'd be overall better for the net if people +# had to knowingly turn this feature on, since enabling it means that +# each client request will result in AT LEAST one lookup request to the +# nameserver. +# +HostnameLookups Off + +# ErrorLog: The location of the error log file. +# If you do not specify an ErrorLog directive within a +# container, error messages relating to that virtual host will be +# logged here. If you *do* define an error logfile for a +# container, that host's errors will be logged there and not here. +# +ErrorLog ${APACHE_LOG_DIR}/error.log + +# +# LogLevel: Control the severity of messages logged to the error_log. +# Available values: trace8, ..., trace1, debug, info, notice, warn, +# error, crit, alert, emerg. +# It is also possible to configure the log level for particular modules, e.g. +# "LogLevel info ssl:warn" +# +LogLevel warn + +# Include module configuration: +IncludeOptional mods-enabled/*.load +IncludeOptional mods-enabled/*.conf + +# Include list of ports to listen on +Include ports.conf + + +# Sets the default security model of the Apache2 HTTPD server. It does +# not allow access to the root filesystem outside of /usr/share and /var/www. +# The former is used by web applications packaged in Debian, +# the latter may be used for local directories served by the web server. If +# your system is serving content from a sub-directory in /srv you must allow +# access here, or in any related virtual host. + + Options FollowSymLinks + AllowOverride None + Require all denied + + + + AllowOverride None + Require all granted + + + + Options Indexes FollowSymLinks + AllowOverride None + Require all granted + + +# +# Options Indexes FollowSymLinks +# AllowOverride None +# Require all granted +# + +# AccessFileName: The name of the file to look for in each directory +# for additional configuration directives. See also the AllowOverride +# directive. +# +AccessFileName .htaccess + +# +# The following lines prevent .htaccess and .htpasswd files from being +# viewed by Web clients. +# + + Require all denied + + + +# +# The following directives define some format nicknames for use with +# a CustomLog directive. +# +# These deviate from the Common Log Format definitions in that they use %O +# (the actual bytes sent including headers) instead of %b (the size of the +# requested file), because the latter makes it impossible to detect partial +# requests. +# +# Note that the use of %{X-Forwarded-For}i instead of %h is not recommended. +# Use mod_remoteip instead. +# +#LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined +LogFormat "%t \"%r\" %>s %O \"%{User-Agent}i\"" vhost_combined + +#LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined +#LogFormat "%h %l %u %t \"%r\" %>s %O" common +LogFormat "- %t \"%r\" %>s %b" noip + +LogFormat "%{Referer}i -> %U" referer +LogFormat "%{User-agent}i" agent + +# Include of directories ignores editors' and dpkg's backup files, +# see README.Debian for details. + +# Include generic snippets of statements +IncludeOptional conf-enabled/*.conf + +# Include the virtual host configurations: +#IncludeOptional sites-enabled/*.conf + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/tests/apache-conf-files/passing/finalize-1243.conf b/tests/apache-conf-files/passing/finalize-1243.conf new file mode 100644 index 000000000..0918e5669 --- /dev/null +++ b/tests/apache-conf-files/passing/finalize-1243.conf @@ -0,0 +1,67 @@ +#LoadModule ssl_module modules/mod_ssl.so + +Listen 443 + + # The ServerName directive sets the request scheme, hostname and port that + # the server uses to identify itself. This is used when creating + # redirection URLs. In the context of virtual hosts, the ServerName + # specifies what hostname must appear in the request's Host: header to + # match this virtual host. For the default virtual host (this file) this + # value is not decisive as it is used as a last resort host regardless. + # However, you must set it for any further virtual host explicitly. + ServerName www.eiserneketten.de + + SSLEngine on + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log noip + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + Options FollowSymLinks + AllowOverride None + Order Deny,Allow + #Deny from All + + + Alias / /eiserneketten/pages/eiserneketten.html +SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem +SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key +SSLCertificateChainFile /etc/ssl/certs/ssl-cert-snakeoil.pem +Include /etc/letsencrypt/options-ssl-apache.conf + + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet + +# +# Directives to allow use of AWStats as a CGI +# +Alias /awstatsclasses "/usr/local/awstats/wwwroot/classes/" +Alias /awstatscss "/usr/local/awstats/wwwroot/css/" +Alias /awstatsicons "/usr/local/awstats/wwwroot/icon/" +ScriptAlias /awstats/ "/usr/local/awstats/wwwroot/cgi-bin/" + +# +# This is to permit URL access to scripts/files in AWStats directory. +# + + Options None + AllowOverride None + Order allow,deny + Allow from all + + diff --git a/tests/apache-conf-files/passing/modmacro-1385.conf b/tests/apache-conf-files/passing/modmacro-1385.conf new file mode 100644 index 000000000..d327c9421 --- /dev/null +++ b/tests/apache-conf-files/passing/modmacro-1385.conf @@ -0,0 +1,33 @@ + + + # The ServerName directive sets the request scheme, hostname and port that + # the server uses to identify itself. This is used when creating + # redirection URLs. In the context of virtual hosts, the ServerName + # specifies what hostname must appear in the request's Host: header to + # match this virtual host. For the default virtual host (this file) this + # value is not decisive as it is used as a last resort host regardless. + # However, you must set it for any further virtual host explicitly. + ServerName $host + + ServerAdmin webmaster@localhost + DocumentRoot $dir + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + +Use Vhost goxogle.com 80 /var/www/goxogle/ +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/tests/apache-conf-files/passing/owncloud-1264.conf b/tests/apache-conf-files/passing/owncloud-1264.conf new file mode 100644 index 000000000..d0ac81fa3 --- /dev/null +++ b/tests/apache-conf-files/passing/owncloud-1264.conf @@ -0,0 +1,13 @@ +Alias /owncloud /usr/share/owncloud + + + Options +FollowSymLinks + AllowOverride All + + order allow,deny + allow from all + + = 2.3> + Require all granted + + diff --git a/tests/apache-conf-files/passing/roundcube-1222.conf b/tests/apache-conf-files/passing/roundcube-1222.conf new file mode 100644 index 000000000..72ced7fb3 --- /dev/null +++ b/tests/apache-conf-files/passing/roundcube-1222.conf @@ -0,0 +1,61 @@ +# Those aliases do not work properly with several hosts on your apache server +# Uncomment them to use it or adapt them to your configuration +# Alias /roundcube/program/js/tiny_mce/ /usr/share/tinymce/www/ +# Alias /roundcube /var/lib/roundcube + +# Access to tinymce files + + Options Indexes MultiViews FollowSymLinks + AllowOverride None + = 2.3> + Require all granted + + + Order allow,deny + Allow from all + + + + + Options +FollowSymLinks + # This is needed to parse /var/lib/roundcube/.htaccess. See its + # content before setting AllowOverride to None. + AllowOverride All + = 2.3> + Require all granted + + + Order allow,deny + Allow from all + + + +# Protecting basic directories: + + Options -FollowSymLinks + AllowOverride None + + + + Options -FollowSymLinks + AllowOverride None + = 2.3> + Require all denied + + + Order allow,deny + Deny from all + + + + + Options -FollowSymLinks + AllowOverride None + = 2.3> + Require all denied + + + Order allow,deny + Deny from all + + diff --git a/tests/apache-conf-files/passing/semacode-1598.conf b/tests/apache-conf-files/passing/semacode-1598.conf new file mode 100644 index 000000000..89e2fb25c --- /dev/null +++ b/tests/apache-conf-files/passing/semacode-1598.conf @@ -0,0 +1,44 @@ + + ServerName semacode.com + ServerAlias www.semacode.com + DocumentRoot /tmp/ + TransferLog /tmp/access + ErrorLog /tmp/error + Redirect /posts/rss http://semacode.com/feed + Redirect permanent /weblog http://semacode.com/blog + +#ProxyPreserveHost On +# ProxyPass /past http://old.semacode.com + #ProxyPassReverse /past http://old.semacode.com +# + # Order allow,deny + #Allow from all +# + + Redirect /stylesheets/inside.css http://old.semacode.com/stylesheets/inside.css + RedirectMatch /images/portal/(.*) http://old.semacode.com/images/portal/$1 + Redirect /images/invisible.gif http://old.semacode.com/images/invisible.gif + RedirectMatch /javascripts/(.*) http://old.semacode.com/javascripts/$1 + + RewriteEngine on + RewriteRule ^/past/(.*) http://old.semacode.com/past/$1 [L,P] + RewriteCond %{HTTP_HOST} !^semacode\.com$ [NC] + RewriteCond %{HTTP_HOST} !^$ + RewriteRule ^/(.*) http://semacode.com/$1 [L,R] + + + + + + ServerName old.semacode.com + ServerAlias www.old.semacode.com + DocumentRoot /home/simon/semacode-server/semacode/website/trunk/public + TransferLog /tmp/access-old + ErrorLog /tmp/error-old + + Options FollowSymLinks + AllowOverride None + Order allow,deny + Allow from all + + From 4072ff3e1af6438f66972ccd03c00183619d4586 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Mon, 30 Nov 2015 19:53:22 -0800 Subject: [PATCH 161/181] Run RabbitMQ setup during test setup. This was recently introduced on the Boulder side. Note: long-term we want to have the client tests run the same setup steps as Boulder does, with the same script. This is a quick fix to unbreak the build. --- tests/boulder-fetch.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/boulder-fetch.sh b/tests/boulder-fetch.sh index a2c31b1d9..0d8a3de38 100755 --- a/tests/boulder-fetch.sh +++ b/tests/boulder-fetch.sh @@ -33,6 +33,7 @@ wget https://github.com/jsha/boulder-tools/raw/master/goose.gz && \ zcat goose.gz > $GOPATH/bin/goose && \ chmod +x $GOPATH/bin/goose ./test/create_db.sh +go run cmd/rabbitmq-setup/main.go -server amqp://localhost # listenbuddy is needed for ./start.py go get github.com/jsha/listenbuddy cd - From ffd30d8c1edb9c1e6426e619d86c5a1ef588aa3f Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 30 Nov 2015 20:57:07 -0800 Subject: [PATCH 162/181] lint --- letsencrypt/tests/cli_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 9f6538eb8..b3b55a981 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -343,7 +343,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods def test_parse_webroot(self): plugins = disco.PluginsRegistry.find_all() - webroot_args = ['--webroot', '-w', '/var/www/example', + webroot_args = ['--webroot', '-w', '/var/www/example', '-d', 'example.com,www.example.com', '-w', '/var/www/superfluous', '-d', 'superfluo.us', '-d', 'www.superfluo.us'] namespace = cli.prepare_and_parse_args(plugins, webroot_args) From 6b122a044adc758847f92fb564f822f5bc14bd91 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 30 Nov 2015 21:08:23 -0800 Subject: [PATCH 163/181] Too many lines? (That's probably true) --- letsencrypt/cli.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index b06b288eb..bd95cd372 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -1,5 +1,7 @@ """Let's Encrypt CLI.""" # TODO: Sanity check all input. Be sure to avoid shell code etc... +# pylint: disable=too-many-lines +# (TODO: split this file into main.py and cli.py) import argparse import atexit import functools @@ -384,7 +386,6 @@ def diagnose_configurator_problem(cfg_type, requested, plugins): def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable=too-many-branches """ Figure out which configurator we're going to use - :raises error.PluginSelectionError if there was a problem """ @@ -802,7 +803,6 @@ class HelpfulArgumentParser(object): return dict([(t, t == chosen_topic) for t in self.help_topics]) - def prepare_and_parse_args(plugins, args): """Returns parsed command line arguments. @@ -946,8 +946,7 @@ def _create_subparsers(helpful): help="Set a custom user agent string for the client. User agent strings allow " "the CA to collect high level statistics about success rates by OS and " "plugin. If you wish to hide your server OS version from the Let's " - 'Encrypt server, set this to "".' - ) + 'Encrypt server, set this to "".') helpful.add("certonly", "--csr", type=read_file, help="Path to a Certificate Signing Request (CSR) in DER" From 1e3cca8e5c0d54cd3f76cc4a2b6d51d062d323e8 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 30 Nov 2015 21:09:40 -0800 Subject: [PATCH 164/181] Added simple confs and compatibility-test tarball --- .../testdata/configs.tar.gz | Bin 0 -> 101287 bytes .../passing/example-ssl.conf | 136 ++++++++++++++++++ tests/apache-conf-files/passing/example.conf | 32 +++++ 3 files changed, 168 insertions(+) create mode 100644 letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/configs.tar.gz create mode 100644 tests/apache-conf-files/passing/example-ssl.conf create mode 100644 tests/apache-conf-files/passing/example.conf diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/configs.tar.gz b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/configs.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..7323acf747643887d8883f3ef74437d811032b2d GIT binary patch literal 101287 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

>PD40$S_DOkt_8cFeaIjOMKd6SRvO4frMW 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 YDNSigQ<~D0rhMh{f548beE?_+0L2q{f&c&j literal 0 HcmV?d00001 diff --git a/tests/apache-conf-files/passing/example-ssl.conf b/tests/apache-conf-files/passing/example-ssl.conf new file mode 100644 index 000000000..466ac9ce3 --- /dev/null +++ b/tests/apache-conf-files/passing/example-ssl.conf @@ -0,0 +1,136 @@ + + ServerName example.com + ServerAlias www.example.com + ServerAdmin webmaster@localhost + + DocumentRoot /var/www/html + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + # SSL Engine Switch: + # Enable/Disable SSL for this virtual host. + SSLEngine on + + # A self-signed (snakeoil) certificate can be created by installing + # the ssl-cert package. See + # /usr/share/doc/apache2/README.Debian.gz for more info. + # If both key and certificate are stored in the same file, only the + # SSLCertificateFile directive is needed. + SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem + SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key + + # Server Certificate Chain: + # Point SSLCertificateChainFile at a file containing the + # concatenation of PEM encoded CA certificates which form the + # certificate chain for the server certificate. Alternatively + # the referenced file can be the same as SSLCertificateFile + # when the CA certificates are directly appended to the server + # certificate for convinience. + #SSLCertificateChainFile /etc/apache2/ssl.crt/server-ca.crt + + # Certificate Authority (CA): + # Set the CA certificate verification path where to find CA + # certificates for client authentication or alternatively one + # huge file containing all of them (file must be PEM encoded) + # Note: Inside SSLCACertificatePath you need hash symlinks + # to point to the certificate files. Use the provided + # Makefile to update the hash symlinks after changes. + #SSLCACertificatePath /etc/ssl/certs/ + #SSLCACertificateFile /etc/apache2/ssl.crt/ca-bundle.crt + + # Certificate Revocation Lists (CRL): + # Set the CA revocation path where to find CA CRLs for client + # authentication or alternatively one huge file containing all + # of them (file must be PEM encoded) + # Note: Inside SSLCARevocationPath you need hash symlinks + # to point to the certificate files. Use the provided + # Makefile to update the hash symlinks after changes. + #SSLCARevocationPath /etc/apache2/ssl.crl/ + #SSLCARevocationFile /etc/apache2/ssl.crl/ca-bundle.crl + + # Client Authentication (Type): + # Client certificate verification type and depth. Types are + # none, optional, require and optional_no_ca. Depth is a + # number which specifies how deeply to verify the certificate + # issuer chain before deciding the certificate is not valid. + #SSLVerifyClient require + #SSLVerifyDepth 10 + + # SSL Engine Options: + # Set various options for the SSL engine. + # o FakeBasicAuth: + # Translate the client X.509 into a Basic Authorisation. This means that + # the standard Auth/DBMAuth methods can be used for access control. The + # user name is the `one line' version of the client's X.509 certificate. + # Note that no password is obtained from the user. Every entry in the user + # file needs this password: `xxj31ZMTZzkVA'. + # o ExportCertData: + # This exports two additional environment variables: SSL_CLIENT_CERT and + # SSL_SERVER_CERT. These contain the PEM-encoded certificates of the + # server (always existing) and the client (only existing when client + # authentication is used). This can be used to import the certificates + # into CGI scripts. + # o StdEnvVars: + # This exports the standard SSL/TLS related `SSL_*' environment variables. + # Per default this exportation is switched off for performance reasons, + # because the extraction step is an expensive operation and is usually + # useless for serving static content. So one usually enables the + # exportation for CGI and SSI requests only. + # o OptRenegotiate: + # This enables optimized SSL connection renegotiation handling when SSL + # directives are used in per-directory context. + #SSLOptions +FakeBasicAuth +ExportCertData +StrictRequire + + SSLOptions +StdEnvVars + + + SSLOptions +StdEnvVars + + + # SSL Protocol Adjustments: + # The safe and default but still SSL/TLS standard compliant shutdown + # approach is that mod_ssl sends the close notify alert but doesn't wait for + # the close notify alert from client. When you need a different shutdown + # approach you can use one of the following variables: + # o ssl-unclean-shutdown: + # This forces an unclean shutdown when the connection is closed, i.e. no + # SSL close notify alert is send or allowed to received. This violates + # the SSL/TLS standard but is needed for some brain-dead browsers. Use + # this when you receive I/O errors because of the standard approach where + # mod_ssl sends the close notify alert. + # o ssl-accurate-shutdown: + # This forces an accurate shutdown when the connection is closed, i.e. a + # SSL close notify alert is send and mod_ssl waits for the close notify + # alert of the client. This is 100% SSL/TLS standard compliant, but in + # practice often causes hanging connections with brain-dead browsers. Use + # this only for browsers where you know that their SSL implementation + # works correctly. + # Notice: Most problems of broken clients are also related to the HTTP + # keep-alive facility, so you usually additionally want to disable + # keep-alive for those clients, too. Use variable "nokeepalive" for this. + # Similarly, one has to force some clients to use HTTP/1.0 to workaround + # their broken HTTP/1.1 implementation. Use variables "downgrade-1.0" and + # "force-response-1.0" for this. + BrowserMatch "MSIE [2-6]" \ + nokeepalive ssl-unclean-shutdown \ + downgrade-1.0 force-response-1.0 + # MSIE 7 and newer should be able to use keepalive + BrowserMatch "MSIE [17-9]" ssl-unclean-shutdown + + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/tests/apache-conf-files/passing/example.conf b/tests/apache-conf-files/passing/example.conf new file mode 100644 index 000000000..60bdeead6 --- /dev/null +++ b/tests/apache-conf-files/passing/example.conf @@ -0,0 +1,32 @@ + + # The ServerName directive sets the request scheme, hostname and port that + # the server uses to identify itself. This is used when creating + # redirection URLs. In the context of virtual hosts, the ServerName + # specifies what hostname must appear in the request's Host: header to + # match this virtual host. For the default virtual host (this file) this + # value is not decisive as it is used as a last resort host regardless. + # However, you must set it for any further virtual host explicitly. + ServerName www.example.com + ServerAlias example.com + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html + + # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, + # error, crit, alert, emerg. + # It is also possible to configure the loglevel for particular + # modules, e.g. + #LogLevel info ssl:warn + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # For most configuration files from conf-available/, which are + # enabled or disabled at a global level, it is possible to + # include a line for only one particular virtual host. For example the + # following line enables the CGI configuration for this host only + # after it has been globally disabled with "a2disconf". + #Include conf-available/serve-cgi-bin.conf + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet From 2befb2d5c174b5bfd8c53174ceb12a0ac594ea62 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Mon, 30 Nov 2015 21:27:41 -0800 Subject: [PATCH 165/181] remove python2.7ism --- letsencrypt/tests/cli_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index c90c1b836..e07f5e83c 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -354,8 +354,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods 'superfluo.us': '/var/www/superfluous'}) webroot_args = ['-d', 'stray.example.com'] + webroot_args - with self.assertRaises(errors.Error): - cli.prepare_and_parse_args(plugins, webroot_args) + self.assertRaises(errors.Error, cli.prepare_and_parse_args, plugins, webroot_args) webroot_map_args = ['--webroot-map', '{"eg.com" : "/tmp"}'] namespace = cli.prepare_and_parse_args(plugins, webroot_map_args) From 739fd6614b7e60b3451c41d7cffdc515c55f050f Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Tue, 1 Dec 2015 09:50:13 -0800 Subject: [PATCH 166/181] moved temp check --- letsencrypt-apache/letsencrypt_apache/configurator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index c4305f724..34c64b87b 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -276,11 +276,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "in the Apache config", target_name) raise errors.PluginError("No vhost selected") + elif temp: + return vhost elif not vhost.ssl: addrs = self._get_proposed_addrs(vhost, "443") # TODO: Conflicts is too conservative - if not any(vhost.enabled and vhost.conflicts(addrs) for vhost in self.vhosts)\ - and not temp: + if not any(vhost.enabled and vhost.conflicts(addrs) for vhost in self.vhosts): vhost = self.make_vhost_ssl(vhost) else: logger.error( From e846b157edc45130ec4b34739a1ac5fb0f3e9564 Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Tue, 1 Dec 2015 09:59:45 -0800 Subject: [PATCH 167/181] removed space --- letsencrypt-apache/letsencrypt_apache/configurator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 34c64b87b..b1feca5d5 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -275,7 +275,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "No vhost was selected. Please specify servernames " "in the Apache config", target_name) raise errors.PluginError("No vhost selected") - elif temp: return vhost elif not vhost.ssl: From ab32e2fd26747f8f029af8901d6f2204e5c38a3f Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Tue, 1 Dec 2015 10:46:13 -0800 Subject: [PATCH 168/181] fix docstring --- letsencrypt-apache/letsencrypt_apache/tls_sni_01.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py index ff7972e1d..4284e240c 100644 --- a/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py +++ b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py @@ -110,7 +110,7 @@ class ApacheTlsSni01(common.TLSSNI01): return addrs def _get_addrs(self, achall): - """Return the Apache addresses needed for DVSNI.""" + """Return the Apache addresses needed for TLS-SNI-01.""" vhost = self.configurator.choose_vhost(achall.domain, temp=True) # TODO: Checkout _default_ rules. addrs = set() From d4d51fe4354701ac0726f37ad8560a96ba8af5cd Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 1 Dec 2015 13:17:19 -0800 Subject: [PATCH 169/181] Remove stray reference to init-script --- letsencrypt-apache/letsencrypt_apache/configurator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 319082934..96e310565 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -137,8 +137,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ # Verify Apache is installed - for exe in (self.conf("ctl"), self.conf("enmod"), - self.conf("dismod"), self.conf("init-script")): + for exe in (self.conf("ctl"), self.conf("enmod"), self.conf("dismod")): if not le_util.exe_exists(exe): raise errors.NoInstallationError From 0b7552ef8b9681186d2864a1c7297be1eb3b7daa Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 15:16:17 -0800 Subject: [PATCH 170/181] Begin cleaning up README.md --- README.rst | 75 +++++++++++++++++++++++++++--------------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/README.rst b/README.rst index ce0d1b686..e4dc0896f 100644 --- a/README.rst +++ b/README.rst @@ -3,12 +3,9 @@ Disclaimer ========== -This is a **DEVELOPER PREVIEW** intended for developers and testers only. - -**DO NOT RUN THIS CODE ON A PRODUCTION SERVER. IT WILL INSTALL CERTIFICATES -SIGNED BY A TEST CA, AND WILL CAUSE CERT WARNINGS FOR USERS.** - -Browser-trusted certificates will be available in the coming months. +The Let's Encrypt client is **BETA SOFTWARE**. It contains plenty of bugs and +rough edges, and should be tested thoroughly in staging evironments before use +on production systems. For more information regarding the status of the project, please see https://letsencrypt.org. Be sure to checkout the @@ -17,37 +14,39 @@ https://letsencrypt.org. Be sure to checkout the About the Let's Encrypt Client ============================== +Installation +------------ + +If `letsencrypt` is packaged for your OS, you can install it from there, and +run it by typing `letsencrypt`. Because not all operating systems have +packages yet, we provide a temporary solution via the `letsencrypt-auto` +wrapper script, which obtains some dependencies from your OS and puts others +in an python virtual environment:: + + user@www:~$ git clone https://github.com/letsencrypt/letsencrypt + user@www:~$ cd letsencrypt + user@www:~/letsencrypt$ ./letsencrypt-auto --help + +`letsencrypt-auto` updates to the latest client release automatically. And +since `letsencrypt-auto` is a wrapper to `letsencrypt`, it accepts exactly the +same command line flags and arguments. More details about this script and +other installation methods can be found [in the User +Guide](https://letsencrypt.readthedocs.org/en/latest/using.html#installation) + +Running the client and understanding client plugins +--------------------------------------------------- + +In many cases, you can just run `letsencrypt-auto` or `letsencrypt`, and the +client will guide you through the process of obtaining and installing certs +interactively. + +But to understand what the client is doing in detail, it's important to +understand the way it uses plugins. Please see the [explanation of +plugins](https://letsencrypt.readthedocs.org/en/latest/using.html#plugins) in +the User Guide. + |build-status| |coverage| |docs| |container| -In short: getting and installing SSL/TLS certificates made easy (`watch demo video`_). - -The Let's Encrypt Client is a tool to automatically receive and install -X.509 certificates to enable TLS on servers. The client will -interoperate with the Let's Encrypt CA which will be issuing browser-trusted -certificates for free. - -It's all automated: - -* The tool will prove domain control to the CA and submit a CSR (Certificate - Signing Request). -* If domain control has been proven, a certificate will get issued and the tool - will automatically install it. - -All you need to do to sign a single domain is:: - - user@www:~$ sudo letsencrypt -d www.example.org certonly - -For multiple domains (SAN) use:: - - user@www:~$ sudo letsencrypt -d www.example.org -d example.org certonly - -and if you have a compatible web server (Apache or Nginx), Let's Encrypt can -not only get a new certificate, but also deploy it and configure your -server automatically!:: - - user@www:~$ sudo letsencrypt -d www.example.org run - - **Encrypt ALL the things!** @@ -78,9 +77,11 @@ Current Features * Supports multiple web servers: - - apache/2.x (tested and working on Ubuntu Linux) - - nginx/0.8.48+ (under development) + - apache/2.x (working on Debian 8+ and Ubuntu 12.04+) - standalone (runs its own simple webserver to prove you control a domain) + - webroot (adds files to webroot directories in order to prove control of + domains and obtain certs) + - nginx/0.8.48+ (under development) * The private key is generated locally on your system. * Can talk to the Let's Encrypt (demo) CA or optionally to other ACME From ec28094ae2fecb2ce964ad0ee2f01de075300b54 Mon Sep 17 00:00:00 2001 From: Noah Swartz Date: Tue, 1 Dec 2015 16:28:15 -0800 Subject: [PATCH 171/181] added test for new temp elif --- .../letsencrypt_apache/tests/configurator_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 0b6170e1d..db5f2e340 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -139,6 +139,12 @@ class TwoVhost80Test(util.ApacheTest): self.assertFalse(self.vh_truth[0].ssl) self.assertTrue(chosen_vhost.ssl) + @mock.patch("letsencrypt_apache.display_ops.select_vhost") + def test_choose_vhost_select_vhost_with_temp(self, mock_select): + mock_select.return_value = self.vh_truth[0] + chosen_vhost = self.config.choose_vhost("none.com", temp=True) + self.assertEqual(self.vh_truth[0], chosen_vhost) + @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_select_vhost_conflicting_non_ssl(self, mock_select): mock_select.return_value = self.vh_truth[3] From 06e273413b609883cbd4587f002460c089a77fa4 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 16:33:35 -0800 Subject: [PATCH 172/181] Fix nits and address review comments --- letsencrypt/cli.py | 27 ++++++++++++--------------- letsencrypt/plugins/webroot.py | 8 ++++---- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index bd95cd372..85478132e 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -10,7 +10,6 @@ import logging import logging.handlers import os import pkg_resources -import string import sys import time import traceback @@ -103,7 +102,7 @@ def usage_strings(plugins): def _find_domains(args, installer): - if args.domains is None: + if not args.domains: domains = display_ops.choose_names(installer) else: domains = args.domains @@ -477,7 +476,7 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo def obtain_cert(args, config, plugins): """Authenticate & obtain cert, but do not install it.""" - if args.domains is not None and args.csr is not None: + if args.domains and args.csr is not None: # TODO: --csr could have a priority, when --domains is # supplied, check if CSR matches given domains? return "--domains and --csr are mutually exclusive" @@ -840,7 +839,7 @@ def prepare_and_parse_args(plugins, args): #for subparser in parser_run, parser_auth, parser_install: # subparser.add_argument("domains", nargs="*", metavar="domain") helpful.add(None, "-d", "--domains", "--domain", dest="domains", - metavar="DOMAIN", action=DomainFlagProcessor, + metavar="DOMAIN", action=DomainFlagProcessor, default=[], help="Domain names to apply. For multiple domains you can use " "multiple -d flags or enter a comma separated list of domains " "as a parameter.") @@ -1073,17 +1072,18 @@ class WebrootPathProcessor(argparse.Action): # pylint: disable=missing-docstring Keep a record of --webroot-path / -w flags during processing, so that we know which apply to which -d flags """ - if not config.webroot_path: + if config.webroot_path is None: # first -w flag encountered config.webroot_path = [] # if any --domain flags preceded the first --webroot-path flag, # apply that webroot path to those; subsequent entries in # config.webroot_map are filled in by cli.DomainFlagProcessor if config.domains: - config.webroot_map = dict([(d, webroot) for d in config.domains]) self.domain_before_webroot = True - else: - config.webroot_map = {} + for d in config.domains: + config.webroot_map.setdefault(d, webroot) elif self.domain_before_webroot: + # FIXME if you set domains in a config file, you should get a different error + # here, pointing you to --webroot-map raise errors.Error("If you specify multiple webroot paths, one of " "them must precede all domain flags") config.webroot_path.append(webroot) @@ -1095,15 +1095,12 @@ class DomainFlagProcessor(argparse.Action): # pylint: disable=missing-docstring Process a new -d flag, helping the webroot plugin construct a map of {domain : webrootpath} if -w / --webroot-path is in use """ - if not config.domains: - config.domains = [] - - for d in map(string.strip, domain_arg.split(",")): # pylint: disable=bad-builtin - if d not in config.domains: - config.domains.append(d) + for domain in (d.strip() for d in domain_arg.split(",")): + if domain not in config.domains: + config.domains.append(domain) # Each domain has a webroot_path of the most recent -w flag if config.webroot_path: - config.webroot_map[d] = config.webroot_path[-1] + config.webroot_map[domain] = config.webroot_path[-1] def setup_log_file_handler(args, logfile, fmt): diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 6709d0c87..705f08113 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -15,7 +15,6 @@ from letsencrypt.plugins import common logger = logging.getLogger(__name__) - class Authenticator(common.Plugin): """Webroot Authenticator.""" zope.interface.implements(interfaces.IAuthenticator) @@ -72,9 +71,10 @@ to serve all files under specified web root ({0}).""" return [self._perform_single(achall) for achall in achalls] def _path_for_achall(self, achall): - path = self.full_roots[achall.domain] - if not path: - raise errors.PluginError("Cannot find path {0} for domain: {1}" + try: + path = self.full_roots[achall.domain] + except IndexError: + raise errors.PluginError("Cannot find webroot path for domain: {1}" .format(path, achall.domain)) return os.path.join(path, achall.chall.encode("token")) From f4dd66040351a075bf9a86d9e9b779e6fdf09722 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 16:47:50 -0800 Subject: [PATCH 173/181] Oops! - Finish a partial commit, providing what are perhaps excessively detailed and mystical errors in improbable cases. --- letsencrypt/plugins/webroot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index 705f08113..e63cf31d4 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -74,7 +74,10 @@ to serve all files under specified web root ({0}).""" try: path = self.full_roots[achall.domain] except IndexError: - raise errors.PluginError("Cannot find webroot path for domain: {1}" + raise errors.PluginError("Missing --webroot-path for domain: {1}" + .format(achall.domain)) + if not os.path.exists(path): + raise errors.PluginError("Mysteriously missing path {0} for domain: {1}" .format(path, achall.domain)) return os.path.join(path, achall.chall.encode("token")) From f15c4125d398774111718f94a79bcc1da7481ea5 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 15:19:25 -0800 Subject: [PATCH 174/181] More-or-less-final README.rst --- README.rst | 117 +++++++++++++++++++++++++++------------------ letsencrypt/cli.py | 5 +- 2 files changed, 73 insertions(+), 49 deletions(-) diff --git a/README.rst b/README.rst index e4dc0896f..b124de7f8 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ Disclaimer ========== -The Let's Encrypt client is **BETA SOFTWARE**. It contains plenty of bugs and +The Let's Encrypt Client is **BETA SOFTWARE**. It contains plenty of bugs and rough edges, and should be tested thoroughly in staging evironments before use on production systems. @@ -14,40 +14,87 @@ https://letsencrypt.org. Be sure to checkout the About the Let's Encrypt Client ============================== +The Let's Encrypt Client is a fully-featured, extensible client for the Let's +Encrypt CA (or any other CA that speaks the `ACME +`_ +protocol) that can automate the tasks of obtaining certificates and +configuring webservers to use them. + Installation ------------ -If `letsencrypt` is packaged for your OS, you can install it from there, and -run it by typing `letsencrypt`. Because not all operating systems have -packages yet, we provide a temporary solution via the `letsencrypt-auto` +If ``letsencrypt`` is packaged for your OS, you can install it from there, and +run it by typing ``letsencrypt``. Because not all operating systems have +packages yet, we provide a temporary solution via the ``letsencrypt-auto`` wrapper script, which obtains some dependencies from your OS and puts others in an python virtual environment:: - user@www:~$ git clone https://github.com/letsencrypt/letsencrypt - user@www:~$ cd letsencrypt - user@www:~/letsencrypt$ ./letsencrypt-auto --help + user@webserver:~$ git clone https://github.com/letsencrypt/letsencrypt + user@webserver:~$ cd letsencrypt + user@webserver:~/letsencrypt$ ./letsencrypt-auto --help -`letsencrypt-auto` updates to the latest client release automatically. And -since `letsencrypt-auto` is a wrapper to `letsencrypt`, it accepts exactly the -same command line flags and arguments. More details about this script and -other installation methods can be found [in the User -Guide](https://letsencrypt.readthedocs.org/en/latest/using.html#installation) +Or for full command line help, type:: -Running the client and understanding client plugins ---------------------------------------------------- + ./letsencrypt-auto --help all | less -In many cases, you can just run `letsencrypt-auto` or `letsencrypt`, and the +``letsencrypt-auto`` updates to the latest client release automatically. And +since ``letsencrypt-auto`` is a wrapper to ``letsencrypt``, it accepts exactly +the same command line flags and arguments. More details about this script and +other installation methods can be found `in the User Guide +`_. + +How to run the client +--------------------- + +In many cases, you can just run ``letsencrypt-auto`` or ``letsencrypt``, and the client will guide you through the process of obtaining and installing certs interactively. -But to understand what the client is doing in detail, it's important to -understand the way it uses plugins. Please see the [explanation of -plugins](https://letsencrypt.readthedocs.org/en/latest/using.html#plugins) in +You can also tell it exactly what you want it to do. For instance, if you +want to obtain a cert for ``thing.com``, ``www.thing.com``, and +``otherthing.net``, using the Apache plugin to both obtain and install the +certs, you could do this:: + + ./letsencrypt-auto --apache -d thing.com -d www.thing.com -d otherthing.net + +(The first time you run the command, it will make an account, and ask for an +email and agreement to the Let's Encrypt Subscriber Agreement; you can +automate those with ``--email`` and ``--agree-tos``) + +If you want to use a webserver that doesn't have full plugin support yet, you +can still use "standlone" or "webroot" plugins to obtain a certificate:: + + ./letsencrypt-auto certonly --standalone --email admin@thing.com -d thing.com -d www.thing.com -d otherthing.net + + +Understanding the client in more depth +-------------------------------------- + +To understand what the client is doing in detail, it's important to +understand the way it uses plugins. Please see the `explanation of +plugins `_ in the User Guide. +Links +===== + +Documentation: https://letsencrypt.readthedocs.org + +Software project: https://github.com/letsencrypt/letsencrypt + +Notes for developers: https://letsencrypt.readthedocs.org/en/latest/contributing.html + +Main Website: https://letsencrypt.org/ + +IRC Channel: #letsencrypt on `Freenode`_ + +Community: https://community.letsencrypt.org + +Mailing list: `client-dev`_ (to subscribe without a Google account, send an +email to client-dev+subscribe@letsencrypt.org) + |build-status| |coverage| |docs| |container| -**Encrypt ALL the things!** .. |build-status| image:: https://travis-ci.org/letsencrypt/letsencrypt.svg?branch=master @@ -73,7 +120,7 @@ the User Guide. Current Features ----------------- +================ * Supports multiple web servers: @@ -84,7 +131,7 @@ Current Features - nginx/0.8.48+ (under development) * The private key is generated locally on your system. -* Can talk to the Let's Encrypt (demo) CA or optionally to other ACME +* Can talk to the Let's Encrypt CA or optionally to other ACME compliant services. * Can get domain-validated (DV) certificates. * Can revoke certificates. @@ -93,34 +140,10 @@ Current Features runs https only (Apache only) * Fully automated. * Configuration changes are logged and can be reverted. -* Text and ncurses UI. +* Supports ncurses and text (-t) UI, or can be driven entirely from the + command line. * Free and Open Source Software, made with Python. -Installation Instructions -------------------------- - -Official **documentation**, including `installation instructions`_, is -available at https://letsencrypt.readthedocs.org. - - -Links ------ - -Documentation: https://letsencrypt.readthedocs.org - -Software project: https://github.com/letsencrypt/letsencrypt - -Notes for developers: https://letsencrypt.readthedocs.org/en/latest/contributing.html - -Main Website: https://letsencrypt.org/ - -IRC Channel: #letsencrypt on `Freenode`_ - -Community: https://community.letsencrypt.org - -Mailing list: `client-dev`_ (to subscribe without a Google account, send an -email to client-dev+subscribe@letsencrypt.org) - .. _Freenode: https://freenode.net .. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 2fae5fe3e..2b2e62262 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -59,6 +59,7 @@ the cert. Major SUBCOMMANDS are: revoke Revoke a previously obtained certificate rollback Rollback server configuration changes made during install config_changes Show changes made to server config during installation + plugins Display information about installed plugins """ @@ -71,7 +72,7 @@ USAGE = SHORT_USAGE + """Choice of server plugins for obtaining and installing c %s --webroot Place files in a server's webroot folder for authentication -OR use different servers to obtain (authenticate) the cert and then install it: +OR use different plugins to obtain (authenticate) the cert and then install it: --authenticator standalone --installer apache @@ -1041,7 +1042,7 @@ def _plugins_parsing(helpful, plugins): helpful.add_group( "plugins", description="Let's Encrypt client supports an " "extensible plugins architecture. See '%(prog)s plugins' for a " - "list of all available plugins and their names. You can force " + "list of all installed plugins and their names. You can force " "a particular plugin by setting options provided below. Further " "down this help message you will find plugin-specific options " "(prefixed by --{plugin_name}).") From 49efc489fc16e94b9ccbe2f8c5b9d878dd106fe7 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 17:49:49 -0800 Subject: [PATCH 175/181] fixes --- README.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index b124de7f8..be5f04671 100644 --- a/README.rst +++ b/README.rst @@ -50,10 +50,10 @@ In many cases, you can just run ``letsencrypt-auto`` or ``letsencrypt``, and the client will guide you through the process of obtaining and installing certs interactively. -You can also tell it exactly what you want it to do. For instance, if you -want to obtain a cert for ``thing.com``, ``www.thing.com``, and -``otherthing.net``, using the Apache plugin to both obtain and install the -certs, you could do this:: +You can also tell it exactly what you want it to do from the command line. +For instance, if you want to obtain a cert for ``thing.com``, +``www.thing.com``, and ``otherthing.net``, using the Apache plugin to both +obtain and install the certs, you could do this:: ./letsencrypt-auto --apache -d thing.com -d www.thing.com -d otherthing.net @@ -62,7 +62,7 @@ email and agreement to the Let's Encrypt Subscriber Agreement; you can automate those with ``--email`` and ``--agree-tos``) If you want to use a webserver that doesn't have full plugin support yet, you -can still use "standlone" or "webroot" plugins to obtain a certificate:: +can still use "standalone" or "webroot" plugins to obtain a certificate:: ./letsencrypt-auto certonly --standalone --email admin@thing.com -d thing.com -d www.thing.com -d otherthing.net From e27e891615e518d30c3cfcd3b3163f0f01f13ab7 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 17:50:46 -0800 Subject: [PATCH 176/181] nginx detail --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index be5f04671..99ef68ad7 100644 --- a/README.rst +++ b/README.rst @@ -128,7 +128,7 @@ Current Features - standalone (runs its own simple webserver to prove you control a domain) - webroot (adds files to webroot directories in order to prove control of domains and obtain certs) - - nginx/0.8.48+ (under development) + - nginx/0.8.48+ (highly experimental, not included in letsencrypt-auto) * The private key is generated locally on your system. * Can talk to the Let's Encrypt CA or optionally to other ACME From 1a4dd56f71a34b632da350e850177723d1b61687 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 18:13:38 -0800 Subject: [PATCH 177/181] Address review comments (sometimes less less is more) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 99ef68ad7..f38b09cfd 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ Disclaimer ========== The Let's Encrypt Client is **BETA SOFTWARE**. It contains plenty of bugs and -rough edges, and should be tested thoroughly in staging evironments before use +rough edges, and should be tested thoroughly in staging environments before use on production systems. For more information regarding the status of the project, please see @@ -35,7 +35,7 @@ in an python virtual environment:: Or for full command line help, type:: - ./letsencrypt-auto --help all | less + ./letsencrypt-auto --help all ``letsencrypt-auto`` updates to the latest client release automatically. And since ``letsencrypt-auto`` is a wrapper to ``letsencrypt``, it accepts exactly From cf807eaf60c4b7ae7eee4ac0d9b8ba4e152462a0 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 18:22:05 -0800 Subject: [PATCH 178/181] Make the ancient python error more friendly --- letsencrypt-auto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt-auto b/letsencrypt-auto index e9b7739d2..c88028b72 100755 --- a/letsencrypt-auto +++ b/letsencrypt-auto @@ -104,7 +104,7 @@ DeterminePythonVersion() { ExperimentalBootstrap "Python 2.6" elif [ $PYVER -lt 26 ] ; then echo "You have an ancient version of Python entombed in your operating system..." - echo "This isn't going to work." + echo "This isn't going to work; you'll need at least version 2.6." exit 1 fi } From 02d93e995a6d6a845282d240ef3c344a33eab7c8 Mon Sep 17 00:00:00 2001 From: Peter Eckersley Date: Tue, 1 Dec 2015 19:24:14 -0800 Subject: [PATCH 179/181] lint --- letsencrypt/plugins/webroot_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index 862921d1d..e7f96b50d 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -74,7 +74,7 @@ class AuthenticatorTest(unittest.TestCase): # Remove exec bit from permission check, so that it # matches the file - responses = self.auth.perform([self.achall]) + self.auth.perform([self.achall]) parent_permissions = (stat.S_IMODE(os.stat(self.path).st_mode) & ~stat.S_IEXEC) From a65641eb858f786bbdb21ede7e1c96871ec6a879 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 1 Dec 2015 19:26:55 -0800 Subject: [PATCH 180/181] Use GPG_TTY --- tools/dev-release.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/dev-release.sh b/tools/dev-release.sh index bd86bff44..9ce26bebe 100755 --- a/tools/dev-release.sh +++ b/tools/dev-release.sh @@ -1,6 +1,9 @@ #!/bin/sh -xe # Release dev packages to PyPI +# Needed to fix problems with git signatures and pinentry +export GPG_TTY=$(tty) + version="0.0.0.dev$(date +%Y%m%d)" DEV_RELEASE_BRANCH="dev-release" # TODO: create a real release key instead of using Kuba's personal one From 77dd30614a9f22bf7e9434084e6912b151acce26 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 1 Dec 2015 19:28:42 -0800 Subject: [PATCH 181/181] Use airgapped key --- tools/dev-release.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/dev-release.sh b/tools/dev-release.sh index 9ce26bebe..d8c720559 100755 --- a/tools/dev-release.sh +++ b/tools/dev-release.sh @@ -6,8 +6,7 @@ export GPG_TTY=$(tty) version="0.0.0.dev$(date +%Y%m%d)" DEV_RELEASE_BRANCH="dev-release" -# TODO: create a real release key instead of using Kuba's personal one -RELEASE_GPG_KEY="${RELEASE_GPG_KEY:-148C30F6F7E429337A72D992B00B9CC82D7ADF2C}" +RELEASE_GPG_KEY=A2CFB51FA275A7286234E7B24D17C995CD9775F2 # port for a local Python Package Index (used in testing) PORT=${PORT:-1234}