Merge remote-tracking branch 'github/letsencrypt/master' into py2.6-3

This commit is contained in:
Jakub Warmuz 2015-10-17 07:28:04 +00:00
commit 09fa1153d9
No known key found for this signature in database
GPG key ID: 2A7BAD3A489B52EA
73 changed files with 2032 additions and 1475 deletions

View file

@ -22,6 +22,15 @@ env:
- TOXENV=lint
- TOXENV=cover
# Only build pushes to the master branch, PRs, and branches beginning with
# `test-`. This reduces the number of simultaneous Travis runs, which speeds
# turnaround time on review since there is a cap of 5 simultaneous runs.
branches:
only:
- master
- /^test-.*$/
sudo: false # containers
addons:
# make sure simplehttp simple verification works (custom /etc/hosts)

View file

@ -88,6 +88,9 @@ class SimpleHTTP(DVChallenge):
TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec
"""Minimum size of the :attr:`token` in bytes."""
URI_ROOT_PATH = ".well-known/acme-challenge"
"""URI root path for the server provisioned resource."""
# TODO: acme-spec doesn't specify token as base64-encoded value
token = jose.Field(
"token", encoder=jose.encode_b64jose, decoder=functools.partial(
@ -106,6 +109,11 @@ class SimpleHTTP(DVChallenge):
# URI_ROOT_PATH!
return b'..' not in self.token and b'/' not in self.token
@property
def path(self):
"""Path (starting with '/') for provisioned resource."""
return '/' + self.URI_ROOT_PATH + '/' + self.encode('token')
@ChallengeResponse.register
class SimpleHTTPResponse(ChallengeResponse):
@ -117,12 +125,12 @@ class SimpleHTTPResponse(ChallengeResponse):
typ = "simpleHttp"
tls = jose.Field("tls", default=True, omitempty=True)
URI_ROOT_PATH = ".well-known/acme-challenge"
"""URI root path for the server provisioned resource."""
URI_ROOT_PATH = SimpleHTTP.URI_ROOT_PATH
_URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{token}"
CONTENT_TYPE = "application/jose+json"
PORT = 80
TLS_PORT = 443
@property
def scheme(self):
@ -132,7 +140,7 @@ class SimpleHTTPResponse(ChallengeResponse):
@property
def port(self):
"""Port that the ACME client should be listening for validation."""
return 443 if self.tls else 80
return self.TLS_PORT if self.tls else self.PORT
def uri(self, domain, chall):
"""Create an URI to the provisioned resource.

View file

@ -26,47 +26,80 @@ logger = logging.getLogger(__name__)
_DEFAULT_DVSNI_SSL_METHOD = OpenSSL.SSL.SSLv23_METHOD
def _serve_sni(certs, sock, reuseaddr=True, method=_DEFAULT_DVSNI_SSL_METHOD,
accept=None):
"""Start SNI-enabled server, that drops connection after handshake.
class SSLSocket(object): # pylint: disable=too-few-public-methods
"""SSL wrapper for sockets.
:param certs: Mapping from SNI name to ``(key, cert)`` `tuple`.
:param sock: Already bound socket.
:param bool reuseaddr: Should `socket.SO_REUSEADDR` be set?
:param method: See `OpenSSL.SSL.Context` for allowed values.
:param accept: Callable that doesn't take any arguments and
returns ``True`` if more connections should be served.
:ivar socket sock: Original wrapped socket.
:ivar dict certs: Mapping from domain names (`bytes`) to
`OpenSSL.crypto.X509`.
:ivar method: See `OpenSSL.SSL.Context` for allowed values.
"""
def _pick_certificate(connection):
def __init__(self, sock, certs, method=_DEFAULT_DVSNI_SSL_METHOD):
self.sock = sock
self.certs = certs
self.method = method
def __getattr__(self, name):
return getattr(self.sock, name)
def _pick_certificate_cb(self, connection):
"""SNI certificate callback.
This method will set a new OpenSSL context object for this
connection when an incoming connection provides an SNI name
(in order to serve the appropriate certificate, if any).
:param connection: The TLS connection object on which the SNI
extension was received.
:type connection: :class:`OpenSSL.Connection`
"""
server_name = connection.get_servername()
try:
key, cert = certs[connection.get_servername()]
key, cert = self.certs[server_name]
except KeyError:
logger.debug("Server name (%s) not recognized, dropping SSL",
server_name)
return
new_context = OpenSSL.SSL.Context(method)
new_context = OpenSSL.SSL.Context(self.method)
new_context.use_privatekey(key)
new_context.use_certificate(cert)
connection.set_context(new_context)
if reuseaddr:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.listen(1) # TODO: add func arg?
class FakeConnection(object):
"""Fake OpenSSL.SSL.Connection."""
while accept is None or accept():
server, addr = sock.accept()
logger.debug('Received connection from %s', addr)
# pylint: disable=missing-docstring
with contextlib.closing(server):
context = OpenSSL.SSL.Context(method)
context.set_tlsext_servername_callback(_pick_certificate)
def __init__(self, connection):
self._wrapped = connection
server_ssl = OpenSSL.SSL.Connection(context, server)
server_ssl.set_accept_state()
try:
server_ssl.do_handshake()
server_ssl.shutdown()
except OpenSSL.SSL.Error as error:
raise errors.Error(error)
def __getattr__(self, name):
return getattr(self._wrapped, name)
def shutdown(self, *unused_args):
# OpenSSL.SSL.Connection.shutdown doesn't accept any args
return self._wrapped.shutdown()
def accept(self): # pylint: disable=missing-docstring
sock, addr = self.sock.accept()
context = OpenSSL.SSL.Context(self.method)
context.set_tlsext_servername_callback(self._pick_certificate_cb)
ssl_sock = self.FakeConnection(OpenSSL.SSL.Connection(context, sock))
ssl_sock.set_accept_state()
logger.debug("Performing handshake with %s", addr)
try:
ssl_sock.do_handshake()
except OpenSSL.SSL.Error as error:
# _pick_certificate_cb might have returned without
# creating SSL context (wrong server name)
raise socket.error(error)
return ssl_sock, addr
def probe_sni(name, host, port=443, timeout=300,

View file

@ -4,45 +4,43 @@ import threading
import time
import unittest
import mock
import OpenSSL
from six.moves import socketserver # pylint: disable=import-error
from acme import errors
from acme import jose
from acme import test_util
class ServeProbeSNITest(unittest.TestCase):
"""Tests for acme.crypto_util._serve_sni/probe_sni."""
class SSLSocketAndProbeSNITest(unittest.TestCase):
"""Tests for acme.crypto_util.SSLSocket/probe_sni."""
def setUp(self):
self.cert = test_util.load_cert('cert.pem')
key = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM,
test_util.load_vector('rsa512_key.pem'))
key = test_util.load_pyopenssl_private_key('rsa512_key.pem')
# pylint: disable=protected-access
certs = {b'foo': (key, self.cert._wrapped)}
sock = socket.socket()
sock.bind(('', 0)) # pick random port
self.port = sock.getsockname()[1]
from acme.crypto_util import SSLSocket
self.server = threading.Thread(target=self._run_server, args=(certs, sock))
self.server.start()
class _TestServer(socketserver.TCPServer):
# pylint: disable=too-few-public-methods
# six.moves.* | pylint: disable=attribute-defined-outside-init,no-init
def server_bind(self): # pylint: disable=missing-docstring
self.socket = SSLSocket(socket.socket(), certs=certs)
socketserver.TCPServer.server_bind(self)
self.server = _TestServer(('', 0), socketserver.BaseRequestHandler)
self.port = self.server.socket.getsockname()[1]
self.server_thread = threading.Thread(
# pylint: disable=no-member
target=self.server.handle_request)
self.server_thread.start()
time.sleep(1) # TODO: avoid race conditions in other way
@classmethod
def _run_server(cls, certs, sock):
from acme.crypto_util import _serve_sni
# TODO: improve testing of server errors and their conditions
try:
return _serve_sni(
certs, sock, accept=mock.Mock(side_effect=[True, False]))
except errors.Error:
pass
def tearDown(self):
self.server.join()
self.server_thread.join()
def _probe(self, name):
from acme.crypto_util import probe_sni

View file

@ -24,6 +24,7 @@ class Error(jose.JSONObjectWithFields, Exception):
'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',

197
acme/acme/standalone.py Normal file
View file

@ -0,0 +1,197 @@
"""Support for standalone client challenge solvers. """
import argparse
import collections
import functools
import logging
import os
import socket
import sys
import six
from six.moves import BaseHTTPServer # pylint: disable=import-error
from six.moves import http_client # pylint: disable=import-error
from six.moves import socketserver # pylint: disable=import-error
import OpenSSL
from acme import challenges
from acme import crypto_util
logger = logging.getLogger(__name__)
# six.moves.* | pylint: disable=no-member,attribute-defined-outside-init
# pylint: disable=too-few-public-methods,no-init
class TLSServer(socketserver.TCPServer):
"""Generic TLS Server."""
def __init__(self, *args, **kwargs):
self.certs = kwargs.pop("certs", {})
self.method = kwargs.pop(
# pylint: disable=protected-access
"method", crypto_util._DEFAULT_DVSNI_SSL_METHOD)
self.allow_reuse_address = kwargs.pop("allow_reuse_address", True)
socketserver.TCPServer.__init__(self, *args, **kwargs)
def _wrap_sock(self):
self.socket = crypto_util.SSLSocket(
self.socket, certs=self.certs, method=self.method)
def server_bind(self): # pylint: disable=missing-docstring
self._wrap_sock()
return socketserver.TCPServer.server_bind(self)
class ACMEServerMixin: # pylint: disable=old-style-class
"""ACME server common settings mixin."""
# TODO: c.f. #858
server_version = "ACME client standalone challenge solver"
allow_reuse_address = True
def __init__(self):
self._stopped = False
def serve_forever2(self):
"""Serve forever, until other thread calls `shutdown2`."""
while not self._stopped:
self.handle_request()
def shutdown2(self):
"""Shutdown server loop from `serve_forever2`."""
self._stopped = True
# dummy request to terminate last server_forever2.handle_request()
sock = socket.socket()
try:
sock.connect(self.socket.getsockname())
except socket.error:
pass # thread is probably already finished
finally:
sock.close()
self.server_close()
class DVSNIServer(TLSServer, ACMEServerMixin):
"""DVSNI Server."""
def __init__(self, server_address, certs):
ACMEServerMixin.__init__(self)
TLSServer.__init__(
self, server_address, socketserver.BaseRequestHandler, certs=certs)
class SimpleHTTPServer(BaseHTTPServer.HTTPServer, ACMEServerMixin):
"""SimpleHTTP Server."""
def __init__(self, server_address, resources):
ACMEServerMixin.__init__(self)
BaseHTTPServer.HTTPServer.__init__(
self, server_address, SimpleHTTPRequestHandler.partial_init(
simple_http_resources=resources))
class SimpleHTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
"""SimpleHTTP challenge handler.
Adheres to the stdlib's `socketserver.BaseRequestHandler` interface.
:ivar set simple_http_resources: A set of `SimpleHTTPResource`
objects. TODO: better name?
"""
SimpleHTTPResource = collections.namedtuple(
"SimpleHTTPResource", "chall response validation")
def __init__(self, *args, **kwargs):
self.simple_http_resources = kwargs.pop("simple_http_resources", set())
socketserver.BaseRequestHandler.__init__(self, *args, **kwargs)
def do_GET(self): # pylint: disable=invalid-name,missing-docstring
if self.path == "/":
self.handle_index()
elif self.path.startswith("/" + challenges.SimpleHTTP.URI_ROOT_PATH):
self.handle_simple_http_resource()
else:
self.handle_404()
def handle_index(self):
"""Handle index page."""
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(self.server.server_version.encode())
def handle_404(self):
"""Handler 404 Not Found errors."""
self.send_response(http_client.NOT_FOUND, message="Not Found")
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"404")
def handle_simple_http_resource(self):
"""Handle SimpleHTTP provisioned resources."""
for resource in self.simple_http_resources:
if resource.chall.path == self.path:
logger.debug("Serving SimpleHTTP with token %r",
resource.chall.encode("token"))
self.send_response(http_client.OK)
self.send_header("Content-type", resource.response.CONTENT_TYPE)
self.end_headers()
self.wfile.write(resource.validation.json_dumps().encode())
return
else: # pylint: disable=useless-else-on-loop
logger.debug("No resources to serve")
logger.debug("%s does not correspond to any resource. ignoring",
self.path)
@classmethod
def partial_init(cls, simple_http_resources):
"""Partially initialize this handler.
This is useful because `socketserver.BaseServer` takes
uninitialized handler and initializes it with the current
request.
"""
return functools.partial(
cls, simple_http_resources=simple_http_resources)
def simple_dvsni_server(cli_args, forever=True):
"""Run simple standalone DVSNI server."""
logging.basicConfig(level=logging.DEBUG)
parser = argparse.ArgumentParser()
parser.add_argument(
"-p", "--port", default=0, help="Port to serve at. By default "
"picks random free port.")
args = parser.parse_args(cli_args[1:])
certs = {}
_, hosts, _ = next(os.walk('.'))
for host in hosts:
with open(os.path.join(host, "cert.pem")) as cert_file:
cert_contents = cert_file.read()
with open(os.path.join(host, "key.pem")) as key_file:
key_contents = key_file.read()
certs[host.encode()] = (
OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key_contents),
OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert_contents))
server = DVSNIServer(('', int(args.port)), certs=certs)
six.print_("Serving at https://localhost:{0}...".format(
server.socket.getsockname()[1]))
if forever: # pragma: no cover
server.serve_forever()
else:
server.handle_request()
if __name__ == "__main__":
sys.exit(simple_dvsni_server(sys.argv)) # pragma: no cover

View file

@ -0,0 +1,198 @@
"""Tests for acme.standalone."""
import os
import shutil
import socket
import threading
import tempfile
import time
import unittest
from six.moves import http_client # pylint: disable=import-error
from six.moves import socketserver # pylint: disable=import-error
import requests
from acme import challenges
from acme import crypto_util
from acme import errors
from acme import jose
from acme import test_util
class TLSServerTest(unittest.TestCase):
"""Tests for acme.standalone.TLSServer."""
def test_bind(self): # pylint: disable=no-self-use
from acme.standalone import TLSServer
server = TLSServer(
('', 0), socketserver.BaseRequestHandler, bind_and_activate=True)
server.server_close() # pylint: disable=no-member
class ACMEServerMixinTest(unittest.TestCase):
"""Tests for acme.standalone.ACMEServerMixin."""
def setUp(self):
from acme.standalone import ACMEServerMixin
class _MockServer(socketserver.TCPServer, ACMEServerMixin):
def __init__(self, *args, **kwargs):
socketserver.TCPServer.__init__(self, *args, **kwargs)
ACMEServerMixin.__init__(self)
self.server = _MockServer(("", 0), socketserver.BaseRequestHandler)
def _busy_wait(self): # pragma: no cover
# This function is used to avoid race coditions in tests, but
# not all of the functionality is always used, hence "no
# cover"
while True:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
# pylint: disable=no-member
sock.connect(self.server.socket.getsockname())
except socket.error:
pass
else:
break
finally:
sock.close()
time.sleep(1)
def test_serve_shutdown(self):
thread = threading.Thread(target=self.server.serve_forever2)
thread.start()
self._busy_wait()
self.server.shutdown2()
def test_shutdown2_not_running(self):
self.server.shutdown2()
self.server.shutdown2()
class DVSNIServerTest(unittest.TestCase):
"""Test for acme.standalone.DVSNIServer."""
def setUp(self):
self.certs = {
b'localhost': (test_util.load_pyopenssl_private_key('rsa512_key.pem'),
# pylint: disable=protected-access
test_util.load_cert('cert.pem')._wrapped),
}
from acme.standalone import DVSNIServer
self.server = DVSNIServer(("", 0), certs=self.certs)
# pylint: disable=no-member
self.thread = threading.Thread(target=self.server.handle_request)
self.thread.start()
def tearDown(self):
self.server.shutdown2()
self.thread.join()
def test_init(self):
# pylint: disable=protected-access
self.assertFalse(self.server._stopped)
def test_dvsni(self):
cert = crypto_util.probe_sni(
b'localhost', *self.server.socket.getsockname())
self.assertEqual(jose.ComparableX509(cert),
jose.ComparableX509(self.certs[b'localhost'][1]))
class SimpleHTTPServerTest(unittest.TestCase):
"""Tests for acme.standalone.SimpleHTTPServer."""
def setUp(self):
self.account_key = jose.JWK.load(
test_util.load_vector('rsa1024_key.pem'))
self.resources = set()
from acme.standalone import SimpleHTTPServer
self.server = SimpleHTTPServer(('', 0), resources=self.resources)
# pylint: disable=no-member
self.port = self.server.socket.getsockname()[1]
self.thread = threading.Thread(target=self.server.handle_request)
self.thread.start()
def tearDown(self):
self.server.shutdown2()
self.thread.join()
def test_index(self):
response = requests.get(
'http://localhost:{0}'.format(self.port), verify=False)
self.assertEqual(
response.text, 'ACME client standalone challenge solver')
self.assertTrue(response.ok)
def test_404(self):
response = requests.get(
'http://localhost:{0}/foo'.format(self.port), verify=False)
self.assertEqual(response.status_code, http_client.NOT_FOUND)
def _test_simple_http(self, add):
chall = challenges.SimpleHTTP(token=(b'x' * 16))
response = challenges.SimpleHTTPResponse(tls=False)
from acme.standalone import SimpleHTTPRequestHandler
resource = SimpleHTTPRequestHandler.SimpleHTTPResource(
chall=chall, response=response, validation=response.gen_validation(
chall, self.account_key))
if add:
self.resources.add(resource)
return resource.response.simple_verify(
resource.chall, 'localhost', self.account_key.public_key(),
port=self.port)
def test_simple_http_found(self):
self.assertTrue(self._test_simple_http(add=True))
def test_simple_http_not_found(self):
self.assertFalse(self._test_simple_http(add=False))
class TestSimpleDVSNIServer(unittest.TestCase):
"""Tests for acme.standalone.simple_dvsni_server."""
def setUp(self):
# mirror ../examples/standalone
self.test_cwd = tempfile.mkdtemp()
localhost_dir = os.path.join(self.test_cwd, 'localhost')
os.makedirs(localhost_dir)
shutil.copy(test_util.vector_path('cert.pem'), localhost_dir)
shutil.copy(test_util.vector_path('rsa512_key.pem'),
os.path.join(localhost_dir, 'key.pem'))
from acme.standalone import simple_dvsni_server
self.port = 1234
self.thread = threading.Thread(target=simple_dvsni_server, kwargs={
'cli_args': ('xxx', '--port', str(self.port)),
'forever': False,
})
self.old_cwd = os.getcwd()
os.chdir(self.test_cwd)
self.thread.start()
def tearDown(self):
os.chdir(self.old_cwd)
self.thread.join()
shutil.rmtree(self.test_cwd)
def test_it(self):
max_attempts = 5
while max_attempts:
max_attempts -= 1
try:
cert = crypto_util.probe_sni(b'localhost', b'0.0.0.0', self.port)
except errors.Error:
self.assertTrue(max_attempts > 0, "Timeout!")
time.sleep(1) # wait until thread starts
else:
self.assertEqual(jose.ComparableX509(cert),
test_util.load_cert('cert.pem'))
break
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -0,0 +1,2 @@
python -m acme.standalone -p 1234
curl -k https://localhost:1234

