diff --git a/.travis.yml b/.travis.yml index 7f800d7c3..803d76cbf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,4 +19,10 @@ env: notifications: email: false - irc: "chat.freenode.net#letsencrypt" + irc: + channels: + - "chat.freenode.net#letsencrypt" + on_success: never + on_failure: always + use_notice: true + skip_join: true diff --git a/MANIFEST.in b/MANIFEST.in index bea6fd9bb..3bd657b87 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ include README.rst include CHANGES.rst -include CONTRIBUTING.rst +include CONTRIBUTING.md include linter_plugin.py include letsencrypt/EULA recursive-include letsencrypt *.json diff --git a/README.rst b/README.rst index 86d85ed1d..fac36dbd7 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,7 @@ Documentation: https://letsencrypt.readthedocs.org/ Software project: https://github.com/letsencrypt/lets-encrypt-preview -Notes for developers: CONTRIBUTING.rst_ +Notes for developers: CONTRIBUTING.md_ Main Website: https://letsencrypt.org/ @@ -91,4 +91,4 @@ email to client-dev+subscribe@letsencrypt.org) .. _Freenode: https://freenode.net .. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev -.. _CONTRIBUTING.rst: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.rst +.. _CONTRIBUTING.md: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.md diff --git a/docs/api/acme/index.rst b/docs/api/acme/index.rst index 89801611e..20206183a 100644 --- a/docs/api/acme/index.rst +++ b/docs/api/acme/index.rst @@ -1,6 +1,8 @@ :mod:`letsencrypt.acme` ======================= +.. contents:: + .. automodule:: letsencrypt.acme :members: @@ -8,9 +10,18 @@ Messages -------- +v00 +~~~ + .. automodule:: letsencrypt.acme.messages :members: +v02 +~~~ + +.. automodule:: letsencrypt.acme.messages2 + :members: + Challenges ---------- @@ -21,10 +32,18 @@ Challenges Other ACME objects ------------------ + .. automodule:: letsencrypt.acme.other :members: +Fields +------ + +.. automodule:: letsencrypt.acme.fields + :members: + + Errors ------ diff --git a/docs/api/client/apache.rst b/docs/api/client/apache.rst deleted file mode 100644 index e69826cf9..000000000 --- a/docs/api/client/apache.rst +++ /dev/null @@ -1,29 +0,0 @@ -:mod:`letsencrypt.client.apache` --------------------------------- - -.. automodule:: letsencrypt.client.apache - :members: - -:mod:`letsencrypt.client.apache.configurator` -============================================= - -.. automodule:: letsencrypt.client.apache.configurator - :members: - -:mod:`letsencrypt.client.apache.dvsni` -============================================= - -.. automodule:: letsencrypt.client.apache.dvsni - :members: - -:mod:`letsencrypt.client.apache.obj` -==================================== - -.. automodule:: letsencrypt.client.apache.obj - :members: - -:mod:`letsencrypt.client.apache.parser` -======================================= - -.. automodule:: letsencrypt.client.apache.parser - :members: diff --git a/docs/api/client/client_authenticator.rst b/docs/api/client/client_authenticator.rst deleted file mode 100644 index 267a0dd50..000000000 --- a/docs/api/client/client_authenticator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.client_authenticator` ----------------------------------------------- - -.. automodule:: letsencrypt.client.client_authenticator - :members: diff --git a/docs/api/client/continuity_auth.rst b/docs/api/client/continuity_auth.rst new file mode 100644 index 000000000..29f6a3ffb --- /dev/null +++ b/docs/api/client/continuity_auth.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.continuity_auth` +----------------------------------------- + +.. automodule:: letsencrypt.client.continuity_auth + :members: diff --git a/docs/api/client/network2.rst b/docs/api/client/network2.rst new file mode 100644 index 000000000..b05017551 --- /dev/null +++ b/docs/api/client/network2.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.network2` +---------------------------------- + +.. automodule:: letsencrypt.client.network2 + :members: diff --git a/docs/api/client/plugins/apache.rst b/docs/api/client/plugins/apache.rst new file mode 100644 index 000000000..6e6e6c462 --- /dev/null +++ b/docs/api/client/plugins/apache.rst @@ -0,0 +1,29 @@ +:mod:`letsencrypt.client.plugins.apache` +---------------------------------------- + +.. automodule:: letsencrypt.client.plugins.apache + :members: + +:mod:`letsencrypt.client.plugins.apache.configurator` +===================================================== + +.. automodule:: letsencrypt.client.plugins.apache.configurator + :members: + +:mod:`letsencrypt.client.plugins.apache.dvsni` +============================================== + +.. automodule:: letsencrypt.client.plugins.apache.dvsni + :members: + +:mod:`letsencrypt.client.plugins.apache.obj` +============================================ + +.. automodule:: letsencrypt.client.plugins.apache.obj + :members: + +:mod:`letsencrypt.client.plugins.apache.parser` +=============================================== + +.. automodule:: letsencrypt.client.plugins.apache.parser + :members: diff --git a/docs/api/client/plugins/standalone.rst b/docs/api/client/plugins/standalone.rst new file mode 100644 index 000000000..44cf4b8ca --- /dev/null +++ b/docs/api/client/plugins/standalone.rst @@ -0,0 +1,11 @@ +:mod:`letsencrypt.client.plugins.standalone` +-------------------------------------------- + +.. automodule:: letsencrypt.client.plugins.standalone + :members: + +:mod:`letsencrypt.client.plugins.standalone.authenticator` +========================================================== + +.. automodule:: letsencrypt.client.plugins.standalone.authenticator + :members: diff --git a/docs/api/client/standalone_authenticator.rst b/docs/api/client/standalone_authenticator.rst deleted file mode 100644 index d05f4f057..000000000 --- a/docs/api/client/standalone_authenticator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.standalone_authenticator` --------------------------------------------------- - -.. automodule:: letsencrypt.client.standalone_authenticator - :members: diff --git a/docs/contributing.rst b/docs/contributing.rst index e3b81b3d4..06fd6f8eb 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -98,15 +98,16 @@ the ACME server. From the protocol, there are essentially two different types of challenges. Challenges that must be solved by individual plugins in order to satisfy domain validation (subclasses of `~.DVChallenge`, i.e. `~.challenges.DVSNI`, -`~.challenges.SimpleHTTPS`, `~.challenges.DNS`) and client specific -challenges (subclasses of `~.ClientChallenge`, +`~.challenges.SimpleHTTPS`, `~.challenges.DNS`) and continuity specific +challenges (subclasses of `~.ContinuityChallenge`, i.e. `~.challenges.RecoveryToken`, `~.challenges.RecoveryContact`, -`~.challenges.ProofOfPossession`). Client specific challenges are -always handled by the `~.ClientAuthenticator`. Right now we have two -DV Authenticators, `~.ApacheConfigurator` and the -`~.StandaloneAuthenticator`. The Standalone and Apache authenticators -only solve the `~.challenges.DVSNI` challenge currently. (You can set -which challenges your authenticator can handle through the +`~.challenges.ProofOfPossession`). Continuity challenges are +always handled by the `~.ContinuityAuthenticator`, while plugins are +expected to handle `~.DVChallenge` types. +Right now, we have two authenticator plugins, the `~.ApacheConfigurator` +and the `~.StandaloneAuthenticator`. The Standalone and Apache +authenticators only solve the `~.challenges.DVSNI` challenge currently. +(You can set which challenges your authenticator can handle through the :meth:`~.IAuthenticator.get_chall_pref`. (FYI: We also have a partial implementation for a `~.DNSAuthenticator` @@ -126,26 +127,27 @@ Installers and Authenticators will oftentimes be the same class/object. Installers and Authenticators are kept separate because it should be possible to use the `~.StandaloneAuthenticator` (it sets up its own Python server to perform challenges) with a program that -cannot solve challenges itself. (I am imagining MTA installers). +cannot solve challenges itself. (Imagine MTA installers). + + +Installer Development +--------------------- + +There are a few existing classes that may be beneficial while +developing a new `~letsencrypt.client.interfaces.IInstaller`. +Installers aimed to reconfigure UNIX servers may use Augeas for +configuration parsing and can inherit from `~.AugeasConfigurator` class +to handle much of the interface. Installers that are unable to use +Augeas may still find the `~.Reverter` class helpful in handling +configuration checkpoints and rollback. Display ~~~~~~~ -We currently offer a pythondialog and "text" mode for displays. I have -rewritten the interface which should be merged within the next day -(the rewrite is in the revoker branch of the repo and should be merged -within the next day). Display plugins implement -`~letsencrypt.client.interfaces.IDisplay` interface. - - -Augeas ------- - -Some plugins, especially those designed to reconfigure UNIX servers, -can take inherit from `~.AugeasConfigurator` class in order to more -efficiently handle common operations on UNIX server configuration -files. +We currently offer a pythondialog and "text" mode for displays. Display +plugins implement the `~letsencrypt.client.interfaces.IDisplay` +interface. .. _coding-style: diff --git a/docs/index.rst b/docs/index.rst index 34615168c..72be096f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Welcome to the Let's Encrypt client documentation! intro using contributing + plugins .. toctree:: :maxdepth: 1 diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 000000000..0451bfe3f --- /dev/null +++ b/docs/plugins.rst @@ -0,0 +1,19 @@ +======= +Plugins +======= + +Let's Encrypt client supports dynamic discovery of plugins through the +`setuptools entry points`_. This way you can, for example, create a +custom implementation of +`~letsencrypt.client.interfaces.IAuthenticator` or the +'~letsencrypt.client.interfaces.IInstaller' without having to +merge it with the core upstream source code. An example is provided in +``examples/plugins/`` directory. + +Please be aware though that as this client is still in a developer-preview +stage, the API may undergo a few changes. If you believe the plugin will be +beneficial to the community, please consider submitting a pull request to the +repo and we will update it with any necessary API changes. + +.. _`setuptools entry points`: + https://pythonhosted.org/setuptools/setuptools.html#dynamic-discovery-of-services-and-plugins diff --git a/examples/plugins/letsencrypt_example_plugins.py b/examples/plugins/letsencrypt_example_plugins.py new file mode 100644 index 000000000..987a2b33b --- /dev/null +++ b/examples/plugins/letsencrypt_example_plugins.py @@ -0,0 +1,18 @@ +"""Example Let's Encrypt plugins.""" +import zope.interface + +from letsencrypt.client import interfaces + + +class Authenticator(object): + zope.interface.implements(interfaces.IAuthenticator) + + description = 'Example Authenticator plugin' + + def __init__(self, config): + self.config = config + + # Implement all methods from IAuthenticator, remembering to add + # "self" as first argument, e.g. def prepare(self)... + + # For full examples, see letsencrypt.client.plugins diff --git a/examples/plugins/setup.py b/examples/plugins/setup.py new file mode 100644 index 000000000..845d6eb66 --- /dev/null +++ b/examples/plugins/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup + + +setup( + name='letsencrypt-example-plugins', + package='letsencrypt_example_plugins.py', + install_requires=[ + 'letsencrypt', + 'zope.interface', + ], + entry_points={ + 'letsencrypt.authenticators': [ + 'example = letsencrypt_example_plugins:Authenticator', + ], + }, +) diff --git a/examples/restified.py b/examples/restified.py new file mode 100644 index 000000000..651ecccd1 --- /dev/null +++ b/examples/restified.py @@ -0,0 +1,42 @@ +import logging +import os +import pkg_resources + +import M2Crypto + +from letsencrypt.acme import messages2 +from letsencrypt.acme import jose + +from letsencrypt.client import network2 + + +logger = logging.getLogger() +logger.setLevel(logging.DEBUG) + +NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg' + +key = jose.JWKRSA.load(pkg_resources.resource_string( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) +net = network2.Network(NEW_REG_URL, key) + +regr = net.register(contact=( + 'mailto:cert-admin@example.com', 'tel:+12025551212')) +logging.info('Auto-accepting TOS: %s', regr.terms_of_service) +net.update_registration(regr.update( + body=regr.body.update(agreement=regr.terms_of_service))) +logging.debug(regr) + +authzr = net.request_challenges( + identifier=messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value='example1.com'), + regr=regr) +logging.debug(authzr) + +authzr, authzr_response = net.poll(authzr) + +csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string( + 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem'))) +try: + net.request_issuance(csr, (authzr,)) +except messages2.Error as error: + print error.detail diff --git a/letsencrypt.py b/letsencrypt.py deleted file mode 120000 index 77b93ee70..000000000 --- a/letsencrypt.py +++ /dev/null @@ -1 +0,0 @@ -letsencrypt/scripts/main.py \ No newline at end of file diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index 9227fa1a1..7a51d7447 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -18,7 +18,7 @@ class Challenge(jose.TypedJSONObjectWithFields): TYPES = {} -class ClientChallenge(Challenge): # pylint: disable=abstract-method +class ContinuityChallenge(Challenge): # pylint: disable=abstract-method """Client validation challenges.""" @@ -139,7 +139,7 @@ class DVSNIResponse(ChallengeResponse): return self.z(chall) + self.DOMAIN_SUFFIX @Challenge.register -class RecoveryContact(ClientChallenge): +class RecoveryContact(ContinuityChallenge): """ACME "recoveryContact" challenge.""" typ = "recoveryContact" @@ -156,7 +156,7 @@ class RecoveryContactResponse(ChallengeResponse): @Challenge.register -class RecoveryToken(ClientChallenge): +class RecoveryToken(ContinuityChallenge): """ACME "recoveryToken" challenge.""" typ = "recoveryToken" @@ -169,7 +169,7 @@ class RecoveryTokenResponse(ChallengeResponse): @Challenge.register -class ProofOfPossession(ClientChallenge): +class ProofOfPossession(ContinuityChallenge): """ACME "proofOfPossession" challenge. :ivar str nonce: Random data, **not** base64-encoded. @@ -184,7 +184,8 @@ class ProofOfPossession(ClientChallenge): """Hints for "proofOfPossession" challenge. :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`) - :ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates. + :ivar list certs: List of :class:`letsencrypt.acme.jose.ComparableX509` + certificates. """ jwk = jose.Field("jwk", decoder=jose.JWK.from_json) diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py index 081560fe1..f1507c7fd 100644 --- a/letsencrypt/acme/challenges_test.py +++ b/letsencrypt/acme/challenges_test.py @@ -13,8 +13,10 @@ from letsencrypt.acme import other CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem'))) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', + os.path.join('testdata', 'rsa256_key.pem')))) class SimpleHTTPSTest(unittest.TestCase): diff --git a/letsencrypt/acme/fields.py b/letsencrypt/acme/fields.py new file mode 100644 index 000000000..f001f1cd5 --- /dev/null +++ b/letsencrypt/acme/fields.py @@ -0,0 +1,25 @@ +"""ACME JSON fields.""" +import pyrfc3339 + +from letsencrypt.acme import jose + + +class RFC3339Field(jose.Field): + """RFC3339 field encoder/decoder. + + Handles decoding/encoding between RFC3339 strings and aware (not + naive) `datetime.datetime` objects + (e.g. ``datetime.datetime.now(pytz.utc)``). + + """ + + @classmethod + def default_encoder(cls, value): + return pyrfc3339.generate(value) + + @classmethod + def default_decoder(cls, value): + try: + return pyrfc3339.parse(value) + except ValueError as error: + raise jose.DeserializationError(error) diff --git a/letsencrypt/acme/fields_test.py b/letsencrypt/acme/fields_test.py new file mode 100644 index 000000000..204849408 --- /dev/null +++ b/letsencrypt/acme/fields_test.py @@ -0,0 +1,35 @@ +"""Tests for letsencrypt.acme.fields.""" +import datetime +import unittest + +import pytz + +from letsencrypt.acme import jose + + +class RFC3339FieldTest(unittest.TestCase): + """Tests for letsencrypt.acme.fields.RFC3339Field.""" + + def setUp(self): + self.decoded = datetime.datetime(2015, 3, 27, tzinfo=pytz.utc) + self.encoded = '2015-03-27T00:00:00Z' + + def test_default_encoder(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertEqual( + self.encoded, RFC3339Field.default_encoder(self.decoded)) + + def test_default_encoder_naive_fails(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertRaises( + ValueError, RFC3339Field.default_encoder, datetime.datetime.now()) + + def test_default_decoder(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertEqual( + self.decoded, RFC3339Field.default_decoder(self.encoded)) + + def test_default_decoder_raises_deserialization_error(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertRaises( + jose.DeserializationError, RFC3339Field.default_decoder, '') diff --git a/letsencrypt/acme/jose/__init__.py b/letsencrypt/acme/jose/__init__.py index 4c7398b79..20f9ba7d3 100644 --- a/letsencrypt/acme/jose/__init__.py +++ b/letsencrypt/acme/jose/__init__.py @@ -70,5 +70,6 @@ from letsencrypt.acme.jose.jws import JWS from letsencrypt.acme.jose.util import ( ComparableX509, + HashableRSAKey, ImmutableMap, ) diff --git a/letsencrypt/acme/jose/interfaces.py b/letsencrypt/acme/jose/interfaces.py index 446a5d2b0..285f51747 100644 --- a/letsencrypt/acme/jose/interfaces.py +++ b/letsencrypt/acme/jose/interfaces.py @@ -129,18 +129,24 @@ class JSONDeSerializable(object): :returns: Fully serialized object. """ - partial = self.to_json() - try_serialize = (lambda x: x.fully_serialize() - if isinstance(x, JSONDeSerializable) else x) - if isinstance(partial, basestring): # strings are sequences - return partial - if isinstance(partial, collections.Sequence): - return [try_serialize(elem) for elem in partial] - elif isinstance(partial, collections.Mapping): - return dict([(try_serialize(key), try_serialize(value)) - for key, value in partial.iteritems()]) - else: - return partial + def _serialize(obj): + if isinstance(obj, JSONDeSerializable): + return _serialize(obj.to_json()) + if isinstance(obj, basestring): # strings are sequence + return obj + elif isinstance(obj, list): + return [_serialize(subobj) for subobj in obj] + elif isinstance(obj, collections.Sequence): + # default to tuple, otherwise Mapping could get + # unhashable list + return tuple(_serialize(subobj) for subobj in obj) + elif isinstance(obj, collections.Mapping): + return dict((_serialize(key), _serialize(value)) + for key, value in obj.iteritems()) + else: + return obj + + return _serialize(self) @util.abstractclassmethod def from_json(cls, unused_jobj): diff --git a/letsencrypt/acme/jose/interfaces_test.py b/letsencrypt/acme/jose/interfaces_test.py index 2e5606bce..90e34d66d 100644 --- a/letsencrypt/acme/jose/interfaces_test.py +++ b/letsencrypt/acme/jose/interfaces_test.py @@ -3,6 +3,7 @@ import unittest class JSONDeSerializableTest(unittest.TestCase): + # pylint: disable=too-many-instance-attributes def setUp(self): from letsencrypt.acme.jose.interfaces import JSONDeSerializable @@ -50,6 +51,8 @@ class JSONDeSerializableTest(unittest.TestCase): self.basic2 = Basic('foo2') self.seq = Sequence(self.basic1, self.basic2) self.mapping = Mapping(self.basic1, self.basic2) + self.nested = Basic([[self.basic1]]) + self.tuple = Basic(('foo',)) # pylint: disable=invalid-name self.Basic = Basic @@ -66,6 +69,12 @@ class JSONDeSerializableTest(unittest.TestCase): mock_value = object() self.assertTrue(self.Basic(mock_value).fully_serialize() is mock_value) + def test_fully_serialize_nested(self): + self.assertEqual(self.nested.fully_serialize(), [['foo1']]) + + def test_fully_serialize(self): + self.assertEqual(self.tuple.fully_serialize(), (('foo', ))) + def test_from_json_not_implemented(self): from letsencrypt.acme.jose.interfaces import JSONDeSerializable self.assertRaises(TypeError, JSONDeSerializable.from_json, 'xxx') diff --git a/letsencrypt/acme/jose/jwk.py b/letsencrypt/acme/jose/jwk.py index 1a83a5305..1b7e00e56 100644 --- a/letsencrypt/acme/jose/jwk.py +++ b/letsencrypt/acme/jose/jwk.py @@ -83,7 +83,11 @@ class JWKOct(JWK): @JWK.register class JWKRSA(JWK): - """RSA JWK.""" + """RSA JWK. + + :ivar key: `Crypto.PublicKey.RSA` wrapped in `.HashableRSAKey` + + """ typ = 'RSA' __slots__ = ('key',) @@ -114,7 +118,8 @@ class JWKRSA(JWK): :rtype: :class:`JWKRSA` """ - return cls(key=Crypto.PublicKey.RSA.importKey(string)) + return cls(key=util.HashableRSAKey( + Crypto.PublicKey.RSA.importKey(string))) def public(self): return type(self)(key=self.key.publickey()) diff --git a/letsencrypt/acme/jose/jws.py b/letsencrypt/acme/jose/jws.py index 81106dd2c..3b962aede 100644 --- a/letsencrypt/acme/jose/jws.py +++ b/letsencrypt/acme/jose/jws.py @@ -3,7 +3,7 @@ import argparse import base64 import sys -import M2Crypto.X509 +import M2Crypto from letsencrypt.acme.jose import b64 from letsencrypt.acme.jose import errors diff --git a/letsencrypt/acme/jose/jws_test.py b/letsencrypt/acme/jose/jws_test.py index 6e6b51350..215960e15 100644 --- a/letsencrypt/acme/jose/jws_test.py +++ b/letsencrypt/acme/jose/jws_test.py @@ -5,7 +5,7 @@ import pkg_resources import unittest import Crypto.PublicKey.RSA -import M2Crypto.X509 +import M2Crypto import mock from letsencrypt.acme.jose import b64 diff --git a/letsencrypt/acme/jose/util.py b/letsencrypt/acme/jose/util.py index 5f516884f..2312055f7 100644 --- a/letsencrypt/acme/jose/util.py +++ b/letsencrypt/acme/jose/util.py @@ -41,6 +41,26 @@ class ComparableX509(object): # pylint: disable=too-few-public-methods return self.as_der() == other.as_der() +class HashableRSAKey(object): # pylint: disable=too-few-public-methods + """Wrapper for `Crypto.PublicKey.RSA` objects that supports hashing.""" + + def __init__(self, wrapped): + self._wrapped = wrapped + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + def __eq__(self, other): + return self._wrapped == other + + def __hash__(self): + return hash((type(self), self.exportKey(format='DER'))) + + def publickey(self): + """Get wrapped public key.""" + return type(self)(self._wrapped.publickey()) + + class ImmutableMap(collections.Mapping, collections.Hashable): # pylint: disable=too-few-public-methods """Immutable key to value mapping with attribute access.""" @@ -57,6 +77,12 @@ class ImmutableMap(collections.Mapping, collections.Hashable): for slot in self.__slots__: object.__setattr__(self, slot, kwargs.pop(slot)) + def update(self, **kwargs): + """Return updated map.""" + items = dict(self) + items.update(kwargs) + return type(self)(**items) # pylint: disable=star-args + def __getitem__(self, key): try: return getattr(self, key) diff --git a/letsencrypt/acme/jose/util_test.py b/letsencrypt/acme/jose/util_test.py index 671b45472..fc75497e0 100644 --- a/letsencrypt/acme/jose/util_test.py +++ b/letsencrypt/acme/jose/util_test.py @@ -1,7 +1,36 @@ """Tests for letsencrypt.acme.jose.util.""" import functools +import os +import pkg_resources import unittest +import Crypto.PublicKey.RSA + + +class HashableRSAKeyTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.util.HashableRSAKey.""" + + def setUp(self): + from letsencrypt.acme.jose.util import HashableRSAKey + self.key = HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) + self.key_same = HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) + + def test_eq(self): + # if __eq__ is not defined, then two HashableRSAKeys with same + # _wrapped do not equate + self.assertEqual(self.key, self.key_same) + + def test_hash(self): + self.assertTrue(isinstance(hash(self.key), int)) + + def test_publickey(self): + from letsencrypt.acme.jose.util import HashableRSAKey + self.assertTrue(isinstance(self.key.publickey(), HashableRSAKey)) + class ImmutableMapTest(unittest.TestCase): """Tests for letsencrypt.acme.jose.util.ImmutableMap.""" @@ -25,6 +54,10 @@ class ImmutableMapTest(unittest.TestCase): self.a2 = self.A(x=3, y=4) self.b = self.B(x=1, y=2) + def test_update(self): + self.assertEqual(self.A(x=2, y=2), self.a1.update(x=2)) + self.assertEqual(self.a2, self.a1.update(x=3, y=4)) + def test_get_missing_item_raises_key_error(self): self.assertRaises(KeyError, self.a1.__getitem__, 'z') diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py new file mode 100644 index 000000000..f4c1e9dce --- /dev/null +++ b/letsencrypt/acme/messages2.py @@ -0,0 +1,298 @@ +"""ACME protocol v02 messages.""" +from letsencrypt.acme import challenges +from letsencrypt.acme import fields +from letsencrypt.acme import jose + + +class Error(jose.JSONObjectWithFields, Exception): + """ACME error. + + https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 + + """ + + ERROR_TYPE_NAMESPACE = 'urn:acme:error:' + ERROR_TYPE_DESCRIPTIONS = { + 'malformed': 'The request message was malformed', + 'unauthorized': 'The client lacks sufficient authorization', + 'serverInternal': 'The server experienced an internal error', + 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', + } + + # TODO: Boulder omits 'type' and 'instance', spec requires + typ = jose.Field('type', omitempty=True) + title = jose.Field('title', omitempty=True) + detail = jose.Field('detail') + instance = jose.Field('instance', omitempty=True) + + @typ.encoder + def typ(value): # pylint: disable=missing-docstring,no-self-argument + return Error.ERROR_TYPE_NAMESPACE + value + + @typ.decoder + def typ(value): # pylint: disable=missing-docstring,no-self-argument + # pylint thinks isinstance(value, Error), so startswith is not found + # pylint: disable=no-member + if not value.startswith(Error.ERROR_TYPE_NAMESPACE): + raise jose.DeserializationError('Missing error type prefix') + + without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):] + if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS: + raise jose.DeserializationError('Error type not recognized') + + return without_prefix + + @property + def description(self): + """Hardcoded error description based on its type.""" + return self.ERROR_TYPE_DESCRIPTIONS[self.typ] + + +class _Constant(jose.JSONDeSerializable): + """ACME constant.""" + __slots__ = ('name',) + POSSIBLE_NAMES = NotImplemented + + def __init__(self, name): + self.POSSIBLE_NAMES[name] = self + self.name = name + + def to_json(self): + return self.name + + @classmethod + def from_json(cls, value): + if value not in cls.POSSIBLE_NAMES: + raise jose.DeserializationError( + '{0} not recognized'.format(cls.__name__)) + return cls.POSSIBLE_NAMES[value] + + def __repr__(self): + return '{0}({1})'.format(self.__class__.__name__, self.name) + + def __eq__(self, other): + return isinstance(other, type(self)) and other.name == self.name + + +class Status(_Constant): + """ACME "status" field.""" + POSSIBLE_NAMES = {} +STATUS_UNKNOWN = Status('unknown') +STATUS_PENDING = Status('pending') +STATUS_PROCESSING = Status('processing') +STATUS_VALID = Status('valid') +STATUS_INVALID = Status('invalid') +STATUS_REVOKED = Status('revoked') + + +class IdentifierType(_Constant): + """ACME identifier type.""" + POSSIBLE_NAMES = {} +IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder + + +class Identifier(jose.JSONObjectWithFields): + """ACME identifier. + + :ivar letsencrypt.acme.messages2.IdentifierType typ: + + """ + typ = jose.Field('type', decoder=IdentifierType.from_json) + value = jose.Field('value') + + +class Resource(jose.ImmutableMap): + """ACME Resource. + + :ivar letsencrypt.acme.messages2.ResourceBody body: Resource body. + :ivar str uri: Location of the resource. + + """ + __slots__ = ('body', 'uri') + + +class ResourceBody(jose.JSONObjectWithFields): + """ACME Resource Body.""" + + +class RegistrationResource(Resource): + """Registration Resource. + + :ivar letsencrypt.acme.messages2.Registration body: + :ivar str new_authzr_uri: URI found in the 'next' ``Link`` header + :ivar str terms_of_service: URL for the CA TOS. + + """ + __slots__ = ('body', 'uri', 'new_authzr_uri', 'terms_of_service') + + +class Registration(ResourceBody): + """Registration Resource Body. + + :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. + :ivar tuple contact: + + """ + + # on new-reg key server ignores 'key' and populates it based on + # JWS.signature.combined.jwk + key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) + contact = jose.Field('contact', omitempty=True, default=()) + recovery_token = jose.Field('recoveryToken', omitempty=True) + agreement = jose.Field('agreement', omitempty=True) + + +class ChallengeResource(Resource, jose.JSONObjectWithFields): + """Challenge Resource. + + :ivar letsencrypt.acme.messages2.ChallengeBody body: + :ivar str authzr_uri: URI found in the 'up' ``Link`` header. + + """ + __slots__ = ('body', 'authzr_uri') + + @property + def uri(self): # pylint: disable=missing-docstring,no-self-argument + # bug? 'method already defined line None' + # pylint: disable=function-redefined + return self.body.uri + + +class ChallengeBody(ResourceBody): + """Challenge Resource Body. + + .. todo:: + Confusingly, this has a similar name to `.challenges.Challenge`, + as well as `.achallenges.AnnotatedChallenge` or + `.achallenges.Indexed`... Once `messages2` and `network2` is + integrated with the rest of the client, this class functionality + will be merged with `.challenges.Challenge`. Meanwhile, + separation allows the ``master`` to be still interoperable with + Node.js server (protocol v00). For the time being use names such + as ``challb`` to distinguish instances of this class from + ``achall`` or ``ichall``. + + :ivar letsencrypt.acme.messages2.Status status: + :ivar datetime.datetime validated: + + """ + + __slots__ = ('chall',) + uri = jose.Field('uri') + status = jose.Field('status', decoder=Status.from_json) + validated = fields.RFC3339Field('validated', omitempty=True) + + def to_json(self): + jobj = super(ChallengeBody, self).to_json() + jobj.update(self.chall.to_json()) + return jobj + + @classmethod + def fields_from_json(cls, jobj): + jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj) + jobj_fields['chall'] = challenges.Challenge.from_json(jobj) + return jobj_fields + + +class AuthorizationResource(Resource): + """Authorization Resource. + + :ivar letsencrypt.acme.messages2.Authorization body: + :ivar str new_cert_uri: URI found in the 'next' ``Link`` header + + """ + __slots__ = ('body', 'uri', 'new_cert_uri') + + +class Authorization(ResourceBody): + """Authorization Resource Body. + + :ivar letsencrypt.acme.messages2.Identifier identifier: + :ivar list challenges: `list` of `Challenge` + :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` + of `int`, as opposed to `list` of `list` from the spec). + :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. + :ivar tuple contact: + :ivar letsencrypt.acme.messages2.Status status: + :ivar datetime.datetime expires: + + """ + + identifier = jose.Field('identifier', decoder=Identifier.from_json) + challenges = jose.Field('challenges', omitempty=True) + combinations = jose.Field('combinations', omitempty=True) + + # TODO: acme-spec #92, #98 + key = Registration._fields['key'] + contact = Registration._fields['contact'] + + status = jose.Field('status', omitempty=True, decoder=Status.from_json) + # TODO: 'expires' is allowed for Authorization Resources in + # general, but for Key Authorization '[t]he "expires" field MUST + # be absent'... then acme-spec gives example with 'expires' + # present... That's confusing! + expires = fields.RFC3339Field('expires', omitempty=True) + + @challenges.decoder + def challenges(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(ChallengeBody.from_json(chall) for chall in value) + + @property + def resolved_combinations(self): + """Combinations with challenges instead of indices.""" + return tuple(tuple(self.challenges[idx] for idx in combo) + for combo in self.combinations) + + +class CertificateRequest(jose.JSONObjectWithFields): + """ACME new-cert request. + + :ivar letsencrypt.acme.jose.util.ComparableX509 csr: + `M2Crypto.X509.Request` wrapped in `.ComparableX509` + :ivar tuple authorizations: `tuple` of URIs (`str`) + + """ + csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr) + authorizations = jose.Field('authorizations', decoder=tuple) + + +class CertificateResource(Resource): + """Certificate Resource. + + :ivar letsencrypt.acme.jose.util.ComparableX509 body: + `M2Crypto.X509.X509` wrapped in `.ComparableX509` + :ivar str cert_chain_uri: URI found in the 'up' ``Link`` header + :ivar tuple authzrs: `tuple` of `AuthorizationResource`. + + """ + __slots__ = ('body', 'uri', 'cert_chain_uri', 'authzrs') + + +class Revocation(jose.JSONObjectWithFields): + """Revocation message. + + :ivar revoke: Either a `datetime.datetime` or `Revocation.NOW`. + :ivar tuple authorizations: Same as `CertificateRequest.authorizations` + + """ + + NOW = 'now' + """A possible value for `revoke`, denoting that certificate should + be revoked now.""" + + revoke = jose.Field('revoke') + authorizations = CertificateRequest._fields['authorizations'] + + @revoke.decoder + def revoke(value): # pylint: disable=missing-docstring,no-self-argument + if value == Revocation.NOW: + return value + else: + return fields.RFC3339Field.default_decoder(value) + + @revoke.encoder + def revoke(value): # pylint: disable=missing-docstring,no-self-argument + if value == Revocation.NOW: + return value + else: + return fields.RFC3339Field.default_encoder(value) diff --git a/letsencrypt/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py new file mode 100644 index 000000000..5297d6362 --- /dev/null +++ b/letsencrypt/acme/messages2_test.py @@ -0,0 +1,172 @@ +"""Tests for letsencrypt.acme.messages2.""" +import datetime +import unittest + +import mock +import pytz + +from letsencrypt.acme import challenges +from letsencrypt.acme import jose + + +class ErrorTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.Error.""" + + def setUp(self): + from letsencrypt.acme.messages2 import Error + self.error = Error(detail='foo', typ='malformed') + + def test_typ_prefix(self): + self.assertEqual('malformed', self.error.typ) + self.assertEqual( + 'urn:acme:error:malformed', self.error.to_json()['type']) + self.assertEqual( + 'malformed', self.error.from_json(self.error.to_json()).typ) + + def test_typ_decoder_missing_prefix(self): + from letsencrypt.acme.messages2 import Error + self.assertRaises(jose.DeserializationError, Error.from_json, + {'detail': 'foo', 'type': 'malformed'}) + self.assertRaises(jose.DeserializationError, Error.from_json, + {'detail': 'foo', 'type': 'not valid bare type'}) + + def test_typ_decoder_not_recognized(self): + from letsencrypt.acme.messages2 import Error + self.assertRaises(jose.DeserializationError, Error.from_json, + {'detail': 'foo', 'type': 'urn:acme:error:baz'}) + + def test_description(self): + self.assertEqual( + 'The request message was malformed', self.error.description) + + +class ConstantTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2._Constant.""" + + def setUp(self): + from letsencrypt.acme.messages2 import _Constant + class MockConstant(_Constant): # pylint: disable=missing-docstring + POSSIBLE_NAMES = {} + + self.MockConstant = MockConstant # pylint: disable=invalid-name + self.const_a = MockConstant('a') + self.const_b = MockConstant('b') + + def test_to_json(self): + self.assertEqual('a', self.const_a.to_json()) + self.assertEqual('b', self.const_b.to_json()) + + def test_from_json(self): + self.assertEqual(self.const_a, self.MockConstant.from_json('a')) + self.assertRaises( + jose.DeserializationError, self.MockConstant.from_json, 'c') + + def test_repr(self): + self.assertEqual('MockConstant(a)', repr(self.const_a)) + self.assertEqual('MockConstant(b)', repr(self.const_b)) + + +class ChallengeResourceTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.ChallengeResource.""" + + def test_uri(self): + from letsencrypt.acme.messages2 import ChallengeResource + self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock( + uri='http://challb'), authzr_uri='http://authz').uri) + + +class ChallengeBodyTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.ChallengeBody.""" + + def setUp(self): + self.chall = challenges.DNS(token='foo') + + from letsencrypt.acme.messages2 import ChallengeBody + from letsencrypt.acme.messages2 import STATUS_VALID + self.status = STATUS_VALID + self.challb = ChallengeBody( + uri='http://challb', chall=self.chall, status=self.status) + + self.jobj_to = { + 'uri': 'http://challb', + 'status': self.status, + 'type': 'dns', + 'token': 'foo', + } + self.jobj_from = self.jobj_to.copy() + self.jobj_from['status'] = 'valid' + + def test_to_json(self): + self.assertEqual(self.jobj_to, self.challb.to_json()) + + def test_fields_from_json(self): + from letsencrypt.acme.messages2 import ChallengeBody + self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from)) + + +class AuthorizationTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.Authorization.""" + + def setUp(self): + from letsencrypt.acme.messages2 import ChallengeBody + from letsencrypt.acme.messages2 import STATUS_VALID + self.challbs = ( + ChallengeBody( + uri='http://challb1', status=STATUS_VALID, + chall=challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A')), + ChallengeBody(uri='http://challb2', status=STATUS_VALID, + chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')), + ChallengeBody(uri='http://challb3', status=STATUS_VALID, + chall=challenges.RecoveryToken()), + ) + combinations = ((0, 2), (1, 2)) + + from letsencrypt.acme.messages2 import Authorization + from letsencrypt.acme.messages2 import Identifier + from letsencrypt.acme.messages2 import IDENTIFIER_FQDN + identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com') + self.authz = Authorization( + identifier=identifier, combinations=combinations, + challenges=self.challbs) + + self.jobj_from = { + 'identifier': identifier.fully_serialize(), + 'challenges': [challb.fully_serialize() for challb in self.challbs], + 'combinations': combinations, + } + + def test_from_json(self): + from letsencrypt.acme.messages2 import Authorization + Authorization.from_json(self.jobj_from) + + def test_resolved_combinations(self): + self.assertEqual(self.authz.resolved_combinations, ( + (self.challbs[0], self.challbs[2]), + (self.challbs[1], self.challbs[2]), + )) + + +class RevocationTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.RevocationTest.""" + + def setUp(self): + from letsencrypt.acme.messages2 import Revocation + self.rev_now = Revocation(authorizations=(), revoke=Revocation.NOW) + self.rev_date = Revocation(authorizations=(), revoke=datetime.datetime( + 2015, 3, 27, tzinfo=pytz.utc)) + self.jobj_now = {'authorizations': (), 'revoke': Revocation.NOW} + self.jobj_date = {'authorizations': (), + 'revoke': '2015-03-27T00:00:00Z'} + + def test_revoke_decoder(self): + from letsencrypt.acme.messages2 import Revocation + self.assertEqual(self.rev_now, Revocation.from_json(self.jobj_now)) + self.assertEqual(self.rev_date, Revocation.from_json(self.jobj_date)) + + def test_revoke_encoder(self): + self.assertEqual(self.jobj_now, self.rev_now.to_json()) + self.assertEqual(self.jobj_date, self.rev_date.to_json()) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index bd6f4d702..0d15633a5 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -11,8 +11,9 @@ from letsencrypt.acme import jose from letsencrypt.acme import other -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))) CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) diff --git a/letsencrypt/acme/other_test.py b/letsencrypt/acme/other_test.py index 61c37f6a3..047abe54d 100644 --- a/letsencrypt/acme/other_test.py +++ b/letsencrypt/acme/other_test.py @@ -7,10 +7,12 @@ import Crypto.PublicKey.RSA from letsencrypt.acme import jose -RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) -RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa512_key.pem')) +RSA256_KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))) +RSA512_KEY = jose.HashableRSAKey( + Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa512_key.pem'))) class SignatureTest(unittest.TestCase): diff --git a/letsencrypt/client/apache/__init__.py b/letsencrypt/client/apache/__init__.py deleted file mode 100644 index f1b2c08e7..000000000 --- a/letsencrypt/client/apache/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Let's Encrypt client.apache.""" diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 7ded4322c..8e5020dc2 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -5,6 +5,7 @@ import sys import Crypto.PublicKey.RSA from letsencrypt.acme import challenges +from letsencrypt.acme import jose from letsencrypt.acme import messages from letsencrypt.client import achallenges @@ -16,12 +17,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ACME Authorization Handler for a client. :ivar dv_auth: Authenticator capable of solving - :const:`~letsencrypt.client.constants.DV_CHALLENGES` + :class:`~letsencrypt.acme.challenges.DVChallenge` types :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` - :ivar client_auth: Authenticator capable of solving - :const:`~letsencrypt.client_auth.constants.CLIENT_CHALLENGES` - :type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + :ivar cont_auth: Authenticator capable of solving + :class:`~letsencrypt.acme.challenges.ContinuityChallenge` types + :type cont_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` :ivar network: Network object for sending and receiving authorization messages @@ -36,13 +37,13 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar dict paths: optimal path for authorization. eg. paths[domain] :ivar dict dv_c: Keys - domain, Values are DV challenges in the form of :class:`letsencrypt.client.achallenges.Indexed` - :ivar dict client_c: Keys - domain, Values are Client challenges in the form - of :class:`letsencrypt.client.achallenges.Indexed` + :ivar dict cont_c: Keys - domain, Values are Continuity challenges in the + form of :class:`letsencrypt.client.achallenges.Indexed` """ - def __init__(self, dv_auth, client_auth, network): + def __init__(self, dv_auth, cont_auth, network): self.dv_auth = dv_auth - self.client_auth = client_auth + self.cont_auth = cont_auth self.network = network self.domains = [] @@ -52,7 +53,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.paths = dict() self.dv_c = dict() - self.client_c = dict() + self.cont_c = dict() def add_chall_msg(self, domain, msg, authkey): """Add a challenge message to the AuthHandler. @@ -76,7 +77,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.authkey[domain] = authkey def get_authorizations(self): - """Retreive all authorizations for challenges. + """Retrieve all authorizations for challenges. :raises LetsEncryptAuthHandlerError: If unable to retrieve all authorizations @@ -119,8 +120,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes nonce=self.msgs[domain].nonce, responses=self.responses[domain], name=domain, - key=Crypto.PublicKey.RSA.importKey( - self.authkey[domain].pem)), + key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + self.authkey[domain].pem))), messages.Authorization) logging.info("Received Authorization for %s", domain) return auth @@ -147,24 +148,24 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self._get_chall_pref(dom), self.msgs[dom].combinations) - self.dv_c[dom], self.client_c[dom] = self._challenge_factory( + self.dv_c[dom], self.cont_c[dom] = self._challenge_factory( dom, self.paths[dom]) # Flatten challs for authenticator functions and remove index # Order is important here as we will not expose the outside # Authenticator to our own indices. - flat_client = [] + flat_cont = [] flat_dv = [] for dom in self.domains: - flat_client.extend(ichall.achall for ichall in self.client_c[dom]) + flat_cont.extend(ichall.achall for ichall in self.cont_c[dom]) flat_dv.extend(ichall.achall for ichall in self.dv_c[dom]) - client_resp = [] + cont_resp = [] dv_resp = [] try: - if flat_client: - client_resp = self.client_auth.perform(flat_client) + if flat_cont: + cont_resp = self.cont_auth.perform(flat_cont) if flat_dv: dv_resp = self.dv_auth.perform(flat_dv) # This will catch both specific types of errors. @@ -181,8 +182,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes logging.info("Ready for verification...") # Assemble Responses - if client_resp: - self._assign_responses(client_resp, self.client_c) + if cont_resp: + self._assign_responses(cont_resp, self.cont_c) if dv_resp: self._assign_responses(dv_resp, self.dv_c) @@ -191,7 +192,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param list flat_list: flat_list of responses from an IAuthenticator :param dict ichall_dict: Master dict mapping all domains to a list of - their associated 'client' and 'dv' Indexed challenges, or their + their associated 'continuity' and 'dv' Indexed challenges, or their :class:`letsencrypt.client.achallenges.Indexed` list """ @@ -203,7 +204,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes def _path_satisfied(self, dom): """Returns whether a path has been completely satisfied.""" - return all(self.responses[dom][i] is not None for i in self.paths[dom]) + # Make sure that there are no 'None' or 'False' entries along path. + return all(self.responses[dom][i] for i in self.paths[dom]) def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -212,7 +214,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ chall_prefs = [] - chall_prefs.extend(self.client_auth.get_chall_pref(domain)) + chall_prefs.extend(self.cont_auth.get_chall_pref(domain)) chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) return chall_prefs @@ -227,11 +229,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # Chose to make these lists instead of a generator to make it easier to # work with... dv_list = [ichall.achall for ichall in self.dv_c[domain]] - client_list = [ichall.achall for ichall in self.client_c[domain]] + cont_list = [ichall.achall for ichall in self.cont_c[domain]] if dv_list: self.dv_auth.cleanup(dv_list) - if client_list: - self.client_auth.cleanup(client_list) + if cont_list: + self.cont_auth.cleanup(cont_list) def _cleanup_state(self, delete_list): """Cleanup state after an authorization is received. @@ -246,7 +248,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes del self.authkey[domain] - del self.client_c[domain] + del self.cont_c[domain] del self.dv_c[domain] self.domains.remove(domain) @@ -258,9 +260,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param list path: List of indices from `challenges`. - :returns: dv_chall, list of + :returns: dv_chall, list of DVChallenge type :class:`letsencrypt.client.achallenges.Indexed` - client_chall, list of + cont_chall, list of ContinuityChallenge type :class:`letsencrypt.client.achallenges.Indexed` :rtype: tuple @@ -269,7 +271,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ dv_chall = [] - client_chall = [] + cont_chall = [] for index in path: chall = self.msgs[domain].challenges[index] @@ -303,35 +305,38 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes ichall = achallenges.Indexed(achall=achall, index=index) - if isinstance(chall, challenges.ClientChallenge): - client_chall.append(ichall) + if isinstance(chall, challenges.ContinuityChallenge): + cont_chall.append(ichall) elif isinstance(chall, challenges.DVChallenge): dv_chall.append(ichall) - return dv_chall, client_chall + return dv_chall, cont_chall def gen_challenge_path(challs, preferences, combinations): """Generate a plan to get authority over the identity. - .. todo:: Make sure that the challenges are feasible... - Example: Do you have the recovery key? + .. todo:: This can be possibly be rewritten to use resolved_combinations. - :param list challs: A list of challenges + :param tuple challs: A tuple of challenges (:class:`letsencrypt.acme.challenges.Challenge`) from :class:`letsencrypt.acme.messages.Challenge` server message to be fulfilled by the client in order to prove possession of the identifier. :param list preferences: List of challenge preferences for domain - (:class:`letsencrypt.acme.challenges.Challege` subclasses) + (:class:`letsencrypt.acme.challenges.Challenge` subclasses) - :param list combinations: A collection of sets of challenges from + :param tuple combinations: A collection of sets of challenges from :class:`letsencrypt.acme.messages.Challenge`, each of which would be sufficient to prove possession of the identifier. - :returns: List of indices from ``challenges``. - :rtype: list + :returns: tuple of indices from ``challenges``. + :rtype: tuple + + :raises letsencrypt.client.errors.LetsEncryptAuthHandlerError: If a + path cannot be created that satisfies the CA given the preferences and + combinations. """ if combinations: @@ -348,29 +353,34 @@ def _find_smart_path(challs, preferences, combinations): """ chall_cost = {} - max_cost = 0 + max_cost = 1 for i, chall_cls in enumerate(preferences): chall_cost[chall_cls] = i max_cost += i + # max_cost is now equal to sum(indices) + 1 + best_combo = [] # Set above completing all of the available challenges - best_combo_cost = max_cost + 1 + best_combo_cost = max_cost combo_total = 0 for combo in combinations: for challenge_index in combo: combo_total += chall_cost.get(challs[ challenge_index].__class__, max_cost) + if combo_total < best_combo_cost: best_combo = combo best_combo_cost = combo_total - combo_total = 0 + + combo_total = 0 if not best_combo: - logging.fatal("Client does not support any combination of " - "challenges to satisfy ACME server") - sys.exit(22) + msg = ("Client does not support any combination of challenges that " + "will satisfy the CA.") + logging.fatal(msg) + raise errors.LetsEncryptAuthHandlerError(msg) return best_combo diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 2f3f9a769..2fcb45d40 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -6,11 +6,11 @@ import sys import Crypto.PublicKey.RSA import M2Crypto +from letsencrypt.acme import jose from letsencrypt.acme import messages -from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import auth_handler -from letsencrypt.client import client_authenticator +from letsencrypt.client import continuity_auth from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import le_util @@ -18,7 +18,7 @@ from letsencrypt.client import network from letsencrypt.client import reverter from letsencrypt.client import revoker -from letsencrypt.client.apache import configurator +from letsencrypt.client.plugins.apache import configurator from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements @@ -33,7 +33,8 @@ class Client(object): :type authkey: :class:`letsencrypt.client.le_util.Key` :ivar auth_handler: Object that supports the IAuthenticator interface. - auth_handler contains both a dv_authenticator and a client_authenticator + auth_handler contains both a dv_authenticator and a + continuity_authenticator :type auth_handler: :class:`letsencrypt.client.auth_handler.AuthHandler` :ivar installer: Object supporting the IInstaller interface. @@ -60,9 +61,9 @@ class Client(object): self.config = config if dv_auth is not None: - client_auth = client_authenticator.ClientAuthenticator(config) + cont_auth = continuity_auth.ContinuityAuthenticator(config) self.auth_handler = auth_handler.AuthHandler( - dv_auth, client_auth, self.network) + dv_auth, cont_auth, self.network) else: self.auth_handler = None @@ -130,9 +131,10 @@ class Client(object): logging.info("Preparing and sending CSR...") return self.network.send_and_receive_expected( messages.CertificateRequest.create( - csr=jose_util.ComparableX509( + csr=jose.ComparableX509( M2Crypto.X509.load_request_der_string(csr_der)), - key=Crypto.PublicKey.RSA.importKey(self.authkey.pem)), + key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + self.authkey.pem))), messages.Certificate) def save_certificate(self, certificate_msg, cert_path, chain_path): diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 3e27d88ac..43cf5e8a0 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -31,7 +31,7 @@ List of expected options parameters: APACHE_MOD_SSL_CONF = pkg_resources.resource_filename( - "letsencrypt.client.apache", "options-ssl.conf") + "letsencrypt.client.plugins.apache", "options-ssl.conf") """Path to the Apache mod_ssl config file found in the Let's Encrypt distribution.""" diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/continuity_auth.py similarity index 83% rename from letsencrypt/client/client_authenticator.py rename to letsencrypt/client/continuity_auth.py index 3cef97355..063d3d408 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/continuity_auth.py @@ -1,4 +1,4 @@ -"""Client Authenticator""" +"""Continuity Authenticator""" import zope.interface from letsencrypt.acme import challenges @@ -9,9 +9,9 @@ from letsencrypt.client import interfaces from letsencrypt.client import recovery_token -class ClientAuthenticator(object): +class ContinuityAuthenticator(object): """IAuthenticator for - :const:`~letsencrypt.client.constants.CLIENT_CHALLENGES`. + :const:`~letsencrypt.acme.challenges.ContinuityChallenge` class challenges. :ivar rec_token: Performs "recoveryToken" challenges :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` @@ -41,7 +41,7 @@ class ClientAuthenticator(object): if isinstance(achall, achallenges.RecoveryToken): responses.append(self.rec_token.perform(achall)) else: - raise errors.LetsEncryptClientAuthError("Unexpected Challenge") + raise errors.LetsEncryptContAuthError("Unexpected Challenge") return responses def cleanup(self, achalls): @@ -50,4 +50,4 @@ class ClientAuthenticator(object): if isinstance(achall, achallenges.RecoveryToken): self.rec_token.cleanup(achall) else: - raise errors.LetsEncryptClientAuthError("Unexpected Challenge") + raise errors.LetsEncryptContAuthError("Unexpected Challenge") diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index c1d6c785f..243326b14 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -5,6 +5,14 @@ class LetsEncryptClientError(Exception): """Generic Let's Encrypt client error.""" +class NetworkError(LetsEncryptClientError): + """Network error.""" + + +class UnexpectedUpdate(NetworkError): + """Unexpected update.""" + + class LetsEncryptReverterError(LetsEncryptClientError): """Let's Encrypt Reverter error.""" @@ -14,7 +22,7 @@ class LetsEncryptAuthHandlerError(LetsEncryptClientError): """Let's Encrypt Auth Handler error.""" -class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError): +class LetsEncryptContAuthError(LetsEncryptAuthHandlerError): """Let's Encrypt Client Authenticator error.""" diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index de6db575b..2719583c3 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -11,6 +11,9 @@ from letsencrypt.acme import messages from letsencrypt.client import errors +# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning +requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() + logging.getLogger("requests").setLevel(logging.WARNING) diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py new file mode 100644 index 000000000..c2f535096 --- /dev/null +++ b/letsencrypt/client/network2.py @@ -0,0 +1,506 @@ +"""Networking for ACME protocol v02.""" +import datetime +import heapq +import httplib +import itertools +import logging +import time + +import M2Crypto +import requests +import werkzeug + +from letsencrypt.acme import jose +from letsencrypt.acme import messages2 + +from letsencrypt.client import errors + + +# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning +requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() + + +class Network(object): + """ACME networking. + + .. todo:: + Clean up raised error types hierarchy, document, and handle (wrap) + instances of `.DeserializationError` raised in `from_json()``. + + :ivar str new_reg_uri: Location of new-reg + :ivar key: `.JWK` (private) + :ivar alg: `.JWASignature` + + """ + + DER_CONTENT_TYPE = 'application/pkix-cert' + JSON_CONTENT_TYPE = 'application/json' + JSON_ERROR_CONTENT_TYPE = 'application/problem+json' + + def __init__(self, new_reg_uri, key, alg=jose.RS256): + self.new_reg_uri = new_reg_uri + self.key = key + self.alg = alg + + def _wrap_in_jws(self, obj): + """Wrap `JSONDeSerializable` object in JWS. + + :rtype: `.JWS` + + """ + dumps = obj.json_dumps() + logging.debug('Serialized JSON: %s', dumps) + return jose.JWS.sign( + payload=dumps, key=self.key, alg=self.alg).json_dumps() + + @classmethod + def _check_response(cls, response, content_type=None): + """Check response content and its type. + + .. note:: + Checking is not strict: wrong server response ``Content-Type`` + HTTP header is ignored if response is an expected JSON object + (c.f. Boulder #56). + + :param str content_type: Expected Content-Type response header. + If JSON is expected and not present in server response, this + function will raise an error. Otherwise, wrong Content-Type + is ignored, but logged. + + :raises letsencrypt.messages2.Error: If server response body + carries HTTP Problem (draft-ietf-appsawg-http-problem-00). + :raises letsencrypt.errors.NetworkError: In case of other + networking errors. + + """ + response_ct = response.headers.get('Content-Type') + + try: + # TODO: response.json() is called twice, once here, and + # once in _get and _post clients + jobj = response.json() + except ValueError as error: + jobj = None + + if not response.ok: + if jobj is not None: + if response_ct != cls.JSON_ERROR_CONTENT_TYPE: + logging.debug( + 'Ignoring wrong Content-Type (%r) for JSON Error', + response_ct) + + try: + raise messages2.Error.from_json(jobj) + except jose.DeserializationError as error: + # Couldn't deserialize JSON object + raise errors.NetworkError((response, error)) + else: + # response is not JSON object + raise errors.NetworkError(response) + else: + if jobj is not None and response_ct != cls.JSON_CONTENT_TYPE: + logging.debug( + 'Ignoring wrong Content-Type (%r) for JSON decodable ' + 'response', response_ct) + + if content_type == cls.JSON_CONTENT_TYPE and jobj is None: + raise errors.NetworkError( + 'Unexpected response Content-Type: {0}'.format(response_ct)) + + def _get(self, uri, content_type=JSON_CONTENT_TYPE, **kwargs): + """Send GET request. + + :raises letsencrypt.client.errors.NetworkError: + + :returns: HTTP Response + :rtype: `requests.Response` + + """ + try: + response = requests.get(uri, **kwargs) + except requests.exceptions.RequestException as error: + raise errors.NetworkError(error) + self._check_response(response, content_type=content_type) + return response + + def _post(self, uri, data, content_type=JSON_CONTENT_TYPE, **kwargs): + """Send POST data. + + :param str content_type: Expected ``Content-Type``, fails if not set. + + :raises letsencrypt.acme.messages2.NetworkError: + + :returns: HTTP Response + :rtype: `requests.Response` + + """ + logging.debug('Sending POST data: %s', data) + try: + response = requests.post(uri, data=data, **kwargs) + except requests.exceptions.RequestException as error: + raise errors.NetworkError(error) + logging.debug('Received response %s: %s', response, response.text) + + self._check_response(response, content_type=content_type) + return response + + @classmethod + def _regr_from_response(cls, response, uri=None, new_authzr_uri=None, + terms_of_service=None): + terms_of_service = ( + response.links['terms-of-service']['url'] + if 'terms-of-service' in response.links else terms_of_service) + + if new_authzr_uri is None: + try: + new_authzr_uri = response.links['next']['url'] + except KeyError: + raise errors.NetworkError('"next" link missing') + + return messages2.RegistrationResource( + body=messages2.Registration.from_json(response.json()), + uri=response.headers.get('Location', uri), + new_authzr_uri=new_authzr_uri, + terms_of_service=terms_of_service) + + def register(self, contact=messages2.Registration._fields[ + 'contact'].default): + """Register. + + :param contact: Contact list, as accpeted by `.RegistrationResource` + :type contact: `tuple` + + :returns: Registration Resource. + :rtype: `.RegistrationResource` + + :raises letsencrypt.client.errors.UnexpectedUpdate: + + """ + new_reg = messages2.Registration(contact=contact) + + response = self._post(self.new_reg_uri, self._wrap_in_jws(new_reg)) + assert response.status_code == httplib.CREATED # TODO: handle errors + + regr = self._regr_from_response(response) + if regr.body.key != self.key.public() or regr.body.contact != contact: + raise errors.UnexpectedUpdate(regr) + + return regr + + def update_registration(self, regr): + """Update registration. + + :pram regr: Registration Resource. + :type regr: `.RegistrationResource` + + :returns: Updated Registration Resource. + :rtype: `.RegistrationResource` + + """ + response = self._post(regr.uri, self._wrap_in_jws(regr.body)) + + # TODO: Boulder returns httplib.ACCEPTED + #assert response.status_code == httplib.OK + + # TODO: Boulder does not set Location or Link on update + # (c.f. acme-spec #94) + + updated_regr = self._regr_from_response( + response, uri=regr.uri, new_authzr_uri=regr.new_authzr_uri, + terms_of_service=regr.terms_of_service) + if updated_regr != regr: + # TODO: Boulder reregisters with new recoveryToken and new URI + raise errors.UnexpectedUpdate(regr) + return updated_regr + + def _authzr_from_response(self, response, identifier, + uri=None, new_cert_uri=None): + if new_cert_uri is None: + try: + new_cert_uri = response.links['next']['url'] + except KeyError: + raise errors.NetworkError('"next" link missing') + + authzr = messages2.AuthorizationResource( + body=messages2.Authorization.from_json(response.json()), + uri=response.headers.get('Location', uri), + new_cert_uri=new_cert_uri) + if (authzr.body.key != self.key.public() + or authzr.body.identifier != identifier): + raise errors.UnexpectedUpdate(authzr) + return authzr + + def request_challenges(self, identifier, regr): + """Request challenges. + + :param identifier: Identifier to be challenged. + :type identifier: `.messages2.Identifier` + + :param regr: Registration Resource. + :type regr: `.RegistrationResource` + + :returns: Authorization Resource. + :rtype: `.AuthorizationResource` + + """ + new_authz = messages2.Authorization(identifier=identifier) + response = self._post(regr.new_authzr_uri, self._wrap_in_jws(new_authz)) + assert response.status_code == httplib.CREATED # TODO: handle errors + return self._authzr_from_response(response, identifier) + + def request_domain_challenges(self, domain, regr): + """Request challenges for domain names. + + This is simply a convenience function that wraps around + `request_challenges`, but works with domain names instead of + generic identifiers. + + :param str domain: Domain name to be challenged. + + """ + return self.request_challenges(messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value=domain), regr) + + def answer_challenge(self, challb, response): + """Answer challenge. + + :param challb: Challenge Resource body. + :type challb: `.ChallengeBody` + + :param response: Corresponding Challenge response + :type response: `.challenges.ChallengeResponse` + + :returns: Challenge Resource with updated body. + :rtype: `.ChallengeResource` + + :raises errors.UnexpectedUpdate: + + """ + response = self._post(challb.uri, self._wrap_in_jws(response)) + try: + authzr_uri = response.links['up']['url'] + except KeyError: + raise errors.NetworkError('"up" Link header missing') + challr = messages2.ChallengeResource( + authzr_uri=authzr_uri, + body=messages2.ChallengeBody.from_json(response.json())) + # TODO: check that challr.uri == response.headers['Location']? + if challr.uri != challb.uri: + raise errors.UnexpectedUpdate(challr.uri) + return challr + + def answer_challenges(self, challbs, responses): + """Answer multiple challenges. + + .. note:: This is a convenience function to make integration + with old proto code easier and shall probably be removed + once restification is over. + + """ + return [self.answer_challenge(challb, response) + for challb, response in itertools.izip(challbs, responses)] + + @classmethod + def retry_after(cls, response, default): + """Compute next `poll` time based on response ``Retry-After`` header. + + :param response: Response from `poll`. + :type response: `requests.Response` + + :param int default: Default value (in seconds), used when + ``Retry-After`` header is not present or invalid. + + :returns: Time point when next `poll` should be performed. + :rtype: `datetime.datetime` + + """ + retry_after = response.headers.get('Retry-After', str(default)) + try: + seconds = int(retry_after) + except ValueError: + # pylint: disable=no-member + decoded = werkzeug.parse_date(retry_after) # RFC1123 + if decoded is None: + seconds = default + else: + return decoded + + return datetime.datetime.now() + datetime.timedelta(seconds=seconds) + + def poll(self, authzr): + """Poll Authorization Resource for status. + + :param authzr: Authorization Resource + :type authzr: `.AuthorizationResource` + + :returns: Updated Authorization Resource and HTTP response. + + :rtype: (`.AuthorizationResource`, `requests.Response`) + + """ + response = self._get(authzr.uri) + updated_authzr = self._authzr_from_response( + response, authzr.body.identifier, authzr.uri, authzr.new_cert_uri) + # TODO: check and raise UnexpectedUpdate + + return updated_authzr, response + + def request_issuance(self, csr, authzrs): + """Request issuance. + + :param csr: CSR + :type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509` + + :param authzrs: `list` of `.AuthorizationResource` + + :returns: Issued certificate + :rtype: `.messages2.CertificateResource` + + """ + assert authzrs, "Authorizations list is empty" + + # TODO: assert len(authzrs) == number of SANs + req = messages2.CertificateRequest( + csr=csr, authorizations=tuple(authzr.uri for authzr in authzrs)) + + content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument + response = self._post( + authzrs[0].new_cert_uri, # TODO: acme-spec #90 + self._wrap_in_jws(req), + content_type=content_type, + headers={'Accept': content_type}) + + cert_chain_uri = response.links.get('up', {}).get('url') + + try: + uri = response.headers['Location'] + except KeyError: + raise errors.NetworkError('"Location" Header missing') + + return messages2.CertificateResource( + uri=uri, authzrs=authzrs, cert_chain_uri=cert_chain_uri, + body=jose.ComparableX509( + M2Crypto.X509.load_cert_der_string(response.content))) + + def poll_and_request_issuance(self, csr, authzrs, mintime=5): + """Poll and request issuance. + + This function polls all provided Authorization Resource URIs + until all challenges are valid, respecting ``Retry-After`` HTTP + headers, and then calls `request_issuance`. + + .. todo:: add `max_attempts` or `timeout` + + :param csr: CSR. + :type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509` + + :param authzrs: `list` of `.AuthorizationResource` + + :param int mintime: Minimum time before next attempt, used if + ``Retry-After`` is not present in the response. + + :returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is + the issued certificate (`.messages2.CertificateResource.), + and ``updated_authzrs`` is a `tuple` consisting of updated + Authorization Resources (`.AuthorizationResource`) as + present in the responses from server, and in the same order + as the input ``authzrs``. + :rtype: `tuple` + + """ + # priority queue with datetime (based od Retry-After) as key, + # and original Authorization Resource as value + waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs] + # mapping between original Authorization Resource and the most + # recently updated one + updated = dict((authzr, authzr) for authzr in authzrs) + + while waiting: + # find the smallest Retry-After, and sleep if necessary + when, authzr = heapq.heappop(waiting) + now = datetime.datetime.now() + if when > now: + seconds = (when - now).seconds + logging.debug('Sleeping for %d seconds', seconds) + time.sleep(seconds) + + # Note that we poll with the latest updated Authorization + # URI, which might have a different URI than initial one + updated_authzr, response = self.poll(updated[authzr]) + updated[authzr] = updated_authzr + + if updated_authzr.body.status != messages2.STATUS_VALID: + # push back to the priority queue, with updated retry_after + heapq.heappush(waiting, (self.retry_after( + response, default=mintime), authzr)) + + updated_authzrs = tuple(updated[authzr] for authzr in authzrs) + return self.request_issuance(csr, updated_authzrs), updated_authzrs + + def _get_cert(self, uri): + content_type = self.DER_CONTENT_TYPE # TODO: make it a param + response = self._get(uri, headers={'Accept': content_type}, + content_type=content_type) + return response, jose.ComparableX509( + M2Crypto.X509.load_cert_der_string(response.content)) + + def check_cert(self, certr): + """Check for new cert. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Updated Certificate Resource. + :rtype: `.CertificateResource` + + """ + # TODO: acme-spec 5.1 table action should be renamed to + # "refresh cert", and this method integrated with self.refresh + response, cert = self._get_cert(certr.uri) + if 'Location' not in response.headers: + raise errors.NetworkError('Location header missing') + if response.headers['Location'] != certr.uri: + raise errors.UnexpectedUpdate(response.text) + return certr.update(body=cert) + + def refresh(self, certr): + """Refresh certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Updated Certificate Resource. + :rtype: `.CertificateResource` + + """ + # TODO: If a client sends a refresh request and the server is + # not willing to refresh the certificate, the server MUST + # respond with status code 403 (Forbidden) + return self.check_cert(certr) + + def fetch_chain(self, certr): + """Fetch chain for certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Certificate chain, or `None` if no "up" Link was provided. + :rtype: `M2Crypto.X509.X509` wrapped in `.ComparableX509` + + """ + if certr.cert_chain_uri is not None: + return self._get_cert(certr.cert_chain_uri) + + def revoke(self, certr, when=messages2.Revocation.NOW): + """Revoke certificate. + + :param when: When should the revocation take place? Takes + the same values as `.messages2.Revocation.revoke`. + + """ + rev = messages2.Revocation(revoke=when, authorizations=tuple( + authzr.uri for authzr in certr.authzrs)) + response = self._post(certr.uri, self._wrap_in_jws(rev)) + if response.status_code != httplib.OK: + raise errors.NetworkError( + 'Successful revocation must return HTTP OK status') diff --git a/letsencrypt/client/plugins/__init__.py b/letsencrypt/client/plugins/__init__.py new file mode 100644 index 000000000..538189015 --- /dev/null +++ b/letsencrypt/client/plugins/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.""" diff --git a/letsencrypt/client/plugins/apache/__init__.py b/letsencrypt/client/plugins/apache/__init__.py new file mode 100644 index 000000000..70172b06d --- /dev/null +++ b/letsencrypt/client/plugins/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.apache.""" diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py similarity index 92% rename from letsencrypt/client/apache/configurator.py rename to letsencrypt/client/plugins/apache/configurator.py index 93db689f8..e6104a559 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -18,9 +18,9 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util -from letsencrypt.client.apache import dvsni -from letsencrypt.client.apache import obj -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import dvsni +from letsencrypt.client.plugins.apache import obj +from letsencrypt.client.plugins.apache import parser # TODO: Augeas sections ie. , beginning and closing @@ -68,11 +68,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :type config: :class:`~letsencrypt.client.interfaces.IConfig` :ivar parser: Handles low level parsing - :type parser: :class:`letsencrypt.client.apache.parser` + :type parser: :class:`~letsencrypt.client.plugins.apache.parser` :ivar tup version: version of Apache :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`letsencrypt.client.apache.obj.VirtualHost`) + (:class:`list` of + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) :ivar dict assoc: Mapping between domains and vhosts @@ -164,7 +165,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): parser.case_i("SSLCertificateChainFile"), None, vhost.path) if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: - # Throw some "can't find all of the directives error" + # Throw some can't find all of the directives error" logging.warn( "Cannot find a cert or key directive in %s", vhost.path) logging.warn("VirtualHost was not modified") @@ -203,7 +204,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str target_name: domain name :returns: ssl vhost associated with name - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ # Allows for domain names to be associated with a virtual host @@ -223,7 +224,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.assoc[target_name] = vhost return vhost - # Check for non ssl vhosts with servernames/aliases == 'name' + # Check for non ssl vhosts with servernames/aliases == "name" for vhost in self.vhosts: if not vhost.ssl and target_name in vhost.names: vhost = self.make_vhost_ssl(vhost) @@ -244,7 +245,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ self.assoc[domain] = vhost @@ -281,15 +282,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Helper function for get_virtual_hosts(). :param host: In progress vhost whose names will be added - :type host: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type host: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " "%s//*[self::directive=~regexp('%s')]" % (host.path, - parser.case_i('ServerName'), + parser.case_i("ServerName"), host.path, - parser.case_i('ServerAlias')))) + parser.case_i("ServerAlias")))) for name in name_match: args = self.aug.match(name + "/*") @@ -302,7 +303,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ addrs = set() @@ -326,7 +327,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Returns list of virtual hosts found in the Apache configuration. :returns: List of - :class:`letsencrypt.client.apache.obj.VirtualHost` objects + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` objects found in configuration :rtype: list @@ -334,7 +335,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Search sites-available, httpd.conf for possible virtual hosts paths = self.aug.match( ("/files%s/sites-available//*[label()=~regexp('%s')]" % - (self.parser.root, parser.case_i('VirtualHost')))) + (self.parser.root, parser.case_i("VirtualHost")))) vhs = [] for path in paths: @@ -404,7 +405,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Checks to see if the server is ready for SNI challenges. :param vhost: VirtualHost to check SNI compatibility - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :param str default_addr: TODO - investigate function further @@ -436,10 +437,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type nonssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: SSL vhost - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ avail_fp = nonssl_vhost.filep @@ -454,8 +456,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.reverter.register_file_creation(False, ssl_fp) try: - with open(avail_fp, 'r') as orig_file: - with open(ssl_fp, 'w') as new_file: + with open(avail_fp, "r") as orig_file: + with open(ssl_fp, "w") as new_file: new_file.write("\n") for line in orig_file: new_file.write(line) @@ -471,7 +473,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # change address to address:443 addr_match = "/files%s//* [label()=~regexp('%s')]/arg" ssl_addr_p = self.aug.match( - addr_match % (ssl_fp, parser.case_i('VirtualHost'))) + addr_match % (ssl_fp, parser.case_i("VirtualHost"))) for addr in ssl_addr_p: old_addr = obj.Addr.fromstring( @@ -482,7 +484,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Add directives vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" % - (ssl_fp, parser.case_i('VirtualHost'))) + (ssl_fp, parser.case_i("VirtualHost"))) if len(vh_p) != 1: logging.error("Error: should only be one vhost in %s", avail_fp) sys.exit(1) @@ -495,7 +497,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Log actions and create save notes logging.info("Created an SSL vhost at %s", ssl_fp) - self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp + self.save_notes += "Created ssl vhost at %s\n" % ssl_fp self.save() # We know the length is one because of the assertion above @@ -548,8 +550,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. - .. todo:: This enhancement should be rewritten and will unfortunately - require lots of debugging by hand. + .. todo:: This enhancement should be rewritten and will + unfortunately require lots of debugging by hand. + Adds Redirect directive to the port 80 equivalent of ssl_vhost First the function attempts to find the vhost with equivalent ip addresses that serves on non-ssl ports @@ -558,13 +561,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :param unused_options: Not currently used :type unused_options: Not Available :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + :rtype: (bool, + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) """ if not mod_loaded("rewrite_module", self.config.apache_ctl): @@ -595,7 +600,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.parser.add_dir(general_v.path, "RewriteEngine", "On") self.parser.add_dir(general_v.path, "RewriteRule", constants.APACHE_REWRITE_HTTPS_ARGS) - self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' % + self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % (general_v.filep, ssl_vhost.filep)) self.save() @@ -616,7 +621,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: Success, code value... see documentation :rtype: bool, int @@ -648,10 +653,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` - :returns: Success, vhost - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + :returns: tuple of the form + (`success`, + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) + :rtype: tuple """ # Consider changing this to a dictionary check @@ -699,7 +707,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] redirect_filepath = os.path.join( - self.parser.root, 'sites-available', redirect_filename) + self.parser.root, "sites-available", redirect_filename) # Register the new file that will be created # Note: always register the creation before writing to ensure file will @@ -707,7 +715,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.reverter.register_file_creation(False, redirect_filepath) # Write out file - with open(redirect_filepath, 'w') as redirect_fd: + with open(redirect_filepath, "w") as redirect_fd: redirect_fd.write(redirect_file) logging.info("Created redirect file: %s", redirect_filename) @@ -717,8 +725,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.vhosts.append(new_vhost) # Finally create documentation for the change - self.save_notes += ('Created a port 80 vhost, %s, for redirection to ' - 'ssl vhost %s\n' % + self.save_notes += ("Created a port 80 vhost, %s, for redirection to " + "ssl vhost %s\n" % (new_vhost.filep, ssl_vhost.filep)) def _conflicting_host(self, ssl_vhost): @@ -733,7 +741,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not conflict: returns space separated list of new host addrs :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: TODO :rtype: TODO @@ -766,10 +775,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` or None + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` + or None """ # _default_:443 check @@ -859,7 +870,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: Success :rtype: bool @@ -875,7 +886,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): os.symlink(vhost.filep, enabled_path) vhost.enabled = True logging.info("Enabling available site: %s", vhost.filep) - self.save_notes += 'Enabled site %s\n' % vhost.filep + self.save_notes += "Enabled site %s\n" % vhost.filep return True return False @@ -897,7 +908,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - ['sudo', self.config.apache_ctl, 'configtest'], # TODO: sudo? + ["sudo", self.config.apache_ctl, "configtest"], # TODO: sudo? stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -941,7 +952,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - [self.config.apache_ctl, '-v'], + [self.config.apache_ctl, "-v"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) text = proc.communicate()[0] @@ -956,7 +967,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): raise errors.LetsEncryptConfiguratorError( "Unable to find Apache version") - return tuple([int(i) for i in matches[0].split('.')]) + return tuple([int(i) for i in matches[0].split(".")]) def more_info(self): """Human-readable string to help understand the module""" @@ -1031,8 +1042,8 @@ def enable_mod(mod_name, apache_init_script, apache_enmod): # Use check_output so the command will finish before reloading # TODO: a2enmod is debian specific... subprocess.check_call(["sudo", apache_enmod, mod_name], # TODO: sudo? - stdout=open("/dev/null", 'w'), - stderr=open("/dev/null", 'w')) + stdout=open("/dev/null", "w"), + stderr=open("/dev/null", "w")) apache_restart(apache_init_script) except (OSError, subprocess.CalledProcessError) as err: logging.error("Error enabling mod_%s", mod_name) @@ -1054,7 +1065,7 @@ def mod_loaded(module, apache_ctl): """ try: proc = subprocess.Popen( - [apache_ctl, '-M'], + [apache_ctl, "-M"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -1092,7 +1103,7 @@ def apache_restart(apache_init_script): """ try: - proc = subprocess.Popen([apache_init_script, 'restart'], + proc = subprocess.Popen([apache_init_script, "restart"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/plugins/apache/dvsni.py similarity index 80% rename from letsencrypt/client/apache/dvsni.py rename to letsencrypt/client/plugins/apache/dvsni.py index b980fdb36..7755658e7 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/plugins/apache/dvsni.py @@ -2,20 +2,19 @@ import logging import os -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import parser class ApacheDvsni(object): """Class performs DVSNI challenges within the Apache configurator. :ivar configurator: ApacheConfigurator object - :type configurator: - :class:`letsencrypt.client.apache.configurator.ApacheConfigurator` + :type configurator: :class:`~apache.configurator.ApacheConfigurator` :ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI` challenges. - :param list indicies: Meant to hold indices of challenges in a + :param list indices: Meant to hold indices of challenges in a larger array. ApacheDvsni is capable of solving many challenges at once which causes an indexing issue within ApacheConfigurator who must return all responses in order. Imagine ApacheConfigurator @@ -26,6 +25,23 @@ class ApacheDvsni(object): :param str challenge_conf: location of the challenge config file """ + + VHOST_TEMPLATE = """\ + + ServerName {server_name} + UseCanonicalName on + SSLStrictSNIVHostCheck on + + LimitRequestBody 1048576 + + Include {ssl_options_conf_path} + SSLCertificateFile {cert_path} + SSLCertificateKeyFile {key_path} + + DocumentRoot {document_root} + + +""" def __init__(self, configurator): self.configurator = configurator self.achalls = [] @@ -50,7 +66,7 @@ class ApacheDvsni(object): def perform(self): """Peform a DVSNI challenge.""" if not self.achalls: - return None + return [] # Save any changes to the configuration as a precaution # About to make temporary changes to the config self.configurator.save() @@ -101,7 +117,7 @@ class ApacheDvsni(object): cert_pem, response = achall.gen_cert_and_response(s) # Write out challenge cert - with open(cert_path, 'w') as cert_chall_fd: + with open(cert_path, "w") as cert_chall_fd: cert_chall_fd.write(cert_pem) return response @@ -112,7 +128,7 @@ class ApacheDvsni(object): Result: Apache config includes virtual servers for issued challs :param list ll_addrs: list of list of - :class:`letsencrypt.client.apache.obj.Addr` to apply + :class:`letsencrypt.client.plugins.apache.obj.Addr` to apply """ # TODO: Use ip address of existing vhost instead of relying on FQDN @@ -125,7 +141,7 @@ class ApacheDvsni(object): self.configurator.reverter.register_file_creation( True, self.challenge_conf) - with open(self.challenge_conf, 'w') as new_conf: + with open(self.challenge_conf, "w") as new_conf: new_conf.write(config_text) def _conf_include_check(self, main_config): @@ -151,7 +167,7 @@ class ApacheDvsni(object): :type achall: :class:`letsencrypt.client.achallenges.DVSNI` :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` + :class:`list` of type :class:`~apache.obj.Addr` :returns: virtual host configuration text :rtype: str @@ -160,19 +176,16 @@ class ApacheDvsni(object): ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( self.configurator.config.config_dir, "dvsni_page/") - return ("\n" - "ServerName " + achall.nonce_domain + "\n" - "UseCanonicalName on\n" - "SSLStrictSNIVHostCheck on\n" - "\n" - "LimitRequestBody 1048576\n" - "\n" - "Include " + self.configurator.parser.loc["ssl_options"] + "\n" - "SSLCertificateFile " + self.get_cert_file(achall) + "\n" - "SSLCertificateKeyFile " + achall.key.file + "\n" - "\n" - "DocumentRoot " + document_root + "\n" - "\n\n") + # TODO: Python docs is not clear how mutliline string literal + # newlines are parsed on different platforms. At least on + # Linux (Debian sid), when source file uses CRLF, Python still + # parses it as "\n"... c.f.: + # https://docs.python.org/2.7/reference/lexical_analysis.html + return self.VHOST_TEMPLATE.format( + vhost=ips, server_name=achall.nonce_domain, + ssl_options_conf_path=self.configurator.parser.loc["ssl_options"], + cert_path=self.get_cert_file(achall), key_path=achall.key.file, + document_root=document_root).replace("\n", os.linesep) def get_cert_file(self, achall): """Returns standardized name for challenge certificate. diff --git a/letsencrypt/client/apache/obj.py b/letsencrypt/client/plugins/apache/obj.py similarity index 100% rename from letsencrypt/client/apache/obj.py rename to letsencrypt/client/plugins/apache/obj.py diff --git a/letsencrypt/client/apache/options-ssl.conf b/letsencrypt/client/plugins/apache/options-ssl.conf similarity index 100% rename from letsencrypt/client/apache/options-ssl.conf rename to letsencrypt/client/plugins/apache/options-ssl.conf diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/plugins/apache/parser.py similarity index 100% rename from letsencrypt/client/apache/parser.py rename to letsencrypt/client/plugins/apache/parser.py diff --git a/letsencrypt/client/tests/apache/__init__.py b/letsencrypt/client/plugins/apache/tests/__init__.py similarity index 100% rename from letsencrypt/client/tests/apache/__init__.py rename to letsencrypt/client/plugins/apache/tests/__init__.py diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/plugins/apache/tests/configurator_test.py similarity index 84% rename from letsencrypt/client/tests/apache/configurator_test.py rename to letsencrypt/client/plugins/apache/tests/configurator_test.py index 1bb4207a3..91758d196 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/plugins/apache/tests/configurator_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt.client.apache.configurator.""" +"""Test for letsencrypt.client.plugins.apache.configurator.""" import os import re import shutil @@ -12,11 +12,11 @@ from letsencrypt.client import achallenges from letsencrypt.client import errors from letsencrypt.client import le_util -from letsencrypt.client.apache import configurator -from letsencrypt.client.apache import obj -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import configurator +from letsencrypt.client.plugins.apache import obj +from letsencrypt.client.plugins.apache import parser -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util class TwoVhost80Test(util.ApacheTest): @@ -25,7 +25,7 @@ class TwoVhost80Test(util.ApacheTest): def setUp(self): super(TwoVhost80Test, self).setUp() - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "mod_loaded") as mock_load: mock_load.return_value = True self.config = util.get_apache_configurator( @@ -43,9 +43,15 @@ class TwoVhost80Test(util.ApacheTest): def test_get_all_names(self): names = self.config.get_all_names() self.assertEqual(names, set( - ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) + ["letsencrypt.demo", "encryption-example.demo", "ip-172-30-0-17"])) def test_get_virtual_hosts(self): + """Make sure all vhosts are being properly found. + + .. note:: If test fails, only finding 1 Vhost... it is likely that + it is a problem with is_enabled. + + """ vhs = self.config.get_virtual_hosts() self.assertEqual(len(vhs), 4) found = 0 @@ -59,6 +65,14 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(found, 4) def test_is_site_enabled(self): + """Test if site is enabled. + + .. note:: This test currently fails for hard links + (which may happen if you move dirs incorrectly) + .. warning:: This test does not work when running using the + unittest.main() function. It incorrectly copies symlinks. + + """ self.assertTrue(self.config.is_site_enabled(self.vh_truth[0].filep)) self.assertFalse(self.config.is_site_enabled(self.vh_truth[1].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) @@ -134,9 +148,9 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(len(self.config.vhosts), 5) - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "dvsni.ApacheDvsni.perform") - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "ApacheConfigurator.restart") def test_perform(self, mock_restart, mock_dvsni_perform): # Only tests functionality specific to configurator.perform @@ -166,7 +180,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(mock_restart.call_count, 1) - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "subprocess.Popen") def test_get_version(self, mock_popen): mock_popen().communicate.return_value = ( @@ -183,7 +197,7 @@ class TwoVhost80Test(util.ApacheTest): errors.LetsEncryptConfiguratorError, self.config.get_version) mock_popen().communicate.return_value = ( - "Server Version: Apache/2.3\n Apache/2.4.7", "") + "Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "") self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) @@ -192,5 +206,5 @@ class TwoVhost80Test(util.ApacheTest): errors.LetsEncryptConfiguratorError, self.config.get_version) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/plugins/apache/tests/dvsni_test.py similarity index 90% rename from letsencrypt/client/tests/apache/dvsni_test.py rename to letsencrypt/client/plugins/apache/tests/dvsni_test.py index 384e426bb..9bddfc481 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/plugins/apache/tests/dvsni_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt.client.apache.dvsni.""" +"""Test for letsencrypt.client.plugins.apache.dvsni.""" import pkg_resources import unittest import shutil @@ -10,9 +10,9 @@ from letsencrypt.acme import challenges from letsencrypt.client import achallenges from letsencrypt.client import le_util -from letsencrypt.client.apache.obj import Addr +from letsencrypt.client.plugins.apache.obj import Addr -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util class DvsniPerformTest(util.ApacheTest): @@ -21,20 +21,20 @@ class DvsniPerformTest(util.ApacheTest): def setUp(self): super(DvsniPerformTest, self).setUp() - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "mod_loaded") as mock_load: mock_load.return_value = True config = util.get_apache_configurator( self.config_path, self.config_dir, self.work_dir, self.ssl_options) - from letsencrypt.client.apache import dvsni + from letsencrypt.client.plugins.apache import dvsni self.sni = dvsni.ApacheDvsni(config) rsa256_file = pkg_resources.resource_filename( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") rsa256_pem = pkg_resources.resource_string( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") auth_key = le_util.Key(rsa256_file, rsa256_pem) self.achalls = [ @@ -60,7 +60,7 @@ class DvsniPerformTest(util.ApacheTest): def test_perform0(self): resp = self.sni.perform() - self.assertTrue(resp is None) + self.assertEqual(len(resp), 0) def test_setup_challenge_cert(self): # This is a helper function that can be used for handling @@ -74,7 +74,7 @@ class DvsniPerformTest(util.ApacheTest): nonce_domain=self.achalls[0].nonce_domain) achall.gen_cert_and_response.return_value = ("pem", response) - with mock.patch("letsencrypt.client.apache.dvsni.open", + with mock.patch("letsencrypt.client.plugins.apache.dvsni.open", m_open, create=True): # pylint: disable=protected-access self.assertEqual(response, self.sni._setup_challenge_cert( @@ -82,7 +82,7 @@ class DvsniPerformTest(util.ApacheTest): self.assertTrue(m_open.called) self.assertEqual( - m_open.call_args[0], (self.sni.get_cert_file(achall), 'w')) + m_open.call_args[0], (self.sni.get_cert_file(achall), "w")) self.assertEqual(m_open().write.call_args[0][0], "pem") def test_perform1(self): @@ -166,5 +166,5 @@ class DvsniPerformTest(util.ApacheTest): set([self.achalls[1].nonce_domain])) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/apache/obj_test.py b/letsencrypt/client/plugins/apache/tests/obj_test.py similarity index 82% rename from letsencrypt/client/tests/apache/obj_test.py rename to letsencrypt/client/plugins/apache/tests/obj_test.py index 070fa7b11..b0c65eadb 100644 --- a/letsencrypt/client/tests/apache/obj_test.py +++ b/letsencrypt/client/plugins/apache/tests/obj_test.py @@ -1,11 +1,11 @@ -"""Test the helper objects in apache.obj.py.""" +"""Test the helper objects in letsencrypt.client.plugins.apache.obj.""" import unittest class AddrTest(unittest.TestCase): """Test the Addr class.""" def setUp(self): - from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import Addr self.addr1 = Addr.fromstring("192.168.1.1") self.addr2 = Addr.fromstring("192.168.1.1:*") self.addr3 = Addr.fromstring("192.168.1.1:80") @@ -34,7 +34,7 @@ class AddrTest(unittest.TestCase): self.assertFalse(self.addr1 == 3333) def test_set_inclusion(self): - from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import Addr set_a = set([self.addr1, self.addr2]) addr1b = Addr.fromstring("192.168.1.1") addr2b = Addr.fromstring("192.168.1.1:*") @@ -46,15 +46,15 @@ class AddrTest(unittest.TestCase): class VirtualHostTest(unittest.TestCase): """Test the VirtualHost class.""" def setUp(self): - from letsencrypt.client.apache.obj import VirtualHost - from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import VirtualHost + from letsencrypt.client.plugins.apache.obj import Addr self.vhost1 = VirtualHost( "filep", "vh_path", set([Addr.fromstring("localhost")]), False, False) def test_eq(self): - from letsencrypt.client.apache.obj import Addr - from letsencrypt.client.apache.obj import VirtualHost + from letsencrypt.client.plugins.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import VirtualHost vhost1b = VirtualHost( "filep", "vh_path", set([Addr.fromstring("localhost")]), False, False) diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/plugins/apache/tests/parser_test.py similarity index 84% rename from letsencrypt/client/tests/apache/parser_test.py rename to letsencrypt/client/plugins/apache/tests/parser_test.py index f30927886..1696841f8 100644 --- a/letsencrypt/client/tests/apache/parser_test.py +++ b/letsencrypt/client/plugins/apache/tests/parser_test.py @@ -1,4 +1,4 @@ -"""Tests the ApacheParser class.""" +"""Tests for letsencrypt.client.plugins.apache.parser.""" import os import shutil import sys @@ -11,7 +11,7 @@ import zope.component from letsencrypt.client import errors from letsencrypt.client.display import util as display_util -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util class ApacheParserTest(util.ApacheTest): @@ -22,7 +22,7 @@ class ApacheParserTest(util.ApacheTest): zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser self.aug = augeas.Augeas(flags=augeas.Augeas.NONE) self.parser = ApacheParser(self.aug, self.config_path, self.ssl_options) @@ -32,19 +32,19 @@ class ApacheParserTest(util.ApacheTest): shutil.rmtree(self.work_dir) def test_root_normalized(self): - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser path = os.path.join(self.temp_dir, "debian_apache_2_4/////" "two_vhost_80/../two_vhost_80/apache2") parser = ApacheParser(self.aug, path, None) self.assertEqual(parser.root, self.config_path) def test_root_absolute(self): - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser parser = ApacheParser(self.aug, os.path.relpath(self.config_path), None) self.assertEqual(parser.root, self.config_path) def test_root_no_trailing_slash(self): - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser parser = ApacheParser(self.aug, self.config_path + os.path.sep, None) self.assertEqual(parser.root, self.config_path) @@ -67,7 +67,7 @@ class ApacheParserTest(util.ApacheTest): self.assertTrue(matches) def test_find_dir(self): - from letsencrypt.client.apache.parser import case_i + from letsencrypt.client.plugins.apache.parser import case_i test = self.parser.find_dir(case_i("Listen"), "443") # This will only look in enabled hosts test2 = self.parser.find_dir(case_i("documentroot")) @@ -92,7 +92,7 @@ class ApacheParserTest(util.ApacheTest): Path must be valid before attempting to add to augeas """ - from letsencrypt.client.apache.parser import get_aug_path + from letsencrypt.client.plugins.apache.parser import get_aug_path self.parser.add_dir_to_ifmodssl( get_aug_path(self.parser.loc["default"]), "FakeDirective", "123") @@ -103,11 +103,11 @@ class ApacheParserTest(util.ApacheTest): self.assertTrue("IfModule" in matches[0]) def test_get_aug_path(self): - from letsencrypt.client.apache.parser import get_aug_path + from letsencrypt.client.plugins.apache.parser import get_aug_path self.assertEqual("/files/etc/apache", get_aug_path("/etc/apache")) def test_set_locations(self): - with mock.patch("letsencrypt.client.apache.parser." + with mock.patch("letsencrypt.client.plugins.apache.parser." "os.path") as mock_path: mock_path.isfile.return_value = False @@ -125,5 +125,5 @@ class ApacheParserTest(util.ApacheTest): self.assertEqual(results["default"], results["name"]) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/sites b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/sites similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/sites rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/sites diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/sites similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/sites diff --git a/letsencrypt/client/tests/apache/util.py b/letsencrypt/client/plugins/apache/tests/util.py similarity index 89% rename from letsencrypt/client/tests/apache/util.py rename to letsencrypt/client/plugins/apache/tests/util.py index 6e8cf7d53..d1ba17f5a 100644 --- a/letsencrypt/client/tests/apache/util.py +++ b/letsencrypt/client/plugins/apache/tests/util.py @@ -1,4 +1,4 @@ -"""Common utilities for letsencrypt.client.apache.""" +"""Common utilities for letsencrypt.client.plugins.apache.""" import os import pkg_resources import shutil @@ -8,8 +8,8 @@ import unittest import mock from letsencrypt.client import constants -from letsencrypt.client.apache import configurator -from letsencrypt.client.apache import obj +from letsencrypt.client.plugins.apache import configurator +from letsencrypt.client.plugins.apache import obj class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods @@ -26,9 +26,9 @@ class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2") self.rsa256_file = pkg_resources.resource_filename( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") self.rsa256_pem = pkg_resources.resource_string( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): @@ -38,7 +38,7 @@ def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): work_dir = tempfile.mkdtemp("work") test_configs = pkg_resources.resource_filename( - "letsencrypt.client.tests", "testdata/%s" % test_dir) + "letsencrypt.client.plugins.apache.tests", "testdata/%s" % test_dir) shutil.copytree( test_configs, os.path.join(temp_dir, test_dir), symlinks=True) @@ -59,7 +59,7 @@ def get_apache_configurator( backups = os.path.join(work_dir, "backups") - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "subprocess.Popen") as mock_popen: # This just states that the ssl module is already loaded mock_popen().communicate.return_value = ("ssl_module", "") diff --git a/letsencrypt/client/plugins/standalone/__init__.py b/letsencrypt/client/plugins/standalone/__init__.py new file mode 100644 index 000000000..41de6eaf7 --- /dev/null +++ b/letsencrypt/client/plugins/standalone/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.standalone.""" diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/plugins/standalone/authenticator.py old mode 100755 new mode 100644 similarity index 99% rename from letsencrypt/client/standalone_authenticator.py rename to letsencrypt/client/plugins/standalone/authenticator.py index bf08a39ec..e0b06aa30 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/plugins/standalone/authenticator.py @@ -33,7 +33,7 @@ class StandaloneAuthenticator(object): description = "Standalone Authenticator" - def __init__(self): + def __init__(self, unused_config): self.child_pid = None self.parent_pid = os.getpid() self.subproc_state = None @@ -410,5 +410,5 @@ class StandaloneAuthenticator(object): "on port 443 and perform DVSNI challenges. Once a certificate" "is attained, it will be saved in the " "(TODO) current working directory.{0}{0}" - "Port 443 must be open in order to use the " + "TCP port 443 must be available in order to use the " "Standalone Authenticator.".format(os.linesep)) diff --git a/letsencrypt/client/plugins/standalone/tests/__init__.py b/letsencrypt/client/plugins/standalone/tests/__init__.py new file mode 100644 index 000000000..059cd2780 --- /dev/null +++ b/letsencrypt/client/plugins/standalone/tests/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Standalone Tests""" diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py similarity index 75% rename from letsencrypt/client/tests/standalone_authenticator_test.py rename to letsencrypt/client/plugins/standalone/tests/authenticator_test.py index 9adf6a167..577bc7e74 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.client.standalone_authenticator.""" +"""Tests for letsencrypt.client.plugins.standalone.authenticator.""" import os import pkg_resources import psutil @@ -49,9 +49,9 @@ class CallableExhausted(Exception): class ChallPrefTest(unittest.TestCase): """Tests for chall_pref() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_chall_pref(self): self.assertEqual(self.authenticator.get_chall_pref("example.com"), @@ -61,11 +61,11 @@ class ChallPrefTest(unittest.TestCase): class SNICallbackTest(unittest.TestCase): """Tests for sni_callback() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") + "letsencrypt.client.tests", "testdata/rsa256_key.pem") key = le_util.Key("foo", test_key) self.cert = achallenges.DVSNI( chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), @@ -104,9 +104,9 @@ class SNICallbackTest(unittest.TestCase): class ClientSignalHandlerTest(unittest.TestCase): """Tests for client_signal_handler() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 @@ -133,15 +133,15 @@ class ClientSignalHandlerTest(unittest.TestCase): class SubprocSignalHandlerTest(unittest.TestCase): """Tests for subproc_signal_handler() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 self.authenticator.parent_pid = 23456 - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.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() @@ -155,8 +155,8 @@ class SubprocSignalHandlerTest(unittest.TestCase): self.authenticator.parent_pid, signal.SIGUSR1) mock_exit.assert_called_once_with(0) - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.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. @@ -185,14 +185,15 @@ class SubprocSignalHandlerTest(unittest.TestCase): class AlreadyListeningTest(unittest.TestCase): """Tests for already_listening() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.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 @@ -200,14 +201,14 @@ class AlreadyListeningTest(unittest.TestCase): # 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)] + 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, @@ -216,42 +217,44 @@ class AlreadyListeningTest(unittest.TestCase): self.assertEqual(mock_get_utility.generic_notification.call_count, 0) mock_process.assert_called_once_with(4416) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.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)] + 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.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.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)] + 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) @@ -259,24 +262,25 @@ class AlreadyListeningTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) mock_process.assert_called_once_with(4416) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.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)] + 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) @@ -288,12 +292,12 @@ class AlreadyListeningTest(unittest.TestCase): class PerformTest(unittest.TestCase): """Tests for perform() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") + "letsencrypt.client.tests", "testdata/rsa256_key.pem") self.key = le_util.Key("something", test_key) self.achall1 = achallenges.DVSNI( @@ -365,13 +369,13 @@ class PerformTest(unittest.TestCase): class StartListenerTest(unittest.TestCase): """Tests for start_listener() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "Crypto.Random.atfork") - @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.fork") def test_start_listener_fork_parent(self, mock_fork, mock_atfork): self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_parent_process.return_value = True @@ -384,9 +388,9 @@ class StartListenerTest(unittest.TestCase): self.authenticator.do_parent_process.assert_called_once_with(1717) mock_atfork.assert_called_once_with() - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "Crypto.Random.atfork") - @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.fork") def test_start_listener_fork_child(self, mock_fork, mock_atfork): self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_child_process = mock.Mock() @@ -400,12 +404,13 @@ class StartListenerTest(unittest.TestCase): class DoParentProcessTest(unittest.TestCase): """Tests for do_parent_process() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_ok(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "ready" @@ -414,8 +419,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_inuse(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "inuse" @@ -424,8 +430,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_cantbind(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "cantbind" @@ -434,8 +441,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_timeout(self, mock_get_utility, mock_signal): # Normally times out in 5 seconds and returns False. We can @@ -450,11 +458,11 @@ class DoParentProcessTest(unittest.TestCase): class DoChildProcessTest(unittest.TestCase): """Tests for do_child_process() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") + "letsencrypt.client.tests", "testdata/rsa256_key.pem") key = le_util.Key("foo", test_key) self.key = key self.cert = achallenges.DVSNI( @@ -466,9 +474,10 @@ class DoChildProcessTest(unittest.TestCase): self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.parent_pid = 12345 - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.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") @@ -488,9 +497,10 @@ class DoChildProcessTest(unittest.TestCase): mock_exit.assert_called_once_with(1) mock_kill.assert_called_once_with(12345, signal.SIGUSR2) - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.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") @@ -504,7 +514,8 @@ class DoChildProcessTest(unittest.TestCase): mock_exit.assert_called_once_with(1) mock_kill.assert_called_once_with(12345, signal.SIGUSR1) - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.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 @@ -517,10 +528,11 @@ class DoChildProcessTest(unittest.TestCase): self.assertRaises( socket.error, self.authenticator.do_child_process, 1717, self.key) - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "OpenSSL.SSL.Connection") - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") def test_do_child_process_success( self, mock_kill, mock_socket, mock_connection): sample_socket = mock.MagicMock() @@ -543,17 +555,18 @@ class DoChildProcessTest(unittest.TestCase): class CleanupTest(unittest.TestCase): """Tests for cleanup() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.achall = achallenges.DVSNI( chall=challenges.DVSNI(r="whee", nonce="foononce"), domain="foo.example.com", key="key") self.authenticator.tasks = {self.achall.nonce_domain: "stuff"} self.authenticator.child_pid = 12345 - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.time.sleep") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "time.sleep") def test_cleanup(self, mock_sleep, mock_kill): mock_sleep.return_value = None mock_kill.return_value = None @@ -573,9 +586,9 @@ class CleanupTest(unittest.TestCase): class MoreInfoTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_more_info(self): """Make sure exceptions aren't raised.""" @@ -585,9 +598,9 @@ class MoreInfoTest(unittest.TestCase): class InitTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_prepare(self): """Make sure exceptions aren't raised. diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index 715b44f80..ebb85a954 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -83,7 +83,8 @@ class Reverter(object): def view_config_changes(self): """Displays all saved checkpoints. - All checkpoints are printed to the console. + All checkpoints are printed by + :meth:`letsencrypt.client.interfaces.IDisplay.notification`. .. todo:: Decide on a policy for error handling, OSError IOError... @@ -130,17 +131,17 @@ class Reverter(object): os.linesep.join(output), display_util.HEIGHT) def add_to_temp_checkpoint(self, save_files, save_notes): - """Add files to temporary checkpoint + """Add files to temporary checkpoint. - param set save_files: set of filepaths to save - param str save_notes: notes about changes during the save + :param set save_files: set of filepaths to save + :param str save_notes: notes about changes during the save """ self._add_to_checkpoint_dir( self.config.temp_checkpoint_dir, save_files, save_notes) def add_to_checkpoint(self, save_files, save_notes): - """Add files to a permanent checkpoint + """Add files to a permanent checkpoint. :param set save_files: set of filepaths to save :param str save_notes: notes about changes during the save @@ -324,15 +325,18 @@ class Reverter(object): new_fd.close() def recovery_routine(self): - """Revert all previously modified files. + """Revert configuration to most recent finalized checkpoint. - First, any changes found in IConfig.temp_checkpoint_dir are removed, - then IN_PROGRESS changes are removed The order is important. - IN_PROGRESS is unable to add files that are already added by a TEMP - change. Thus TEMP must be rolled back first because that will be the - 'latest' occurrence of the file. + Remove all changes (temporary and permanent) that have not been + finalized. This is useful to protect against crashes and other + execution interruptions. """ + # First, any changes found in IConfig.temp_checkpoint_dir are removed, + # then IN_PROGRESS changes are removed The order is important. + # IN_PROGRESS is unable to add files that are already added by a TEMP + # change. Thus TEMP must be rolled back first because that will be the + # 'latest' occurrence of the file. self.revert_temporary_config() if os.path.isdir(self.config.in_progress_dir): try: @@ -385,11 +389,10 @@ class Reverter(object): return True def finalize_checkpoint(self, title): - """Move IN_PROGRESS checkpoint to timestamped checkpoint. + """Finalize the checkpoint. - Adds title to self.config.in_progress_dir CHANGES_SINCE - Move self.config.in_progress_dir to Backups directory and - rename the directory as a timestamp + Timestamps and permanently saves all changes made through the use + of :func:`~add_to_checkpoint` and :func:`~register_file_creation` :param str title: Title describing checkpoint @@ -397,6 +400,9 @@ class Reverter(object): checkpoint is not able to be finalized. """ + # Adds title to self.config.in_progress_dir CHANGES_SINCE + # Move self.config.in_progress_dir to Backups directory and + # rename the directory as a timestamp # Check to make sure an "in progress" directory exists if not os.path.isdir(self.config.in_progress_dir): return diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index aba839f8c..5a2e2b16f 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -8,8 +8,10 @@ from letsencrypt.acme import challenges from letsencrypt.acme import jose -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - "letsencrypt.client.tests", os.path.join("testdata", "rsa256_key.pem"))) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + "letsencrypt.client.tests", + os.path.join("testdata", "rsa256_key.pem")))) # Challenges SIMPLE_HTTPS = challenges.SimpleHTTPS( @@ -27,40 +29,40 @@ POP = challenges.ProofOfPossession( alg="RS256", nonce="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ", hints=challenges.ProofOfPossession.Hints( jwk=jose.JWKRSA(key=KEY.publickey()), - cert_fingerprints=[ + cert_fingerprints=( "93416768eb85e33adc4277f4c9acd63e7418fcfe", "16d95b7b63f1972b980b14c20291f3c0d1855d95", "48b46570d9fc6358108af43ad1649484def0debf" - ], - certs=[], # TODO - subject_key_identifiers=["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"], - serial_numbers=[34234239832, 23993939911, 17], - issuers=[ + ), + certs=(), # TODO + subject_key_identifiers=("d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"), + serial_numbers=(34234239832, 23993939911, 17), + issuers=( "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA", "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure", - ], - authorized_for=["www.example.com", "example.net"], + ), + authorized_for=("www.example.com", "example.net"), ) ) CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] DV_CHALLENGES = [chall for chall in CHALLENGES if isinstance(chall, challenges.DVChallenge)] -CLIENT_CHALLENGES = [chall for chall in CHALLENGES - if isinstance(chall, challenges.ClientChallenge)] +CONT_CHALLENGES = [chall for chall in CHALLENGES + if isinstance(chall, challenges.ContinuityChallenge)] def gen_combos(challs): """Generate natural combinations for challs.""" dv_chall = [] - renewal_chall = [] + cont_chall = [] for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name if isinstance(chall, challenges.DVChallenge): dv_chall.append(i) else: - renewal_chall.append(i) + cont_chall.append(i) - # Gen combos for 1 of each type - return [[i, j] for i in xrange(len(dv_chall)) - for j in xrange(len(renewal_chall))] + # Gen combos for 1 of each type, lowest index first (makes testing easier) + return tuple((i, j) if i < j else (j, i) + for i in dv_chall for j in cont_chall) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index abf7032b9..b9508709d 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -30,17 +30,17 @@ class SatisfyChallengesTest(unittest.TestCase): from letsencrypt.client.auth_handler import AuthHandler self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator") - self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator") + self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator") self.mock_dv_auth.get_chall_pref.return_value = [challenges.DVSNI] - self.mock_client_auth.get_chall_pref.return_value = [ + self.mock_cont_auth.get_chall_pref.return_value = [ challenges.RecoveryToken] - self.mock_client_auth.perform.side_effect = gen_auth_resp + self.mock_cont_auth.perform.side_effect = gen_auth_resp self.mock_dv_auth.perform.side_effect = gen_auth_resp self.handler = AuthHandler( - self.mock_dv_auth, self.mock_client_auth, None) + self.mock_dv_auth, self.mock_cont_auth, None) logging.disable(logging.CRITICAL) @@ -61,9 +61,9 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual("DVSNI0", self.handler.responses[dom][0]) self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual(len(self.handler.cont_c[dom]), 0) def test_name1_rectok1(self): dom = "0" @@ -78,16 +78,16 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses[dom]), 1) # Test if statement for dv_auth perform - self.assertEqual(self.mock_client_auth.perform.call_count, 1) + self.assertEqual(self.mock_cont_auth.perform.call_count, 1) self.assertEqual(self.mock_dv_auth.perform.call_count, 0) self.assertEqual("RecoveryToken0", self.handler.responses[dom][0]) # Assert 1 domain self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) # Assert 1 auth challenge, 0 dv self.assertEqual(len(self.handler.dv_c[dom]), 0) - self.assertEqual(len(self.handler.client_c[dom]), 1) + self.assertEqual(len(self.handler.cont_c[dom]), 1) def test_name5_dvsni5(self): for i in xrange(5): @@ -102,11 +102,11 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses), 5) self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) + self.assertEqual(len(self.handler.cont_c), 5) # Each message contains 1 auth, 0 client # Test proper call count for methods - self.assertEqual(self.mock_client_auth.perform.call_count, 0) + self.assertEqual(self.mock_cont_auth.perform.call_count, 0) self.assertEqual(self.mock_dv_auth.perform.call_count, 1) for i in xrange(5): @@ -114,7 +114,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses[dom]), 1) self.assertEqual(self.handler.responses[dom][0], "DVSNI%d" % i) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual(len(self.handler.cont_c[dom]), 0) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.DVSNI)) @@ -138,10 +138,10 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses[dom]), len(acme_util.DV_CHALLENGES)) self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) - # Test if statement for client_auth perform - self.assertEqual(self.mock_client_auth.perform.call_count, 0) + # Test if statement for cont_auth perform + self.assertEqual(self.mock_cont_auth.perform.call_count, 0) self.assertEqual(self.mock_dv_auth.perform.call_count, 1) self.assertEqual( @@ -149,7 +149,7 @@ class SatisfyChallengesTest(unittest.TestCase): self._get_exp_response(dom, path, acme_util.DV_CHALLENGES)) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual(len(self.handler.cont_c[dom]), 0) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.SimpleHTTPS)) @@ -175,16 +175,16 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual( len(self.handler.responses[dom]), len(acme_util.CHALLENGES)) self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 1) + self.assertEqual(len(self.handler.cont_c[dom]), 1) self.assertEqual( self.handler.responses[dom], self._get_exp_response(dom, path, acme_util.CHALLENGES)) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.SimpleHTTPS)) - self.assertTrue(isinstance(self.handler.client_c[dom][0].achall, + self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall, achallenges.RecoveryToken)) @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") @@ -209,7 +209,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual( len(self.handler.responses[str(i)]), len(acme_util.CHALLENGES)) self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) + self.assertEqual(len(self.handler.cont_c), 5) for i in xrange(5): dom = str(i) @@ -217,11 +217,11 @@ class SatisfyChallengesTest(unittest.TestCase): self.handler.responses[dom], self._get_exp_response(dom, path, acme_util.CHALLENGES)) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 1) + self.assertEqual(len(self.handler.cont_c[dom]), 1) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.DVSNI)) - self.assertTrue(isinstance(self.handler.client_c[dom][0].achall, + self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall, achallenges.RecoveryContact)) @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") @@ -255,7 +255,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses), 5) self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) + self.assertEqual(len(self.handler.cont_c), 5) for i in xrange(5): dom = str(i) @@ -263,7 +263,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(self.handler.responses[dom], resp) self.assertEqual(len(self.handler.dv_c[dom]), 1) self.assertEqual( - len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) + len(self.handler.cont_c[dom]), len(chosen_chall[i]) - 1) self.assertTrue(isinstance( self.handler.dv_c["0"][0].achall, achallenges.DNS)) @@ -276,10 +276,10 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertTrue(isinstance( self.handler.dv_c["4"][0].achall, achallenges.DNS)) - self.assertTrue(isinstance(self.handler.client_c["2"][0].achall, + self.assertTrue(isinstance(self.handler.cont_c["2"][0].achall, achallenges.ProofOfPossession)) self.assertTrue(isinstance( - self.handler.client_c["4"][0].achall, achallenges.RecoveryToken)) + self.handler.cont_c["4"][0].achall, achallenges.RecoveryToken)) @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") def test_perform_exception_cleanup(self, mock_chall_path): @@ -309,11 +309,11 @@ class SatisfyChallengesTest(unittest.TestCase): # Verify cleanup is actually run correctly self.assertEqual(self.mock_dv_auth.cleanup.call_count, 2) - self.assertEqual(self.mock_client_auth.cleanup.call_count, 2) + self.assertEqual(self.mock_cont_auth.cleanup.call_count, 2) dv_cleanup_args = self.mock_dv_auth.cleanup.call_args_list - client_cleanup_args = self.mock_client_auth.cleanup.call_args_list + cont_cleanup_args = self.mock_cont_auth.cleanup.call_args_list # Check DV cleanup for i in xrange(2): @@ -325,10 +325,10 @@ class SatisfyChallengesTest(unittest.TestCase): # Check Auth cleanup for i in xrange(2): - client_chall_list = client_cleanup_args[i][0][0] - self.assertEqual(len(client_chall_list), 1) + cont_chall_list = cont_cleanup_args[i][0][0] + self.assertEqual(len(cont_chall_list), 1) self.assertTrue( - isinstance(client_chall_list[0], achallenges.ProofOfPossession)) + isinstance(cont_chall_list[0], achallenges.ProofOfPossession)) def _get_exp_response(self, domain, path, challs): @@ -346,7 +346,7 @@ class GetAuthorizationsTest(unittest.TestCase): from letsencrypt.client.auth_handler import AuthHandler self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator") - self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator") + self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator") self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges") self.mock_acme_auth = mock.MagicMock(name="acme_authorization") @@ -354,7 +354,7 @@ class GetAuthorizationsTest(unittest.TestCase): self.iteration = 0 self.handler = AuthHandler( - self.mock_dv_auth, self.mock_client_auth, None) + self.mock_dv_auth, self.mock_cont_auth, None) self.handler._satisfy_challenges = self.mock_sat_chall self.handler.acme_authorization = self.mock_acme_auth @@ -388,7 +388,7 @@ class GetAuthorizationsTest(unittest.TestCase): # Assignment was > 80 char... dv_c, c_c = self.handler._challenge_factory(dom, [0]) - self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c def test_progress_failure(self): self.handler.add_chall_msg( @@ -414,7 +414,7 @@ class GetAuthorizationsTest(unittest.TestCase): self.handler.msgs[dom].challenges) dv_c, c_c = self.handler._challenge_factory( dom, self.handler.paths[dom]) - self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c def test_incremental_progress(self): for dom, challs in [("0", acme_util.CHALLENGES), @@ -444,9 +444,9 @@ class GetAuthorizationsTest(unittest.TestCase): self.handler.paths["1"] = [2] # This is probably overkill... but set it anyway dv_c, c_c = self.handler._challenge_factory("0", [1, 3]) - self.handler.dv_c["0"], self.handler.client_c["0"] = dv_c, c_c + self.handler.dv_c["0"], self.handler.cont_c["0"] = dv_c, c_c dv_c, c_c = self.handler._challenge_factory("1", [2]) - self.handler.dv_c["1"], self.handler.client_c["1"] = dv_c, c_c + self.handler.dv_c["1"], self.handler.cont_c["1"] = dv_c, c_c self.iteration += 1 @@ -481,7 +481,7 @@ class PathSatisfiedTest(unittest.TestCase): self.handler.responses[dom[0]] = [None, "sat", "sat2", None] self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = ["sat", None, None, None] + self.handler.responses[dom[1]] = ["sat", None, None, False] self.handler.paths[dom[2]] = [0] self.handler.responses[dom[2]] = ["sat"] @@ -496,7 +496,7 @@ class PathSatisfiedTest(unittest.TestCase): self.assertTrue(self.handler._path_satisfied(dom[i])) def test_not_satisfied(self): - dom = ["0", "1", "2"] + dom = ["0", "1", "2", "3"] self.handler.paths[dom[0]] = [1, 2] self.handler.responses[dom[0]] = ["sat1", None, "sat2", None] @@ -506,10 +506,84 @@ class PathSatisfiedTest(unittest.TestCase): self.handler.paths[dom[2]] = [0] self.handler.responses[dom[2]] = [None] + self.handler.paths[dom[3]] = [0] + self.handler.responses[dom[3]] = [False] + for i in xrange(3): self.assertFalse(self.handler._path_satisfied(dom[i])) +class GenChallengePathTest(unittest.TestCase): + """Tests for letsencrypt.client.auth_handler.gen_challenge_path. + + .. todo:: Add more tests for dumb_path... depending on what we want to do. + + """ + def setUp(self): + logging.disable(logging.fatal) + + def tearDown(self): + logging.disable(logging.NOTSET) + + @classmethod + def _call(cls, challs, preferences, combinations): + from letsencrypt.client.auth_handler import gen_challenge_path + return gen_challenge_path(challs, preferences, combinations) + + def test_common_case(self): + """Given DVSNI and SimpleHTTPS with appropriate combos.""" + challs = (acme_util.DVSNI, acme_util.SIMPLE_HTTPS) + prefs = [challenges.DVSNI] + combos = ((0,), (1,)) + + # Smart then trivial dumb path test + self.assertEqual(self._call(challs, prefs, combos), (0,)) + self.assertTrue(self._call(challs, prefs, None)) + # Rearrange order... + self.assertEqual(self._call(challs[::-1], prefs, combos), (1,)) + self.assertTrue(self._call(challs[::-1], prefs, None)) + + def test_common_case_with_continuity(self): + challs = (acme_util.RECOVERY_TOKEN, + acme_util.RECOVERY_CONTACT, + acme_util.DVSNI, + acme_util.SIMPLE_HTTPS) + prefs = [challenges.RecoveryToken, challenges.DVSNI] + combos = acme_util.gen_combos(challs) + self.assertEqual(self._call(challs, prefs, combos), (0, 2)) + + # dumb_path() trivial test + self.assertTrue(self._call(challs, prefs, None)) + + def test_full_cont_server(self): + challs = (acme_util.RECOVERY_TOKEN, + acme_util.RECOVERY_CONTACT, + acme_util.POP, + acme_util.DVSNI, + acme_util.SIMPLE_HTTPS, + acme_util.DNS) + # Typical webserver client that can do everything except DNS + # Attempted to make the order realistic + prefs = [challenges.RecoveryToken, + challenges.ProofOfPossession, + challenges.SimpleHTTPS, + challenges.DVSNI, + challenges.RecoveryContact] + combos = acme_util.gen_combos(challs) + self.assertEqual(self._call(challs, prefs, combos), (0, 4)) + + # Dumb path trivial test + self.assertTrue(self._call(challs, prefs, None)) + + def test_not_supported(self): + challs = (acme_util.POP, acme_util.DVSNI) + prefs = [challenges.DVSNI] + combos = ((0, 1),) + + self.assertRaises(errors.LetsEncryptAuthHandlerError, + self._call, challs, prefs, combos) + + class MutuallyExclusiveTest(unittest.TestCase): """Tests for letsencrypt.client.auth_handler.mutually_exclusive.""" diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 5ae6d6107..1c1a0d68a 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -8,8 +8,9 @@ from letsencrypt.client import errors class DetermineAuthenticatorTest(unittest.TestCase): def setUp(self): - from letsencrypt.client.apache.configurator import ApacheConfigurator - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.apache.configurator import ( + ApacheConfigurator) + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) self.mock_stand = mock.MagicMock( @@ -65,7 +66,8 @@ class DetermineAuthenticatorTest(unittest.TestCase): class RollbackTest(unittest.TestCase): """Test the rollback function.""" def setUp(self): - from letsencrypt.client.apache.configurator import ApacheConfigurator + from letsencrypt.client.plugins.apache.configurator import ( + ApacheConfigurator) self.m_install = mock.MagicMock(spec=ApacheConfigurator) @classmethod diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/continuity_auth_test.py similarity index 84% rename from letsencrypt/client/tests/client_authenticator_test.py rename to letsencrypt/client/tests/continuity_auth_test.py index 7db1956d5..c1f4a229c 100644 --- a/letsencrypt/client/tests/client_authenticator_test.py +++ b/letsencrypt/client/tests/continuity_auth_test.py @@ -1,4 +1,4 @@ -"""Test the ClientAuthenticator dispatcher.""" +"""Test the ContinuityAuthenticator dispatcher.""" import unittest import mock @@ -13,9 +13,9 @@ class PerformTest(unittest.TestCase): """Test client perform function.""" def setUp(self): - from letsencrypt.client.client_authenticator import ClientAuthenticator + from letsencrypt.client.continuity_auth import ContinuityAuthenticator - self.auth = ClientAuthenticator( + self.auth = ContinuityAuthenticator( mock.MagicMock(server="demo_server.org")) self.auth.rec_token.perform = mock.MagicMock( name="rec_token_perform", side_effect=gen_client_resp) @@ -38,7 +38,7 @@ class PerformTest(unittest.TestCase): def test_unexpected(self): self.assertRaises( - errors.LetsEncryptClientAuthError, self.auth.perform, [ + errors.LetsEncryptContAuthError, self.auth.perform, [ achallenges.DVSNI(chall=None, domain="0", key="invalid_key")]) def test_chall_pref(self): @@ -50,9 +50,9 @@ class CleanupTest(unittest.TestCase): """Test the Authenticator cleanup function.""" def setUp(self): - from letsencrypt.client.client_authenticator import ClientAuthenticator + from letsencrypt.client.continuity_auth import ContinuityAuthenticator - self.auth = ClientAuthenticator( + self.auth = ContinuityAuthenticator( mock.MagicMock(server="demo_server.org")) self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup") self.auth.rec_token.cleanup = self.mock_cleanup @@ -70,7 +70,7 @@ class CleanupTest(unittest.TestCase): token = achallenges.RecoveryToken(chall=None, domain="0") unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key") - self.assertRaises(errors.LetsEncryptClientAuthError, + self.assertRaises(errors.LetsEncryptContAuthError, self.auth.cleanup, [token, unexpected]) diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py new file mode 100644 index 000000000..c2a7d877a --- /dev/null +++ b/letsencrypt/client/tests/network2_test.py @@ -0,0 +1,458 @@ +"""Tests for letsencrypt.client.network2.""" +import datetime +import httplib +import os +import pkg_resources +import unittest + +import M2Crypto +import mock +import requests + +from letsencrypt.client import errors + +from letsencrypt.acme import challenges +from letsencrypt.acme import jose +from letsencrypt.acme import messages2 + + +CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( + pkg_resources.resource_string( + __name__, os.path.join('testdata/cert.pem')))) +CERT2 = jose.ComparableX509(M2Crypto.X509.load_cert_string( + pkg_resources.resource_string( + __name__, os.path.join('testdata/cert-san.pem')))) +CSR = jose.ComparableX509(M2Crypto.X509.load_request_string( + pkg_resources.resource_string( + __name__, os.path.join('testdata/csr.pem')))) +KEY = jose.JWKRSA.load(pkg_resources.resource_string( + __name__, os.path.join('testdata/rsa512_key.pem'))) +KEY2 = jose.JWKRSA.load(pkg_resources.resource_string( + __name__, os.path.join('testdata/rsa256_key.pem'))) + + +class NetworkTest(unittest.TestCase): + """Tests for letsencrypt.client.network2.Network.""" + + # pylint: disable=too-many-instance-attributes,too-many-public-methods + + def setUp(self): + from letsencrypt.client.network2 import Network + self.net = Network( + new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', + key=KEY, alg=jose.RS256) + self.response = mock.MagicMock(ok=True, status_code=httplib.OK) + self.response.headers = {} + self.response.links = {} + + self.identifier = messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value='example.com') + + # Registration + self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') + reg = messages2.Registration( + contact=self.contact, key=KEY.public(), recovery_token='t') + self.regr = messages2.RegistrationResource( + body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1', + new_authzr_uri='https://www.letsencrypt-demo.org/acme/new-reg', + terms_of_service='https://www.letsencrypt-demo.org/tos') + + # Authorization + authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1' + challb = messages2.ChallengeBody( + uri=(authzr_uri + '/1'), status=messages2.STATUS_VALID, + chall=challenges.DNS(token='foo')) + self.challr = messages2.ChallengeResource( + body=challb, authzr_uri=authzr_uri) + self.authz = messages2.Authorization( + identifier=messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value='example.com'), + challenges=(challb,), combinations=None, key=KEY.public()) + self.authzr = messages2.AuthorizationResource( + body=self.authz, uri=authzr_uri, + new_cert_uri='https://www.letsencrypt-demo.org/acme/new-cert') + + # Request issuance + self.certr = messages2.CertificateResource( + body=CERT, authzrs=(self.authzr,), + uri='https://www.letsencrypt-demo.org/acme/cert/1', + cert_chain_uri='https://www.letsencrypt-demo.org/ca') + + def _mock_post_get(self): + # pylint: disable=protected-access + self.net._post = mock.MagicMock(return_value=self.response) + self.net._get = mock.MagicMock(return_value=self.response) + + def test_wrap_in_jws(self): + class MockJSONDeSerializable(jose.JSONDeSerializable): + # pylint: disable=missing-docstring + def __init__(self, value): + self.value = value + def to_json(self): + return self.value + @classmethod + def from_json(cls, value): + return cls(value) + # pylint: disable=protected-access + jws = self.net._wrap_in_jws(MockJSONDeSerializable('foo')) + self.assertEqual(jose.JWS.json_loads(jws).payload, '"foo"') + + def test_check_response_not_ok_jobj_no_error(self): + self.response.ok = False + self.response.json.return_value = {} + # pylint: disable=protected-access + self.assertRaises( + errors.NetworkError, self.net._check_response, self.response) + + def test_check_response_not_ok_jobj_error(self): + self.response.ok = False + self.response.json.return_value = messages2.Error(detail='foo') + # pylint: disable=protected-access + self.assertRaises( + messages2.Error, self.net._check_response, self.response) + + def test_check_response_not_ok_no_jobj(self): + self.response.ok = False + self.response.json.side_effect = ValueError + # pylint: disable=protected-access + self.assertRaises( + errors.NetworkError, self.net._check_response, self.response) + + def test_check_response_ok_no_jobj_ct_required(self): + self.response.json.side_effect = ValueError + for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: + self.response.headers['Content-Type'] = response_ct + # pylint: disable=protected-access + self.assertRaises( + errors.NetworkError, self.net._check_response, self.response, + content_type=self.net.JSON_CONTENT_TYPE) + + def test_check_response_ok_no_jobj_no_ct(self): + self.response.json.side_effect = ValueError + for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: + self.response.headers['Content-Type'] = response_ct + # pylint: disable=protected-access + self.net._check_response(self.response) + + def test_check_response_jobj(self): + self.response.json.return_value = {} + for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: + self.response.headers['Content-Type'] = response_ct + # pylint: disable=protected-access + self.net._check_response(self.response) + + @mock.patch('letsencrypt.client.network2.requests') + def test_get_requests_error_passthrough(self, requests_mock): + requests_mock.exceptions = requests.exceptions + requests_mock.get.side_effect = requests.exceptions.RequestException + # pylint: disable=protected-access + self.assertRaises(errors.NetworkError, self.net._get, 'uri') + + @mock.patch('letsencrypt.client.network2.requests') + def test_get(self, requests_mock): + # pylint: disable=protected-access + self.net._check_response = mock.MagicMock() + self.net._get('uri', content_type='ct') + self.net._check_response.assert_called_once_with( + requests_mock.get('uri'), content_type='ct') + + @mock.patch('letsencrypt.client.network2.requests') + def test_post_requests_error_passthrough(self, requests_mock): + requests_mock.exceptions = requests.exceptions + requests_mock.post.side_effect = requests.exceptions.RequestException + # pylint: disable=protected-access + self.assertRaises(errors.NetworkError, self.net._post, 'uri', 'data') + + @mock.patch('letsencrypt.client.network2.requests') + def test_post(self, requests_mock): + # pylint: disable=protected-access + self.net._check_response = mock.MagicMock() + self.net._post('uri', 'data', content_type='ct') + self.net._check_response.assert_called_once_with( + requests_mock.post('uri', 'data'), content_type='ct') + + def test_register(self): + self.response.status_code = httplib.CREATED + self.response.json.return_value = self.regr.body.fully_serialize() + self.response.headers['Location'] = self.regr.uri + self.response.links.update({ + 'next': {'url': self.regr.new_authzr_uri}, + 'terms-of-service': {'url': self.regr.terms_of_service}, + }) + + self._mock_post_get() + self.assertEqual(self.regr, self.net.register(self.contact)) + # TODO: test POST call arguments + + # TODO: split here and separate test + reg_wrong_key = self.regr.body.update(key=KEY2.public()) + self.response.json.return_value = reg_wrong_key.fully_serialize() + self.assertRaises( + errors.UnexpectedUpdate, self.net.register, self.contact) + + def test_register_missing_next(self): + self.response.status_code = httplib.CREATED + self._mock_post_get() + self.assertRaises( + errors.NetworkError, self.net.register, self.regr.body) + + def test_update_registration(self): + self.response.headers['Location'] = self.regr.uri + self.response.json.return_value = self.regr.body.fully_serialize() + self._mock_post_get() + self.assertEqual(self.regr, self.net.update_registration(self.regr)) + + # TODO: split here and separate test + self.response.json.return_value = self.regr.body.update( + contact=()).fully_serialize() + self.assertRaises( + errors.UnexpectedUpdate, self.net.update_registration, self.regr) + + def test_request_challenges(self): + self.response.status_code = httplib.CREATED + self.response.headers['Location'] = self.authzr.uri + self.response.json.return_value = self.authz.fully_serialize() + self.response.links = { + 'next': {'url': self.authzr.new_cert_uri}, + } + + self._mock_post_get() + self.net.request_challenges(self.identifier, self.regr) + # TODO: test POST call arguments + + # TODO: split here and separate test + authz_wrong_key = self.authz.update(key=KEY2.public()) + self.response.json.return_value = authz_wrong_key.fully_serialize() + self.assertRaises( + errors.UnexpectedUpdate, self.net.request_challenges, + self.identifier, self.regr) + + def test_request_challenges_missing_next(self): + self.response.status_code = httplib.CREATED + self._mock_post_get() + self.assertRaises( + errors.NetworkError, self.net.request_challenges, + self.identifier, self.regr) + + def test_request_domain_challenges(self): + self.net.request_challenges = mock.MagicMock() + self.assertEqual( + self.net.request_challenges(self.identifier), + self.net.request_domain_challenges('example.com', self.regr)) + + def test_answer_challenge(self): + self.response.links['up'] = {'url': self.challr.authzr_uri} + self.response.json.return_value = self.challr.body.fully_serialize() + + chall_response = challenges.DNSResponse() + + self._mock_post_get() + self.net.answer_challenge(self.challr.body, chall_response) + + # TODO: split here and separate test + self.assertRaises(errors.UnexpectedUpdate, self.net.answer_challenge, + self.challr.body.update(uri='foo'), chall_response) + + def test_answer_challenge_missing_next(self): + self._mock_post_get() + self.assertRaises(errors.NetworkError, self.net.answer_challenge, + self.challr.body, challenges.DNSResponse()) + + def test_answer_challenges(self): + self.net.answer_challenge = mock.MagicMock() + self.assertEqual( + [self.net.answer_challenge( + self.challr.body, challenges.DNSResponse())], + self.net.answer_challenges( + [self.challr.body], [challenges.DNSResponse()])) + + def test_retry_after_date(self): + self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT' + self.assertEqual( + datetime.datetime(1999, 12, 31, 23, 59, 59), + self.net.retry_after(response=self.response, default=10)) + + @mock.patch('letsencrypt.client.network2.datetime') + def test_retry_after_invalid(self, dt_mock): + dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) + dt_mock.timedelta = datetime.timedelta + + self.response.headers['Retry-After'] = 'foooo' + self.assertEqual( + datetime.datetime(2015, 3, 27, 0, 0, 10), + self.net.retry_after(response=self.response, default=10)) + + @mock.patch('letsencrypt.client.network2.datetime') + def test_retry_after_seconds(self, dt_mock): + dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) + dt_mock.timedelta = datetime.timedelta + + self.response.headers['Retry-After'] = '50' + self.assertEqual( + datetime.datetime(2015, 3, 27, 0, 0, 50), + self.net.retry_after(response=self.response, default=10)) + + @mock.patch('letsencrypt.client.network2.datetime') + def test_retry_after_missing(self, dt_mock): + dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) + dt_mock.timedelta = datetime.timedelta + + self.assertEqual( + datetime.datetime(2015, 3, 27, 0, 0, 10), + self.net.retry_after(response=self.response, default=10)) + + def test_poll(self): + self.response.json.return_value = self.authzr.body.fully_serialize() + self._mock_post_get() + self.assertEqual((self.authzr, self.response), + self.net.poll(self.authzr)) + + def test_request_issuance(self): + self.response.content = CERT.as_der() + self.response.headers['Location'] = self.certr.uri + self.response.links['up'] = {'url': self.certr.cert_chain_uri} + self._mock_post_get() + self.assertEqual( + self.certr, self.net.request_issuance(CSR, (self.authzr,))) + # TODO: check POST args + + def test_request_issuance_missing_up(self): + self.response.content = CERT.as_der() + self.response.headers['Location'] = self.certr.uri + self._mock_post_get() + self.assertEqual( + self.certr.update(cert_chain_uri=None), + self.net.request_issuance(CSR, (self.authzr,))) + + def test_request_issuance_missing_location(self): + self._mock_post_get() + self.assertRaises( + errors.NetworkError, self.net.request_issuance, + CSR, (self.authzr,)) + + @mock.patch('letsencrypt.client.network2.datetime') + @mock.patch('letsencrypt.client.network2.time') + def test_poll_and_request_issuance(self, time_mock, dt_mock): + # clock.dt | pylint: disable=no-member + clock = mock.MagicMock(dt=datetime.datetime(2015, 3, 27)) + + def sleep(seconds): + """increment clock""" + clock.dt += datetime.timedelta(seconds=seconds) + time_mock.sleep.side_effect = sleep + + def now(): + """return current clock value""" + return clock.dt + dt_mock.datetime.now.side_effect = now + dt_mock.timedelta = datetime.timedelta + + def poll(authzr): # pylint: disable=missing-docstring + # record poll start time based on the current clock value + authzr.times.append(clock.dt) + + # suppose it takes 2 seconds for server to produce the + # result, increment clock + clock.dt += datetime.timedelta(seconds=2) + + if not authzr.retries: # no more retries + done = mock.MagicMock(uri=authzr.uri, times=authzr.times) + done.body.status = messages2.STATUS_VALID + return done, [] + + # response (2nd result tuple element) is reduced to only + # Retry-After header contents represented as integer + # seconds; authzr.retries is a list of Retry-After + # headers, head(retries) is peeled of as a current + # Retry-After header, and tail(retries) is persisted for + # later poll() calls + return (mock.MagicMock(retries=authzr.retries[1:], + uri=authzr.uri + '.', times=authzr.times), + authzr.retries[0]) + self.net.poll = mock.MagicMock(side_effect=poll) + + mintime = 7 + + def retry_after(response, default): # pylint: disable=missing-docstring + # check that poll_and_request_issuance correctly passes mintime + self.assertEqual(default, mintime) + return clock.dt + datetime.timedelta(seconds=response) + self.net.retry_after = mock.MagicMock(side_effect=retry_after) + + def request_issuance(csr, authzrs): # pylint: disable=missing-docstring + return csr, authzrs + self.net.request_issuance = mock.MagicMock(side_effect=request_issuance) + + csr = mock.MagicMock() + authzrs = ( + mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)), + mock.MagicMock(uri='b', times=[], retries=(5,)), + ) + + cert, updated_authzrs = self.net.poll_and_request_issuance( + csr, authzrs, mintime=mintime) + self.assertTrue(cert[0] is csr) + self.assertTrue(cert[1] is updated_authzrs) + self.assertEqual(updated_authzrs[0].uri, 'a...') + self.assertEqual(updated_authzrs[1].uri, 'b.') + self.assertEqual(updated_authzrs[0].times, [ + datetime.datetime(2015, 3, 27), + # a is scheduled for 10, but b is polling [9..11), so it + # will be picked up as soon as b is finished, without + # additional sleeping + datetime.datetime(2015, 3, 27, 0, 0, 11), + datetime.datetime(2015, 3, 27, 0, 0, 33), + datetime.datetime(2015, 3, 27, 0, 1, 5), + ]) + self.assertEqual(updated_authzrs[1].times, [ + datetime.datetime(2015, 3, 27, 0, 0, 2), + datetime.datetime(2015, 3, 27, 0, 0, 9), + ]) + self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7)) + + def test_check_cert(self): + self.response.headers['Location'] = self.certr.uri + self.response.content = CERT2.as_der() + self._mock_post_get() + self.assertEqual( + self.certr.update(body=CERT2), self.net.check_cert(self.certr)) + + # TODO: split here and separate test + self.response.headers['Location'] = 'foo' + self.assertRaises( + errors.UnexpectedUpdate, self.net.check_cert, self.certr) + + def test_check_cert_missing_location(self): + self.response.content = CERT2.as_der() + self._mock_post_get() + self.assertRaises(errors.NetworkError, self.net.check_cert, self.certr) + + def test_refresh(self): + self.net.check_cert = mock.MagicMock() + self.assertEqual( + self.net.check_cert(self.certr), self.net.refresh(self.certr)) + + def test_fetch_chain(self): + # pylint: disable=protected-access + self.net._get_cert = mock.MagicMock() + self.assertEqual(self.net._get_cert(self.certr.cert_chain_uri), + self.net.fetch_chain(self.certr)) + + def test_fetch_chain_no_up_link(self): + self.assertTrue(self.net.fetch_chain(self.certr.update( + cert_chain_uri=None)) is None) + + def test_revoke(self): + self._mock_post_get() + self.net.revoke(self.certr, when=messages2.Revocation.NOW) + # pylint: disable=protected-access + self.net._post.assert_called_once_with(self.certr.uri, mock.ANY) + + def test_revoke_bad_status_raises_error(self): + self.response.status_code = httplib.METHOD_NOT_ALLOWED + self._mock_post_get() + self.assertRaises(errors.NetworkError, self.net.revoke, self.certr) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index f5a940df8..ff2ce6aca 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -10,7 +10,7 @@ import mock from letsencrypt.client import errors from letsencrypt.client import le_util -from letsencrypt.client.apache import configurator +from letsencrypt.client.plugins.apache import configurator from letsencrypt.client.display import util as display_util diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py old mode 100755 new mode 100644 index 0d76a1581..3b4b7c10d --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """Parse command line and call the appropriate functions. .. todo:: Sanity check all input. Be sure to avoid shell code etc... @@ -12,6 +11,8 @@ import sys import confargparse import zope.component +import zope.interface.exceptions +import zope.interface.verify import letsencrypt @@ -21,12 +22,32 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import log -from letsencrypt.client import standalone_authenticator as standalone -from letsencrypt.client.apache import configurator from letsencrypt.client.display import util as display_util from letsencrypt.client.display import ops as display_ops +SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = "letsencrypt.authenticators" +"""Setuptools entry point group name for Authenticator plugins.""" + + +def init_auths(config): + """Find (setuptools entry points) and initialize Authenticators.""" + auths = {} + for entrypoint in pkg_resources.iter_entry_points( + SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): + auth_cls = entrypoint.load() + auth = auth_cls(config) + try: + zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) + except zope.interface.exceptions.BrokenImplementation: + logging.debug( + "%r object does not provide IAuthenticator, skipping", + entrypoint.name) + else: + auths[auth] = entrypoint.name + return auths + + def create_parser(): """Create parser.""" parser = confargparse.ConfArgParser( @@ -137,12 +158,10 @@ def main(): # pylint: disable=too-many-branches, too-many-statements if not args.eula: display_eula() - all_auths = [ - configurator.ApacheConfigurator(config), - standalone.StandaloneAuthenticator(), - ] + all_auths = init_auths(config) + logging.debug('Initialized authenticators: %s', all_auths.values()) try: - auth = client.determine_authenticator(all_auths) + auth = client.determine_authenticator(all_auths.keys()) except errors.LetsEncryptClientError: logging.critical("No authentication mechanisms were found on your " "system.") diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 520147433..c399179e4 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python import codecs import os import re @@ -32,12 +31,17 @@ install_requires = [ 'ConfArgParse', 'jsonschema', 'mock', + 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'psutil>=2.1.0', # net_connections introduced in 2.1.0 + 'pyasn1', # urllib3 InsecurePlatformWarning (#304) 'pycrypto', 'PyOpenSSL', + 'pyrfc3339', 'python-augeas', 'python2-pythondialog', + 'pytz', 'requests', + 'werkzeug', 'zope.component', 'zope.interface', # order of items in install_requires DOES matter and M2Crypto has @@ -95,10 +99,13 @@ setup( 'letsencrypt.acme', 'letsencrypt.acme.jose', 'letsencrypt.client', - 'letsencrypt.client.apache', 'letsencrypt.client.display', + 'letsencrypt.client.plugins', + 'letsencrypt.client.plugins.apache', + 'letsencrypt.client.plugins.apache.tests', + 'letsencrypt.client.plugins.standalone', + 'letsencrypt.client.plugins.standalone.tests', 'letsencrypt.client.tests', - 'letsencrypt.client.tests.apache', 'letsencrypt.client.tests.display', 'letsencrypt.scripts', ], @@ -118,6 +125,12 @@ setup( 'letsencrypt = letsencrypt.scripts.main:main', 'jws = letsencrypt.acme.jose.jws:CLI.run', ], + 'letsencrypt.authenticators': [ + 'apache = letsencrypt.client.plugins.apache.configurator' + ':ApacheConfigurator', + 'standalone = letsencrypt.client.plugins.standalone.authenticator' + ':StandaloneAuthenticator', + ], }, zip_safe=False, diff --git a/tox.ini b/tox.ini index bb5ac1bb7..fe9da1865 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=86 + python setup.py nosetests --with-coverage --cover-min-percentage=87 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187)