diff --git a/.gitignore b/.gitignore index 8afb61ffc..ba843d9cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,9 @@ *.egg-info/ .eggs/ build/ -dist/ -/venv/ -/venv3/ +dist*/ +/venv*/ +/kgs/ /.tox/ letsencrypt.log diff --git a/.pylintrc b/.pylintrc index bf318704a..268d61ec6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -38,7 +38,7 @@ load-plugins=linter_plugin # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=fixme,locally-disabled,abstract-class-not-used,bad-continuation,too-few-public-methods,no-self-use +disable=fixme,locally-disabled,abstract-class-not-used,bad-continuation,too-few-public-methods,no-self-use,invalid-name # abstract-class-not-used cannot be disabled locally (at least in pylint 1.4.1) diff --git a/Dockerfile b/Dockerfile index 789e26af9..b9ea168de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,5 +62,5 @@ RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ # bash" and investigate, apply patches, etc. ENV PATH /opt/letsencrypt/venv/bin:$PATH -# TODO: is --text really necessary? -ENTRYPOINT [ "letsencrypt", "--text" ] + +ENTRYPOINT [ "letsencrypt" ] diff --git a/LICENSE.txt b/LICENSE.txt index 5a9f6fa55..2ed752521 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,5 +1,5 @@ -Let's Encrypt: -Copyright (c) Internet Security Research Group +Let's Encrypt Python Client +Copyright (c) Electronic Frontier Foundation and others Licensed Apache Version 2.0 Incorporating code from nginxparser diff --git a/MANIFEST.in b/MANIFEST.in index 530044212..e421e0cd7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,8 @@ include requirements.txt include README.rst include CHANGES.rst include CONTRIBUTING.md +include LICENSE.txt include linter_plugin.py include letsencrypt/EULA +recursive-include docs * recursive-include letsencrypt/tests/testdata * diff --git a/README.rst b/README.rst index 23e4dad29..43ecd413c 100644 --- a/README.rst +++ b/README.rst @@ -79,7 +79,7 @@ Current Features * web servers supported: - apache/2.x (tested and working on Ubuntu Linux) - - nginx/0.8.48+ (tested and mostly working on Ubuntu Linux) + - nginx/0.8.48+ (under development) - standalone (runs its own webserver to prove you control the domain) * the private key is generated locally on your system diff --git a/acme/LICENSE.txt b/acme/LICENSE.txt new file mode 100644 index 000000000..981c46c9f --- /dev/null +++ b/acme/LICENSE.txt @@ -0,0 +1,190 @@ + Copyright 2015 Electronic Frontier Foundation and others + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/acme/MANIFEST.in b/acme/MANIFEST.in index f3444f746..ec2b09e05 100644 --- a/acme/MANIFEST.in +++ b/acme/MANIFEST.in @@ -1 +1,3 @@ +include LICENSE.txt +include README.rst recursive-include acme/testdata * diff --git a/acme/README.rst b/acme/README.rst new file mode 100644 index 000000000..e3ca8b738 --- /dev/null +++ b/acme/README.rst @@ -0,0 +1 @@ +ACME protocol implementation for Python diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 13186cc4f..d81e77f83 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -25,6 +25,14 @@ class Challenge(jose.TypedJSONObjectWithFields): """ACME challenge.""" TYPES = {} + @classmethod + def from_json(cls, jobj): + try: + return super(Challenge, cls).from_json(jobj) + except jose.UnrecognizedTypeError as error: + logger.debug(error) + return UnrecognizedChallenge.from_json(jobj) + class ContinuityChallenge(Challenge): # pylint: disable=abstract-method """Client validation challenges.""" @@ -42,6 +50,32 @@ class ChallengeResponse(jose.TypedJSONObjectWithFields): resource = fields.Resource(resource_type) +class UnrecognizedChallenge(Challenge): + """Unrecognized challenge. + + ACME specification defines a generic framework for challenges and + defines some standard challenges that are implemented in this + module. However, other implementations (including peers) might + define additional challenge types, which should be ignored if + unrecognized. + + :ivar jobj: Original JSON decoded object. + + """ + + def __init__(self, jobj): + super(UnrecognizedChallenge, self).__init__() + object.__setattr__(self, "jobj", jobj) + + def to_partial_json(self): + # pylint: disable=no-member + return self.jobj + + @classmethod + def from_json(cls, jobj): + return cls(jobj) + + @Challenge.register class SimpleHTTP(DVChallenge): """ACME "simpleHttp" challenge. @@ -542,7 +576,7 @@ class DNS(DVChallenge): def check_validation(self, validation, account_public_key): """Check validation. - :param validation + :param JWS validation: :type account_public_key: `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` or diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index c82d95e19..ed44d4c45 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -17,6 +17,32 @@ CERT = test_util.load_cert('cert.pem') KEY = test_util.load_rsa_private_key('rsa512_key.pem') +class ChallengeTest(unittest.TestCase): + + def test_from_json_unrecognized(self): + from acme.challenges import Challenge + from acme.challenges import UnrecognizedChallenge + chall = UnrecognizedChallenge({"type": "foo"}) + # pylint: disable=no-member + self.assertEqual(chall, Challenge.from_json(chall.jobj)) + + +class UnrecognizedChallengeTest(unittest.TestCase): + + def setUp(self): + from acme.challenges import UnrecognizedChallenge + self.jobj = {"type": "foo"} + self.chall = UnrecognizedChallenge(self.jobj) + + def test_to_partial_json(self): + self.assertEqual(self.jobj, self.chall.to_partial_json()) + + def test_from_json(self): + from acme.challenges import UnrecognizedChallenge + self.assertEqual( + self.chall, UnrecognizedChallenge.from_json(self.jobj)) + + class SimpleHTTPTest(unittest.TestCase): def setUp(self): diff --git a/acme/acme/jose/interfaces.py b/acme/acme/jose/interfaces.py index f841848b3..f85777a30 100644 --- a/acme/acme/jose/interfaces.py +++ b/acme/acme/jose/interfaces.py @@ -194,7 +194,7 @@ class JSONDeSerializable(object): :rtype: str """ - return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': ')) + return self.json_dumps(sort_keys=True, indent=4) @classmethod def json_dump_default(cls, python_object): diff --git a/acme/acme/jose/interfaces_test.py b/acme/acme/jose/interfaces_test.py index 380c3a2a5..84dc2a1be 100644 --- a/acme/acme/jose/interfaces_test.py +++ b/acme/acme/jose/interfaces_test.py @@ -1,6 +1,8 @@ """Tests for acme.jose.interfaces.""" import unittest +import six + class JSONDeSerializableTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes @@ -90,8 +92,9 @@ class JSONDeSerializableTest(unittest.TestCase): self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps()) def test_json_dumps_pretty(self): - self.assertEqual( - self.seq.json_dumps_pretty(), '[\n "foo1",\n "foo2"\n]') + filler = ' ' if six.PY2 else '' + self.assertEqual(self.seq.json_dumps_pretty(), + '[\n "foo1",{0}\n "foo2"\n]'.format(filler)) def test_json_dump_default(self): from acme.jose.interfaces import JSONDeSerializable diff --git a/acme/acme/jose/jwk.py b/acme/acme/jose/jwk.py index 7a976f189..74fa72319 100644 --- a/acme/acme/jose/jwk.py +++ b/acme/acme/jose/jwk.py @@ -1,10 +1,12 @@ """JSON Web Key.""" import abc import binascii +import json import logging import cryptography.exceptions from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric import rsa @@ -27,6 +29,32 @@ class JWK(json_util.TypedJSONObjectWithFields): cryptography_key_types = () """Subclasses should override.""" + required = NotImplemented + """Required members of public key's representation as defined by JWK/JWA.""" + + _thumbprint_json_dumps_params = { + # "no whitespace or line breaks before or after any syntactic + # elements" + 'indent': 0, + 'separators': (',', ':'), + # "members ordered lexicographically by the Unicode [UNICODE] + # code points of the member names" + 'sort_keys': True, + } + + def thumbprint(self, hash_function=hashes.SHA256): + """Compute JWK Thumbprint. + + https://tools.ietf.org/html/rfc7638 + + """ + digest = hashes.Hash(hash_function(), backend=default_backend()) + digest.update(json.dumps( + dict((k, v) for k, v in six.iteritems(self.to_json()) + if k in self.required), + **self._thumbprint_json_dumps_params).encode()) + return digest.finalize() + @abc.abstractmethod def public_key(self): # pragma: no cover """Generate JWK with public key. @@ -60,7 +88,7 @@ class JWK(json_util.TypedJSONObjectWithFields): exceptions[loader] = error # no luck - raise errors.Error("Unable to deserialize key: {0}".format(exceptions)) + raise errors.Error('Unable to deserialize key: {0}'.format(exceptions)) @classmethod def load(cls, data, password=None, backend=None): @@ -81,17 +109,17 @@ class JWK(json_util.TypedJSONObjectWithFields): try: key = cls._load_cryptography_key(data, password, backend) except errors.Error as error: - logger.debug("Loading symmetric key, assymentric failed: %s", error) + logger.debug('Loading symmetric key, assymentric failed: %s', error) return JWKOct(key=data) if cls.typ is not NotImplemented and not isinstance( key, cls.cryptography_key_types): - raise errors.Error("Unable to deserialize {0} into {1}".format( + raise errors.Error('Unable to deserialize {0} into {1}'.format( key.__class__, cls.__class__)) for jwk_cls in six.itervalues(cls.TYPES): if isinstance(key, jwk_cls.cryptography_key_types): return jwk_cls(key=key) - raise errors.Error("Unsupported algorithm: {0}".format(key.__class__)) + raise errors.Error('Unsupported algorithm: {0}'.format(key.__class__)) @JWK.register @@ -105,6 +133,7 @@ class JWKES(JWK): # pragma: no cover typ = 'ES' cryptography_key_types = ( ec.EllipticCurvePublicKey, ec.EllipticCurvePrivateKey) + required = ('crv', JWK.type_field_name, 'x', 'y') def fields_to_partial_json(self): raise NotImplementedError() @@ -122,6 +151,7 @@ class JWKOct(JWK): """Symmetric JWK.""" typ = 'oct' __slots__ = ('key',) + required = ('k', JWK.type_field_name) def fields_to_partial_json(self): # TODO: An "alg" member SHOULD also be present to identify the @@ -150,6 +180,7 @@ class JWKRSA(JWK): typ = 'RSA' cryptography_key_types = (rsa.RSAPublicKey, rsa.RSAPrivateKey) __slots__ = ('key',) + required = ('e', JWK.type_field_name, 'n') def __init__(self, *args, **kwargs): if 'key' in kwargs and not isinstance( @@ -204,7 +235,7 @@ class JWKRSA(JWK): jobj.get(x) for x in ('p', 'q', 'dp', 'dq', 'qi')) if tuple(param for param in all_params if param is None): raise errors.Error( - "Some private parameters are missing: {0}".format( + 'Some private parameters are missing: {0}'.format( all_params)) p, q, dp, dq, qi = tuple( cls._decode_param(x) for x in all_params) diff --git a/acme/acme/jose/jwk_test.py b/acme/acme/jose/jwk_test.py index 5462af6b0..d8a7410e8 100644 --- a/acme/acme/jose/jwk_test.py +++ b/acme/acme/jose/jwk_test.py @@ -25,9 +25,24 @@ class JWKTest(unittest.TestCase): self.assertRaises(errors.Error, JWKRSA.load, DSA_PEM) -class JWKOctTest(unittest.TestCase): +class JWKTestBaseMixin(object): + """Mixin test for JWK subclass tests.""" + + thumbprint = NotImplemented + + def test_thumbprint_private(self): + self.assertEqual(self.thumbprint, self.jwk.thumbprint()) + + def test_thumbprint_public(self): + self.assertEqual(self.thumbprint, self.jwk.public_key().thumbprint()) + + +class JWKOctTest(unittest.TestCase, JWKTestBaseMixin): """Tests for acme.jose.jwk.JWKOct.""" + thumbprint = (b"=,\xdd;I\x1a+i\x02x\x8a\x12?06IM\xc2\x80" + b"\xe4\xc3\x1a\xfc\x89\xf3)'\xce\xccm\xfd5") + def setUp(self): from acme.jose.jwk import JWKOct self.jwk = JWKOct(key=b'foo') @@ -52,10 +67,13 @@ class JWKOctTest(unittest.TestCase): self.assertTrue(self.jwk.public_key() is self.jwk) -class JWKRSATest(unittest.TestCase): +class JWKRSATest(unittest.TestCase, JWKTestBaseMixin): """Tests for acme.jose.jwk.JWKRSA.""" # pylint: disable=too-many-instance-attributes + thumbprint = (b'\x08\xfa1\x87\x1d\x9b6H/*\x1eW\xc2\xe3\xf6P' + b'\xefs\x0cKB\x87\xcf\x85yO\x045\x0e\x91\x80\x0b') + def setUp(self): from acme.jose.jwk import JWKRSA self.jwk256 = JWKRSA(key=RSA256_KEY.public_key()) @@ -87,6 +105,7 @@ class JWKRSATest(unittest.TestCase): 'dq': 'bHh2u7etM8LKKCF2pY2UdQ', 'qi': 'oi45cEkbVoJjAbnQpFY87Q', }) + self.jwk = self.private def test_init_auto_comparable(self): self.assertTrue(isinstance( diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py index 25f07018c..d2d859bc5 100644 --- a/acme/acme/messages_test.py +++ b/acme/acme/messages_test.py @@ -274,6 +274,7 @@ class AuthorizationTest(unittest.TestCase): def setUp(self): from acme.messages import ChallengeBody from acme.messages import STATUS_VALID + self.challbs = ( ChallengeBody( uri='http://challb1', status=STATUS_VALID, diff --git a/acme/acme/test_util.py b/acme/acme/test_util.py index c9c076d27..2b4c6e00c 100644 --- a/acme/acme/test_util.py +++ b/acme/acme/test_util.py @@ -1,5 +1,3 @@ -# Symlinked in letsencrypt/tests/test_util.py, causes duplicate-code -# warning that cannot be disabled locally. """Test utilities. .. warning:: This module is not part of the public API. diff --git a/acme/setup.py b/acme/setup.py index 4cf215b40..36a724f97 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -4,11 +4,12 @@ from setuptools import setup from setuptools import find_packages +version = '0.0.0.dev20151008' + install_requires = [ # load_pem_private/public_key (>=0.6) # rsa_recover_prime_factors (>=0.8) 'cryptography>=0.8', - 'mock<1.1.0', # py26 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'pyasn1', # urllib3 InsecurePlatformWarning (#304) # Connection.set_tlsext_host_name (>=0.13), X509Req.get_extensions (>=0.15) @@ -16,14 +17,20 @@ install_requires = [ 'pyrfc3339', 'pytz', 'requests', + 'setuptools', # pkg_resources 'six', 'werkzeug', ] # env markers in extras_require cause problems with older pip: #517 if sys.version_info < (2, 7): - # only some distros recognize stdlib argparse as already satisfying - install_requires.append('argparse') + install_requires.extend([ + # only some distros recognize stdlib argparse as already satisfying + 'argparse', + 'mock<1.1.0', + ]) +else: + install_requires.append('mock') testing_extras = [ 'nose', @@ -33,7 +40,25 @@ testing_extras = [ setup( name='acme', + version=version, + description='ACME protocol implementation', + url='https://github.com/letsencrypt/letsencrypt', + author="Let's Encrypt Project", + author_email='client-dev@letsencrypt.org', + license='Apache License 2.0', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Security', + ], + packages=find_packages(), + include_package_data=True, install_requires=install_requires, extras_require={ 'testing': testing_extras, diff --git a/bootstrap/archlinux.sh b/bootstrap/archlinux.sh new file mode 100755 index 000000000..6de7c23d4 --- /dev/null +++ b/bootstrap/archlinux.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# "python-virtualenv" is Python3, but "python2-virtualenv" provides +# only "virtualenv2" binary, not "virtualenv" necessary in +# ./bootstrap/dev/_common_venv.sh +pacman -S \ + git \ + python2 \ + python-virtualenv \ + gcc \ + dialog \ + augeas \ + openssl \ + libffi \ + ca-certificates \ diff --git a/bootstrap/dev/README b/bootstrap/dev/README new file mode 100644 index 000000000..759496187 --- /dev/null +++ b/bootstrap/dev/README @@ -0,0 +1 @@ +This directory contains developer setup. diff --git a/bootstrap/dev/_venv_common.sh b/bootstrap/dev/_venv_common.sh new file mode 100755 index 000000000..2d84dc39b --- /dev/null +++ b/bootstrap/dev/_venv_common.sh @@ -0,0 +1,25 @@ +#!/bin/sh -xe + +VENV_NAME=${VENV_NAME:-venv} + +# .egg-info directories tend to cause bizzaire problems (e.g. `pip -e +# .` might unexpectedly install letshelp-letsencrypt only, in case +# `python letshelp-letsencrypt/setup.py build` has been called +# earlier) +rm -rf *.egg-info + +# virtualenv setup is NOT idempotent: shutil.Error: +# `/home/jakub/dev/letsencrypt/letsencrypt/venv/bin/python2` and +# `venv/bin/python2` are the same file +mv $VENV_NAME "$VENV_NAME.$(date +%s).bak" || true +virtualenv --no-site-packages $VENV_NAME $VENV_ARGS +. ./$VENV_NAME/bin/activate + +# Separately install setuptools and pip to make sure following +# invocations use latest +pip install -U setuptools +pip install -U pip +pip install "$@" + +echo "Please run the following command to activate developer environment:" +echo "source $VENV_NAME/bin/activate" diff --git a/bootstrap/dev/venv.sh b/bootstrap/dev/venv.sh new file mode 100755 index 000000000..d6cf95bb5 --- /dev/null +++ b/bootstrap/dev/venv.sh @@ -0,0 +1,13 @@ +#!/bin/sh -xe +# Developer virtualenv setup for Let's Encrypt client + +export VENV_ARGS="--python python2" + +./bootstrap/dev/_venv_common.sh \ + -r requirements.txt \ + -e acme[testing] \ + -e .[dev,docs,testing] \ + -e letsencrypt-apache \ + -e letsencrypt-nginx \ + -e letshelp-letsencrypt \ + -e letsencrypt-compatibility-test diff --git a/bootstrap/dev/venv3.sh b/bootstrap/dev/venv3.sh new file mode 100755 index 000000000..ccffffb83 --- /dev/null +++ b/bootstrap/dev/venv3.sh @@ -0,0 +1,8 @@ +#!/bin/sh -xe +# Developer Python3 virtualenv setup for Let's Encrypt + +export VENV_NAME="${VENV_NAME:-venv3}" +export VENV_ARGS="--python python3" + +./bootstrap/dev/_venv_common.sh \ + -e acme[testing] \ diff --git a/docs/api/display.rst b/docs/api/display.rst index b79ef25d7..117a91708 100644 --- a/docs/api/display.rst +++ b/docs/api/display.rst @@ -21,9 +21,3 @@ .. automodule:: letsencrypt.display.enhancements :members: - -:mod:`letsencrypt.display.revocation` -===================================== - -.. automodule:: letsencrypt.display.revocation - :members: diff --git a/docs/api/recovery_token.rst b/docs/api/recovery_token.rst deleted file mode 100644 index 774aa4b3c..000000000 --- a/docs/api/recovery_token.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.recovery_token` --------------------------------------------------- - -.. automodule:: letsencrypt.recovery_token - :members: diff --git a/docs/api/revoker.rst b/docs/api/revoker.rst deleted file mode 100644 index a482a138e..000000000 --- a/docs/api/revoker.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.revoker` --------------------------- - -.. automodule:: letsencrypt.revoker - :members: diff --git a/docs/conf.py b/docs/conf.py index 2b4b2cd43..e2b360a6e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ here = os.path.abspath(os.path.dirname(__file__)) # read version number (and other metadata) from package init init_fn = os.path.join(here, '..', 'letsencrypt', '__init__.py') with codecs.open(init_fn, encoding='utf8') as fd: - meta = dict(re.findall(r"""__([a-z]+)__ = "([^"]+)""", fd.read())) + meta = dict(re.findall(r"""__([a-z]+)__ = '([^']+)""", fd.read())) # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the diff --git a/docs/contributing.rst b/docs/contributing.rst index c6443e3b2..3277d321a 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -7,38 +7,37 @@ Contributing Hacking ======= -Start by :doc:`installing dependencies and setting up Let's Encrypt -`. +All changes in your pull request **must** have 100% unit test coverage, pass +our `integration`_ tests, **and** be compliant with the +:ref:`coding style `. -When you're done activate the virtualenv: + +Bootstrap +--------- + +Start by :ref:`installing Let's Encrypt prerequisites +`. Then run: .. code-block:: shell - source ./venv/bin/activate + ./bootstrap/dev/venv.sh -This step should prepend you prompt with ``(venv)`` and save you from -typing ``./venv/bin/...``. It is also required to run some of the -`testing`_ tools. Virtualenv can be disabled at any time by typing -``deactivate``. More information can be found in `virtualenv +Activate the virtualenv: + +.. code-block:: shell + + source ./$VENV_NAME/bin/activate + +This step should prepend you prompt with ``($VENV_NAME)`` and save you +from typing ``./$VENV_NAME/bin/...``. It is also required to run some +of the `testing`_ tools. Virtualenv can be disabled at any time by +typing ``deactivate``. More information can be found in `virtualenv documentation`_. -Install the development packages: - -.. code-block:: shell - - pip install -r requirements.txt -e acme -e .[dev,docs,testing] -e letsencrypt-apache -e letsencrypt-nginx -e letshelp-letsencrypt - -.. note:: `-e` (short for `--editable`) turns on *editable mode* in - which any source code changes in the current working - directory are "live" and no further `pip install ...` - invocations are necessary while developing. - - This is roughly equivalent to `python setup.py develop`. For - more info see `man pip`. - -The code base, including your pull requests, **must** have 100% unit -test coverage, pass our `integration`_ tests **and** be compliant with -the :ref:`coding style `. +Note that packages are installed in so called *editable mode*, in +which any source code changes in the current working directory are +"live" and no further ``./bootstrap/dev/venv.sh`` or ``pip install +...`` invocations are necessary while developing. .. _`virtualenv documentation`: https://virtualenv.pypa.io @@ -67,8 +66,10 @@ The following tools are there to help you: Integration ~~~~~~~~~~~ +Mac OS X users: Run `./tests/mac-bootstrap.sh` instead of `boulder-start.sh` to +install dependencies, configure the environment, and start boulder. -First, install `Go`_ 1.5, libtool-ltdl, mariadb-server and +Otherwise, install `Go`_ 1.5, libtool-ltdl, mariadb-server and rabbitmq-server and then start Boulder_, an ACME CA server:: ./tests/boulder-start.sh diff --git a/docs/pkgs/letsencrypt_compatibility_test.rst b/docs/pkgs/letsencrypt_compatibility_test.rst new file mode 100644 index 000000000..f792a2cc3 --- /dev/null +++ b/docs/pkgs/letsencrypt_compatibility_test.rst @@ -0,0 +1,53 @@ +:mod:`letsencrypt_compatibility_test` +------------------------------------- + +.. automodule:: letsencrypt_compatibility_test + :members: + +:mod:`letsencrypt_compatibility_test.errors` +============================================ + +.. automodule:: letsencrypt_compatibility_test.errors + :members: + +:mod:`letsencrypt_compatibility_test.interfaces` +================================================ + +.. automodule:: letsencrypt_compatibility_test.interfaces + :members: + +:mod:`letsencrypt_compatibility_test.test_driver` +================================================= + +.. automodule:: letsencrypt_compatibility_test.test_driver + :members: + +:mod:`letsencrypt_compatibility_test.util` +========================================== + +.. automodule:: letsencrypt_compatibility_test.util + :members: + +:mod:`letsencrypt_compatibility_test.configurators` +=================================================== + +.. automodule:: letsencrypt_compatibility_test.configurators + :members: + +:mod:`letsencrypt_compatibility_test.configurators.apache` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: letsencrypt_compatibility_test.configurators.apache + :members: + +:mod:`letsencrypt_compatibility_test.configurators.apache.apache24` +------------------------------------------------------------------- + +.. automodule:: letsencrypt_compatibility_test.configurators.apache.apache24 + :members: + +:mod:`letsencrypt_compatibility_test.configurators.apache.common` +------------------------------------------------------------------- + +.. automodule:: letsencrypt_compatibility_test.configurators.apache.common + :members: diff --git a/docs/pkgs/letshelp_letsencrypt.rst b/docs/pkgs/letshelp_letsencrypt.rst new file mode 100644 index 000000000..8f6872eac --- /dev/null +++ b/docs/pkgs/letshelp_letsencrypt.rst @@ -0,0 +1,11 @@ +:mod:`letshelp_letsencrypt` +--------------------------- + +.. automodule:: letshelp_letsencrypt + :members: + +:mod:`letshelp_letsencrypt.apache` +================================== + +.. automodule:: letshelp_letsencrypt.apache + :members: diff --git a/docs/using.rst b/docs/using.rst index cfce29bae..9611f37c0 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -42,6 +42,8 @@ above method instead. https://github.com/letsencrypt/letsencrypt/archive/master.zip +.. _prerequisites: + Prerequisites ============= @@ -121,11 +123,13 @@ Installation ============ .. "pip install acme" doesn't search for "acme" in cwd, just like "pip - install -e acme" does + install -e acme" does; `-U setuptools pip` necessary for #722 .. code-block:: shell virtualenv --no-site-packages -p python2 venv + ./venv/bin/pip install -U setuptools + ./venv/bin/pip install -U pip ./venv/bin/pip install -r requirements.txt acme/ . letsencrypt-apache/ letsencrypt-nginx/ .. warning:: Please do **not** use ``python setup.py install``. Please diff --git a/examples/dev-cli.ini b/examples/dev-cli.ini index 761bc58c9..085d4bfcc 100644 --- a/examples/dev-cli.ini +++ b/examples/dev-cli.ini @@ -9,6 +9,7 @@ domains = example.com text = True agree-eula = True +agree-tos = True debug = True # Unfortunately, it's not possible to specify "verbose" multiple times # (correspondingly to -vvvvvv) diff --git a/letsencrypt-apache/LICENSE.txt b/letsencrypt-apache/LICENSE.txt new file mode 100644 index 000000000..981c46c9f --- /dev/null +++ b/letsencrypt-apache/LICENSE.txt @@ -0,0 +1,190 @@ + Copyright 2015 Electronic Frontier Foundation and others + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/letsencrypt-apache/MANIFEST.in b/letsencrypt-apache/MANIFEST.in index aac2bfb36..ff99bf0d8 100644 --- a/letsencrypt-apache/MANIFEST.in +++ b/letsencrypt-apache/MANIFEST.in @@ -1,2 +1,4 @@ +include LICENSE.txt +include README.rst recursive-include letsencrypt_apache/tests/testdata * include letsencrypt_apache/options-ssl-apache.conf diff --git a/letsencrypt-apache/README.rst b/letsencrypt-apache/README.rst new file mode 100644 index 000000000..3505fd594 --- /dev/null +++ b/letsencrypt-apache/README.rst @@ -0,0 +1 @@ +Apache plugin for Let's Encrypt client diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index f301de8b9..f3d2b5f9a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -137,6 +137,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :raises .errors.PluginError: If there is any other error """ + # Verify Apache is installed + for exe in (self.conf("ctl"), self.conf("enmod"), + self.conf("dismod"), self.conf("init-script")): + if not le_util.exe_exists(exe): + raise errors.NoInstallationError + # Make sure configuration is valid self.config_test() @@ -1162,7 +1168,7 @@ def _get_mod_deps(mod_name): changes. .. warning:: If all deps are not included, it may cause incorrect parsing behavior, due to enable_mod's shortcut for updating the parser's - currently defined modules (:method:`.ApacheConfigurator._add_parser_mod`) + currently defined modules (`.ApacheConfigurator._add_parser_mod`) This would only present a major problem in extremely atypical configs that use ifmod for the missing deps. diff --git a/letsencrypt-apache/letsencrypt_apache/parser.py b/letsencrypt-apache/letsencrypt_apache/parser.py index 823d9794b..0a3643064 100644 --- a/letsencrypt-apache/letsencrypt_apache/parser.py +++ b/letsencrypt-apache/letsencrypt_apache/parser.py @@ -241,6 +241,10 @@ class ApacheParser(object): Directives should be in the form of a case insensitive regex currently .. todo:: arg should probably be a list + .. todo:: arg search currently only supports direct matching. It does + not handle the case of variables or quoted arguments. This should + be adapted to use a generic search for the directive and then do a + case-insensitive self.get_arg filter Note: Augeas is inherently case sensitive while Apache is case insensitive. Augeas 1.0 allows case insensitive regexes like @@ -315,6 +319,14 @@ class ApacheParser(object): """ value = self.aug.get(match) + + # No need to strip quotes for variables, as apache2ctl already does this + # but we do need to strip quotes for all normal arguments. + + # Note: normal argument may be a quoted variable + # e.g. strip now, not later + value = value.strip("'\"") + variables = ApacheParser.arg_var_interpreter.findall(value) for var in variables: @@ -390,6 +402,9 @@ class ApacheParser(object): # logger.error("Error: Invalid regexp characters in %s", arg) # return [] + # Remove beginning and ending quotes + arg = arg.strip("'\"") + # Standardize the include argument based on server root if not arg.startswith("/"): # Normpath will condense ../ diff --git a/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py b/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py index 64ecaa321..7099c388f 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py @@ -32,6 +32,7 @@ class ComplexParserTest(util.ParserTest): "COMPLEX": "", "tls_port": "1234", "fnmatch_filename": "test_fnmatch.conf", + "tls_port_str": "1234" } ) @@ -49,6 +50,12 @@ class ComplexParserTest(util.ParserTest): self.assertEqual(len(matches), 1) self.assertEqual(self.parser.get_arg(matches[0]), "1234") + def test_basic_variable_parsing_quotes(self): + matches = self.parser.find_dir("TestVariablePortStr") + + self.assertEqual(len(matches), 1) + self.assertEqual(self.parser.get_arg(matches[0]), "1234") + def test_invalid_variable_parsing(self): del self.parser.variables["tls_port"] @@ -89,6 +96,7 @@ class ComplexParserTest(util.ParserTest): else: self.assertFalse(self.parser.find_dir("FNMATCH_DIRECTIVE")) + # NOTE: Only run one test per function otherwise you will have inf recursion def test_include(self): self.verify_fnmatch("test_fnmatch.?onf") @@ -101,6 +109,12 @@ class ComplexParserTest(util.ParserTest): def test_include_fullpath_trailing_slash(self): self.verify_fnmatch(self.config_path + "//") + def test_include_single_quotes(self): + self.verify_fnmatch("'" + self.config_path + "'") + + def test_include_double_quotes(self): + self.verify_fnmatch('"' + self.config_path + '"') + def test_include_variable(self): self.verify_fnmatch("../complex_parsing/${fnmatch_filename}") diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 026594a8f..7c2137c45 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -37,8 +37,16 @@ class TwoVhost80Test(util.ApacheTest): shutil.rmtree(self.config_dir) shutil.rmtree(self.work_dir) + @mock.patch("letsencrypt_apache.configurator.le_util.exe_exists") + def test_prepare_no_install(self, mock_exe_exists): + mock_exe_exists.return_value = False + self.assertRaises( + errors.NoInstallationError, self.config.prepare) + @mock.patch("letsencrypt_apache.parser.ApacheParser") - def test_prepare_version(self, _): + @mock.patch("letsencrypt_apache.configurator.le_util.exe_exists") + def test_prepare_version(self, mock_exe_exists, _): + mock_exe_exists.return_value = True self.config.version = None self.config.config_test = mock.Mock() self.config.get_version = mock.Mock(return_value=(1, 1)) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/apache2.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/apache2.conf index 26bf47263..14cf95f9e 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/apache2.conf +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/apache2.conf @@ -46,6 +46,8 @@ IncludeOptional sites-enabled/*.conf Define COMPLEX Define tls_port 1234 +Define tls_port_str "1234" + Define fnmatch_filename test_fnmatch.conf diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/test_variables.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/test_variables.conf index a38191837..1a9edff74 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/test_variables.conf +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/complex_parsing/test_variables.conf @@ -1,4 +1,5 @@ TestVariablePort ${tls_port} +TestVariablePortStr "${tls_port_str}" LoadModule status_module modules/mod_status.so diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index b544e06ee..2594ba773 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -66,31 +66,34 @@ def get_apache_configurator( """ backups = os.path.join(work_dir, "backups") + mock_le_config = mock.MagicMock( + apache_server_root=config_path, + apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"], + backup_dir=backups, + config_dir=config_dir, + temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), + in_progress_dir=os.path.join(backups, "IN_PROGRESS"), + work_dir=work_dir) with mock.patch("letsencrypt_apache.configurator." "subprocess.Popen") as mock_popen: - with mock.patch("letsencrypt_apache.parser.ApacheParser." - "update_runtime_variables"): - # This indicates config_test passes - mock_popen().communicate.return_value = ("Fine output", "No problems") - mock_popen().returncode = 0 + # This indicates config_test passes + mock_popen().communicate.return_value = ("Fine output", "No problems") + mock_popen().returncode = 0 + with mock.patch("letsencrypt_apache.configurator.le_util." + "exe_exists") as mock_exe_exists: + mock_exe_exists.return_value = True + with mock.patch("letsencrypt_apache.parser.ApacheParser." + "update_runtime_variables"): + config = configurator.ApacheConfigurator( + config=mock_le_config, + name="apache", + version=version) + # This allows testing scripts to set it a bit more quickly + if conf is not None: + config.conf = conf # pragma: no cover - config = configurator.ApacheConfigurator( - config=mock.MagicMock( - apache_server_root=config_path, - apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"], - backup_dir=backups, - config_dir=config_dir, - temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), - in_progress_dir=os.path.join(backups, "IN_PROGRESS"), - work_dir=work_dir), - name="apache", - version=version) - # This allows testing scripts to set it a bit more quickly - if conf is not None: - config.conf = conf # pragma: no cover - - config.prepare() + config.prepare() return config diff --git a/letsencrypt-apache/setup.py b/letsencrypt-apache/setup.py index 5ecb071c7..ef9299b2c 100644 --- a/letsencrypt-apache/setup.py +++ b/letsencrypt-apache/setup.py @@ -1,24 +1,56 @@ +import sys + from setuptools import setup from setuptools import find_packages +version = '0.0.0.dev20151008' + install_requires = [ - 'acme', - 'letsencrypt', - 'mock<1.1.0', # py26 + 'acme=={0}'.format(version), + 'letsencrypt=={0}'.format(version), 'python-augeas', + 'setuptools', # pkg_resources 'zope.component', 'zope.interface', ] +if sys.version_info < (2, 7): + install_requires.append('mock<1.1.0') +else: + install_requires.append('mock') + setup( name='letsencrypt-apache', + version=version, + description="Apache plugin for Let's Encrypt client", + url='https://github.com/letsencrypt/letsencrypt', + author="Let's Encrypt Project", + author_email='client-dev@letsencrypt.org', + license='Apache License 2.0', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Plugins', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Security', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Networking', + 'Topic :: System :: Systems Administration', + 'Topic :: Utilities', + ], + packages=find_packages(), + include_package_data=True, install_requires=install_requires, entry_points={ 'letsencrypt.plugins': [ 'apache = letsencrypt_apache.configurator:ApacheConfigurator', ], }, - include_package_data=True, ) diff --git a/letsencrypt-compatibility-test/LICENSE.txt b/letsencrypt-compatibility-test/LICENSE.txt new file mode 100644 index 000000000..981c46c9f --- /dev/null +++ b/letsencrypt-compatibility-test/LICENSE.txt @@ -0,0 +1,190 @@ + Copyright 2015 Electronic Frontier Foundation and others + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/letsencrypt-compatibility-test/MANIFEST.in b/letsencrypt-compatibility-test/MANIFEST.in index a6aa14443..4d346a5d0 100644 --- a/letsencrypt-compatibility-test/MANIFEST.in +++ b/letsencrypt-compatibility-test/MANIFEST.in @@ -1 +1,6 @@ +include LICENSE.txt +include README.rst +include letsencrypt_compatibility_test/configurators/apache/a2enmod.sh +include letsencrypt_compatibility_test/configurators/apache/a2dismod.sh +include letsencrypt_compatibility_test/configurators/apache/Dockerfile recursive-include letsencrypt_compatibility_test/testdata * diff --git a/letsencrypt-compatibility-test/README.rst b/letsencrypt-compatibility-test/README.rst new file mode 100644 index 000000000..4afd999a8 --- /dev/null +++ b/letsencrypt-compatibility-test/README.rst @@ -0,0 +1 @@ +Compatibility tests for Let's Encrypt client diff --git a/letsencrypt-compatibility-test/setup.py b/letsencrypt-compatibility-test/setup.py index f02041e55..2e70fd1d7 100644 --- a/letsencrypt-compatibility-test/setup.py +++ b/letsencrypt-compatibility-test/setup.py @@ -1,19 +1,45 @@ +import sys + from setuptools import setup from setuptools import find_packages +version = '0.1.0.dev0' + install_requires = [ - 'letsencrypt', - 'letsencrypt-apache', - 'letsencrypt-nginx', + 'letsencrypt=={0}'.format(version), + 'letsencrypt-apache=={0}'.format(version), + 'letsencrypt-nginx=={0}'.format(version), 'docker-py', - 'mock<1.1.0', # py26 'zope.interface', ] +if sys.version_info < (2, 7): + install_requires.append('mock<1.1.0') +else: + install_requires.append('mock') + setup( name='letsencrypt-compatibility-test', + version=version, + description="Compatibility tests for Let's Encrypt client", + url='https://github.com/letsencrypt/letsencrypt', + author="Let's Encrypt Project", + author_email='client-dev@letsencrypt.org', + license='Apache License 2.0', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Security', + ], + packages=find_packages(), + include_package_data=True, install_requires=install_requires, entry_points={ 'console_scripts': [ diff --git a/letsencrypt-nginx/LICENSE.txt b/letsencrypt-nginx/LICENSE.txt new file mode 100644 index 000000000..981c46c9f --- /dev/null +++ b/letsencrypt-nginx/LICENSE.txt @@ -0,0 +1,190 @@ + Copyright 2015 Electronic Frontier Foundation and others + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/letsencrypt-nginx/MANIFEST.in b/letsencrypt-nginx/MANIFEST.in index 94f85e40f..c4bd67735 100644 --- a/letsencrypt-nginx/MANIFEST.in +++ b/letsencrypt-nginx/MANIFEST.in @@ -1,2 +1,4 @@ +include LICENSE.txt +include README.rst recursive-include letsencrypt_nginx/tests/testdata * include letsencrypt_nginx/options-ssl-nginx.conf diff --git a/letsencrypt-nginx/README.rst b/letsencrypt-nginx/README.rst new file mode 100644 index 000000000..ff6d50ce4 --- /dev/null +++ b/letsencrypt-nginx/README.rst @@ -0,0 +1 @@ +Nginx plugin for Let's Encrypt client diff --git a/letsencrypt-nginx/letsencrypt_nginx/configurator.py b/letsencrypt-nginx/letsencrypt_nginx/configurator.py index 2899e1f76..a88607e58 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/configurator.py +++ b/letsencrypt-nginx/letsencrypt_nginx/configurator.py @@ -56,7 +56,7 @@ class NginxConfigurator(common.Plugin): zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) zope.interface.classProvides(interfaces.IPluginFactory) - description = "Nginx Web Server" + description = "Nginx Web Server - currently doesn't work" @classmethod def add_parser_arguments(cls, add): diff --git a/letsencrypt-nginx/setup.py b/letsencrypt-nginx/setup.py index 30dfa584f..dde7243a9 100644 --- a/letsencrypt-nginx/setup.py +++ b/letsencrypt-nginx/setup.py @@ -1,24 +1,56 @@ +import sys + from setuptools import setup from setuptools import find_packages +version = '0.0.0.dev20151008' + install_requires = [ - 'acme', - 'letsencrypt', - 'mock<1.1.0', # py26 + 'acme=={0}'.format(version), + 'letsencrypt=={0}'.format(version), 'PyOpenSSL', 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? + 'setuptools', # pkg_resources 'zope.interface', ] +if sys.version_info < (2, 7): + install_requires.append('mock<1.1.0') +else: + install_requires.append('mock') + setup( name='letsencrypt-nginx', + version=version, + description="Nginx plugin for Let's Encrypt client", + url='https://github.com/letsencrypt/letsencrypt', + author="Let's Encrypt Project", + author_email='client-dev@letsencrypt.org', + license='Apache License 2.0', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Plugins', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Security', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Networking', + 'Topic :: System :: Systems Administration', + 'Topic :: Utilities', + ], + packages=find_packages(), + include_package_data=True, install_requires=install_requires, entry_points={ 'letsencrypt.plugins': [ 'nginx = letsencrypt_nginx.configurator:NginxConfigurator', ], }, - include_package_data=True, ) diff --git a/letsencrypt/__init__.py b/letsencrypt/__init__.py index 560191bf1..d7ec9c5e3 100644 --- a/letsencrypt/__init__.py +++ b/letsencrypt/__init__.py @@ -1,4 +1,4 @@ """Let's Encrypt client.""" # version number like 1.2.3a0, must have at least 2 parts, like 1.2 -__version__ = "0.1" +__version__ = '0.0.0.dev20151008' diff --git a/letsencrypt/account.py b/letsencrypt/account.py index 8bee22102..81d31b831 100644 --- a/letsencrypt/account.py +++ b/letsencrypt/account.py @@ -54,7 +54,7 @@ class Account(object): # pylint: disable=too-few-public-methods tz=pytz.UTC).replace(microsecond=0), creation_host=socket.getfqdn()) if meta is None else meta - self.id = hashlib.md5( # pylint: disable=invalid-name + self.id = hashlib.md5( self.key.key.public_key().public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo) @@ -92,13 +92,13 @@ def report_new_account(acc, config): "contain certificates and private keys obtained by Let's Encrypt " "so making regular backups of this folder is ideal.".format( config.config_dir), - reporter.MEDIUM_PRIORITY, True) + reporter.MEDIUM_PRIORITY) if acc.regr.body.emails: recovery_msg = ("If you lose your account credentials, you can " "recover through e-mails sent to {0}.".format( ", ".join(acc.regr.body.emails))) - reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True) + reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY) class AccountMemoryStorage(interfaces.AccountStorage): diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 6498a5c19..b27a569f6 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -11,6 +11,7 @@ from acme import messages from letsencrypt import achallenges from letsencrypt import constants from letsencrypt import errors +from letsencrypt import error_handler from letsencrypt import interfaces @@ -106,17 +107,16 @@ class AuthHandler(object): """Get Responses for challenges from authenticators.""" cont_resp = [] dv_resp = [] - try: - if self.cont_c: - cont_resp = self.cont_auth.perform(self.cont_c) - if self.dv_c: - dv_resp = self.dv_auth.perform(self.dv_c) - # This will catch both specific types of errors. - except errors.AuthorizationError: - logger.critical("Failure in setting up challenges.") - logger.info("Attempting to clean up outstanding challenges...") - self._cleanup_challenges() - raise + with error_handler.ErrorHandler(self._cleanup_challenges): + try: + if self.cont_c: + cont_resp = self.cont_auth.perform(self.cont_c) + if self.dv_c: + dv_resp = self.dv_auth.perform(self.dv_c) + except errors.AuthorizationError: + logger.critical("Failure in setting up challenges.") + logger.info("Attempting to clean up outstanding challenges...") + raise assert len(cont_resp) == len(self.cont_c) assert len(dv_resp) == len(self.dv_c) @@ -531,7 +531,7 @@ def _report_failed_challs(failed_achalls): reporter = zope.component.getUtility(interfaces.IReporter) for achalls in problems.itervalues(): reporter.add_message( - _generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY, True) + _generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY) def _generate_failed_chall_msg(failed_achalls): diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index dcd1e55b1..64cba508d 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -24,6 +24,7 @@ from acme import jose import letsencrypt from letsencrypt import account +from letsencrypt import colored_logging from letsencrypt import configuration from letsencrypt import constants from letsencrypt import client @@ -79,8 +80,8 @@ More detailed help: -h, --help [topic] print this message, or detailed help on a topic; the available topics are: - all, apache, automation, nginx, paths, security, testing, or any of the - subcommands + all, apache, automation, manual, nginx, paths, security, testing, or any of + the subcommands """ @@ -172,6 +173,9 @@ def _find_duplicative_certs(domains, config, renew_config): identical_names_cert, subset_names_cert = None, None configs_dir = renew_config.renewal_configs_dir + # Verify the directory is there + le_util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid()) + cli_config = configuration.RenewerConfiguration(config) for renewal_file in os.listdir(configs_dir): try: @@ -196,8 +200,116 @@ def _find_duplicative_certs(domains, config, renew_config): return identical_names_cert, subset_names_cert +def _treat_as_renewal(config, domains): + """Determine whether or not the call should be treated as a renewal. + + :returns: RenewableCert or None if renewal shouldn't occur. + :rtype: :class:`.storage.RenewableCert` + + :raises .Error: If the user would like to rerun the client again. + + """ + renewal = False + + # Considering the possibility that the requested certificate is + # related to an existing certificate. (config.duplicate, which + # is set with --duplicate, skips all of this logic and forces any + # kind of certificate to be obtained with renewal = False.) + if not config.duplicate: + ident_names_cert, subset_names_cert = _find_duplicative_certs( + domains, config, configuration.RenewerConfiguration(config)) + # I am not sure whether that correctly reads the systemwide + # configuration file. + question = None + if ident_names_cert is not None: + question = ( + "You have an existing certificate that contains exactly the " + "same domains you requested (ref: {0}){br}{br}Do you want to " + "renew and replace this certificate with a newly-issued one?" + ).format(ident_names_cert.configfile.filename, br=os.linesep) + elif subset_names_cert is not None: + question = ( + "You have an existing certificate that contains a portion of " + "the domains you requested (ref: {0}){br}{br}It contains these " + "names: {1}{br}{br}You requested these names for the new " + "certificate: {2}.{br}{br}Do you want to replace this existing " + "certificate with the new certificate?" + ).format(subset_names_cert.configfile.filename, + ", ".join(subset_names_cert.names()), + ", ".join(domains), + br=os.linesep) + if question is None: + # We aren't in a duplicative-names situation at all, so we don't + # have to tell or ask the user anything about this. + pass + elif config.renew_by_default or zope.component.getUtility( + interfaces.IDisplay).yesno(question, "Replace", "Cancel"): + renewal = True + else: + reporter_util = zope.component.getUtility(interfaces.IReporter) + reporter_util.add_message( + "To obtain a new certificate that {0} an existing certificate " + "in its domain-name coverage, you must use the --duplicate " + "option.{br}{br}For example:{br}{br}{1} --duplicate {2}".format( + "duplicates" if ident_names_cert is not None else + "overlaps with", + sys.argv[0], " ".join(sys.argv[1:]), + br=os.linesep + ), + reporter_util.HIGH_PRIORITY) + raise errors.Error( + "User did not use proper CLI and would like " + "to reinvoke the client.") + + if renewal: + return ident_names_cert if ident_names_cert is not None else subset_names_cert + + return None + + +def _report_new_cert(cert_path): + """Reports the creation of a new certificate to the user.""" + reporter_util = zope.component.getUtility(interfaces.IReporter) + reporter_util.add_message("Congratulations! Your certificate has been " + "saved at {0}.".format(cert_path), + reporter_util.MEDIUM_PRIORITY) + + +def _auth_from_domains(le_client, config, domains, plugins): + """Authenticate and enroll certificate.""" + # Note: This can raise errors... caught above us though. + lineage = _treat_as_renewal(config, domains) + + if lineage is not None: + # TODO: schoen wishes to reuse key - discussion + # https://github.com/letsencrypt/letsencrypt/pull/777/files#r40498574 + new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains) + # TODO: Check whether it worked! <- or make sure errors are thrown (jdk) + lineage.save_successor( + lineage.latest_common_version(), OpenSSL.crypto.dump_certificate( + OpenSSL.crypto.FILETYPE_PEM, new_certr.body), + new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain)) + + lineage.update_all_links_to(lineage.latest_common_version()) + # TODO: Check return value of save_successor + # TODO: Also update lineage renewal config with any relevant + # configuration values from this attempt? <- Absolutely (jdkasten) + else: + # TREAT AS NEW REQUEST + lineage = le_client.obtain_and_enroll_certificate(domains, plugins) + if not lineage: + raise errors.Error("Certificate could not be obtained") + + _report_new_cert(lineage.cert) + + return lineage + + +# TODO: Make run as close to auth + install as possible +# Possible difficulties: args.csr was hacked into auth def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-locals """Obtain a certificate and install.""" + # Begin authenticator and installer setup if args.configurator is not None and (args.installer is not None or args.authenticator is not None): return ("Either --configurator or --authenticator/--installer" @@ -216,92 +328,28 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo if installer is None or authenticator is None: return "Configurator could not be determined" + # End authenticator and installer setup domains = _find_domains(args, installer) - treat_as_renewal = False - - # Considering the possibility that the requested certificate is - # related to an existing certificate. (config.duplicate, which - # is set with --duplicate, skips all of this logic and forces any - # kind of certificate to be obtained with treat_as_renewal = False.) - if not config.duplicate: - identical_names_cert, subset_names_cert = _find_duplicative_certs( - domains, config, configuration.RenewerConfiguration(config)) - # I am not sure whether that correctly reads the systemwide - # configuration file. - question = None - if identical_names_cert is not None: - question = ( - "You have an existing certificate that contains exactly the " - "same domains you requested (ref: {0})\n\nDo you want to " - "renew and replace this certificate with a newly-issued one?" - ).format(identical_names_cert.configfile.filename) - elif subset_names_cert is not None: - question = ( - "You have an existing certificate that contains a portion of " - "the domains you requested (ref: {0})\n\nIt contains these " - "names: {1}\n\nYou requested these names for the new " - "certificate: {2}.\n\nDo you want to replace this existing " - "certificate with the new certificate?" - ).format(subset_names_cert.configfile.filename, - ", ".join(subset_names_cert.names()), - ", ".join(domains)) - if question is None: - # We aren't in a duplicative-names situation at all, so we don't - # have to tell or ask the user anything about this. - pass - elif zope.component.getUtility(interfaces.IDisplay).yesno( - question, "Replace", "Cancel"): - treat_as_renewal = True - else: - reporter_util = zope.component.getUtility(interfaces.IReporter) - reporter_util.add_message( - "To obtain a new certificate that {0} an existing certificate " - "in its domain-name coverage, you must use the --duplicate " - "option.\n\nFor example:\n\n{1} --duplicate {2}".format( - "duplicates" if identical_names_cert is not None else - "overlaps with", sys.argv[0], " ".join(sys.argv[1:])), - reporter_util.HIGH_PRIORITY) - return 1 - - # Attempting to obtain the certificate # TODO: Handle errors from _init_le_client? le_client = _init_le_client(args, config, authenticator, installer) - if treat_as_renewal: - lineage = identical_names_cert if identical_names_cert is not None else subset_names_cert - # TODO: Use existing privkey instead of generating a new one - new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains) - # TODO: Check whether it worked! - lineage.save_successor( - lineage.latest_common_version(), OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, new_certr.body), - new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain)) - lineage.update_all_links_to(lineage.latest_common_version()) - # TODO: Check return value of save_successor - # TODO: Also update lineage renewal config with any relevant - # configuration values from this attempt? - le_client.deploy_certificate( - domains, lineage.privkey, lineage.cert, lineage.chain) - display_ops.success_renewal(domains) - else: - # TREAT AS NEW REQUEST - lineage = le_client.obtain_and_enroll_certificate( - domains, authenticator, installer, plugins) - if not lineage: - return "Certificate could not be obtained" - # TODO: This treats the key as changed even when it wasn't - # TODO: We also need to pass the fullchain (for Nginx) - le_client.deploy_certificate( - domains, lineage.privkey, lineage.cert, lineage.chain) - le_client.enhance_config(domains, args.redirect) + lineage = _auth_from_domains(le_client, config, domains, plugins) + + # TODO: We also need to pass the fullchain (for Nginx) + le_client.deploy_certificate( + domains, lineage.privkey, lineage.cert, lineage.chain) + le_client.enhance_config(domains, args.redirect) + + if len(lineage.available_versions("cert")) == 1: display_ops.success_installation(domains) + else: + display_ops.success_renewal(domains) def auth(args, config, plugins): """Authenticate & obtain cert, but do not install it.""" - # XXX: Update for renewer / RenewableCert if args.domains is not None and args.csr is not None: # TODO: --csr could have a priority, when --domains is @@ -321,16 +369,16 @@ def auth(args, config, plugins): # TODO: Handle errors from _init_le_client? le_client = _init_le_client(args, config, authenticator, installer) + # This is a special case; cert and chain are simply saved if args.csr is not None: certr, chain = le_client.obtain_certificate_from_csr(le_util.CSR( file=args.csr[0], data=args.csr[1], form="der")) le_client.save_certificate( certr, chain, args.cert_path, args.chain_path) + _report_new_cert(args.cert_path) else: domains = _find_domains(args, installer) - if not le_client.obtain_and_enroll_certificate( - domains, authenticator, installer, plugins): - return "Certificate could not be obtained" + _auth_from_domains(le_client, config, domains, plugins) def install(args, config, plugins): @@ -383,7 +431,7 @@ def plugins_cmd(args, config, plugins): # TODO: Use IDisplay rather than print logger.debug("Expected interfaces: %s", args.ifaces) ifaces = [] if args.ifaces is None else args.ifaces - filtered = plugins.ifaces(ifaces) + filtered = plugins.visible().ifaces(ifaces) logger.debug("Filtered plugins: %r", filtered) if not args.init and not args.prepare: @@ -452,9 +500,6 @@ class SilentParser(object): # pylint: disable=too-few-public-methods self.parser.add_argument(*args, **kwargs) -HELP_TOPICS = ["all", "security", "paths", "automation", "testing", "plugins"] - - class HelpfulArgumentParser(object): """Argparse Wrapper. @@ -476,12 +521,13 @@ class HelpfulArgumentParser(object): self.parser._add_config_file_help = False # pylint: disable=protected-access self.silent_parser = SilentParser(self.parser) + self.verb = None self.args = self.preprocess_args(args) help1 = self.prescan_for_flag("-h", self.help_topics) help2 = self.prescan_for_flag("--help", self.help_topics) assert max(True, "a") == "a", "Gravity changed direction" help_arg = max(help1, help2) - if help_arg: + if help_arg is True: # just --help with no topic; avoid argparse altogether print USAGE sys.exit(0) @@ -492,13 +538,22 @@ class HelpfulArgumentParser(object): def preprocess_args(self, args): """Work around some limitations in argparse. - Currently, add the default verb "run" as a default. + Currently: add the default verb "run" as a default, and ensure that the + subcommand / verb comes last. """ + if "-h" in args or "--help" in args: + # all verbs double as help arguments; don't get them confused + self.verb = "help" + return args - for token in args: + for i, token in enumerate(args): if token in VERBS: - return args - return ["run"] + args + reordered = args[:i] + args[i+1:] + [args[i]] + self.verb = token + return reordered + + self.verb = "run" + return args + ["run"] def prescan_for_flag(self, flag, possible_arguments): """Checks cli input for flags. @@ -618,8 +673,9 @@ def create_parser(plugins, args): version="%(prog)s {0}".format(letsencrypt.__version__), help="show program's version number and exit") helpful.add( - "automation", "--no-confirm", dest="no_confirm", action="store_true", - help="Turn off confirmation screens, currently used for --revoke") + "automation", "--renew-by-default", action="store_true", + help="Select renewal by default when domains are a superset of a " + "a previously attained cert") helpful.add( "automation", "--agree-eula", dest="eula", action="store_true", help="Agree to the Let's Encrypt Developer Preview EULA") @@ -646,8 +702,6 @@ def create_parser(plugins, args): help=config_help("dvsni_port")) helpful.add("testing", "--simple-http-port", type=int, help=config_help("simple_http_port")) - helpful.add("testing", "--no-simple-http-tls", action="store_true", - help=config_help("no_simple_http_tls")) helpful.add_group( "security", description="Security parameters & server settings") @@ -673,79 +727,84 @@ def create_parser(plugins, args): return helpful.parser, helpful.args + # For now unfortunately this constant just needs to match the code below; # there isn't an elegant way to autogenerate it in time. -VERBS = ["run", "auth", "install", "revoke", "rollback", "config_changes", - "plugins"] +VERBS = ["run", "auth", "install", "revoke", "rollback", "config_changes", "plugins"] +HELP_TOPICS = ["all", "security", "paths", "automation", "testing"] + VERBS def _create_subparsers(helpful): subparsers = helpful.parser.add_subparsers(metavar="SUBCOMMAND") - def add_subparser(name, func): # pylint: disable=missing-docstring - subparser = subparsers.add_parser( - name, help=func.__doc__.splitlines()[0], description=func.__doc__) + def add_subparser(name): # pylint: disable=missing-docstring + if name == "plugins": + func = plugins_cmd + else: + func = eval(name) # pylint: disable=eval-used + h = func.__doc__.splitlines()[0] + subparser = subparsers.add_parser(name, help=h, description=func.__doc__) subparser.set_defaults(func=func) return subparser # the order of add_subparser() calls is important: it defines the # order in which subparser names will be displayed in --help - add_subparser("run", run) - parser_auth = add_subparser("auth", auth) - parser_install = add_subparser("install", install) - parser_revoke = add_subparser("revoke", revoke) - parser_rollback = add_subparser("rollback", rollback) - add_subparser("config_changes", config_changes) - parser_plugins = add_subparser("plugins", plugins_cmd) + # these add_subparser objects return objects to which arguments could be + # attached, but they have annoying arg ordering constrains so we use + # groups instead: https://github.com/letsencrypt/letsencrypt/issues/820 + for v in VERBS: + add_subparser(v) - parser_auth.add_argument( - "--csr", type=read_file, help="Path to a Certificate Signing " - "Request (CSR) in DER format.") - parser_auth.add_argument( - "--cert-path", default=flag_default("auth_cert_path"), - help="When using --csr this is where certificate is saved.") - parser_auth.add_argument( - "--chain-path", default=flag_default("auth_chain_path"), - help="When using --csr this is where certificate chain is saved.") + helpful.add_group("auth", description="Options for modifying how a cert is obtained") + helpful.add_group("install", description="Options for modifying how a cert is deployed") + helpful.add_group("revoke", description="Options for revocation of certs") + helpful.add_group("rollback", description="Options for reverting config changes") + helpful.add_group("plugins", description="Plugin options") - parser_install.add_argument( - "--cert-path", required=True, help="Path to a certificate that " - "is going to be installed.") - parser_install.add_argument( - "--key-path", required=True, help="Accompanying private key") - parser_install.add_argument( - "--chain-path", help="Accompanying path to a certificate chain.") - parser_revoke.add_argument( - "--cert-path", type=read_file, help="Revoke a specific certificate.", - required=True) - parser_revoke.add_argument( - "--key-path", type=read_file, - help="Revoke certificate using its accompanying key. Useful if " - "Account Key is lost.") + helpful.add("auth", + "--csr", type=read_file, + help="Path to a Certificate Signing Request (CSR) in DER format.") + helpful.add("rollback", + "--checkpoints", type=int, metavar="N", + default=flag_default("rollback_checkpoints"), + help="Revert configuration N number of checkpoints.") - parser_rollback.add_argument( - "--checkpoints", type=int, metavar="N", - default=flag_default("rollback_checkpoints"), - help="Revert configuration N number of checkpoints.") - - parser_plugins.add_argument( - "--init", action="store_true", help="Initialize plugins.") - parser_plugins.add_argument( - "--prepare", action="store_true", - help="Initialize and prepare plugins.") - parser_plugins.add_argument( - "--authenticators", action="append_const", dest="ifaces", - const=interfaces.IAuthenticator, - help="Limit to authenticator plugins only.") - parser_plugins.add_argument( - "--installers", action="append_const", dest="ifaces", - const=interfaces.IInstaller, help="Limit to installer plugins only.") + helpful.add("plugins", + "--init", action="store_true", help="Initialize plugins.") + helpful.add("plugins", + "--prepare", action="store_true", help="Initialize and prepare plugins.") + helpful.add("plugins", + "--authenticators", action="append_const", dest="ifaces", + const=interfaces.IAuthenticator, help="Limit to authenticator plugins only.") + helpful.add("plugins", + "--installers", action="append_const", dest="ifaces", + const=interfaces.IInstaller, help="Limit to installer plugins only.") def _paths_parser(helpful): add = helpful.add + verb = helpful.verb helpful.add_group( "paths", description="Arguments changing execution paths & servers") + + cph = "Path to where cert is saved (with auth), installed (with install --csr) or revoked." + if verb == "auth": + add("paths", "--cert-path", default=flag_default("auth_cert_path"), help=cph) + elif verb == "revoke": + add("paths", "--cert-path", type=read_file, required=True, help=cph) + else: + add("paths", "--cert-path", help=cph, required=(verb == "install")) + + # revoke --key-path reads a file, install --key-path takes a string + add("paths", "--key-path", type=((verb == "revoke" and read_file) or str), + required=(verb == "install"), + help="Path to private key for cert creation or revocation (if account key is missing)") + + default_cp = None + if verb == "auth": + default_cp = flag_default("auth_chain_path") + add("paths", "--chain-path", default=default_cp, + help="Accompanying path to a certificate chain.") add("paths", "--config-dir", default=flag_default("config_dir"), help=config_help("config_dir")) add("paths", "--work-dir", default=flag_default("work_dir"), @@ -784,7 +843,7 @@ def _setup_logging(args): level = -args.verbose_count * 10 fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s" if args.text_mode: - handler = logging.StreamHandler() + handler = colored_logging.StreamHandler() handler.setFormatter(logging.Formatter(fmt)) else: handler = log.DialogHandler() @@ -829,7 +888,8 @@ def _handle_exception(exc_type, exc_value, trace, args): """ logger.debug( - "Exiting abnormally:\n%s", + "Exiting abnormally:%s%s", + os.linesep, "".join(traceback.format_exception(exc_type, exc_value, trace))) if issubclass(exc_type, Exception) and (args is None or not args.debug): diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 60eaea5a1..7a78add38 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -18,10 +18,10 @@ from letsencrypt import constants from letsencrypt import continuity_auth from letsencrypt import crypto_util from letsencrypt import errors +from letsencrypt import error_handler from letsencrypt import interfaces from letsencrypt import le_util from letsencrypt import reverter -from letsencrypt import revoker from letsencrypt import storage from letsencrypt.display import ops as display_ops @@ -111,6 +111,8 @@ class Client(object): :ivar .AuthHandler auth_handler: Authorizations handler that will dispatch DV and Continuity challenges to appropriate authenticators (providing `.IAuthenticator` interface). + :ivar .IAuthenticator dv_auth: Prepared (`.IAuthenticator.prepare`) + authenticator that can solve the `.constants.DV_CHALLENGES`. :ivar .IInstaller installer: Installer. :ivar acme.client.Client acme: Optional ACME client API handle. You might already have one from `register`. @@ -118,14 +120,10 @@ class Client(object): """ def __init__(self, config, account_, dv_auth, installer, acme=None): - """Initialize a client. - - :param .IAuthenticator dv_auth: Prepared (`.IAuthenticator.prepare`) - authenticator that can solve the `.constants.DV_CHALLENGES`. - - """ + """Initialize a client.""" self.config = config self.account = account_ + self.dv_auth = dv_auth self.installer = installer # Initialize ACME if account is provided @@ -211,12 +209,11 @@ class Client(object): # Create CSR from names key = crypto_util.init_save_key( self.config.rsa_key_size, self.config.key_dir) - csr = crypto_util.init_save_csr(key, domains, self.config.cert_dir) + csr = crypto_util.init_save_csr(key, domains, self.config.csr_dir) return self._obtain_certificate(domains, csr) + (key, csr) - def obtain_and_enroll_certificate( - self, domains, authenticator, installer, plugins): + def obtain_and_enroll_certificate(self, domains, plugins): """Obtain and enroll certificate. Get a new certificate for the specified domains using the specified @@ -224,12 +221,6 @@ class Client(object): containing it. :param list domains: Domains to request. - :param authenticator: The authenticator to use. - :type authenticator: :class:`letsencrypt.interfaces.IAuthenticator` - - :param installer: The installer to use. - :type installer: :class:`letsencrypt.interfaces.IInstaller` - :param plugins: A PluginsFactory object. :returns: A new :class:`letsencrypt.storage.RenewableCert` instance @@ -241,9 +232,10 @@ class Client(object): # TODO: remove this dirty hack self.config.namespace.authenticator = plugins.find_init( - authenticator).name - if installer is not None: - self.config.namespace.installer = plugins.find_init(installer).name + self.dv_auth).name + if self.installer is not None: + self.config.namespace.installer = plugins.find_init( + self.installer).name # XXX: We clearly need a more general and correct way of getting # options into the configobj for the RenewableCert instance. @@ -276,25 +268,21 @@ class Client(object): :param .RenewableCert cert: Newly issued certificate """ - if ("autorenew" not in cert.configuration or - cert.configuration.as_bool("autorenew")): - if ("autodeploy" not in cert.configuration or - cert.configuration.as_bool("autodeploy")): + if cert.autorenewal_is_enabled(): + if cert.autodeployment_is_enabled(): msg = "Automatic renewal and deployment has " else: msg = "Automatic renewal but not automatic deployment has " + elif cert.autodeployment_is_enabled(): + msg = "Automatic deployment but not automatic renewal has " else: - if ("autodeploy" not in cert.configuration or - cert.configuration.as_bool("autodeploy")): - msg = "Automatic deployment but not automatic renewal has " - else: - msg = "Automatic renewal and deployment has not " + msg = "Automatic renewal and deployment has not " msg += ("been enabled for your certificate. These settings can be " "configured in the directories under {0}.").format( cert.cli_config.renewal_configs_dir) reporter = zope.component.getUtility(interfaces.IReporter) - reporter.add_message(msg, reporter.LOW_PRIORITY, True) + reporter.add_message(msg, reporter.LOW_PRIORITY) def save_certificate(self, certr, chain_cert, cert_path, chain_path): # pylint: disable=no-self-use @@ -364,16 +352,17 @@ class Client(object): chain_path = None if chain_path is None else os.path.abspath(chain_path) - for dom in domains: - # TODO: Provide a fullchain reference for installers like - # nginx that want it - self.installer.deploy_cert( - dom, os.path.abspath(cert_path), - os.path.abspath(privkey_path), chain_path) + with error_handler.ErrorHandler(self.installer.recovery_routine): + for dom in domains: + # TODO: Provide a fullchain reference for installers like + # nginx that want it + self.installer.deploy_cert( + dom, os.path.abspath(cert_path), + os.path.abspath(privkey_path), chain_path) - self.installer.save("Deployed Let's Encrypt Certificate") - # sites may have been enabled / final cleanup - self.installer.restart() + self.installer.save("Deployed Let's Encrypt Certificate") + # sites may have been enabled / final cleanup + self.installer.restart() def enhance_config(self, domains, redirect=None): """Enhance the configuration. @@ -399,6 +388,8 @@ class Client(object): if redirect is None: redirect = enhancements.ask("redirect") + # When support for more enhancements are added, the call to the + # plugin's `enhance` function should be wrapped by an ErrorHandler if redirect: self.redirect_to_ssl(domains) @@ -409,14 +400,16 @@ class Client(object): :type vhost: :class:`letsencrypt.interfaces.IInstaller` """ - for dom in domains: - try: - self.installer.enhance(dom, "redirect") - except errors.PluginError: - logger.warn("Unable to perform redirect for %s", dom) + with error_handler.ErrorHandler(self.installer.recovery_routine): + for dom in domains: + try: + self.installer.enhance(dom, "redirect") + except errors.PluginError: + logger.warn("Unable to perform redirect for %s", dom) + raise - self.installer.save("Add Redirects") - self.installer.restart() + self.installer.save("Add Redirects") + self.installer.restart() def validate_key_csr(privkey, csr=None): @@ -485,27 +478,6 @@ def rollback(default_installer, checkpoints, config, plugins): installer.restart() -def revoke(default_installer, config, plugins, no_confirm, cert, authkey): - """Revoke certificates. - - :param config: Configuration. - :type config: :class:`letsencrypt.interfaces.IConfig` - - """ - installer = display_ops.pick_installer( - config, default_installer, plugins, question="Which installer " - "should be used for certificate revocation?") - - revoc = revoker.Revoker(installer, config, no_confirm) - # Cert is most selective, so it is chosen first. - if cert is not None: - revoc.revoke_from_cert(cert[0]) - elif authkey is not None: - revoc.revoke_from_key(le_util.Key(authkey[0], authkey[1])) - else: - revoc.revoke_from_menu() - - def view_config_changes(config): """View checkpoints and associated configuration changes. diff --git a/letsencrypt/colored_logging.py b/letsencrypt/colored_logging.py new file mode 100644 index 000000000..170da0b38 --- /dev/null +++ b/letsencrypt/colored_logging.py @@ -0,0 +1,40 @@ +"""A formatter and StreamHandler for colorizing logging output.""" +import logging +import sys + +from letsencrypt import le_util + + +class StreamHandler(logging.StreamHandler): + """Sends colored logging output to a stream. + + If the specified stream is not a tty, the class works like the + standard logging.StreamHandler. Default red_level is logging.WARNING. + + :ivar bool colored: True if output should be colored + :ivar bool red_level: The level at which to output + + """ + + def __init__(self, stream=None): + super(StreamHandler, self).__init__(stream) + self.colored = (sys.stderr.isatty() if stream is None else + stream.isatty()) + self.red_level = logging.WARNING + + def format(self, record): + """Formats the string representation of record. + + :param logging.LogRecord record: Record to be formatted + + :returns: Formatted, string representation of record + :rtype: str + + """ + output = super(StreamHandler, self).format(record) + if self.colored and record.levelno >= self.red_level: + return ''.join((le_util.ANSI_SGR_RED, + output, + le_util.ANSI_SGR_RESET)) + else: + return output diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 6f3ece9fd..20774e5cc 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -18,8 +18,7 @@ class NamespaceConfig(object): paths defined in :py:mod:`letsencrypt.constants`: - `accounts_dir` - - `cert_dir` - - `cert_key_backup` + - `csr_dir` - `in_progress_dir` - `key_dir` - `renewer_config_file` @@ -54,13 +53,8 @@ class NamespaceConfig(object): return os.path.join(self.namespace.work_dir, constants.BACKUP_DIR) @property - def cert_dir(self): # pylint: disable=missing-docstring - return os.path.join(self.namespace.config_dir, constants.CERT_DIR) - - @property - def cert_key_backup(self): # pylint: disable=missing-docstring - return os.path.join(self.namespace.work_dir, - constants.CERT_KEY_BACKUP_DIR, self.server_path) + def csr_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.CSR_DIR) @property def in_progress_dir(self): # pylint: disable=missing-docstring diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 6c67ce445..762409d25 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -68,12 +68,8 @@ ACCOUNTS_DIR = "accounts" BACKUP_DIR = "backups" """Directory (relative to `IConfig.work_dir`) where backups are kept.""" -CERT_DIR = "certs" -"""See `.IConfig.cert_dir`.""" - -CERT_KEY_BACKUP_DIR = "keys-certs" -"""Directory where all certificates and keys are stored (relative to -`IConfig.work_dir`). Used for easy revocation.""" +CSR_DIR = "csr" +"""See `.IConfig.csr_dir`.""" IN_PROGRESS_DIR = "IN_PROGRESS" """Directory used before a permanent checkpoint is finalized (relative to diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index 79cd24ed6..61aa8b0db 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -4,7 +4,6 @@ is capable of handling the signatures. """ -import datetime import logging import os @@ -201,29 +200,26 @@ def valid_privkey(privkey): return False -def _pyopenssl_load(data, method, types=( - OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1)): - openssl_errors = [] - for filetype in types: - try: - return method(filetype, data), filetype - except OpenSSL.crypto.Error as error: # TODO: anything else? - openssl_errors.append(error) - raise errors.Error("Unable to load: {0}".format(",".join( - str(error) for error in openssl_errors))) - - def pyopenssl_load_certificate(data): """Load PEM/DER certificate. :raises errors.Error: """ - return _pyopenssl_load(data, OpenSSL.crypto.load_certificate) + + openssl_errors = [] + + for file_type in (OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1): + try: + return OpenSSL.crypto.load_certificate(file_type, data), file_type + except OpenSSL.crypto.Error as error: # TODO: other errors? + openssl_errors.append(error) + raise errors.Error("Unable to load: {0}".format(",".join( + str(error) for error in openssl_errors))) -def _get_sans_from_cert_or_req( - cert_or_req_str, load_func, typ=OpenSSL.crypto.FILETYPE_PEM): +def _get_sans_from_cert_or_req(cert_or_req_str, load_func, + typ=OpenSSL.crypto.FILETYPE_PEM): try: cert_or_req = load_func(typ, cert_or_req_str) except OpenSSL.crypto.Error as error: @@ -261,24 +257,6 @@ def get_sans_from_csr(csr, typ=OpenSSL.crypto.FILETYPE_PEM): csr, OpenSSL.crypto.load_certificate_request, typ) -def asn1_generalizedtime_to_dt(timestamp): - """Convert ASN.1 GENERALIZEDTIME to datetime. - - Useful for deserialization of `OpenSSL.crypto.X509.get_notAfter` and - `OpenSSL.crypto.X509.get_notAfter` outputs. - - .. todo:: This function support only one format: `%Y%m%d%H%M%SZ`. - Implement remaining two. - - """ - return datetime.datetime.strptime(timestamp, '%Y%m%d%H%M%SZ') - - -def pyopenssl_x509_name_as_text(x509name): - """Convert `OpenSSL.crypto.X509Name` to text.""" - return "/".join("{0}={1}" for key, value in x509name.get_components()) - - def dump_pyopenssl_chain(chain, filetype=OpenSSL.crypto.FILETYPE_PEM): """Dump certificate chain into a bundle. diff --git a/letsencrypt/display/enhancements.py b/letsencrypt/display/enhancements.py index 8edc72ba0..c56198161 100644 --- a/letsencrypt/display/enhancements.py +++ b/letsencrypt/display/enhancements.py @@ -11,7 +11,7 @@ from letsencrypt.display import util as display_util logger = logging.getLogger(__name__) # Define a helper function to avoid verbose code -util = zope.component.getUtility # pylint: disable=invalid-name +util = zope.component.getUtility def ask(enhancement): diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index 4ab3ec579..cb424a81b 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -12,7 +12,7 @@ from letsencrypt.display import util as display_util logger = logging.getLogger(__name__) # Define a helper function to avoid verbose code -util = zope.component.getUtility # pylint: disable=invalid-name +util = zope.component.getUtility def choose_plugin(prepared, question): @@ -65,7 +65,7 @@ def pick_plugin(config, default, plugins, question, ifaces): # throw more UX-friendly error if default not in plugins filtered = plugins.filter(lambda p_ep: p_ep.name == default) else: - filtered = plugins.ifaces(ifaces) + filtered = plugins.visible().ifaces(ifaces) filtered.init(config) verified = filtered.verify(ifaces) diff --git a/letsencrypt/display/revocation.py b/letsencrypt/display/revocation.py deleted file mode 100644 index 02a253676..000000000 --- a/letsencrypt/display/revocation.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Revocation UI class.""" -import os - -import zope.component - -from letsencrypt import interfaces -from letsencrypt.display import util as display_util - -# Define a helper function to avoid verbose code -util = zope.component.getUtility # pylint: disable=invalid-name - - -def display_certs(certs): - """Display the certificates in a menu for revocation. - - :param list certs: each is a :class:`letsencrypt.revoker.Cert` - - :returns: tuple of the form (code, selection) where - code is a display exit code - selection is the user's int selection - :rtype: tuple - - """ - list_choices = [ - "%s | %s | %s" % ( - str(cert.get_cn().ljust(display_util.WIDTH - 39)), - cert.get_not_before().strftime("%m-%d-%y"), - "Installed" if cert.installed and cert.installed != ["Unknown"] - else "") for cert in certs - ] - - code, tag = util(interfaces.IDisplay).menu( - "Which certificates would you like to revoke?", - list_choices, help_label="More Info", ok_label="Revoke", - cancel_label="Exit") - - return code, tag - - -def confirm_revocation(cert): - """Confirm revocation screen. - - :param cert: certificate object - :type cert: :class: - - :returns: True if user would like to revoke, False otherwise - :rtype: bool - - """ - return util(interfaces.IDisplay).yesno( - "Are you sure you would like to revoke the following " - "certificate:{0}{cert}This action cannot be reversed!".format( - os.linesep, cert=cert.pretty_print())) - - -def more_info_cert(cert): - """Displays more info about the cert. - - :param dict cert: cert dict used throughout revoker.py - - """ - util(interfaces.IDisplay).notification( - "Certificate Information:{0}{1}".format( - os.linesep, cert.pretty_print()), - height=display_util.HEIGHT) - - -def success_revocation(cert): - """Display a success message. - - :param cert: cert that was revoked - :type cert: :class:`letsencrypt.revoker.Cert` - - """ - util(interfaces.IDisplay).notification( - "You have successfully revoked the certificate for " - "%s" % cert.get_cn()) diff --git a/letsencrypt/error_handler.py b/letsencrypt/error_handler.py new file mode 100644 index 000000000..8b0eb7c8b --- /dev/null +++ b/letsencrypt/error_handler.py @@ -0,0 +1,99 @@ +"""Registers functions to be called if an exception or signal occurs.""" +import logging +import os +import signal +import traceback + + +logger = logging.getLogger(__name__) + + +# _SIGNALS stores the signals that will be handled by the ErrorHandler. These +# signals were chosen as their default handler terminates the process and could +# potentially occur from inside Python. Signals such as SIGILL were not +# included as they could be a sign of something devious and we should terminate +# immediately. +_SIGNALS = ([signal.SIGTERM] if os.name == "nt" else + [signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT, + signal.SIGXCPU, signal.SIGXFSZ]) + + +class ErrorHandler(object): + """Registers functions to be called if an exception or signal occurs. + + This class allows you to register functions that will be called when + an exception (excluding SystemExit) or signal is encountered. The + class works best as a context manager. For example: + + with ErrorHandler(cleanup_func): + do_something() + + If an exception is raised out of do_something, cleanup_func will be + called. The exception is not caught by the ErrorHandler. Similarly, + if a signal is encountered, cleanup_func is called followed by the + previously registered signal handler. + + Every registered function is attempted to be run to completion + exactly once. If a registered function raises an exception, it is + logged and the next function is called. If a (different) handled + signal occurs while calling a registered function, it is attempted + to be called again by the next signal handler. + + """ + def __init__(self, func=None): + self.funcs = [] + self.prev_handlers = {} + if func is not None: + self.register(func) + + def __enter__(self): + self.set_signal_handlers() + + def __exit__(self, exec_type, exec_value, trace): + # SystemExit is ignored to properly handle forks that don't exec + if exec_type not in (None, SystemExit): + logger.debug("Encountered exception:\n%s", "".join( + traceback.format_exception(exec_type, exec_value, trace))) + self.call_registered() + self.reset_signal_handlers() + + def register(self, func): + """Registers func to be called if an error occurs.""" + self.funcs.append(func) + + def call_registered(self): + """Calls all registered functions""" + logger.debug("Calling registered functions") + while self.funcs: + try: + self.funcs[-1]() + except Exception as error: # pylint: disable=broad-except + logger.error("Encountered exception during recovery") + logger.exception(error) + self.funcs.pop() + + def set_signal_handlers(self): + """Sets signal handlers for signals in _SIGNALS.""" + for signum in _SIGNALS: + prev_handler = signal.getsignal(signum) + # If prev_handler is None, the handler was set outside of Python + if prev_handler is not None: + self.prev_handlers[signum] = prev_handler + signal.signal(signum, self._signal_handler) + + def reset_signal_handlers(self): + """Resets signal handlers for signals in _SIGNALS.""" + for signum in self.prev_handlers: + signal.signal(signum, self.prev_handlers[signum]) + self.prev_handlers.clear() + + def _signal_handler(self, signum, unused_frame): + """Calls registered functions and the previous signal handler. + + :param int signum: number of current signal + + """ + logger.debug("Singal %s encountered", signum) + self.call_registered() + signal.signal(signum, self.prev_handlers[signum]) + os.kill(os.getpid(), signum) diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index 5db92b368..5e82d61aa 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -205,12 +205,9 @@ class IConfig(zope.interface.Interface): accounts_dir = zope.interface.Attribute( "Directory where all account information is stored.") backup_dir = zope.interface.Attribute("Configuration backups directory.") - cert_dir = zope.interface.Attribute( + csr_dir = zope.interface.Attribute( "Directory where newly generated Certificate Signing Requests " - "(CSRs) and certificates not enrolled in the renewer are saved.") - cert_key_backup = zope.interface.Attribute( - "Directory where all certificates and keys are stored. " - "Used for easy revocation.") + "(CSRs) are saved.") in_progress_dir = zope.interface.Attribute( "Directory used before a permanent checkpoint is finalized.") key_dir = zope.interface.Attribute("Keys storage.") @@ -226,8 +223,6 @@ class IConfig(zope.interface.Interface): "Port number to perform DVSNI challenge. " "Boulder in testing mode defaults to 5001.") - no_simple_http_tls = zope.interface.Attribute( - "Do not use TLS when solving SimpleHTTP challenges.") simple_http_port = zope.interface.Attribute( "Port used in the SimpleHttp challenge.") @@ -321,6 +316,17 @@ class IInstaller(IPlugin): """ + def recovery_routine(): + """Revert configuration to most recent finalized checkpoint. + + Remove all changes (temporary and permanent) that have not been + finalized. This is useful to protect against crashes and other + execution interruptions. + + :raises .errors.PluginError: If unable to recover the configuration + + """ + def view_config_changes(): """Display all of the LE config changes. @@ -470,7 +476,7 @@ class IReporter(zope.interface.Interface): LOW_PRIORITY = zope.interface.Attribute( "Used to denote low priority messages") - def add_message(self, msg, priority, on_crash=False): + def add_message(self, msg, priority, on_crash=True): """Adds msg to the list of messages to be printed. :param str msg: Message to be displayed to the user. diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index ffc7da190..5626902ef 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -18,6 +18,15 @@ Key = collections.namedtuple("Key", "file pem") CSR = collections.namedtuple("CSR", "file data form") +# ANSI SGR escape codes +# Formats text as bold or with increased intensity +ANSI_SGR_BOLD = '\033[1m' +# Colors text red +ANSI_SGR_RED = "\033[31m" +# Resets output format +ANSI_SGR_RESET = "\033[0m" + + def run_script(params): """Run the script with the given params. diff --git a/letsencrypt/log.py b/letsencrypt/log.py index e800d37c9..6436f6fc2 100644 --- a/letsencrypt/log.py +++ b/letsencrypt/log.py @@ -25,7 +25,7 @@ class DialogHandler(logging.Handler): # pylint: disable=too-few-public-methods logging.Handler.__init__(self, level) self.height = height self.width = width - # "dialog" collides with module name... pylint: disable=invalid-name + # "dialog" collides with module name... self.d = dialog.Dialog() if d is None else d self.lines = [] diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 59598a35e..95ad56a0a 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -23,10 +23,10 @@ def dest_namespace(name): """ArgumentParser dest namespace (prefix of all destinations).""" return name.replace("-", "_") + "_" -private_ips_regex = re.compile( # pylint: disable=invalid-name +private_ips_regex = re.compile( r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|" r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)") -hostname_regex = re.compile( # pylint: disable=invalid-name +hostname_regex = re.compile( r"^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*[a-z]+$", re.IGNORECASE) @@ -173,7 +173,7 @@ class Dvsni(object): achall.chall.encode("token") + '.pem') def _setup_challenge_cert(self, achall, s=None): - # pylint: disable=invalid-name + """Generate and write out challenge certificate.""" cert_path = self.get_cert_path(achall) key_path = self.get_key_path(achall) diff --git a/letsencrypt/plugins/disco.py b/letsencrypt/plugins/disco.py index b6cdb1f99..5a41fda88 100644 --- a/letsencrypt/plugins/disco.py +++ b/letsencrypt/plugins/disco.py @@ -50,6 +50,11 @@ class PluginEntryPoint(object): """Description with name. Handy for UI.""" return "{0} ({1})".format(self.description, self.name) + @property + def hidden(self): + """Should this plugin be hidden from UI?""" + return getattr(self.plugin_cls, "hidden", False) + def ifaces(self, *ifaces_groups): """Does plugin implements specified interface groups?""" return not ifaces_groups or any( @@ -183,6 +188,10 @@ class PluginsRegistry(collections.Mapping): return type(self)(dict((name, plugin_ep) for name, plugin_ep in self._plugins.iteritems() if pred(plugin_ep))) + def visible(self): + """Filter plugins based on visibility.""" + return self.filter(lambda plugin_ep: not plugin_ep.hidden) + def ifaces(self, *ifaces_groups): """Filter plugins based on interfaces.""" # pylint: disable=star-args diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py index 43d0ac055..9d5ef87e9 100644 --- a/letsencrypt/plugins/manual.py +++ b/letsencrypt/plugins/manual.py @@ -23,7 +23,7 @@ from letsencrypt.plugins import common logger = logging.getLogger(__name__) -class ManualAuthenticator(common.Plugin): +class Authenticator(common.Plugin): """Manual Authenticator. .. todo:: Support for `~.challenges.DVSNI`. @@ -53,7 +53,7 @@ command on the target server (as root): # served and makes it more obvious that Python command will serve # anything recursively under the cwd - HTTP_TEMPLATE = """\ + CMD_TEMPLATE = """\ mkdir -p {root}/public_html/{response.URI_ROOT_PATH} cd {root}/public_html echo -n {validation} > {response.URI_ROOT_PATH}/{encoded_token} @@ -63,33 +63,10 @@ $(command -v python2 || command -v python2.7 || command -v python2.6) -c \\ SimpleHTTPServer.SimpleHTTPRequestHandler.extensions_map = {{'': '{ct}'}}; \\ s = BaseHTTPServer.HTTPServer(('', {port}), SimpleHTTPServer.SimpleHTTPRequestHandler); \\ s.serve_forever()" """ - """Non-TLS command template.""" - - # https://www.piware.de/2011/01/creating-an-https-server-in-python/ - HTTPS_TEMPLATE = """\ -mkdir -p {root}/public_html/{response.URI_ROOT_PATH} -cd {root}/public_html -echo -n {validation} > {response.URI_ROOT_PATH}/{encoded_token} -# run only once per server: -openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout ../key.pem -out ../cert.pem -$(command -v python2 || command -v python2.7 || command -v python2.6) -c \\ -"import BaseHTTPServer, SimpleHTTPServer, ssl; \\ -SimpleHTTPServer.SimpleHTTPRequestHandler.extensions_map = {{'': '{ct}'}}; \\ -s = BaseHTTPServer.HTTPServer(('', {port}), SimpleHTTPServer.SimpleHTTPRequestHandler); \\ -s.socket = ssl.wrap_socket(s.socket, keyfile='../key.pem', certfile='../cert.pem'); \\ -s.serve_forever()" """ - """TLS command template. - - According to the ACME specification, "the ACME server MUST ignore - the certificate provided by the HTTPS server", so the first command - generates temporary self-signed certificate. - - """ + """Command template.""" def __init__(self, *args, **kwargs): - super(ManualAuthenticator, self).__init__(*args, **kwargs) - self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls - else self.HTTPS_TEMPLATE) + super(Authenticator, self).__init__(*args, **kwargs) self._root = (tempfile.mkdtemp() if self.conf("test-mode") else "/tmp/letsencrypt") self._httpd = None @@ -97,8 +74,7 @@ s.serve_forever()" """ @classmethod def add_parser_arguments(cls, add): add("test-mode", action="store_true", - help="Test mode. Executes the manual command in subprocess. " - "Requires openssl to be installed unless --no-simple-http-tls.") + help="Test mode. Executes the manual command in subprocess.") def prepare(self): # pylint: disable=missing-docstring,no-self-use pass # pragma: no cover @@ -142,11 +118,11 @@ binary for temporary key/certificate generation.""".replace("\n", "") # users, but will not work if multiple domains point at the # same server: default command doesn't support virtual hosts response, validation = achall.gen_response_and_validation( - tls=(not self.config.no_simple_http_tls)) + tls=False) # SimpleHTTP TLS is dead: ietf-wg-acme/acme#7 port = (response.port if self.config.simple_http_port is None else int(self.config.simple_http_port)) - command = self.template.format( + command = self.CMD_TEMPLATE.format( root=self._root, achall=achall, response=response, validation=pipes.quote(validation.json_dumps()), encoded_token=achall.chall.encode("token"), @@ -182,6 +158,8 @@ binary for temporary key/certificate generation.""".replace("\n", "") achall.account_key.public_key(), self.config.simple_http_port): return response else: + logger.error( + "Self-verify of challenge failed, authorization abandoned.") if self.conf("test-mode") and self._httpd.poll() is not None: # simply verify cause command failure... return False diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py index 6b9359db1..8cfff1cc5 100644 --- a/letsencrypt/plugins/manual_test.py +++ b/letsencrypt/plugins/manual_test.py @@ -17,22 +17,20 @@ from letsencrypt.tests import test_util KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) -class ManualAuthenticatorTest(unittest.TestCase): - """Tests for letsencrypt.plugins.manual.ManualAuthenticator.""" +class AuthenticatorTest(unittest.TestCase): + """Tests for letsencrypt.plugins.manual.Authenticator.""" def setUp(self): - from letsencrypt.plugins.manual import ManualAuthenticator + from letsencrypt.plugins.manual import Authenticator self.config = mock.MagicMock( - no_simple_http_tls=True, simple_http_port=4430, - manual_test_mode=False) - self.auth = ManualAuthenticator(config=self.config, name="manual") + simple_http_port=8080, manual_test_mode=False) + self.auth = Authenticator(config=self.config, name="manual") self.achalls = [achallenges.SimpleHTTP( challb=acme_util.SIMPLE_HTTP_P, domain="foo.com", account_key=KEY)] config_test_mode = mock.MagicMock( - no_simple_http_tls=True, simple_http_port=4430, - manual_test_mode=True) - self.auth_test_mode = ManualAuthenticator( + simple_http_port=8080, manual_test_mode=True) + self.auth_test_mode = Authenticator( config=config_test_mode, name="manual") def test_more_info(self): @@ -55,7 +53,7 @@ class ManualAuthenticatorTest(unittest.TestCase): self.assertEqual([resp], self.auth.perform(self.achalls)) self.assertEqual(1, mock_raw_input.call_count) mock_verify.assert_called_with( - self.achalls[0].challb.chall, "foo.com", KEY.public_key(), 4430) + self.achalls[0].challb.chall, "foo.com", KEY.public_key(), 8080) message = mock_stdout.write.mock_calls[0][1][0] self.assertTrue(self.achalls[0].chall.encode("token") in message) @@ -68,7 +66,7 @@ class ManualAuthenticatorTest(unittest.TestCase): mock_popen.side_effect = OSError self.assertEqual([False], self.auth_test_mode.perform(self.achalls)) - @mock.patch("letsencrypt.plugins.manual.socket.socket", autospec=True) + @mock.patch("letsencrypt.plugins.manual.socket.socket") @mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True) @mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True) def test_perform_test_command_run_failure( @@ -78,7 +76,7 @@ class ManualAuthenticatorTest(unittest.TestCase): self.assertRaises( errors.Error, self.auth_test_mode.perform, self.achalls) - @mock.patch("letsencrypt.plugins.manual.socket.socket", autospec=True) + @mock.patch("letsencrypt.plugins.manual.socket.socket") @mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True) @mock.patch("acme.challenges.SimpleHTTPResponse.simple_verify", autospec=True) diff --git a/letsencrypt/plugins/null.py b/letsencrypt/plugins/null.py index bc9565e5a..4ba6c9d64 100644 --- a/letsencrypt/plugins/null.py +++ b/letsencrypt/plugins/null.py @@ -17,6 +17,7 @@ class Installer(common.Plugin): zope.interface.classProvides(interfaces.IPluginFactory) description = "Null Installer" + hidden = True # pylint: disable=missing-docstring,no-self-use @@ -47,6 +48,9 @@ class Installer(common.Plugin): def rollback_checkpoints(self, rollback=1): pass # pragma: no cover + def recovery_routine(self): + pass # pragma: no cover + def view_config_changes(self): pass # pragma: no cover diff --git a/letsencrypt/reporter.py b/letsencrypt/reporter.py index 0c4a7b378..0905dfa54 100644 --- a/letsencrypt/reporter.py +++ b/letsencrypt/reporter.py @@ -9,6 +9,7 @@ import textwrap import zope.interface from letsencrypt import interfaces +from letsencrypt import le_util logger = logging.getLogger(__name__) @@ -30,14 +31,12 @@ class Reporter(object): LOW_PRIORITY = 2 """Low priority constant. See `add_message`.""" - _RESET = '\033[0m' - _BOLD = '\033[1m' _msg_type = collections.namedtuple('ReporterMsg', 'priority text on_crash') def __init__(self): self.messages = Queue.PriorityQueue() - def add_message(self, msg, priority, on_crash=False): + def add_message(self, msg, priority, on_crash=True): """Adds msg to the list of messages to be printed. :param str msg: Message to be displayed to the user. @@ -76,7 +75,7 @@ class Reporter(object): no_exception = sys.exc_info()[0] is None bold_on = sys.stdout.isatty() if bold_on: - print self._BOLD + print le_util.ANSI_SGR_BOLD print 'IMPORTANT NOTES:' first_wrapper = textwrap.TextWrapper( initial_indent=' - ', subsequent_indent=(' ' * 3)) @@ -87,7 +86,7 @@ class Reporter(object): msg = self.messages.get() if no_exception or msg.on_crash: if bold_on and msg.priority > self.HIGH_PRIORITY: - sys.stdout.write(self._RESET) + sys.stdout.write(le_util.ANSI_SGR_RESET) bold_on = False lines = msg.text.splitlines() print first_wrapper.fill(lines[0]) @@ -95,4 +94,4 @@ class Reporter(object): print "\n".join( next_wrapper.fill(line) for line in lines[1:]) if bold_on: - sys.stdout.write(self._RESET) + sys.stdout.write(le_util.ANSI_SGR_RESET) diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py deleted file mode 100644 index 32c6f003d..000000000 --- a/letsencrypt/revoker.py +++ /dev/null @@ -1,560 +0,0 @@ -"""Revoker module to enable LE revocations. - -The backend of this module would fit a database quite nicely, but in order to -minimize dependencies and maintain transparency, the class currently implements -its own storage system. The number of certs that will likely be stored on any -given client might not warrant requiring a database. - -""" -import collections -import csv -import logging -import os -import shutil -import tempfile - -import OpenSSL - -from acme import client as acme_client -from acme import crypto_util as acme_crypto_util -from acme.jose import util as jose_util - -from letsencrypt import crypto_util -from letsencrypt import errors -from letsencrypt import le_util - -from letsencrypt.display import util as display_util -from letsencrypt.display import revocation - - -logger = logging.getLogger(__name__) - - -class Revoker(object): - """A revocation class for LE. - - .. todo:: Add a method to specify your own certificate for revocation - CLI - - :ivar .acme.client.Client acme: ACME client - - :ivar installer: Installer object - :type installer: :class:`~letsencrypt.interfaces.IInstaller` - - :ivar config: Configuration. - :type config: :class:`~letsencrypt.interfaces.IConfig` - - :ivar bool no_confirm: Whether or not to ask for confirmation for revocation - - """ - def __init__(self, installer, config, no_confirm=False): - # XXX - self.acme = acme_client.Client(directory=None, key=None, alg=None) - - self.installer = installer - self.config = config - self.no_confirm = no_confirm - - le_util.make_or_verify_dir(config.cert_key_backup, 0o700, os.geteuid(), - self.config.strict_permissions) - - # TODO: Find a better solution for this... - self.list_path = os.path.join(config.cert_key_backup, "LIST") - # Make sure that the file is available for use for rest of class - open(self.list_path, "a").close() - - def revoke_from_key(self, authkey): - """Revoke all certificates under an authorized key. - - :param authkey: Authorized key used in previous transactions - :type authkey: :class:`letsencrypt.le_util.Key` - - """ - certs = [] - try: - clean_pem = OpenSSL.crypto.dump_privatekey( - OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.load_privatekey( - OpenSSL.crypto.FILETYPE_PEM, authkey.pem)) - except OpenSSL.crypto.Error as error: - logger.debug(error, exc_info=True) - raise errors.RevokerError( - "Invalid key file specified to revoke_from_key") - - with open(self.list_path, "rb") as csvfile: - csvreader = csv.reader(csvfile) - for row in csvreader: - # idx, cert, key - # Add all keys that match to marked list - # Note: The key can be different than the pub key found in the - # certificate. - _, b_k = self._row_to_backup(row) - try: - test_pem = OpenSSL.crypto.dump_privatekey( - OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.load_privatekey( - OpenSSL.crypto.FILETYPE_PEM, open(b_k).read())) - except OpenSSL.crypto.Error as error: - logger.debug(error, exc_info=True) - # This should never happen given the assumptions of the - # module. If it does, it is probably best to delete the - # the offending key/cert. For now... just raise an exception - raise errors.RevokerError("%s - backup file is corrupted.") - - if clean_pem == test_pem: - certs.append( - Cert.fromrow(row, self.config.cert_key_backup)) - if certs: - self._safe_revoke(certs) - else: - logger.info("No certificates using the authorized key were found.") - - def revoke_from_cert(self, cert_path): - """Revoke a certificate by specifying a file path. - - .. todo:: Add the ability to revoke the certificate even if the cert - is not stored locally. A path to the auth key will need to be - attained from the user. - - :param str cert_path: path to ACME certificate in pem form - - """ - # Locate the correct certificate (do not rely on filename) - cert_to_revoke = Cert(cert_path) - - with open(self.list_path, "rb") as csvfile: - csvreader = csv.reader(csvfile) - for row in csvreader: - cert = Cert.fromrow(row, self.config.cert_key_backup) - - if cert.get_der() == cert_to_revoke.get_der(): - self._safe_revoke([cert]) - return - - logger.info("Associated ACME certificate was not found.") - - def revoke_from_menu(self): - """List trusted Let's Encrypt certificates.""" - - csha1_vhlist = self._get_installed_locations() - certs = self._populate_saved_certs(csha1_vhlist) - - while True: - if certs: - code, selection = revocation.display_certs(certs) - - if code == display_util.OK: - revoked_certs = self._safe_revoke([certs[selection]]) - # Since we are currently only revoking one cert at a time... - if revoked_certs: - del certs[selection] - elif code == display_util.HELP: - revocation.more_info_cert(certs[selection]) - else: - return - else: - logger.info( - "There are not any trusted Let's Encrypt " - "certificates for this server.") - return - - def _populate_saved_certs(self, csha1_vhlist): - # pylint: disable=no-self-use - """Populate a list of all the saved certs. - - It is important to read from the file rather than the directory. - We assume that the LIST file is the master record and depending on - program crashes, this may differ from what is actually in the directory. - Namely, additional certs/keys may exist. There should never be any - certs/keys in the LIST that don't exist in the directory however. - - :param dict csha1_vhlist: map from cert sha1 fingerprints to a list - of it's installed location paths. - - """ - certs = [] - with open(self.list_path, "rb") as csvfile: - csvreader = csv.reader(csvfile) - # idx, orig_cert, orig_key - for row in csvreader: - cert = Cert.fromrow(row, self.config.cert_key_backup) - - # If we were able to find the cert installed... update status - cert.installed = csha1_vhlist.get(cert.get_fingerprint(), []) - - certs.append(cert) - - return certs - - def _get_installed_locations(self): - """Get installed locations of certificates. - - :returns: map from cert sha1 fingerprint to :class:`list` of vhosts - where the certificate is installed. - - """ - csha1_vhlist = {} - - if self.installer is None: - return csha1_vhlist - - for (cert_path, _, path) in self.installer.get_all_certs_keys(): - try: - with open(cert_path) as cert_file: - cert_data = cert_file.read() - except IOError: - continue - try: - cert_obj, _ = crypto_util.pyopenssl_load_certificate(cert_data) - except errors.Error: - continue - cert_sha1 = cert_obj.digest("sha1") - if cert_sha1 in csha1_vhlist: - csha1_vhlist[cert_sha1].append(path) - else: - csha1_vhlist[cert_sha1] = [path] - - return csha1_vhlist - - def _safe_revoke(self, certs): - """Confirm and revoke certificates. - - :param certs: certs intended to be revoked - :type certs: :class:`list` of :class:`letsencrypt.revoker.Cert` - - :returns: certs successfully revoked - :rtype: :class:`list` of :class:`letsencrypt.revoker.Cert` - - """ - success_list = [] - try: - for cert in certs: - if self.no_confirm or revocation.confirm_revocation(cert): - try: - self._acme_revoke(cert) - except errors.Error: - # TODO: Improve error handling when networking is set... - logger.error( - "Unable to revoke cert:%s%s", os.linesep, str(cert)) - success_list.append(cert) - revocation.success_revocation(cert) - finally: - if success_list: - self._remove_certs_keys(success_list) - - return success_list - - def _acme_revoke(self, cert): - """Revoke the certificate with the ACME server. - - :param cert: certificate to revoke - :type cert: :class:`letsencrypt.revoker.Cert` - - :returns: TODO - - """ - # XXX | pylint: disable=unused-variable - - # pylint: disable=protected-access - certificate = jose_util.ComparableX509(cert._cert) - try: - with open(cert.backup_key_path, "rU") as backup_key_file: - key = OpenSSL.crypto.load_privatekey( - OpenSSL.crypto.FILETYPE_PEM, backup_key_file.read()) - # If the key file doesn't exist... or is corrupted - except OpenSSL.crypto.Error as error: - logger.debug(error, exc_info=True) - raise errors.RevokerError( - "Corrupted backup key file: %s" % cert.backup_key_path) - - return self.acme.revoke(cert=None) # XXX - - def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use - """Remove certificate and key. - - :param list cert_list: Must contain certs, each is of type - :class:`letsencrypt.revoker.Cert` - - """ - # This must occur first, LIST is the official key - self._remove_certs_from_list(cert_list) - - # Remove files - for cert in cert_list: - os.remove(cert.backup_path) - os.remove(cert.backup_key_path) - - def _remove_certs_from_list(self, cert_list): # pylint: disable=no-self-use - """Remove a certificate from the LIST file. - - :param list cert_list: Must contain valid certs, each is of type - :class:`letsencrypt.revoker.Cert` - - """ - newfile_handle, list_path2 = tempfile.mkstemp(".tmp", "LIST") - idx = 0 - - with open(self.list_path, "rb") as orgfile: - csvreader = csv.reader(orgfile) - with os.fdopen(newfile_handle, "wb") as newfile: - csvwriter = csv.writer(newfile) - - for row in csvreader: - if idx >= len(cert_list) or row != cert_list[idx].get_row(): - csvwriter.writerow(row) - else: - idx += 1 - - # This should never happen... - if idx != len(cert_list): - raise errors.RevokerError( - "Did not find all cert_list items to remove from LIST") - - shutil.copy2(list_path2, self.list_path) - os.remove(list_path2) - - def _row_to_backup(self, row): - """Convenience function - - :param list row: csv file row 'idx', 'cert_path', 'key_path' - - :returns: tuple of the form ('backup_cert_path', 'backup_key_path') - :rtype: tuple - - """ - return (self._get_backup(self.config.cert_key_backup, row[0], row[1]), - self._get_backup(self.config.cert_key_backup, row[0], row[2])) - - @classmethod - def store_cert_key(cls, cert_path, key_path, config): - """Store certificate key. (Used to allow quick revocation) - - :param str cert_path: Path to a certificate file. - :param str key_path: Path to authorized key for certificate - - :ivar config: Configuration. - :type config: :class:`~letsencrypt.interfaces.IConfig` - - """ - list_path = os.path.join(config.cert_key_backup, "LIST") - le_util.make_or_verify_dir(config.cert_key_backup, 0o700, os.geteuid(), - config.strict_permissions) - - cls._catalog_files( - config.cert_key_backup, cert_path, key_path, list_path) - - @classmethod - def _catalog_files(cls, backup_dir, cert_path, key_path, list_path): - idx = 0 - if os.path.isfile(list_path): - with open(list_path, "r+b") as csvfile: - csvreader = csv.reader(csvfile) - - # Find the highest index in the file - for row in csvreader: - idx = int(row[0]) + 1 - csvwriter = csv.writer(csvfile) - # You must move the files before appending the row - cls._copy_files(backup_dir, idx, cert_path, key_path) - csvwriter.writerow([str(idx), cert_path, key_path]) - - else: - with open(list_path, "wb") as csvfile: - csvwriter = csv.writer(csvfile) - # You must move the files before appending the row - cls._copy_files(backup_dir, idx, cert_path, key_path) - csvwriter.writerow([str(idx), cert_path, key_path]) - - @classmethod - def _copy_files(cls, backup_dir, idx, cert_path, key_path): - """Copies the files into the backup dir appropriately.""" - shutil.copy2(cert_path, cls._get_backup(backup_dir, idx, cert_path)) - shutil.copy2(key_path, cls._get_backup(backup_dir, idx, key_path)) - - @classmethod - def _get_backup(cls, backup_dir, idx, orig_path): - """Returns the path to the backup.""" - return os.path.join( - backup_dir, "{name}_{idx}".format( - name=os.path.basename(orig_path), idx=str(idx))) - - -class Cert(object): - """Cert object used for Revocation convenience. - - :ivar _cert: Certificate - :type _cert: :class:`OpenSSL.crypto.X509` - - :ivar int idx: convenience index used for listing - :ivar orig: (`str` path - original certificate, `str` status) - :type orig: :class:`PathStatus` - :ivar orig_key: (`str` path - original auth key, `str` status) - :type orig_key: :class:`PathStatus` - :ivar str backup_path: backup filepath of the certificate - :ivar str backup_key_path: backup filepath of the authorized key - - :ivar list installed: `list` of `str` describing all locations the cert - is installed - - """ - PathStatus = collections.namedtuple("PathStatus", "path status") - """Convenience container to hold path and status info""" - - DELETED_MSG = "This file has been moved or deleted" - CHANGED_MSG = "This file has changed" - - def __init__(self, cert_path): - """Cert initialization - - :param str cert_filepath: Name of file containing certificate in - PEM format. - - """ - try: - with open(cert_path) as cert_file: - cert_data = cert_file.read() - except IOError: - raise errors.RevokerError( - "Error loading certificate: %s" % cert_path) - - try: - self._cert = OpenSSL.crypto.load_certificate( - OpenSSL.crypto.FILETYPE_PEM, cert_data) - except OpenSSL.crypto.Error: - raise errors.RevokerError( - "Error loading certificate: %s" % cert_path) - - self.idx = -1 - - self.orig = None - self.orig_key = None - self.backup_path = "" - self.backup_key_path = "" - - self.installed = ["Unknown"] - - @classmethod - def fromrow(cls, row, backup_dir): - # pylint: disable=protected-access - """Initialize Cert from a csv row.""" - idx = int(row[0]) - backup = Revoker._get_backup(backup_dir, idx, row[1]) - backup_key = Revoker._get_backup(backup_dir, idx, row[2]) - - obj = cls(backup) - obj.add_meta(idx, row[1], row[2], backup, backup_key) - return obj - - def get_row(self): - """Returns a list in CSV format. If meta data is available.""" - if self.orig is not None and self.orig_key is not None: - return [str(self.idx), self.orig.path, self.orig_key.path] - return None - - def add_meta(self, idx, orig, orig_key, backup, backup_key): - """Add meta data to cert - - :param int idx: convenience index for revoker - :param tuple orig: (`str` original certificate filepath, `str` status) - :param tuple orig_key: (`str` original auth key path, `str` status) - :param str backup: backup certificate filepath - :param str backup_key: backup key filepath - - """ - status = "" - key_status = "" - - # Verify original cert path - if not os.path.isfile(orig): - status = Cert.DELETED_MSG - else: - with open(orig) as orig_file: - orig_data = orig_file.read() - o_cert = OpenSSL.crypto.load_certificate( - OpenSSL.crypto.FILETYPE_PEM, orig_data) - if self.get_fingerprint() != o_cert.digest("sha1"): - status = Cert.CHANGED_MSG - - # Verify original key path - if not os.path.isfile(orig_key): - key_status = Cert.DELETED_MSG - else: - with open(orig_key, "r") as fd: - key_pem = fd.read() - with open(backup_key, "r") as fd: - backup_key_pem = fd.read() - if key_pem != backup_key_pem: - key_status = Cert.CHANGED_MSG - - self.idx = idx - self.orig = Cert.PathStatus(orig, status) - self.orig_key = Cert.PathStatus(orig_key, key_status) - self.backup_path = backup - self.backup_key_path = backup_key - - def get_cn(self): - """Get common name.""" - return self._cert.get_subject().CN - - def get_fingerprint(self): - """Get SHA1 fingerprint.""" - return self._cert.digest("sha1") - - def get_not_before(self): - """Get not_valid_before field.""" - return crypto_util.asn1_generalizedtime_to_dt( - self._cert.get_notBefore()) - - def get_not_after(self): - """Get not_valid_after field.""" - return crypto_util.asn1_generalizedtime_to_dt( - self._cert.get_notAfter()) - - def get_der(self): - """Get certificate in der format.""" - return OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_ASN1, self._cert) - - def get_pub_key(self): - """Get public key size. - - .. todo:: Support for ECC - - """ - return "RSA {0}".format(self._cert.get_pubkey().bits) - - def get_san(self): - """Get subject alternative name if available.""" - # pylint: disable=protected-access - return ", ".join(acme_crypto_util._pyopenssl_cert_or_req_san(self._cert)) - - def __str__(self): - text = [ - "Subject: %s" % crypto_util.pyopenssl_x509_name_as_text( - self._cert.get_subject()), - "SAN: %s" % self.get_san(), - "Issuer: %s" % crypto_util.pyopenssl_x509_name_as_text( - self._cert.get_issuer()), - "Public Key: %s" % self.get_pub_key(), - "Not Before: %s" % str(self.get_not_before()), - "Not After: %s" % str(self.get_not_after()), - "Serial Number: %s" % self._cert.get_serial_number(), - "SHA1: %s%s" % (self.get_fingerprint(), os.linesep), - "Installed: %s" % ", ".join(self.installed), - ] - - if self.orig is not None: - if self.orig.status == "": - text.append("Path: %s" % self.orig.path) - else: - text.append("Orig Path: %s (%s)" % self.orig) - if self.orig_key is not None: - if self.orig_key.status == "": - text.append("Auth Key Path: %s" % self.orig_key.path) - else: - text.append("Orig Auth Key Path: %s (%s)" % self.orig_key) - - text.append("") - return os.linesep.join(text) - - def pretty_print(self): - """Nicely frames a cert str""" - frame = "-" * (display_util.WIDTH - 4) + os.linesep - return "{frame}{cert}{frame}".format(frame=frame, cert=str(self)) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 08dff25a1..8a0f4829e 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -129,7 +129,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self.chain = self.configuration["chain"] self.fullchain = self.configuration["fullchain"] - def consistent(self): + def _consistent(self): """Are the files associated with this lineage self-consistent? :returns: Whether the files stored in connection with this @@ -187,7 +187,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # for x in ALL_FOUR))) == 1 return True - def fix(self): + def _fix(self): """Attempt to fix defects or inconsistencies in this lineage. .. todo:: Currently unimplemented. @@ -347,7 +347,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes smallest_current = min(self.current_version(x) for x in ALL_FOUR) return smallest_current < self.latest_common_version() - def update_link_to(self, kind, version): + def _update_link_to(self, kind, version): """Make the specified item point at the specified version. (Note that this method doesn't verify that the specified version @@ -379,7 +379,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :param int version: the desired version""" for kind in ALL_FOUR: - self.update_link_to(kind, version) + self._update_link_to(kind, version) def _notafterbefore(self, method, version): """Internal helper function for finding notbefore/notafter.""" @@ -439,6 +439,18 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes with open(target) as f: return crypto_util.get_sans_from_cert(f.read()) + def autodeployment_is_enabled(self): + """Is automatic deployment enabled for this cert? + + If autodeploy is not specified, defaults to True. + + :returns: True if automatic deployment is enabled + :rtype: bool + + """ + return ("autodeploy" not in self.configuration or + self.configuration.as_bool("autodeploy")) + def should_autodeploy(self): """Should this lineage now automatically deploy a newer version? @@ -453,8 +465,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :rtype: bool """ - if ("autodeploy" not in self.configuration or - self.configuration.as_bool("autodeploy")): + if self.autodeployment_is_enabled(): if self.has_pending_deployment(): interval = self.configuration.get("deploy_before_expiry", "5 days") @@ -488,6 +499,18 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # certificate is not revoked). return False + def autorenewal_is_enabled(self): + """Is automatic renewal enabled for this cert? + + If autorenew is not specified, defaults to True. + + :returns: True if automatic renewal is enabled + :rtype: bool + + """ + return ("autorenew" not in self.configuration or + self.configuration.as_bool("autorenew")) + def should_autorenew(self): """Should we now try to autorenew the most recent cert version? @@ -504,8 +527,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :rtype: bool """ - if ("autorenew" not in self.configuration or - self.configuration.as_bool("autorenew")): + if self.autorenewal_is_enabled(): # Consider whether to attempt to autorenew this cert now # Renewals on the basis of revocation @@ -520,7 +542,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes remaining = expiry - now if remaining < autorenew_interval: return True - return False + return False @classmethod def new_lineage(cls, lineagename, cert, privkey, chain, diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index ed29ead25..18ee56081 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -355,7 +355,7 @@ class GenChallengePathTest(unittest.TestCase): class MutuallyExclusiveTest(unittest.TestCase): """Tests for letsencrypt.auth_handler.mutually_exclusive.""" - # pylint: disable=invalid-name,missing-docstring,too-few-public-methods + # pylint: disable=missing-docstring,too-few-public-methods class A(object): pass diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 2da1272fc..d0fae370d 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -2,6 +2,7 @@ import itertools import os import shutil +import StringIO import traceback import tempfile import unittest @@ -16,6 +17,9 @@ from letsencrypt.tests import renewer_test from letsencrypt.tests import test_util +CSR = test_util.vector_path('csr.der') + + class CLITest(unittest.TestCase): """Tests for different commands.""" @@ -39,12 +43,52 @@ class CLITest(unittest.TestCase): ret = cli.main(args) return ret, stdout, stderr, client + def _call_stdout(self, args): + """ + Variant of _call that preserves stdout so that it can be mocked by the + caller. + """ + from letsencrypt import cli + args = ['--text', '--config-dir', self.config_dir, + '--work-dir', self.work_dir, '--logs-dir', self.logs_dir, + '--agree-eula'] + args + with mock.patch('letsencrypt.cli.sys.stderr') as stderr: + with mock.patch('letsencrypt.cli.client') as client: + ret = cli.main(args) + return ret, None, stderr, client + def test_no_flags(self): - self.assertRaises(SystemExit, self._call, []) + with mock.patch('letsencrypt.cli.run') as mock_run: + self._call([]) + self.assertEqual(1, mock_run.call_count) def test_help(self): self.assertRaises(SystemExit, self._call, ['--help']) - self.assertRaises(SystemExit, self._call, ['--help all']) + self.assertRaises(SystemExit, self._call, ['--help', 'all']) + output = StringIO.StringIO() + with mock.patch('letsencrypt.cli.sys.stdout', new=output): + self.assertRaises(SystemExit, self._call_stdout, ['--help', 'all']) + out = output.getvalue() + self.assertTrue("--configurator" in out) + self.assertTrue("how a cert is deployed" in out) + self.assertTrue("--manual-test-mode" in out) + output.truncate(0) + self.assertRaises(SystemExit, self._call_stdout, ['-h', 'nginx']) + out = output.getvalue() + self.assertTrue("--nginx-ctl" in out) + self.assertTrue("--manual-test-mode" not in out) + self.assertTrue("--checkpoints" not in out) + output.truncate(0) + self.assertRaises(SystemExit, self._call_stdout, ['--help', 'plugins']) + out = output.getvalue() + self.assertTrue("--manual-test-mode" not in out) + self.assertTrue("--prepare" in out) + self.assertTrue("Plugin options" in out) + output.truncate(0) + self.assertRaises(SystemExit, self._call_stdout, ['-h']) + out = output.getvalue() + from letsencrypt import cli + self.assertTrue(cli.USAGE in out) def test_rollback(self): _, _, _, client = self._call(['rollback']) @@ -65,40 +109,114 @@ class CLITest(unittest.TestCase): for r in xrange(len(flags)))): self._call(['plugins'] + list(args)) - @mock.patch("letsencrypt.cli.sys") + def test_auth_bad_args(self): + ret, _, _, _ = self._call(['-d', 'foo.bar', 'auth', '--csr', CSR]) + self.assertEqual(ret, '--domains and --csr are mutually exclusive') + + ret, _, _, _ = self._call(['-a', 'bad_auth', 'auth']) + self.assertEqual(ret, 'Authenticator could not be determined') + + @mock.patch('letsencrypt.cli.zope.component.getUtility') + def test_auth_new_request_success(self, mock_get_utility): + cert_path = '/etc/letsencrypt/live/foo.bar' + mock_lineage = mock.MagicMock(cert=cert_path) + mock_client = mock.MagicMock() + mock_client.obtain_and_enroll_certificate.return_value = mock_lineage + self._auth_new_request_common(mock_client) + self.assertEqual( + mock_client.obtain_and_enroll_certificate.call_count, 1) + self.assertTrue( + cert_path in mock_get_utility().add_message.call_args[0][0]) + + def test_auth_new_request_failure(self): + mock_client = mock.MagicMock() + mock_client.obtain_and_enroll_certificate.return_value = False + self.assertRaises(errors.Error, + self._auth_new_request_common, mock_client) + + def _auth_new_request_common(self, mock_client): + with mock.patch('letsencrypt.cli._treat_as_renewal') as mock_renewal: + mock_renewal.return_value = None + with mock.patch('letsencrypt.cli._init_le_client') as mock_init: + mock_init.return_value = mock_client + self._call(['-d', 'foo.bar', '-a', 'standalone', 'auth']) + + @mock.patch('letsencrypt.cli.zope.component.getUtility') + @mock.patch('letsencrypt.cli._treat_as_renewal') + @mock.patch('letsencrypt.cli._init_le_client') + def test_auth_renewal(self, mock_init, mock_renewal, mock_get_utility): + cert_path = '/etc/letsencrypt/live/foo.bar' + mock_lineage = mock.MagicMock(cert=cert_path) + mock_cert = mock.MagicMock(body='body') + mock_key = mock.MagicMock(pem='pem_key') + mock_renewal.return_value = mock_lineage + mock_client = mock.MagicMock() + mock_client.obtain_certificate.return_value = (mock_cert, 'chain', + mock_key, 'csr') + mock_init.return_value = mock_client + with mock.patch('letsencrypt.cli.OpenSSL'): + with mock.patch('letsencrypt.cli.crypto_util'): + self._call(['-d', 'foo.bar', '-a', 'standalone', 'auth']) + mock_client.obtain_certificate.assert_called_once_with(['foo.bar']) + self.assertEqual(mock_lineage.save_successor.call_count, 1) + mock_lineage.update_all_links_to.assert_called_once_with( + mock_lineage.latest_common_version()) + self.assertTrue( + cert_path in mock_get_utility().add_message.call_args[0][0]) + + @mock.patch('letsencrypt.cli.display_ops.pick_installer') + @mock.patch('letsencrypt.cli.zope.component.getUtility') + @mock.patch('letsencrypt.cli._init_le_client') + def test_auth_csr(self, mock_init, mock_get_utility, mock_pick_installer): + cert_path = '/etc/letsencrypt/live/foo.bar' + mock_client = mock.MagicMock() + mock_client.obtain_certificate_from_csr.return_value = ('certr', + 'chain') + mock_init.return_value = mock_client + installer = 'installer' + self._call( + ['-a', 'standalone', '-i', installer, 'auth', '--csr', CSR, + '--cert-path', cert_path, '--chain-path', '/']) + self.assertEqual(mock_pick_installer.call_args[0][1], installer) + mock_client.save_certificate.assert_called_once_with( + 'certr', 'chain', cert_path, '/') + self.assertTrue( + cert_path in mock_get_utility().add_message.call_args[0][0]) + + @mock.patch('letsencrypt.cli.sys') def test_handle_exception(self, mock_sys): # pylint: disable=protected-access from letsencrypt import cli mock_open = mock.mock_open() - with mock.patch("letsencrypt.cli.open", mock_open, create=True): - exception = Exception("detail") + with mock.patch('letsencrypt.cli.open', mock_open, create=True): + exception = Exception('detail') cli._handle_exception( Exception, exc_value=exception, trace=None, args=None) - mock_open().write.assert_called_once_with("".join( + mock_open().write.assert_called_once_with(''.join( traceback.format_exception_only(Exception, exception))) error_msg = mock_sys.exit.call_args_list[0][0][0] - self.assertTrue("unexpected error" in error_msg) + self.assertTrue('unexpected error' in error_msg) - with mock.patch("letsencrypt.cli.open", mock_open, create=True): + with mock.patch('letsencrypt.cli.open', mock_open, create=True): mock_open.side_effect = [KeyboardInterrupt] - error = errors.Error("detail") + error = errors.Error('detail') cli._handle_exception( errors.Error, exc_value=error, trace=None, args=None) # assert_any_call used because sys.exit doesn't exit in cli.py - mock_sys.exit.assert_any_call("".join( + mock_sys.exit.assert_any_call(''.join( traceback.format_exception_only(errors.Error, error))) args = mock.MagicMock(debug=False) cli._handle_exception( - Exception, exc_value=Exception("detail"), trace=None, args=args) + Exception, exc_value=Exception('detail'), trace=None, args=args) error_msg = mock_sys.exit.call_args_list[-1][0][0] - self.assertTrue("unexpected error" in error_msg) + self.assertTrue('unexpected error' in error_msg) - interrupt = KeyboardInterrupt("detail") + interrupt = KeyboardInterrupt('detail') cli._handle_exception( KeyboardInterrupt, exc_value=interrupt, trace=None, args=None) - mock_sys.exit.assert_called_with("".join( + mock_sys.exit.assert_called_with(''.join( traceback.format_exception_only(KeyboardInterrupt, interrupt))) @@ -108,13 +226,13 @@ class DetermineAccountTest(unittest.TestCase): def setUp(self): self.args = mock.MagicMock(account=None, email=None) self.config = configuration.NamespaceConfig(self.args) - self.accs = [mock.MagicMock(id="x"), mock.MagicMock(id="y")] + self.accs = [mock.MagicMock(id='x'), mock.MagicMock(id='y')] self.account_storage = account.AccountMemoryStorage() def _call(self): # pylint: disable=protected-access from letsencrypt.cli import _determine_account - with mock.patch("letsencrypt.cli.account.AccountFileStorage") as mock_storage: + with mock.patch('letsencrypt.cli.account.AccountFileStorage') as mock_storage: mock_storage.return_value = self.account_storage return _determine_account(self.args, self.config) @@ -131,7 +249,7 @@ class DetermineAccountTest(unittest.TestCase): self.assertEqual(self.accs[0].id, self.args.account) self.assertTrue(self.args.email is None) - @mock.patch("letsencrypt.client.display_ops.choose_account") + @mock.patch('letsencrypt.client.display_ops.choose_account') def test_multiple_accounts(self, mock_choose_accounts): for acc in self.accs: self.account_storage.save(acc) @@ -142,11 +260,11 @@ class DetermineAccountTest(unittest.TestCase): self.assertEqual(self.accs[1].id, self.args.account) self.assertTrue(self.args.email is None) - @mock.patch("letsencrypt.client.display_ops.get_email") + @mock.patch('letsencrypt.client.display_ops.get_email') def test_no_accounts_no_email(self, mock_get_email): - mock_get_email.return_value = "foo@bar.baz" + mock_get_email.return_value = 'foo@bar.baz' - with mock.patch("letsencrypt.cli.client") as client: + with mock.patch('letsencrypt.cli.client') as client: client.register.return_value = ( self.accs[0], mock.sentinel.acme) self.assertEqual((self.accs[0], mock.sentinel.acme), self._call()) @@ -154,15 +272,15 @@ class DetermineAccountTest(unittest.TestCase): self.config, self.account_storage, tos_cb=mock.ANY) self.assertEqual(self.accs[0].id, self.args.account) - self.assertEqual("foo@bar.baz", self.args.email) + self.assertEqual('foo@bar.baz', self.args.email) def test_no_accounts_email(self): - self.args.email = "other email" - with mock.patch("letsencrypt.cli.client") as client: + self.args.email = 'other email' + with mock.patch('letsencrypt.cli.client') as client: client.register.return_value = (self.accs[1], mock.sentinel.acme) self._call() self.assertEqual(self.accs[1].id, self.args.account) - self.assertEqual("other email", self.args.email) + self.assertEqual('other email', self.args.email) class DuplicativeCertsTest(renewer_test.BaseRenewableCertTest): @@ -176,32 +294,33 @@ class DuplicativeCertsTest(renewer_test.BaseRenewableCertTest): def tearDown(self): shutil.rmtree(self.tempdir) - def test_find_duplicative_names(self): + @mock.patch('letsencrypt.le_util.make_or_verify_dir') + def test_find_duplicative_names(self, unused_makedir): from letsencrypt.cli import _find_duplicative_certs - test_cert = test_util.load_vector("cert-san.pem") - with open(self.test_rc.cert, "w") as f: + test_cert = test_util.load_vector('cert-san.pem') + with open(self.test_rc.cert, 'w') as f: f.write(test_cert) # No overlap at all - result = _find_duplicative_certs(["wow.net", "hooray.org"], + result = _find_duplicative_certs(['wow.net', 'hooray.org'], self.config, self.cli_config) self.assertEqual(result, (None, None)) # Totally identical - result = _find_duplicative_certs(["example.com", "www.example.com"], + result = _find_duplicative_certs(['example.com', 'www.example.com'], self.config, self.cli_config) - self.assertTrue(result[0].configfile.filename.endswith("example.org.conf")) + self.assertTrue(result[0].configfile.filename.endswith('example.org.conf')) self.assertEqual(result[1], None) # Superset - result = _find_duplicative_certs(["example.com", "www.example.com", - "something.new"], self.config, + result = _find_duplicative_certs(['example.com', 'www.example.com', + 'something.new'], self.config, self.cli_config) self.assertEqual(result[0], None) - self.assertTrue(result[1].configfile.filename.endswith("example.org.conf")) + self.assertTrue(result[1].configfile.filename.endswith('example.org.conf')) # Partial overlap doesn't count - result = _find_duplicative_certs(["example.com", "something.new"], + result = _find_duplicative_certs(['example.com', 'something.new'], self.config, self.cli_config) self.assertEqual(result, (None, None)) diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 93fdf2cd3..1e63bdbb6 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -4,14 +4,12 @@ import shutil import tempfile import unittest -import configobj import OpenSSL import mock from acme import jose from letsencrypt import account -from letsencrypt import configuration from letsencrypt import errors from letsencrypt import le_util @@ -113,36 +111,35 @@ class ClientTest(unittest.TestCase): mock_crypto_util.init_save_key.assert_called_once_with( self.config.rsa_key_size, self.config.key_dir) mock_crypto_util.init_save_csr.assert_called_once_with( - mock.sentinel.key, domains, self.config.cert_dir) + mock.sentinel.key, domains, self.config.csr_dir) self._check_obtain_certificate() @mock.patch("letsencrypt.client.zope.component.getUtility") def test_report_renewal_status(self, mock_zope): # pylint: disable=protected-access cert = mock.MagicMock() - cert.configuration = configobj.ConfigObj() - cert.cli_config = configuration.RenewerConfiguration(self.config) + cert.cli_config.renewal_configs_dir = "/foo/bar/baz" - cert.configuration["autorenew"] = "True" - cert.configuration["autodeploy"] = "True" + cert.autorenewal_is_enabled.return_value = True + cert.autodeployment_is_enabled.return_value = True self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal and deployment has been" in msg) self.assertTrue(cert.cli_config.renewal_configs_dir in msg) - cert.configuration["autorenew"] = "False" + cert.autorenewal_is_enabled.return_value = False self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("deployment but not automatic renewal" in msg) self.assertTrue(cert.cli_config.renewal_configs_dir in msg) - cert.configuration["autodeploy"] = "False" + cert.autodeployment_is_enabled.return_value = False self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal and deployment has not" in msg) self.assertTrue(cert.cli_config.renewal_configs_dir in msg) - cert.configuration["autorenew"] = "True" + cert.autorenewal_is_enabled.return_value = True self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal but not automatic deployment" in msg) @@ -178,6 +175,39 @@ class ClientTest(unittest.TestCase): shutil.rmtree(tmp_path) + def test_deploy_certificate(self): + self.assertRaises(errors.Error, self.client.deploy_certificate, + ["foo.bar"], "key", "cert", "chain") + + installer = mock.MagicMock() + self.client.installer = installer + + self.client.deploy_certificate(["foo.bar"], "key", "cert", "chain") + installer.deploy_cert.assert_called_once_with( + "foo.bar", os.path.abspath("cert"), + os.path.abspath("key"), os.path.abspath("chain")) + self.assertEqual(installer.save.call_count, 1) + installer.restart.assert_called_once_with() + + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config(self, mock_enhancements): + self.assertRaises(errors.Error, + self.client.enhance_config, ["foo.bar"]) + + mock_enhancements.ask.return_value = True + installer = mock.MagicMock() + self.client.installer = installer + + self.client.enhance_config(["foo.bar"]) + installer.enhance.assert_called_once_with("foo.bar", "redirect") + self.assertEqual(installer.save.call_count, 1) + installer.restart.assert_called_once_with() + + installer.enhance.side_effect = errors.PluginError + self.assertRaises(errors.PluginError, + self.client.enhance_config, ["foo.bar"], True) + installer.recovery_routine.assert_called_once_with() + class RollbackTest(unittest.TestCase): """Tests for letsencrypt.client.rollback.""" diff --git a/letsencrypt/tests/colored_logging_test.py b/letsencrypt/tests/colored_logging_test.py new file mode 100644 index 000000000..5b49ec820 --- /dev/null +++ b/letsencrypt/tests/colored_logging_test.py @@ -0,0 +1,40 @@ +"""Tests for letsencrypt.colored_logging.""" +import logging +import StringIO +import unittest + +from letsencrypt import le_util + + +class StreamHandlerTest(unittest.TestCase): + """Tests for letsencrypt.colored_logging.""" + + def setUp(self): + from letsencrypt import colored_logging + + self.stream = StringIO.StringIO() + self.stream.isatty = lambda: True + self.handler = colored_logging.StreamHandler(self.stream) + + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG) + self.logger.addHandler(self.handler) + + def test_format(self): + msg = 'I did a thing' + self.logger.debug(msg) + self.assertEqual(self.stream.getvalue(), '{0}\n'.format(msg)) + + def test_format_and_red_level(self): + msg = 'I did another thing' + self.handler.red_level = logging.DEBUG + self.logger.debug(msg) + + self.assertEqual(self.stream.getvalue(), + '{0}{1}{2}\n'.format(le_util.ANSI_SGR_RED, + msg, + le_util.ANSI_SGR_RESET)) + + +if __name__ == "__main__": + unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py index 498147c6d..948cd20aa 100644 --- a/letsencrypt/tests/configuration_test.py +++ b/letsencrypt/tests/configuration_test.py @@ -32,8 +32,8 @@ class NamespaceConfigTest(unittest.TestCase): def test_dynamic_dirs(self, constants): constants.ACCOUNTS_DIR = 'acc' constants.BACKUP_DIR = 'backups' - constants.CERT_KEY_BACKUP_DIR = 'c/' - constants.CERT_DIR = 'certs' + constants.CSR_DIR = 'csr' + constants.IN_PROGRESS_DIR = '../p' constants.KEY_DIR = 'keys' constants.TEMP_CHECKPOINT_DIR = 't' @@ -41,9 +41,7 @@ class NamespaceConfigTest(unittest.TestCase): self.assertEqual( self.config.accounts_dir, '/tmp/config/acc/acme-server.org:443/new') self.assertEqual(self.config.backup_dir, '/tmp/foo/backups') - self.assertEqual(self.config.cert_dir, '/tmp/config/certs') - self.assertEqual( - self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new') + self.assertEqual(self.config.csr_dir, '/tmp/config/csr') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual(self.config.key_dir, '/tmp/config/keys') self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') @@ -59,9 +57,9 @@ class RenewerConfigurationTest(unittest.TestCase): @mock.patch('letsencrypt.configuration.constants') def test_dynamic_dirs(self, constants): - constants.ARCHIVE_DIR = "a" + constants.ARCHIVE_DIR = 'a' constants.LIVE_DIR = 'l' - constants.RENEWAL_CONFIGS_DIR = "renewal_configs" + constants.RENEWAL_CONFIGS_DIR = 'renewal_configs' constants.RENEWER_CONFIG_FILENAME = 'r.conf' self.assertEqual(self.config.archive_dir, '/tmp/config/a') diff --git a/letsencrypt/tests/crypto_util_test.py b/letsencrypt/tests/crypto_util_test.py index b248ffd8a..2e04c748a 100644 --- a/letsencrypt/tests/crypto_util_test.py +++ b/letsencrypt/tests/crypto_util_test.py @@ -6,7 +6,10 @@ import unittest import OpenSSL import mock +import zope.component +from letsencrypt import errors +from letsencrypt import interfaces from letsencrypt.tests import test_util @@ -20,6 +23,8 @@ class InitSaveKeyTest(unittest.TestCase): """Tests for letsencrypt.crypto_util.init_save_key.""" def setUp(self): logging.disable(logging.CRITICAL) + zope.component.provideUtility( + mock.Mock(strict_permissions=True), interfaces.IConfig) self.key_dir = tempfile.mkdtemp('key_dir') def tearDown(self): @@ -48,6 +53,8 @@ class InitSaveCSRTest(unittest.TestCase): """Tests for letsencrypt.crypto_util.init_save_csr.""" def setUp(self): + zope.component.provideUtility( + mock.Mock(strict_permissions=True), interfaces.IConfig) self.csr_dir = tempfile.mkdtemp('csr_dir') def tearDown(self): @@ -207,5 +214,23 @@ class GetSANsFromCSRTest(unittest.TestCase): [], self._call(test_util.load_vector('csr-nosans.pem'))) +class CertLoaderTest(unittest.TestCase): + """Tests for letsencrypt.crypto_util.pyopenssl_load_certificate""" + + def test_load_valid_cert(self): + from letsencrypt.crypto_util import pyopenssl_load_certificate + + cert, file_type = pyopenssl_load_certificate(CERT) + self.assertEqual(cert.digest('sha1'), + OpenSSL.crypto.load_certificate(file_type, CERT).digest('sha1')) + + def test_load_invalid_cert(self): + from letsencrypt.crypto_util import pyopenssl_load_certificate + bad_cert_data = CERT.replace("BEGIN CERTIFICATE", "ASDFASDFASDF!!!") + + with self.assertRaises(errors.Error): + pyopenssl_load_certificate(bad_cert_data) + + if __name__ == '__main__': unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index 7420a62f0..9d4a3a933 100644 --- a/letsencrypt/tests/display/ops_test.py +++ b/letsencrypt/tests/display/ops_test.py @@ -84,7 +84,7 @@ class PickPluginTest(unittest.TestCase): def test_no_default(self): self._call() - self.assertEqual(1, self.reg.ifaces.call_count) + self.assertEqual(1, self.reg.visible().ifaces.call_count) def test_no_candidate(self): self.assertTrue(self._call() is None) @@ -94,7 +94,8 @@ class PickPluginTest(unittest.TestCase): plugin_ep.init.return_value = "foo" plugin_ep.misconfigured = False - self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep} + self.reg.visible().ifaces().verify().available.return_value = { + "bar": plugin_ep} self.assertEqual("foo", self._call()) def test_single_misconfigured(self): @@ -102,13 +103,14 @@ class PickPluginTest(unittest.TestCase): plugin_ep.init.return_value = "foo" plugin_ep.misconfigured = True - self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep} + self.reg.visible().ifaces().verify().available.return_value = { + "bar": plugin_ep} self.assertTrue(self._call() is None) def test_multiple(self): plugin_ep = mock.MagicMock() plugin_ep.init.return_value = "foo" - self.reg.ifaces().verify().available.return_value = { + self.reg.visible().ifaces().verify().available.return_value = { "bar": plugin_ep, "baz": plugin_ep, } @@ -119,7 +121,7 @@ class PickPluginTest(unittest.TestCase): [plugin_ep, plugin_ep], self.question) def test_choose_plugin_none(self): - self.reg.ifaces().verify().available.return_value = { + self.reg.visible().ifaces().verify().available.return_value = { "bar": None, "baz": None, } diff --git a/letsencrypt/tests/display/revocation_test.py b/letsencrypt/tests/display/revocation_test.py deleted file mode 100644 index 6e9763006..000000000 --- a/letsencrypt/tests/display/revocation_test.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Test :mod:`letsencrypt.display.revocation`.""" -import sys -import unittest - -import mock -import zope.component - -from letsencrypt.display import util as display_util - -from letsencrypt.tests import test_util - - -class DisplayCertsTest(unittest.TestCase): - def setUp(self): - from letsencrypt.revoker import Cert - self.cert0 = Cert(test_util.vector_path("cert.pem")) - self.cert1 = Cert(test_util.vector_path("cert-san.pem")) - - self.certs = [self.cert0, self.cert1] - - zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) - - @classmethod - def _call(cls, certs): - from letsencrypt.display.revocation import display_certs - return display_certs(certs) - - @mock.patch("letsencrypt.display.revocation.util") - def test_revocation(self, mock_util): - mock_util().menu.return_value = (display_util.OK, 0) - - code, choice = self._call(self.certs) - - self.assertEqual(display_util.OK, code) - self.assertEqual(self.certs[choice], self.cert0) - - @mock.patch("letsencrypt.display.revocation.util") - def test_cancel(self, mock_util): - mock_util().menu.return_value = (display_util.CANCEL, -1) - - code, _ = self._call(self.certs) - self.assertEqual(display_util.CANCEL, code) - - -class MoreInfoCertTest(unittest.TestCase): - # pylint: disable=too-few-public-methods - @classmethod - def _call(cls, cert): - from letsencrypt.display.revocation import more_info_cert - more_info_cert(cert) - - @mock.patch("letsencrypt.display.revocation.util") - def test_more_info(self, mock_util): - self._call(mock.MagicMock()) - - self.assertEqual(mock_util().notification.call_count, 1) - - -class SuccessRevocationTest(unittest.TestCase): - def setUp(self): - from letsencrypt.revoker import Cert - self.cert = Cert(test_util.vector_path("cert.pem")) - - @classmethod - def _call(cls, cert): - from letsencrypt.display.revocation import success_revocation - success_revocation(cert) - - # Pretty trivial test... something is displayed... - @mock.patch("letsencrypt.display.revocation.util") - def test_success_revocation(self, mock_util): - self._call(self.cert) - - self.assertEqual(mock_util().notification.call_count, 1) - - -class ConfirmRevocationTest(unittest.TestCase): - def setUp(self): - from letsencrypt.revoker import Cert - self.cert = Cert(test_util.vector_path("cert.pem")) - - @classmethod - def _call(cls, cert): - from letsencrypt.display.revocation import confirm_revocation - return confirm_revocation(cert) - - @mock.patch("letsencrypt.display.revocation.util") - def test_confirm_revocation(self, mock_util): - mock_util().yesno.return_value = True - self.assertTrue(self._call(self.cert)) - - mock_util().yesno.return_value = False - self.assertFalse(self._call(self.cert)) - - -if __name__ == "__main__": - unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/error_handler_test.py b/letsencrypt/tests/error_handler_test.py new file mode 100644 index 000000000..c92f12435 --- /dev/null +++ b/letsencrypt/tests/error_handler_test.py @@ -0,0 +1,64 @@ +"""Tests for letsencrypt.error_handler.""" +import signal +import sys +import unittest + +import mock + + +class ErrorHandlerTest(unittest.TestCase): + """Tests for letsencrypt.error_handler.""" + + def setUp(self): + from letsencrypt import error_handler + + self.init_func = mock.MagicMock() + self.handler = error_handler.ErrorHandler(self.init_func) + # pylint: disable=protected-access + self.signals = error_handler._SIGNALS + + def test_context_manager(self): + try: + with self.handler: + raise ValueError + except ValueError: + pass + self.init_func.assert_called_once_with() + + @mock.patch('letsencrypt.error_handler.os') + @mock.patch('letsencrypt.error_handler.signal') + def test_signal_handler(self, mock_signal, mock_os): + # pylint: disable=protected-access + mock_signal.getsignal.return_value = signal.SIG_DFL + self.handler.set_signal_handlers() + signal_handler = self.handler._signal_handler + for signum in self.signals: + mock_signal.signal.assert_any_call(signum, signal_handler) + + signum = self.signals[0] + signal_handler(signum, None) + self.init_func.assert_called_once_with() + mock_os.kill.assert_called_once_with(mock_os.getpid(), signum) + + self.handler.reset_signal_handlers() + for signum in self.signals: + mock_signal.signal.assert_any_call(signum, signal.SIG_DFL) + + def test_bad_recovery(self): + bad_func = mock.MagicMock(side_effect=[ValueError]) + self.handler.register(bad_func) + self.handler.call_registered() + self.init_func.assert_called_once_with() + bad_func.assert_called_once_with() + + def test_sysexit_ignored(self): + try: + with self.handler: + sys.exit(0) + except SystemExit: + pass + self.assertFalse(self.init_func.called) + + +if __name__ == "__main__": + unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/log_test.py b/letsencrypt/tests/log_test.py index 50d0712e7..c1afd2c8a 100644 --- a/letsencrypt/tests/log_test.py +++ b/letsencrypt/tests/log_test.py @@ -8,7 +8,7 @@ import mock class DialogHandlerTest(unittest.TestCase): def setUp(self): - self.d = mock.MagicMock() # pylint: disable=invalid-name + self.d = mock.MagicMock() from letsencrypt.log import DialogHandler self.handler = DialogHandler(height=2, width=6, d=self.d) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 293b09537..6f115abf9 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -33,7 +33,12 @@ def fill_with_sample_data(rc_object): class BaseRenewableCertTest(unittest.TestCase): + """Base class for setting up Renewable Cert tests. + .. note:: It may be required to write out self.config for + your test. Check :class:`.cli_test.DuplicateCertTest` for an example. + + """ def setUp(self): from letsencrypt import storage self.tempdir = tempfile.mkdtemp() @@ -118,46 +123,47 @@ class RenewableCertTests(BaseRenewableCertTest): self.assertRaises( errors.CertStorageError, storage.RenewableCert, config, defaults) - def test_consistent(self): # pylint: disable=too-many-statements + def test_consistent(self): + # pylint: disable=too-many-statements,protected-access oldcert = self.test_rc.cert self.test_rc.cert = "relative/path" # Absolute path for item requirement - self.assertFalse(self.test_rc.consistent()) + self.assertFalse(self.test_rc._consistent()) self.test_rc.cert = oldcert # Items must exist requirement - self.assertFalse(self.test_rc.consistent()) + self.assertFalse(self.test_rc._consistent()) # Items must be symlinks requirements fill_with_sample_data(self.test_rc) - self.assertFalse(self.test_rc.consistent()) + self.assertFalse(self.test_rc._consistent()) unlink_all(self.test_rc) # Items must point to desired place if they are relative for kind in ALL_FOUR: os.symlink(os.path.join("..", kind + "17.pem"), getattr(self.test_rc, kind)) - self.assertFalse(self.test_rc.consistent()) + self.assertFalse(self.test_rc._consistent()) unlink_all(self.test_rc) # Items must point to desired place if they are absolute for kind in ALL_FOUR: os.symlink(os.path.join(self.tempdir, kind + "17.pem"), getattr(self.test_rc, kind)) - self.assertFalse(self.test_rc.consistent()) + self.assertFalse(self.test_rc._consistent()) unlink_all(self.test_rc) # Items must point to things that exist for kind in ALL_FOUR: os.symlink(os.path.join("..", "..", "archive", "example.org", kind + "17.pem"), getattr(self.test_rc, kind)) - self.assertFalse(self.test_rc.consistent()) + self.assertFalse(self.test_rc._consistent()) # This version should work fill_with_sample_data(self.test_rc) - self.assertTrue(self.test_rc.consistent()) + self.assertTrue(self.test_rc._consistent()) # Items must point to things that follow the naming convention os.unlink(self.test_rc.fullchain) os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain_17.pem"), self.test_rc.fullchain) with open(self.test_rc.fullchain, "w") as f: f.write("wrongly-named fullchain") - self.assertFalse(self.test_rc.consistent()) + self.assertFalse(self.test_rc._consistent()) def test_current_target(self): # Relative path logic @@ -254,14 +260,15 @@ class RenewableCertTests(BaseRenewableCertTest): with open(where, "w") as f: f.write(kind) self.assertEqual(ver, self.test_rc.current_version(kind)) - self.test_rc.update_link_to("cert", 3) - self.test_rc.update_link_to("privkey", 2) + # pylint: disable=protected-access + self.test_rc._update_link_to("cert", 3) + self.test_rc._update_link_to("privkey", 2) self.assertEqual(3, self.test_rc.current_version("cert")) self.assertEqual(2, self.test_rc.current_version("privkey")) self.assertEqual(5, self.test_rc.current_version("chain")) self.assertEqual(5, self.test_rc.current_version("fullchain")) # Currently we are allowed to update to a version that doesn't exist - self.test_rc.update_link_to("chain", 3000) + self.test_rc._update_link_to("chain", 3000) # However, current_version doesn't allow querying the resulting # version (because it's a broken link). self.assertEqual(os.path.basename(os.readlink(self.test_rc.chain)), @@ -400,6 +407,14 @@ class RenewableCertTests(BaseRenewableCertTest): self.assertEqual(self.test_rc.should_autodeploy(), result) self.assertEqual(self.test_rc.should_autorenew(), result) + def test_autodeployment_is_enabled(self): + self.assertTrue(self.test_rc.autodeployment_is_enabled()) + self.test_rc.configuration["autodeploy"] = "1" + self.assertTrue(self.test_rc.autodeployment_is_enabled()) + + self.test_rc.configuration["autodeploy"] = "0" + self.assertFalse(self.test_rc.autodeployment_is_enabled()) + def test_should_autodeploy(self): """Test should_autodeploy() on the basis of reasons other than expiry time window.""" @@ -420,6 +435,14 @@ class RenewableCertTests(BaseRenewableCertTest): f.write(kind) self.assertFalse(self.test_rc.should_autodeploy()) + def test_autorenewal_is_enabled(self): + self.assertTrue(self.test_rc.autorenewal_is_enabled()) + self.test_rc.configuration["autorenew"] = "1" + self.assertTrue(self.test_rc.autorenewal_is_enabled()) + + self.test_rc.configuration["autorenew"] = "0" + self.assertFalse(self.test_rc.autorenewal_is_enabled()) + @mock.patch("letsencrypt.storage.RenewableCert.ocsp_revoked") def test_should_autorenew(self, mock_ocsp): """Test should_autorenew on the basis of reasons other than @@ -502,7 +525,8 @@ class RenewableCertTests(BaseRenewableCertTest): self.defaults, self.cli_config) # This consistency check tests most relevant properties about the # newly created cert lineage. - self.assertTrue(result.consistent()) + # pylint: disable=protected-access + self.assertTrue(result._consistent()) self.assertTrue(os.path.exists(os.path.join( self.cli_config.renewal_configs_dir, "the-lineage.com.conf"))) with open(result.fullchain) as f: @@ -573,9 +597,10 @@ class RenewableCertTests(BaseRenewableCertTest): self.assertRaises( errors.CertStorageError, self.test_rc.newest_available_version, "elephant") + # pylint: disable=protected-access self.assertRaises( errors.CertStorageError, - self.test_rc.update_link_to, "elephant", 17) + self.test_rc._update_link_to, "elephant", 17) def test_ocsp_revoked(self): # XXX: This is currently hardcoded to False due to a lack of an diff --git a/letsencrypt/tests/reporter_test.py b/letsencrypt/tests/reporter_test.py index c43511208..c848b1cab 100644 --- a/letsencrypt/tests/reporter_test.py +++ b/letsencrypt/tests/reporter_test.py @@ -82,9 +82,11 @@ class ReporterTest(unittest.TestCase): self.assertTrue("Low" not in output) def _add_messages(self): - self.reporter.add_message("High", self.reporter.HIGH_PRIORITY, True) - self.reporter.add_message("Med", self.reporter.MEDIUM_PRIORITY) - self.reporter.add_message("Low", self.reporter.LOW_PRIORITY) + self.reporter.add_message("High", self.reporter.HIGH_PRIORITY) + self.reporter.add_message( + "Med", self.reporter.MEDIUM_PRIORITY, on_crash=False) + self.reporter.add_message( + "Low", self.reporter.LOW_PRIORITY, on_crash=False) if __name__ == "__main__": diff --git a/letsencrypt/tests/reverter_test.py b/letsencrypt/tests/reverter_test.py index 62c47f8d6..d31b6f2cc 100644 --- a/letsencrypt/tests/reverter_test.py +++ b/letsencrypt/tests/reverter_test.py @@ -85,7 +85,7 @@ class ReverterCheckpointLocalTest(unittest.TestCase): self.assertEqual(read_in(self.config1), "directive-dir1") def test_multiple_registration_fail_and_revert(self): - # pylint: disable=invalid-name + config3 = os.path.join(self.dir1, "config3.txt") update_file(config3, "Config3") config4 = os.path.join(self.dir2, "config4.txt") @@ -173,7 +173,7 @@ class ReverterCheckpointLocalTest(unittest.TestCase): self.assertRaises(errors.ReverterError, self.reverter.recovery_routine) def test_recover_checkpoint_revert_temp_failures(self): - # pylint: disable=invalid-name + mock_recover = mock.MagicMock( side_effect=errors.ReverterError("e")) @@ -291,7 +291,7 @@ class TestFullCheckpointsReverter(unittest.TestCase): errors.ReverterError, self.reverter.rollback_checkpoints, "one") def test_rollback_finalize_checkpoint_valid_inputs(self): - # pylint: disable=invalid-name + config3 = self._setup_three_checkpoints() # Check resulting backup directory @@ -334,7 +334,7 @@ class TestFullCheckpointsReverter(unittest.TestCase): @mock.patch("letsencrypt.reverter.os.rename") def test_finalize_checkpoint_no_rename_directory(self, mock_rename): - # pylint: disable=invalid-name + self.reverter.add_to_checkpoint(self.sets[0], "perm save") mock_rename.side_effect = OSError diff --git a/letsencrypt/tests/revoker_test.py b/letsencrypt/tests/revoker_test.py deleted file mode 100644 index 87dab4eb8..000000000 --- a/letsencrypt/tests/revoker_test.py +++ /dev/null @@ -1,409 +0,0 @@ -"""Test letsencrypt.revoker.""" -import csv -import os -import shutil -import tempfile -import unittest - -import mock -import OpenSSL - -from letsencrypt import errors -from letsencrypt import le_util -from letsencrypt.display import util as display_util - -from letsencrypt.tests import test_util - - -KEY = OpenSSL.crypto.load_privatekey( - OpenSSL.crypto.FILETYPE_PEM, test_util.load_vector("rsa512_key.pem")) - - -class RevokerBase(unittest.TestCase): # pylint: disable=too-few-public-methods - """Base Class for Revoker Tests.""" - def setUp(self): - self.paths, self.certs, self.key_path = create_revoker_certs() - - self.backup_dir = tempfile.mkdtemp("cert_backup") - self.mock_config = mock.MagicMock(cert_key_backup=self.backup_dir) - - self.list_path = os.path.join(self.backup_dir, "LIST") - - def _store_certs(self): - # pylint: disable=protected-access - from letsencrypt.revoker import Revoker - Revoker.store_cert_key(self.paths[0], self.key_path, self.mock_config) - Revoker.store_cert_key(self.paths[1], self.key_path, self.mock_config) - - # Set metadata - for i in xrange(2): - self.certs[i].add_meta( - i, self.paths[i], self.key_path, - Revoker._get_backup(self.backup_dir, i, self.paths[i]), - Revoker._get_backup(self.backup_dir, i, self.key_path)) - - def _get_rows(self): - with open(self.list_path, "rb") as csvfile: - return [row for row in csv.reader(csvfile)] - - def _write_rows(self, rows): - with open(self.list_path, "wb") as csvfile: - csvwriter = csv.writer(csvfile) - for row in rows: - csvwriter.writerow(row) - - -class RevokerTest(RevokerBase): - def setUp(self): - from letsencrypt.revoker import Revoker - super(RevokerTest, self).setUp() - - with open(self.key_path) as key_file: - self.key = le_util.Key(self.key_path, key_file.read()) - - self._store_certs() - - self.revoker = Revoker( - installer=mock.MagicMock(), config=self.mock_config) - - def tearDown(self): - shutil.rmtree(self.backup_dir) - - @mock.patch("acme.client.Client.revoke") - @mock.patch("letsencrypt.revoker.revocation") - def test_revoke_by_key_all(self, mock_display, mock_acme): - mock_display().confirm_revocation.return_value = True - - self.revoker.revoke_from_key(self.key) - self.assertEqual(self._get_rows(), []) - - # Check to make sure backups were eliminated - for i in xrange(2): - self.assertFalse(self._backups_exist(self.certs[i].get_row())) - - self.assertEqual(mock_acme.call_count, 2) - - @mock.patch("letsencrypt.revoker.OpenSSL.crypto.load_privatekey") - def test_revoke_by_invalid_keys(self, mock_load_privatekey): - mock_load_privatekey.side_effect = OpenSSL.crypto.Error - self.assertRaises( - errors.RevokerError, self.revoker.revoke_from_key, self.key) - - mock_load_privatekey.side_effect = [KEY, OpenSSL.crypto.Error] - self.assertRaises( - errors.RevokerError, self.revoker.revoke_from_key, self.key) - - @mock.patch("acme.client.Client.revoke") - @mock.patch("letsencrypt.revoker.revocation") - def test_revoke_by_wrong_key(self, mock_display, mock_acme): - mock_display().confirm_revocation.return_value = True - - key_path = test_util.vector_path("rsa256_key.pem") - - wrong_key = le_util.Key(key_path, open(key_path).read()) - self.revoker.revoke_from_key(wrong_key) - - # Nothing was removed - self.assertEqual(len(self._get_rows()), 2) - # No revocation went through - self.assertEqual(mock_acme.call_count, 0) - - @mock.patch("acme.client.Client.revoke") - @mock.patch("letsencrypt.revoker.revocation") - def test_revoke_by_cert(self, mock_display, mock_acme): - mock_display().confirm_revocation.return_value = True - - self.revoker.revoke_from_cert(self.paths[1]) - - row0 = self.certs[0].get_row() - row1 = self.certs[1].get_row() - - self.assertEqual(self._get_rows(), [row0]) - - self.assertTrue(self._backups_exist(row0)) - self.assertFalse(self._backups_exist(row1)) - - self.assertEqual(mock_acme.call_count, 1) - - @mock.patch("acme.client.Client.revoke") - @mock.patch("letsencrypt.revoker.revocation") - def test_revoke_by_cert_not_found(self, mock_display, mock_acme): - mock_display().confirm_revocation.return_value = True - - self.revoker.revoke_from_cert(self.paths[0]) - self.revoker.revoke_from_cert(self.paths[0]) - - row0 = self.certs[0].get_row() - row1 = self.certs[1].get_row() - - # Same check as last time... just reversed. - self.assertEqual(self._get_rows(), [row1]) - - self.assertTrue(self._backups_exist(row1)) - self.assertFalse(self._backups_exist(row0)) - - self.assertEqual(mock_acme.call_count, 1) - - @mock.patch("acme.client.Client.revoke") - @mock.patch("letsencrypt.revoker.revocation") - def test_revoke_by_menu(self, mock_display, mock_acme): - mock_display().confirm_revocation.return_value = True - mock_display.display_certs.side_effect = [ - (display_util.HELP, 0), - (display_util.OK, 0), - (display_util.CANCEL, -1), - ] - - self.revoker.revoke_from_menu() - - row0 = self.certs[0].get_row() - row1 = self.certs[1].get_row() - - self.assertEqual(self._get_rows(), [row1]) - - self.assertFalse(self._backups_exist(row0)) - self.assertTrue(self._backups_exist(row1)) - - self.assertEqual(mock_acme.call_count, 1) - self.assertEqual(mock_display.more_info_cert.call_count, 1) - - @mock.patch("letsencrypt.revoker.logger") - @mock.patch("acme.client.Client.revoke") - @mock.patch("letsencrypt.revoker.revocation") - def test_revoke_by_menu_delete_all(self, mock_display, mock_acme, mock_log): - mock_display().confirm_revocation.return_value = True - mock_display.display_certs.return_value = (display_util.OK, 0) - - self.revoker.revoke_from_menu() - - self.assertEqual(self._get_rows(), []) - - # Everything should be deleted... - for i in xrange(2): - self.assertFalse(self._backups_exist(self.certs[i].get_row())) - - self.assertEqual(mock_acme.call_count, 2) - # Info is called when there aren't any certs left... - self.assertTrue(mock_log.info.called) - - @mock.patch("letsencrypt.revoker.revocation") - @mock.patch("letsencrypt.revoker.Revoker._acme_revoke") - @mock.patch("letsencrypt.revoker.logger") - def test_safe_revoke_acme_fail(self, mock_log, mock_revoke, mock_display): - # pylint: disable=protected-access - mock_revoke.side_effect = errors.Error - mock_display().confirm_revocation.return_value = True - - self.revoker._safe_revoke(self.certs) - self.assertTrue(mock_log.error.called) - - @mock.patch("letsencrypt.revoker.OpenSSL.crypto.load_privatekey") - def test_acme_revoke_failure(self, mock_load_privatekey): - # pylint: disable=protected-access - mock_load_privatekey.side_effect = OpenSSL.crypto.Error - self.assertRaises( - errors.Error, self.revoker._acme_revoke, self.certs[0]) - - def test_remove_certs_from_list_bad_certs(self): - # pylint: disable=protected-access - from letsencrypt.revoker import Cert - - new_cert = Cert(self.paths[0]) - - # This isn't stored in the db - new_cert.idx = 10 - new_cert.backup_path = self.paths[0] - new_cert.backup_key_path = self.key_path - new_cert.orig = Cert.PathStatus("false path", "not here") - new_cert.orig_key = Cert.PathStatus("false path", "not here") - - self.assertRaises(errors.RevokerError, - self.revoker._remove_certs_from_list, [new_cert]) - - def _backups_exist(self, row): - # pylint: disable=protected-access - cert_path, key_path = self.revoker._row_to_backup(row) - return os.path.isfile(cert_path) and os.path.isfile(key_path) - - -class RevokerInstallerTest(RevokerBase): - def setUp(self): - super(RevokerInstallerTest, self).setUp() - - self.installs = [ - ["installation/path0a", "installation/path0b"], - ["installation/path1"], - ] - - self.certs_keys = [ - (self.paths[0], self.key_path, self.installs[0][0]), - (self.paths[0], self.key_path, self.installs[0][1]), - (self.paths[1], self.key_path, self.installs[1][0]), - ] - - self._store_certs() - - def _get_revoker(self, installer): - from letsencrypt.revoker import Revoker - return Revoker(installer, self.mock_config) - - def test_no_installer_get_installed_locations(self): - # pylint: disable=protected-access - revoker = self._get_revoker(None) - self.assertEqual(revoker._get_installed_locations(), {}) - - def test_get_installed_locations(self): - # pylint: disable=protected-access - mock_installer = mock.MagicMock() - mock_installer.get_all_certs_keys.return_value = self.certs_keys - - revoker = self._get_revoker(mock_installer) - sha_vh = revoker._get_installed_locations() - - self.assertEqual(len(sha_vh), 2) - for i, cert in enumerate(self.certs): - self.assertTrue(cert.get_fingerprint() in sha_vh) - self.assertEqual( - sha_vh[cert.get_fingerprint()], self.installs[i]) - - @mock.patch("letsencrypt.revoker.OpenSSL.crypto.load_certificate") - def test_get_installed_load_failure(self, mock_load_certificate): - mock_installer = mock.MagicMock() - mock_installer.get_all_certs_keys.return_value = self.certs_keys - - mock_load_certificate.side_effect = OpenSSL.crypto.Error - - revoker = self._get_revoker(mock_installer) - - # pylint: disable=protected-access - self.assertEqual(revoker._get_installed_locations(), {}) - - def test_get_installed_load_failure_open(self): - tmp = tempfile.mkdtemp() - mock_installer = mock.MagicMock() - mock_installer.get_all_certs_keys.return_value = [( - os.path.join(tmp, 'missing'), None, None)] - revoker = self._get_revoker(mock_installer) - # pylint: disable=protected-access - self.assertEqual(revoker._get_installed_locations(), {}) - os.rmdir(tmp) - - -class RevokerClassMethodsTest(RevokerBase): - def setUp(self): - super(RevokerClassMethodsTest, self).setUp() - self.mock_config = mock.MagicMock(cert_key_backup=self.backup_dir) - - def tearDown(self): - shutil.rmtree(self.backup_dir) - - def _call(self, cert_path, key_path): - from letsencrypt.revoker import Revoker - Revoker.store_cert_key(cert_path, key_path, self.mock_config) - - def test_store_two(self): - from letsencrypt.revoker import Revoker - self._call(self.paths[0], self.key_path) - self._call(self.paths[1], self.key_path) - - self.assertTrue(os.path.isfile(self.list_path)) - rows = self._get_rows() - - for i, row in enumerate(rows): - # pylint: disable=protected-access - self.assertTrue(os.path.isfile( - Revoker._get_backup(self.backup_dir, i, self.paths[i]))) - self.assertTrue(os.path.isfile( - Revoker._get_backup(self.backup_dir, i, self.key_path))) - self.assertEqual([str(i), self.paths[i], self.key_path], row) - - self.assertEqual(len(rows), 2) - - def test_store_one_mixed(self): - from letsencrypt.revoker import Revoker - self._write_rows( - [["5", "blank", "blank"], ["18", "dc", "dc"], ["21", "b", "b"]]) - self._call(self.paths[0], self.key_path) - - self.assertEqual( - self._get_rows()[3], ["22", self.paths[0], self.key_path]) - - # pylint: disable=protected-access - self.assertTrue(os.path.isfile( - Revoker._get_backup(self.backup_dir, 22, self.paths[0]))) - self.assertTrue(os.path.isfile( - Revoker._get_backup(self.backup_dir, 22, self.key_path))) - - -class CertTest(unittest.TestCase): - def setUp(self): - self.paths, self.certs, self.key_path = create_revoker_certs() - - def test_failed_load(self): - from letsencrypt.revoker import Cert - self.assertRaises(errors.RevokerError, Cert, self.key_path) - - def test_failed_load_open(self): - tmp = tempfile.mkdtemp() - from letsencrypt.revoker import Cert - self.assertRaises( - errors.RevokerError, Cert, os.path.join(tmp, 'missing')) - os.rmdir(tmp) - - def test_no_row(self): - self.assertEqual(self.certs[0].get_row(), None) - - def test_meta_moved_files(self): - from letsencrypt.revoker import Cert - fake_path = "/not/a/real/path/r72d3t6" - self.certs[0].add_meta( - 0, fake_path, fake_path, self.paths[0], self.key_path) - - self.assertEqual(self.certs[0].orig.status, Cert.DELETED_MSG) - self.assertEqual(self.certs[0].orig_key.status, Cert.DELETED_MSG) - - def test_meta_changed_files(self): - from letsencrypt.revoker import Cert - self.certs[0].add_meta( - 0, self.paths[1], self.paths[1], self.paths[0], self.key_path) - - self.assertEqual(self.certs[0].orig.status, Cert.CHANGED_MSG) - self.assertEqual(self.certs[0].orig_key.status, Cert.CHANGED_MSG) - - def test_meta_no_status(self): - self.certs[0].add_meta( - 0, self.paths[0], self.key_path, self.paths[0], self.key_path) - - self.assertEqual(self.certs[0].orig.status, "") - self.assertEqual(self.certs[0].orig_key.status, "") - - def test_print_meta(self): - """Just make sure there aren't any major errors.""" - self.certs[0].add_meta( - 0, self.paths[0], self.key_path, self.paths[0], self.key_path) - # Changed path and deleted file - self.certs[1].add_meta( - 1, self.paths[0], "/not/a/path", self.paths[1], self.key_path) - self.assertTrue(self.certs[0].pretty_print()) - self.assertTrue(self.certs[1].pretty_print()) - - def test_print_no_meta(self): - self.assertTrue(self.certs[0].pretty_print()) - self.assertTrue(self.certs[1].pretty_print()) - - -def create_revoker_certs(): - """Create a few revoker.Cert objects.""" - cert0_path = test_util.vector_path("cert.pem") - cert1_path = test_util.vector_path("cert-san.pem") - key_path = test_util.vector_path("rsa512_key.pem") - - from letsencrypt.revoker import Cert - cert0 = Cert(cert0_path) - cert1 = Cert(cert1_path) - - return [cert0_path, cert1_path], [cert0, cert1], key_path - - -if __name__ == "__main__": - unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/test_util.py b/letsencrypt/tests/test_util.py deleted file mode 120000 index 80d26cbe8..000000000 --- a/letsencrypt/tests/test_util.py +++ /dev/null @@ -1 +0,0 @@ -../../acme/acme/test_util.py \ No newline at end of file diff --git a/letsencrypt/tests/test_util.py b/letsencrypt/tests/test_util.py new file mode 100644 index 000000000..2b4c6e00c --- /dev/null +++ b/letsencrypt/tests/test_util.py @@ -0,0 +1,67 @@ +"""Test utilities. + +.. warning:: This module is not part of the public API. + +""" +import os +import pkg_resources + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +import OpenSSL + +from acme import jose + + +def vector_path(*names): + """Path to a test vector.""" + return pkg_resources.resource_filename( + __name__, os.path.join('testdata', *names)) + + +def load_vector(*names): + """Load contents of a test vector.""" + # luckily, resource_string opens file in binary mode + return pkg_resources.resource_string( + __name__, os.path.join('testdata', *names)) + + +def _guess_loader(filename, loader_pem, loader_der): + _, ext = os.path.splitext(filename) + if ext.lower() == '.pem': + return loader_pem + elif ext.lower() == '.der': + return loader_der + else: # pragma: no cover + raise ValueError("Loader could not be recognized based on extension") + + +def load_cert(*names): + """Load certificate.""" + loader = _guess_loader( + names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) + return jose.ComparableX509(OpenSSL.crypto.load_certificate( + loader, load_vector(*names))) + + +def load_csr(*names): + """Load certificate request.""" + loader = _guess_loader( + names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) + return jose.ComparableX509(OpenSSL.crypto.load_certificate_request( + loader, load_vector(*names))) + + +def load_rsa_private_key(*names): + """Load RSA private key.""" + loader = _guess_loader(names[-1], serialization.load_pem_private_key, + serialization.load_der_private_key) + return jose.ComparableRSAKey(loader( + load_vector(*names), password=None, backend=default_backend())) + + +def load_pyopenssl_private_key(*names): + """Load pyOpenSSL private key.""" + loader = _guess_loader( + names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) + return OpenSSL.crypto.load_privatekey(loader, load_vector(*names)) diff --git a/letshelp-letsencrypt/LICENSE.txt b/letshelp-letsencrypt/LICENSE.txt new file mode 100644 index 000000000..981c46c9f --- /dev/null +++ b/letshelp-letsencrypt/LICENSE.txt @@ -0,0 +1,190 @@ + Copyright 2015 Electronic Frontier Foundation and others + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/letshelp-letsencrypt/MANIFEST.in b/letshelp-letsencrypt/MANIFEST.in index 61a3d3150..96c1d7ba5 100644 --- a/letshelp-letsencrypt/MANIFEST.in +++ b/letshelp-letsencrypt/MANIFEST.in @@ -1 +1,3 @@ -recursive-include letshelp-letsencrypt/testdata * +include LICENSE.txt +include README.rst +recursive-include letshelp_letsencrypt/testdata * diff --git a/letshelp-letsencrypt/README.rst b/letshelp-letsencrypt/README.rst new file mode 100644 index 000000000..159048d6d --- /dev/null +++ b/letshelp-letsencrypt/README.rst @@ -0,0 +1 @@ +Let's help Let's Encrypt client diff --git a/letshelp-letsencrypt/setup.py b/letshelp-letsencrypt/setup.py index 6b89a6d09..ad517c74e 100644 --- a/letshelp-letsencrypt/setup.py +++ b/letshelp-letsencrypt/setup.py @@ -4,20 +4,46 @@ from setuptools import setup from setuptools import find_packages -install_requires = [] +version = '0.0.0.dev20151008' + +install_requires = [ + 'setuptools', # pkg_resources +] if sys.version_info < (2, 7): - install_requires.append("mock<1.1.0") + install_requires.append('mock<1.1.0') else: - install_requires.append("mock") + install_requires.append('mock') setup( - name="letshelp-letsencrypt", + name='letshelp-letsencrypt', + version=version, + description="Let's help Let's Encrypt client", + url='https://github.com/letsencrypt/letsencrypt', + author="Let's Encrypt Project", + author_email='client-dev@letsencrypt.org', + license='Apache License 2.0', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Security', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Networking', + 'Topic :: System :: Systems Administration', + 'Topic :: Utilities', + ], + packages=find_packages(), + include_package_data=True, install_requires=install_requires, entry_points={ 'console_scripts': [ - "letshelp-letsencrypt-apache = letshelp_letsencrypt.apache:main", + 'letshelp-letsencrypt-apache = letshelp_letsencrypt.apache:main', ], }, - include_package_data=True, ) diff --git a/readthedocs.org.requirements.txt b/readthedocs.org.requirements.txt index f686b00bf..3c3a3c576 100644 --- a/readthedocs.org.requirements.txt +++ b/readthedocs.org.requirements.txt @@ -11,3 +11,5 @@ -e .[docs] -e letsencrypt-apache -e letsencrypt-nginx +-e letsencrypt-compatibility-test +-e letshelp-letsencrypt diff --git a/setup.py b/setup.py index 6e1640e3e..016dc146e 100644 --- a/setup.py +++ b/setup.py @@ -24,17 +24,17 @@ here = os.path.abspath(os.path.dirname(__file__)) # read version number (and other metadata) from package init init_fn = os.path.join(here, 'letsencrypt', '__init__.py') -meta = dict(re.findall(r"""__([a-z]+)__ = "([^"]+)""", read_file(init_fn))) +meta = dict(re.findall(r"""__([a-z]+)__ = '([^']+)""", read_file(init_fn))) readme = read_file(os.path.join(here, 'README.rst')) changes = read_file(os.path.join(here, 'CHANGES.rst')) +version = meta['version'] install_requires = [ - 'acme', + 'acme=={0}'.format(version), 'ConfigArgParse', 'configobj', 'cryptography>=0.7', # load_pem_x509_certificate - 'mock<1.1.0', # py26 'parsedatetime', 'psutil>=2.1.0', # net_connections introduced in 2.1.0 'PyOpenSSL', @@ -42,19 +42,27 @@ install_requires = [ 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 'pytz', 'requests', + 'setuptools', # pkg_resources 'zope.component', 'zope.interface', ] # env markers in extras_require cause problems with older pip: #517 if sys.version_info < (2, 7): - # only some distros recognize stdlib argparse as already satisfying - install_requires.append('argparse') + install_requires.extend([ + # only some distros recognize stdlib argparse as already satisfying + 'argparse', + 'mock<1.1.0', + ]) +else: + install_requires.append('mock') dev_extras = [ # Pin astroid==1.3.5, pylint==1.4.2 as a workaround for #289 'astroid==1.3.5', 'pylint==1.4.2', # upstream #248 + 'twine', + 'wheel', ] docs_extras = [ @@ -74,13 +82,15 @@ testing_extras = [ setup( name='letsencrypt', - version=meta['version'], - description="Let's Encrypt", + version=version, + description="Let's Encrypt client", long_description=readme, # later: + '\n\n' + changes + url='https://github.com/letsencrypt/letsencrypt', author="Let's Encrypt Project", + author_email='client-dev@letsencrypt.org', license='Apache License 2.0', - url='https://letsencrypt.org', classifiers=[ + 'Development Status :: 3 - Alpha', 'Environment :: Console', 'Environment :: Console :: Curses', 'Intended Audience :: System Administrators', @@ -98,6 +108,8 @@ setup( ], packages=find_packages(exclude=['docs', 'examples', 'tests', 'venv']), + include_package_data=True, + install_requires=install_requires, extras_require={ 'dev': dev_extras, @@ -116,14 +128,10 @@ setup( 'letsencrypt-renewer = letsencrypt.renewer:main', ], 'letsencrypt.plugins': [ - 'manual = letsencrypt.plugins.manual:ManualAuthenticator', - # TODO: null should probably not be presented to the user + 'manual = letsencrypt.plugins.manual:Authenticator', 'null = letsencrypt.plugins.null:Installer', 'standalone = letsencrypt.plugins.standalone.authenticator' ':StandaloneAuthenticator', ], }, - - zip_safe=False, # letsencrypt/tests/test_util.py is a symlink! - include_package_data=True, ) diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh index ed877d136..25db8ba6d 100755 --- a/tests/boulder-integration.sh +++ b/tests/boulder-integration.sh @@ -24,7 +24,6 @@ common() { common --domains le1.wtf auth common --domains le2.wtf run common -a manual -d le.wtf auth -common -a manual -d le.wtf --no-simple-http-tls auth export CSR_PATH="${root}/csr.der" KEY_PATH="${root}/key.pem" \ OPENSSL_CNF=examples/openssl.cnf diff --git a/tests/boulder-start.sh b/tests/boulder-start.sh index 0875c3d2f..530f9c598 100755 --- a/tests/boulder-start.sh +++ b/tests/boulder-start.sh @@ -1,6 +1,29 @@ -#!/bin/sh -xe +#!/bin/bash # Download and run Boulder instance for integration testing + +# ugh, go version output is like: +# go version go1.4.2 linux/amd64 +GOVER=`go version | cut -d" " -f3 | cut -do -f2` + +# version comparison +function verlte { + #OS X doesn't support version sorting; emulate with sed + if [ `uname` == 'Darwin' ]; then + [ "$1" = "`echo -e \"$1\n$2\" | sed 's/\b\([0-9]\)\b/0\1/g' \ + | sort | sed 's/\b0\([0-9]\)/\1/g' | head -n1`" ] + else + [ "$1" = "`echo -e "$1\n$2" | sort -V | head -n1`" ] + fi +} + +if ! verlte 1.5 "$GOVER" ; then + echo "We require go version 1.5 or later; you have... $GOVER" + exit 1 +fi + +set -xe + export GOPATH="${GOPATH:-/tmp/go}" export PATH="$GOPATH/bin:$PATH" @@ -9,7 +32,11 @@ export PATH="$GOPATH/bin:$PATH" go get -d github.com/letsencrypt/boulder/... cd $GOPATH/src/github.com/letsencrypt/boulder # goose is needed for ./test/create_db.sh -go get bitbucket.org/liamstask/goose/cmd/goose +if ! go get bitbucket.org/liamstask/goose/cmd/goose ; then + echo Problems installing goose... perhaps rm -rf \$GOPATH \("$GOPATH"\) + echo and try again... + exit 1 +fi ./test/create_db.sh ./start.py & # Hopefully start.py bootstraps before integration test is started... diff --git a/tests/integration/_common.sh b/tests/integration/_common.sh index c8b142cf2..fd60b9258 100755 --- a/tests/integration/_common.sh +++ b/tests/integration/_common.sh @@ -23,6 +23,7 @@ letsencrypt_test () { --agree-eula \ --agree-tos \ --email "" \ + --renew-by-default \ --debug \ -vvvvvvv \ "$@" diff --git a/tests/mac-bootstrap.sh b/tests/mac-bootstrap.sh new file mode 100755 index 000000000..66036ce56 --- /dev/null +++ b/tests/mac-bootstrap.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +#Check Homebrew +if ! hash brew 2>/dev/null; then + echo "Homebrew Not Installed\nDownloading..." + ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" +fi + +brew install libtool mariadb rabbitmq coreutils go + +mysql.server start + +rabbit_pid=`ps | grep rabbitmq | grep -v grep | awk '{ print $1}'` +if [ -n "$rabbit_pid" ]; then + echo "RabbitMQ already running" +else + rabbitmq-server & +fi + +hosts_entry=`cat /etc/hosts | grep "127.0.0.1 le.wtf"` +if [ -z "$hosts_entry" ]; then + echo "Adding hosts entry for le.wtf..." + sudo sh -c "echo 127.0.0.1 le.wtf >> /etc/hosts" +fi + +./tests/boulder-start.sh diff --git a/tools/dev-release.sh b/tools/dev-release.sh new file mode 100755 index 000000000..d93a6d21f --- /dev/null +++ b/tools/dev-release.sh @@ -0,0 +1,97 @@ +#!/bin/sh -xe +# Release dev packages to PyPI + +version="0.0.0.dev$(date +%Y%m%d)" +DEV_RELEASE_BRANCH="dev-release" +# TODO: create a real release key instead of using Kuba's personal one +RELEASE_GPG_KEY="${RELEASE_GPG_KEY:-148C30F6F7E429337A72D992B00B9CC82D7ADF2C}" + +# port for a local Python Package Index (used in testing) +PORT=${PORT:-1234} + +# subpackages to be released +SUBPKGS=${SUBPKGS:-"acme letsencrypt_apache letsencrypt_nginx letshelp_letsencrypt"} +subpkgs_dirs="$(echo $SUBPKGS | sed s/_/-/g)" +# letsencrypt_compatibility_test is not packaged because: +# - it is not meant to be used by anyone else than Let's Encrypt devs +# - it causes problems when running nosetests - the latter tries to +# run everything that matches test*, while there are no unittests +# there + +tag="v$version" +mv "dist.$version" "dist.$version.$(date +%s).bak" || true +git tag --delete "$tag" || true + +root="$(mktemp -d -t le.$version.XXX)" +echo "Cloning into fresh copy at $root" # clean repo = no artificats +git clone . $root +cd $root +git branch -f "$DEV_RELEASE_BRANCH" +git checkout "$DEV_RELEASE_BRANCH" + +for pkg_dir in $subpkgs_dirs +do + sed -i $x "s/^version.*/version = '$version'/" $pkg_dir/setup.py +done +sed -i "s/^__version.*/__version__ = '$version'/" letsencrypt/__init__.py + +git add -p # interactive user input +git -c commit.gpgsign=true commit -m "Release $version" +git tag --local-user "$RELEASE_GPG_KEY" \ + --sign --message "Release $version" "$tag" + +echo "Preparing sdists and wheels" +for pkg_dir in . $subpkgs_dirs +do + cd $pkg_dir + + python setup.py clean + rm -rf build dist + python setup.py sdist + python setup.py bdist_wheel + + echo "Signing ($pkg_dir)" + for x in dist/*.tar.gz dist/*.whl + do + gpg2 --detach-sign --armor --sign $x + done + + cd - +done + +mkdir "dist.$version" +mv dist "dist.$version/letsencrypt" +for pkg_dir in $subpkgs_dirs +do + mv $pkg_dir/dist "dist.$version/$pkg_dir/" +done + +echo "Testing packages" +cd "dist.$version" +# start local PyPI +python -m SimpleHTTPServer $PORT & +# cd .. is NOT done on purpose: we make sure that all subpackages are +# installed from local PyPI rather than current directory (repo root) +virtualenv --no-site-packages ../venv +. ../venv/bin/activate +# Now, use our local PyPI. --pre allows installation of pre-release (incl. dev) +pip install \ + --pre \ + --extra-index-url http://localhost:$PORT \ + letsencrypt $SUBPKGS +# stop local PyPI +kill $! + +# freeze before installing anything else, so that we know end-user KGS +# make sure "twine upload" doesn't catch "kgs" +mkdir ../kgs +kgs="../kgs/$version" +pip freeze | tee $kgs +pip install nose +# TODO: letsencrypt_apache fails due to symlink, c.f. #838 +nosetests letsencrypt $SUBPKGS || true + +echo "New root: $root" +echo "KGS is at $root/kgs" +echo "In order to upload packages run the following command:" +echo twine upload "$root/dist.$version/*/*" diff --git a/tox.cover.sh b/tox.cover.sh index edfd9b81a..8418de9a8 100755 --- a/tox.cover.sh +++ b/tox.cover.sh @@ -16,7 +16,7 @@ fi cover () { if [ "$1" = "letsencrypt" ]; then - min=97 + min=98 elif [ "$1" = "acme" ]; then min=100 elif [ "$1" = "letsencrypt_apache" ]; then