merge from master

This commit is contained in:
Noah Swartz
2015-12-01 10:44:01 -08:00
67 changed files with 2722 additions and 417 deletions
@@ -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/
@@ -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
@@ -441,6 +455,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 <VirtualHost>
# 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)
@@ -594,7 +614,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]
@@ -670,6 +691,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")
@@ -708,7 +748,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.
@@ -735,6 +775,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.
@@ -804,8 +911,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)
@@ -822,7 +933,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):
@@ -982,7 +1094,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.
@@ -1017,7 +1129,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.
@@ -1059,7 +1171,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):
@@ -1082,16 +1194,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.
@@ -1156,26 +1268,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
@@ -1209,42 +1325,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):
@@ -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}
@@ -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."""
@@ -0,0 +1,36 @@
<IfModule mod_ssl.c>
<VirtualHost _default_:443>
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
<FilesMatch "\.(cgi|shtml|phtml|php)$">
SSLOptions +StdEnvVars
</FilesMatch>
<Directory /usr/lib/cgi-bin>
SSLOptions +StdEnvVars
</Directory>
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
</VirtualHost>
</IfModule>
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
@@ -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__":
@@ -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
@@ -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 = "<IfModule mod_ssl.c>\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,29 @@ 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):
def _get_addrs(self, achall):
"""Return the Apache addresses needed for DVSNI."""
vhost = self.configurator.choose_vhost(achall.domain, temp=True)
# 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 +146,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 +157,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