1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-21 19:01:07 +03:00

Merge remote-tracking branch 'origin/master' into webroot

This commit is contained in:
Peter Eckersley
2015-11-30 20:57:27 -08:00
42 changed files with 902 additions and 376 deletions

View File

@@ -2,7 +2,7 @@ Let's Encrypt Python Client
Copyright (c) Electronic Frontier Foundation and others
Licensed Apache Version 2.0
Incorporating code from nginxparser
The nginx plugin incorporates code from nginxparser
Copyright (c) 2014 Fatih Erikli
Licensed MIT

View File

@@ -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."""

View File

@@ -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):

View File

@@ -246,9 +246,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes
def retry_after(cls, response, default):
"""Compute next `poll` time based on response ``Retry-After`` header.
:param response: Response from `poll`.
:type response: `requests.Response`
:param requests.Response response: Response from `poll`.
:param int default: Default value (in seconds), used when
``Retry-After`` header is not present or invalid.
@@ -323,22 +321,21 @@ class Client(object): # pylint: disable=too-many-instance-attributes
body=jose.ComparableX509(OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_ASN1, response.content)))
def poll_and_request_issuance(self, csr, authzrs, mintime=5):
def poll_and_request_issuance(
self, csr, authzrs, mintime=5, max_attempts=10):
"""Poll and request issuance.
This function polls all provided Authorization Resource URIs
until all challenges are valid, respecting ``Retry-After`` HTTP
headers, and then calls `request_issuance`.
.. todo:: add `max_attempts` or `timeout`
:param csr: CSR.
:type csr: `OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
:param .ComparableX509 csr: CSR (`OpenSSL.crypto.X509Req`
wrapped in `.ComparableX509`)
:param authzrs: `list` of `.AuthorizationResource`
:param int mintime: Minimum time before next attempt, used if
``Retry-After`` is not present in the response.
:param int max_attempts: Maximum number of attempts before
`PollError` with non-empty ``waiting`` is raised.
:returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is
the issued certificate (`.messages.CertificateResource.),
@@ -348,6 +345,9 @@ class Client(object): # pylint: disable=too-many-instance-attributes
as the input ``authzrs``.
:rtype: `tuple`
:raises PollError: in case of timeout or if some authorization
was marked by the CA as invalid
"""
# priority queue with datetime (based on Retry-After) as key,
# and original Authorization Resource as value
@@ -356,7 +356,8 @@ class Client(object): # pylint: disable=too-many-instance-attributes
# recently updated one
updated = dict((authzr, authzr) for authzr in authzrs)
while waiting:
while waiting and max_attempts:
max_attempts -= 1
# find the smallest Retry-After, and sleep if necessary
when, authzr = heapq.heappop(waiting)
now = datetime.datetime.now()
@@ -371,11 +372,16 @@ class Client(object): # pylint: disable=too-many-instance-attributes
updated[authzr] = updated_authzr
# pylint: disable=no-member
if updated_authzr.body.status != messages.STATUS_VALID:
if updated_authzr.body.status not in (
messages.STATUS_VALID, messages.STATUS_INVALID):
# push back to the priority queue, with updated retry_after
heapq.heappush(waiting, (self.retry_after(
response, default=mintime), authzr))
if not max_attempts or any(authzr.body.status == messages.STATUS_INVALID
for authzr in six.itervalues(updated)):
raise errors.PollError(waiting, updated)
updated_authzrs = tuple(updated[authzr] for authzr in authzrs)
return self.request_issuance(csr, updated_authzrs), updated_authzrs

View File

@@ -271,9 +271,9 @@ class ClientTest(unittest.TestCase):
# result, increment clock
clock.dt += datetime.timedelta(seconds=2)
if not authzr.retries: # no more retries
if len(authzr.retries) == 1: # no more retries
done = mock.MagicMock(uri=authzr.uri, times=authzr.times)
done.body.status = messages.STATUS_VALID
done.body.status = authzr.retries[0]
return done, []
# response (2nd result tuple element) is reduced to only
@@ -289,7 +289,8 @@ class ClientTest(unittest.TestCase):
mintime = 7
def retry_after(response, default): # pylint: disable=missing-docstring
def retry_after(response, default):
# pylint: disable=missing-docstring
# check that poll_and_request_issuance correctly passes mintime
self.assertEqual(default, mintime)
return clock.dt + datetime.timedelta(seconds=response)
@@ -302,8 +303,10 @@ class ClientTest(unittest.TestCase):
csr = mock.MagicMock()
authzrs = (
mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)),
mock.MagicMock(uri='b', times=[], retries=(5,)),
mock.MagicMock(uri='a', times=[], retries=(
8, 20, 30, messages.STATUS_VALID)),
mock.MagicMock(uri='b', times=[], retries=(
5, messages.STATUS_VALID)),
)
cert, updated_authzrs = self.client.poll_and_request_issuance(
@@ -327,6 +330,17 @@ class ClientTest(unittest.TestCase):
])
self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7))
# CA sets invalid | TODO: move to a separate test
invalid_authzr = mock.MagicMock(times=[], retries=[messages.STATUS_INVALID])
self.assertRaises(
errors.PollError, self.client.poll_and_request_issuance,
csr, authzrs=(invalid_authzr,), mintime=mintime)
# exceeded max_attemps | TODO: move to a separate test
self.assertRaises(
errors.PollError, self.client.poll_and_request_issuance,
csr, authzrs, mintime=mintime, max_attempts=2)
def test_check_cert(self):
self.response.headers['Location'] = self.certr.uri
self.response.content = CERT_DER

