diff --git a/.pep8 b/.pep8 new file mode 100644 index 000000000..22045d3d3 --- /dev/null +++ b/.pep8 @@ -0,0 +1,4 @@ +[pep8] +# E265 block comment should start with '# ' +# E501 line too long (X > 79 characters) +ignore = E265,E501 diff --git a/.pylintrc b/.pylintrc index d954b2658..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 +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) @@ -101,7 +101,7 @@ function-rgx=[a-z_][a-z0-9_]{2,40}$ function-name-hint=[a-z_][a-z0-9_]{2,40}$ # Regular expression matching correct variable names -variable-rgx=[a-z_][a-z0-9_]{2,30}$ +variable-rgx=[a-z_][a-z0-9_]{1,30}$ # Naming hint for variable names variable-name-hint=[a-z_][a-z0-9_]{2,30}$ @@ -218,7 +218,7 @@ ignore-long-lines=^\s*(# )??$ single-line-if-stmt=no # List of optional constructs for which whitespace checking is disabled -no-space-check=trailing-comma,dict-separator +no-space-check=trailing-comma # Maximum number of lines in a module max-module-lines=1250 @@ -228,7 +228,8 @@ max-module-lines=1250 indent-string=' ' # Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 +# This does something silly/broken... +#indent-after-paren=4 [TYPECHECK] diff --git a/.travis.yml b/.travis.yml index b4a9d3220..46b14fe63 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,5 @@ language: python -go: - - 1.5 - services: - rabbitmq - mysql @@ -10,8 +7,6 @@ services: # http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS # gimme has to be kept in sync with Boulder's Go version setting in .travis.yml before_install: - - travis_retry sudo ./bootstrap/ubuntu.sh - - travis_retry sudo apt-get install --no-install-recommends nginx-light openssl - '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || eval "$(gimme 1.5)"' # using separate envs with different TOXENVs creates 4x1 Travis build @@ -22,16 +17,33 @@ env: - GOPATH=/tmp/go - PATH=$GOPATH/bin:$PATH matrix: - - TOXENV=py26 BOULDER_INTEGRATION=1 - TOXENV=py27 BOULDER_INTEGRATION=1 - TOXENV=lint - TOXENV=cover -# make sure simplehttp simple verification works (custom /etc/hosts) +sudo: false # containers addons: + # make sure simplehttp simple verification works (custom /etc/hosts) hosts: - le.wtf mariadb: "10.0" + apt: + packages: # keep in sync with bootstrap/ubuntu.sh and Boulder + - lsb-release + - python + - python-dev + - python-virtualenv + - gcc + - dialog + - libaugeas0 + - libssl-dev + - libffi-dev + - ca-certificates + # For letsencrypt-nginx integration testing + - nginx-light + - openssl + # For Boulder integration testing + - rsyslog install: "travis_retry pip install tox coveralls" before_script: '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/boulder-start.sh' 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/Dockerfile-dev b/Dockerfile-dev index 835b3a7cc..2fe1a818d 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -32,7 +32,7 @@ RUN /opt/letsencrypt/src/ubuntu.sh && \ # the above is not likely to change, so by putting it further up the # Dockerfile we make sure we cache as much as possible -COPY setup.py README.rst CHANGES.rst MANIFEST.in requirements.txt EULA linter_plugin.py tox.cover.sh tox.ini /opt/letsencrypt/src/ +COPY setup.py README.rst CHANGES.rst MANIFEST.in requirements.txt EULA linter_plugin.py tox.cover.sh tox.ini pep8.travis.sh .pep8 .pylintrc /opt/letsencrypt/src/ # all above files are necessary for setup.py, however, package source # code directory has to be copied separately to a subdirectory... @@ -46,6 +46,8 @@ COPY letsencrypt /opt/letsencrypt/src/letsencrypt/ COPY acme /opt/letsencrypt/src/acme/ COPY letsencrypt-apache /opt/letsencrypt/src/letsencrypt-apache/ COPY letsencrypt-nginx /opt/letsencrypt/src/letsencrypt-nginx/ +COPY letshelp-letsencrypt /opt/letsencrypt/src/letshelp-letsencrypt/ +COPY letsencrypt-compatibility-test /opt/letsencrypt/src/letsencrypt-compatibility-test/ COPY tests /opt/letsencrypt/src/tests/ RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ @@ -55,6 +57,8 @@ RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ -e /opt/letsencrypt/src \ -e /opt/letsencrypt/src/letsencrypt-apache \ -e /opt/letsencrypt/src/letsencrypt-nginx \ + -e /opt/letsencrypt/src/letshelp-letsencrypt \ + -e /opt/letsencrypt/src/letsencrypt-compatibility-test \ -e /opt/letsencrypt/src[dev,docs,testing] # install in editable mode (-e) to save space: it's not possible to 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/README.rst b/README.rst index 1c54befd8..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 @@ -116,6 +116,8 @@ Main Website: https://letsencrypt.org/ IRC Channel: #letsencrypt on `Freenode`_ +Community: https://community.letsencrypt.org + Mailing list: `client-dev`_ (to subscribe without a Google account, send an email to client-dev+subscribe@letsencrypt.org) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index a2235b61e..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. @@ -514,10 +548,100 @@ class DNS(DVChallenge): """ typ = "dns" - token = jose.Field("token") + + LABEL = "_acme-challenge" + """Label clients prepend to the domain name being validated.""" + + TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec + """Minimum size of the :attr:`token` in bytes.""" + + token = jose.Field( + "token", encoder=jose.encode_b64jose, decoder=functools.partial( + jose.decode_b64jose, size=TOKEN_SIZE, minimum=True)) + + def gen_validation(self, account_key, alg=jose.RS256, **kwargs): + """Generate validation. + + :param .JWK account_key: Private account key. + :param .JWA alg: + + :returns: This challenge wrapped in `.JWS` + :rtype: .JWS + + """ + return jose.JWS.sign( + payload=self.json_dumps(sort_keys=True).encode('utf-8'), + key=account_key, alg=alg, **kwargs) + + def check_validation(self, validation, account_public_key): + """Check validation. + + :param JWS validation: + :type account_public_key: + `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` + wrapped in `.ComparableKey` + + :rtype: bool + + """ + if not validation.verify(key=account_public_key): + return False + try: + return self == self.json_loads( + validation.payload.decode('utf-8')) + except jose.DeserializationError as error: + logger.debug("Checking validation for DNS failed: %s", error) + return False + + def gen_response(self, account_key, **kwargs): + """Generate response. + + :param .JWK account_key: Private account key. + :param .JWA alg: + + :rtype: DNSResponse + + """ + return DNSResponse(validation=self.gen_validation( + self, account_key, **kwargs)) + + def validation_domain_name(self, name): + """Domain name for TXT validation record. + + :param unicode name: Domain name being validated. + + """ + return "{0}.{1}".format(self.LABEL, name) @ChallengeResponse.register class DNSResponse(ChallengeResponse): - """ACME "dns" challenge response.""" + """ACME "dns" challenge response. + + :param JWS validation: + + """ typ = "dns" + + validation = jose.Field("validation", decoder=jose.JWS.from_json) + + def check_validation(self, chall, account_public_key): + """Check validation. + + :param challenges.DNS chall: + :type account_public_key: + `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` + wrapped in `.ComparableKey` + + :rtype: bool + + """ + return chall.check_validation(self.validation, account_public_key) diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index d123eca20..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): @@ -136,7 +162,7 @@ class SimpleHTTPResponseTest(unittest.TestCase): jose.JWS.sign(payload=bad_resource.json_dumps().encode('utf-8'), alg=jose.RS256, key=account_key) for bad_resource in (resource.update(tls=True), - resource.update(token=b'x'*20)) + resource.update(token=(b'x' * 20))) ) for validation in validations: self.assertFalse(self.resp_http.check_validation( @@ -144,7 +170,7 @@ class SimpleHTTPResponseTest(unittest.TestCase): account_public_key=account_key.public_key())) @mock.patch("acme.challenges.requests.get") - def test_simple_verify_good_token(self, mock_get): + def test_simple_verify_good_validation(self, mock_get): account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) for resp in self.resp_http, self.resp_https: mock_get.reset_mock() @@ -156,9 +182,9 @@ class SimpleHTTPResponseTest(unittest.TestCase): "local", self.chall), verify=False) @mock.patch("acme.challenges.requests.get") - def test_simple_verify_bad_token(self, mock_get): + def test_simple_verify_bad_validation(self, mock_get): mock_get.return_value = mock.MagicMock( - text=self.chall.token + "!", headers=self.good_headers) + text="!", headers=self.good_headers) self.assertFalse(self.resp_http.simple_verify( self.chall, "local", None)) @@ -320,7 +346,7 @@ class DVSNIResponseTest(unittest.TestCase): def test_simple_verify_wrong_token(self): msg = self.msg.update(validation=jose.JWS.sign( - payload=self.chall.update(token=b'b'*20).json_dumps().encode(), + payload=self.chall.update(token=(b'b' * 20)).json_dumps().encode(), key=self.key, alg=jose.RS256)) self.assertFalse(msg.simple_verify( self.chall, self.domain, self.key.public_key())) @@ -350,9 +376,9 @@ class RecoveryContactTest(unittest.TestCase): contact='c********n@example.com') self.jmsg = { 'type': 'recoveryContact', - 'activationURL' : 'https://example.ca/sendrecovery/a5bd99383fb0', - 'successURL' : 'https://example.ca/confirmrecovery/bb1b9928932', - 'contact' : 'c********n@example.com', + 'activationURL': 'https://example.ca/sendrecovery/a5bd99383fb0', + 'successURL': 'https://example.ca/confirmrecovery/bb1b9928932', + 'contact': 'c********n@example.com', } def test_to_partial_json(self): @@ -570,9 +596,15 @@ class ProofOfPossessionResponseTest(unittest.TestCase): class DNSTest(unittest.TestCase): def setUp(self): + self.account_key = jose.JWKRSA.load( + test_util.load_vector('rsa512_key.pem')) from acme.challenges import DNS - self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a') - self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'} + self.msg = DNS(token=jose.b64decode( + b'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA')) + self.jmsg = { + 'type': 'dns', + 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA', + } def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -585,27 +617,84 @@ class DNSTest(unittest.TestCase): from acme.challenges import DNS hash(DNS.from_json(self.jmsg)) + def test_gen_check_validation(self): + self.assertTrue(self.msg.check_validation( + self.msg.gen_validation(self.account_key), + self.account_key.public_key())) + + def test_gen_check_validation_wrong_key(self): + key2 = jose.JWKRSA.load(test_util.load_vector('rsa1024_key.pem')) + self.assertFalse(self.msg.check_validation( + self.msg.gen_validation(self.account_key), key2.public_key())) + + def test_check_validation_wrong_payload(self): + validations = tuple( + jose.JWS.sign(payload=payload, alg=jose.RS256, key=self.account_key) + for payload in (b'', b'{}') + ) + for validation in validations: + self.assertFalse(self.msg.check_validation( + validation, self.account_key.public_key())) + + def test_check_validation_wrong_fields(self): + bad_validation = jose.JWS.sign( + payload=self.msg.update(token=b'x' * 20).json_dumps().encode('utf-8'), + alg=jose.RS256, key=self.account_key) + self.assertFalse(self.msg.check_validation( + bad_validation, self.account_key.public_key())) + + def test_gen_response(self): + with mock.patch('acme.challenges.DNS.gen_validation') as mock_gen: + mock_gen.return_value = mock.sentinel.validation + response = self.msg.gen_response(self.account_key) + from acme.challenges import DNSResponse + self.assertTrue(isinstance(response, DNSResponse)) + self.assertEqual(response.validation, mock.sentinel.validation) + + def test_validation_domain_name(self): + self.assertEqual( + '_acme-challenge.le.wtf', self.msg.validation_domain_name('le.wtf')) + class DNSResponseTest(unittest.TestCase): def setUp(self): + self.key = jose.JWKRSA(key=KEY) + + from acme.challenges import DNS + self.chall = DNS(token=jose.b64decode( + b"evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA")) + self.validation = jose.JWS.sign( + payload=self.chall.json_dumps(sort_keys=True).encode(), + key=self.key, alg=jose.RS256) + from acme.challenges import DNSResponse - self.msg = DNSResponse() - self.jmsg = { + self.msg = DNSResponse(validation=self.validation) + self.jmsg_to = { 'resource': 'challenge', 'type': 'dns', + 'validation': self.validation, + } + self.jmsg_from = { + 'resource': 'challenge', + 'type': 'dns', + 'validation': self.validation.to_json(), } def test_to_partial_json(self): - self.assertEqual(self.jmsg, self.msg.to_partial_json()) + self.assertEqual(self.jmsg_to, self.msg.to_partial_json()) def test_from_json(self): from acme.challenges import DNSResponse - self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg)) + self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg_from)) def test_from_json_hashable(self): from acme.challenges import DNSResponse - hash(DNSResponse.from_json(self.jmsg)) + hash(DNSResponse.from_json(self.jmsg_from)) + + def test_check_validation(self): + self.assertTrue( + self.msg.check_validation(self.chall, self.key.public_key())) if __name__ == '__main__': diff --git a/acme/acme/client.py b/acme/acme/client.py index 1fbd9ca5b..4c89458fb 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -4,11 +4,12 @@ import heapq import logging import time +import six from six.moves import http_client # pylint: disable=import-error import OpenSSL import requests -import six +import sys import werkzeug from acme import errors @@ -19,8 +20,9 @@ from acme import messages logger = logging.getLogger(__name__) +# Python does not validate certificates by default before version 2.7.9 # https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning -if six.PY2: +if sys.version_info < (2, 7, 9): # pragma: no cover requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() @@ -31,7 +33,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes Clean up raised error types hierarchy, document, and handle (wrap) instances of `.DeserializationError` raised in `from_json()`. - :ivar str new_reg_uri: Location of new-reg + :ivar messages.Directory directory: :ivar key: `.JWK` (private) :ivar alg: `.JWASignature` :ivar bool verify_ssl: Verify SSL certificates? @@ -42,12 +44,23 @@ class Client(object): # pylint: disable=too-many-instance-attributes """ DER_CONTENT_TYPE = 'application/pkix-cert' - def __init__(self, new_reg_uri, key, alg=jose.RS256, - verify_ssl=True, net=None): - self.new_reg_uri = new_reg_uri + def __init__(self, directory, key, alg=jose.RS256, verify_ssl=True, + net=None): + """Initialize. + + :param directory: Directory Resource (`.messages.Directory`) or + URI from which the resource will be downloaded. + + """ self.key = key self.net = ClientNetwork(key, alg, verify_ssl) if net is None else net + if isinstance(directory, six.string_types): + self.directory = messages.Directory.from_json( + self.net.get(directory).json()) + else: + self.directory = directory + @classmethod def _regr_from_response(cls, response, uri=None, new_authzr_uri=None, terms_of_service=None): @@ -81,7 +94,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes new_reg = messages.NewRegistration() if new_reg is None else new_reg assert isinstance(new_reg, messages.NewRegistration) - response = self.net.post(self.new_reg_uri, new_reg) + response = self.net.post(self.directory[new_reg], new_reg) # TODO: handle errors assert response.status_code == http_client.CREATED @@ -416,20 +429,34 @@ class Client(object): # pylint: disable=too-many-instance-attributes # respond with status code 403 (Forbidden) return self.check_cert(certr) - def fetch_chain(self, certr): + def fetch_chain(self, certr, max_length=10): """Fetch chain for certificate. - :param certr: Certificate Resource - :type certr: `.CertificateResource` + :param .CertificateResource certr: Certificate Resource + :param int max_length: Maximum allowed length of the chain. + Note that each element in the certificate requires new + ``HTTP GET`` request, and the length of the chain is + controlled by the ACME CA. - :returns: Certificate chain, or `None` if no "up" Link was provided. - :rtype: `OpenSSL.crypto.X509` wrapped in `.ComparableX509` + :raises errors.Error: if recursion exceeds `max_length` + + :returns: Certificate chain for the Certificate Resource. It is + a list ordered so that the first element is a signer of the + certificate from Certificate Resource. Will be empty if + ``cert_chain_uri`` is ``None``. + :rtype: `list` of `OpenSSL.crypto.X509` wrapped in `.ComparableX509` """ - if certr.cert_chain_uri is not None: - return self._get_cert(certr.cert_chain_uri)[1] - else: - return None + chain = [] + uri = certr.cert_chain_uri + while uri is not None and len(chain) < max_length: + response, cert = self._get_cert(uri) + uri = response.links.get('up', {}).get('url') + chain.append(cert) + if uri is not None: + raise errors.Error( + "Recursion limit reached. Didn't get {0}".format(uri)) + return chain def revoke(self, cert): """Revoke certificate. @@ -440,8 +467,9 @@ class Client(object): # pylint: disable=too-many-instance-attributes :raises .ClientError: If revocation is unsuccessful. """ - response = self.net.post(messages.Revocation.url(self.new_reg_uri), - messages.Revocation(certificate=cert)) + response = self.net.post(self.directory[messages.Revocation], + messages.Revocation(certificate=cert), + content_type=None) if response.status_code != http_client.OK: raise errors.ClientError( 'Successful revocation must return HTTP OK status') @@ -559,7 +587,7 @@ class ClientNetwork(object): """Send HEAD request without checking the response. Note, that `_check_response` is not called, as it is expected - that status code other than successfuly 2xx will be returned, or + that status code other than successfully 2xx will be returned, or messages2.Error will be raised by the server. """ diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index dcc0832e3..7e895218c 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -33,10 +33,14 @@ class ClientTest(unittest.TestCase): self.net.post.return_value = self.response self.net.get.return_value = self.response + self.directory = messages.Directory({ + messages.NewRegistration: 'https://www.letsencrypt-demo.org/acme/new-reg', + messages.Revocation: 'https://www.letsencrypt-demo.org/acme/revoke-cert', + }) + from acme.client import Client self.client = Client( - new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', - key=KEY, alg=jose.RS256, net=self.net) + directory=self.directory, key=KEY, alg=jose.RS256, net=self.net) self.identifier = messages.Identifier( typ=messages.IDENTIFIER_FQDN, value='example.com') @@ -55,7 +59,8 @@ class ClientTest(unittest.TestCase): authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1' challb = messages.ChallengeBody( uri=(authzr_uri + '/1'), status=messages.STATUS_VALID, - chall=challenges.DNS(token='foo')) + chall=challenges.DNS(token=jose.b64decode( + 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA'))) self.challr = messages.ChallengeResource( body=challb, authzr_uri=authzr_uri) self.authz = messages.Authorization( @@ -72,6 +77,13 @@ class ClientTest(unittest.TestCase): uri='https://www.letsencrypt-demo.org/acme/cert/1', cert_chain_uri='https://www.letsencrypt-demo.org/ca') + def test_init_downloads_directory(self): + uri = 'http://www.letsencrypt-demo.org/directory' + from acme.client import Client + self.client = Client( + directory=uri, key=KEY, alg=jose.RS256, net=self.net) + self.net.get.assert_called_once_with(uri) + def test_register(self): # "Instance of 'Field' has no to_json/update member" bug: # pylint: disable=no-member @@ -155,7 +167,7 @@ class ClientTest(unittest.TestCase): self.response.links['up'] = {'url': self.challr.authzr_uri} self.response.json.return_value = self.challr.body.to_json() - chall_response = challenges.DNSResponse() + chall_response = challenges.DNSResponse(validation=None) self.client.answer_challenge(self.challr.body, chall_response) @@ -164,8 +176,9 @@ class ClientTest(unittest.TestCase): self.challr.body.update(uri='foo'), chall_response) def test_answer_challenge_missing_next(self): - self.assertRaises(errors.ClientError, self.client.answer_challenge, - self.challr.body, challenges.DNSResponse()) + self.assertRaises( + errors.ClientError, self.client.answer_challenge, + self.challr.body, challenges.DNSResponse(validation=None)) def test_retry_after_date(self): self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT' @@ -335,21 +348,39 @@ class ClientTest(unittest.TestCase): self.assertEqual( self.client.check_cert(self.certr), self.client.refresh(self.certr)) - def test_fetch_chain(self): + def test_fetch_chain_no_up_link(self): + self.assertEqual([], self.client.fetch_chain(self.certr.update( + cert_chain_uri=None))) + + def test_fetch_chain_single(self): # pylint: disable=protected-access self.client._get_cert = mock.MagicMock() - self.client._get_cert.return_value = ("response", "certificate") - self.assertEqual(self.client._get_cert(self.certr.cert_chain_uri)[1], + self.client._get_cert.return_value = ( + mock.MagicMock(links={}), "certificate") + self.assertEqual([self.client._get_cert(self.certr.cert_chain_uri)[1]], self.client.fetch_chain(self.certr)) - def test_fetch_chain_no_up_link(self): - self.assertTrue(self.client.fetch_chain(self.certr.update( - cert_chain_uri=None)) is None) + def test_fetch_chain_max(self): + # pylint: disable=protected-access + up_response = mock.MagicMock(links={'up': {'url': 'http://cert'}}) + noup_response = mock.MagicMock(links={}) + self.client._get_cert = mock.MagicMock() + self.client._get_cert.side_effect = [ + (up_response, "cert")] * 9 + [(noup_response, "last_cert")] + chain = self.client.fetch_chain(self.certr, max_length=10) + self.assertEqual(chain, ["cert"] * 9 + ["last_cert"]) + + def test_fetch_chain_too_many(self): # recursive + # pylint: disable=protected-access + response = mock.MagicMock(links={'up': {'url': 'http://cert'}}) + self.client._get_cert = mock.MagicMock() + self.client._get_cert.return_value = (response, "certificate") + self.assertRaises(errors.Error, self.client.fetch_chain, self.certr) def test_revoke(self): self.client.revoke(self.certr.body) - self.net.post.assert_called_once_with(messages.Revocation.url( - self.client.new_reg_uri), mock.ANY) + self.net.post.assert_called_once_with( + self.directory[messages.Revocation], mock.ANY, content_type=None) def test_revoke_bad_status_raises_error(self): self.response.status_code = http_client.METHOD_NOT_ALLOWED @@ -379,11 +410,14 @@ class ClientNetworkTest(unittest.TestCase): # pylint: disable=missing-docstring def __init__(self, value): self.value = value + def to_partial_json(self): return {'foo': self.value} + @classmethod def from_json(cls, value): pass # pragma: no cover + # pylint: disable=protected-access jws_dump = self.net._wrap_in_jws( MockJSONDeSerializable('foo'), nonce=b'Tg') @@ -487,6 +521,7 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase): self.all_nonces = [jose.b64encode(b'Nonce'), jose.b64encode(b'Nonce2')] self.available_nonces = self.all_nonces[:] + def send_request(*args, **kwargs): # pylint: disable=unused-argument,missing-docstring if self.available_nonces: diff --git a/acme/acme/crypto_util_test.py b/acme/acme/crypto_util_test.py index 49aacfa1b..64c7cb552 100644 --- a/acme/acme/crypto_util_test.py +++ b/acme/acme/crypto_util_test.py @@ -55,10 +55,11 @@ class ServeProbeSNITest(unittest.TestCase): def test_probe_not_recognized_name(self): self.assertRaises(errors.Error, self._probe, b'bar') - def test_probe_connection_error(self): - self._probe(b'foo') - time.sleep(1) # TODO: avoid race conditions in other way - self.assertRaises(errors.Error, self._probe, b'bar') + # TODO: py33/py34 tox hangs forever on do_hendshake in second probe + #def probe_connection_error(self): + # self._probe(b'foo') + # #time.sleep(1) # TODO: avoid race conditions in other way + # self.assertRaises(errors.Error, self._probe, b'bar') class PyOpenSSLCertOrReqSANTest(unittest.TestCase): diff --git a/acme/acme/jose/interfaces.py b/acme/acme/jose/interfaces.py index a714fee51..f841848b3 100644 --- a/acme/acme/jose/interfaces.py +++ b/acme/acme/jose/interfaces.py @@ -41,7 +41,7 @@ class JSONDeSerializable(object): be encoded into a JSON document. **Full serialization** produces a Python object composed of only basic types as required by the :ref:`conversion table `. **Partial - serialization** (acomplished by :meth:`to_partial_json`) + serialization** (accomplished by :meth:`to_partial_json`) produces a Python object that might also be built from other :class:`JSONDeSerializable` objects. diff --git a/acme/acme/jose/json_util.py b/acme/acme/jose/json_util.py index 51d55ebd9..7b95e3fce 100644 --- a/acme/acme/jose/json_util.py +++ b/acme/acme/jose/json_util.py @@ -307,6 +307,7 @@ def encode_b64jose(data): # b64encode produces ASCII characters only return b64.b64encode(data).decode('ascii') + def decode_b64jose(data, size=None, minimum=False): """Decode JOSE Base-64 field. @@ -324,13 +325,14 @@ def decode_b64jose(data, size=None, minimum=False): except error_cls as error: raise errors.DeserializationError(error) - if size is not None and ((not minimum and len(decoded) != size) - or (minimum and len(decoded) < size)): + if size is not None and ((not minimum and len(decoded) != size) or + (minimum and len(decoded) < size)): raise errors.DeserializationError( "Expected at least or exactly {0} bytes".format(size)) return decoded + def encode_hex16(value): """Hexlify. @@ -340,6 +342,7 @@ def encode_hex16(value): """ return binascii.hexlify(value).decode() + def decode_hex16(value, size=None, minimum=False): """Decode hexlified field. @@ -352,8 +355,8 @@ def decode_hex16(value, size=None, minimum=False): """ value = value.encode() - if size is not None and ((not minimum and len(value) != size * 2) - or (minimum and len(value) < size * 2)): + if size is not None and ((not minimum and len(value) != size * 2) or + (minimum and len(value) < size * 2)): raise errors.DeserializationError() error_cls = TypeError if six.PY2 else binascii.Error try: @@ -361,6 +364,7 @@ def decode_hex16(value, size=None, minimum=False): except error_cls as error: raise errors.DeserializationError(error) + def encode_cert(cert): """Encode certificate as JOSE Base-64 DER. @@ -371,6 +375,7 @@ def encode_cert(cert): return encode_b64jose(OpenSSL.crypto.dump_certificate( OpenSSL.crypto.FILETYPE_ASN1, cert)) + def decode_cert(b64der): """Decode JOSE Base-64 DER-encoded certificate. @@ -384,6 +389,7 @@ def decode_cert(b64der): except OpenSSL.crypto.Error as error: raise errors.DeserializationError(error) + def encode_csr(csr): """Encode CSR as JOSE Base-64 DER. @@ -394,6 +400,7 @@ def encode_csr(csr): return encode_b64jose(OpenSSL.crypto.dump_certificate_request( OpenSSL.crypto.FILETYPE_ASN1, csr)) + def decode_csr(b64der): """Decode JOSE Base-64 DER-encoded CSR. diff --git a/acme/acme/jose/json_util_test.py b/acme/acme/jose/json_util_test.py index 313282e67..a055f3bf7 100644 --- a/acme/acme/jose/json_util_test.py +++ b/acme/acme/jose/json_util_test.py @@ -52,6 +52,7 @@ class FieldTest(unittest.TestCase): # pylint: disable=missing-docstring def to_partial_json(self): return 'foo' # pragma: no cover + @classmethod def from_json(cls, jobj): pass # pragma: no cover @@ -93,14 +94,18 @@ class JSONObjectWithFieldsMetaTest(unittest.TestCase): self.field2 = Field('Baz2') # pylint: disable=invalid-name,missing-docstring,too-few-public-methods # pylint: disable=blacklisted-name + @six.add_metaclass(JSONObjectWithFieldsMeta) class A(object): __slots__ = ('bar',) baz = self.field + class B(A): pass + class C(A): baz = self.field2 + self.a_cls = A self.b_cls = B self.c_cls = C diff --git a/acme/acme/jose/jwa.py b/acme/acme/jose/jwa.py index 0c84905df..4ce5ca3f5 100644 --- a/acme/acme/jose/jwa.py +++ b/acme/acme/jose/jwa.py @@ -21,7 +21,7 @@ from acme.jose import jwk logger = logging.getLogger(__name__) -class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method +class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method # pylint: disable=too-few-public-methods # for some reason disable=abstract-method has to be on the line # above... @@ -159,7 +159,7 @@ class _JWAES(JWASignature): # pylint: disable=abstract-class-not-used def sign(self, key, msg): # pragma: no cover raise NotImplementedError() - def verify(self, key, msg, sig): # pragma: no cover + def verify(self, key, msg, sig): # pragma: no cover raise NotImplementedError() diff --git a/acme/acme/jose/jwk.py b/acme/acme/jose/jwk.py index d9b903eb0..7a976f189 100644 --- a/acme/acme/jose/jwk.py +++ b/acme/acme/jose/jwk.py @@ -231,7 +231,7 @@ class JWKRSA(JWK): 'n': numbers.n, 'e': numbers.e, } - else: # rsa.RSAPrivateKey + else: # rsa.RSAPrivateKey private = self.key.private_numbers() public = self.key.public_key().public_numbers() params = { diff --git a/acme/acme/jose/jws.py b/acme/acme/jose/jws.py index 392a2f074..61a3b5aea 100644 --- a/acme/acme/jose/jws.py +++ b/acme/acme/jose/jws.py @@ -53,7 +53,7 @@ class Header(json_util.JSONObjectWithFields): .. warning:: This class does not support any extensions through the "crit" (Critical) Header Parameter (4.1.11) and as a conforming implementation, :meth:`from_json` treats its - occurence as an error. Please subclass if you seek for + occurrence as an error. Please subclass if you seek for a different behaviour. :ivar x5tS256: "x5t#S256" @@ -294,10 +294,10 @@ class JWS(json_util.JSONObjectWithFields): # ... it must be in protected return ( - b64.b64encode(self.signature.protected.encode('utf-8')) - + b'.' + - b64.b64encode(self.payload) - + b'.' + + b64.b64encode(self.signature.protected.encode('utf-8')) + + b'.' + + b64.b64encode(self.payload) + + b'.' + b64.b64encode(self.signature.signature)) @classmethod @@ -345,6 +345,7 @@ class JWS(json_util.JSONObjectWithFields): signatures=tuple(cls.signature_cls.from_json(sig) for sig in jobj['signatures'])) + class CLI(object): """JWS CLI.""" diff --git a/acme/acme/jose/util.py b/acme/acme/jose/util.py index 704476795..ab3606efc 100644 --- a/acme/acme/jose/util.py +++ b/acme/acme/jose/util.py @@ -107,8 +107,8 @@ class ComparableRSAKey(ComparableKey): # pylint: disable=too-few-public-methods """Wrapper for `cryptography` RSA keys. Wraps around: - - `cryptography.hazmat.primitives.assymetric.RSAPrivateKey` - - `cryptography.hazmat.primitives.assymetric.RSAPublicKey` + - `cryptography.hazmat.primitives.asymmetric.RSAPrivateKey` + - `cryptography.hazmat.primitives.asymmetric.RSAPublicKey` """ diff --git a/acme/acme/messages.py b/acme/acme/messages.py index 970cf4e6e..02ae24c8f 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -1,11 +1,10 @@ """ACME protocol messages.""" import collections -from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error - from acme import challenges from acme import fields from acme import jose +from acme import util class Error(jose.JSONObjectWithFields, Exception): @@ -128,6 +127,56 @@ class Identifier(jose.JSONObjectWithFields): value = jose.Field('value') +class Directory(jose.JSONDeSerializable): + """Directory.""" + + _REGISTERED_TYPES = {} + + @classmethod + def _canon_key(cls, key): + return getattr(key, 'resource_type', key) + + @classmethod + def register(cls, resource_body_cls): + """Register resource.""" + assert resource_body_cls.resource_type not in cls._REGISTERED_TYPES + cls._REGISTERED_TYPES[resource_body_cls.resource_type] = resource_body_cls + return resource_body_cls + + def __init__(self, jobj): + canon_jobj = util.map_keys(jobj, self._canon_key) + if not set(canon_jobj).issubset(self._REGISTERED_TYPES): + # TODO: acme-spec is not clear about this: 'It is a JSON + # dictionary, whose keys are the "resource" values listed + # in {{https-requests}}'z + raise ValueError('Wrong directory fields') + # TODO: check that everything is an absolute URL; acme-spec is + # not clear on that + self._jobj = canon_jobj + + def __getattr__(self, name): + try: + return self[name.replace('_', '-')] + except KeyError as error: + raise AttributeError(str(error)) + + def __getitem__(self, name): + try: + return self._jobj[self._canon_key(name)] + except KeyError: + raise KeyError('Directory field not found') + + def to_partial_json(self): + return self._jobj + + @classmethod + def from_json(cls, jobj): + try: + return cls(jobj) + except ValueError as error: + raise jose.DeserializationError(str(error)) + + class Resource(jose.JSONObjectWithFields): """ACME Resource. @@ -216,16 +265,20 @@ class Registration(ResourceBody): """All emails found in the ``contact`` field.""" return self._filter_contact(self.email_prefix) + +@Directory.register class NewRegistration(Registration): """New registration.""" resource_type = 'new-reg' resource = fields.Resource(resource_type) + class UpdateRegistration(Registration): """Update registration.""" resource_type = 'reg' resource = fields.Resource(resource_type) + class RegistrationResource(ResourceWithURI): """Registration Resource. @@ -328,11 +381,14 @@ class Authorization(ResourceBody): return tuple(tuple(self.challenges[idx] for idx in combo) for combo in self.combinations) + +@Directory.register class NewAuthorization(Authorization): """New authorization.""" resource_type = 'new-authz' resource = fields.Resource(resource_type) + class AuthorizationResource(ResourceWithURI): """Authorization Resource. @@ -344,6 +400,7 @@ class AuthorizationResource(ResourceWithURI): new_cert_uri = jose.Field('new_cert_uri') +@Directory.register class CertificateRequest(jose.JSONObjectWithFields): """ACME new-cert request. @@ -369,6 +426,7 @@ class CertificateResource(ResourceWithURI): authzrs = jose.Field('authzrs') +@Directory.register class Revocation(jose.JSONObjectWithFields): """Revocation message. @@ -380,16 +438,3 @@ class Revocation(jose.JSONObjectWithFields): resource = fields.Resource(resource_type) certificate = jose.Field( 'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert) - - # TODO: acme-spec#138, this allows only one ACME server instance per domain - PATH = '/acme/revoke-cert' - """Path to revocation URL, see `url`""" - - @classmethod - def url(cls, base): - """Get revocation URL. - - :param str base: New Registration Resource or server (root) URL. - - """ - return urllib_parse.urljoin(base, cls.PATH) diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py index 481c2e2a3..d2d859bc5 100644 --- a/acme/acme/messages_test.py +++ b/acme/acme/messages_test.py @@ -60,6 +60,7 @@ class ConstantTest(unittest.TestCase): def setUp(self): from acme.messages import _Constant + class MockConstant(_Constant): # pylint: disable=missing-docstring POSSIBLE_NAMES = {} @@ -92,6 +93,45 @@ class ConstantTest(unittest.TestCase): self.assertFalse(self.const_a != const_a_prime) +class DirectoryTest(unittest.TestCase): + """Tests for acme.messages.Directory.""" + + def setUp(self): + from acme.messages import Directory + self.dir = Directory({ + 'new-reg': 'reg', + mock.MagicMock(resource_type='new-cert'): 'cert', + }) + + def test_init_wrong_key_value_error(self): + from acme.messages import Directory + self.assertRaises(ValueError, Directory, {'foo': 'bar'}) + + def test_getitem(self): + self.assertEqual('reg', self.dir['new-reg']) + from acme.messages import NewRegistration + self.assertEqual('reg', self.dir[NewRegistration]) + self.assertEqual('reg', self.dir[NewRegistration()]) + + def test_getitem_fails_with_key_error(self): + self.assertRaises(KeyError, self.dir.__getitem__, 'foo') + + def test_getattr(self): + self.assertEqual('reg', self.dir.new_reg) + + def test_getattr_fails_with_attribute_error(self): + self.assertRaises(AttributeError, self.dir.__getattr__, 'foo') + + def test_to_partial_json(self): + self.assertEqual( + self.dir.to_partial_json(), {'new-reg': 'reg', 'new-cert': 'cert'}) + + def test_from_json_deserialization_error_on_wrong_key(self): + from acme.messages import Directory + self.assertRaises( + jose.DeserializationError, Directory.from_json, {'foo': 'bar'}) + + class RegistrationTest(unittest.TestCase): """Tests for acme.messages.Registration.""" @@ -185,7 +225,8 @@ class ChallengeBodyTest(unittest.TestCase): """Tests for acme.messages.ChallengeBody.""" def setUp(self): - self.chall = challenges.DNS(token='foo') + self.chall = challenges.DNS(token=jose.b64decode( + 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA')) from acme.messages import ChallengeBody from acme.messages import Error @@ -201,7 +242,7 @@ class ChallengeBodyTest(unittest.TestCase): 'uri': 'http://challb', 'status': self.status, 'type': 'dns', - 'token': 'foo', + 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA', 'error': error, } self.jobj_from = self.jobj_to.copy() @@ -211,7 +252,6 @@ class ChallengeBodyTest(unittest.TestCase): 'detail': 'Unable to communicate with DNS server', } - def test_to_partial_json(self): self.assertEqual(self.jobj_to, self.challb.to_partial_json()) @@ -224,7 +264,8 @@ class ChallengeBodyTest(unittest.TestCase): hash(ChallengeBody.from_json(self.jobj_from)) def test_proxy(self): - self.assertEqual('foo', self.challb.token) + self.assertEqual(jose.b64decode( + 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA'), self.challb.token) class AuthorizationTest(unittest.TestCase): @@ -233,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, @@ -320,13 +362,6 @@ class CertificateResourceTest(unittest.TestCase): class RevocationTest(unittest.TestCase): """Tests for acme.messages.RevocationTest.""" - def test_url(self): - from acme.messages import Revocation - url = 'https://letsencrypt-demo.org/acme/revoke-cert' - self.assertEqual(url, Revocation.url('https://letsencrypt-demo.org')) - self.assertEqual( - url, Revocation.url('https://letsencrypt-demo.org/acme/new-reg')) - def setUp(self): from acme.messages import Revocation self.rev = Revocation(certificate=CERT) diff --git a/acme/acme/other.py b/acme/acme/other.py index 59bb0129b..edd7210b2 100644 --- a/acme/acme/other.py +++ b/acme/acme/other.py @@ -36,7 +36,7 @@ class Signature(jose.JSONObjectWithFields): :param bytes msg: Message to be signed. :param key: Key used for signing. - :type key: `cryptography.hazmat.primitives.assymetric.rsa.RSAPrivateKey` + :type key: `cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey` (optionally wrapped in `.ComparableRSAKey`). :param bytes nonce: Nonce to be used. If None, nonce of diff --git a/acme/acme/test_util.py b/acme/acme/test_util.py index 8ad118e17..c9c076d27 100644 --- a/acme/acme/test_util.py +++ b/acme/acme/test_util.py @@ -1,4 +1,4 @@ -# Symlinked in letsencrypt/tests/test_util.py, casues duplicate-code +# Symlinked in letsencrypt/tests/test_util.py, causes duplicate-code # warning that cannot be disabled locally. """Test utilities. @@ -20,12 +20,14 @@ def vector_path(*names): 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': @@ -35,6 +37,7 @@ def _guess_loader(filename, loader_pem, 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( @@ -42,6 +45,7 @@ def load_cert(*names): return jose.ComparableX509(OpenSSL.crypto.load_certificate( loader, load_vector(*names))) + def load_csr(*names): """Load certificate request.""" loader = _guess_loader( @@ -49,6 +53,7 @@ def load_csr(*names): 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, @@ -56,6 +61,7 @@ def load_rsa_private_key(*names): 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( diff --git a/acme/acme/util.py b/acme/acme/util.py new file mode 100644 index 000000000..1fff89a9e --- /dev/null +++ b/acme/acme/util.py @@ -0,0 +1,7 @@ +"""ACME utilities.""" +import six + + +def map_keys(dikt, func): + """Map dictionary keys.""" + return dict((func(key), value) for key, value in six.iteritems(dikt)) diff --git a/acme/acme/util_test.py b/acme/acme/util_test.py new file mode 100644 index 000000000..00aa8b02d --- /dev/null +++ b/acme/acme/util_test.py @@ -0,0 +1,16 @@ +"""Tests for acme.util.""" +import unittest + + +class MapKeysTest(unittest.TestCase): + """Tests for acme.util.map_keys.""" + + def test_it(self): + from acme.util import map_keys + self.assertEqual({'a': 'b', 'c': 'd'}, + map_keys({'a': 'b', 'c': 'd'}, lambda key: key)) + self.assertEqual({2: 2, 4: 4}, map_keys({1: 2, 3: 4}, lambda x: x + 1)) + + +if __name__ == '__main__': + unittest.main() # pragma: no cover diff --git a/acme/setup.py b/acme/setup.py index 5f1da2391..55c2d4985 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -5,17 +5,17 @@ from setuptools import find_packages install_requires = [ - 'argparse', # load_pem_private/public_key (>=0.6) # rsa_recover_prime_factors (>=0.8) 'cryptography>=0.8', - 'pyrfc3339', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'pyasn1', # urllib3 InsecurePlatformWarning (#304) # Connection.set_tlsext_host_name (>=0.13), X509Req.get_extensions (>=0.15) 'PyOpenSSL>=0.15', + 'pyrfc3339', 'pytz', 'requests', + 'setuptools', # pkg_resources 'six', 'werkzeug', ] diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh index 398cfe315..3fd0f59f9 100755 --- a/bootstrap/_rpm_common.sh +++ b/bootstrap/_rpm_common.sh @@ -4,8 +4,19 @@ # - Fedora 22 (x64) # - Centos 7 (x64: on AWS EC2 t2.micro, DigitalOcean droplet) +if type yum 2>/dev/null +then + tool=yum +elif type dnf 2>/dev/null +then + tool=dnf +else + echo "Neither yum nor dnf found. Aborting bootstrap!" + exit 1 +fi + # "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails) -yum install -y \ +$tool install -y \ git-core \ python \ python-devel \ diff --git a/bootstrap/freebsd.sh b/bootstrap/freebsd.sh new file mode 100755 index 000000000..180ee21b4 --- /dev/null +++ b/bootstrap/freebsd.sh @@ -0,0 +1,8 @@ +#!/bin/sh -xe + +pkg install -Ay \ + git \ + python \ + py27-virtualenv \ + augeas \ + libffi \ diff --git a/bootstrap/mac.sh b/bootstrap/mac.sh index a48afe11e..6779188a7 100755 --- a/bootstrap/mac.sh +++ b/bootstrap/mac.sh @@ -1,2 +1,8 @@ #!/bin/sh +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 augeas +brew install dialog 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/contributing.rst b/docs/contributing.rst index e4d7da1f9..c6443e3b2 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -52,7 +52,8 @@ The following tools are there to help you: before submitting a new pull request. - ``tox -e cover`` checks the test coverage only. Calling the - ``./tox.cover.sh`` script directly might be a bit quicker, though. + ``./tox.cover.sh`` script directly (or even ``./tox.cover.sh $pkg1 + $pkg2 ...`` for any subpackages) might be a bit quicker, though. - ``tox -e lint`` checks the style of the whole project, while ``pylint --rcfile=.pylintrc path`` will check a single file or @@ -60,27 +61,29 @@ The following tools are there to help you: - For debugging, we recommend ``pip install ipdb`` and putting ``import ipdb; ipdb.set_trace()`` statement inside the source - code. Alternatively, you can use Python'd standard library `pdb`, + code. Alternatively, you can use Python's standard library `pdb`, but you won't get TAB completion... Integration ~~~~~~~~~~~ -First, install `Go`_ 1.5 and start Boulder_, an ACME CA server:: +First, install `Go`_ 1.5, libtool-ltdl, mariadb-server and +rabbitmq-server and then start Boulder_, an ACME CA server:: ./tests/boulder-start.sh The script will download, compile and run the executable; please be patient - it will take some time... Once its ready, you will see -``Server running, listening on 127.0.0.1:4000...``. You may now run -(in a separate terminal):: +``Server running, listening on 127.0.0.1:4000...``. Add an +``/etc/hosts`` entry pointing ``le.wtf`` to 127.0.0.1. You may now +run (in a separate terminal):: ./tests/boulder-integration.sh && echo OK || echo FAIL If you would like to test `letsencrypt_nginx` plugin (highly encouraged) make sure to install prerequisites as listed in -``tests/integration/nginx.sh``: +``letsencrypt-nginx/tests/boulder-integration.sh``: .. include:: ../letsencrypt-nginx/tests/boulder-integration.sh :start-line: 1 @@ -126,9 +129,8 @@ Docker OSX users will probably find it easiest to set up a Docker container for development. Let's Encrypt comes with a Dockerfile (``Dockerfile-dev``) -for doing so. To use Docker on OSX, install boot2docker using the -instructions at https://docs.docker.com/installation/mac/ and start it -from the command line (``boot2docker init``). +for doing so. To use Docker on OSX, install and setup docker-machine using the +instructions at https://docs.docker.com/installation/mac/. To build the development Docker image:: diff --git a/docs/using.rst b/docs/using.rst index d22f22076..cfce29bae 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -102,6 +102,21 @@ Centos 7 sudo ./bootstrap/centos.sh +FreeBSD +------- + +.. code-block:: shell + + sudo ./bootstrap/freebsd.sh + +Bootstrap script for FreeBSD uses ``pkg`` for package installation, +i.e. it does not use ports. + +FreeBSD by default uses ``tcsh``. In order to activate virtulenv (see +below), you will need a compatbile shell, e.g. ``pkg install bash && +bash``. + + Installation ============ @@ -129,7 +144,7 @@ To get a new certificate run: .. code-block:: shell - ./venv/bin/letsencrypt auth + sudo ./venv/bin/letsencrypt auth The ``letsencrypt`` commandline tool has a builtin help: 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/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 8403b974c..f3d2b5f9a 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -84,7 +84,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): description = "Apache Web Server - Alpha" - @classmethod def add_parser_arguments(cls, add): add("ctl", default=constants.CLI_DEFAULTS["ctl"], @@ -138,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() @@ -283,7 +288,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.assoc[target_name] = vhost return vhost - def _find_best_vhost(self, target_name): """Finds the best vhost for a target_name. @@ -492,7 +496,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ if "ssl_module" not in self.parser.modules: - logger.info("Loading mod_ssl into Apache Server") self.enable_mod("ssl", temp=temp) # Check for Listen @@ -583,7 +586,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ssl_vhost = self._create_vhost(vh_p) self.vhosts.append(ssl_vhost) - # NOTE: Searches through Augeas seem to ruin changes to directives # The configuration must also be saved before being searched # for the new directives; For these reasons... this is tacked @@ -794,7 +796,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): raise errors.PluginError( "Let's Encrypt has already enabled redirection") - def _create_redirect_vhost(self, ssl_vhost): """Creates an http_vhost specifically to redirect for the ssl_vhost. @@ -997,22 +998,41 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ # Support Debian specific setup - if (not os.path.isdir(os.path.join(self.parser.root, "mods-available")) - or not os.path.isdir( - os.path.join(self.parser.root, "mods-enabled"))): + avail_path = os.path.join(self.parser.root, "mods-available") + enabled_path = os.path.join(self.parser.root, "mods-enabled") + if not os.path.isdir(avail_path) or not os.path.isdir(enabled_path): raise errors.NotSupportedError( "Unsupported directory layout. You may try to enable mod %s " "and try again." % mod_name) + deps = _get_mod_deps(mod_name) + + # Enable all dependencies + for dep in deps: + if (dep + "_module") not in self.parser.modules: + self._enable_mod_debian(dep, temp) + self._add_parser_mod(dep) + + note = "Enabled dependency of %s module - %s" % (mod_name, dep) + if not temp: + self.save_notes += note + os.linesep + logger.debug(note) + + # Enable actual module self._enable_mod_debian(mod_name, temp) - self.save_notes += "Enabled %s module in Apache" % mod_name - logger.debug("Enabled Apache %s module", mod_name) + self._add_parser_mod(mod_name) + + if not temp: + self.save_notes += "Enabled %s module in Apache\n" % mod_name + logger.info("Enabled Apache %s module", mod_name) # Modules can enable additional config files. Variables may be defined # within these new configuration sections. # Restart is not necessary as DUMP_RUN_CFG uses latest config. self.parser.update_runtime_variables(self.conf("ctl")) + def _add_parser_mod(self, mod_name): + """Shortcut for updating parser modules.""" self.parser.modules.add(mod_name + "_module") self.parser.modules.add("mod_" + mod_name + ".c") @@ -1140,6 +1160,25 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.parser.init_modules() +def _get_mod_deps(mod_name): + """Get known module dependencies. + + .. note:: This does not need to be accurate in order for the client to + run. This simply keeps things clean if the user decides to revert + 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 (`.ApacheConfigurator._add_parser_mod`) + This would only present a major problem in extremely atypical + configs that use ifmod for the missing deps. + + """ + deps = { + "ssl": ["setenvif", "mime", "socache_shmcb"] + } + return deps.get(mod_name, []) + + def apache_restart(apache_init_script): """Restarts the Apache Server. diff --git a/letsencrypt-apache/letsencrypt_apache/obj.py b/letsencrypt-apache/letsencrypt_apache/obj.py index 8cd2378a4..58a6c740e 100644 --- a/letsencrypt-apache/letsencrypt_apache/obj.py +++ b/letsencrypt-apache/letsencrypt_apache/obj.py @@ -14,8 +14,8 @@ class Addr(common.Addr): """ if isinstance(other, self.__class__): return ((self.tup == other.tup) or - (self.tup[0] == other.tup[0] - and self.is_wildcard() and other.is_wildcard())) + (self.tup[0] == other.tup[0] and + self.is_wildcard() and other.is_wildcard())) return False def __ne__(self, other): diff --git a/letsencrypt-apache/letsencrypt_apache/parser.py b/letsencrypt-apache/letsencrypt_apache/parser.py index da3fc97e7..0a3643064 100644 --- a/letsencrypt-apache/letsencrypt_apache/parser.py +++ b/letsencrypt-apache/letsencrypt_apache/parser.py @@ -195,8 +195,7 @@ class ApacheParser(object): self.aug.set(nvh_path + "/arg", args[0]) else: for i, arg in enumerate(args): - self.aug.set("%s/arg[%d]" % (nvh_path, i+1), arg) - + self.aug.set("%s/arg[%d]" % (nvh_path, i + 1), arg) def _get_ifmod(self, aug_conf_path, mod): """Returns the path to and creates one if it doesn't exist. @@ -242,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 @@ -316,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: @@ -391,10 +402,15 @@ 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 ../ arg = os.path.normpath(os.path.join(self.root, arg)) + else: + arg = os.path.normpath(arg) # Attempts to add a transform to the file if one does not already exist if os.path.isdir(arg): @@ -568,7 +584,7 @@ def case_i(string): :param str string: string to make case i regex """ - return "".join(["["+c.upper()+c.lower()+"]" + return "".join(["[" + c.upper() + c.lower() + "]" if c.isalpha() else c for c in re.escape(string)]) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py b/letsencrypt-apache/letsencrypt_apache/tests/complex_parsing_test.py index 406b6c39e..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"] @@ -56,7 +63,6 @@ class ComplexParserTest(util.ParserTest): self.assertRaises( errors.PluginError, self.parser.get_arg, matches[0]) - def test_basic_ifdefine(self): self.assertEqual(len(self.parser.find_dir("VAR_DIRECTIVE")), 2) self.assertEqual(len(self.parser.find_dir("INVALID_VAR_DIRECTIVE")), 0) @@ -71,7 +77,6 @@ class ComplexParserTest(util.ParserTest): self.assertEqual( len(self.parser.find_dir("INVALID_NESTED_DIRECTIVE")), 0) - def test_load_modules(self): """If only first is found, there is bad variable parsing.""" self.assertTrue("status_module" in self.parser.modules) @@ -91,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") @@ -100,6 +106,15 @@ class ComplexParserTest(util.ParserTest): def test_include_fullpath(self): self.verify_fnmatch(os.path.join(self.config_path, "test_fnmatch.conf")) + 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 71599bd1d..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)) @@ -551,6 +559,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertRaises( errors.PluginError, self.config.enhance, "letsencrypt.demo", "redirect") + def test_unknown_rewrite2(self): # Skip the enable mod self.config.parser.modules.add("rewrite_module") diff --git a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py index ce234bff7..d2e4dec14 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py @@ -143,7 +143,7 @@ class BasicParserTest(util.ParserTest): 'Group: name="www-data" id=33 not_used\n' ) expected_vars = {"TEST": "", "U_MICH": "", "TLS": "443", - "example_path":"Documents/path"} + "example_path": "Documents/path"} self.parser.update_runtime_variables("ctl") self.assertEqual(self.parser.variables, expected_vars) 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 5f1b0a95d..c7615f3f5 100644 --- a/letsencrypt-apache/setup.py +++ b/letsencrypt-apache/setup.py @@ -8,6 +8,7 @@ install_requires = [ 'acme', 'letsencrypt', 'python-augeas', + 'setuptools', # pkg_resources 'zope.component', 'zope.interface', ] @@ -24,7 +25,7 @@ setup( entry_points={ 'letsencrypt.plugins': [ 'apache = letsencrypt_apache.configurator:ApacheConfigurator', - ], + ], }, include_package_data=True, ) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py index 2ffc44976..3cc6fdf8e 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py @@ -11,7 +11,7 @@ from letsencrypt_compatibility_test.configurators.apache import common as apache # config uses mod_heartbeat or mod_heartmonitor (which aren't installed and # therefore the config won't be loaded), I believe this isn't a problem # http://httpd.apache.org/docs/2.4/mod/mod_watchdog.html -STATIC_MODULES = {"core", "so", "http", "mpm_event", "watchdog",} +STATIC_MODULES = set(["core", "so", "http", "mpm_event", "watchdog"]) SHARED_MODULES = { @@ -31,7 +31,7 @@ SHARED_MODULES = { "session_cookie", "session_crypto", "session_dbd", "setenvif", "slotmem_shm", "socache_dbm", "socache_memcache", "socache_shmcb", "speling", "ssl", "status", "substitute", "unique_id", "userdir", - "vhost_alias",} + "vhost_alias"} class Proxy(apache_common.Proxy): diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py index 65f14bbe9..7c5e5dfcb 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py @@ -72,11 +72,10 @@ class Proxy(object): logger.debug(line) host_config = docker.utils.create_host_config( - binds={ - self._temp_dir : {"bind" : self._temp_dir, "mode" : "rw"}}, + binds={self._temp_dir: {"bind": self._temp_dir, "mode": "rw"}}, port_bindings={ - 80 : ("127.0.0.1", self.http_port), - 443 : ("127.0.0.1", self.https_port)},) + 80: ("127.0.0.1", self.http_port), + 443: ("127.0.0.1", self.https_port)},) container = self._docker_client.create_container( image_name, command, ports=[80, 443], volumes=self._temp_dir, host_config=host_config) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py index b0785fa8e..fcf7a504f 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py @@ -23,7 +23,7 @@ class IPluginProxy(zope.interface.Interface): def cleanup_from_tests(): """Performs any necessary cleanup from running plugin tests. - This is guarenteed to be called before the program exits. + This is guaranteed to be called before the program exits. """ diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index eac2278bb..b91322c3c 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -30,7 +30,7 @@ tests that the plugin supports are performed. """ -PLUGINS = {"apache" : apache24.Proxy} +PLUGINS = {"apache": apache24.Proxy} logger = logging.getLogger(__name__) @@ -191,7 +191,7 @@ def test_enhancements(plugin, domains): success = True for domain in domains: verify = functools.partial(validator.Validator().redirect, "localhost", - plugin.http_port, headers={"Host" : domain}) + plugin.http_port, headers={"Host": domain}) if not _try_until_true(verify): logger.error("Improper redirect for domain %s", domain) success = False diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py index 03b15d217..6181da16b 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py @@ -34,12 +34,12 @@ def create_le_config(parent_dir): os.mkdir(config["work_dir"]) os.mkdir(config["logs_dir"]) - return argparse.Namespace(**config) # pylint: disable=star-args + return argparse.Namespace(**config) # pylint: disable=star-args def extract_configs(configs, parent_dir): """Extracts configs to a new dir under parent_dir and returns it""" - config_dir = os.path.join(parent_dir, "configs") + config_dir = os.path.join(parent_dir, "renewal") if os.path.isdir(configs): shutil.copytree(configs, config_dir, symlinks=True) 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/letsencrypt_nginx/nginxparser.py b/letsencrypt-nginx/letsencrypt_nginx/nginxparser.py index 814b5f15e..2926a43d0 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/nginxparser.py +++ b/letsencrypt-nginx/letsencrypt_nginx/nginxparser.py @@ -7,6 +7,7 @@ from pyparsing import ( from pyparsing import stringEnd from pyparsing import restOfLine + class RawNginxParser(object): # pylint: disable=expression-not-assigned """A class that parses nginx configuration with pyparsing.""" @@ -32,10 +33,10 @@ class RawNginxParser(object): block = Forward() block << Group( - (Group(key + location_statement) ^ Group(if_statement)) - + left_bracket - + Group(ZeroOrMore(Group(comment | assignment) | block)) - + right_bracket) + (Group(key + location_statement) ^ Group(if_statement)) + + left_bracket + + Group(ZeroOrMore(Group(comment | assignment) | block)) + + right_bracket) script = OneOrMore(Group(comment | assignment) ^ block) + stringEnd diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py index a164397b6..a09bebba2 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py @@ -41,7 +41,6 @@ class DvsniPerformTest(util.NginxTest): domain="www.example.org", account_key=account_key), ] - def setUp(self): super(DvsniPerformTest, self).setUp() diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py index 9c8c6a5dd..363944490 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py @@ -4,9 +4,12 @@ import pkg_resources import unittest import mock +import zope.component from acme import jose +from letsencrypt import configuration + from letsencrypt.tests import test_util from letsencrypt.plugins import common @@ -55,11 +58,17 @@ def get_nginx_configurator( backup_dir=backups, temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), in_progress_dir=os.path.join(backups, "IN_PROGRESS"), + server="https://acme-server.org:443/new", dvsni_port=5001, ), name="nginx", version=version) config.prepare() + + # Provide general config utility. + nsconfig = configuration.NamespaceConfig(config.config) + zope.component.provideUtility(nsconfig) + return config diff --git a/letsencrypt-nginx/setup.py b/letsencrypt-nginx/setup.py index 64742e7b6..3eb70bede 100644 --- a/letsencrypt-nginx/setup.py +++ b/letsencrypt-nginx/setup.py @@ -7,7 +7,9 @@ from setuptools import find_packages install_requires = [ 'acme', 'letsencrypt', + 'PyOpenSSL', 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? + 'setuptools', # pkg_resources 'zope.interface', ] @@ -23,7 +25,7 @@ setup( entry_points={ 'letsencrypt.plugins': [ 'nginx = letsencrypt_nginx.configurator:NginxConfigurator', - ], + ], }, include_package_data=True, ) diff --git a/letsencrypt/account.py b/letsencrypt/account.py index 22f625bca..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) @@ -62,7 +62,7 @@ class Account(object): # pylint: disable=too-few-public-methods # Implementation note: Email? Multiple accounts can have the # same email address. Registration URI? Assigned by the # server, not guaranteed to be stable over time, nor - # cannonical URI can be generated. ACME protocol doesn't allow + # canonical URI can be generated. ACME protocol doesn't allow # account key (and thus its fingerprint) to be updated... @property @@ -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): @@ -129,8 +129,9 @@ class AccountFileStorage(interfaces.AccountStorage): """ def __init__(self, config): - le_util.make_or_verify_dir(config.accounts_dir, 0o700, os.geteuid()) self.config = config + le_util.make_or_verify_dir(config.accounts_dir, 0o700, os.geteuid(), + self.config.strict_permissions) def _account_dir_path(self, account_id): return os.path.join(self.config.accounts_dir, account_id) @@ -186,7 +187,8 @@ class AccountFileStorage(interfaces.AccountStorage): def save(self, account): account_dir_path = self._account_dir_path(account.id) - le_util.make_or_verify_dir(account_dir_path, 0o700, os.geteuid()) + le_util.make_or_verify_dir(account_dir_path, 0o700, os.geteuid(), + self.config.strict_permissions) try: with open(self._regr_path(account_dir_path), "w") as regr_file: regr_file.write(account.regr.json_dumps()) diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 894510191..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) @@ -244,7 +244,7 @@ class AuthHandler(object): """ for authzr_challb in authzr.body.challenges: - if type(authzr_challb.chall) is type(achall.challb.chall): + if type(authzr_challb.chall) is type(achall.challb.chall): # noqa return authzr_challb raise errors.AuthorizationError( "Target challenge not found in authorization resource") @@ -493,26 +493,27 @@ _ERROR_HELP_COMMON = ( _ERROR_HELP = { - "connection" : + "connection": _ERROR_HELP_COMMON + " Additionally, please check that your computer " "has publicly routable IP address and no firewalls are preventing the " "server from communicating with the client.", - "dnssec" : + "dnssec": _ERROR_HELP_COMMON + " Additionally, if you have DNSSEC enabled for " "your domain, please ensure the signature is valid.", - "malformed" : + "malformed": "To fix these errors, please make sure that you did not provide any " "invalid information to the client and try running Let's Encrypt " "again.", - "serverInternal" : + "serverInternal": "Unfortunately, an error on the ACME server prevented you from completing " "authorization. Please try again later.", - "tls" : + "tls": _ERROR_HELP_COMMON + " Additionally, please check that you have an up " "to date TLS configuration that allows the server to communicate with " "the Let's Encrypt client.", - "unauthorized" : _ERROR_HELP_COMMON, - "unknownHost" : _ERROR_HELP_COMMON,} + "unauthorized": _ERROR_HELP_COMMON, + "unknownHost": _ERROR_HELP_COMMON, +} def _report_failed_challs(failed_achalls): @@ -530,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 066aa388d..73dd24bdb 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -12,21 +12,29 @@ import time import traceback import configargparse +import configobj +import OpenSSL import zope.component import zope.interface.exceptions import zope.interface.verify +from acme import client as acme_client +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 +from letsencrypt import crypto_util from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import le_util from letsencrypt import log from letsencrypt import reporter +from letsencrypt import storage from letsencrypt.display import util as display_util from letsencrypt.display import ops as display_ops @@ -69,11 +77,11 @@ Choice of server for authentication/installation: More detailed help: - -h, --help [topic] print this message, or detailed help on a topic; + -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 """ @@ -159,8 +167,149 @@ def _init_le_client(args, config, authenticator, installer): return client.Client(config, acc, authenticator, installer, acme=acme) -def run(args, config, plugins): +def _find_duplicative_certs(domains, config, renew_config): + """Find existing certs that duplicate the request.""" + + 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: + full_path = os.path.join(configs_dir, renewal_file) + rc_config = configobj.ConfigObj(renew_config.renewer_config_file) + rc_config.merge(configobj.ConfigObj(full_path)) + rc_config.filename = full_path + candidate_lineage = storage.RenewableCert( + rc_config, config_opts=None, cli_config=cli_config) + except (configobj.ConfigObjError, errors.CertStorageError, IOError): + logger.warning("Renewal configuration file %s is broken. " + "Skipping.", full_path) + continue + # TODO: Handle these differently depending on whether they are + # expired or still valid? + candidate_names = set(candidate_lineage.names()) + if candidate_names == set(domains): + identical_names_cert = candidate_lineage + elif candidate_names.issubset(set(domains)): + subset_names_cert = candidate_lineage + + 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" @@ -179,22 +328,28 @@ def run(args, config, plugins): if installer is None or authenticator is None: return "Configurator could not be determined" + # End authenticator and installer setup domains = _find_domains(args, installer) + # TODO: Handle errors from _init_le_client? le_client = _init_le_client(args, config, authenticator, installer) - lineage = le_client.obtain_and_enroll_certificate( - domains, authenticator, installer, plugins) - if not lineage: - return "Certificate could not be obtained" + + 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 @@ -214,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): @@ -241,16 +396,20 @@ def install(args, config, plugins): le_client.enhance_config(domains, args.redirect) -def revoke(args, unused_config, unused_plugins): +def revoke(args, config, unused_plugins): # TODO: coop with renewal config """Revoke a previously obtained certificate.""" - if args.cert_path is None and args.key_path is None: - return "At least one of --cert-path or --key-path is required" - - # This depends on the renewal config and cannot be completed yet. - zope.component.getUtility(interfaces.IDisplay).notification( - "Revocation is not available with the new Boulder server yet.") - #client.revoke(args.installer, config, plugins, args.no_confirm, - # args.cert_path, args.key_path) + if args.key_path is not None: # revocation by cert key + logger.debug("Revoking %s using cert key %s", + args.cert_path[0], args.key_path[0]) + acme = acme_client.Client( + config.server, key=jose.JWK.load(args.key_path[1])) + else: # revocation by account key + logger.debug("Revoking %s using Account Key", args.cert_path[0]) + acc, _ = _determine_account(args, config) + # pylint: disable=protected-access + acme = client._acme_from_config_key(config, acc.key) + acme.revoke(jose.ComparableX509(crypto_util.pyopenssl_load_certificate( + args.cert_path[1])[0])) def rollback(args, config, plugins): @@ -272,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: @@ -334,15 +493,13 @@ class SilentParser(object): # pylint: disable=too-few-public-methods """ def __init__(self, parser): self.parser = parser + def add_argument(self, *args, **kwargs): """Wrap, but silence help""" kwargs["help"] = argparse.SUPPRESS self.parser.add_argument(*args, **kwargs) -HELP_TOPICS = ["all", "security", "paths", "automation", "testing", "plugins"] - - class HelpfulArgumentParser(object): """Argparse Wrapper. @@ -352,7 +509,6 @@ class HelpfulArgumentParser(object): """ def __init__(self, args, plugins): - self.args = args plugin_names = [name for name, _p in plugins.iteritems()] self.help_topics = HELP_TOPICS + plugin_names + [None] self.parser = configargparse.ArgParser( @@ -362,14 +518,16 @@ class HelpfulArgumentParser(object): default_config_files=flag_default("config_files")) # This is the only way to turn off overly verbose config flag documentation - self.parser._add_config_file_help = False # pylint: disable=protected-access + 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 == True: + if help_arg is True: # just --help with no topic; avoid argparse altogether print USAGE sys.exit(0) @@ -377,6 +535,26 @@ class HelpfulArgumentParser(object): #print self.visible_topics self.groups = {} # elements are added by .add_group() + def preprocess_args(self, args): + """Work around some limitations in argparse. + + 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 i, token in enumerate(args): + if token in VERBS: + 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. @@ -483,6 +661,9 @@ def create_parser(plugins, args): #for subparser in parser_run, parser_auth, parser_install: # subparser.add_argument("domains", nargs="*", metavar="domain") helpful.add(None, "-d", "--domains", metavar="DOMAIN", action="append") + helpful.add( + None, "--duplicate", dest="duplicate", action="store_true", + help="Allow getting a certificate that duplicates an existing one") helpful.add_group( "automation", @@ -492,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") @@ -533,6 +715,10 @@ def create_parser(plugins, args): "security", "-r", "--redirect", action="store_true", help="Automatically redirect all HTTP traffic to HTTPS for the newly " "authenticated vhost.") + helpful.add( + "security", "--strict-permissions", action="store_true", + help="Require that all configuration files are owned by the current " + "user; only needed if your config is somewhere unsafe like /tmp/") _paths_parser(helpful) # _plugins_parsing should be the last thing to act upon the main @@ -541,73 +727,83 @@ def create_parser(plugins, args): _create_subparsers(helpful) - return helpful.parser + 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"] +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="Accompynying 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.") - parser_revoke.add_argument( - "--key-path", type=read_file, - help="Revoke all certs generated by the provided authorized key.") - - parser_rollback.add_argument( + 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_plugins.add_argument( + helpful.add("plugins", "--init", action="store_true", help="Initialize plugins.") - parser_plugins.add_argument( - "--prepare", action="store_true", - help="Initialize and prepare plugins.") - parser_plugins.add_argument( + 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.") - parser_plugins.add_argument( + 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"), @@ -623,7 +819,7 @@ def _plugins_parsing(helpful, plugins): "plugins", description="Let's Encrypt client supports an " "extensible plugins architecture. See '%(prog)s plugins' for a " "list of all available plugins and their names. You can force " - "a particular plugin by setting options provided below. Futher " + "a particular plugin by setting options provided below. Further " "down this help message you will find plugin-specific options " "(prefixed by --{plugin_name}).") helpful.add( @@ -646,7 +842,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() @@ -660,7 +856,7 @@ def _setup_logging(args): # TODO: change before release? log_file_name = os.path.join(args.logs_dir, 'letsencrypt.log') file_handler = logging.handlers.RotatingFileHandler( - log_file_name, maxBytes=2**20, backupCount=10) + log_file_name, maxBytes=2 ** 20, backupCount=10) # rotate on each invocation, rollover only possible when maxBytes # is nonzero and backupCount is nonzero, so we set maxBytes as big # as possible not to overrun in single CLI invocation (1MB). @@ -691,7 +887,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): @@ -701,20 +898,23 @@ def _handle_exception(exc_type, exc_value, trace, args): with open(logfile, "w") as logfd: traceback.print_exception( exc_type, exc_value, trace, file=logfd) - except: # pylint: disable=bare-except + except: # pylint: disable=bare-except sys.exit("".join( traceback.format_exception(exc_type, exc_value, trace))) if issubclass(exc_type, errors.Error): sys.exit(exc_value) - elif args is None: - sys.exit( - "An unexpected error occurred. Please see the logfile '{0}' " - "for more details.".format(logfile)) else: - sys.exit( - "An unexpected error occurred. Please see the logfiles in {0} " - "for more details.".format(args.logs_dir)) + # Tell the user a bit about what happened, without overwhelming + # them with a full traceback + msg = ("An unexpected error occurred.\n" + + traceback.format_exception_only(exc_type, exc_value)[0] + + "Please see the ") + if args is None: + msg += "logfile '{0}' for more details.".format(logfile) + else: + msg += "logfiles in {0} for more details.".format(args.logs_dir) + sys.exit(msg) else: sys.exit("".join( traceback.format_exception(exc_type, exc_value, trace))) @@ -726,17 +926,21 @@ def main(cli_args=sys.argv[1:]): # note: arg parser internally handles --help (and exits afterwards) plugins = plugins_disco.PluginsRegistry.find_all() - args = create_parser(plugins, cli_args).parse_args(cli_args) + parser, tweaked_cli_args = create_parser(plugins, cli_args) + args = parser.parse_args(tweaked_cli_args) config = configuration.NamespaceConfig(args) + zope.component.provideUtility(config) # Setup logging ASAP, otherwise "No handlers could be found for # logger ..." TODO: this should be done before plugins discovery for directory in config.config_dir, config.work_dir: le_util.make_or_verify_dir( - directory, constants.CONFIG_DIRS_MODE, os.geteuid()) + directory, constants.CONFIG_DIRS_MODE, os.geteuid(), + "--strict-permissions" in cli_args) # TODO: logs might contain sensitive data such as contents of the # private key! #525 - le_util.make_or_verify_dir(args.logs_dir, 0o700, os.geteuid()) + le_util.make_or_verify_dir( + args.logs_dir, 0o700, os.geteuid(), "--strict-permissions" in cli_args) _setup_logging(args) # do not log `args`, as it contains sensitive data (e.g. revoke --key)! diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e8dd08c8e..c82131af3 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 @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) def _acme_from_config_key(config, key): # TODO: Allow for other alg types besides RS256 - return acme_client.Client(new_reg_uri=config.server, key=key, + return acme_client.Client(directory=config.server, key=key, verify_ssl=(not config.no_verify_ssl)) @@ -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. @@ -261,14 +253,11 @@ class Client(object): "Non-standard path(s), might not work with crontab installed " "by your operating system package manager") - # XXX: just to stop RenewableCert from complaining; this is - # probably not a good solution - chain_pem = "" if chain is None else OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, chain) lineage = storage.RenewableCert.new_lineage( domains[0], OpenSSL.crypto.dump_certificate( OpenSSL.crypto.FILETYPE_PEM, certr.body), - key.pem, chain_pem, params, config, cli_config) + key.pem, crypto_util.dump_pyopenssl_chain(chain), + params, config, cli_config) self._report_renewal_status(lineage) return lineage @@ -279,8 +268,8 @@ class Client(object): :param .RenewableCert cert: Newly issued certificate """ - if ("autorenew" not in cert.configuration - or cert.configuration.as_bool("autorenew")): + if ("autorenew" not in cert.configuration or + cert.configuration.as_bool("autorenew")): if ("autodeploy" not in cert.configuration or cert.configuration.as_bool("autodeploy")): msg = "Automatic renewal and deployment has " @@ -297,7 +286,7 @@ class Client(object): "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 @@ -306,7 +295,7 @@ class Client(object): :param certr: ACME "certificate" resource. :type certr: :class:`acme.messages.Certificate` - :param chain_cert: + :param list chain_cert: :param str cert_path: Candidate path to a certificate. :param str chain_path: Candidate path to a certificate chain. @@ -318,7 +307,8 @@ class Client(object): """ for path in cert_path, chain_path: le_util.make_or_verify_dir( - os.path.dirname(path), 0o755, os.geteuid()) + os.path.dirname(path), 0o755, os.geteuid(), + self.config.strict_permissions) # try finally close cert_chain_abspath = None @@ -333,12 +323,11 @@ class Client(object): logger.info("Server issued certificate; certificate written to %s", act_cert_path) - if chain_cert is not None: + if chain_cert: chain_file, act_chain_path = le_util.unique_file( chain_path, 0o644) # TODO: Except - chain_pem = OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, chain_cert) + chain_pem = crypto_util.dump_pyopenssl_chain(chain_cert) try: chain_file.write(chain_pem) finally: @@ -367,18 +356,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() - - display_ops.success_installation(domains) + 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. @@ -404,6 +392,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) @@ -414,14 +404,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): @@ -490,27 +482,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 c7c780535..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` @@ -45,7 +44,7 @@ class NamespaceConfig(object): return (parsed.netloc + parsed.path).replace('/', os.path.sep) @property - def accounts_dir(self): #pylint: disable=missing-docstring + def accounts_dir(self): # pylint: disable=missing-docstring return os.path.join( self.namespace.config_dir, constants.ACCOUNTS_DIR, self.server_path) @@ -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 230860762..762409d25 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -16,7 +16,7 @@ CLI_DEFAULTS = dict( "letsencrypt", "cli.ini"), ], verbose_count=-(logging.WARNING / 10), - server="https://acme-staging.api.letsencrypt.org/acme/new-reg", + server="https://acme-staging.api.letsencrypt.org/directory", rsa_key_size=2048, rollback_checkpoints=1, config_dir="/etc/letsencrypt", @@ -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 @@ -88,7 +84,7 @@ LIVE_DIR = "live" TEMP_CHECKPOINT_DIR = "temp_checkpoint" """Temporary checkpoint directory (relative to `IConfig.work_dir`).""" -RENEWAL_CONFIGS_DIR = "configs" +RENEWAL_CONFIGS_DIR = "renewal" """Renewal configs directory, relative to `IConfig.config_dir`.""" RENEWER_CONFIG_FILENAME = "renewer.conf" diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index 279330f0c..79cd24ed6 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -9,10 +9,13 @@ import logging import os import OpenSSL +import zope.component from acme import crypto_util as acme_crypto_util +from acme import jose from letsencrypt import errors +from letsencrypt import interfaces from letsencrypt import le_util @@ -44,8 +47,10 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): logger.exception(err) raise err + config = zope.component.getUtility(interfaces.IConfig) # Save file - le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid()) + le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid(), + config.strict_permissions) key_f, key_path = le_util.unique_file( os.path.join(key_dir, keyname), 0o600) key_f.write(key_pem) @@ -72,8 +77,10 @@ def init_save_csr(privkey, names, path, csrname="csr-letsencrypt.pem"): """ csr_pem, csr_der = make_csr(privkey.pem, names) + config = zope.component.getUtility(interfaces.IConfig) # Save CSR - le_util.make_or_verify_dir(path, 0o755, os.geteuid()) + le_util.make_or_verify_dir(path, 0o755, os.geteuid(), + config.strict_permissions) csr_f, csr_filename = le_util.unique_file( os.path.join(path, csrname), 0o644) csr_f.write(csr_pem) @@ -205,6 +212,7 @@ def _pyopenssl_load(data, method, types=( 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. @@ -269,3 +277,24 @@ def asn1_generalizedtime_to_dt(timestamp): 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. + + :param list chain: List of `OpenSSL.crypto.X509` (or wrapped in + `acme.jose.ComparableX509`). + + """ + # XXX: returns empty string when no chain is available, which + # shuts up RenewableCert, but might not be the best solution... + + def _dump_cert(cert): + if isinstance(cert, jose.ComparableX509): + # pylint: disable=protected-access + cert = cert._wrapped + return OpenSSL.crypto.dump_certificate(filetype, cert) + + # assumes that OpenSSL.crypto.dump_certificate includes ending + # newline character + return "".join(_dump_cert(cert) for cert in chain) 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 a220d07d9..cb424a81b 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -12,11 +12,11 @@ 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): - """Allow the user to choose ther plugin. + """Allow the user to choose their plugin. :param list prepared: List of `~.PluginEntryPoint`. :param str question: Question to be presented to the user. @@ -25,8 +25,8 @@ def choose_plugin(prepared, question): :rtype: `~.PluginEntryPoint` """ - opts = [plugin_ep.description_with_name - + (" [Misconfigured]" if plugin_ep.misconfigured else "") + opts = [plugin_ep.description_with_name + + (" [Misconfigured]" if plugin_ep.misconfigured else "") for plugin_ep in prepared] while True: @@ -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) @@ -233,6 +233,26 @@ def success_installation(domains): pause=False) +def success_renewal(domains): + """Display a box confirming the renewal of an existing certificate. + + .. todo:: This should be centered on the screen + + :param list domains: domain names which were renewed + + """ + util(interfaces.IDisplay).notification( + "Your existing certificate has been successfully renewed, and the " + "new certificate has been installed.{1}{1}" + "The new certificate covers the following domains: {0}{1}{1}" + "You should test your configuration at:{1}{2}".format( + _gen_https_names(domains), + os.linesep, + os.linesep.join(_gen_ssl_lab_urls(domains))), + height=(14 + len(domains)), + pause=False) + + def _gen_ssl_lab_urls(domains): """Returns a list of urls. 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/display/util.py b/letsencrypt/display/util.py index de3e829fe..0e9c76e38 100644 --- a/letsencrypt/display/util.py +++ b/letsencrypt/display/util.py @@ -76,7 +76,7 @@ class NcursesDisplay(object): "help_label": help_label, "width": self.width, "height": self.height, - "menu_height": self.height-6, + "menu_height": self.height - 6, } # Can accept either tuples or just the actual choices @@ -315,7 +315,7 @@ class FileDisplay(object): if index < 1 or index > len(tags): return [] # Transform indices to appropriate tags - return [tags[index-1] for index in indices] + return [tags[index - 1] for index in indices] def _print_menu(self, message, choices): """Print a menu on the screen. 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/errors.py b/letsencrypt/errors.py index b15728c39..ba0601d29 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -73,6 +73,7 @@ class NoInstallationError(PluginError): class MisconfigurationError(PluginError): """Let's Encrypt Misconfiguration error.""" + class NotSupportedError(PluginError): """Let's Encrypt Plugin function not supported error.""" diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index f330e28ce..1f51645ab 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -142,7 +142,7 @@ class IAuthenticator(IPlugin): :param str domain: Domain for which challenge preferences are sought. - :returns: List of challege types (subclasses of + :returns: List of challenge types (subclasses of :class:`acme.challenges.Challenge`) with the most preferred challenges first. If a type is not specified, it means the Authenticator cannot perform the challenge. @@ -194,8 +194,7 @@ class IConfig(zope.interface.Interface): filtered, stripped or sanitized. """ - server = zope.interface.Attribute( - "ACME new registration URI (including /acme/new-reg).") + server = zope.interface.Attribute("ACME Directory Resource URI.") email = zope.interface.Attribute( "Email used for registration and recovery contact.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.") @@ -206,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.") @@ -322,6 +318,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. @@ -440,7 +447,6 @@ class IValidator(zope.interface.Interface): """ - def hsts(name): """Verify HSTS header is enabled @@ -472,7 +478,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 f8c911d99..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. @@ -70,7 +79,7 @@ def exe_exists(exe): return False -def make_or_verify_dir(directory, mode=0o755, uid=0): +def make_or_verify_dir(directory, mode=0o755, uid=0, strict=False): """Make sure directory exists with proper permissions. :param str directory: Path to a directory. @@ -89,9 +98,10 @@ def make_or_verify_dir(directory, mode=0o755, uid=0): os.makedirs(directory, mode) except OSError as exception: if exception.errno == errno.EEXIST: - if not check_permissions(directory, mode, uid): + if strict and not check_permissions(directory, mode, uid): raise errors.Error( - "%s exists, this client can't access it" % directory) + "%s exists, but it should be owned by user %d with" + "permissions %s" % (directory, uid, oct(mode))) else: raise @@ -196,6 +206,8 @@ def safely_remove(path): # start with a period or have two consecutive periods <- this needs to # be done in addition to the regex EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$") + + def safe_email(email): """Scrub email address before using it.""" if EMAIL_REGEX.match(email) is not None: 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 bef8b4d81..95ad56a0a 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -18,14 +18,15 @@ def option_namespace(name): """ArgumentParser options namespace (prefix of all options).""" return name + "-" + 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) @@ -86,6 +87,7 @@ class Plugin(object): # other + class Addr(object): r"""Represents an virtual host address. @@ -171,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/disco_test.py b/letsencrypt/plugins/disco_test.py index 56808c7da..41699d1ef 100644 --- a/letsencrypt/plugins/disco_test.py +++ b/letsencrypt/plugins/disco_test.py @@ -101,6 +101,7 @@ class PluginEntryPointTest(unittest.TestCase): with mock.patch("letsencrypt.plugins." "disco.zope.interface") as mock_zope: mock_zope.exceptions = exceptions + def verify_object(iface, obj): # pylint: disable=missing-docstring assert obj is plugin assert iface is iface1 or iface is iface2 or iface is iface3 diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py index d13f35f99..3f7276725 100644 --- a/letsencrypt/plugins/manual.py +++ b/letsencrypt/plugins/manual.py @@ -4,6 +4,7 @@ import logging import pipes import shutil import signal +import socket import subprocess import sys import tempfile @@ -22,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`. @@ -37,7 +38,7 @@ class ManualAuthenticator(common.Plugin): Make sure your web server displays the following content at {uri} before continuing: -{achall.token} +{validation} Content-Type header MUST be set to {ct}. @@ -86,7 +87,7 @@ s.serve_forever()" """ """ def __init__(self, *args, **kwargs): - super(ManualAuthenticator, self).__init__(*args, **kwargs) + super(Authenticator, self).__init__(*args, **kwargs) self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls else self.HTTPS_TEMPLATE) self._root = (tempfile.mkdtemp() if self.conf("test-mode") @@ -122,6 +123,20 @@ binary for temporary key/certificate generation.""".replace("\n", "") responses.append(self._perform_single(achall)) return responses + @classmethod + def _test_mode_busy_wait(cls, port): + while True: + time.sleep(1) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect(("localhost", port)) + except socket.error: # pragma: no cover + pass + else: + break + finally: + sock.close() + def _perform_single(self, achall): # same path for each challenge response would be easier for # users, but will not work if multiple domains point at the @@ -129,13 +144,13 @@ binary for temporary key/certificate generation.""".replace("\n", "") response, validation = achall.gen_response_and_validation( tls=(not self.config.no_simple_http_tls)) + port = (response.port if self.config.simple_http_port is None + else int(self.config.simple_http_port)) command = self.template.format( root=self._root, achall=achall, response=response, validation=pipes.quote(validation.json_dumps()), encoded_token=achall.chall.encode("token"), - ct=response.CONTENT_TYPE, port=( - response.port if self.config.simple_http_port is None - else self.config.simple_http_port)) + ct=response.CONTENT_TYPE, port=port) if self.conf("test-mode"): logger.debug("Test mode. Executing the manual command: %s", command) try: @@ -153,12 +168,12 @@ binary for temporary key/certificate generation.""".replace("\n", "") logger.debug("Manual command running as PID %s.", self._httpd.pid) # give it some time to bootstrap, before we try to verify # (cert generation in case of simpleHttpS might take time) - time.sleep(4) # XXX + self._test_mode_busy_wait(port) if self._httpd.poll() is not None: raise errors.Error("Couldn't execute manual command") else: self._notify_and_wait(self.MESSAGE_TEMPLATE.format( - achall=achall, response=response, + validation=validation.json_dumps(), response=response, uri=response.uri(achall.domain, achall.challb.chall), ct=response.CONTENT_TYPE, command=command)) @@ -167,6 +182,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 caf7fb3c4..cfe47b833 100644 --- a/letsencrypt/plugins/manual_test.py +++ b/letsencrypt/plugins/manual_test.py @@ -17,22 +17,22 @@ 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") + 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( + self.auth_test_mode = Authenticator( config=config_test_mode, name="manual") def test_more_info(self): @@ -46,12 +46,9 @@ class ManualAuthenticatorTest(unittest.TestCase): self.assertEqual([], self.auth.perform([])) @mock.patch("letsencrypt.plugins.manual.sys.stdout") - @mock.patch("letsencrypt.plugins.manual.os.urandom") @mock.patch("acme.challenges.SimpleHTTPResponse.simple_verify") @mock.patch("__builtin__.raw_input") - def test_perform(self, mock_raw_input, mock_verify, mock_urandom, - mock_stdout): - mock_urandom.side_effect = nonrandom_urandom + def test_perform(self, mock_raw_input, mock_verify, mock_stdout): mock_verify.return_value = True resp = challenges.SimpleHTTPResponse(tls=False) @@ -61,7 +58,7 @@ class ManualAuthenticatorTest(unittest.TestCase): self.achalls[0].challb.chall, "foo.com", KEY.public_key(), 4430) message = mock_stdout.write.mock_calls[0][1][0] - self.assertTrue(self.achalls[0].token in message) + self.assertTrue(self.achalls[0].chall.encode("token") in message) mock_verify.return_value = False self.assertEqual([None], self.auth.perform(self.achalls)) @@ -71,25 +68,29 @@ 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.time.sleep", autospec=True) @mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True) def test_perform_test_command_run_failure( - self, mock_popen, unused_mock_sleep): + self, mock_popen, unused_mock_sleep, unused_mock_socket): mock_popen.poll.return_value = 10 mock_popen.return_value.pid = 1234 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.time.sleep", autospec=True) @mock.patch("acme.challenges.SimpleHTTPResponse.simple_verify", autospec=True) @mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True) - def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep): + def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep, + mock_socket): mock_popen.return_value.poll.side_effect = [None, 10] mock_popen.return_value.pid = 1234 mock_verify.return_value = False self.assertEqual([False], self.auth_test_mode.perform(self.achalls)) self.assertEqual(1, mock_sleep.call_count) + self.assertEqual(1, mock_socket.call_count) def test_cleanup_test_mode_already_terminated(self): # pylint: disable=protected-access @@ -106,10 +107,5 @@ class ManualAuthenticatorTest(unittest.TestCase): mock_killpg.assert_called_once_with(1234, signal.SIGTERM) -def nonrandom_urandom(num_bytes): - """Returns a string of length num_bytes""" - return "x" * num_bytes - - if __name__ == "__main__": unittest.main() # pragma: no cover 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/plugins/standalone/tests/authenticator_test.py b/letsencrypt/plugins/standalone/tests/authenticator_test.py index bae20ac4d..7ff2c03e1 100644 --- a/letsencrypt/plugins/standalone/tests/authenticator_test.py +++ b/letsencrypt/plugins/standalone/tests/authenticator_test.py @@ -321,10 +321,8 @@ class PerformTest(unittest.TestCase): self.authenticator.already_listening = mock.Mock(return_value=False) result = self.authenticator.perform(self.achalls) self.assertEqual(len(self.authenticator.tasks), 2) - self.assertTrue( - self.authenticator.tasks.has_key(self.achall1.token)) - self.assertTrue( - self.authenticator.tasks.has_key(self.achall2.token)) + self.assertTrue(self.achall1.token in self.authenticator.tasks) + self.assertTrue(self.achall2.token in self.authenticator.tasks) self.assertTrue(isinstance(result, list)) self.assertEqual(len(result), 3) self.assertTrue(isinstance(result[0], challenges.ChallengeResponse)) @@ -340,10 +338,8 @@ class PerformTest(unittest.TestCase): self.authenticator.already_listening = mock.Mock(return_value=False) result = self.authenticator.perform(self.achalls) self.assertEqual(len(self.authenticator.tasks), 2) - self.assertTrue( - self.authenticator.tasks.has_key(self.achall1.token)) - self.assertTrue( - self.authenticator.tasks.has_key(self.achall2.token)) + self.assertTrue(self.achall1.token in self.authenticator.tasks) + self.assertTrue(self.achall2.token in self.authenticator.tasks) self.assertTrue(isinstance(result, list)) self.assertEqual(len(result), 3) self.assertEqual(result, [None, None, False]) diff --git a/letsencrypt/proof_of_possession.py b/letsencrypt/proof_of_possession.py index f13238c85..7928c60e7 100644 --- a/letsencrypt/proof_of_possession.py +++ b/letsencrypt/proof_of_possession.py @@ -17,7 +17,7 @@ from letsencrypt.display import util as display_util logger = logging.getLogger(__name__) -class ProofOfPossession(object): # pylint: disable=too-few-public-methods +class ProofOfPossession(object): # pylint: disable=too-few-public-methods """Proof of Possession Identifier Validation Challenge. Based on draft-barnes-acme, section 6.5. @@ -71,7 +71,7 @@ class ProofOfPossession(object): # pylint: disable=too-few-public-methods # If we get here, the key wasn't found return False - def _gen_response(self, achall, key_path): # pylint: disable=no-self-use + def _gen_response(self, achall, key_path): # pylint: disable=no-self-use """Create the response to the Proof of Possession Challenge. :param achall: Proof of Possession Challenge diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index e26e8742b..1c9cddc95 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -70,6 +70,7 @@ def renew(cert, old_version): # was an int, not a str) config.rsa_key_size = int(config.rsa_key_size) config.dvsni_port = int(config.dvsni_port) + zope.component.provideUtility(config) try: authenticator = plugins[renewalparams["authenticator"]] except KeyError: @@ -85,7 +86,7 @@ def renew(cert, old_version): with open(cert.version("cert", old_version)) as f: sans = crypto_util.get_sans_from_cert(f.read()) new_certr, new_chain, new_key, _ = le_client.obtain_certificate(sans) - if new_chain is not None: + if new_chain: # XXX: Assumes that there was no key change. We need logic # for figuring out whether there was or not. Probably # best is to have obtain_certificate return None for @@ -94,8 +95,7 @@ def renew(cert, old_version): return cert.save_successor( old_version, OpenSSL.crypto.dump_certificate( OpenSSL.crypto.FILETYPE_PEM, new_certr.body), - new_key.pem, OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, new_chain)) + new_key.pem, crypto_util.dump_pyopenssl_chain(new_chain)) # TODO: Notify results else: # TODO: Notify negative results 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/reverter.py b/letsencrypt/reverter.py index 8eed59156..d5114ae71 100644 --- a/letsencrypt/reverter.py +++ b/letsencrypt/reverter.py @@ -31,7 +31,8 @@ class Reverter(object): self.config = config le_util.make_or_verify_dir( - config.backup_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) + config.backup_dir, constants.CONFIG_DIRS_MODE, os.geteuid(), + self.config.strict_permissions) def revert_temporary_config(self): """Reload users original configuration files after a temporary save. @@ -180,7 +181,8 @@ class Reverter(object): """ le_util.make_or_verify_dir( - cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) + cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid(), + self.config.strict_permissions) op_fd, existing_filepaths = self._read_and_append( os.path.join(cp_dir, "FILEPATHS")) @@ -393,7 +395,8 @@ class Reverter(object): cp_dir = self.config.in_progress_dir le_util.make_or_verify_dir( - cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) + cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid(), + self.config.strict_permissions) return cp_dir diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py deleted file mode 100644 index 160d911a5..000000000 --- a/letsencrypt/revoker.py +++ /dev/null @@ -1,558 +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(new_reg_uri=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()) - - # 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` - - """ - list_path2 = tempfile.mktemp(".tmp", "LIST") - idx = 0 - - with open(self.list_path, "rb") as orgfile: - csvreader = csv.reader(orgfile) - with open(list_path2, "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()) - - 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 431f56aff..be270a762 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -11,6 +11,7 @@ import pytz import pyrfc3339 from letsencrypt import constants +from letsencrypt import crypto_util from letsencrypt import errors from letsencrypt import le_util @@ -223,7 +224,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes target = os.readlink(link) if not os.path.isabs(target): target = os.path.join(os.path.dirname(link), target) - return target + return os.path.abspath(target) def current_version(self, kind): """Returns numerical version of the specified item. @@ -421,6 +422,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes """ return self._notafterbefore(lambda x509: x509.get_notAfter(), version) + def names(self, version=None): + """What are the subject names of this certificate? + + (If no version is specified, use the current version.) + + :param int version: the desired version number + :returns: the subject names + :rtype: `list` of `str` + + """ + if version is None: + target = self.current_target("cert") + else: + target = self.version("cert", version) + with open(target) as f: + return crypto_util.get_sans_from_cert(f.read()) + def should_autodeploy(self): """Should this lineage now automatically deploy a newer version? @@ -486,8 +504,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :rtype: bool """ - if ("autorenew" not in self.configuration - or self.configuration.as_bool("autorenew")): + if ("autorenew" not in self.configuration or + self.configuration.as_bool("autorenew")): # Consider whether to attempt to autorenew this cert now # Renewals on the basis of revocation @@ -502,7 +520,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, @@ -586,6 +604,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes with open(target["chain"], "w") as f: f.write(chain) with open(target["fullchain"], "w") as f: + # assumes that OpenSSL.crypto.dump_certificate includes + # ending newline character f.write(cert + chain) # Document what we've done in a new renewal config file @@ -603,7 +623,6 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes new_config.write() return cls(new_config, config, cli_config) - def save_successor(self, prior_version, new_cert, new_privkey, new_chain): """Save new cert and chain as a successor of a prior version. @@ -626,7 +645,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes """ # XXX: assumes official archive location rather than examining links - # XXX: consider using os.open for availablity of os.O_EXCL + # XXX: consider using os.open for availability of os.O_EXCL # XXX: ensure file permissions are correct; also create directories # if needed (ensuring their permissions are correct) # Figure out what the new version is and hence where to save things diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py index 33bf605e0..235810435 100644 --- a/letsencrypt/tests/acme_util.py +++ b/letsencrypt/tests/acme_util.py @@ -30,7 +30,7 @@ POP = challenges.ProofOfPossession( "16d95b7b63f1972b980b14c20291f3c0d1855d95", "48b46570d9fc6358108af43ad1649484def0debf" ), - certs=(), # TODO + certs=(), # TODO subject_key_identifiers=("d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"), serial_numbers=(34234239832, 23993939911, 17), issuers=( diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index 486b55a20..18ee56081 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -37,7 +37,7 @@ class ChallengeFactoryTest(unittest.TestCase): self.dom = "test" self.handler.authzr[self.dom] = acme_util.gen_authzr( messages.STATUS_PENDING, self.dom, acme_util.CHALLENGES, - [messages.STATUS_PENDING]*6, False) + [messages.STATUS_PENDING] * 6, False) def test_all(self): cont_c, dv_c = self.handler._challenge_factory( @@ -163,7 +163,7 @@ class GetAuthorizationsTest(unittest.TestCase): messages.STATUS_VALID, dom, [challb.chall for challb in azr.body.challenges], - [messages.STATUS_VALID]*len(azr.body.challenges), + [messages.STATUS_VALID] * len(azr.body.challenges), azr.body.combinations) @@ -183,15 +183,15 @@ class PollChallengesTest(unittest.TestCase): self.doms = ["0", "1", "2"] self.handler.authzr[self.doms[0]] = acme_util.gen_authzr( messages.STATUS_PENDING, self.doms[0], - acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING] * 3, False) self.handler.authzr[self.doms[1]] = acme_util.gen_authzr( messages.STATUS_PENDING, self.doms[1], - acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING] * 3, False) self.handler.authzr[self.doms[2]] = acme_util.gen_authzr( messages.STATUS_PENDING, self.doms[2], - acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING] * 3, False) self.chall_update = {} for dom in self.doms: @@ -282,6 +282,7 @@ class PollChallengesTest(unittest.TestCase): ) return (new_authzr, "response") + class GenChallengePathTest(unittest.TestCase): """Tests for letsencrypt.auth_handler.gen_challenge_path. @@ -321,7 +322,7 @@ class GenChallengePathTest(unittest.TestCase): combos = acme_util.gen_combos(challbs) self.assertEqual(self._call(challbs, prefs, combos), (0, 2)) - # dumb_path() trivial test + # dumb_path() trivial test self.assertTrue(self._call(challbs, prefs, None)) def test_full_cont_server(self): @@ -354,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 @@ -427,26 +428,29 @@ class ReportFailedChallsTest(unittest.TestCase): from letsencrypt import achallenges kwargs = { - "chall" : acme_util.SIMPLE_HTTP, + "chall": acme_util.SIMPLE_HTTP, "uri": "uri", "status": messages.STATUS_INVALID, "error": messages.Error(typ="tls", detail="detail"), } self.simple_http = achallenges.SimpleHTTP( - challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + # pylint: disable=star-args + challb=messages.ChallengeBody(**kwargs), domain="example.com", account_key="key") kwargs["chall"] = acme_util.DVSNI self.dvsni_same = achallenges.DVSNI( - challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + # pylint: disable=star-args + challb=messages.ChallengeBody(**kwargs), domain="example.com", account_key="key") kwargs["error"] = messages.Error(typ="dnssec", detail="detail") self.dvsni_diff = achallenges.DVSNI( - challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + # pylint: disable=star-args + challb=messages.ChallengeBody(**kwargs), domain="foo.bar", account_key="key") @@ -477,7 +481,7 @@ def gen_dom_authzr(domain, unused_new_authzr_uri, challs): """Generates new authzr for domains.""" return acme_util.gen_authzr( messages.STATUS_PENDING, domain, challs, - [messages.STATUS_PENDING]*len(challs)) + [messages.STATUS_PENDING] * len(challs)) if __name__ == "__main__": diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 613c3189b..0a92aba62 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 @@ -12,6 +13,12 @@ from letsencrypt import account from letsencrypt import configuration from letsencrypt import errors +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.""" @@ -36,12 +43,54 @@ 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']) @@ -60,42 +109,116 @@ class CLITest(unittest.TestCase): for args in itertools.chain( *(itertools.combinations(flags, r) for r in xrange(len(flags)))): - self._call(['plugins',] + list(args)) + 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))) @@ -105,13 +228,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) @@ -128,7 +251,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) @@ -139,11 +262,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()) @@ -151,15 +274,57 @@ 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): + """Test to avoid duplicate lineages.""" + + def setUp(self): + super(DuplicativeCertsTest, self).setUp() + self.config.write() + self._write_out_ex_kinds() + + def tearDown(self): + shutil.rmtree(self.tempdir) + + @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: + f.write(test_cert) + + # No overlap at all + 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'], + self.config, self.cli_config) + 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, + self.cli_config) + self.assertEqual(result[0], None) + self.assertTrue(result[1].configfile.filename.endswith('example.org.conf')) + + # Partial overlap doesn't count + result = _find_duplicative_certs(['example.com', 'something.new'], + self.config, self.cli_config) + self.assertEqual(result, (None, None)) if __name__ == '__main__': diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index b992089cc..1a232bccb 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -1,4 +1,7 @@ """Tests for letsencrypt.client.""" +import os +import shutil +import tempfile import unittest import configobj @@ -70,7 +73,7 @@ class ClientTest(unittest.TestCase): def test_init_acme_verify_ssl(self): self.acme_client.assert_called_once_with( - new_reg_uri=mock.ANY, key=mock.ANY, verify_ssl=True) + directory=mock.ANY, key=mock.ANY, verify_ssl=True) def _mock_obtain_certificate(self): self.client.auth_handler = mock.MagicMock() @@ -110,7 +113,7 @@ 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") @@ -145,6 +148,69 @@ class ClientTest(unittest.TestCase): self.assertTrue("renewal but not automatic deployment" in msg) self.assertTrue(cert.cli_config.renewal_configs_dir in msg) + def test_save_certificate(self): + certs = ["matching_cert.pem", "cert.pem", "cert-san.pem"] + tmp_path = tempfile.mkdtemp() + os.chmod(tmp_path, 0o755) # TODO: really?? + + certr = mock.MagicMock(body=test_util.load_cert(certs[0])) + cert1 = test_util.load_cert(certs[1]) + cert2 = test_util.load_cert(certs[2]) + candidate_cert_path = os.path.join(tmp_path, "certs", "cert.pem") + candidate_chain_path = os.path.join(tmp_path, "chains", "chain.pem") + + cert_path, chain_path = self.client.save_certificate( + certr, [cert1, cert2], candidate_cert_path, candidate_chain_path) + + self.assertEqual(os.path.dirname(cert_path), + os.path.dirname(candidate_cert_path)) + self.assertEqual(os.path.dirname(chain_path), + os.path.dirname(candidate_chain_path)) + + with open(cert_path, "r") as cert_file: + cert_contents = cert_file.read() + self.assertEqual(cert_contents, test_util.load_vector(certs[0])) + + with open(chain_path, "r") as chain_file: + chain_contents = chain_file.read() + self.assertEqual(chain_contents, test_util.load_vector(certs[1]) + + test_util.load_vector(certs[2])) + + 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.""" @@ -166,7 +232,7 @@ class RollbackTest(unittest.TestCase): self.assertEqual(self.m_install().restart.call_count, 1) def test_no_installer(self): - self._call(1, None) # Just make sure no exceptions are raised + self._call(1, None) # Just make sure no exceptions are raised if __name__ == "__main__": 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/continuity_auth_test.py b/letsencrypt/tests/continuity_auth_test.py index f8238a727..d80a1cfb4 100644 --- a/letsencrypt/tests/continuity_auth_test.py +++ b/letsencrypt/tests/continuity_auth_test.py @@ -35,7 +35,7 @@ class PerformTest(unittest.TestCase): self.assertRaises( errors.ContAuthError, self.auth.perform, [ achallenges.DVSNI( - challb=None, domain="0", account_key="invalid_key"),]) + challb=None, domain="0", account_key="invalid_key")]) def test_chall_pref(self): self.assertEqual( diff --git a/letsencrypt/tests/crypto_util_test.py b/letsencrypt/tests/crypto_util_test.py index b248ffd8a..b4d2aa394 100644 --- a/letsencrypt/tests/crypto_util_test.py +++ b/letsencrypt/tests/crypto_util_test.py @@ -6,7 +6,9 @@ import unittest import OpenSSL import mock +import zope.component +from letsencrypt import interfaces from letsencrypt.tests import test_util @@ -20,6 +22,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 +52,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): diff --git a/letsencrypt/tests/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index fc4013bed..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, } @@ -250,6 +252,7 @@ class GenSSLLabURLs(unittest.TestCase): self.assertTrue("eff.org" in urls[0]) self.assertTrue("umich.edu" in urls[1]) + class GenHttpsNamesTest(unittest.TestCase): """Test _gen_https_names.""" def setUp(self): @@ -383,5 +386,27 @@ class SuccessInstallationTest(unittest.TestCase): self.assertTrue(name in arg) +class SuccessRenewalTest(unittest.TestCase): + # pylint: disable=too-few-public-methods + """Test the success renewal message.""" + @classmethod + def _call(cls, names): + from letsencrypt.display.ops import success_renewal + success_renewal(names) + + @mock.patch("letsencrypt.display.ops.util") + def test_success_renewal(self, mock_util): + mock_util().notification.return_value = None + names = ["example.com", "abc.com"] + + self._call(names) + + self.assertEqual(mock_util().notification.call_count, 1) + arg = mock_util().notification.call_args_list[0][0][0] + + for name in names: + self.assertTrue(name in arg) + + if __name__ == "__main__": unittest.main() # pragma: no cover 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/display/util_test.py b/letsencrypt/tests/display/util_test.py index 41075c9ce..001a9e578 100644 --- a/letsencrypt/tests/display/util_test.py +++ b/letsencrypt/tests/display/util_test.py @@ -35,7 +35,7 @@ class NcursesDisplayTest(unittest.TestCase): "help_label": "", "width": display_util.WIDTH, "height": display_util.HEIGHT, - "menu_height": display_util.HEIGHT-6, + "menu_height": display_util.HEIGHT - 6, } @mock.patch("letsencrypt.display.util.dialog.Dialog.msgbox") 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/le_util_test.py b/letsencrypt/tests/le_util_test.py index 98b7eb803..ed976f72d 100644 --- a/letsencrypt/tests/le_util_test.py +++ b/letsencrypt/tests/le_util_test.py @@ -92,7 +92,7 @@ class MakeOrVerifyDirTest(unittest.TestCase): def _call(self, directory, mode): from letsencrypt.le_util import make_or_verify_dir - return make_or_verify_dir(directory, mode, self.uid) + return make_or_verify_dir(directory, mode, self.uid, strict=True) def test_creates_dir_when_missing(self): path = os.path.join(self.root_path, "bar") 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/notify_test.py b/letsencrypt/tests/notify_test.py index 1ccfdbf87..60364fff8 100644 --- a/letsencrypt/tests/notify_test.py +++ b/letsencrypt/tests/notify_test.py @@ -1,9 +1,10 @@ """Tests for letsencrypt.notify.""" - -import mock import socket import unittest +import mock + + class NotifyTests(unittest.TestCase): """Tests for the notifier.""" diff --git a/letsencrypt/tests/proof_of_possession_test.py b/letsencrypt/tests/proof_of_possession_test.py index bfe3478d1..f2e7b2021 100644 --- a/letsencrypt/tests/proof_of_possession_test.py +++ b/letsencrypt/tests/proof_of_possession_test.py @@ -80,4 +80,4 @@ class ProofOfPossessionTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() # pragma: no cover + unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 1b58d9e0f..e67631605 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -24,6 +24,7 @@ def unlink_all(rc_object): for kind in ALL_FOUR: os.unlink(getattr(rc_object, kind)) + def fill_with_sample_data(rc_object): """Put dummy data into all four files of this RenewableCert.""" for kind in ALL_FOUR: @@ -31,9 +32,13 @@ def fill_with_sample_data(rc_object): f.write(kind) -class RenewableCertTests(unittest.TestCase): - # pylint: disable=too-many-public-methods - """Tests for letsencrypt.renewer.*.""" +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() @@ -43,18 +48,39 @@ class RenewableCertTests(unittest.TestCase): # TODO: maybe provide RenewerConfiguration.make_dirs? os.makedirs(os.path.join(self.tempdir, "live", "example.org")) os.makedirs(os.path.join(self.tempdir, "archive", "example.org")) - os.makedirs(os.path.join(self.tempdir, "configs")) + os.makedirs(os.path.join(self.tempdir, "renewal")) config = configobj.ConfigObj() for kind in ALL_FOUR: config[kind] = os.path.join(self.tempdir, "live", "example.org", kind + ".pem") - config.filename = os.path.join(self.tempdir, "configs", + config.filename = os.path.join(self.tempdir, "renewal", "example.org.conf") + self.config = config self.defaults = configobj.ConfigObj() self.test_rc = storage.RenewableCert( - config, self.defaults, self.cli_config) + self.config, self.defaults, self.cli_config) + + def _write_out_ex_kinds(self): + for kind in ALL_FOUR: + where = getattr(self.test_rc, kind) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}12.pem".format(kind)), where) + with open(where, "w") as f: + f.write(kind) + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}11.pem".format(kind)), where) + with open(where, "w") as f: + f.write(kind) + + +class RenewableCertTests(BaseRenewableCertTest): + # pylint: disable=too-many-public-methods + """Tests for letsencrypt.renewer.*.""" + def setUp(self): + super(RenewableCertTests, self).setUp() def tearDown(self): shutil.rmtree(self.tempdir) @@ -97,7 +123,7 @@ class RenewableCertTests(unittest.TestCase): 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 oldcert = self.test_rc.cert self.test_rc.cert = "relative/path" # Absolute path for item requirement @@ -295,6 +321,26 @@ class RenewableCertTests(unittest.TestCase): else: self.assertFalse(self.test_rc.has_pending_deployment()) + def test_names(self): + # Trying the current version + test_cert = test_util.load_vector("cert-san.pem") + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert12.pem"), self.test_rc.cert) + with open(self.test_rc.cert, "w") as f: + f.write(test_cert) + self.assertEqual(self.test_rc.names(), + ["example.com", "www.example.com"]) + + # Trying a non-current version + test_cert = test_util.load_vector("cert.pem") + os.unlink(self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert15.pem"), self.test_rc.cert) + with open(self.test_rc.cert, "w") as f: + f.write(test_cert) + self.assertEqual(self.test_rc.names(12), + ["example.com", "www.example.com"]) + def _test_notafterbefore(self, function, timestamp): test_cert = test_util.load_vector("cert.pem") os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -320,17 +366,8 @@ class RenewableCertTests(unittest.TestCase): """Test should_autodeploy() and should_autorenew() on the basis of expiry time windows.""" test_cert = test_util.load_vector("cert.pem") - for kind in ALL_FOUR: - where = getattr(self.test_rc, kind) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "{0}12.pem".format(kind)), where) - with open(where, "w") as f: - f.write(kind) - os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "{0}11.pem".format(kind)), where) - with open(where, "w") as f: - f.write(kind) + self._write_out_ex_kinds() + self.test_rc.update_all_links_to(12) with open(self.test_rc.cert, "w") as f: f.write(test_cert) @@ -596,7 +633,7 @@ class RenewableCertTests(unittest.TestCase): mock_client = mock.MagicMock() # pylint: disable=star-args mock_client.obtain_certificate.return_value = ( - mock.MagicMock(body=CERT), CERT, mock.Mock(pem="key"), + mock.MagicMock(body=CERT), [CERT], mock.Mock(pem="key"), mock.sentinel.csr) mock_c.return_value = mock_client self.assertEqual(2, renewer.renew(self.test_rc, 1)) @@ -604,11 +641,10 @@ class RenewableCertTests(unittest.TestCase): # have been made to the mock functions here. mock_acc_storage().load.assert_called_once_with(account_id="abcde") mock_client.obtain_certificate.return_value = ( - mock.sentinel.certr, None, mock.sentinel.key, mock.sentinel.csr) + mock.sentinel.certr, [], mock.sentinel.key, mock.sentinel.csr) # This should fail because the renewal itself appears to fail self.assertFalse(renewer.renew(self.test_rc, 1)) - @mock.patch("letsencrypt.renewer.notify") @mock.patch("letsencrypt.storage.RenewableCert") @mock.patch("letsencrypt.renewer.renew") 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/validator_test.py b/letsencrypt/tests/validator_test.py index c02a7d865..c7416dc46 100644 --- a/letsencrypt/tests/validator_test.py +++ b/letsencrypt/tests/validator_test.py @@ -38,15 +38,15 @@ class ValidatorTest(unittest.TestCase): @mock.patch("letsencrypt.validator.requests.get") def test_succesful_redirect(self, mock_get_request): mock_get_request.return_value = create_response( - 301, {"location" : "https://test.com"}) + 301, {"location": "https://test.com"}) self.assertTrue(self.validator.redirect("test.com")) @mock.patch("letsencrypt.validator.requests.get") def test_redirect_with_headers(self, mock_get_request): mock_get_request.return_value = create_response( - 301, {"location" : "https://test.com"}) + 301, {"location": "https://test.com"}) self.assertTrue(self.validator.redirect( - "test.com", headers={"Host" : "test.com"})) + "test.com", headers={"Host": "test.com"})) @mock.patch("letsencrypt.validator.requests.get") def test_redirect_missing_location(self, mock_get_request): @@ -56,13 +56,13 @@ class ValidatorTest(unittest.TestCase): @mock.patch("letsencrypt.validator.requests.get") def test_redirect_wrong_status_code(self, mock_get_request): mock_get_request.return_value = create_response( - 201, {"location" : "https://test.com"}) + 201, {"location": "https://test.com"}) self.assertFalse(self.validator.redirect("test.com")) @mock.patch("letsencrypt.validator.requests.get") def test_redirect_wrong_redirect_code(self, mock_get_request): mock_get_request.return_value = create_response( - 303, {"location" : "https://test.com"}) + 303, {"location": "https://test.com"}) self.assertFalse(self.validator.redirect("test.com")) @mock.patch("letsencrypt.validator.requests.get") @@ -106,6 +106,7 @@ class ValidatorTest(unittest.TestCase): self.assertRaises( NotImplementedError, self.validator.ocsp_stapling, "test.com") + def create_response(status_code=200, headers=None): """Creates a requests.Response object for testing""" response = requests.Response() @@ -118,4 +119,4 @@ def create_response(status_code=200, headers=None): if __name__ == '__main__': - unittest.main() # pragma: no cover + unittest.main() # pragma: no cover diff --git a/letshelp-letsencrypt/letshelp_letsencrypt/apache.py b/letshelp-letsencrypt/letshelp_letsencrypt/apache.py index 3b3ab31e7..ac4e9b831 100755 --- a/letshelp-letsencrypt/letshelp_letsencrypt/apache.py +++ b/letshelp-letsencrypt/letshelp_letsencrypt/apache.py @@ -87,7 +87,7 @@ def copy_config(server_root, temp_dir): dir_len = len(os.path.dirname(server_root)) for config_path, config_dirs, config_files in os.walk(server_root): - temp_path = os.path.join(temp_dir, config_path[dir_len+1:]) + temp_path = os.path.join(temp_dir, config_path[dir_len + 1:]) os.mkdir(temp_path) copied_all = True @@ -151,7 +151,7 @@ def safe_config_file(config_file): empty_or_all_comments = False if line.startswith("-----BEGIN"): return False - elif not ":" in line: + elif ":" not in line: possible_password_file = False # If file isn't empty or commented out and could be a password file, # don't include it in selection. It is safe to include the file if @@ -234,9 +234,9 @@ def locate_config(apache_ctl): for line in output.splitlines(): # Relevant output lines are of the form: -D DIRECTIVE="VALUE" if "HTTPD_ROOT" in line: - server_root = line[line.find('"')+1:-1] + server_root = line[line.find('"') + 1:-1] elif "SERVER_CONFIG_FILE" in line: - config_file = line[line.find('"')+1:-1] + config_file = line[line.find('"') + 1:-1] if not (server_root and config_file): sys.exit("Unable to locate Apache configuration. Please run this " @@ -272,7 +272,7 @@ def get_args(): args.config_file = os.path.abspath(args.config_file) if args.config_file.startswith(args.server_root): - args.config_file = args.config_file[len(args.server_root)+1:] + args.config_file = args.config_file[len(args.server_root) + 1:] else: sys.exit("This script expects the Apache configuration file to be " "inside the server root") @@ -300,4 +300,4 @@ def main(): if __name__ == "__main__": - main() # pragma: no cover + main() # pragma: no cover diff --git a/letshelp-letsencrypt/letshelp_letsencrypt/apache_test.py b/letshelp-letsencrypt/letshelp_letsencrypt/apache_test.py index e1012797a..7ed1df760 100644 --- a/letshelp-letsencrypt/letshelp_letsencrypt/apache_test.py +++ b/letshelp-letsencrypt/letshelp_letsencrypt/apache_test.py @@ -141,7 +141,7 @@ class LetsHelpApacheTest(unittest.TestCase): @mock.patch(_MODULE_NAME + ".subprocess.Popen") def test_locate_config(self, mock_popen): mock_popen().communicate.side_effect = [ - OSError, ("bad_output", None), (_COMPILE_SETTINGS, None),] + OSError, ("bad_output", None), (_COMPILE_SETTINGS, None)] self.assertRaises( SystemExit, letshelp_le_apache.locate_config, "ctl") diff --git a/letshelp-letsencrypt/setup.py b/letshelp-letsencrypt/setup.py index 7d42af88b..db87f8b33 100644 --- a/letshelp-letsencrypt/setup.py +++ b/letshelp-letsencrypt/setup.py @@ -4,7 +4,9 @@ from setuptools import setup from setuptools import find_packages -install_requires = [] +install_requires = [ + 'setuptools', # pkg_resources +] if sys.version_info < (2, 7): install_requires.append('mock<1.1.0') else: diff --git a/pep8.travis.sh b/pep8.travis.sh new file mode 100755 index 000000000..ccac0a435 --- /dev/null +++ b/pep8.travis.sh @@ -0,0 +1,12 @@ +#!/bin/sh +pep8 \ + setup.py \ + acme \ + letsencrypt \ + letsencrypt-apache \ + letsencrypt-nginx \ + letsencrypt-compatibility-test \ + letshelp-letsencrypt \ + || echo "PEP8 checking failed, but it's ignored in Travis" + +# echo exits with 0 diff --git a/setup.py b/setup.py index e15ade595..3942322fc 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,8 @@ install_requires = [ 'pyrfc3339', 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 'pytz', + 'requests', + 'setuptools', # pkg_resources 'zope.component', 'zope.interface', ] @@ -71,6 +73,7 @@ testing_extras = [ 'coverage', 'nose', 'nosexcover', + 'pep8', 'tox', ] @@ -90,7 +93,6 @@ setup( 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', @@ -119,8 +121,7 @@ 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', diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh index 23bfcf3ca..ed877d136 100755 --- a/tests/boulder-integration.sh +++ b/tests/boulder-integration.sh @@ -4,15 +4,15 @@ # instance (see ./boulder-start.sh). # # Environment variables: -# SERVER: Passed as "letsencrypt --server" argument. Boulder -# monolithic defaults to :4000, AMQP defaults to :4300. This -# script defaults to monolithic. +# SERVER: Passed as "letsencrypt --server" argument. # # Note: this script is called by Boulder integration test suite! . ./tests/integration/_common.sh export PATH="/usr/sbin:$PATH" # /usr/sbin/nginx +export GOPATH="${GOPATH:-/tmp/go}" +export PATH="$GOPATH/bin:$PATH" common() { letsencrypt_test \ @@ -54,6 +54,13 @@ do [ "${dir}/${latest}" = "$live" ] # renewer fails this test done +# revoke by account key +common revoke --cert-path "$root/conf/live/le.wtf/cert.pem" +# revoke renewed +common revoke --cert-path "$root/conf/live/le1.wtf/cert.pem" +# revoke by cert key +common revoke --cert-path "$root/conf/live/le2.wtf/cert.pem" \ + --key-path "$root/conf/live/le2.wtf/privkey.pem" if type nginx; then diff --git a/tests/boulder-start.sh b/tests/boulder-start.sh index e17716b54..530f9c598 100755 --- a/tests/boulder-start.sh +++ b/tests/boulder-start.sh @@ -1,14 +1,42 @@ -#!/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" # `/...` avoids `no buildable Go source files` errors, for more info # see `go help packages` 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 8656b8518..fd60b9258 100755 --- a/tests/integration/_common.sh +++ b/tests/integration/_common.sh @@ -13,7 +13,7 @@ export root store_flags letsencrypt_test () { letsencrypt \ - --server "${SERVER:-http://localhost:4000/acme/new-reg}" \ + --server "${SERVER:-http://localhost:4000/directory}" \ --no-verify-ssl \ --dvsni-port 5001 \ --simple-http-port 5001 \ @@ -23,6 +23,7 @@ letsencrypt_test () { --agree-eula \ --agree-tos \ --email "" \ + --renew-by-default \ --debug \ -vvvvvvv \ "$@" diff --git a/tools/deps.sh b/tools/deps.sh new file mode 100755 index 000000000..28bfdaff5 --- /dev/null +++ b/tools/deps.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# +# Find all Python imports. +# +# ./deps.sh letsencrypt +# ./deps.sh acme +# ./deps.sh letsencrypt-apache +# ... +# +# Manually compare the output with deps in setup.py. + +git grep -h -E '^(import|from.*import)' $1/ | \ + awk '{print $2}' | \ + grep -vE "^$1" | \ + sort -u diff --git a/tox.cover.sh b/tox.cover.sh index 65ab43039..edfd9b81a 100755 --- a/tox.cover.sh +++ b/tox.cover.sh @@ -1,10 +1,35 @@ -#!/bin/sh +#!/bin/sh -xe +# USAGE: ./tox.cover.sh [package] +# # This script is used by tox.ini (and thus Travis CI) in order to # generate separate stats for each package. It should be removed once # those packages are moved to separate repo. +# +# -e makes sure we fail fast and don't submit coveralls submit + +if [ "xxx$1" = "xxx" ]; then + pkgs="letsencrypt acme letsencrypt_apache letsencrypt_nginx letshelp_letsencrypt" +else + pkgs="$@" +fi cover () { + if [ "$1" = "letsencrypt" ]; then + min=97 + elif [ "$1" = "acme" ]; then + min=100 + elif [ "$1" = "letsencrypt_apache" ]; then + min=100 + elif [ "$1" = "letsencrypt_nginx" ]; then + min=97 + elif [ "$1" = "letshelp_letsencrypt" ]; then + min=100 + else + echo "Unrecognized package: $1" + exit 1 + fi + # "-c /dev/null" makes sure setup.cfg is not loaded (multiple # --with-cover add up, --cover-erase must not be set for coveralls # to get all the data); --with-cover scopes coverage to only @@ -12,16 +37,11 @@ cover () { # specific package directory; --cover-tests makes sure every tests # is run (c.f. #403) nosetests -c /dev/null --with-cover --cover-tests --cover-package \ - "$1" --cover-min-percentage="$2" "$1" + "$1" --cover-min-percentage="$min" "$1" } rm -f .coverage # --cover-erase is off, make sure stats are correct - -# don't use sequential composition (;), if letsencrypt_nginx returns -# 0, coveralls submit will be triggered (c.f. .travis.yml, -# after_success) -cover letsencrypt 97 && \ - cover acme 100 && \ - cover letsencrypt_apache 100 && \ - cover letsencrypt_nginx 96 && \ - cover letshelp_letsencrypt 100 +for pkg in $pkgs +do + cover $pkg +done diff --git a/tox.ini b/tox.ini index ebe9746c9..b10558077 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ # acme and letsencrypt are not yet on pypi, so when Tox invokes # "install *.zip", it will not find deps skipsdist = true -envlist = py26,py27,cover,lint +envlist = py27,cover,lint [testenv] commands = @@ -23,6 +23,16 @@ setenv = PYTHONHASHSEED = 0 # https://testrun.org/tox/latest/example/basic.html#special-handling-of-pythonhas +[testenv:py33] +commands = + pip install -e acme[testing] + nosetests acme + +[testenv:py34] +commands = + pip install -e acme[testing] + nosetests acme + [testenv:cover] basepython = python2.7 commands = @@ -30,13 +40,13 @@ commands = ./tox.cover.sh [testenv:lint] -# recent versions of pylint do not support Python 2.6 (#97, #187) basepython = python2.7 # separating into multiple invocations disables cross package # duplicate code checking; if one of the commands fails, others will # continue, but tox return code will reflect previous error commands = pip install -r requirements.txt -e acme -e .[dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt + ./pep8.travis.sh pylint --rcfile=.pylintrc letsencrypt pylint --rcfile=.pylintrc acme/acme pylint --rcfile=.pylintrc letsencrypt-apache/letsencrypt_apache