diff --git a/docs/api/client/standalone_authenticator.rst b/docs/api/client/standalone_authenticator.rst new file mode 100644 index 000000000..d05f4f057 --- /dev/null +++ b/docs/api/client/standalone_authenticator.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.standalone_authenticator` +-------------------------------------------------- + +.. automodule:: letsencrypt.client.standalone_authenticator + :members: diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index a1b1daa58..284992b1e 100644 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -1,12 +1,4 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""An authenticator that doesn't rely on any existing server program. - -This authenticator creates its own ephemeral TCP listener on the specified -port in order to respond to incoming DVSNI challenges from the certificate -authority.""" - +"""Standalone authenticator.""" import os import signal import socket @@ -19,18 +11,21 @@ import OpenSSL.SSL import zope.component import zope.interface -from letsencrypt.client import challenge_util from letsencrypt.client import constants +from letsencrypt.client import challenge_util from letsencrypt.client import interfaces class StandaloneAuthenticator(object): # pylint: disable=too-many-instance-attributes - """The StandaloneAuthenticator class itself. + """Standalone authenticator. - This authenticator can be invoked by the Let's Encrypt client - according to the IAuthenticator API interface. It creates a local - TCP listener on a specified port and satisfies DVSNI challenges.""" + 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. + + """ zope.interface.implements(interfaces.IAuthenticator) def __init__(self): @@ -49,10 +44,12 @@ class StandaloneAuthenticator(object): 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 → client READY : SIGIO - # subprocess → client INUSE : SIGUSR1 - # subprocess → client CANTBIND: SIGUSR2 + :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: @@ -69,8 +66,10 @@ class StandaloneAuthenticator(object): 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 → subprocess CLEANUP : SIGINT + :param int sig: Which signal the process received. + + """ + # client to subprocess CLEANUP : SIGINT if sig == signal.SIGINT: try: self.ssl_conn.shutdown() @@ -91,6 +90,7 @@ class StandaloneAuthenticator(object): # 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) @@ -101,18 +101,20 @@ class StandaloneAuthenticator(object): connection when an incoming connection provides an SNI name (in order to serve the appropriate certificate, if any). - :param OpenSSL.Connection connection: The TLS connection object - on which the SNI extension was received.""" + :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.tasks: pem_cert = self.tasks[sni_name] else: # TODO: Should we really present a certificate if we get an - # unexpected SNI name? Or should we just disconnect? + # unexpected SNI name? Or should we just disconnect? pem_cert = self.tasks.values()[0] - cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, - pem_cert) + 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) @@ -122,32 +124,36 @@ class StandaloneAuthenticator(object): 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 start_listener(). We will wait - up to delay_amount seconds to hear from the child process via - a signal. + 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. + 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.""" + :returns: ``True`` or ``False`` according to whether we were notified + that the child process succeeded or failed in binding the port. + :rtype: bool + """ signal.signal(signal.SIGIO, self.client_signal_handler) signal.signal(signal.SIGUSR1, self.client_signal_handler) signal.signal(signal.SIGUSR2, self.client_signal_handler) + display = zope.component.getUtility(interfaces.IDisplay) + start_time = time.time() while time.time() < start_time + delay_amount: if self.subproc_state == "ready": return True - if self.subproc_state == "inuse": + elif self.subproc_state == "inuse": display.generic_notification( "Could not bind TCP port {0} because it is already in " "use it is already in use by another process on this " "system (such as a web server).".format(port)) return False - if self.subproc_state == "cantbind": + elif self.subproc_state == "cantbind": display.generic_notification( "Could not bind TCP port {0} because you don't have " "the appropriate permissions (for example, you " @@ -155,23 +161,28 @@ class StandaloneAuthenticator(object): "root).".format(port)) return False time.sleep(0.1) + display.generic_notification( "Subprocess unexpectedly timed out while trying to bind TCP " "port {0}.".format(port)) + return False def do_child_process(self, port, key): """Perform the child process side of the TCP listener task. - This should only be called by start_listener(). + 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. - :param le_util.Key key: The private key to use to respond to - DVSNI challenge requests.""" + :param key: The private key to use to respond to DVSNI challenge + requests. + :type key: `letsencrypt.client.le_util.Key` + + """ signal.signal(signal.SIGINT, self.subproc_signal_handler) self.sock = socket.socket() try: @@ -217,14 +228,20 @@ class StandaloneAuthenticator(object): self.ssl_conn.close() def start_listener(self, port, key): - """Create a child process which will start a TCP listener on the + """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. - :param le_util.Key key: The private key to use to respond to - DVSNI challenge requests. - :returns: True or False to indicate success or failure creating - the subprocess. + :param key: The private key to use to respond to DVSNI challenge + requests. + :type key: :class:`letsencrypt.client.le_util.Key` + + :returns: ``True`` or ``False`` to indicate success or failure creating + the subprocess. + :rtype: bool + """ fork_result = os.fork() Crypto.Random.atfork() @@ -243,33 +260,21 @@ class StandaloneAuthenticator(object): # IAuthenticator method implementations follow - def get_chall_pref(self, unused_domain): - # pylint: disable=no-self-use - """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'.""" + def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use + """Get challenge preferences.""" return ["dvsni"] def perform(self, chall_list): - """IAuthenticator interface method perform. + """Perform the challege. - Attempt to perform the - specified challenges, returning the status of each. 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. + .. 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. - :param list chall_list: A list of the the challenge objects to - be attempted by this authenticator. - :returns: A list in the same order containing, in each position, - the successfully configured challenge, False, or None.""" + """ if self.child_pid or self.tasks: # We should not be willing to continue with perform # if there were existing pending challenges. @@ -305,17 +310,14 @@ class StandaloneAuthenticator(object): return results_if_failure def cleanup(self, chall_list): - """IAuthenticator interface method cleanup. + """Clean up. - Remove each of the specified challenges from the list of - challenges that still need to be performed. (In the case of - the StandaloneAuthenticator, 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. + 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. - :param list chall_list: A list of the the challenge objects to - be deactivated.""" + """ # Remove this from pending tasks list for chall in chall_list: assert isinstance(chall, challenge_util.DvsniChall) diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py index 0beb0b1d9..8b0336a59 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -1,14 +1,11 @@ -#!/usr/bin/env python - -"""Tests for standalone_authenticator.py.""" -import mock -import unittest - +"""Tests for letsencrypt.client.standalone_authenticator.""" import os import pkg_resources import signal import socket +import unittest +import mock import OpenSSL.crypto import OpenSSL.SSL @@ -20,7 +17,7 @@ from letsencrypt.client import le_util # after one iteration, based on. # http://igorsobreira.com/2013/03/17/testing-infinite-loops.html -class SocketAcceptOnlyNTimes(object): +class _SocketAcceptOnlyNTimes(object): # pylint: disable=too-few-public-methods """ Callable that will raise `CallableExhausted` @@ -39,6 +36,7 @@ class SocketAcceptOnlyNTimes(object): # 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 @@ -65,7 +63,7 @@ class SNICallbackTest(unittest.TestCase): self.authenticator = StandaloneAuthenticator() name, r_b64 = "example.com", le_util.jose_b64encode("x" * 32) test_key = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + __name__, "testdata/rsa256_key.pem") nonce, key = "abcdef", le_util.Key("foo", test_key) self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] private_key = OpenSSL.crypto.load_privatekey( @@ -189,7 +187,7 @@ class PerformTest(unittest.TestCase): def test_can_perform(self): """What happens if start_listener() returns True.""" test_key = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + __name__, "testdata/rsa256_key.pem") key = le_util.Key("something", test_key) chall1 = challenge_util.DvsniChall( "foo.example.com", "whee", "foononce", key) @@ -216,7 +214,7 @@ class PerformTest(unittest.TestCase): def test_cannot_perform(self): """What happens if start_listener() returns False.""" test_key = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + __name__, "testdata/rsa256_key.pem") key = le_util.Key("something", test_key) chall1 = challenge_util.DvsniChall( "foo.example.com", "whee", "foononce", key) @@ -347,7 +345,7 @@ class DoChildProcessTest(unittest.TestCase): self.authenticator = StandaloneAuthenticator() name, r_b64 = "example.com", le_util.jose_b64encode("x" * 32) test_key = pkg_resources.resource_string( - __name__, 'testdata/rsa256_key.pem') + __name__, "testdata/rsa256_key.pem") nonce, key = "abcdef", le_util.Key("foo", test_key) self.key = key self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] @@ -412,10 +410,10 @@ class DoChildProcessTest(unittest.TestCase): "OpenSSL.SSL.Connection") @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - def test_do_child_process_success(self, mock_kill, mock_socket, - mock_connection): + def test_do_child_process_success( + self, mock_kill, mock_socket, mock_connection): sample_socket = mock.MagicMock() - sample_socket.accept.side_effect = SocketAcceptOnlyNTimes(2) + sample_socket.accept.side_effect = _SocketAcceptOnlyNTimes(2) mock_socket.return_value = sample_socket mock_connection.return_value = mock.MagicMock() self.assertRaises( @@ -457,5 +455,5 @@ class CleanupTest(unittest.TestCase): self.assertRaises(ValueError, self.authenticator.cleanup, [chall]) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main()