View File

@@ -51,3 +51,31 @@ class MissingNonce(NonceError):
return ('Server {0} response did not include a replay '
'nonce, headers: {1}'.format(
self.response.request.method, self.response.headers))
class PollError(ClientError):
"""Generic error when polling for authorization fails.
This might be caused by either timeout (`waiting` will be non-empty)
or by some authorization being invalid.
:ivar waiting: Priority queue with `datetime.datatime` (based on
``Retry-After``) as key, and original `.AuthorizationResource`
as value.
:ivar updated: Mapping from original `.AuthorizationResource`
to the most recently updated one
"""
def __init__(self, waiting, updated):
self.waiting = waiting
self.updated = updated
super(PollError, self).__init__()
@property
def timeout(self):
"""Was the error caused by timeout?"""
return bool(self.waiting)
def __repr__(self):
return '{0}(waiting={1!r}, updated={2!r})'.format(
self.__class__.__name__, self.waiting, self.updated)

View File

@@ -1,4 +1,5 @@
"""Tests for acme.errors."""
import datetime
import unittest
import mock
@@ -29,5 +30,25 @@ class MissingNonceTest(unittest.TestCase):
self.assertTrue("{}" in str(self.error))
class PollErrorTest(unittest.TestCase):
"""Tests for acme.errors.PollError."""
def setUp(self):
from acme.errors import PollError
self.timeout = PollError(
waiting=[(datetime.datetime(2015, 11, 29), mock.sentinel.AR)],
updated={})
self.invalid = PollError(waiting=[], updated={
mock.sentinel.AR: mock.sentinel.AR2})
def test_timeout(self):
self.assertTrue(self.timeout.timeout)
self.assertFalse(self.invalid.timeout)
def test_repr(self):
self.assertEqual('PollError(waiting=[], updated={sentinel.AR: '
'sentinel.AR2})', repr(self.invalid))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View File

@@ -2,12 +2,13 @@
import collections
from acme import challenges
from acme import errors
from acme import fields
from acme import jose
from acme import util
class Error(jose.JSONObjectWithFields, Exception):
class Error(jose.JSONObjectWithFields, errors.Error):
"""ACME error.
https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
@@ -17,55 +18,40 @@ class Error(jose.JSONObjectWithFields, Exception):
:ivar unicode detail:
"""
ERROR_TYPE_NAMESPACE = 'urn:acme:error:'
ERROR_TYPE_DESCRIPTIONS = {
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
'badNonce': 'The client sent an unacceptable anti-replay nonce',
'connection': 'The server could not connect to the client for DV',
'dnssec': 'The server could not validate a DNSSEC signed domain',
'malformed': 'The request message was malformed',
'rateLimited': 'There were too many requests of a given type',
'serverInternal': 'The server experienced an internal error',
'tls': 'The server experienced a TLS error during DV',
'unauthorized': 'The client lacks sufficient authorization',
'unknownHost': 'The server could not resolve a domain name',
}
ERROR_TYPE_DESCRIPTIONS = dict(
('urn:acme:error:' + name, description) for name, description in (
('badCSR', 'The CSR is unacceptable (e.g., due to a short key)'),
('badNonce', 'The client sent an unacceptable anti-replay nonce'),
('connection', 'The server could not connect to the client for DV'),
('dnssec', 'The server could not validate a DNSSEC signed domain'),
('malformed', 'The request message was malformed'),
('rateLimited', 'There were too many requests of a given type'),
('serverInternal', 'The server experienced an internal error'),
('tls', 'The server experienced a TLS error during DV'),
('unauthorized', 'The client lacks sufficient authorization'),
('unknownHost', 'The server could not resolve a domain name'),
)
)
typ = jose.Field('type')
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail')
@typ.encoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
return Error.ERROR_TYPE_NAMESPACE + value
@typ.decoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
# pylint thinks isinstance(value, Error), so startswith is not found
# pylint: disable=no-member
if not value.startswith(Error.ERROR_TYPE_NAMESPACE):
raise jose.DeserializationError('Missing error type prefix')
without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):]
if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS:
raise jose.DeserializationError('Error type not recognized')
return without_prefix
@property
def description(self):
"""Hardcoded error description based on its type.
:returns: Description if standard ACME error or ``None``.
:rtype: unicode
"""
return self.ERROR_TYPE_DESCRIPTIONS[self.typ]
return self.ERROR_TYPE_DESCRIPTIONS.get(self.typ)
def __str__(self):
if self.typ is not None:
return ' :: '.join([self.typ, self.description, self.detail])
else:
return str(self.detail)
return ' :: '.join(
part for part in
(self.typ, self.description, self.detail, self.title)
if part is not None)
class _Constant(jose.JSONDeSerializable, collections.Hashable):

