mirror of
https://github.com/certbot/certbot.git
synced 2026-06-09 00:32:12 -04:00
Merge remote-tracking branch 'github/letsencrypt/master' into py2.6-3
This commit is contained in:
commit
09fa1153d9
73 changed files with 2032 additions and 1475 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
197
acme/acme/standalone.py
Normal 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
|
||||
198
acme/acme/standalone_test.py
Normal file
198
acme/acme/standalone_test.py
Normal 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
|
||||
2
acme/examples/standalone/README
Normal file
2
acme/examples/standalone/README
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
python -m acme.standalone -p 1234
|
||||
curl -k https://localhost:1234
|
||||
1
acme/examples/standalone/localhost/cert.pem
Symbolic link
1
acme/examples/standalone/localhost/cert.pem
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../acme/testdata/cert.pem
|
||||
1
acme/examples/standalone/localhost/key.pem
Symbolic link
1
acme/examples/standalone/localhost/key.pem
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../acme/testdata/rsa512_key.pem
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
33
bootstrap/venv.sh
Executable 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'."
|
||||
5
docs/api/plugins/util.rst
Normal file
5
docs/api/plugins/util.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.plugins.util`
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.plugins.util
|
||||
:members:
|
||||
5
docs/api/plugins/webroot.rst
Normal file
5
docs/api/plugins/webroot.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.plugins.webroot`
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.plugins.webroot
|
||||
:members:
|
||||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -47,3 +47,10 @@ Errors
|
|||
|
||||
.. automodule:: acme.errors
|
||||
:members:
|
||||
|
||||
|
||||
Standalone
|
||||
----------
|
||||
|
||||
.. automodule:: acme.standalone
|
||||
:members:
|
||||
|
|
|
|||
156
docs/using.rst
156
docs/using.rst
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
89
letsencrypt-auto
Executable 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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
256
letsencrypt/plugins/standalone.py
Normal file
256
letsencrypt/plugins/standalone.py
Normal 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)
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Let's Encrypt Standalone Authenticator plugin."""
|
||||
|
|
@ -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))
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Let's Encrypt Standalone Tests"""
|
||||
|
|
@ -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
|
||||
210
letsencrypt/plugins/standalone_test.py
Normal file
210
letsencrypt/plugins/standalone_test.py
Normal 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
|
||||
64
letsencrypt/plugins/util.py
Normal file
64
letsencrypt/plugins/util.py
Normal 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
|
||||
103
letsencrypt/plugins/util_test.py
Normal file
103
letsencrypt/plugins/util_test.py
Normal 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
|
||||
87
letsencrypt/plugins/webroot.py
Normal file
87
letsencrypt/plugins/webroot.py
Normal 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)
|
||||
82
letsencrypt/plugins/webroot_test.py
Normal file
82
letsencrypt/plugins/webroot_test.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
setup.py
5
setup.py
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
10
tox.ini
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue