diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 976d7ab12..1e456d325 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. @@ -266,17 +269,11 @@ 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 - - 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 @@ -288,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 c4f3d6c61..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,24 +112,26 @@ 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())) @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( + 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)) + 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_connection_error(self, mock_get): 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/docs/using.rst b/docs/using.rst index 3f04fc5fa..d6ae2c5ee 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -108,8 +108,7 @@ Operating System Packages .. code-block:: shell - sudo pacman -S letsencrypt letsencrypt-nginx letsencrypt-apache \ - letshelp-letsencrypt + sudo pacman -S letsencrypt letsencrypt-apache **Other Operating Systems** @@ -184,7 +183,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 +199,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 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..2c11a3394 --- /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 c811501a9..5777d204d 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 @@ -22,7 +23,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 @@ -96,7 +97,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"], @@ -121,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, + "ensure-http-header": self._set_http_header} @property def mod_ssl_conf(self): @@ -182,6 +184,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 @@ -205,16 +208,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 - 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) + 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 chain_path is not None: + self.parser.add_dir(vhost.path, + "SSLCertificateChainFile", chain_path) else: - self.aug.set(path["chain_path"][-1], chain_path) + 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\ + 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" @@ -222,7 +236,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "\tSSLCertificateKeyFile %s\n" % (vhost.filep, ", ".join(str(addr) for addr in vhost.addrs), - cert_path, key_path)) + set_cert_path, key_path)) if chain_path is not None: self.save_notes += "\tSSLCertificateChainFile %s\n" % chain_path @@ -433,6 +447,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) @@ -586,7 +606,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] @@ -662,6 +683,25 @@ 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: + 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", "insert_cert_file_path") @@ -700,7 +740,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ############################################################################ def supported_enhancements(self): # pylint: disable=no-self-use """Returns currently supported enhancements.""" - return ["redirect"] + return ["redirect", "ensure-http-header"] def enhance(self, domain, enhancement, options=None): """Enhance configuration. @@ -727,6 +767,73 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.warn("Failed %s for %s", enhancement, domain) raise + def _set_http_header(self, ssl_vhost, header_substring): + """Enables header that is identified by header_substring on ssl_vhost. + + 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_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_substring. + + """ + if "headers_module" not in self.parser.modules: + self.enable_mod("headers") + + # Check if selected header is already set + self._verify_no_matching_http_header(ssl_vhost, header_substring) + + # Add directives to server + self.parser.add_dir(ssl_vhost.path, "Header", + constants.HEADER_ARGS[header_substring]) + + self.save_notes += ("Adding %s header to ssl vhost in %s\n" % + (header_substring, ssl_vhost.filep)) + + self.save() + logger.info("Adding %s header to ssl vhost in %s", header_substring, + ssl_vhost.filep) + + 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. + + :param ssl_vhost: vhost to check + :type vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + + :param header_substring: string that uniquely identifies a header. + e.g: Strict-Transport-Security, Upgrade-Insecure-Requests. + :type str + + :returns: boolean + :rtype: (bool) + + :raises errors.PluginEnhancementAlreadyPresent 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" + pat = '(?:[ "]|^)(%s)(?:[ "]|$)' % (header_substring.lower()) + for match in header_path: + if re.search(pat, self.aug.get(match).lower()): + raise errors.PluginEnhancementAlreadyPresent( + "Existing %s header" % (header_substring)) + def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. @@ -796,8 +903,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) @@ -814,7 +925,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): @@ -974,7 +1086,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 +1121,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 +1163,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 +1186,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. @@ -1148,26 +1260,30 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ self._chall_out.update(achalls) responses = [None] * len(achalls) - apache_dvsni = dvsni.ApacheDvsni(self) + chall_doer = tls_sni_01.ApacheTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have dvsni 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) + # 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 = apache_dvsni.perform() + sni_response = chall_doer.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() + # 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. for i, resp in enumerate(sni_response): - responses[apache_dvsni.indices[i]] = resp + responses[chall_doer.indices[i]] = resp return responses @@ -1201,42 +1317,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): diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index 1c17eacc3..813eae582 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -27,3 +27,15 @@ 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""" + +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 0350a32ec..0b6170e1d 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): @@ -236,6 +236,64 @@ 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_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")) + + 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") @@ -351,7 +409,66 @@ 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 + 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" + for _ in range(10): + self.config.parser.add_dir(self.vh_truth[1].path, DIRECTIVE, ["bar"]) + self.config.save() + + self.config._deduplicate_directives(self.vh_truth[1].path, [DIRECTIVE]) + self.config.save() + + 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( + 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"]) @@ -380,23 +497,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) @@ -480,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) @@ -513,6 +630,84 @@ 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_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", "ensure-http-header", + "Strict-Transport-Security") + + 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) + + def test_http_header_hsts_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", "ensure-http-header", + "Strict-Transport-Security") + + self.assertRaises( + errors.PluginEnhancementAlreadyPresent, + self.config.enhance, "encryption-example.demo", "ensure-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", "ensure-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", "ensure-http-header", + "Upgrade-Insecure-Requests") + + self.assertRaises( + errors.PluginEnhancementAlreadyPresent, + self.config.enhance, "encryption-example.demo", "ensure-http-header", + "Upgrade-Insecure-Requests") + + + @mock.patch("letsencrypt.le_util.run_script") @mock.patch("letsencrypt.le_util.exe_exists") def test_redirect_well_formed_http(self, mock_exe, _): @@ -553,7 +748,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): @@ -593,7 +788,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/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/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 diff --git a/letsencrypt-apache/letsencrypt_apache/dvsni.py b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py similarity index 77% rename from letsencrypt-apache/letsencrypt_apache/dvsni.py rename to letsencrypt-apache/letsencrypt_apache/tls_sni_01.py index 2f9e9ed18..e1a7d2d53 100644 --- a/letsencrypt-apache/letsencrypt_apache/dvsni.py +++ b/letsencrypt-apache/letsencrypt_apache/tls_sni_01.py @@ -1,4 +1,5 @@ -"""ApacheDVSNI""" +"""A class that performs TLS-SNI-01 challenges 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-auto b/letsencrypt-auto index a3009fe52..083de58c4 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-apache" + 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 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..f9f584b0c --- /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..aaaf43c5f 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 @@ -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) ###################################### @@ -573,15 +574,15 @@ class NginxConfigurator(common.Plugin): """ self._chall_out += len(achalls) responses = [None] * len(achalls) - nginx_dvsni = dvsni.NginxDvsni(self) + chall_doer = tls_sni_01.NginxTlsSni01(self) for i, achall in enumerate(achalls): - # Currently also have dvsni 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) + # 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 = nginx_dvsni.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() @@ -590,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[nginx_dvsni.indices[i]] = resp + responses[chall_doer.indices[i]] = resp return responses diff --git a/letsencrypt-nginx/letsencrypt_nginx/parser.py b/letsencrypt-nginx/letsencrypt_nginx/parser.py index e5c6be0e6..14db2f8b7 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/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/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-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..e59281c4c 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tls_sni_01.py @@ -1,4 +1,5 @@ -"""NginxDVSNI""" +"""A class that performs TLS-SNI-01 challenges 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/cli.py b/letsencrypt/cli.py index bd947e191..2fae5fe3e 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -379,7 +379,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 @@ -463,7 +463,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, config) if len(lineage.available_versions("cert")) == 1: display_ops.success_installation(domains) @@ -517,7 +517,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, config) def revoke(args, config, unused_plugins): # TODO: coop with renewal config @@ -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 @@ -923,6 +923,25 @@ 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", default=False) + helpful.add( + "security", "--no-hsts", action="store_false", + 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", + 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=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 e8cd71d6d..f7010e09d 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -383,57 +383,86 @@ 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, redirect=None): + def enhance_config(self, domains, config): """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 - :param redirect: If traffic should be forwarded from HTTP to HTTPS. - :type redirect: bool or None + :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 client. """ + 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 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") - # 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): - """Redirect all traffic from HTTP to HTTPS + if hsts: + self.apply_enhancement(domains, "ensure-http-header", + "Strict-Transport-Security") + if uir: + self.apply_enhancement(domains, "ensure-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() + + def apply_enhancement(self, domains, enhancement, options=None): + """Applies an enhacement on all domains. + + :param domains: list of ssl_vhosts + :type list of str + + :param enhancement: name of enhancement, e.g. ensure-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 + + :raises .errors.PluginError: If Enhancement is not supported, or if + there is any other problem with the enhancement. - :param vhost: list of ssl_vhosts - :type vhost: :class:`letsencrypt.interfaces.IInstaller` """ - 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: - self.installer.enhance(dom, "redirect") + self.installer.enhance(dom, enhancement, options) + except errors.PluginEnhancementAlreadyPresent: + logger.warn("Enhancement %s was already set.", + 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") - - with error_handler.ErrorHandler(self._rollback_and_restart, msg): - self.installer.restart() + self.installer.save("Add enhancement %s" % (enhancement)) def _recovery_routine_with_msg(self, success_msg): """Calls the installer's recovery routine and prints success_msg 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""" diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 93daa90ff..f18b1fb3b 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 challenge performers""" def __init__(self, configurator): self.configurator = configurator diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py index 72c5bc259..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.""" @@ -90,6 +87,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 @@ -140,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 @@ -164,14 +163,15 @@ 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, 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/manual_test.py b/letsencrypt/plugins/manual_test.py index 5bde1f909..e16fadd13 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)] 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 diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 52be94f68..7e2802b14 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -1,5 +1,6 @@ """Renewable certificates storage.""" import datetime +import logging import os import re @@ -13,6 +14,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") @@ -136,14 +139,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)): - 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 path.", 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)): - 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) @@ -157,16 +163,26 @@ 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's link does not point within the " + "cert lineage's directory within the " + "official archive directory. Link: %s, " + "target directory: %s, " + "archive directory: %s.", + 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 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("%s does not follow the archive naming " + "convention.", target) return False # It is NOT required that the link's target be a regular @@ -251,6 +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("Expected symlink %s for %s does not exist.", + link, kind) return None target = os.readlink(link) if not os.path.isabs(target): @@ -275,11 +293,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("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 for target %s.", kind) return None def version(self, kind, version): @@ -529,6 +550,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 @@ -537,6 +559,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 " + "has been expired since %s.", + expiry.strftime("%Y-%m-%d %H:%M:%S %Z")) return True return False @@ -588,6 +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 directory %s.", i) config_file, config_filename = le_util.unique_lineage_name( cli_config.renewal_configs_dir, lineagename) if not config_filename.endswith(".conf"): @@ -608,6 +634,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 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 @@ -617,15 +645,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 to %s.", target["cert"]) f.write(cert) with open(target["privkey"], "w") as f: + 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 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 to %s.", target["fullchain"]) f.write(cert + chain) # Document what we've done in a new renewal config file @@ -640,6 +672,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 %s.", config_filename) new_config.write() return cls(new_config.filename, cli_config) @@ -690,16 +723,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 to %s.", target["privkey"]) f.write(new_privkey) # Save everything else with open(target["cert"], "w") as f: + logger.debug("Writing certificate to %s.", target["cert"]) f.write(new_cert) with open(target["chain"], "w") as f: + logger.debug("Writing chain to %s.", target["chain"]) f.write(new_chain) with open(target["fullchain"], "w") as f: + logger.debug("Writing full chain to %s.", target["fullchain"]) f.write(new_cert + new_chain) return target_version diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index d53b4700a..e512668c5 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): diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 160dd55c1..578cd77ab 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.""" @@ -224,21 +233,50 @@ 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"]) - installer.enhance.assert_called_once_with("foo.bar", "redirect") + 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() - def test_enhance_config_no_installer(self): + @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() + 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", "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", "ensure-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): + config = ConfigHelper(redirect=True, hsts=False, uir=False) + self.assertRaises(errors.Error, + self.client.enhance_config, ["foo.bar"], config) @mock.patch("letsencrypt.client.zope.component.getUtility") @mock.patch("letsencrypt.client.enhancements") @@ -249,8 +287,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) @@ -263,8 +303,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) @@ -277,8 +319,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) @@ -293,8 +338,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)