View File

@@ -18,41 +18,30 @@ class ErrorTest(unittest.TestCase):
def setUp(self):
from acme.messages import Error
self.error = Error(detail='foo', typ='malformed', title='title')
self.jobj = {'detail': 'foo', 'title': 'some title'}
def test_typ_prefix(self):
self.assertEqual('malformed', self.error.typ)
self.assertEqual(
'urn:acme:error:malformed', self.error.to_partial_json()['type'])
self.assertEqual(
'malformed', self.error.from_json(self.error.to_partial_json()).typ)
def test_typ_decoder_missing_prefix(self):
from acme.messages import Error
self.jobj['type'] = 'malformed'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
self.jobj['type'] = 'not valid bare type'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
def test_typ_decoder_not_recognized(self):
from acme.messages import Error
self.jobj['type'] = 'urn:acme:error:baz'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
def test_description(self):
self.assertEqual(
'The request message was malformed', self.error.description)
self.error = Error(
detail='foo', typ='urn:acme:error:malformed', title='title')
self.jobj = {
'detail': 'foo',
'title': 'some title',
'type': 'urn:acme:error:malformed',
}
self.error_custom = Error(typ='custom', detail='bar')
self.jobj_cusom = {'type': 'custom', 'detail': 'bar'}
def test_from_json_hashable(self):
from acme.messages import Error
hash(Error.from_json(self.error.to_json()))
def test_description(self):
self.assertEqual(
'The request message was malformed', self.error.description)
self.assertTrue(self.error_custom.description is None)
def test_str(self):
self.assertEqual(
'malformed :: The request message was malformed :: foo',
str(self.error))
self.assertEqual('foo', str(self.error.update(typ=None)))
'urn:acme:error:malformed :: The request message was '
'malformed :: foo :: title', str(self.error))
self.assertEqual('custom :: bar', str(self.error_custom))
class ConstantTest(unittest.TestCase):
@@ -232,7 +221,7 @@ class ChallengeBodyTest(unittest.TestCase):
from acme.messages import Error
from acme.messages import STATUS_INVALID
self.status = STATUS_INVALID
error = Error(typ='serverInternal',
error = Error(typ='urn:acme:error:serverInternal',
detail='Unable to communicate with DNS server')
self.challb = ChallengeBody(
uri='http://challb', chall=self.chall, status=self.status,

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +0,0 @@
:mod:`letsencrypt_apache.dvsni`
-------------------------------
.. automodule:: letsencrypt_apache.dvsni
:members:

View File

@@ -0,0 +1,5 @@
:mod:`letsencrypt_apache.tls_sni_01`
------------------------------------
.. automodule:: letsencrypt_apache.tls_sni_01
:members:

View File

@@ -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/

View File

@@ -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 <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)
@@ -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):

View File

@@ -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}

View File