View file

@ -0,0 +1 @@
../../../acme/testdata/cert.pem

View file

@ -0,0 +1 @@
../../../acme/testdata/rsa512_key.pem

View file

@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
version = '0.0.0.dev20151008'
version = '0.1.0.dev0'
install_requires = [
# load_pem_private/public_key (>=0.6)

View file

@ -1,4 +1,4 @@
#!/bin/sh
#!/bin/sh -e
if ! hash brew 2>/dev/null; then
echo "Homebrew Not Installed\nDownloading..."
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
@ -6,3 +6,13 @@ fi
brew install augeas
brew install dialog
if ! hash pip 2>/dev/null; then
echo "pip Not Installed\nInstalling python from Homebrew..."
brew install python
fi
if ! hash virtualenv 2>/dev/null; then
echo "virtualenv Not Installed\nInstalling with pip"
pip install virtualenv
fi

33
bootstrap/venv.sh Executable file
View file

@ -0,0 +1,33 @@
#!/bin/sh -e
#
# Installs and updates letencrypt virtualenv
#
# USAGE: source ./dev/venv.sh
XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
VENV_NAME="letsencrypt"
VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"}
# virtualenv call is not idempotent: it overwrites pip upgraded in
# later steps, causing "ImportError: cannot import name unpack_url"
if [ ! -d $VENV_PATH ]
then
virtualenv --no-site-packages --python python2 $VENV_PATH
fi
. $VENV_PATH/bin/activate
pip install -U setuptools
pip install -U pip
pip install -U letsencrypt letsencrypt-apache # letsencrypt-nginx
echo
echo "Congratulations, Let's Encrypt has been successfully installed/updated!"
echo
echo -n "Your prompt should now be prepended with ($VENV_NAME). Next "
echo -n "time, if the prompt is different, 'source' this script again "
echo -n "before running 'letsencrypt'."
echo
echo
echo "You can now run 'letsencrypt --help'."

View file

@ -0,0 +1,5 @@
:mod:`letsencrypt.plugins.util`
-------------------------------
.. automodule:: letsencrypt.plugins.util
:members:

View file

@ -0,0 +1,5 @@
:mod:`letsencrypt.plugins.webroot`
----------------------------------
.. automodule:: letsencrypt.plugins.webroot
:members:

View file

@ -267,6 +267,22 @@ Please:
.. _PEP 8 - Style Guide for Python Code:
https://www.python.org/dev/peps/pep-0008
Submitting a pull request
=========================
Steps:
1. Write your code!
2. Make sure your environment is set up properly and that you're in your
virtualenv. You can do this by running ``./bootstrap/dev/venv.sh``.
(this is a **very important** step)
3. Run ``./pep8.travis.sh`` to do a cursory check of your code style.
Fix any errors.
4. Run ``tox -e lint`` to check for pylint errors. Fix any errors.
5. Run ``tox`` to run the entire test suite including coverage. Fix any errors.
6. If your code touches communication with an ACME server/Boulder, you
should run the integration tests, see `integration`_.
7. Submit the PR.
Updating the documentation
==========================
@ -280,3 +296,82 @@ commands:
This should generate documentation in the ``docs/_build/html``
directory.
.. _prerequisites:
Notes on OS depedencies
=======================
OS level dependencies are managed by scripts in ``bootstrap``. Some notes
are provided here mainly for the :ref:`developers <hacking>` reference.
In general:
* ``sudo`` is required as a suggested way of running privileged process
* `Augeas`_ is required for the Python bindings
* ``virtualenv`` and ``pip`` are used for managing other python library
dependencies
.. _Augeas: http://augeas.net/
.. _Virtualenv: https://virtualenv.pypa.io
Ubuntu
------
.. code-block:: shell
sudo ./bootstrap/ubuntu.sh
Debian
------
.. code-block:: shell
sudo ./bootstrap/debian.sh
For squeeze you will need to:
- Use ``virtualenv --no-site-packages -p python`` instead of ``-p python2``.
.. _`#280`: https://github.com/letsencrypt/letsencrypt/issues/280
Mac OSX
-------
.. code-block:: shell
./bootstrap/mac.sh
Fedora
------
.. code-block:: shell
sudo ./bootstrap/fedora.sh
Centos 7
--------
.. code-block:: shell
sudo ./bootstrap/centos.sh
FreeBSD
-------
.. code-block:: shell
sudo ./bootstrap/freebsd.sh
Bootstrap script for FreeBSD uses ``pkg`` for package installation,
i.e. it does not use ports.
FreeBSD by default uses ``tcsh``. In order to activate virtulenv (see
below), you will need a compatbile shell, e.g. ``pkg install bash &&
bash``.

View file

@ -47,3 +47,10 @@ Errors
.. automodule:: acme.errors
:members:
Standalone
----------
.. automodule:: acme.standalone
:members:

View file

@ -2,26 +2,6 @@
Using the Let's Encrypt client
==============================
Quick start
===========
Using Docker_ you can quickly get yourself a testing cert. From the
server that the domain your requesting a cert for resolves to,
`install Docker`_, issue the following command:
.. code-block:: shell
sudo docker run -it --rm -p 443:443 --name letsencrypt \
-v "/etc/letsencrypt:/etc/letsencrypt" \
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
quay.io/letsencrypt/letsencrypt:latest
and follow the instructions. Your new cert will be available in
``/etc/letsencrypt/certs``.
.. _Docker: https://docker.com
.. _`install Docker`: https://docs.docker.com/docker/userguide/
Getting the code
================
@ -42,126 +22,35 @@ above method instead.
https://github.com/letsencrypt/letsencrypt/archive/master.zip
.. _prerequisites:
Installation and Usage
======================
Prerequisites
=============
The demo code is supported and known to work on **Ubuntu and
Debian**. Therefore, prerequisites for other platforms listed below
are provided mainly for the :ref:`developers <hacking>` reference.
In general:
* ``sudo`` is required as a suggested way of running privileged process
* `Augeas`_ is required for the Python bindings
Ubuntu
------
To install and run the client you just need to type:
.. code-block:: shell
sudo ./bootstrap/ubuntu.sh
./letsencrypt-auto
(Once letsencrypt is packaged by distributions, the command will just be
``letsencrypt``. ``letsencrypt-auto`` is a wrapper which installs virtualized
dependencies and provides automated updates during the beta program)
Debian
------
.. code-block:: shell
sudo ./bootstrap/debian.sh
For squeeze you will need to:
- Use ``virtualenv --no-site-packages -p python`` instead of ``-p python2``.
.. _`#280`: https://github.com/letsencrypt/letsencrypt/issues/280
Mac OSX
-------
.. code-block:: shell
./bootstrap/mac.sh
Fedora
------
.. code-block:: shell
sudo ./bootstrap/fedora.sh
Centos 7
--------
.. code-block:: shell
sudo ./bootstrap/centos.sh
FreeBSD
-------
.. code-block:: shell
sudo ./bootstrap/freebsd.sh
Bootstrap script for FreeBSD uses ``pkg`` for package installation,
i.e. it does not use ports.
FreeBSD by default uses ``tcsh``. In order to activate virtulenv (see
below), you will need a compatbile shell, e.g. ``pkg install bash &&
bash``.
Installation
============
.. "pip install acme" doesn't search for "acme" in cwd, just like "pip
install -e acme" does; `-U setuptools pip` necessary for #722
.. code-block:: shell
virtualenv --no-site-packages -p python2 venv
./venv/bin/pip install -U setuptools
./venv/bin/pip install -U pip
./venv/bin/pip install -r requirements.txt acme/ . letsencrypt-apache/ letsencrypt-nginx/
.. warning:: Please do **not** use ``python setup.py install``. Please
do **not** attempt the installation commands as
superuser/root and/or without Virtualenv_, e.g. ``sudo
python setup.py install``, ``sudo pip install``, ``sudo
./venv/bin/...``. These modes of operation might corrupt
your operating system and are **not supported** by the
Let's Encrypt team!
Usage
=====
To get a new certificate run:
.. code-block:: shell
sudo ./venv/bin/letsencrypt auth
.. warning:: Please do **not** use ``python setup.py install`` or ``sudo pip install`.
Those mode of operation might corrupt your operating system and is
**not supported** by the Let's Encrypt team!
The ``letsencrypt`` commandline tool has a builtin help:
.. code-block:: shell
./venv/bin/letsencrypt --help
./letsencrypt-auto --help
Configuration file
------------------
It is possible to specify configuration file with
``letsencrypt --config cli.ini`` (or shorter ``-c cli.ini``). For
``letsencrypt-auto --config cli.ini`` (or shorter ``-c cli.ini``). For
instance, if you are a contributor, you might find the following
handy:
@ -178,5 +67,22 @@ By default, the following locations are searched:
.. keep it up to date with constants.py
.. _Augeas: http://augeas.net/
.. _Virtualenv: https://virtualenv.pypa.io
Running with Docker
===================
Docker_ is another way to quickly obtain testing certs. From the
server that the domain your requesting a cert for resolves to,
`install Docker`_, issue the following command:
.. code-block:: shell
sudo docker auth -it --rm -p 443:443 --name letsencrypt \
-v "/etc/letsencrypt:/etc/letsencrypt" \
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
quay.io/letsencrypt/letsencrypt:latest auth
and follow the instructions. Your new cert will be available in
``/etc/letsencrypt/certs``.
.. _Docker: https://docker.com
.. _`install Docker`: https://docs.docker.com/docker/userguide/

View file

@ -1,5 +1,6 @@
"""Apache Configuration based off of Augeas Configurator."""
# pylint: disable=too-many-lines
import filecmp
import itertools
import logging
import os
@ -163,7 +164,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
temp_install(self.mod_ssl_conf)
def deploy_cert(self, domain, cert_path, key_path, chain_path=None):
def deploy_cert(self, domain, cert_path, key_path,
chain_path=None, fullchain_path=None): # pylint: disable=unused-argument
"""Deploys certificate to specified virtual host.
Currently tries to find the last directives to deploy the cert in
@ -945,9 +947,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
enabled_dir = os.path.join(self.parser.root, "sites-enabled")
for entry in os.listdir(enabled_dir):
if os.path.realpath(os.path.join(enabled_dir, entry)) == avail_fp:
return True
try:
if filecmp.cmp(avail_fp, os.path.join(enabled_dir, entry)):
return True
except OSError:
pass
return False
def enable_site(self, vhost):

View file

@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
version = '0.0.0.dev20151008'
version = '0.1.0.dev0'
install_requires = [
'acme=={0}'.format(version),

89
letsencrypt-auto Executable file
View file

@ -0,0 +1,89 @@
#!/bin/sh -e
#
# Installs and updates the letencrypt virtualenv, and runs letsencrypt
# using that virtual environment. This allows the client to function decently
# without requiring specific versions of its dependencies from the operating
# system.
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
if test "`id -u`" -ne "0" ; then
SUDO=sudo
else
SUDO=
fi
for arg in "$@" ; do
# This first clause is redundant with the third, but hedging on portability
if [ "$arg" = "-v" ] || [ "$arg" = "--verbose" ] || echo "$arg" | grep -E -- -v+ ; then
VERBOSE=1
fi
done
# virtualenv call is not idempotent: it overwrites pip upgraded in
# 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
fi
if [ -f /etc/debian_version ] ; then
echo "Bootstrapping dependencies for Debian-based OSes..."
$SUDO $BOOTSTRAP/_deb_common.sh
elif [ -f /etc/arch-release ] ; then
echo "Bootstrapping dependencies for Archlinux..."
$SUDO $BOOTSTRAP/archlinux.sh
elif [ -f /etc/redhat-release ] ; then
echo "Bootstrapping dependencies for RedHat-based OSes..."
$SUDO $BOOTSTRAP/_rpm_common.sh
elif uname | grep -iq FreeBSD ; then
echo "Bootstrapping dependencies for FreeBSD..."
$SUDO $BOOTSTRAP/freebsd.sh
elif uname | grep -iq Darwin ; then
echo "Bootstrapping dependencies for Mac OS X..."
echo "WARNING: Mac support is very experimental at present..."
$BOOTSTRAP/mac.sh
else
echo "Sorry, I don't know how to bootstrap Let's Encrypt on your operating system!"
echo
echo "You will need to bootstrap, configure virtualenv, and run a pip install manually"
echo "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites"
echo "for more info"
fi
echo "Creating virtual environment..."
if [ "$VERBOSE" = 1 ] ; then
virtualenv --no-site-packages --python python2 $VENV_PATH
else
virtualenv --no-site-packages --python python2 $VENV_PATH > /dev/null
fi
fi
echo -n "Updating letsencrypt and virtual environment dependencies..."
if [ "$VERBOSE" = 1 ] ; then
echo
$VENV_BIN/pip install -U setuptools
$VENV_BIN/pip install -U pip
# nginx is buggy / disabled for now...
$VENV_BIN/pip install -U letsencrypt letsencrypt-apache #letsencrypt-nginx
else
$VENV_BIN/pip install -U setuptools > /dev/null
echo -n .
$VENV_BIN/pip install -U pip > /dev/null
echo -n .
# nginx is buggy / disabled for now...
$VENV_BIN/pip install -U letsencrypt > /dev/null
echo -n .
$VENV_BIN/pip install -U letsencrypt-apache > /dev/null
echo
fi
# Explain what's about to happen, for the benefit of those getting sudo
# password prompts...
echo "Running with virtualenv:" $SUDO $VENV_BIN/letsencrypt "$@"
$SUDO $VENV_BIN/letsencrypt "$@"

View file

@ -1,33 +1,18 @@
#!/bin/bash
# An extremely simplified version of `a2enmod` for enabling modules in the
# httpd docker image. First argument is the server_root and the second is the
# module to be enabled.
# httpd docker image. First argument is the Apache ServerRoot which should be
# an absolute path. The second is the module to be enabled, such as `ssl`.
APACHE_CONFDIR=$1
confdir=$1
module=$2
enable () {
echo "LoadModule "$1"_module /usr/local/apache2/modules/mod_"$1".so" >> \
$APACHE_CONFDIR"/test.conf"
available_base="/mods-available/"$1".conf"
available_conf=$APACHE_CONFDIR$available_base
enabled_dir=$APACHE_CONFDIR"/mods-enabled"
enabled_conf=$enabled_dir"/"$1".conf"
if [ -e "$available_conf" -a -d "$enabled_dir" -a ! -e "$enabled_conf" ]
then
ln -s "..$available_base" $enabled_conf
fi
}
if [ $2 == "ssl" ]
echo "LoadModule ${module}_module " \
"/usr/local/apache2/modules/mod_${module}.so" >> "${confdir}/test.conf"
availbase="/mods-available/${module}.conf"
availconf=$confdir$availbase
enabldir="$confdir/mods-enabled"
enablconf="$enabldir/${module}.conf"
if [ -e $availconf -a -d $enabldir -a ! -e $enablconf ]
then
# Enables ssl and all its dependencies
enable "setenvif"
enable "mime"
enable "socache_shmcb"
enable "ssl"
elif [ $2 == "rewrite" ]
then
enable "rewrite"
else
exit 1
ln -s "..$availbase" $enablconf
fi

View file

@ -175,12 +175,13 @@ class Proxy(configurators_common.Proxy):
else:
return {"example.com"}
def deploy_cert(self, domain, cert_path, key_path, chain_path=None):
def deploy_cert(self, domain, cert_path, key_path, chain_path=None,
fullchain_path=None):
"""Installs cert"""
cert_path, key_path, chain_path = self.copy_certs_and_keys(
cert_path, key_path, chain_path)
self._apache_configurator.deploy_cert(
domain, cert_path, key_path, chain_path)
domain, cert_path, key_path, chain_path, fullchain_path)
def _is_apache_command(command):

View file

@ -39,7 +39,7 @@ def create_le_config(parent_dir):
def extract_configs(configs, parent_dir):
"""Extracts configs to a new dir under parent_dir and returns it"""
config_dir = os.path.join(parent_dir, "renewal")
config_dir = os.path.join(parent_dir, "configs")
if os.path.isdir(configs):
shutil.copytree(configs, config_dir, symlinks=True)

View file

@ -6,6 +6,7 @@ import shutil
import socket
import subprocess
import sys
import time
import OpenSSL
import zope.interface
@ -117,30 +118,44 @@ class NginxConfigurator(common.Plugin):
temp_install(self.mod_ssl_conf)
# Entry point in main.py for installing cert
def deploy_cert(self, domain, cert_path, key_path, chain_path=None):
def deploy_cert(self, domain, cert_path, key_path,
chain_path, fullchain_path):
# pylint: disable=unused-argument
"""Deploys certificate to specified virtual host.
.. note:: Aborts if the vhost is missing ssl_certificate or
ssl_certificate_key.
.. note:: Nginx doesn't have a cert chain directive, so the last
parameter is always ignored. It expects the cert file to have
the concatenated chain.
.. note:: Nginx doesn't have a cert chain directive.
It expects the cert file to have the concatenated chain.
However, we use the chain file as input to the
ssl_trusted_certificate directive, used for verify OCSP responses.
.. note:: This doesn't save the config files!
"""
vhost = self.choose_vhost(domain)
directives = [['ssl_certificate', cert_path],
['ssl_certificate_key', key_path]]
cert_directives = [['ssl_certificate', fullchain_path],
['ssl_certificate_key', key_path]]
# OCSP stapling was introduced in Nginx 1.3.7. If we have that version
# or greater, add config settings for it.
stapling_directives = []
if self.version >= (1, 3, 7):
stapling_directives = [
['ssl_trusted_certificate', chain_path],
['ssl_stapling', 'on'],
['ssl_stapling_verify', 'on']]
try:
self.parser.add_server_directives(vhost.filep, vhost.names,
directives, True)
cert_directives, replace=True)
self.parser.add_server_directives(vhost.filep, vhost.names,
stapling_directives, replace=False)
logger.info("Deployed Certificate to VirtualHost %s for %s",
vhost.filep, vhost.names)
except errors.MisconfigurationError:
except errors.MisconfigurationError as error:
logger.debug(error)
logger.warn(
"Cannot find a cert or key directive in %s for %s. "
"VirtualHost was not modified.", vhost.filep, vhost.names)
@ -598,6 +613,10 @@ def nginx_restart(nginx_ctl, nginx_conf="/etc/nginx.conf"):
except (OSError, ValueError):
logger.fatal("Nginx Restart Failed - Please Check the Configuration")
sys.exit(1)
# Nginx can take a moment to recognize a newly added TLS SNI servername, so sleep
# for a second. TODO: Check for expected servername and loop until it
# appears or return an error if looping too long.
time.sleep(1)
return True

View file

@ -90,14 +90,22 @@ class NginxDvsni(common.Dvsni):
# Add the 'include' statement for the challenges if it doesn't exist
# already in the main config
included = False
directive = ['include', self.challenge_conf]
include_directive = ['include', self.challenge_conf]
root = self.configurator.parser.loc["root"]
bucket_directive = ['server_names_hash_bucket_size', '128']
main = self.configurator.parser.parsed[root]
for entry in main:
if entry[0] == ['http']:
body = entry[1]
if directive not in body:
body.append(directive)
for key, body in main:
if key == ['http']:
found_bucket = False
for key, _ in body:
if key == bucket_directive[0]:
found_bucket = True
if not found_bucket:
body.insert(0, bucket_directive)
if include_directive not in body:
body.insert(0, include_directive)
included = True
break
if not included:

View file

@ -84,7 +84,7 @@ class RawNginxDumper(object):
else:
yield spacer * current_indent + key + spacer + values + ';'
def as_string(self):
def __str__(self):
"""Return the parsed block as a string."""
return '\n'.join(self) + '\n'
@ -122,7 +122,7 @@ def dumps(blocks, indentation=4):
:rtype: str
"""
return RawNginxDumper(blocks, indentation).as_string()
return str(RawNginxDumper(blocks, indentation))
def dump(blocks, _file, indentation=4):

View file

@ -476,8 +476,12 @@ def _add_directives(block, directives, replace=False):
:param list directives: The new directives.
"""
if replace:
for directive in directives:
for directive in directives:
if not replace:
# We insert new directives at the top of the block, mostly
# to work around https://trac.nginx.org/nginx/ticket/810
block.insert(0, directive)
else:
changed = False
if len(directive) == 0:
continue
@ -489,5 +493,3 @@ def _add_directives(block, directives, replace=False):
raise errors.MisconfigurationError(
'LetsEncrypt expected directive for %s in the Nginx '
'config but did not find it.' % directive[0])
else:
block.extend(directives)

View file

@ -63,11 +63,11 @@ class NginxConfiguratorTest(util.NginxTest):
# pylint: disable=protected-access
parsed = self.config.parser._parse_files(filep, override=True)
self.assertEqual([[['server'], [['listen', '69.50.225.155:9000'],
self.assertEqual([[['server'], [['listen', '5001 ssl'],
['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
['listen', '5001 ssl']]]],
['server_name', 'example.*']]]],
parsed[0])
def test_choose_vhost(self):
@ -96,18 +96,49 @@ class NginxConfiguratorTest(util.NginxTest):
def test_more_info(self):
self.assertTrue('nginx.conf' in self.config.more_info())
def test_deploy_cert_stapling(self):
# Choose a version of Nginx greater than 1.3.7 so stapling code gets
# invoked.
self.config.version = (1, 9, 6)
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
self.config.deploy_cert(
"www.example.com",
"example/cert.pem",
"example/key.pem",
"example/chain.pem",
"example/fullchain.pem")
self.config.save()
self.config.parser.load()
generated_conf = self.config.parser.parsed[example_conf]
self.assertTrue(util.contains_at_depth(generated_conf,
['ssl_stapling', 'on'], 2))
self.assertTrue(util.contains_at_depth(generated_conf,
['ssl_stapling_verify', 'on'], 2))
self.assertTrue(util.contains_at_depth(generated_conf,
['ssl_trusted_certificate', 'example/chain.pem'], 2))
def test_deploy_cert(self):
server_conf = self.config.parser.abs_path('server.conf')
nginx_conf = self.config.parser.abs_path('nginx.conf')
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
# Choose a version of Nginx less than 1.3.7 so stapling code doesn't get
# invoked.
self.config.version = (1, 3, 1)
# Get the default SSL vhost
self.config.deploy_cert(
"www.example.com",
"example/cert.pem", "example/key.pem")
"example/cert.pem",
"example/key.pem",
"example/chain.pem",
"example/fullchain.pem")
self.config.deploy_cert(
"another.alias",
"/etc/nginx/cert.pem", "/etc/nginx/key.pem")
"/etc/nginx/cert.pem",
"/etc/nginx/key.pem",
"/etc/nginx/chain.pem",
"/etc/nginx/fullchain.pem")
self.config.save()
self.config.parser.load()
@ -119,35 +150,34 @@ class NginxConfiguratorTest(util.NginxTest):
access_log = os.path.join(self.work_dir, "access.log")
error_log = os.path.join(self.work_dir, "error.log")
self.assertEqual([[['server'],
[['listen', '69.50.225.155:9000'],
[['include', self.config.parser.loc["ssl_options"]],
['ssl_certificate_key', 'example/key.pem'],
['ssl_certificate', 'example/fullchain.pem'],
['error_log', error_log],
['access_log', access_log],
['listen', '5001 ssl'],
['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
['listen', '5001 ssl'],
['access_log', access_log],
['error_log', error_log],
['ssl_certificate', 'example/cert.pem'],
['ssl_certificate_key', 'example/key.pem'],
['include',
self.config.parser.loc["ssl_options"]]]]],
['server_name', 'example.*']]]],
parsed_example_conf)
self.assertEqual([['server_name', 'somename alias another.alias']],
parsed_server_conf)
self.assertEqual([['server'],
[['listen', '8000'],
['listen', 'somename:8080'],
['include', 'server.conf'],
[['location', '/'],
[['root', 'html'],
['index', 'index.html index.htm']]],
['listen', '5001 ssl'],
['access_log', access_log],
['error_log', error_log],
['ssl_certificate', '/etc/nginx/cert.pem'],
['ssl_certificate_key', '/etc/nginx/key.pem'],
['include',
self.config.parser.loc["ssl_options"]]]],
parsed_nginx_conf[-1][-1][-1])
self.assertTrue(util.contains_at_depth(parsed_nginx_conf,
[['server'],
[['include', self.config.parser.loc["ssl_options"]],
['ssl_certificate_key', '/etc/nginx/key.pem'],
['ssl_certificate', '/etc/nginx/fullchain.pem'],
['error_log', error_log],
['access_log', access_log],
['listen', '5001 ssl'],
['listen', '8000'],
['listen', 'somename:8080'],
['include', 'server.conf'],
[['location', '/'],
[['root', 'html'], ['index', 'index.html index.htm']]]]],
2))
def test_get_all_certs_keys(self):
nginx_conf = self.config.parser.abs_path('nginx.conf')
@ -156,16 +186,22 @@ class NginxConfiguratorTest(util.NginxTest):
# Get the default SSL vhost
self.config.deploy_cert(
"www.example.com",
"example/cert.pem", "example/key.pem")
"example/cert.pem",
"example/key.pem",
"example/chain.pem",
"example/fullchain.pem")
self.config.deploy_cert(
"another.alias",
"/etc/nginx/cert.pem", "/etc/nginx/key.pem")
"/etc/nginx/cert.pem",
"/etc/nginx/key.pem",
"/etc/nginx/chain.pem",
"/etc/nginx/fullchain.pem")
self.config.save()
self.config.parser.load()
self.assertEqual(set([
('example/cert.pem', 'example/key.pem', example_conf),
('/etc/nginx/cert.pem', '/etc/nginx/key.pem', nginx_conf),
('example/fullchain.pem', 'example/key.pem', example_conf),
('/etc/nginx/fullchain.pem', '/etc/nginx/key.pem', nginx_conf),
]), self.config.get_all_certs_keys())
@mock.patch("letsencrypt_nginx.configurator.dvsni.NginxDvsni.perform")

View file

@ -85,7 +85,8 @@ class DvsniPerformTest(util.NginxTest):
# Make sure challenge config is included in main config
http = self.sni.configurator.parser.parsed[
self.sni.configurator.parser.loc["root"]][-1]
self.assertTrue(['include', self.sni.challenge_conf] in http[1])
self.assertTrue(
util.contains_at_depth(http, ['include', self.sni.challenge_conf], 1))
def test_perform2(self):
acme_responses = []
@ -108,7 +109,8 @@ class DvsniPerformTest(util.NginxTest):
http = self.sni.configurator.parser.parsed[
self.sni.configurator.parser.loc["root"]][-1]
self.assertTrue(['include', self.sni.challenge_conf] in http[1])
self.assertTrue(['server_name', 'blah'] in http[1][-2][1])
self.assertTrue(
util.contains_at_depth(http, ['server_name', 'blah'], 3))
self.assertEqual(len(sni_responses), 3)
for i in xrange(3):

View file

@ -128,18 +128,20 @@ class NginxParserTest(util.NginxTest):
r'~^(www\.)?(example|bar)\.']),
[['foo', 'bar'], ['ssl_certificate',
'/etc/ssl/cert.pem']])
ssl_re = re.compile(r'foo bar;\n\s+ssl_certificate /etc/ssl/cert.pem')
self.assertEqual(1, len(re.findall(ssl_re, nginxparser.dumps(
nparser.parsed[nparser.abs_path('nginx.conf')]))))
nparser.add_server_directives(nparser.abs_path('server.conf'),
ssl_re = re.compile(r'\n\s+ssl_certificate /etc/ssl/cert.pem')
dump = nginxparser.dumps(nparser.parsed[nparser.abs_path('nginx.conf')])
self.assertEqual(1, len(re.findall(ssl_re, dump)))
server_conf = nparser.abs_path('server.conf')
nparser.add_server_directives(server_conf,
set(['alias', 'another.alias',
'somename']),
[['foo', 'bar'], ['ssl_certificate',
'/etc/ssl/cert2.pem']])
self.assertEqual(nparser.parsed[nparser.abs_path('server.conf')],
[['server_name', 'somename alias another.alias'],
self.assertEqual(nparser.parsed[server_conf],
[['ssl_certificate', '/etc/ssl/cert2.pem'],
['foo', 'bar'],
['ssl_certificate', '/etc/ssl/cert2.pem']])
['server_name', 'somename alias another.alias']])
def test_add_http_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
@ -148,8 +150,15 @@ class NginxParserTest(util.NginxTest):
[['listen', '80'],
['server_name', 'localhost']]]
nparser.add_http_directives(filep, block)
self.assertEqual(nparser.parsed[filep][-1][0], ['http'])
self.assertEqual(nparser.parsed[filep][-1][1][-1], block)
root = nparser.parsed[filep]
self.assertTrue(util.contains_at_depth(root, ['http'], 1))
self.assertTrue(util.contains_at_depth(root, block, 2))
# Check that our server block got inserted first among all server
# blocks.
http_block = [x for x in root if x[0] == ['http']][0][1]
server_blocks = [x for x in http_block if x[0] == ['server']]
self.assertEqual(server_blocks[0], block)
def test_replace_server_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)

View file

@ -85,3 +85,22 @@ def filter_comments(tree):
yield [key, values]
return list(traverse(tree))
def contains_at_depth(haystack, needle, n):
"""Is the needle in haystack at depth n?
Return true if the needle is present in one of the sub-iterables in haystack
at depth n. Haystack must be an iterable.
"""
# Specifically use hasattr rather than isinstance(..., collections.Iterable)
# because we want to include lists but reject strings.
if not hasattr(haystack, '__iter__'):
return False
if n == 0:
return needle in haystack
else:
for item in haystack:
if contains_at_depth(item, needle, n - 1):
return True
return False

View file

@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
version = '0.0.0.dev20151008'
version = '0.1.0.dev0'
install_requires = [
'acme=={0}'.format(version),

View file

@ -20,7 +20,6 @@ events {
}
http {
server_names_hash_bucket_size 2048;
# Set an array of temp and cache file options that will otherwise default to
# restricted locations accessible only to root.
client_body_temp_path $root/client_body;

View file

@ -1,4 +1,4 @@
"""Let's Encrypt client."""
# version number like 1.2.3a0, must have at least 2 parts, like 1.2
__version__ = '0.0.0.dev20151008'
__version__ = '0.1.0.dev0'

View file

@ -19,8 +19,6 @@ Note, that all annotated challenges act as a proxy objects::
"""
import logging
import OpenSSL
from acme import challenges
from acme import jose
@ -56,10 +54,10 @@ class DVSNI(AnnotatedChallenge):
__slots__ = ('challb', 'domain', 'account_key')
acme_type = challenges.DVSNI
def gen_cert_and_response(self, key_pem=None, bits=2048, alg=jose.RS256):
def gen_cert_and_response(self, key=None, bits=2048, alg=jose.RS256):
"""Generate a DVSNI cert and response.
:param bytes key_pem: Private PEM-encoded key used for
:param OpenSSL.crypto.PKey key: Private key used for
certificate generation. If none provided, a fresh key will
be generated.
:param int bits: Number of bits for fresh key generation.
@ -67,23 +65,15 @@ class DVSNI(AnnotatedChallenge):
:returns: ``(response, cert_pem, key_pem)`` tuple, where
``response`` is an instance of
`acme.challenges.DVSNIResponse`, ``cert_pem`` is the
PEM-encoded certificate and ``key_pem`` is PEM-encoded
private key.
`acme.challenges.DVSNIResponse`, ``cert`` is a certificate
(`OpenSSL.crypto.X509`) and ``key`` is a private key
(`OpenSSL.crypto.PKey`).
:rtype: tuple
"""
key = None if key_pem is None else OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key_pem)
response = self.challb.chall.gen_response(self.account_key, alg=alg)
cert, key = response.gen_cert(key=key, bits=bits)
cert_pem = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert)
key_pem = OpenSSL.crypto.dump_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key)
return response, cert_pem, key_pem
return response, cert, key
class SimpleHTTP(AnnotatedChallenge):

View file

@ -301,6 +301,12 @@ def _auth_from_domains(le_client, config, domains, plugins):
raise errors.Error("Certificate could not be obtained")
_report_new_cert(lineage.cert)
reporter_util = zope.component.getUtility(interfaces.IReporter)
reporter_util.add_message(
"Your certificate will expire on {0}. To obtain a new version of the "
"certificate in the future, simply run this client again.".format(
lineage.notafter().date()),
reporter_util.MEDIUM_PRIORITY)
return lineage
@ -337,9 +343,9 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo
lineage = _auth_from_domains(le_client, config, domains, plugins)
# TODO: We also need to pass the fullchain (for Nginx)
le_client.deploy_certificate(
domains, lineage.privkey, lineage.cert, lineage.chain)
domains, lineage.privkey, lineage.cert,
lineage.chain, lineage.fullchain)
le_client.enhance_config(domains, args.redirect)
if len(lineage.available_versions("cert")) == 1:
@ -392,7 +398,8 @@ def install(args, config, plugins):
args, config, authenticator=None, installer=installer)
assert args.cert_path is not None # required=True in the subparser
le_client.deploy_certificate(
domains, args.key_path, args.cert_path, args.chain_path)
domains, args.key_path, args.cert_path, args.chain_path,
args.fullchain_path)
le_client.enhance_config(domains, args.redirect)
@ -548,7 +555,7 @@ class HelpfulArgumentParser(object):
for i, token in enumerate(args):
if token in VERBS:
reordered = args[:i] + args[i+1:] + [args[i]]
reordered = args[:i] + args[(i + 1):] + [args[i]]
self.verb = token
return reordered
@ -803,6 +810,8 @@ def _paths_parser(helpful):
default_cp = None
if verb == "auth":
default_cp = flag_default("auth_chain_path")
add("paths", "--fullchain-path", default=default_cp,
help="Accompanying path to a full certificate chain (cert plus chain).")
add("paths", "--chain-path", default=default_cp,
help="Accompanying path to a certificate chain.")
add("paths", "--config-dir", default=flag_default("config_dir"),
@ -839,9 +848,23 @@ def _plugins_parsing(helpful, plugins):
helpful.add_plugin_args(plugins)
def _setup_logging(args):
level = -args.verbose_count * 10
fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
def setup_log_file_handler(args, logfile, fmt):
"""Setup file debug logging."""
log_file_path = os.path.join(args.logs_dir, logfile)
handler = logging.handlers.RotatingFileHandler(
log_file_path, maxBytes=2 ** 20, backupCount=10)
# rotate on each invocation, rollover only possible when maxBytes
# is nonzero and backupCount is nonzero, so we set maxBytes as big
# as possible not to overrun in single CLI invocation (1MB).
handler.doRollover() # TODO: creates empty letsencrypt.log.1 file
handler.setLevel(logging.DEBUG)
handler_formatter = logging.Formatter(fmt=fmt)
handler_formatter.converter = time.gmtime # don't use localtime
handler.setFormatter(handler_formatter)
return handler, log_file_path
def _cli_log_handler(args, level, fmt):
if args.text_mode:
handler = colored_logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt))
@ -850,30 +873,26 @@ def _setup_logging(args):
# dialog box is small, display as less as possible
handler.setFormatter(logging.Formatter("%(message)s"))
handler.setLevel(level)
return handler
def setup_logging(args, cli_handler_factory, logfile):
"""Setup logging."""
fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
level = -args.verbose_count * 10
file_handler, log_file_path = setup_log_file_handler(
args, logfile=logfile, fmt=fmt)
cli_handler = cli_handler_factory(args, level, fmt)
# TODO: use fileConfig?
# unconditionally log to file for debugging purposes
# TODO: change before release?
log_file_name = os.path.join(args.logs_dir, 'letsencrypt.log')
file_handler = logging.handlers.RotatingFileHandler(
log_file_name, maxBytes=2 ** 20, backupCount=10)
# rotate on each invocation, rollover only possible when maxBytes
# is nonzero and backupCount is nonzero, so we set maxBytes as big
# as possible not to overrun in single CLI invocation (1MB).
file_handler.doRollover() # TODO: creates empty letsencrypt.log.1 file
file_handler.setLevel(logging.DEBUG)
file_handler_formatter = logging.Formatter(fmt=fmt)
file_handler_formatter.converter = time.gmtime # don't use localtime
file_handler.setFormatter(file_handler_formatter)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG) # send all records to handlers
root_logger.addHandler(handler)
root_logger.addHandler(cli_handler)
root_logger.addHandler(file_handler)
logger.debug("Root logging level set at %d", level)
logger.info("Saving debug log to %s", log_file_name)
logger.info("Saving debug log to %s", log_file_path)
def _handle_exception(exc_type, exc_value, trace, args):
@ -942,7 +961,7 @@ def main(cli_args=sys.argv[1:]):
# private key! #525
le_util.make_or_verify_dir(
args.logs_dir, 0o700, os.geteuid(), "--strict-permissions" in cli_args)
_setup_logging(args)
setup_logging(args, _cli_log_handler, logfile='letsencrypt.log')
# do not log `args`, as it contains sensitive data (e.g. revoke --key)!
logger.debug("Arguments: %r", cli_args)

View file

@ -5,7 +5,6 @@ import os
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
import OpenSSL
import zope.component
from acme import client as acme_client
from acme import jose
@ -19,7 +18,6 @@ from letsencrypt import continuity_auth
from letsencrypt import crypto_util
from letsencrypt import errors
from letsencrypt import error_handler
from letsencrypt import interfaces
from letsencrypt import le_util
from letsencrypt import reverter
from letsencrypt import storage
@ -258,32 +256,8 @@ class Client(object):
OpenSSL.crypto.FILETYPE_PEM, certr.body),
key.pem, crypto_util.dump_pyopenssl_chain(chain),
params, config, cli_config)
self._report_renewal_status(lineage)
return lineage
def _report_renewal_status(self, cert):
# pylint: disable=no-self-use
"""Informs the user about automatic renewal and deployment.
:param .RenewableCert cert: Newly issued certificate
"""
if cert.autorenewal_is_enabled():
if cert.autodeployment_is_enabled():
msg = "Automatic renewal and deployment has "
else:
msg = "Automatic renewal but not automatic deployment has "
elif cert.autodeployment_is_enabled():
msg = "Automatic deployment but not automatic renewal has "
else:
msg = "Automatic renewal and deployment has not "
msg += ("been enabled for your certificate. These settings can be "
"configured in the directories under {0}.").format(
cert.cli_config.renewal_configs_dir)
reporter = zope.component.getUtility(interfaces.IReporter)
reporter.add_message(msg, reporter.LOW_PRIORITY)
def save_certificate(self, certr, chain_cert, cert_path, chain_path):
# pylint: disable=no-self-use
"""Saves the certificate received from the ACME server.
@ -336,7 +310,8 @@ class Client(object):
return os.path.abspath(act_cert_path), cert_chain_abspath
def deploy_certificate(self, domains, privkey_path, cert_path, chain_path):
def deploy_certificate(self, domains, privkey_path,
cert_path, chain_path, fullchain_path):
"""Install certificate
:param list domains: list of domains to install the certificate
@ -357,8 +332,10 @@ class Client(object):
# TODO: Provide a fullchain reference for installers like
# nginx that want it
self.installer.deploy_cert(
dom, os.path.abspath(cert_path),
os.path.abspath(privkey_path), chain_path)
domain=dom, cert_path=os.path.abspath(cert_path),
key_path=os.path.abspath(privkey_path),
chain_path=chain_path,
fullchain_path=fullchain_path)
self.installer.save("Deployed Let's Encrypt Certificate")
# sites may have been enabled / final cleanup

View file

@ -4,7 +4,10 @@ import urlparse
import zope.interface
from acme import challenges
from letsencrypt import constants
from letsencrypt import errors
from letsencrypt import interfaces
@ -34,6 +37,11 @@ class NamespaceConfig(object):
def __init__(self, namespace):
self.namespace = namespace
if self.simple_http_port == self.dvsni_port:
raise errors.Error(
"Trying to run SimpleHTTP and DVSNI "
"on the same port ({0})".format(self.dvsni_port))
def __getattr__(self, name):
return getattr(self.namespace, name)
@ -69,6 +77,13 @@ class NamespaceConfig(object):
return os.path.join(
self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR)
@property
def simple_http_port(self): # pylint: disable=missing-docstring
if self.namespace.simple_http_port is not None:
return self.namespace.simple_http_port
else:
return challenges.SimpleHTTPResponse.PORT
class RenewerConfiguration(object):
"""Configuration wrapper for renewer."""

View file

@ -27,6 +27,7 @@ CLI_DEFAULTS = dict(
auth_cert_path="./cert.pem",
auth_chain_path="./chain.pem",
strict_permissions=False,
)
"""Defaults for CLI flags and `.IConfig` attributes."""

View file

@ -124,7 +124,7 @@ def get_email():
"""
while True:
code, email = zope.component.getUtility(interfaces.IDisplay).input(
"Enter email address")
"Enter email address (used for urgent notices and lost key recovery)")
if code == display_util.OK:
if le_util.safe_email(email):

View file

@ -80,3 +80,13 @@ class NotSupportedError(PluginError):
class RevokerError(Error):
"""Let's Encrypt Revoker error."""
class StandaloneBindError(Error):
"""Standalone plugin bind error."""
def __init__(self, socket_error, port):
super(StandaloneBindError, self).__init__(
"Problem binding to port {0}: {1}".format(port, socket_error))
self.socket_error = socket_error
self.port = port

View file

@ -241,13 +241,15 @@ class IInstaller(IPlugin):
"""
def deploy_cert(domain, cert_path, key_path, chain_path=None):
def deploy_cert(domain, cert_path, key_path, chain_path, fullchain_path):
"""Deploy certificate.
:param str domain: domain to deploy certificate file
:param str cert_path: absolute path to the certificate file
:param str key_path: absolute path to the private key file
:param str chain_path: absolute path to the certificate chain file
:param str fullchain_path: absolute path to the certificate fullchain
file (cert plus chain)
:raises .PluginError: when cert cannot be deployed

View file

@ -5,6 +5,7 @@ import re
import shutil
import tempfile
import OpenSSL
import zope.interface
from acme.jose import util as jose_util
@ -45,6 +46,10 @@ class Plugin(object):
"""ArgumentParser options namespace (prefix of all options)."""
return option_namespace(self.name)
def option_name(self, name):
"""Option name (include plugin namespace)."""
return self.option_namespace + name
@property
def dest_namespace(self):
"""ArgumentParser dest namespace (prefix of all destinations)."""
@ -181,7 +186,11 @@ class Dvsni(object):
self.configurator.reverter.register_file_creation(True, key_path)
self.configurator.reverter.register_file_creation(True, cert_path)
response, cert_pem, key_pem = achall.gen_cert_and_response(s)
response, cert, key = achall.gen_cert_and_response(s)
cert_pem = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert)
key_pem = OpenSSL.crypto.dump_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key)
# Write out challenge cert and key
with open(cert_path, "wb") as cert_chall_fd:

View file

@ -2,6 +2,7 @@
import unittest
import mock
import OpenSSL
from acme import challenges
from acme import jose
@ -50,6 +51,9 @@ class PluginTest(unittest.TestCase):
def test_option_namespace(self):
self.assertEqual("mock-", self.plugin.option_namespace)
def test_option_name(self):
self.assertEqual("mock-foo_bar", self.plugin.option_name("foo_bar"))
def test_dest_namespace(self):
self.assertEqual("mock_", self.plugin.dest_namespace)
@ -144,7 +148,9 @@ class DvsniTest(unittest.TestCase):
response = challenges.DVSNIResponse(validation=mock.Mock())
achall = mock.MagicMock()
achall.gen_cert_and_response.return_value = (response, "cert", "key")
key = test_util.load_pyopenssl_private_key("rsa512_key.pem")
achall.gen_cert_and_response.return_value = (
response, test_util.load_cert("cert.pem"), key)
with mock.patch("letsencrypt.plugins.common.open",
mock_open, create=True):
@ -156,10 +162,12 @@ class DvsniTest(unittest.TestCase):
# pylint: disable=no-member
mock_open.assert_called_once_with(self.sni.get_cert_path(achall), "wb")
mock_open.return_value.write.assert_called_once_with("cert")
mock_open.return_value.write.assert_called_once_with(
test_util.load_vector("cert.pem"))
mock_safe_open.assert_called_once_with(
self.sni.get_key_path(achall), "wb", chmod=0o400)
mock_safe_open.return_value.write.assert_called_once_with("key")
mock_safe_open.return_value.write.assert_called_once_with(
OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key))
if __name__ == "__main__":

View file

@ -8,11 +8,11 @@ import zope.interface
from letsencrypt import errors
from letsencrypt import interfaces
from letsencrypt.plugins.standalone import authenticator
from letsencrypt.plugins import standalone
EP_SA = pkg_resources.EntryPoint(
"sa", "letsencrypt.plugins.standalone.authenticator",
attrs=("StandaloneAuthenticator",),
"sa", "letsencrypt.plugins.standalone",
attrs=("Authenticator",),
dist=mock.MagicMock(key="letsencrypt"))
@ -71,8 +71,7 @@ class PluginEntryPointTest(unittest.TestCase):
self.assertTrue(self.plugin_ep.entry_point is EP_SA)
self.assertEqual("sa", self.plugin_ep.name)
self.assertTrue(
self.plugin_ep.plugin_cls is authenticator.StandaloneAuthenticator)
self.assertTrue(self.plugin_ep.plugin_cls is standalone.Authenticator)
def test_init(self):
config = mock.MagicMock()
@ -174,8 +173,7 @@ class PluginsRegistryTest(unittest.TestCase):
with mock.patch("letsencrypt.plugins.disco.pkg_resources") as mock_pkg:
mock_pkg.iter_entry_points.return_value = iter([EP_SA])
plugins = PluginsRegistry.find_all()
self.assertTrue(plugins["sa"].plugin_cls
is authenticator.StandaloneAuthenticator)
self.assertTrue(plugins["sa"].plugin_cls is standalone.Authenticator)
self.assertTrue(plugins["sa"].entry_point is EP_SA)
def test_getitem(self):

View file

@ -129,12 +129,18 @@ binary for temporary key/certificate generation.""".replace("\n", "")
ct=response.CONTENT_TYPE, 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
if sys.platform == "darwin":
executable = "/bin/bash"
else:
executable = None
try:
self._httpd = subprocess.Popen(
command,
# don't care about setting stdout and stderr,
# we're in test mode anyway
shell=True,
executable=executable,
# "preexec_fn" is UNIX specific, but so is "command"
preexec_fn=os.setsid)
except OSError as error: # ValueError should not happen!

View file

@ -30,7 +30,8 @@ class Installer(common.Plugin):
def get_all_names(self):
return []
def deploy_cert(self, domain, cert_path, key_path, chain_path=None):
def deploy_cert(self, domain, cert_path, key_path,
chain_path=None, fullchain_path=None):
pass # pragma: no cover
def enhance(self, domain, enhancement, options=None):

View file

@ -0,0 +1,256 @@
"""Standalone Authenticator."""
import argparse
import collections
import logging
import random
import socket
import threading
import OpenSSL
import six
import zope.interface
from acme import challenges
from acme import crypto_util as acme_crypto_util
from acme import standalone as acme_standalone
from letsencrypt import achallenges
from letsencrypt import errors
from letsencrypt import interfaces
from letsencrypt.plugins import common
from letsencrypt.plugins import util
logger = logging.getLogger(__name__)
class ServerManager(object):
"""Standalone servers manager.
Manager for `ACMEServer` and `ACMETLSServer` instances.
`certs` and `simple_http_resources` correspond to
`acme.crypto_util.SSLSocket.certs` and
`acme.crypto_util.SSLSocket.simple_http_resources` respectively. All
created servers share the same certificates and resources, so if
you're running both TLS and non-TLS instances, SimpleHTTP handlers
will serve the same URLs!
"""
_Instance = collections.namedtuple("_Instance", "server thread")
def __init__(self, certs, simple_http_resources):
self._instances = {}
self.certs = certs
self.simple_http_resources = simple_http_resources
def run(self, port, challenge_type):
"""Run ACME server on specified ``port``.
This method is idempotent, i.e. all calls with the same pair of
``(port, challenge_type)`` will reuse the same server.
:param int port: Port to run the server on.
:param challenge_type: Subclass of `acme.challenges.Challenge`,
either `acme.challenge.SimpleHTTP` or `acme.challenges.DVSNI`.
:returns: Server instance.
:rtype: ACMEServerMixin
"""
assert challenge_type in (challenges.DVSNI, challenges.SimpleHTTP)
if port in self._instances:
return self._instances[port].server
address = ("", port)
try:
if challenge_type is challenges.DVSNI:
server = acme_standalone.DVSNIServer(address, self.certs)
else: # challenges.SimpleHTTP
server = acme_standalone.SimpleHTTPServer(
address, self.simple_http_resources)
except socket.error as error:
raise errors.StandaloneBindError(error, port)
# if port == 0, then random free port on OS is taken
# pylint: disable=no-member
host, real_port = server.socket.getsockname()
thread = threading.Thread(target=server.serve_forever2)
logger.debug("Starting server at %s:%d", host, real_port)
thread.start()
self._instances[real_port] = self._Instance(server, thread)
return server
def stop(self, port):
"""Stop ACME server running on the specified ``port``.
:param int port:
"""
instance = self._instances[port]
instance.server.shutdown2()
instance.thread.join()
del self._instances[port]
def running(self):
"""Return all running instances.
Once the server is stopped using `stop`, it will not be
returned.
:returns: Mapping from ``port`` to ``server``.
:rtype: tuple
"""
return dict((port, instance.server) for port, instance
in six.iteritems(self._instances))
SUPPORTED_CHALLENGES = set([challenges.DVSNI, challenges.SimpleHTTP])
def supported_challenges_validator(data):
"""Supported challenges validator for the `argparse`.
It should be passed as `type` argument to `add_argument`.
"""
challs = data.split(",")
unrecognized = [name for name in challs
if name not in challenges.Challenge.TYPES]
if unrecognized:
raise argparse.ArgumentTypeError(
"Unrecognized challenges: {0}".format(", ".join(unrecognized)))
choices = set(chall.typ for chall in SUPPORTED_CHALLENGES)
if not set(challs).issubset(choices):
raise argparse.ArgumentTypeError(
"Plugin does not support the following (valid) "
"challenges: {0}".format(", ".join(set(challs) - choices)))
return data
class Authenticator(common.Plugin):
"""Standalone Authenticator.
This authenticator creates its own ephemeral TCP listener on the
necessary port in order to respond to incoming DVSNI and SimpleHTTP
challenges from the certificate authority. Therefore, it does not
rely on any existing server program.
"""
zope.interface.implements(interfaces.IAuthenticator)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Standalone Authenticator"
def __init__(self, *args, **kwargs):
super(Authenticator, self).__init__(*args, **kwargs)
# one self-signed key for all DVSNI and SimpleHTTP certificates
self.key = OpenSSL.crypto.PKey()
self.key.generate_key(OpenSSL.crypto.TYPE_RSA, bits=2048)
# TODO: generate only when the first SimpleHTTP challenge is solved
self.simple_http_cert = acme_crypto_util.gen_ss_cert(
self.key, domains=["temp server"])
self.served = collections.defaultdict(set)
# Stuff below is shared across threads (i.e. servers read
# values, main thread writes). Due to the nature of CPython's
# GIL, the operations are safe, c.f.
# https://docs.python.org/2/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe
self.certs = {}
self.simple_http_resources = set()
self.servers = ServerManager(self.certs, self.simple_http_resources)
@classmethod
def add_parser_arguments(cls, add):
add("supported-challenges", help="Supported challenges, "
"order preferences are randomly chosen.",
type=supported_challenges_validator, default=",".join(
sorted(chall.typ for chall in SUPPORTED_CHALLENGES)))
@property
def supported_challenges(self):
"""Challenges supported by this plugin."""
return set(challenges.Challenge.TYPES[name] for name in
self.conf("supported-challenges").split(","))
def more_info(self): # pylint: disable=missing-docstring
return self.__doc__
def prepare(self): # pylint: disable=missing-docstring
pass
def get_chall_pref(self, domain):
# pylint: disable=unused-argument,missing-docstring
chall_pref = list(self.supported_challenges)
random.shuffle(chall_pref) # 50% for each challenge
return chall_pref
def perform(self, achalls): # pylint: disable=missing-docstring
if any(util.already_listening(port) for port in
(self.config.dvsni_port, self.config.simple_http_port)):
raise errors.MisconfigurationError(
"At least one of the (possibly) required ports is "
"already taken.")
try:
return self.perform2(achalls)
except errors.StandaloneBindError as error:
display = zope.component.getUtility(interfaces.IDisplay)
if error.socket_error.errno == socket.errno.EACCES:
display.notification(
"Could not bind TCP port {0} because you don't have "
"the appropriate permissions (for example, you "
"aren't running this program as "
"root).".format(error.port))
elif error.socket_error.errno == socket.errno.EADDRINUSE:
display.notification(
"Could not bind TCP port {0} because it is already in "
"use by another process on this system (such as a web "
"server). Please stop the program in question and then "
"try again.".format(error.port))
else:
raise # XXX: How to handle unknown errors in binding?
def perform2(self, achalls):
"""Perform achallenges without IDisplay interaction."""
responses = []
for achall in achalls:
if isinstance(achall, achallenges.SimpleHTTP):
server = self.servers.run(
self.config.simple_http_port, challenges.SimpleHTTP)
response, validation = achall.gen_response_and_validation(
tls=False)
self.simple_http_resources.add(
acme_standalone.SimpleHTTPRequestHandler.SimpleHTTPResource(
chall=achall.chall, response=response,
validation=validation))
cert = self.simple_http_cert
domain = achall.domain
else: # DVSNI
server = self.servers.run(self.config.dvsni_port, challenges.DVSNI)
response, cert, _ = achall.gen_cert_and_response(self.key)
domain = response.z_domain
self.certs[domain] = (self.key, cert)
self.served[server].add(achall)
responses.append(response)
return responses
def cleanup(self, achalls): # pylint: disable=missing-docstring
# reduce self.served and close servers if none challenges are served
for server, server_achalls in self.served.items():
for achall in achalls:
if achall in server_achalls:
server_achalls.remove(achall)
for port, server in six.iteritems(self.servers.running()):
if not self.served[server]:
self.servers.stop(port)

View file

@ -1 +0,0 @@
"""Let's Encrypt Standalone Authenticator plugin."""

View file

@ -1,436 +0,0 @@
"""Standalone authenticator."""
import os
import psutil
import signal
import socket
import sys
import time
import OpenSSL
import zope.component
import zope.interface
from acme import challenges
from letsencrypt import achallenges
from letsencrypt import crypto_util
from letsencrypt import interfaces
from letsencrypt.plugins import common
class StandaloneAuthenticator(common.Plugin):
# pylint: disable=too-many-instance-attributes
"""Standalone authenticator.
This authenticator creates its own ephemeral TCP listener on the
specified port in order to respond to incoming DVSNI challenges from
the certificate authority. Therefore, it does not rely on any
existing server program.
:param OpenSSL.crypto.PKey private_key: DVSNI challenge certificate
key.
:param sni_names: Mapping from z_domain (`bytes`) to PEM-encoded
certificate (`bytes`).
"""
zope.interface.implements(interfaces.IAuthenticator)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Standalone Authenticator"
def __init__(self, *args, **kwargs):
super(StandaloneAuthenticator, self).__init__(*args, **kwargs)
self.child_pid = None
self.parent_pid = os.getpid()
self.subproc_state = None
self.tasks = {}
self.sni_names = {}
self.sock = None
self.connection = None
self.key_pem = crypto_util.make_key(bits=2048)
self.private_key = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, self.key_pem)
self.ssl_conn = None
def prepare(self):
"""There is nothing left to setup.
.. todo:: This should probably do the port check
"""
def client_signal_handler(self, sig, unused_frame):
"""Signal handler for the parent process.
This handler receives inter-process communication from the
child process in the form of Unix signals.
:param int sig: Which signal the process received.
"""
# subprocess to client READY: SIGIO
# subprocess to client INUSE: SIGUSR1
# subprocess to client CANTBIND: SIGUSR2
if sig == signal.SIGIO:
self.subproc_state = "ready"
elif sig == signal.SIGUSR1:
self.subproc_state = "inuse"
elif sig == signal.SIGUSR2:
self.subproc_state = "cantbind"
else:
# NOTREACHED
raise ValueError("Unexpected signal in signal handler")
def subproc_signal_handler(self, sig, unused_frame):
"""Signal handler for the child process.
This handler receives inter-process communication from the parent
process in the form of Unix signals.
:param int sig: Which signal the process received.
"""
# client to subprocess CLEANUP : SIGINT
if sig == signal.SIGINT:
try:
self.ssl_conn.shutdown()
self.ssl_conn.close()
except BaseException:
# There might not even be any currently active SSL connection.
pass
try:
self.connection.close()
except BaseException:
# There might not even be any currently active connection.
pass
try:
self.sock.close()
except BaseException:
# Various things can go wrong in the course of closing these
# connections, but none of them can clearly be usefully
# reported here and none of them should impede us from
# exiting as gracefully as possible.
pass
os.kill(self.parent_pid, signal.SIGUSR1)
sys.exit(0)
def sni_callback(self, connection):
"""Used internally to respond to incoming SNI names.
This method will set a new OpenSSL context object for this
connection when an incoming connection provides an SNI name
(in order to serve the appropriate certificate, if any).
:param connection: The TLS connection object on which the SNI
extension was received.
:type connection: :class:`OpenSSL.Connection`
"""
sni_name = connection.get_servername()
if sni_name in self.sni_names:
pem_cert = self.sni_names[sni_name]
else:
# TODO: Should we really present a certificate if we get an
# unexpected SNI name? Or should we just disconnect?
pem_cert = next(self.sni_names.itervalues())
cert = OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, pem_cert)
new_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
new_ctx.set_verify(OpenSSL.SSL.VERIFY_NONE, lambda: False)
new_ctx.use_certificate(cert)
new_ctx.use_privatekey(self.private_key)
connection.set_context(new_ctx)
def do_parent_process(self, port, delay_amount=5):
"""Perform the parent process side of the TCP listener task.
This should only be called by :meth:`start_listener`. We will
wait up to delay_amount seconds to hear from the child process
via a signal.
:param int port: Which TCP port to bind.
:param float delay_amount: How long in seconds to wait for the
subprocess to notify us whether it succeeded.
:returns: ``True`` or ``False`` according to whether we were notified
that the child process succeeded or failed in binding the port.
:rtype: bool
"""
display = zope.component.getUtility(interfaces.IDisplay)
start_time = time.time()
while time.time() < start_time + delay_amount:
if self.subproc_state == "ready":
return True
elif self.subproc_state == "inuse":
display.notification(
"Could not bind TCP port {0} because it is already in "
"use by another process on this system (such as a web "
"server). Please stop the program in question and then "
"try again.".format(port))
return False
elif self.subproc_state == "cantbind":
display.notification(
"Could not bind TCP port {0} because you don't have "
"the appropriate permissions (for example, you "
"aren't running this program as "
"root).".format(port))
return False
time.sleep(0.1)
display.notification(
"Subprocess unexpectedly timed out while trying to bind TCP "
"port {0}.".format(port))
return False
def do_child_process(self, port):
"""Perform the child process side of the TCP listener task.
This should only be called by :meth:`start_listener`.
Normally does not return; instead, the child process exits from
within this function or from within the child process signal
handler.
:param int port: Which TCP port to bind.
"""
signal.signal(signal.SIGINT, self.subproc_signal_handler)
self.sock = socket.socket()
# SO_REUSEADDR flag tells the kernel to reuse a local socket
# in TIME_WAIT state, without waiting for its natural timeout
# to expire.
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
self.sock.bind(("0.0.0.0", port))
except socket.error, error:
if error.errno == socket.errno.EACCES:
# Signal permissions denied to bind TCP port
os.kill(self.parent_pid, signal.SIGUSR2)
elif error.errno == socket.errno.EADDRINUSE:
# Signal TCP port is already in use
os.kill(self.parent_pid, signal.SIGUSR1)
else:
# XXX: How to handle unknown errors in binding?
raise error
sys.exit(1)
# XXX: We could use poll mechanism to handle simultaneous
# XXX: rather than sequential inbound TCP connections here
self.sock.listen(1)
# Signal that we've successfully bound TCP port
os.kill(self.parent_pid, signal.SIGIO)
while True:
self.connection, _ = self.sock.accept()
# The code below uses the PyOpenSSL bindings to respond to
# the client. This may expose us to bugs and vulnerabilities
# in OpenSSL (and creates additional dependencies).
ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
ctx.set_verify(OpenSSL.SSL.VERIFY_NONE, lambda: False)
pem_cert = self.tasks.values()[0]
first_cert = OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, pem_cert)
ctx.use_certificate(first_cert)
ctx.use_privatekey(self.private_key)
ctx.set_cipher_list("HIGH")
ctx.set_tlsext_servername_callback(self.sni_callback)
self.ssl_conn = OpenSSL.SSL.Connection(ctx, self.connection)
self.ssl_conn.set_accept_state()
self.ssl_conn.do_handshake()
self.ssl_conn.shutdown()
self.ssl_conn.close()
def start_listener(self, port):
"""Start listener.
Create a child process which will start a TCP listener on the
specified port to perform the specified DVSNI challenges.
:param int port: The TCP port to bind.
:returns: ``True`` or ``False`` to indicate success or failure creating
the subprocess.
:rtype: bool
"""
# In order to avoid a race condition, we set the signal handler
# that will be needed by the parent process now, and undo this
# action if we turn out to be the child process. (This needs
# to happen before the fork because the child will send one of
# these signals to the parent almost immediately after the
# fork, and the parent must already be ready to receive it.)
signal.signal(signal.SIGIO, self.client_signal_handler)
signal.signal(signal.SIGUSR1, self.client_signal_handler)
signal.signal(signal.SIGUSR2, self.client_signal_handler)
sys.stdout.flush()
fork_result = os.fork()
if fork_result:
# PARENT process (still the Let's Encrypt client process)
self.child_pid = fork_result
# do_parent_process() can return True or False to indicate
# reported success or failure creating the listener.
return self.do_parent_process(port)
else:
# CHILD process (the TCP listener subprocess)
# Undo the parent's signal handler settings, which aren't
# applicable to us.
signal.signal(signal.SIGIO, signal.SIG_DFL)
signal.signal(signal.SIGUSR1, signal.SIG_DFL)
signal.signal(signal.SIGUSR2, signal.SIG_DFL)
self.child_pid = os.getpid()
# do_child_process() is normally not expected to return but
# should terminate via sys.exit().
return self.do_child_process(port)
def already_listening(self, port): # pylint: disable=no-self-use
"""Check if a process is already listening on the port.
If so, also tell the user via a display notification.
.. warning::
On some operating systems, this function can only usefully be
run as root.
:param int port: The TCP port in question.
:returns: True or False."""
listeners = [conn.pid for conn in psutil.net_connections()
if conn.status == 'LISTEN' and
conn.type == socket.SOCK_STREAM and
conn.laddr[1] == port]
try:
if listeners and listeners[0] is not None:
# conn.pid may be None if the current process doesn't have
# permission to identify the listening process! Additionally,
# listeners may have more than one element if separate
# sockets have bound the same port on separate interfaces.
# We currently only have UI to notify the user about one
# of them at a time.
pid = listeners[0]
name = psutil.Process(pid).name()
display = zope.component.getUtility(interfaces.IDisplay)
display.notification(
"The program {0} (process ID {1}) is already listening "
"on TCP port {2}. This will prevent us from binding to "
"that port. Please stop the {0} program temporarily "
"and then try again.".format(name, pid, port))
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Perhaps the result of a race where the process could have
# exited or relinquished the port (NoSuchProcess), or the result
# of an OS policy where we're not allowed to look up the process
# name (AccessDenied).
pass
return False
# IAuthenticator method implementations follow
def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use
"""Get challenge preferences.
IAuthenticator interface method get_chall_pref.
Return a list of challenge types that this authenticator
can perform for this domain. In the case of the
StandaloneAuthenticator, the only challenge type that can ever
be performed is dvsni.
:returns: A list containing only 'dvsni'.
"""
return [challenges.DVSNI]
def perform(self, achalls):
"""Perform the challenge.
.. warning::
For the StandaloneAuthenticator, because there is no convenient
way to add additional requests, this should only be invoked
once; subsequent invocations are an error. To perform
validations for multiple independent sets of domains, a separate
StandaloneAuthenticator should be instantiated.
"""
if self.child_pid or self.tasks:
# We should not be willing to continue with perform
# if there were existing pending challenges.
raise ValueError(".perform() was called with pending tasks!")
results_if_success = []
results_if_failure = []
if not achalls or not isinstance(achalls, list):
raise ValueError(".perform() was called without challenge list")
# TODO: "bits" should be user-configurable
for achall in achalls:
if isinstance(achall, achallenges.DVSNI):
# We will attempt to do it
response, cert_pem, _ = achall.gen_cert_and_response(
key_pem=self.key_pem)
self.sni_names[response.z_domain] = cert_pem
self.tasks[achall.token] = cert_pem
results_if_success.append(response)
results_if_failure.append(None)
else:
# We will not attempt to do this challenge because it
# is not a type we can handle
results_if_success.append(False)
results_if_failure.append(False)
if not self.tasks:
raise ValueError("nothing for .perform() to do")
if self.already_listening(self.config.dvsni_port):
# If we know a process is already listening on this port,
# tell the user, and don't even attempt to bind it. (This
# test is Linux-specific and won't indicate that the port
# is bound if invoked on a different operating system.)
return results_if_failure
# Try to do the authentication; note that this creates
# the listener subprocess via os.fork()
if self.start_listener(self.config.dvsni_port):
return results_if_success
else:
# TODO: This should probably raise a DVAuthError exception
# rather than returning a list of None objects.
return results_if_failure
def cleanup(self, achalls):
"""Clean up.
If some challenges are removed from the list, the authenticator
socket will still respond to those challenges. Once all
challenges have been removed from the list, the listener is
deactivated and stops listening.
"""
# Remove this from pending tasks list
for achall in achalls:
assert isinstance(achall, achallenges.DVSNI)
if achall.token in self.tasks:
del self.tasks[achall.token]
else:
# Could not find the challenge to remove!
raise ValueError("could not find the challenge to remove")
if self.child_pid and not self.tasks:
# There are no remaining challenges, so
# try to shutdown self.child_pid cleanly.
# TODO: ignore any signals from child during this process
os.kill(self.child_pid, signal.SIGINT)
time.sleep(1)
# TODO: restore original signal handlers in parent process
# by resetting their actions to SIG_DFL
# print "TCP listener subprocess has been told to shut down"
def more_info(self): # pylint: disable=no-self-use
"""Human-readable string that describes the Authenticator."""
return ("The Standalone Authenticator uses PyOpenSSL to listen "
"on port {port} and perform DVSNI challenges. Once a "
"certificate is attained, it will be saved in the "
"(TODO) current working directory.{linesep}{linesep}"
"TCP port {port} must be available in order to use the "
"Standalone Authenticator.".format(
linesep=os.linesep, port=self.config.dvsni_port))

View file

@ -1 +0,0 @@
"""Let's Encrypt Standalone Tests"""

View file

@ -1,586 +0,0 @@
"""Tests for letsencrypt.plugins.standalone.authenticator."""
import os
import psutil
import signal
import socket
import unittest
import mock
import OpenSSL
from acme import challenges
from acme import jose
from letsencrypt import achallenges
from letsencrypt.tests import acme_util
from letsencrypt.tests import test_util
ACCOUNT_KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
CHALL_KEY_PEM = test_util.load_vector("rsa512_key_2.pem")
CHALL_KEY = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, CHALL_KEY_PEM)
CONFIG = mock.Mock(dvsni_port=5001)
# Classes based on to allow interrupting infinite loop under test
# after one iteration, based on.
# http://igorsobreira.com/2013/03/17/testing-infinite-loops.html
class _SocketAcceptOnlyNTimes(object):
# pylint: disable=too-few-public-methods
"""
Callable that will raise `CallableExhausted`
exception after `limit` calls, modified to also return
a tuple simulating the return values of a socket.accept()
call
"""
def __init__(self, limit):
self.limit = limit
self.calls = 0
def __call__(self):
self.calls += 1
if self.calls > self.limit:
raise CallableExhausted
# Modified here for a single use as socket.accept()
return (mock.MagicMock(), "ignored")
class CallableExhausted(Exception):
# pylint: disable=too-few-public-methods
"""Exception raised when a method is called more than the
specified number of times."""
class ChallPrefTest(unittest.TestCase):
"""Tests for chall_pref() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
def test_chall_pref(self):
self.assertEqual(self.authenticator.get_chall_pref("example.com"),
[challenges.DVSNI])
class SNICallbackTest(unittest.TestCase):
"""Tests for sni_callback() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.cert = achallenges.DVSNI(
challb=acme_util.DVSNI_P,
domain="example.com",
account_key=ACCOUNT_KEY
).gen_cert_and_response(key_pem=CHALL_KEY_PEM)[1]
self.authenticator.private_key = CHALL_KEY
self.authenticator.sni_names = {"abcdef.acme.invalid": self.cert}
self.authenticator.child_pid = 12345
def test_real_servername(self):
connection = mock.MagicMock()
connection.get_servername.return_value = "abcdef.acme.invalid"
self.authenticator.sni_callback(connection)
self.assertEqual(connection.set_context.call_count, 1)
called_ctx = connection.set_context.call_args[0][0]
self.assertTrue(isinstance(called_ctx, OpenSSL.SSL.Context))
def test_fake_servername(self):
"""Test behavior of SNI callback when an unexpected name is received.
(Currently the expected behavior in this case is to return the
"first" certificate with which the listener was configured,
although they are stored in an unordered data structure so
this might not be the one that was first in the challenge list
passed to the perform method. In the future, this might result
in dropping the connection instead.)"""
connection = mock.MagicMock()
connection.get_servername.return_value = "example.com"
self.authenticator.sni_callback(connection)
self.assertEqual(connection.set_context.call_count, 1)
called_ctx = connection.set_context.call_args[0][0]
self.assertTrue(isinstance(called_ctx, OpenSSL.SSL.Context))
class ClientSignalHandlerTest(unittest.TestCase):
"""Tests for client_signal_handler() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.authenticator.tasks = {"footoken.acme.invalid": "stuff"}
self.authenticator.child_pid = 12345
def test_client_signal_handler(self):
self.assertTrue(self.authenticator.subproc_state is None)
self.authenticator.client_signal_handler(signal.SIGIO, None)
self.assertEqual(self.authenticator.subproc_state, "ready")
self.authenticator.client_signal_handler(signal.SIGUSR1, None)
self.assertEqual(self.authenticator.subproc_state, "inuse")
self.authenticator.client_signal_handler(signal.SIGUSR2, None)
self.assertEqual(self.authenticator.subproc_state, "cantbind")
# Testing the unreached path for a signal other than these
# specified (which can't occur in normal use because this
# function is only set as a signal handler for the above three
# signals).
self.assertRaises(
ValueError, self.authenticator.client_signal_handler,
signal.SIGPIPE, None)
class SubprocSignalHandlerTest(unittest.TestCase):
"""Tests for subproc_signal_handler() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.authenticator.tasks = {"footoken.acme.invalid": "stuff"}
self.authenticator.child_pid = 12345
self.authenticator.parent_pid = 23456
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill")
@mock.patch("letsencrypt.plugins.standalone.authenticator.sys.exit")
def test_subproc_signal_handler(self, mock_exit, mock_kill):
self.authenticator.ssl_conn = mock.MagicMock()
self.authenticator.connection = mock.MagicMock()
self.authenticator.sock = mock.MagicMock()
self.authenticator.subproc_signal_handler(signal.SIGINT, None)
self.assertEquals(self.authenticator.ssl_conn.shutdown.call_count, 1)
self.assertEquals(self.authenticator.ssl_conn.close.call_count, 1)
self.assertEquals(self.authenticator.connection.close.call_count, 1)
self.assertEquals(self.authenticator.sock.close.call_count, 1)
mock_kill.assert_called_once_with(
self.authenticator.parent_pid, signal.SIGUSR1)
mock_exit.assert_called_once_with(0)
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill")
@mock.patch("letsencrypt.plugins.standalone.authenticator.sys.exit")
def test_subproc_signal_handler_trouble(self, mock_exit, mock_kill):
"""Test attempting to shut down a non-existent connection.
(This could occur because none was established or active at the
time the signal handler tried to perform the cleanup)."""
self.authenticator.ssl_conn = mock.MagicMock()
self.authenticator.connection = mock.MagicMock()
self.authenticator.sock = mock.MagicMock()
# AttributeError simulates the case where one of these properties
# is None because no connection exists. We raise it for
# ssl_conn.close() instead of ssl_conn.shutdown() for better code
# coverage.
self.authenticator.ssl_conn.close.side_effect = AttributeError("!")
self.authenticator.connection.close.side_effect = AttributeError("!")
self.authenticator.sock.close.side_effect = AttributeError("!")
self.authenticator.subproc_signal_handler(signal.SIGINT, None)
self.assertEquals(self.authenticator.ssl_conn.shutdown.call_count, 1)
self.assertEquals(self.authenticator.ssl_conn.close.call_count, 1)
self.assertEquals(self.authenticator.connection.close.call_count, 1)
self.assertEquals(self.authenticator.sock.close.call_count, 1)
mock_kill.assert_called_once_with(
self.authenticator.parent_pid, signal.SIGUSR1)
mock_exit.assert_called_once_with(0)
class AlreadyListeningTest(unittest.TestCase):
"""Tests for already_listening() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil."
"net_connections")
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process")
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_race_condition(self, mock_get_utility, mock_process, mock_net):
# This tests a race condition, or permission problem, or OS
# incompatibility in which, for some reason, no process name can be
# found to match the identified listening PID.
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None),
sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17),
raddr=(), status="LISTEN", pid=4416)]
mock_net.return_value = conns
mock_process.side_effect = psutil.NoSuchProcess("No such PID")
# We simulate being unable to find the process name of PID 4416,
# which results in returning False.
self.assertFalse(self.authenticator.already_listening(17))
self.assertEqual(mock_get_utility.generic_notification.call_count, 0)
mock_process.assert_called_once_with(4416)
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil."
"net_connections")
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process")
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_not_listening(self, mock_get_utility, mock_process, mock_net):
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None)]
mock_net.return_value = conns
mock_process.name.return_value = "inetd"
self.assertFalse(self.authenticator.already_listening(17))
self.assertEqual(mock_get_utility.generic_notification.call_count, 0)
self.assertEqual(mock_process.call_count, 0)
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil."
"net_connections")
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process")
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_listening_ipv4(self, mock_get_utility, mock_process, mock_net):
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None),
sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17),
raddr=(), status="LISTEN", pid=4416)]
mock_net.return_value = conns
mock_process.name.return_value = "inetd"
result = self.authenticator.already_listening(17)
self.assertTrue(result)
self.assertEqual(mock_get_utility.call_count, 1)
mock_process.assert_called_once_with(4416)
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil."
"net_connections")
@mock.patch("letsencrypt.plugins.standalone.authenticator.psutil.Process")
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_listening_ipv6(self, mock_get_utility, mock_process, mock_net):
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None),
sconn(fd=3, family=10, type=1, laddr=("::", 12345), raddr=(),
status="LISTEN", pid=4420),
sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17),
raddr=(), status="LISTEN", pid=4416)]
mock_net.return_value = conns
mock_process.name.return_value = "inetd"
result = self.authenticator.already_listening(12345)
self.assertTrue(result)
self.assertEqual(mock_get_utility.call_count, 1)
mock_process.assert_called_once_with(4420)
class PerformTest(unittest.TestCase):
"""Tests for perform() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.achall1 = achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(token=b"foo"), "pending"),
domain="foo.example.com", account_key=ACCOUNT_KEY)
self.achall2 = achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(token=b"bar"), "pending"),
domain="bar.example.com", account_key=ACCOUNT_KEY)
bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge")
self.achalls = [self.achall1, self.achall2, bad_achall]
def test_perform_when_already_listening(self):
self.authenticator.already_listening = mock.Mock()
self.authenticator.already_listening.return_value = True
result = self.authenticator.perform([self.achall1])
self.assertEqual(result, [None])
def test_can_perform(self):
"""What happens if start_listener() returns True."""
self.authenticator.start_listener = mock.Mock()
self.authenticator.start_listener.return_value = True
self.authenticator.already_listening = mock.Mock(return_value=False)
result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue(self.achall1.token in self.authenticator.tasks)
self.assertTrue(self.achall2.token in self.authenticator.tasks)
self.assertTrue(isinstance(result, list))
self.assertEqual(len(result), 3)
self.assertTrue(isinstance(result[0], challenges.ChallengeResponse))
self.assertTrue(isinstance(result[1], challenges.ChallengeResponse))
self.assertFalse(result[2])
self.authenticator.start_listener.assert_called_once_with(
CONFIG.dvsni_port)
def test_cannot_perform(self):
"""What happens if start_listener() returns False."""
self.authenticator.start_listener = mock.Mock()
self.authenticator.start_listener.return_value = False
self.authenticator.already_listening = mock.Mock(return_value=False)
result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue(self.achall1.token in self.authenticator.tasks)
self.assertTrue(self.achall2.token in self.authenticator.tasks)
self.assertTrue(isinstance(result, list))
self.assertEqual(len(result), 3)
self.assertEqual(result, [None, None, False])
self.authenticator.start_listener.assert_called_once_with(
CONFIG.dvsni_port)
def test_perform_with_pending_tasks(self):
self.authenticator.tasks = {"footoken.acme.invalid": "cert_data"}
extra_achall = acme_util.DVSNI_P
self.assertRaises(
ValueError, self.authenticator.perform, [extra_achall])
def test_perform_without_challenge_list(self):
extra_achall = acme_util.DVSNI_P
# This is wrong because a challenge must be specified.
self.assertRaises(ValueError, self.authenticator.perform, [])
# This is wrong because it must be a list, not a bare challenge.
self.assertRaises(
ValueError, self.authenticator.perform, extra_achall)
# This is wrong because the list must contain at least one challenge.
self.assertRaises(
ValueError, self.authenticator.perform, range(20))
class StartListenerTest(unittest.TestCase):
"""Tests for start_listener() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.fork")
def test_start_listener_fork_parent(self, mock_fork):
self.authenticator.do_parent_process = mock.Mock()
self.authenticator.do_parent_process.return_value = True
mock_fork.return_value = 22222
result = self.authenticator.start_listener(1717)
# start_listener is expected to return the True or False return
# value from do_parent_process.
self.assertTrue(result)
self.assertEqual(self.authenticator.child_pid, 22222)
self.authenticator.do_parent_process.assert_called_once_with(1717)
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.fork")
def test_start_listener_fork_child(self, mock_fork):
self.authenticator.do_parent_process = mock.Mock()
self.authenticator.do_child_process = mock.Mock()
mock_fork.return_value = 0
self.authenticator.start_listener(1717)
self.assertEqual(self.authenticator.child_pid, os.getpid())
self.authenticator.do_child_process.assert_called_once_with(1717)
class DoParentProcessTest(unittest.TestCase):
"""Tests for do_parent_process() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_do_parent_process_ok(self, mock_get_utility):
self.authenticator.subproc_state = "ready"
result = self.authenticator.do_parent_process(1717)
self.assertTrue(result)
self.assertEqual(mock_get_utility.call_count, 1)
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_do_parent_process_inuse(self, mock_get_utility):
self.authenticator.subproc_state = "inuse"
result = self.authenticator.do_parent_process(1717)
self.assertFalse(result)
self.assertEqual(mock_get_utility.call_count, 1)
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_do_parent_process_cantbind(self, mock_get_utility):
self.authenticator.subproc_state = "cantbind"
result = self.authenticator.do_parent_process(1717)
self.assertFalse(result)
self.assertEqual(mock_get_utility.call_count, 1)
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"zope.component.getUtility")
def test_do_parent_process_timeout(self, mock_get_utility):
# Normally times out in 5 seconds and returns False. We can
# now set delay_amount to a lower value so that it times out
# faster than it would under normal use.
result = self.authenticator.do_parent_process(1717, delay_amount=1)
self.assertFalse(result)
self.assertEqual(mock_get_utility.call_count, 1)
class DoChildProcessTest(unittest.TestCase):
"""Tests for do_child_process() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.cert = achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(token=b"abcdef"), "pending"),
domain="example.com", account_key=ACCOUNT_KEY).gen_cert_and_response(
key_pem=CHALL_KEY_PEM)[1]
self.authenticator.private_key = CHALL_KEY
self.authenticator.tasks = {"abcdef.acme.invalid": self.cert}
self.authenticator.parent_pid = 12345
@mock.patch("letsencrypt.plugins.standalone.authenticator.socket.socket")
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill")
@mock.patch("letsencrypt.plugins.standalone.authenticator.sys.exit")
def test_do_child_process_cantbind1(
self, mock_exit, mock_kill, mock_socket):
mock_exit.side_effect = IndentationError("subprocess would exit here")
eaccess = socket.error(socket.errno.EACCES, "Permission denied")
sample_socket = mock.MagicMock()
sample_socket.bind.side_effect = eaccess
mock_socket.return_value = sample_socket
# Using the IndentationError as an error that cannot easily be
# generated at runtime, to indicate the behavior of sys.exit has
# taken effect without actually causing the test process to exit.
# (Just replacing it with a no-op causes logic errors because the
# do_child_process code assumes that calling sys.exit() will
# cause subsequent code not to be executed.)
self.assertRaises(
IndentationError, self.authenticator.do_child_process, 1717)
mock_exit.assert_called_once_with(1)
mock_kill.assert_called_once_with(12345, signal.SIGUSR2)
@mock.patch("letsencrypt.plugins.standalone.authenticator.socket.socket")
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill")
@mock.patch("letsencrypt.plugins.standalone.authenticator.sys.exit")
def test_do_child_process_cantbind2(self, mock_exit, mock_kill,
mock_socket):
mock_exit.side_effect = IndentationError("subprocess would exit here")
eaccess = socket.error(socket.errno.EADDRINUSE, "Port already in use")
sample_socket = mock.MagicMock()
sample_socket.bind.side_effect = eaccess
mock_socket.return_value = sample_socket
self.assertRaises(
IndentationError, self.authenticator.do_child_process, 1717)
mock_exit.assert_called_once_with(1)
mock_kill.assert_called_once_with(12345, signal.SIGUSR1)
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"socket.socket")
def test_do_child_process_cantbind3(self, mock_socket):
"""Test case where attempt to bind socket results in an unhandled
socket error. (The expected behavior is arguably wrong because it
will crash the program; the reason for the expected behavior is
that we don't have a way to report arbitrary socket errors.)"""
eio = socket.error(socket.errno.EIO, "Imaginary unhandled error")
sample_socket = mock.MagicMock()
sample_socket.bind.side_effect = eio
mock_socket.return_value = sample_socket
self.assertRaises(
socket.error, self.authenticator.do_child_process, 1717)
@mock.patch("letsencrypt.plugins.standalone.authenticator."
"OpenSSL.SSL.Connection")
@mock.patch("letsencrypt.plugins.standalone.authenticator.socket.socket")
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill")
def test_do_child_process_success(
self, mock_kill, mock_socket, mock_connection):
sample_socket = mock.MagicMock()
sample_socket.accept.side_effect = _SocketAcceptOnlyNTimes(2)
mock_socket.return_value = sample_socket
mock_connection.return_value = mock.MagicMock()
self.assertRaises(
CallableExhausted, self.authenticator.do_child_process, 1717)
mock_socket.assert_called_once_with()
sample_socket.bind.assert_called_once_with(("0.0.0.0", 1717))
sample_socket.listen.assert_called_once_with(1)
self.assertEqual(sample_socket.accept.call_count, 3)
mock_kill.assert_called_once_with(12345, signal.SIGIO)
# TODO: We could have some tests about the fact that the listener
# asks OpenSSL to negotiate a TLS connection (and correctly
# sets the SNI callback function).
class CleanupTest(unittest.TestCase):
"""Tests for cleanup() method."""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.achall = achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(token=b"footoken"), "pending"),
domain="foo.example.com", account_key="key")
self.authenticator.tasks = {self.achall.token: "stuff"}
self.authenticator.child_pid = 12345
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill")
@mock.patch("letsencrypt.plugins.standalone.authenticator.time.sleep")
def test_cleanup(self, mock_sleep, mock_kill):
mock_sleep.return_value = None
mock_kill.return_value = None
self.authenticator.cleanup([self.achall])
mock_kill.assert_called_once_with(12345, signal.SIGINT)
mock_sleep.assert_called_once_with(1)
def test_bad_cleanup(self):
self.assertRaises(
ValueError, self.authenticator.cleanup, [achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(token=b"badtoken"), "pending"),
domain="bad.example.com", account_key="key")])
class MoreInfoTest(unittest.TestCase):
"""Tests for more_info() method. (trivially)"""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import (
StandaloneAuthenticator)
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
def test_more_info(self):
"""Make sure exceptions aren't raised."""
self.authenticator.more_info()
class InitTest(unittest.TestCase):
"""Tests for more_info() method. (trivially)"""
def setUp(self):
from letsencrypt.plugins.standalone.authenticator import (
StandaloneAuthenticator)
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
def test_prepare(self):
"""Make sure exceptions aren't raised.
.. todo:: Add on more once things are setup appropriately.
"""
self.authenticator.prepare()
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -0,0 +1,210 @@
"""Tests for letsencrypt.plugins.standalone."""
import argparse
import socket
import unittest
import mock
import six
from acme import challenges
from acme import jose
from acme import standalone as acme_standalone
from letsencrypt import achallenges
from letsencrypt import errors
from letsencrypt import interfaces
from letsencrypt.tests import acme_util
from letsencrypt.tests import test_util
class ServerManagerTest(unittest.TestCase):
"""Tests for letsencrypt.plugins.standalone.ServerManager."""
def setUp(self):
from letsencrypt.plugins.standalone import ServerManager
self.certs = {}
self.simple_http_resources = {}
self.mgr = ServerManager(self.certs, self.simple_http_resources)
def test_init(self):
self.assertTrue(self.mgr.certs is self.certs)
self.assertTrue(
self.mgr.simple_http_resources is self.simple_http_resources)
def _test_run_stop(self, challenge_type):
server = self.mgr.run(port=0, challenge_type=challenge_type)
port = server.socket.getsockname()[1] # pylint: disable=no-member
self.assertEqual(self.mgr.running(), {port: server})
self.mgr.stop(port=port)
self.assertEqual(self.mgr.running(), {})
def test_run_stop_dvsni(self):
self._test_run_stop(challenges.DVSNI)
def test_run_stop_simplehttp(self):
self._test_run_stop(challenges.SimpleHTTP)
def test_run_idempotent(self):
server = self.mgr.run(port=0, challenge_type=challenges.SimpleHTTP)
port = server.socket.getsockname()[1] # pylint: disable=no-member
server2 = self.mgr.run(port=port, challenge_type=challenges.SimpleHTTP)
self.assertEqual(self.mgr.running(), {port: server})
self.assertTrue(server is server2)
self.mgr.stop(port)
self.assertEqual(self.mgr.running(), {})
def test_run_bind_error(self):
some_server = socket.socket()
some_server.bind(("", 0))
port = some_server.getsockname()[1]
self.assertRaises(
errors.StandaloneBindError, self.mgr.run, port,
challenge_type=challenges.SimpleHTTP)
self.assertEqual(self.mgr.running(), {})
class SupportedChallengesValidatorTest(unittest.TestCase):
"""Tests for plugins.standalone.supported_challenges_validator."""
def _call(self, data):
from letsencrypt.plugins.standalone import (
supported_challenges_validator)
return supported_challenges_validator(data)
def test_correct(self):
self.assertEqual("dvsni", self._call("dvsni"))
self.assertEqual("simpleHttp", self._call("simpleHttp"))
self.assertEqual("dvsni,simpleHttp", self._call("dvsni,simpleHttp"))
self.assertEqual("simpleHttp,dvsni", self._call("simpleHttp,dvsni"))
def test_unrecognized(self):
assert "foo" not in challenges.Challenge.TYPES
self.assertRaises(argparse.ArgumentTypeError, self._call, "foo")
def test_not_subset(self):
self.assertRaises(argparse.ArgumentTypeError, self._call, "dns")
class AuthenticatorTest(unittest.TestCase):
"""Tests for letsencrypt.plugins.standalone.Authenticator."""
def setUp(self):
from letsencrypt.plugins.standalone import Authenticator
self.config = mock.MagicMock(dvsni_port=1234, simple_http_port=4321,
standalone_supported_challenges="dvsni,simpleHttp")
self.auth = Authenticator(self.config, name="standalone")
def test_supported_challenges(self):
self.assertEqual(self.auth.supported_challenges,
set([challenges.DVSNI, challenges.SimpleHTTP]))
def test_more_info(self):
self.assertTrue(isinstance(self.auth.more_info(), six.string_types))
def test_get_chall_pref(self):
self.assertEqual(set(self.auth.get_chall_pref(domain=None)),
set([challenges.DVSNI, challenges.SimpleHTTP]))
@mock.patch("letsencrypt.plugins.standalone.util")
def test_perform_misconfiguration(self, mock_util):
mock_util.already_listening.return_value = True
self.assertRaises(errors.MisconfigurationError, self.auth.perform, [])
mock_util.already_listening.assert_called_once_with(1234)
@mock.patch("letsencrypt.plugins.standalone.zope.component.getUtility")
def test_perform(self, unused_mock_get_utility):
achalls = [1, 2, 3]
self.auth.perform2 = mock.Mock(return_value=mock.sentinel.responses)
self.assertEqual(mock.sentinel.responses, self.auth.perform(achalls))
self.auth.perform2.assert_called_once_with(achalls)
@mock.patch("letsencrypt.plugins.standalone.zope.component.getUtility")
def _test_perform_bind_errors(self, errno, achalls, mock_get_utility):
def _perform2(unused_achalls):
raise errors.StandaloneBindError(mock.Mock(errno=errno), 1234)
self.auth.perform2 = mock.MagicMock(side_effect=_perform2)
self.auth.perform(achalls)
mock_get_utility.assert_called_once_with(interfaces.IDisplay)
notification = mock_get_utility.return_value.notification
self.assertEqual(1, notification.call_count)
self.assertTrue("1234" in notification.call_args[0][0])
def test_perform_eacces(self):
# pylint: disable=no-value-for-parameter
self._test_perform_bind_errors(socket.errno.EACCES, [])
def test_perform_eaddrinuse(self):
# pylint: disable=no-value-for-parameter
self._test_perform_bind_errors(socket.errno.EADDRINUSE, [])
def test_perfom_unknown_bind_error(self):
self.assertRaises(
errors.StandaloneBindError, self._test_perform_bind_errors,
socket.errno.ENOTCONN, [])
def test_perform2(self):
domain = b'localhost'
key = jose.JWK.load(test_util.load_vector('rsa512_key.pem'))
simple_http = achallenges.SimpleHTTP(
challb=acme_util.SIMPLE_HTTP_P, domain=domain, account_key=key)
dvsni = achallenges.DVSNI(
challb=acme_util.DVSNI_P, domain=domain, account_key=key)
self.auth.servers = mock.MagicMock()
def _run(port, tls): # pylint: disable=unused-argument
return "server{0}".format(port)
self.auth.servers.run.side_effect = _run
responses = self.auth.perform2([simple_http, dvsni])
self.assertTrue(isinstance(responses, list))
self.assertEqual(2, len(responses))
self.assertTrue(isinstance(responses[0], challenges.SimpleHTTPResponse))
self.assertTrue(isinstance(responses[1], challenges.DVSNIResponse))
self.assertEqual(self.auth.servers.run.mock_calls, [
mock.call(4321, challenges.SimpleHTTP),
mock.call(1234, challenges.DVSNI),
])
self.assertEqual(self.auth.served, {
"server1234": set([dvsni]),
"server4321": set([simple_http]),
})
self.assertEqual(1, len(self.auth.simple_http_resources))
self.assertEqual(2, len(self.auth.certs))
self.assertEqual(list(self.auth.simple_http_resources), [
acme_standalone.SimpleHTTPRequestHandler.SimpleHTTPResource(
acme_util.SIMPLE_HTTP, responses[0], mock.ANY)])
def test_cleanup(self):
self.auth.servers = mock.Mock()
self.auth.servers.running.return_value = {
1: "server1",
2: "server2",
}
self.auth.served["server1"].add("chall1")
self.auth.served["server2"].update(["chall2", "chall3"])
self.auth.cleanup(["chall1"])
self.assertEqual(self.auth.served, {
"server1": set(), "server2": set(["chall2", "chall3"])})
self.auth.servers.stop.assert_called_once_with(1)
self.auth.servers.running.return_value = {
2: "server2",
}
self.auth.cleanup(["chall2"])
self.assertEqual(self.auth.served, {
"server1": set(), "server2": set(["chall3"])})
self.assertEqual(1, self.auth.servers.stop.call_count)
self.auth.cleanup(["chall3"])
self.assertEqual(self.auth.served, {
"server1": set(), "server2": set([])})
self.auth.servers.stop.assert_called_with(2)
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -0,0 +1,64 @@
"""Plugin utilities."""
import logging
import socket
import psutil
import zope.component
from letsencrypt import interfaces
logger = logging.getLogger(__name__)
def already_listening(port):
"""Check if a process is already listening on the port.
If so, also tell the user via a display notification.
.. warning::
On some operating systems, this function can only usefully be
run as root.
:param int port: The TCP port in question.
:returns: True or False.
"""
try:
net_connections = psutil.net_connections()
except psutil.AccessDenied as error:
logger.info("Access denied when trying to list network "
"connections: %s. Are you root?", error)
# this function is just a pre-check that often causes false
# positives and problems in testing (c.f. #680 on Mac, #255
# generally); we will fail later in bind() anyway
return False
listeners = [conn.pid for conn in net_connections
if conn.status == 'LISTEN' and
conn.type == socket.SOCK_STREAM and
conn.laddr[1] == port]
try:
if listeners and listeners[0] is not None:
# conn.pid may be None if the current process doesn't have
# permission to identify the listening process! Additionally,
# listeners may have more than one element if separate
# sockets have bound the same port on separate interfaces.
# We currently only have UI to notify the user about one
# of them at a time.
pid = listeners[0]
name = psutil.Process(pid).name()
display = zope.component.getUtility(interfaces.IDisplay)
display.notification(
"The program {0} (process ID {1}) is already listening "
"on TCP port {2}. This will prevent us from binding to "
"that port. Please stop the {0} program temporarily "
"and then try again.".format(name, pid, port))
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
# Perhaps the result of a race where the process could have
# exited or relinquished the port (NoSuchProcess), or the result
# of an OS policy where we're not allowed to look up the process
# name (AccessDenied).
pass
return False

View file

@ -0,0 +1,103 @@
"""Tests for letsencrypt.plugins.util."""
import unittest
import mock
import psutil
class AlreadyListeningTest(unittest.TestCase):
"""Tests for letsencrypt.plugins.already_listening."""
def _call(self, *args, **kwargs):
from letsencrypt.plugins.util import already_listening
return already_listening(*args, **kwargs)
@mock.patch("letsencrypt.plugins.util.psutil.net_connections")
@mock.patch("letsencrypt.plugins.util.psutil.Process")
@mock.patch("letsencrypt.plugins.util.zope.component.getUtility")
def test_race_condition(self, mock_get_utility, mock_process, mock_net):
# This tests a race condition, or permission problem, or OS
# incompatibility in which, for some reason, no process name can be
# found to match the identified listening PID.
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None),
sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17),
raddr=(), status="LISTEN", pid=4416)]
mock_net.return_value = conns
mock_process.side_effect = psutil.NoSuchProcess("No such PID")
# We simulate being unable to find the process name of PID 4416,
# which results in returning False.
self.assertFalse(self._call(17))
self.assertEqual(mock_get_utility.generic_notification.call_count, 0)
mock_process.assert_called_once_with(4416)
@mock.patch("letsencrypt.plugins.util.psutil.net_connections")
@mock.patch("letsencrypt.plugins.util.psutil.Process")
@mock.patch("letsencrypt.plugins.util.zope.component.getUtility")
def test_not_listening(self, mock_get_utility, mock_process, mock_net):
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None)]
mock_net.return_value = conns
mock_process.name.return_value = "inetd"
self.assertFalse(self._call(17))
self.assertEqual(mock_get_utility.generic_notification.call_count, 0)
self.assertEqual(mock_process.call_count, 0)
@mock.patch("letsencrypt.plugins.util.psutil.net_connections")
@mock.patch("letsencrypt.plugins.util.psutil.Process")
@mock.patch("letsencrypt.plugins.util.zope.component.getUtility")
def test_listening_ipv4(self, mock_get_utility, mock_process, mock_net):
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None),
sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17),
raddr=(), status="LISTEN", pid=4416)]
mock_net.return_value = conns
mock_process.name.return_value = "inetd"
result = self._call(17)
self.assertTrue(result)
self.assertEqual(mock_get_utility.call_count, 1)
mock_process.assert_called_once_with(4416)
@mock.patch("letsencrypt.plugins.util.psutil.net_connections")
@mock.patch("letsencrypt.plugins.util.psutil.Process")
@mock.patch("letsencrypt.plugins.util.zope.component.getUtility")
def test_listening_ipv6(self, mock_get_utility, mock_process, mock_net):
from psutil._common import sconn
conns = [
sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30),
raddr=(), status="LISTEN", pid=None),
sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783),
raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234),
sconn(fd=-1, family=10, type=1, laddr=("::1", 54321),
raddr=("::1", 111), status="CLOSE_WAIT", pid=None),
sconn(fd=3, family=10, type=1, laddr=("::", 12345), raddr=(),
status="LISTEN", pid=4420),
sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17),
raddr=(), status="LISTEN", pid=4416)]
mock_net.return_value = conns
mock_process.name.return_value = "inetd"
result = self._call(12345)
self.assertTrue(result)
self.assertEqual(mock_get_utility.call_count, 1)
mock_process.assert_called_once_with(4420)
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -0,0 +1,87 @@
"""Webroot plugin."""
import errno
import logging
import os
import zope.interface
from acme import challenges
from letsencrypt import errors
from letsencrypt import interfaces
from letsencrypt.plugins import common
logger = logging.getLogger(__name__)
class Authenticator(common.Plugin):
"""Webroot Authenticator."""
zope.interface.implements(interfaces.IAuthenticator)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Webroot Authenticator"
MORE_INFO = """\
Authenticator plugin that performs SimpleHTTP challenge by saving
necessary validation resources to appropriate paths on the file
system. It expects that there is some other HTTP server configured
to serve all files under specified web root ({0})."""
def more_info(self): # pylint: disable=missing-docstring,no-self-use
return self.MORE_INFO.format(self.conf("path"))
@classmethod
def add_parser_arguments(cls, add):
add("path", help="public_html / webroot path")
def get_chall_pref(self, domain): # pragma: no cover
# pylint: disable=missing-docstring,no-self-use,unused-argument
return [challenges.SimpleHTTP]
def __init__(self, *args, **kwargs):
super(Authenticator, self).__init__(*args, **kwargs)
self.full_root = None
def prepare(self): # pylint: disable=missing-docstring
path = self.conf("path")
if path is None:
raise errors.PluginError("--{0} must be set".format(
self.option_name("path")))
if not os.path.isdir(path):
raise errors.PluginError(
path + " does not exist or is not a directory")
self.full_root = os.path.join(
path, challenges.SimpleHTTPResponse.URI_ROOT_PATH)
logger.debug("Creating root challenges validation dir at %s",
self.full_root)
try:
os.makedirs(self.full_root)
except OSError as exception:
if exception.errno != errno.EEXIST:
raise errors.PluginError(
"Couldn't create root for SimpleHTTP "
"challenge responses: {0}", exception)
def perform(self, achalls): # pylint: disable=missing-docstring
assert self.full_root is not None
return [self._perform_single(achall) for achall in achalls]
def _path_for_achall(self, achall):
return os.path.join(self.full_root, achall.chall.encode("token"))
def _perform_single(self, achall):
response, validation = achall.gen_response_and_validation(
tls=(not self.config.no_simple_http_tls))
path = self._path_for_achall(achall)
logger.debug("Attempting to save validation to %s", path)
with open(path, "w") as validation_file:
validation_file.write(validation.json_dumps())
return response
def cleanup(self, achalls): # pylint: disable=missing-docstring
for achall in achalls:
path = self._path_for_achall(achall)
logger.debug("Removing %s", path)
os.remove(path)

View file

@ -0,0 +1,82 @@
"""Tests for letsencrypt.plugins.webroot."""
import os
import shutil
import tempfile
import unittest
import mock
from acme import jose
from letsencrypt import achallenges
from letsencrypt import errors
from letsencrypt.tests import acme_util
from letsencrypt.tests import test_util
KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
class AuthenticatorTest(unittest.TestCase):
"""Tests for letsencrypt.plugins.webroot.Authenticator."""
achall = achallenges.SimpleHTTP(
challb=acme_util.SIMPLE_HTTP_P, domain=None, account_key=KEY)
def setUp(self):
from letsencrypt.plugins.webroot import Authenticator
self.path = tempfile.mkdtemp()
self.validation_path = os.path.join(
self.path, ".well-known", "acme-challenge",
"ZXZhR3hmQURzNnBTUmIyTEF2OUlaZjE3RHQzanV4R0orUEN0OTJ3citvQQ")
self.config = mock.MagicMock(webroot_path=self.path)
self.auth = Authenticator(self.config, "webroot")
self.auth.prepare()
def tearDown(self):
shutil.rmtree(self.path)
def test_more_info(self):
more_info = self.auth.more_info()
self.assertTrue(isinstance(more_info, str))
self.assertTrue(self.path in more_info)
def test_add_parser_arguments(self):
add = mock.MagicMock()
self.auth.add_parser_arguments(add)
self.assertEqual(1, add.call_count)
def test_prepare_bad_root(self):
self.config.webroot_path = os.path.join(self.path, "null")
self.assertRaises(errors.PluginError, self.auth.prepare)
def test_prepare_missing_root(self):
self.config.webroot_path = None
self.assertRaises(errors.PluginError, self.auth.prepare)
def test_prepare_full_root_exists(self):
# prepare() has already been called once in setUp()
self.auth.prepare() # shouldn't raise any exceptions
def test_prepare_reraises_other_errors(self):
self.auth.full_path = os.path.join(self.path, "null")
os.chmod(self.path, 0o000)
self.assertRaises(errors.PluginError, self.auth.prepare)
os.chmod(self.path, 0o700)
def test_perform_cleanup(self):
responses = self.auth.perform([self.achall])
self.assertEqual(1, len(responses))
self.assertTrue(os.path.exists(self.validation_path))
with open(self.validation_path) as validation_f:
validation = jose.JWS.json_loads(validation_f.read())
self.assertTrue(responses[0].check_validation(
validation, self.achall.chall, KEY.public_key()))
self.auth.cleanup([self.achall])
self.assertFalse(os.path.exists(self.validation_path))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -8,6 +8,7 @@ within lineages of successor certificates, according to configuration.
"""
import argparse
import logging
import os
import sys
@ -17,10 +18,13 @@ import zope.component
from letsencrypt import account
from letsencrypt import configuration
from letsencrypt import constants
from letsencrypt import colored_logging
from letsencrypt import cli
from letsencrypt import client
from letsencrypt import crypto_util
from letsencrypt import errors
from letsencrypt import le_util
from letsencrypt import notify
from letsencrypt import storage
@ -28,6 +32,9 @@ from letsencrypt.display import util as display_util
from letsencrypt.plugins import disco as plugins_disco
logger = logging.getLogger(__name__)
class _AttrDict(dict):
"""Attribute dictionary.
@ -70,6 +77,7 @@ def renew(cert, old_version):
# was an int, not a str)
config.rsa_key_size = int(config.rsa_key_size)
config.dvsni_port = int(config.dvsni_port)
config.namespace.simple_http_port = int(config.namespace.simple_http_port)
zope.component.provideUtility(config)
try:
authenticator = plugins[renewalparams["authenticator"]]
@ -104,6 +112,12 @@ def renew(cert, old_version):
# (where fewer than all names were renewed)
def _cli_log_handler(args, level, fmt): # pylint: disable=unused-argument
handler = colored_logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt))
return handler
def _paths_parser(parser):
add = parser.add_argument_group("paths").add_argument
add("--config-dir", default=cli.flag_default("config_dir"),
@ -119,11 +133,16 @@ def _paths_parser(parser):
def _create_parser():
parser = argparse.ArgumentParser()
#parser.add_argument("--cron", action="store_true", help="Run as cronjob.")
# pylint: disable=protected-access
parser.add_argument(
"-v", "--verbose", dest="verbose_count", action="count",
default=cli.flag_default("verbose_count"), help="This flag can be used "
"multiple times to incrementally increase the verbosity of output, "
"e.g. -vvv.")
return _paths_parser(parser)
def main(config=None, args=sys.argv[1:]):
def main(config=None, cli_args=sys.argv[1:]):
"""Main function for autorenewer script."""
# TODO: Distinguish automated invocation from manual invocation,
# perhaps by looking at sys.argv[0] and inhibiting automated
@ -133,8 +152,13 @@ def main(config=None, args=sys.argv[1:]):
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
cli_config = configuration.RenewerConfiguration(
_create_parser().parse_args(args))
args = _create_parser().parse_args(cli_args)
uid = os.geteuid()
le_util.make_or_verify_dir(args.logs_dir, 0o700, uid)
cli.setup_logging(args, _cli_log_handler, logfile='renewer.log')
cli_config = configuration.RenewerConfiguration(args)
config = storage.config_with_defaults(config)
# Now attempt to read the renewer config file and augment or replace
@ -145,6 +169,9 @@ def main(config=None, args=sys.argv[1:]):
# specify a config file on the command line, which, if provided, should
# take precedence over this one.
config.merge(configobj.ConfigObj(cli_config.renewer_config_file))
# Ensure that all of the needed folders have been created before continuing
le_util.make_or_verify_dir(cli_config.work_dir,
constants.CONFIG_DIRS_MODE, uid)
for i in os.listdir(cli_config.renewal_configs_dir):
print "Processing", i

View file

@ -70,12 +70,12 @@ class ReportNewAccountTest(unittest.TestCase):
from letsencrypt.account import report_new_account
report_new_account(self.acc, self.config)
@mock.patch("letsencrypt.client.zope.component.queryUtility")
@mock.patch("letsencrypt.account.zope.component.queryUtility")
def test_no_reporter(self, mock_zope):
mock_zope.return_value = None
self._call()
@mock.patch("letsencrypt.client.zope.component.queryUtility")
@mock.patch("letsencrypt.account.zope.component.queryUtility")
def test_it(self, mock_zope):
self._call()
call_list = mock_zope().add_message.call_args_list

View file

@ -1,6 +1,8 @@
"""Tests for letsencrypt.achallenges."""
import unittest
import OpenSSL
from acme import challenges
from acme import jose
@ -22,10 +24,10 @@ class DVSNITest(unittest.TestCase):
self.assertEqual(self.challb.token, self.achall.token)
def test_gen_cert_and_response(self):
response, cert_pem, key_pem = self.achall.gen_cert_and_response()
response, cert, key = self.achall.gen_cert_and_response()
self.assertTrue(isinstance(response, challenges.DVSNIResponse))
self.assertTrue(isinstance(cert_pem, bytes))
self.assertTrue(isinstance(key_pem, bytes))
self.assertTrue(isinstance(cert, OpenSSL.crypto.X509))
self.assertTrue(isinstance(key, OpenSSL.crypto.PKey))
if __name__ == "__main__":

View file

@ -75,7 +75,10 @@ class CLITest(unittest.TestCase):
output.truncate(0)
self.assertRaises(SystemExit, self._call_stdout, ['-h', 'nginx'])
out = output.getvalue()
self.assertTrue("--nginx-ctl" in out)
from letsencrypt.plugins import disco
if "nginx" in disco.PluginsRegistry.find_all():
# may be false while building distributions without plugins
self.assertTrue("--nginx-ctl" in out)
self.assertTrue("--manual-test-mode" not in out)
self.assertTrue("--checkpoints" not in out)
output.truncate(0)
@ -125,8 +128,9 @@ class CLITest(unittest.TestCase):
self._auth_new_request_common(mock_client)
self.assertEqual(
mock_client.obtain_and_enroll_certificate.call_count, 1)
self.assertTrue(
cert_path in mock_get_utility().add_message.call_args[0][0])
msg = mock_get_utility().add_message.call_args_list[0][0][0]
self.assertTrue(cert_path in msg)
self.assertEqual(mock_get_utility().add_message.call_count, 2)
def test_auth_new_request_failure(self):
mock_client = mock.MagicMock()
@ -161,8 +165,9 @@ class CLITest(unittest.TestCase):
self.assertEqual(mock_lineage.save_successor.call_count, 1)
mock_lineage.update_all_links_to.assert_called_once_with(
mock_lineage.latest_common_version())
self.assertTrue(
cert_path in mock_get_utility().add_message.call_args[0][0])
msg = mock_get_utility().add_message.call_args_list[0][0][0]
self.assertTrue(cert_path in msg)
self.assertEqual(mock_get_utility().add_message.call_count, 2)
@mock.patch('letsencrypt.cli.display_ops.pick_installer')
@mock.patch('letsencrypt.cli.zope.component.getUtility')

View file

@ -114,37 +114,6 @@ class ClientTest(unittest.TestCase):
mock.sentinel.key, domains, self.config.csr_dir)
self._check_obtain_certificate()
@mock.patch("letsencrypt.client.zope.component.getUtility")
def test_report_renewal_status(self, mock_zope):
# pylint: disable=protected-access
cert = mock.MagicMock()
cert.cli_config.renewal_configs_dir = "/foo/bar/baz"
cert.autorenewal_is_enabled.return_value = True
cert.autodeployment_is_enabled.return_value = True
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal and deployment has been" in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
cert.autorenewal_is_enabled.return_value = False
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("deployment but not automatic renewal" in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
cert.autodeployment_is_enabled.return_value = False
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal and deployment has not" in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
cert.autorenewal_is_enabled.return_value = True
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal but not automatic deployment" in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
def test_save_certificate(self):
certs = ["matching_cert.pem", "cert.pem", "cert-san.pem"]
tmp_path = tempfile.mkdtemp()
@ -177,15 +146,19 @@ class ClientTest(unittest.TestCase):
def test_deploy_certificate(self):
self.assertRaises(errors.Error, self.client.deploy_certificate,
["foo.bar"], "key", "cert", "chain")
["foo.bar"], "key", "cert", "chain", "fullchain")
installer = mock.MagicMock()
self.client.installer = installer
self.client.deploy_certificate(["foo.bar"], "key", "cert", "chain")
self.client.deploy_certificate(
["foo.bar"], "key", "cert", "chain", "fullchain")
installer.deploy_cert.assert_called_once_with(
"foo.bar", os.path.abspath("cert"),
os.path.abspath("key"), os.path.abspath("chain"))
cert_path=os.path.abspath("cert"),
chain_path=os.path.abspath("chain"),
domain='foo.bar',
fullchain_path='fullchain',
key_path=os.path.abspath("key"))
self.assertEqual(installer.save.call_count, 1)
installer.restart.assert_called_once_with()

View file

@ -4,6 +4,8 @@ import unittest
import mock
from letsencrypt import errors
class NamespaceConfigTest(unittest.TestCase):
"""Tests for letsencrypt.configuration.NamespaceConfig."""
@ -11,10 +13,16 @@ class NamespaceConfigTest(unittest.TestCase):
def setUp(self):
self.namespace = mock.MagicMock(
config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar',
server='https://acme-server.org:443/new')
server='https://acme-server.org:443/new',
dvsni_port=1234, simple_http_port=4321)
from letsencrypt.configuration import NamespaceConfig
self.config = NamespaceConfig(self.namespace)
def test_init_same_ports(self):
self.namespace.dvsni_port = 4321
from letsencrypt.configuration import NamespaceConfig
self.assertRaises(errors.Error, NamespaceConfig, self.namespace)
def test_proxy_getattr(self):
self.assertEqual(self.config.foo, 'bar')
self.assertEqual(self.config.work_dir, '/tmp/foo')
@ -46,6 +54,11 @@ class NamespaceConfigTest(unittest.TestCase):
self.assertEqual(self.config.key_dir, '/tmp/config/keys')
self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t')
def test_simple_http_port(self):
self.assertEqual(4321, self.config.simple_http_port)
self.namespace.simple_http_port = None
self.assertEqual(80, self.config.simple_http_port)
class RenewerConfigurationTest(unittest.TestCase):
"""Test for letsencrypt.configuration.RenewerConfiguration."""

View file

@ -1,6 +1,8 @@
"""Tests for letsencrypt.errors."""
import unittest
import mock
from acme import messages
from letsencrypt import achallenges
@ -22,5 +24,21 @@ class FaiiledChallengesTest(unittest.TestCase):
"Failed authorization procedure. example.com (dns): tls"))
class StandaloneBindErrorTest(unittest.TestCase):
"""Tests for letsencrypt.errors.StandaloneBindError."""
def setUp(self):
from letsencrypt.errors import StandaloneBindError
self.error = StandaloneBindError(mock.sentinel.error, 1234)
def test_instance_args(self):
self.assertEqual(mock.sentinel.error, self.error.socket_error)
self.assertEqual(1234, self.error.port)
def test_str(self):
self.assertTrue(str(self.error).startswith(
"Problem binding to port 1234: "))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -44,8 +44,16 @@ class BaseRenewableCertTest(unittest.TestCase):
self.tempdir = tempfile.mkdtemp()
self.cli_config = configuration.RenewerConfiguration(
namespace=mock.MagicMock(config_dir=self.tempdir))
namespace=mock.MagicMock(
config_dir=self.tempdir,
work_dir=self.tempdir,
logs_dir=self.tempdir,
no_simple_http_tls=False,
)
)
# TODO: maybe provide RenewerConfiguration.make_dirs?
# TODO: main() should create those dirs, c.f. #902
os.makedirs(os.path.join(self.tempdir, "live", "example.org"))
os.makedirs(os.path.join(self.tempdir, "archive", "example.org"))
os.makedirs(os.path.join(self.tempdir, "renewal"))
@ -62,6 +70,9 @@ class BaseRenewableCertTest(unittest.TestCase):
self.test_rc = storage.RenewableCert(
self.config, self.defaults, self.cli_config)
def tearDown(self):
shutil.rmtree(self.tempdir)
def _write_out_ex_kinds(self):
for kind in ALL_FOUR:
where = getattr(self.test_rc, kind)
@ -79,11 +90,6 @@ class BaseRenewableCertTest(unittest.TestCase):
class RenewableCertTests(BaseRenewableCertTest):
# pylint: disable=too-many-public-methods
"""Tests for letsencrypt.renewer.*."""
def setUp(self):
super(RenewableCertTests, self).setUp()
def tearDown(self):
shutil.rmtree(self.tempdir)
def test_initialization(self):
self.assertEqual(self.test_rc.lineagename, "example.org")
@ -644,6 +650,7 @@ class RenewableCertTests(BaseRenewableCertTest):
self.test_rc.configfile["renewalparams"]["server"] = "acme.example.com"
self.test_rc.configfile["renewalparams"]["authenticator"] = "fake"
self.test_rc.configfile["renewalparams"]["dvsni_port"] = "4430"
self.test_rc.configfile["renewalparams"]["simple_http_port"] = "1234"
self.test_rc.configfile["renewalparams"]["account"] = "abcde"
mock_auth = mock.MagicMock()
mock_pd.PluginsRegistry.find_all.return_value = {"apache": mock_auth}
@ -665,11 +672,17 @@ class RenewableCertTests(BaseRenewableCertTest):
# This should fail because the renewal itself appears to fail
self.assertFalse(renewer.renew(self.test_rc, 1))
def _common_cli_args(self):
return [
"--config-dir", self.cli_config.config_dir,
"--work-dir", self.cli_config.work_dir,
"--logs-dir", self.cli_config.logs_dir,
]
@mock.patch("letsencrypt.renewer.notify")
@mock.patch("letsencrypt.storage.RenewableCert")
@mock.patch("letsencrypt.renewer.renew")
def test_main(self, mock_renew, mock_rc, mock_notify):
"""Test for main() function."""
from letsencrypt import renewer
mock_rc_instance = mock.MagicMock()
mock_rc_instance.should_autodeploy.return_value = True
@ -691,8 +704,7 @@ class RenewableCertTests(BaseRenewableCertTest):
"example.com.conf"), "w") as f:
f.write("cert = cert.pem\nprivkey = privkey.pem\n")
f.write("chain = chain.pem\nfullchain = fullchain.pem\n")
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
renewer.main(self.defaults, cli_args=self._common_cli_args())
self.assertEqual(mock_rc.call_count, 2)
self.assertEqual(mock_rc_instance.update_all_links_to.call_count, 2)
self.assertEqual(mock_notify.notify.call_count, 4)
@ -705,8 +717,7 @@ class RenewableCertTests(BaseRenewableCertTest):
mock_happy_instance.should_autorenew.return_value = False
mock_happy_instance.latest_common_version.return_value = 10
mock_rc.return_value = mock_happy_instance
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
renewer.main(self.defaults, cli_args=self._common_cli_args())
self.assertEqual(mock_rc.call_count, 4)
self.assertEqual(mock_happy_instance.update_all_links_to.call_count, 0)
self.assertEqual(mock_notify.notify.call_count, 4)
@ -717,8 +728,7 @@ class RenewableCertTests(BaseRenewableCertTest):
with open(os.path.join(self.cli_config.renewal_configs_dir,
"bad.conf"), "w") as f:
f.write("incomplete = configfile\n")
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
renewer.main(self.defaults, cli_args=self._common_cli_args())
# The errors.CertStorageError is caught inside and nothing happens.

View file

@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
version = '0.0.0.dev20151008'
version = '0.1.0.dev0'
install_requires = [
'setuptools', # pkg_resources

View file

@ -43,6 +43,7 @@ install_requires = [
'pytz',
'requests',
'setuptools', # pkg_resources
'six',
'zope.component',
'zope.interface',
]
@ -131,8 +132,8 @@ setup(
'letsencrypt.plugins': [
'manual = letsencrypt.plugins.manual:Authenticator',
'null = letsencrypt.plugins.null:Installer',
'standalone = letsencrypt.plugins.standalone.authenticator'
':StandaloneAuthenticator',
'standalone = letsencrypt.plugins.standalone:Authenticator',
'webroot = letsencrypt.plugins.webroot:Authenticator',
],
},
)

View file

@ -14,6 +14,12 @@ export PATH="/usr/sbin:$PATH" # /usr/sbin/nginx
export GOPATH="${GOPATH:-/tmp/go}"
export PATH="$GOPATH/bin:$PATH"
if [ `uname` = "Darwin" ];then
readlink="greadlink"
else
readlink="readlink"
fi
common() {
letsencrypt_test \
--authenticator standalone \
@ -21,14 +27,22 @@ common() {
"$@"
}
common --domains le1.wtf auth
common --domains le2.wtf run
common -a manual -d le.wtf auth
# TODO: boulder#985
common_dvsni() {
common --dvsni-port 5001 --simple-http-port 0 "$@"
}
common_http() {
common --dvsni-port 0 --simple-http-port ${SIMPLE_HTTP_PORT:-5001} "$@"
}
common_dvsni --domains le1.wtf --standalone-supported-challenges dvsni auth
common_http --domains le2.wtf --standalone-supported-challenges simpleHttp run
common_http -a manual -d le.wtf auth
export CSR_PATH="${root}/csr.der" KEY_PATH="${root}/key.pem" \
OPENSSL_CNF=examples/openssl.cnf
./examples/generate-csr.sh le3.wtf
common auth --csr "$CSR_PATH" \
common_dvsni auth --csr "$CSR_PATH" \
--cert-path "${root}/csr/cert.pem" \
--chain-path "${root}/csr/chain.pem"
openssl x509 -in "${root}/csr/0000_cert.pem" -text
@ -49,7 +63,7 @@ dir="$root/conf/archive/le1.wtf"
for x in cert chain fullchain privkey;
do
latest="$(ls -1t $dir/ | grep -e "^${x}" | head -n1)"
live="$(readlink -f "$root/conf/live/le1.wtf/${x}.pem")"
live="$($readlink -f "$root/conf/live/le1.wtf/${x}.pem")"
[ "${dir}/${latest}" = "$live" ] # renewer fails this test
done

View file

@ -32,11 +32,10 @@ export PATH="$GOPATH/bin:$PATH"
go get -d github.com/letsencrypt/boulder/...
cd $GOPATH/src/github.com/letsencrypt/boulder
# goose is needed for ./test/create_db.sh
if ! go get bitbucket.org/liamstask/goose/cmd/goose ; then
echo Problems installing goose... perhaps rm -rf \$GOPATH \("$GOPATH"\)
echo and try again...
exit 1
fi
wget https://github.com/jsha/boulder-tools/raw/master/goose.gz && \
mkdir $GOPATH/bin && \
zcat goose.gz > $GOPATH/bin/goose && \
chmod +x $GOPATH/bin/goose
./test/create_db.sh
./start.py &
# Hopefully start.py bootstraps before integration test is started...

View file

@ -16,7 +16,7 @@ letsencrypt_test () {
--server "${SERVER:-http://localhost:4000/directory}" \
--no-verify-ssl \
--dvsni-port 5001 \
--simple-http-port 5001 \
--simple-http-port 5002 \
--manual-test-mode \
$store_flags \
--text \

View file

@ -88,8 +88,7 @@ mkdir ../kgs
kgs="../kgs/$version"
pip freeze | tee $kgs
pip install nose
# TODO: letsencrypt_apache fails due to symlink, c.f. #838
nosetests letsencrypt $SUBPKGS || true
nosetests letsencrypt $SUBPKGS
echo "New root: $root"
echo "KGS is at $root/kgs"

10
tox.ini
View file

@ -12,11 +12,11 @@ envlist = py26,py27,py33,py34,cover,lint
commands =
pip install -r requirements.txt -e acme -e .[testing] -e letsencrypt-apache -e letsencrypt-nginx -e letshelp-letsencrypt
# -q does not suppress errors
python setup.py test -q
python setup.py test -q -s acme
python setup.py test -q -s letsencrypt_apache
python setup.py test -q -s letsencrypt_nginx
python setup.py test -q -s letshelp_letsencrypt
python setup.py test
python setup.py test -s acme
python setup.py test -s letsencrypt_apache
python setup.py test -s letsencrypt_nginx
python setup.py test -s letshelp_letsencrypt
setenv =
PYTHONPATH = {toxinidir}