@@ -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."""

View File

@@ -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

View File

@@ -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__":

View File

@@ -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

View File

@@ -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,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

View File

@@ -14,6 +14,10 @@ XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
VENV_NAME="letsencrypt"
VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"}
VENV_BIN=${VENV_PATH}/bin
# The path to the letsencrypt-auto script. Everything that uses these might
# at some point be inlined...
LEA_PATH=`dirname "$0"`
BOOTSTRAP=${LEA_PATH}/bootstrap
# This script takes the same arguments as the main letsencrypt program, but it
# additionally responds to --verbose (more output) and --debug (allow support
@@ -110,7 +114,6 @@ DeterminePythonVersion() {
# later steps, causing "ImportError: cannot import name unpack_url"
if [ ! -d $VENV_PATH ]
then
BOOTSTRAP=`dirname $0`/bootstrap
if [ ! -f $BOOTSTRAP/debian.sh ] ; then
echo "Cannot find the letsencrypt bootstrap scripts in $BOOTSTRAP"
exit 1
@@ -126,8 +129,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
@@ -163,7 +175,7 @@ if [ "$VERBOSE" = 1 ] ; then
echo
$VENV_BIN/pip install -U setuptools
$VENV_BIN/pip install -U pip
$VENV_BIN/pip install -r py26reqs.txt -U letsencrypt letsencrypt-apache
$VENV_BIN/pip install -r "$LEA_PATH"/py26reqs.txt -U letsencrypt letsencrypt-apache
# nginx is buggy / disabled for now, but upgrade it if the user has
# installed it manually
if $VENV_BIN/pip freeze | grep -q letsencrypt-nginx ; then
@@ -175,7 +187,7 @@ else
$VENV_BIN/pip install -U pip > /dev/null
printf .
# nginx is buggy / disabled for now...
$VENV_BIN/pip install -r py26reqs.txt > /dev/null
$VENV_BIN/pip install -r "$LEA_PATH"/py26reqs.txt > /dev/null
printf .
$VENV_BIN/pip install -U letsencrypt > /dev/null
printf .

View File

@@ -12,6 +12,13 @@
See the License for the specific language governing permissions and
limitations under the License.
Incorporating code from nginxparser
Copyright 2014 Fatih Erikli
Licensed MIT
Text of Apache License
======================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@@ -188,3 +195,22 @@
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Text of MIT License
===================
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,5 +0,0 @@
:mod:`letsencrypt_nginx.dvsni`
------------------------------
.. automodule:: letsencrypt_nginx.dvsni
:members:

View File

@@ -0,0 +1,5 @@
:mod:`letsencrypt_nginx.tls_sni_01`
-----------------------------------
.. automodule:: letsencrypt_nginx.tls_sni_01
:members:

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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(

View File

@@ -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]

View File

@@ -381,7 +381,7 @@ def diagnose_configurator_problem(cfg_type, requested, plugins):
raise errors.PluginSelectionError(msg)
def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable=too-many-branches
def choose_configurator_plugins(args, config, plugins, verb): # pylint: disable=too-many-branches
"""
Figure out which configurator we're going to use
@@ -465,7 +465,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)
@@ -519,7 +519,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
@@ -832,7 +832,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
@@ -902,6 +902,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 "

View File

@@ -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

View File

@@ -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"""

View File

@@ -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

View File

@@ -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,26 +163,22 @@ 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 response.simple_verify(
if not response.simple_verify(
achall.chall, achall.domain,
achall.account_key.public_key(), self.config.http01_port):
return response
else:
logger.error(
"Self-verify of challenge failed, authorization abandoned.")
if self.conf("test-mode") and self._httpd.poll() is not None:
# simply verify cause command failure...
return False
return None
logger.warning("Self-verify of challenge failed.")
return response
def _notify_and_wait(self, message): # pylint: disable=no-self-use
# TODO: IDisplay wraps messages, breaking the command

View File

@@ -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)]
@@ -61,7 +61,9 @@ class AuthenticatorTest(unittest.TestCase):
self.assertTrue(self.achalls[0].chall.encode("token") in message)
mock_verify.return_value = False
self.assertEqual([None], self.auth.perform(self.achalls))
with mock.patch("letsencrypt.plugins.manual.logger") as mock_logger:
self.auth.perform(self.achalls)
mock_logger.warning.assert_called_once_with(mock.ANY)
@mock.patch("letsencrypt.plugins.manual.zope.component.getUtility")
@mock.patch("letsencrypt.plugins.manual.Authenticator._notify_and_wait")
@@ -87,20 +89,6 @@ class AuthenticatorTest(unittest.TestCase):
self.assertRaises(
errors.Error, self.auth_test_mode.perform, self.achalls)
@mock.patch("letsencrypt.plugins.manual.socket.socket")
@mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True)
@mock.patch("acme.challenges.HTTP01Response.simple_verify",
autospec=True)
@mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True)
def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep,
mock_socket):
mock_popen.return_value.poll.side_effect = [None, 10]
mock_popen.return_value.pid = 1234
mock_verify.return_value = False
self.assertEqual([False], self.auth_test_mode.perform(self.achalls))
self.assertEqual(1, mock_sleep.call_count)
self.assertEqual(1, mock_socket.call_count)
def test_cleanup_test_mode_already_terminated(self):
# pylint: disable=protected-access
self.auth_test_mode._httpd = httpd = mock.Mock()

View File

@@ -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::
<IfModule mod_headers.c>
<LocationMatch "/.well-known/acme-challenge/*">
Header set Content-Type "text/plain"
</LocationMatch>
</IfModule>
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

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -33,6 +33,7 @@ wget https://github.com/jsha/boulder-tools/raw/master/goose.gz && \
zcat goose.gz > $GOPATH/bin/goose && \
chmod +x $GOPATH/bin/goose
./test/create_db.sh
go run cmd/rabbitmq-setup/main.go -server amqp://localhost
# listenbuddy is needed for ./start.py
go get github.com/jsha/listenbuddy
cd -