diff --git a/.gitignore b/.gitignore index ace5d7a0f..9dac99790 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ dist/ # editor temporary files *~ *.swp -\#*# \ No newline at end of file +\#*# + +# auth --cert-path --chain-path +/*.pem \ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst index 741d9bc7c..3ed13041b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,7 +3,7 @@ ChangeLog Please note: the change log will only get updated after first release - for now please use the -`commit log `_. +`commit log `_. Release 0.1.0 (not released yet) diff --git a/Dockerfile b/Dockerfile index b6a07388c..479aa4e85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# https://github.com/letsencrypt/lets-encrypt-preview/pull/431#issuecomment-103659297 +# https://github.com/letsencrypt/letsencrypt/pull/431#issuecomment-103659297 # it is more likely developers will already have ubuntu:trusty rather # than e.g. debian:jessie and image size differences are negligible FROM ubuntu:trusty @@ -48,6 +48,7 @@ COPY letsencrypt_apache /opt/letsencrypt/src/letsencrypt_apache/ COPY letsencrypt_nginx /opt/letsencrypt/src/letsencrypt_nginx/ +# requirements.txt not installed! RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ /opt/letsencrypt/venv/bin/pip install -e /opt/letsencrypt/src diff --git a/LICENSE.txt b/LICENSE.txt index d3c19bbd1..5a9f6fa55 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Let's Encrypt Preview: +Let's Encrypt: Copyright (c) Internet Security Research Group Licensed Apache Version 2.0 diff --git a/README.rst b/README.rst index db32889db..7c98999e8 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,13 @@ +.. notice for github users + +Official **documentation**, including `installation instructions`_, is +available at https://letsencrypt.readthedocs.org. + +Generic information about Let's Encrypt project can be found at +https://letsencrypt.org. Please read `Frequently Asked Questions (FAQ) +`_. + + About the Let's Encrypt Client ============================== @@ -31,22 +41,25 @@ server automatically!:: **Encrypt ALL the things!** -.. |build-status| image:: https://travis-ci.org/letsencrypt/lets-encrypt-preview.svg?branch=master - :target: https://travis-ci.org/letsencrypt/lets-encrypt-preview +.. |build-status| image:: https://travis-ci.org/letsencrypt/letsencrypt.svg?branch=master + :target: https://travis-ci.org/letsencrypt/letsencrypt :alt: Travis CI status -.. |coverage| image:: https://coveralls.io/repos/letsencrypt/lets-encrypt-preview/badge.svg?branch=master - :target: https://coveralls.io/r/letsencrypt/lets-encrypt-preview +.. |coverage| image:: https://coveralls.io/repos/letsencrypt/letsencrypt/badge.svg?branch=master + :target: https://coveralls.io/r/letsencrypt/letsencrypt :alt: Coverage status .. |docs| image:: https://readthedocs.org/projects/letsencrypt/badge/ :target: https://readthedocs.org/projects/letsencrypt/ :alt: Documentation status -.. |container| image:: https://quay.io/repository/letsencrypt/lets-encrypt-preview/status - :target: https://quay.io/repository/letsencrypt/lets-encrypt-preview +.. |container| image:: https://quay.io/repository/letsencrypt/letsencrypt/status + :target: https://quay.io/repository/letsencrypt/letsencrypt :alt: Docker Repository on Quay.io +.. _`installation instructions`: + https://letsencrypt.readthedocs.org/en/latest/using.html + .. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU @@ -85,9 +98,9 @@ Current Features Links ----- -Documentation: https://letsencrypt.readthedocs.org/ +Documentation: https://letsencrypt.readthedocs.org -Software project: https://github.com/letsencrypt/lets-encrypt-preview +Software project: https://github.com/letsencrypt/letsencrypt Notes for developers: CONTRIBUTING.md_ @@ -100,4 +113,4 @@ email to client-dev+subscribe@letsencrypt.org) .. _Freenode: https://freenode.net .. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev -.. _CONTRIBUTING.md: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.md +.. _CONTRIBUTING.md: https://github.com/letsencrypt/letsencrypt/blob/master/CONTRIBUTING.md diff --git a/Vagrantfile b/Vagrantfile index 7eb2b4cce..1d3b48f06 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -8,8 +8,6 @@ VAGRANTFILE_API_VERSION = "2" $ubuntu_setup_script = < csr.der + +and for the certificate: + + openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der diff --git a/acme/jose/testdata/cert.der b/acme/jose/testdata/cert.der new file mode 100644 index 000000000..5f1018505 Binary files /dev/null and b/acme/jose/testdata/cert.der differ diff --git a/acme/jose/testdata/csr.der b/acme/jose/testdata/csr.der new file mode 100644 index 000000000..adc29ff18 Binary files /dev/null and b/acme/jose/testdata/csr.der differ diff --git a/acme/jose/testdata/csr2.pem b/acme/jose/testdata/csr2.pem deleted file mode 100644 index bd059a448..000000000 --- a/acme/jose/testdata/csr2.pem +++ /dev/null @@ -1,10 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIBXzCCAQkCAQAwejELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw -EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy -c2l0eSBvZiBNaWNoaWdhbjEVMBMGA1UEAwwMZXhhbXBsZTIuY29tMFwwDQYJKoZI -hvcNAQEBBQADSwAwSAJBAPS2EXFRNza/qpXnnBHF/CcFQ543htV+7nLAmrLrmTNH -tPXJmLlM8SJDIzv/ceAFXL110VzxFfi81lpH5E5c0TMCAwEAAaAqMCgGCSqGSIb3 -DQEJDjEbMBkwFwYDVR0RBBAwDoIMZXhhbXBsZTIuY29tMA0GCSqGSIb3DQEBCwUA -A0EAwsdL4FLIgISKV4vXFmc6QTV7CjBiP4XmPFbeN+gMFdR7QcnRyyxSpXxB0v8Z -oqYboP5LGFt9zC6/9GyjcI9/IQ== ------END CERTIFICATE REQUEST----- diff --git a/acme/messages.py b/acme/messages.py index 6d46f894c..31acd6000 100644 --- a/acme/messages.py +++ b/acme/messages.py @@ -1,106 +1,294 @@ -"""ACME protocol v00 messages. - -.. warning:: This module is an implementation of the draft `ACME - protocol version 00`_, and not the "RESTified" `ACME protocol version - 01`_ or later. It should work with `older Node.js implementation`_, - but will definitely not work with Boulder_. It is kept for reference - purposes only. - - -.. _`ACME protocol version 00`: - https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md - -.. _`ACME protocol version 01`: - https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md - -.. _Boulder: https://github.com/letsencrypt/boulder - -.. _`older Node.js implementation`: - https://github.com/letsencrypt/node-acme/commit/f42aa5b7fad4cd2fc289653c4ab14f18052367b3 - - -""" -import jsonschema +"""ACME protocol messages.""" +import urlparse from acme import challenges -from acme import errors +from acme import fields from acme import jose -from acme import other -from acme import util -class Message(jose.TypedJSONObjectWithFields): - # _fields_to_partial_json | pylint: disable=abstract-method - # pylint: disable=too-few-public-methods - """ACME message.""" - TYPES = {} - type_field_name = "type" +class Error(jose.JSONObjectWithFields, Exception): + """ACME error. - schema = NotImplemented - """JSON schema the object is tested against in :meth:`from_json`. - - Subclasses must overrride it with a value that is acceptable by - :func:`jsonschema.validate`, most probably using - :func:`acme.util.load_schema`. + https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 """ + ERROR_TYPE_NAMESPACE = 'urn:acme:error:' + ERROR_TYPE_DESCRIPTIONS = { + 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', + 'badNonce': 'The client sent an unacceptable anti-replay nonce', + 'connection': 'The server could not connect to the client for DV', + 'dnssec': 'The server could not validate a DNSSEC signed domain', + 'malformed': 'The request message was malformed', + 'serverInternal': 'The server experienced an internal error', + 'tls': 'The server experienced a TLS error during DV', + 'unauthorized': 'The client lacks sufficient authorization', + 'unknownHost': 'The server could not resolve a domain name', + } + + typ = jose.Field('type') + title = jose.Field('title', omitempty=True) + detail = jose.Field('detail') + + @typ.encoder + def typ(value): # pylint: disable=missing-docstring,no-self-argument + return Error.ERROR_TYPE_NAMESPACE + value + + @typ.decoder + def typ(value): # pylint: disable=missing-docstring,no-self-argument + # pylint thinks isinstance(value, Error), so startswith is not found + # pylint: disable=no-member + if not value.startswith(Error.ERROR_TYPE_NAMESPACE): + raise jose.DeserializationError('Missing error type prefix') + + without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):] + if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS: + raise jose.DeserializationError('Error type not recognized') + + return without_prefix + + @property + def description(self): + """Hardcoded error description based on its type.""" + return self.ERROR_TYPE_DESCRIPTIONS[self.typ] + + def __str__(self): + if self.typ is not None: + return ' :: '.join([self.typ, self.description, self.detail]) + else: + return str(self.detail) + + +class _Constant(jose.JSONDeSerializable): + """ACME constant.""" + __slots__ = ('name',) + POSSIBLE_NAMES = NotImplemented + + def __init__(self, name): + self.POSSIBLE_NAMES[name] = self + self.name = name + + def to_partial_json(self): + return self.name @classmethod - def from_json(cls, jobj): - """Deserialize from (possibly invalid) JSON object. + def from_json(cls, value): + if value not in cls.POSSIBLE_NAMES: + raise jose.DeserializationError( + '{0} not recognized'.format(cls.__name__)) + return cls.POSSIBLE_NAMES[value] - Note that the input ``jobj`` has not been sanitized in any way. + def __repr__(self): + return '{0}({1})'.format(self.__class__.__name__, self.name) - :param jobj: JSON object. + def __eq__(self, other): + return isinstance(other, type(self)) and other.name == self.name - :raises acme.errors.SchemaValidationError: if the input - JSON object could not be validated against JSON schema specified - in :attr:`schema`. - :raises acme.jose.errors.DeserializationError: for any - other generic error in decoding. - - :returns: instance of the class - - """ - msg_cls = cls.get_type_cls(jobj) - - # TODO: is that schema testing still relevant? - try: - jsonschema.validate(jobj, msg_cls.schema) - except jsonschema.ValidationError as error: - raise errors.SchemaValidationError(error) - - return super(Message, cls).from_json(jobj) + def __ne__(self, other): + return not self.__eq__(other) -@Message.register # pylint: disable=too-few-public-methods -class Challenge(Message): - """ACME "challenge" message. +class Status(_Constant): + """ACME "status" field.""" + POSSIBLE_NAMES = {} +STATUS_UNKNOWN = Status('unknown') +STATUS_PENDING = Status('pending') +STATUS_PROCESSING = Status('processing') +STATUS_VALID = Status('valid') +STATUS_INVALID = Status('invalid') +STATUS_REVOKED = Status('revoked') - :ivar str nonce: Random data, **not** base64-encoded. - :ivar list challenges: List of - :class:`~acme.challenges.Challenge` objects. - .. todo:: - 1. can challenges contain two challenges of the same type? - 2. can challenges contain duplicates? - 3. check "combinations" indices are in valid range - 4. turn "combinations" elements into sets? - 5. turn "combinations" into set? +class IdentifierType(_Constant): + """ACME identifier type.""" + POSSIBLE_NAMES = {} +IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder + + +class Identifier(jose.JSONObjectWithFields): + """ACME identifier. + + :ivar acme.messages.IdentifierType typ: """ - typ = "challenge" - schema = util.load_schema(typ) + typ = jose.Field('type', decoder=IdentifierType.from_json) + value = jose.Field('value') - session_id = jose.Field("sessionID") - nonce = jose.Field("nonce", encoder=jose.b64encode, - decoder=jose.decode_b64jose) - challenges = jose.Field("challenges") - combinations = jose.Field("combinations", omitempty=True, default=()) + +class Resource(jose.JSONObjectWithFields): + """ACME Resource. + + :ivar str uri: Location of the resource. + :ivar acme.messages.ResourceBody body: Resource body. + + """ + body = jose.Field('body') + + +class ResourceWithURI(Resource): + """ACME Resource with URI. + + :ivar str uri: Location of the resource. + + """ + uri = jose.Field('uri') # no ChallengeResource.uri + + +class ResourceBody(jose.JSONObjectWithFields): + """ACME Resource Body.""" + + +class Registration(ResourceBody): + """Registration Resource Body. + + :ivar acme.jose.jwk.JWK key: Public key. + :ivar tuple contact: Contact information following ACME spec + + """ + # on new-reg key server ignores 'key' and populates it based on + # JWS.signature.combined.jwk + key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) + contact = jose.Field('contact', omitempty=True, default=()) + recovery_token = jose.Field('recoveryToken', omitempty=True) + agreement = jose.Field('agreement', omitempty=True) + + phone_prefix = 'tel:' + email_prefix = 'mailto:' + + @classmethod + def from_data(cls, phone=None, email=None, **kwargs): + """Create registration resource from contact details.""" + details = list(kwargs.pop('contact', ())) + if phone is not None: + details.append(cls.phone_prefix + phone) + if email is not None: + details.append(cls.email_prefix + email) + kwargs['contact'] = tuple(details) + return cls(**kwargs) + + def _filter_contact(self, prefix): + return tuple( + detail[len(prefix):] for detail in self.contact + if detail.startswith(prefix)) + + @property + def phones(self): + """All phones found in the ``contact`` field.""" + return self._filter_contact(self.phone_prefix) + + @property + def emails(self): + """All emails found in the ``contact`` field.""" + return self._filter_contact(self.email_prefix) + + @property + def phone(self): + """Phone.""" + assert len(self.phones) == 1 + return self.phones[0] + + @property + def email(self): + """Email.""" + assert len(self.emails) == 1 + return self.emails[0] + + +class RegistrationResource(ResourceWithURI): + """Registration Resource. + + :ivar acme.messages.Registration body: + :ivar str new_authzr_uri: URI found in the 'next' ``Link`` header + :ivar str terms_of_service: URL for the CA TOS. + + """ + body = jose.Field('body', decoder=Registration.from_json) + new_authzr_uri = jose.Field('new_authzr_uri') + terms_of_service = jose.Field('terms_of_service', omitempty=True) + + +class ChallengeBody(ResourceBody): + """Challenge Resource Body. + + .. todo:: + Confusingly, this has a similar name to `.challenges.Challenge`, + as well as `.achallenges.AnnotatedChallenge`. Please use names + such as ``challb`` to distinguish instances of this class from + ``achall``. + + :ivar acme.challenges.Challenge: Wrapped challenge. + Conveniently, all challenge fields are proxied, i.e. you can + call ``challb.x`` to get ``challb.chall.x`` contents. + :ivar acme.messages.Status status: + :ivar datetime.datetime validated: + + """ + __slots__ = ('chall',) + uri = jose.Field('uri') + status = jose.Field('status', decoder=Status.from_json, + omitempty=True, default=STATUS_PENDING) + validated = fields.RFC3339Field('validated', omitempty=True) + error = jose.Field('error', decoder=Error.from_json, + omitempty=True, default=None) + + def to_partial_json(self): + jobj = super(ChallengeBody, self).to_partial_json() + jobj.update(self.chall.to_partial_json()) + return jobj + + @classmethod + def fields_from_json(cls, jobj): + jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj) + jobj_fields['chall'] = challenges.Challenge.from_json(jobj) + return jobj_fields + + def __getattr__(self, name): + return getattr(self.chall, name) + + +class ChallengeResource(Resource): + """Challenge Resource. + + :ivar acme.messages.ChallengeBody body: + :ivar str authzr_uri: URI found in the 'up' ``Link`` header. + + """ + body = jose.Field('body', decoder=ChallengeBody.from_json) + authzr_uri = jose.Field('authzr_uri') + + @property + def uri(self): # pylint: disable=missing-docstring,no-self-argument + # bug? 'method already defined line None' + # pylint: disable=function-redefined + return self.body.uri # pylint: disable=no-member + + +class Authorization(ResourceBody): + """Authorization Resource Body. + + :ivar acme.messages.Identifier identifier: + :ivar list challenges: `list` of `.ChallengeBody` + :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` + of `int`, as opposed to `list` of `list` from the spec). + :ivar acme.jose.jwk.JWK key: Public key. + :ivar tuple contact: + :ivar acme.messages.Status status: + :ivar datetime.datetime expires: + + """ + identifier = jose.Field('identifier', decoder=Identifier.from_json) + challenges = jose.Field('challenges', omitempty=True) + combinations = jose.Field('combinations', omitempty=True) + + status = jose.Field('status', omitempty=True, decoder=Status.from_json) + # TODO: 'expires' is allowed for Authorization Resources in + # general, but for Key Authorization '[t]he "expires" field MUST + # be absent'... then acme-spec gives example with 'expires' + # present... That's confusing! + expires = fields.RFC3339Field('expires', omitempty=True) @challenges.decoder def challenges(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(challenges.Challenge.from_json(chall) for chall in value) + return tuple(ChallengeBody.from_json(chall) for chall in value) @property def resolved_combinations(self): @@ -109,259 +297,61 @@ class Challenge(Message): for combo in self.combinations) -@Message.register # pylint: disable=too-few-public-methods -class ChallengeRequest(Message): - """ACME "challengeRequest" message.""" - typ = "challengeRequest" - schema = util.load_schema(typ) - identifier = jose.Field("identifier") +class AuthorizationResource(ResourceWithURI): + """Authorization Resource. - -@Message.register # pylint: disable=too-few-public-methods -class Authorization(Message): - """ACME "authorization" message. - - :ivar jwk: :class:`acme.jose.JWK` + :ivar acme.messages.Authorization body: + :ivar str new_cert_uri: URI found in the 'next' ``Link`` header """ - typ = "authorization" - schema = util.load_schema(typ) - - recovery_token = jose.Field("recoveryToken", omitempty=True) - identifier = jose.Field("identifier", omitempty=True) - jwk = jose.Field("jwk", decoder=jose.JWK.from_json, omitempty=True) + body = jose.Field('body', decoder=Authorization.from_json) + new_cert_uri = jose.Field('new_cert_uri') -@Message.register -class AuthorizationRequest(Message): - """ACME "authorizationRequest" message. +class CertificateRequest(jose.JSONObjectWithFields): + """ACME new-cert request. - :ivar str nonce: Random data from the corresponding - :attr:`Challenge.nonce`, **not** base64-encoded. - :ivar list responses: List of completed challenges ( - :class:`acme.challenges.ChallengeResponse`). - :ivar signature: Signature (:class:`acme.other.Signature`). + :ivar acme.jose.util.ComparableX509 csr: + `M2Crypto.X509.Request` wrapped in `.ComparableX509` + :ivar tuple authorizations: `tuple` of URIs (`str`) """ - typ = "authorizationRequest" - schema = util.load_schema(typ) + csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr) + authorizations = jose.Field('authorizations', decoder=tuple) - session_id = jose.Field("sessionID") - nonce = jose.Field("nonce", encoder=jose.b64encode, - decoder=jose.decode_b64jose) - responses = jose.Field("responses") - signature = jose.Field("signature", decoder=other.Signature.from_json) - contact = jose.Field("contact", omitempty=True, default=()) - @responses.decoder - def responses(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(challenges.ChallengeResponse.from_json(chall) - for chall in value) +class CertificateResource(ResourceWithURI): + """Certificate Resource. + + :ivar acme.jose.util.ComparableX509 body: + `M2Crypto.X509.X509` wrapped in `.ComparableX509` + :ivar str cert_chain_uri: URI found in the 'up' ``Link`` header + :ivar tuple authzrs: `tuple` of `AuthorizationResource`. + + """ + cert_chain_uri = jose.Field('cert_chain_uri') + authzrs = jose.Field('authzrs') + + +class Revocation(jose.JSONObjectWithFields): + """Revocation message. + + :ivar .ComparableX509 certificate: `M2Crypto.X509.X509` wrapped in + `.ComparableX509` + + """ + 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 create(cls, name, key, sig_nonce=None, **kwargs): - """Create signed "authorizationRequest". + def url(cls, base): + """Get revocation URL. - :param str name: Hostname - - :param key: Key used for signing. - :type key: :class:`Crypto.PublicKey.RSA` - - :param str sig_nonce: Nonce used for signature. Useful for testing. - :kwargs: Any other arguments accepted by the class constructor. - - :returns: Signed "authorizationRequest" ACME message. - :rtype: :class:`AuthorizationRequest` + :param str base: New Registration Resource or server (root) URL. """ - # pylint: disable=too-many-arguments - signature = other.Signature.from_msg( - name + kwargs["nonce"], key, sig_nonce) - return cls( - signature=signature, contact=kwargs.pop("contact", ()), **kwargs) - - def verify(self, name): - """Verify signature. - - .. warning:: Caller must check that the public key encoded in the - :attr:`signature`'s :class:`acme.jose.JWK` object - is the correct key for a given context. - - :param str name: Hostname - - :returns: True iff ``signature`` can be verified, False otherwise. - :rtype: bool - - """ - # self.signature is not Field | pylint: disable=no-member - return self.signature.verify(name + self.nonce) - - -@Message.register # pylint: disable=too-few-public-methods -class Certificate(Message): - """ACME "certificate" message. - - :ivar certificate: The certificate (:class:`M2Crypto.X509.X509` - wrapped in :class:`acme.util.ComparableX509`). - - :ivar list chain: Chain of certificates (:class:`M2Crypto.X509.X509` - wrapped in :class:`acme.util.ComparableX509` ). - - """ - typ = "certificate" - schema = util.load_schema(typ) - - certificate = jose.Field("certificate", encoder=jose.encode_cert, - decoder=jose.decode_cert) - chain = jose.Field("chain", omitempty=True, default=()) - refresh = jose.Field("refresh", omitempty=True) - - @chain.decoder - def chain(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(jose.decode_cert(cert) for cert in value) - - @chain.encoder - def chain(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(jose.encode_cert(cert) for cert in value) - - -@Message.register -class CertificateRequest(Message): - """ACME "certificateRequest" message. - - :ivar csr: Certificate Signing Request (:class:`M2Crypto.X509.Request` - wrapped in :class:`acme.util.ComparableX509`. - :ivar signature: Signature (:class:`acme.other.Signature`). - - """ - typ = "certificateRequest" - schema = util.load_schema(typ) - - csr = jose.Field("csr", encoder=jose.encode_csr, - decoder=jose.decode_csr) - signature = jose.Field("signature", decoder=other.Signature.from_json) - - @classmethod - def create(cls, key, sig_nonce=None, **kwargs): - """Create signed "certificateRequest". - - :param key: Key used for signing. - :type key: :class:`Crypto.PublicKey.RSA` - - :param str sig_nonce: Nonce used for signature. Useful for testing. - :kwargs: Any other arguments accepted by the class constructor. - - :returns: Signed "certificateRequest" ACME message. - :rtype: :class:`CertificateRequest` - - """ - return cls(signature=other.Signature.from_msg( - kwargs["csr"].as_der(), key, sig_nonce), **kwargs) - - def verify(self): - """Verify signature. - - .. warning:: Caller must check that the public key encoded in the - :attr:`signature`'s :class:`acme.jose.JWK` object - is the correct key for a given context. - - :returns: True iff ``signature`` can be verified, False otherwise. - :rtype: bool - - """ - # self.signature is not Field | pylint: disable=no-member - return self.signature.verify(self.csr.as_der()) - - -@Message.register # pylint: disable=too-few-public-methods -class Defer(Message): - """ACME "defer" message.""" - typ = "defer" - schema = util.load_schema(typ) - - token = jose.Field("token") - interval = jose.Field("interval", omitempty=True) - message = jose.Field("message", omitempty=True) - - -@Message.register # pylint: disable=too-few-public-methods -class Error(Message): - """ACME "error" message.""" - typ = "error" - schema = util.load_schema(typ) - - error = jose.Field("error") - message = jose.Field("message", omitempty=True) - more_info = jose.Field("moreInfo", omitempty=True) - - MESSAGE_CODES = { - "malformed": "The request message was malformed", - "unauthorized": "The client lacks sufficient authorization", - "serverInternal": "The server experienced an internal error", - "notSupported": "The request type is not supported", - "unknown": "The server does not recognize an ID/token in the request", - "badCSR": "The CSR is unacceptable (e.g., due to a short key)", - } - - -@Message.register # pylint: disable=too-few-public-methods -class Revocation(Message): - """ACME "revocation" message.""" - typ = "revocation" - schema = util.load_schema(typ) - - -@Message.register -class RevocationRequest(Message): - """ACME "revocationRequest" message. - - :ivar certificate: Certificate (:class:`M2Crypto.X509.X509` - wrapped in :class:`acme.util.ComparableX509`). - :ivar signature: Signature (:class:`acme.other.Signature`). - - """ - typ = "revocationRequest" - schema = util.load_schema(typ) - - certificate = jose.Field("certificate", decoder=jose.decode_cert, - encoder=jose.encode_cert) - signature = jose.Field("signature", decoder=other.Signature.from_json) - - @classmethod - def create(cls, key, sig_nonce=None, **kwargs): - """Create signed "revocationRequest". - - :param key: Key used for signing. - :type key: :class:`Crypto.PublicKey.RSA` - - :param str sig_nonce: Nonce used for signature. Useful for testing. - :kwargs: Any other arguments accepted by the class constructor. - - :returns: Signed "revocationRequest" ACME message. - :rtype: :class:`RevocationRequest` - - """ - return cls(signature=other.Signature.from_msg( - kwargs["certificate"].as_der(), key, sig_nonce), **kwargs) - - def verify(self): - """Verify signature. - - .. warning:: Caller must check that the public key encoded in the - :attr:`signature`'s :class:`acme.jose.JWK` object - is the correct key for a given context. - - :returns: True iff ``signature`` can be verified, False otherwise. - :rtype: bool - - """ - # self.signature is not Field | pylint: disable=no-member - return self.signature.verify(self.certificate.as_der()) - - -@Message.register # pylint: disable=too-few-public-methods -class StatusRequest(Message): - """ACME "statusRequest" message.""" - typ = "statusRequest" - schema = util.load_schema(typ) - token = jose.Field("token") + return urlparse.urljoin(base, cls.PATH) diff --git a/acme/messages2.py b/acme/messages2.py deleted file mode 100644 index 15b4521de..000000000 --- a/acme/messages2.py +++ /dev/null @@ -1,298 +0,0 @@ -"""ACME protocol messages.""" -from acme import challenges -from acme import fields -from acme import jose - - -class Error(jose.JSONObjectWithFields, Exception): - """ACME error. - - https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 - - """ - ERROR_TYPE_NAMESPACE = 'urn:acme:error:' - ERROR_TYPE_DESCRIPTIONS = { - 'malformed': 'The request message was malformed', - 'unauthorized': 'The client lacks sufficient authorization', - 'serverInternal': 'The server experienced an internal error', - 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', - 'badNonce': 'The client sent an unacceptable anti-replay nonce', - } - - typ = jose.Field('type') - title = jose.Field('title', omitempty=True) - detail = jose.Field('detail') - - @typ.encoder - def typ(value): # pylint: disable=missing-docstring,no-self-argument - return Error.ERROR_TYPE_NAMESPACE + value - - @typ.decoder - def typ(value): # pylint: disable=missing-docstring,no-self-argument - # pylint thinks isinstance(value, Error), so startswith is not found - # pylint: disable=no-member - if not value.startswith(Error.ERROR_TYPE_NAMESPACE): - raise jose.DeserializationError('Missing error type prefix') - - without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):] - if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS: - raise jose.DeserializationError('Error type not recognized') - - return without_prefix - - @property - def description(self): - """Hardcoded error description based on its type.""" - return self.ERROR_TYPE_DESCRIPTIONS[self.typ] - - def __str__(self): - if self.typ is not None: - return ' :: '.join([self.typ, self.description, self.detail]) - else: - return str(self.detail) - -class _Constant(jose.JSONDeSerializable): - """ACME constant.""" - __slots__ = ('name',) - POSSIBLE_NAMES = NotImplemented - - def __init__(self, name): - self.POSSIBLE_NAMES[name] = self - self.name = name - - def to_partial_json(self): - return self.name - - @classmethod - def from_json(cls, value): - if value not in cls.POSSIBLE_NAMES: - raise jose.DeserializationError( - '{0} not recognized'.format(cls.__name__)) - return cls.POSSIBLE_NAMES[value] - - def __repr__(self): - return '{0}({1})'.format(self.__class__.__name__, self.name) - - def __eq__(self, other): - return isinstance(other, type(self)) and other.name == self.name - - def __ne__(self, other): - return not self.__eq__(other) - - -class Status(_Constant): - """ACME "status" field.""" - POSSIBLE_NAMES = {} -STATUS_UNKNOWN = Status('unknown') -STATUS_PENDING = Status('pending') -STATUS_PROCESSING = Status('processing') -STATUS_VALID = Status('valid') -STATUS_INVALID = Status('invalid') -STATUS_REVOKED = Status('revoked') - - -class IdentifierType(_Constant): - """ACME identifier type.""" - POSSIBLE_NAMES = {} -IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder - - -class Identifier(jose.JSONObjectWithFields): - """ACME identifier. - - :ivar acme.messages2.IdentifierType typ: - - """ - typ = jose.Field('type', decoder=IdentifierType.from_json) - value = jose.Field('value') - - -class Resource(jose.ImmutableMap): - """ACME Resource. - - :ivar acme.messages2.ResourceBody body: Resource body. - :ivar str uri: Location of the resource. - - """ - __slots__ = ('body', 'uri') - - -class ResourceBody(jose.JSONObjectWithFields): - """ACME Resource Body.""" - - -class RegistrationResource(Resource): - """Registration Resource. - - :ivar acme.messages2.Registration body: - :ivar str new_authzr_uri: URI found in the 'next' ``Link`` header - :ivar str terms_of_service: URL for the CA TOS. - - """ - __slots__ = ('body', 'uri', 'new_authzr_uri', 'terms_of_service') - - -class Registration(ResourceBody): - """Registration Resource Body. - - :ivar acme.jose.jwk.JWK key: Public key. - :ivar tuple contact: Contact information following ACME spec - - """ - # on new-reg key server ignores 'key' and populates it based on - # JWS.signature.combined.jwk - key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) - contact = jose.Field('contact', omitempty=True, default=()) - recovery_token = jose.Field('recoveryToken', omitempty=True) - agreement = jose.Field('agreement', omitempty=True) - - -class ChallengeResource(Resource, jose.JSONObjectWithFields): - """Challenge Resource. - - :ivar acme.messages2.ChallengeBody body: - :ivar str authzr_uri: URI found in the 'up' ``Link`` header. - - """ - __slots__ = ('body', 'authzr_uri') - - @property - def uri(self): # pylint: disable=missing-docstring,no-self-argument - # bug? 'method already defined line None' - # pylint: disable=function-redefined - return self.body.uri - - -class ChallengeBody(ResourceBody): - """Challenge Resource Body. - - .. todo:: - Confusingly, this has a similar name to `.challenges.Challenge`, - as well as `.achallenges.AnnotatedChallenge`. Please use names - such as ``challb`` to distinguish instances of this class from - ``achall``. - - :ivar acme.challenges.Challenge: Wrapped challenge. - Conveniently, all challenge fields are proxied, i.e. you can - call ``challb.x`` to get ``challb.chall.x`` contents. - :ivar acme.messages2.Status status: - :ivar datetime.datetime validated: - - """ - __slots__ = ('chall',) - uri = jose.Field('uri') - status = jose.Field('status', decoder=Status.from_json) - validated = fields.RFC3339Field('validated', omitempty=True) - - def to_partial_json(self): - jobj = super(ChallengeBody, self).to_partial_json() - jobj.update(self.chall.to_partial_json()) - return jobj - - @classmethod - def fields_from_json(cls, jobj): - jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj) - jobj_fields['chall'] = challenges.Challenge.from_json(jobj) - return jobj_fields - - def __getattr__(self, name): - return getattr(self.chall, name) - - -class AuthorizationResource(Resource): - """Authorization Resource. - - :ivar acme.messages2.Authorization body: - :ivar str new_cert_uri: URI found in the 'next' ``Link`` header - - """ - __slots__ = ('body', 'uri', 'new_cert_uri') - - -class Authorization(ResourceBody): - """Authorization Resource Body. - - :ivar acme.messages2.Identifier identifier: - :ivar list challenges: `list` of `.ChallengeBody` - :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` - of `int`, as opposed to `list` of `list` from the spec). - :ivar acme.jose.jwk.JWK key: Public key. - :ivar tuple contact: - :ivar acme.messages2.Status status: - :ivar datetime.datetime expires: - - """ - identifier = jose.Field('identifier', decoder=Identifier.from_json) - challenges = jose.Field('challenges', omitempty=True) - combinations = jose.Field('combinations', omitempty=True) - - status = jose.Field('status', omitempty=True, decoder=Status.from_json) - # TODO: 'expires' is allowed for Authorization Resources in - # general, but for Key Authorization '[t]he "expires" field MUST - # be absent'... then acme-spec gives example with 'expires' - # present... That's confusing! - expires = fields.RFC3339Field('expires', omitempty=True) - - @challenges.decoder - def challenges(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(ChallengeBody.from_json(chall) for chall in value) - - @property - def resolved_combinations(self): - """Combinations with challenges instead of indices.""" - return tuple(tuple(self.challenges[idx] for idx in combo) - for combo in self.combinations) - - -class CertificateRequest(jose.JSONObjectWithFields): - """ACME new-cert request. - - :ivar acme.jose.util.ComparableX509 csr: - `M2Crypto.X509.Request` wrapped in `.ComparableX509` - :ivar tuple authorizations: `tuple` of URIs (`str`) - - """ - csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr) - authorizations = jose.Field('authorizations', decoder=tuple) - - -class CertificateResource(Resource): - """Certificate Resource. - - :ivar acme.jose.util.ComparableX509 body: - `M2Crypto.X509.X509` wrapped in `.ComparableX509` - :ivar str cert_chain_uri: URI found in the 'up' ``Link`` header - :ivar tuple authzrs: `tuple` of `AuthorizationResource`. - - """ - __slots__ = ('body', 'uri', 'cert_chain_uri', 'authzrs') - - -class Revocation(jose.JSONObjectWithFields): - """Revocation message. - - :ivar revoke: Either a `datetime.datetime` or `Revocation.NOW`. - :ivar tuple authorizations: Same as `CertificateRequest.authorizations` - - """ - - NOW = 'now' - """A possible value for `revoke`, denoting that certificate should - be revoked now.""" - - revoke = jose.Field('revoke') - authorizations = CertificateRequest._fields['authorizations'] - - @revoke.decoder - def revoke(value): # pylint: disable=missing-docstring,no-self-argument - if value == Revocation.NOW: - return value - else: - return fields.RFC3339Field.default_decoder(value) - - @revoke.encoder - def revoke(value): # pylint: disable=missing-docstring,no-self-argument - if value == Revocation.NOW: - return value - else: - return fields.RFC3339Field.default_encoder(value) diff --git a/acme/messages2_test.py b/acme/messages2_test.py deleted file mode 100644 index 72ffc954a..000000000 --- a/acme/messages2_test.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Tests for acme.messages2.""" -import datetime -import os -import pkg_resources -import unittest - -import mock -import pytz -from Crypto.PublicKey import RSA - -from acme import challenges -from acme import jose - - -KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) - - -class ErrorTest(unittest.TestCase): - """Tests for acme.messages2.Error.""" - - def setUp(self): - from acme.messages2 import Error - self.error = Error(detail='foo', typ='malformed', title='title') - self.jobj = {'detail': 'foo', 'title': 'some title'} - - def test_typ_prefix(self): - self.assertEqual('malformed', self.error.typ) - self.assertEqual( - 'urn:acme:error:malformed', self.error.to_partial_json()['type']) - self.assertEqual( - 'malformed', self.error.from_json(self.error.to_partial_json()).typ) - - def test_typ_decoder_missing_prefix(self): - from acme.messages2 import Error - self.jobj['type'] = 'malformed' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - self.jobj['type'] = 'not valid bare type' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - - def test_typ_decoder_not_recognized(self): - from acme.messages2 import Error - self.jobj['type'] = 'urn:acme:error:baz' - self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) - - def test_description(self): - self.assertEqual( - 'The request message was malformed', self.error.description) - - def test_from_json_hashable(self): - from acme.messages2 import Error - hash(Error.from_json(self.error.to_json())) - - def test_str(self): - self.assertEqual( - 'malformed :: The request message was malformed :: foo', - str(self.error)) - self.assertEqual('foo', str(self.error.update(typ=None))) - - -class ConstantTest(unittest.TestCase): - """Tests for acme.messages2._Constant.""" - - def setUp(self): - from acme.messages2 import _Constant - class MockConstant(_Constant): # pylint: disable=missing-docstring - POSSIBLE_NAMES = {} - - self.MockConstant = MockConstant # pylint: disable=invalid-name - self.const_a = MockConstant('a') - self.const_b = MockConstant('b') - - def test_to_partial_json(self): - self.assertEqual('a', self.const_a.to_partial_json()) - self.assertEqual('b', self.const_b.to_partial_json()) - - def test_from_json(self): - self.assertEqual(self.const_a, self.MockConstant.from_json('a')) - self.assertRaises( - jose.DeserializationError, self.MockConstant.from_json, 'c') - - def test_from_json_hashable(self): - hash(self.MockConstant.from_json('a')) - - def test_repr(self): - self.assertEqual('MockConstant(a)', repr(self.const_a)) - self.assertEqual('MockConstant(b)', repr(self.const_b)) - - def test_equality(self): - const_a_prime = self.MockConstant('a') - self.assertFalse(self.const_a == self.const_b) - self.assertTrue(self.const_a == const_a_prime) - - self.assertTrue(self.const_a != self.const_b) - self.assertFalse(self.const_a != const_a_prime) - -class RegistrationTest(unittest.TestCase): - """Tests for acme.messages2.Registration.""" - - def setUp(self): - key = jose.jwk.JWKRSA(key=KEY.publickey()) - contact = ('mailto:letsencrypt-client@letsencrypt.org',) - recovery_token = 'XYZ' - agreement = 'https://letsencrypt.org/terms' - - from acme.messages2 import Registration - self.reg = Registration( - key=key, contact=contact, recovery_token=recovery_token, - agreement=agreement) - - self.jobj_to = { - 'contact': contact, - 'recoveryToken': recovery_token, - 'agreement': agreement, - 'key': key, - } - self.jobj_from = self.jobj_to.copy() - self.jobj_from['key'] = key.to_json() - - def test_to_partial_json(self): - self.assertEqual(self.jobj_to, self.reg.to_partial_json()) - - def test_from_json(self): - from acme.messages2 import Registration - self.assertEqual(self.reg, Registration.from_json(self.jobj_from)) - - def test_from_json_hashable(self): - from acme.messages2 import Registration - hash(Registration.from_json(self.jobj_from)) - - -class ChallengeResourceTest(unittest.TestCase): - """Tests for acme.messages2.ChallengeResource.""" - - def test_uri(self): - from acme.messages2 import ChallengeResource - self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock( - uri='http://challb'), authzr_uri='http://authz').uri) - - -class ChallengeBodyTest(unittest.TestCase): - """Tests for acme.messages2.ChallengeBody.""" - - def setUp(self): - self.chall = challenges.DNS(token='foo') - - from acme.messages2 import ChallengeBody - from acme.messages2 import STATUS_VALID - self.status = STATUS_VALID - self.challb = ChallengeBody( - uri='http://challb', chall=self.chall, status=self.status) - - self.jobj_to = { - 'uri': 'http://challb', - 'status': self.status, - 'type': 'dns', - 'token': 'foo', - } - self.jobj_from = self.jobj_to.copy() - self.jobj_from['status'] = 'valid' - - def test_to_partial_json(self): - self.assertEqual(self.jobj_to, self.challb.to_partial_json()) - - def test_from_json(self): - from acme.messages2 import ChallengeBody - self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from)) - - def test_from_json_hashable(self): - from acme.messages2 import ChallengeBody - hash(ChallengeBody.from_json(self.jobj_from)) - - def test_proxy(self): - self.assertEqual('foo', self.challb.token) - - -class AuthorizationTest(unittest.TestCase): - """Tests for acme.messages2.Authorization.""" - - def setUp(self): - from acme.messages2 import ChallengeBody - from acme.messages2 import STATUS_VALID - self.challbs = ( - ChallengeBody( - uri='http://challb1', status=STATUS_VALID, - chall=challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A')), - ChallengeBody(uri='http://challb2', status=STATUS_VALID, - chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')), - ChallengeBody(uri='http://challb3', status=STATUS_VALID, - chall=challenges.RecoveryToken()), - ) - combinations = ((0, 2), (1, 2)) - - from acme.messages2 import Authorization - from acme.messages2 import Identifier - from acme.messages2 import IDENTIFIER_FQDN - identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com') - self.authz = Authorization( - identifier=identifier, combinations=combinations, - challenges=self.challbs) - - self.jobj_from = { - 'identifier': identifier.to_json(), - 'challenges': [challb.to_json() for challb in self.challbs], - 'combinations': combinations, - } - - def test_from_json(self): - from acme.messages2 import Authorization - Authorization.from_json(self.jobj_from) - - def test_from_json_hashable(self): - from acme.messages2 import Authorization - hash(Authorization.from_json(self.jobj_from)) - - def test_resolved_combinations(self): - self.assertEqual(self.authz.resolved_combinations, ( - (self.challbs[0], self.challbs[2]), - (self.challbs[1], self.challbs[2]), - )) - - -class RevocationTest(unittest.TestCase): - """Tests for acme.messages2.RevocationTest.""" - - def setUp(self): - from acme.messages2 import Revocation - self.rev_now = Revocation(authorizations=(), revoke=Revocation.NOW) - self.rev_date = Revocation(authorizations=(), revoke=datetime.datetime( - 2015, 3, 27, tzinfo=pytz.utc)) - self.jobj_now = {'authorizations': (), 'revoke': Revocation.NOW} - self.jobj_date = {'authorizations': (), - 'revoke': '2015-03-27T00:00:00Z'} - - def test_revoke_decoder(self): - from acme.messages2 import Revocation - self.assertEqual(self.rev_now, Revocation.from_json(self.jobj_now)) - self.assertEqual(self.rev_date, Revocation.from_json(self.jobj_date)) - - def test_revoke_encoder(self): - self.assertEqual(self.jobj_now, self.rev_now.to_partial_json()) - self.assertEqual(self.jobj_date, self.rev_date.to_partial_json()) - - def test_from_json_hashable(self): - from acme.messages2 import Revocation - hash(Revocation.from_json(self.rev_now.to_json())) - - -if __name__ == '__main__': - unittest.main() # pragma: no cover diff --git a/acme/messages_test.py b/acme/messages_test.py index baff2a21a..dca1cd280 100644 --- a/acme/messages_test.py +++ b/acme/messages_test.py @@ -3,477 +3,346 @@ import os import pkg_resources import unittest -import Crypto.PublicKey.RSA +from Crypto.PublicKey import RSA import M2Crypto +import mock from acme import challenges -from acme import errors from acme import jose -from acme import other -KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( +CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) + 'acme.jose', os.path.join('testdata', 'cert.der')), + M2Crypto.X509.FORMAT_DER)) +CSR = jose.ComparableX509(M2Crypto.X509.load_request_string( + pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'csr.der')), + M2Crypto.X509.FORMAT_DER)) +KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) CERT = jose.ComparableX509(M2Crypto.X509.load_cert( - pkg_resources.resource_filename( - 'letsencrypt.tests', os.path.join('testdata', 'cert.pem')))) -CSR = jose.ComparableX509(M2Crypto.X509.load_request( - pkg_resources.resource_filename( - 'letsencrypt.tests', os.path.join('testdata', 'csr.pem')))) -CSR2 = jose.ComparableX509(M2Crypto.X509.load_request( - pkg_resources.resource_filename( - 'acme.jose', os.path.join('testdata', 'csr2.pem')))) - - -class MessageTest(unittest.TestCase): - """Tests for acme.messages.Message.""" - - def setUp(self): - # pylint: disable=missing-docstring,too-few-public-methods - from acme.messages import Message - - class MockParentMessage(Message): - # pylint: disable=abstract-method - TYPES = {} - - @MockParentMessage.register - class MockMessage(MockParentMessage): - typ = 'test' - schema = { - 'type': 'object', - 'properties': { - 'price': {'type': 'number'}, - 'name': {'type': 'string'}, - }, - } - price = jose.Field('price') - name = jose.Field('name') - - self.parent_cls = MockParentMessage - self.msg = MockMessage(price=123, name='foo') - - def test_from_json_validates(self): - self.assertRaises(errors.SchemaValidationError, - self.parent_cls.from_json, - {'type': 'test', 'price': 'asd'}) - - -class ChallengeTest(unittest.TestCase): - - def setUp(self): - challs = ( - challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'), - challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), - challenges.RecoveryToken(), - ) - combinations = ((0, 2), (1, 2)) - - from acme.messages import Challenge - self.msg = Challenge( - session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4', - nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9', - challenges=challs, combinations=combinations) - - self.jmsg_to = { - 'type': 'challenge', - 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', - 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'challenges': challs, - 'combinations': combinations, - } - - self.jmsg_from = { - 'type': 'challenge', - 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', - 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'challenges': [chall.to_json() for chall in challs], - 'combinations': [[0, 2], [1, 2]], # TODO array tuples - } - - def test_resolved_combinations(self): - self.assertEqual(self.msg.resolved_combinations, ( - ( - challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'), - challenges.RecoveryToken() - ), - ( - challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), - challenges.RecoveryToken(), - ) - )) - - def test_to_partial_json(self): - self.assertEqual(self.msg.to_partial_json(), self.jmsg_to) - - def test_from_json(self): - from acme.messages import Challenge - self.assertEqual(Challenge.from_json(self.jmsg_from), self.msg) - - def test_json_without_optionals(self): - del self.jmsg_from['combinations'] - del self.jmsg_to['combinations'] - - from acme.messages import Challenge - msg = Challenge.from_json(self.jmsg_from) - - self.assertEqual(msg.combinations, ()) - self.assertEqual(msg.to_partial_json(), self.jmsg_to) - - -class ChallengeRequestTest(unittest.TestCase): - - def setUp(self): - from acme.messages import ChallengeRequest - self.msg = ChallengeRequest(identifier='example.com') - - self.jmsg = { - 'type': 'challengeRequest', - 'identifier': 'example.com', - } - - def test_to_partial_json(self): - self.assertEqual(self.msg.to_partial_json(), self.jmsg) - - def test_from_json(self): - from acme.messages import ChallengeRequest - self.assertEqual(ChallengeRequest.from_json(self.jmsg), self.msg) - - -class AuthorizationTest(unittest.TestCase): - - def setUp(self): - jwk = jose.JWKRSA(key=KEY.publickey()) - - from acme.messages import Authorization - self.msg = Authorization(recovery_token='tok', jwk=jwk, - identifier='example.com') - - self.jmsg = { - 'type': 'authorization', - 'recoveryToken': 'tok', - 'identifier': 'example.com', - 'jwk': jwk, - } - - def test_to_partial_json(self): - self.assertEqual(self.msg.to_partial_json(), self.jmsg) - - def test_from_json(self): - self.jmsg['jwk'] = self.jmsg['jwk'].to_partial_json() - - from acme.messages import Authorization - self.assertEqual(Authorization.from_json(self.jmsg), self.msg) - - def test_json_without_optionals(self): - del self.jmsg['recoveryToken'] - del self.jmsg['identifier'] - del self.jmsg['jwk'] - - from acme.messages import Authorization - msg = Authorization.from_json(self.jmsg) - - self.assertTrue(msg.recovery_token is None) - self.assertTrue(msg.identifier is None) - self.assertTrue(msg.jwk is None) - self.assertEqual(self.jmsg, msg.to_partial_json()) - - -class AuthorizationRequestTest(unittest.TestCase): - - def setUp(self): - self.responses = ( - challenges.SimpleHTTPResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'), - None, # null - challenges.RecoveryTokenResponse(token='23029d88d9e123e'), - ) - self.contact = ("mailto:cert-admin@example.com", "tel:+12025551212") - signature = other.Signature( - alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()), - sig='-v\xd8\xc2\xa3\xba0\xd6\x92\x16\xb5.\xbe\xa1[\x04\xbe' - '\x1b\xa1X\xd2)\x18\x94\x8f\xd7\xd0\xc0\xbbcI`W\xdf v' - '\xe4\xed\xe8\x03J\xe8\xc8 60, 14.04 => 1404 # TODO: in sid version==unstable @@ -29,6 +32,8 @@ newer () { fi } +apt-get update + # you can force newer if lsb_release is not available (e.g. Docker # debian:jessie base image) if [ "$1" = "newer" ] || newer @@ -43,7 +48,16 @@ fi # #276, https://github.com/martinpaljak/M2Crypto/issues/62, # M2Crypto setup.py:add_multiarch_paths -apt-get update apt-get install -y --no-install-recommends \ - python python-setuptools "$virtualenv" python-dev gcc swig \ - dialog libaugeas0 libssl-dev libffi-dev ca-certificates dpkg-dev + git-core \ + python \ + python-dev \ + "$virtualenv" \ + gcc \ + swig \ + dialog \ + libaugeas0 \ + libssl-dev \ + libffi-dev \ + ca-certificates \ + dpkg-dev \ diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh new file mode 100755 index 000000000..1209cd44a --- /dev/null +++ b/bootstrap/_rpm_common.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Tested with: +# - Fedora 22 (x64) +# - Centos 7 (x64: on AWS EC2 t2.micro, DigitalOcean droplet) + +# "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails) +yum install -y \ + git-core \ + python \ + python-devel \ + python-virtualenv \ + python-devel \ + gcc \ + swig \ + dialog \ + augeas-libs \ + openssl-devel \ + libffi-devel \ + ca-certificates \ diff --git a/bootstrap/centos.sh b/bootstrap/centos.sh new file mode 120000 index 000000000..a0db46d70 --- /dev/null +++ b/bootstrap/centos.sh @@ -0,0 +1 @@ +_rpm_common.sh \ No newline at end of file diff --git a/bootstrap/fedora.sh b/bootstrap/fedora.sh new file mode 120000 index 000000000..a0db46d70 --- /dev/null +++ b/bootstrap/fedora.sh @@ -0,0 +1 @@ +_rpm_common.sh \ No newline at end of file diff --git a/docs/api/network2.rst b/docs/api/network2.rst deleted file mode 100644 index a73308e1b..000000000 --- a/docs/api/network2.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.network2` ---------------------------- - -.. automodule:: letsencrypt.network2 - :members: diff --git a/docs/api/plugins/manual.rst b/docs/api/plugins/manual.rst new file mode 100644 index 000000000..4661ab7df --- /dev/null +++ b/docs/api/plugins/manual.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.plugins.manual` +--------------------------------- + +.. automodule:: letsencrypt.plugins.manual + :members: diff --git a/docs/contributing.rst b/docs/contributing.rst index da28686a2..05a6875fe 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -17,6 +17,14 @@ Now you can install the development packages: ./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing] +.. note:: `-e` (short for `--editable`) turns on *editable mode* in + which any source code changes in the current working + directory are "live" and no further `pip install ...` + invocations are necessary while developing. + + This is roughly equivalent to `python setup.py develop`. For + more info see `man pip`. + The code base, including your pull requests, **must** have 100% test statement coverage **and** be compliant with the :ref:`coding style `. @@ -48,7 +56,7 @@ synced to ``/vagrant``, so you can get started with: vagrant ssh cd /vagrant - ./venv/bin/pip install -r requirements.txt + ./venv/bin/pip install -r requirements.txt .[dev,docs,testing] sudo ./venv/bin/letsencrypt Support for other Linux distributions coming soon. @@ -89,7 +97,7 @@ Configurators may implement just one of those). There are also `~letsencrypt.interfaces.IDisplay` plugins, which implement bindings to alternative UI libraries. -.. _interfaces.py: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/interfaces.py +.. _interfaces.py: https://github.com/letsencrypt/letsencrypt/blob/master/letsencrypt/interfaces.py Authenticators diff --git a/docs/pkgs.rst b/docs/pkgs.rst index 8119ffc7e..2e1b18dfb 100644 --- a/docs/pkgs.rst +++ b/docs/pkgs.rst @@ -6,7 +6,7 @@ Packages described in `#358`_. For the time being those packages are bundled together into a single repo, and single documentation. -.. _`#358`: https://github.com/letsencrypt/lets-encrypt-preview/issues/358 +.. _`#358`: https://github.com/letsencrypt/letsencrypt/issues/358 .. toctree:: :glob: diff --git a/docs/pkgs/acme/index.rst b/docs/pkgs/acme/index.rst index 9cca3b795..2df2615a5 100644 --- a/docs/pkgs/acme/index.rst +++ b/docs/pkgs/acme/index.rst @@ -7,21 +7,19 @@ :members: +Client +------ + +.. automodule:: acme.client + :members: + + Messages -------- -v00 -~~~ - .. automodule:: acme.messages :members: -v02 -~~~ - -.. automodule:: acme.messages2 - :members: - Challenges ---------- @@ -51,9 +49,6 @@ Errors :members: - :members: - - Utilities --------- diff --git a/docs/using.rst b/docs/using.rst index 89cbc48f6..951c991d2 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -5,20 +5,42 @@ Using the Let's Encrypt client Quick start =========== -Using docker you can quickly get yourself a testing cert. From the +Using Docker_ you can quickly get yourself a testing cert. From the server that the domain your requesting a cert for resolves to, -download docker, and issue the following command +`install Docker`_, issue the following command: .. code-block:: shell sudo docker run -it --rm -p 443:443 --name letsencrypt \ -v "/etc/letsencrypt:/etc/letsencrypt" \ -v "/var/lib/letsencrypt:/var/lib/letsencrypt" \ - quay.io/letsencrypt/lets-encrypt-preview:latest + quay.io/letsencrypt/letsencrypt:latest -And follow the instructions. Your new cert will be available in +and follow the instructions. Your new cert will be available in ``/etc/letsencrypt/certs``. +.. _Docker: https://docker.com +.. _`install Docker`: https://docs.docker.com/docker/userguide/ + + +Getting the code +================ + +Please `install Git`_ and run the following commands: + +.. code-block:: shell + + git clone https://github.com/letsencrypt/letsencrypt + cd letsencrypt + +Alternatively you could `download the ZIP archive`_ and extract the +snapshot of our repository, but it's strongly recommended to use the +above method instead. + +.. _`install Git`: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git +.. _`download the ZIP archive`: + https://github.com/letsencrypt/letsencrypt/archive/master.zip + Prerequisites ============= @@ -30,8 +52,8 @@ are provided mainly for the :ref:`developers ` reference. In general: * ``sudo`` is required as a suggested way of running privileged process -* `swig`_ is required for compiling `m2crypto`_ -* `augeas`_ is required for the ``python-augeas`` bindings +* `SWIG`_ is required for compiling `M2Crypto`_ +* `Augeas`_ is required for the Python bindings Ubuntu @@ -54,7 +76,7 @@ For squeeze you will need to: - Use ``virtualenv --no-site-packages -p python`` instead of ``-p python2``. -.. _`#280`: https://github.com/letsencrypt/lets-encrypt-preview/issues/280 +.. _`#280`: https://github.com/letsencrypt/letsencrypt/issues/280 Mac OSX @@ -65,25 +87,71 @@ Mac OSX sudo ./bootstrap/mac.sh +Fedora +------ + +.. code-block:: shell + + sudo ./bootstrap/fedora.sh + + +Centos 7 +-------- + +.. code-block:: shell + + sudo ./bootstrap/centos.sh + +For installation run this modified command (note the trailing +backslash): + +.. code-block:: shell + + SWIG_FEATURES="-includeall -D__`uname -m`__-I/usr/include/openssl" \ + ./venv/bin/pip install -r requirements.txt . + + Installation ============ .. code-block:: shell virtualenv --no-site-packages -p python2 venv - ./venv/bin/pip install -r requirements.txt + ./venv/bin/pip install -r requirements.txt . + +.. warning:: Please do **not** use ``python setup.py install``. Please + do **not** attempt the installation commands as + superuser/root and/or without Virtualenv_, e.g. ``sudo + python setup.py install``, ``sudo pip install``, ``sudo + ./venv/bin/...``. These modes of operation might corrupt + your operating system and are **not supported** by the + Let's Encrypt team! + +.. note:: If your operating system uses SWIG 3.0.5+, you will need to + run ``pip install -r requirements-swig-3.0.5.txt -r + requirements.txt .`` instead. Known affected systems: + + * Fedora 22 + * some versions of Mac OS X Usage ===== -The letsencrypt commandline tool has a builtin help: +To get a new certificate run: + +.. code-block:: shell + + ./venv/bin/letsencrypt auth + +The ``letsencrypt`` commandline tool has a builtin help: .. code-block:: shell ./venv/bin/letsencrypt --help -.. _augeas: http://augeas.net/ -.. _m2crypto: https://github.com/M2Crypto/M2Crypto -.. _swig: http://www.swig.org/ +.. _Augeas: http://augeas.net/ +.. _M2Crypto: https://github.com/M2Crypto/M2Crypto +.. _SWIG: http://www.swig.org/ +.. _Virtualenv: https://virtualenv.pypa.io diff --git a/examples/acme_client.py b/examples/acme_client.py new file mode 100644 index 000000000..09ff2bfc3 --- /dev/null +++ b/examples/acme_client.py @@ -0,0 +1,45 @@ +"""Example script showing how to use acme client API.""" +import logging +import os +import pkg_resources + +import Crypto.PublicKey.RSA +import M2Crypto + +from acme import client +from acme import messages +from acme import jose + + +logging.basicConfig(level=logging.DEBUG) + + +NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg' +BITS = 2048 # minimum for Boulder +DOMAIN = 'example1.com' # example.com is ignored by Boulder + +key = jose.JWKRSA.load( + Crypto.PublicKey.RSA.generate(BITS).exportKey(format="PEM")) +acme = client.Client(NEW_REG_URL, key) + +regr = acme.register(contact=()) +logging.info('Auto-accepting TOS: %s', regr.terms_of_service) +acme.update_registration(regr.update( + body=regr.body.update(agreement=regr.terms_of_service))) +logging.debug(regr) + +authzr = acme.request_challenges( + identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=DOMAIN), + new_authzr_uri=regr.new_authzr_uri) +logging.debug(authzr) + +authzr, authzr_response = acme.poll(authzr) + +csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'csr.der')), + M2Crypto.X509.FORMAT_DER) +try: + acme.request_issuance(csr, (authzr,)) +except messages.Error as error: + print ("This script is doomed to fail as no authorization " + "challenges are ever solved. Error from server: {0}".format(error)) diff --git a/examples/restified.py b/examples/restified.py deleted file mode 100644 index c0252c1eb..000000000 --- a/examples/restified.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging -import os -import pkg_resources - -import M2Crypto - -from acme import messages2 -from acme import jose - -from letsencrypt import network2 - - -logger = logging.getLogger() -logger.setLevel(logging.DEBUG) - -NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg' - -key = jose.JWKRSA.load(pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) -net = network2.Network(NEW_REG_URL, key) - -regr = net.register(contact=( - 'mailto:cert-admin@example.com', 'tel:+12025551212')) -logging.info('Auto-accepting TOS: %s', regr.terms_of_service) -net.update_registration(regr.update( - body=regr.body.update(agreement=regr.terms_of_service))) -logging.debug(regr) - -authzr = net.request_challenges( - identifier=messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value='example1.com'), - new_authzr_uri=regr.new_authzr_uri) -logging.debug(authzr) - -authzr, authzr_response = net.poll(authzr) - -csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string( - 'letsencrypt.tests', os.path.join('testdata', 'csr.pem'))) -try: - net.request_issuance(csr, (authzr,)) -except messages2.Error as error: - print error.detail diff --git a/letsencrypt/account.py b/letsencrypt/account.py index 3f8e3d012..f651bfdb2 100644 --- a/letsencrypt/account.py +++ b/letsencrypt/account.py @@ -6,7 +6,7 @@ import re import configobj import zope.component -from acme import messages2 +from acme import messages from letsencrypt import crypto_util from letsencrypt import errors @@ -28,7 +28,7 @@ class Account(object): :ivar str phone: Client's phone number :ivar regr: Registration Resource - :type regr: :class:`~acme.messages2.RegistrationResource` + :type regr: :class:`~acme.messages.RegistrationResource` """ @@ -127,7 +127,7 @@ class Account(object): acc_config = configobj.ConfigObj( infile=config_fp, file_error=True, create_empty=False) except IOError: - raise errors.LetsEncryptClientError( + raise errors.Error( "Account for %s does not exist" % os.path.basename(config_fp)) if os.path.basename(config_fp) != "default": @@ -141,11 +141,11 @@ class Account(object): if "RegistrationResource" in acc_config: acc_config_rr = acc_config["RegistrationResource"] - regr = messages2.RegistrationResource( + regr = messages.RegistrationResource( uri=acc_config_rr["uri"], new_authzr_uri=acc_config_rr["new_authzr_uri"], terms_of_service=acc_config_rr["terms_of_service"], - body=messages2.Registration.from_json(acc_config_rr["body"])) + body=messages.Registration.from_json(acc_config_rr["body"])) else: regr = None @@ -186,12 +186,12 @@ class Account(object): """ while True: code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address (optional, press Enter to skip)") + "Enter email address") if code == display_util.OK: try: return cls.from_email(config, email) - except errors.LetsEncryptClientError: + except errors.Error: continue else: return None @@ -205,8 +205,7 @@ class Account(object): :param str email: Email address - :raises letsencrypt.errors.LetsEncryptClientError: If invalid - email address is given. + :raises .errors.Error: If invalid email address is given. """ if not email or cls.safe_email(email): @@ -219,7 +218,7 @@ class Account(object): cls._get_config_filename(email)) return cls(config, key, email) - raise errors.LetsEncryptClientError("Invalid email address.") + raise errors.Error("Invalid email address.") @classmethod def safe_email(cls, email): @@ -227,5 +226,5 @@ class Account(object): if cls.EMAIL_REGEX.match(email): return not email.startswith(".") and ".." not in email else: - logging.warn("Invalid email address.") + logging.warn("Invalid email address: %s.", email) return False diff --git a/letsencrypt/achallenges.py b/letsencrypt/achallenges.py index 46ef167e0..88dcdbe11 100644 --- a/letsencrypt/achallenges.py +++ b/letsencrypt/achallenges.py @@ -5,11 +5,11 @@ Please use names such as ``achall`` to distiguish from variables "of type" and :class:`.ChallengeBody` (denoted by ``challb``):: from acme import challenges - from acme import messages2 + from acme import messages from letsencrypt import achallenges chall = challenges.DNS(token='foo') - challb = messages2.ChallengeBody(chall=chall) + challb = messages.ChallengeBody(chall=chall) achall = achallenges.DNS(chall=challb, domain='example.com') Note, that all annotated challenges act as a proxy objects:: diff --git a/letsencrypt/augeas_configurator.py b/letsencrypt/augeas_configurator.py index c59d755c2..a375b2e17 100644 --- a/letsencrypt/augeas_configurator.py +++ b/letsencrypt/augeas_configurator.py @@ -52,10 +52,10 @@ class AugeasConfigurator(common.Plugin): lens_path = self.aug.get(path + "/lens") # As aug.get may return null if lens_path and lens in lens_path: - # Strip off /augeas/files and /error - logging.error("There has been an error in parsing the file: %s", - path[13:len(path) - 6]) - logging.error(self.aug.get(path + "/message")) + logging.error( + "There has been an error in parsing the file (%s): %s", + # Strip off /augeas/files and /error + path[13:len(path) - 6], self.aug.get(path + "/message")) def save(self, title=None, temporary=False): """Saves all changes to the configuration files. @@ -122,13 +122,10 @@ class AugeasConfigurator(common.Plugin): # Check for the root of save problems new_errs = self.aug.match("/augeas//error") # logging.error("During Save - %s", mod_conf) - # Only print new errors caused by recent save - for err in new_errs: - if err not in ex_errs: - logging.error( - "Unable to save file - %s", err[13:len(err) - 6]) - logging.error("Attempted Save Notes") - logging.error(self.save_notes) + logging.error("Unable to save files: %s. Attempted Save Notes: %s", + ", ".join(err[13:len(err) - 6] for err in new_errs + # Only new errors caused by recent save + if err not in ex_errs), self.save_notes) # Wrapper functions for Reverter class def recovery_routine(self): diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 5f9d29e6e..43f7b9fd2 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -3,12 +3,15 @@ import itertools import logging import time +import zope.component + from acme import challenges -from acme import messages2 +from acme import messages from letsencrypt import achallenges from letsencrypt import constants from letsencrypt import errors +from letsencrypt import interfaces class AuthHandler(object): @@ -24,13 +27,13 @@ class AuthHandler(object): :ivar network: Network object for sending and receiving authorization messages - :type network: :class:`letsencrypt.network2.Network` + :type network: :class:`letsencrypt.network.Network` :ivar account: Client's Account :type account: :class:`letsencrypt.account.Account` :ivar dict authzr: ACME Authorization Resource dict where keys are domains - and values are :class:`acme.messages2.AuthorizationResource` + and values are :class:`acme.messages.AuthorizationResource` :ivar list dv_c: DV challenges in the form of :class:`letsencrypt.achallenges.AnnotatedChallenge` :ivar list cont_c: Continuity challenges in the @@ -60,7 +63,7 @@ class AuthHandler(object): form of (`completed`, `failed`) :rtype: tuple - :raises AuthorizationError: If unable to retrieve all + :raises .AuthorizationError: If unable to retrieve all authorizations """ @@ -82,7 +85,7 @@ class AuthHandler(object): self.verify_authzr_complete() # Only return valid authorizations return [authzr for authzr in self.authzr.values() - if authzr.body.status == messages2.STATUS_VALID] + if authzr.body.status == messages.STATUS_VALID] def _choose_challenges(self, domains): """Retrieve necessary challenges to satisfy server.""" @@ -134,9 +137,11 @@ class AuthHandler(object): self._send_responses(self.cont_c, cont_resp, chall_update)) # Check for updated status... - self._poll_challenges(chall_update, best_effort) - # This removes challenges from self.dv_c and self.cont_c - self._cleanup_challenges(active_achalls) + try: + self._poll_challenges(chall_update, best_effort) + finally: + # This removes challenges from self.dv_c and self.cont_c + self._cleanup_challenges(active_achalls) def _send_responses(self, achalls, resps, chall_update): """Send responses and make sure errors are handled. @@ -150,6 +155,9 @@ class AuthHandler(object): # Don't send challenges for None and False authenticator responses if resp: self.network.answer_challenge(achall.challb, resp) + # TODO: answer_challenge returns challr, with URI, + # that can be used in _find_updated_challr + # comparisons... active_achalls.append(achall) if achall.domain in chall_update: chall_update[achall.domain].append(achall) @@ -168,23 +176,28 @@ class AuthHandler(object): while dom_to_check and rounds < max_rounds: # TODO: Use retry-after... time.sleep(min_sleep) + all_failed_achalls = set() for domain in dom_to_check: - comp_challs, failed_challs = self._handle_check( + comp_achalls, failed_achalls = self._handle_check( domain, chall_update[domain]) - if len(comp_challs) == len(chall_update[domain]): + if len(comp_achalls) == len(chall_update[domain]): comp_domains.add(domain) - elif not failed_challs: - for chall in comp_challs: - chall_update[domain].remove(chall) + elif not failed_achalls: + for achall, _ in comp_achalls: + chall_update[domain].remove(achall) # We failed some challenges... damage control else: # Right now... just assume a loss and carry on... if best_effort: comp_domains.add(domain) else: - raise errors.AuthorizationError( - "Failed Authorization procedure for %s" % domain) + all_failed_achalls.update( + updated for _, updated in failed_achalls) + + if all_failed_achalls: + _report_failed_challs(all_failed_achalls) + raise errors.FailedChallenges(all_failed_achalls) dom_to_check -= comp_domains comp_domains.clear() @@ -196,38 +209,37 @@ class AuthHandler(object): failed = [] self.authzr[domain], _ = self.network.poll(self.authzr[domain]) - if self.authzr[domain].body.status == messages2.STATUS_VALID: + if self.authzr[domain].body.status == messages.STATUS_VALID: return achalls, [] # Note: if the whole authorization is invalid, the individual failed # challenges will be determined here... for achall in achalls: - status = self._get_chall_status(self.authzr[domain], achall) + updated_achall = achall.update(challb=self._find_updated_challb( + self.authzr[domain], achall)) # This does nothing for challenges that have yet to be decided yet. - if status == messages2.STATUS_VALID: - completed.append(achall) - elif status == messages2.STATUS_INVALID: - failed.append(achall) + if updated_achall.status == messages.STATUS_VALID: + completed.append((achall, updated_achall)) + elif updated_achall.status == messages.STATUS_INVALID: + failed.append((achall, updated_achall)) return completed, failed - def _get_chall_status(self, authzr, achall): # pylint: disable=no-self-use - """Get the status of the challenge. + def _find_updated_challb(self, authzr, achall): # pylint: disable=no-self-use + """Find updated challenge body within Authorization Resource. .. warning:: This assumes only one instance of type of challenge in each challenge resource. - :param authzr: Authorization Resource - :type authzr: :class:`acme.messages2.AuthorizationResource` - - :param achall: Annotated challenge for which to get status - :type achall: :class:`letsencrypt.achallenges.AnnotatedChallenge` + :param .AuthorizationResource authzr: Authorization Resource + :param .AnnotatedChallenge achall: Annotated challenge for which + to get status """ for authzr_challb in authzr.body.challenges: if type(authzr_challb.chall) is type(achall.challb.chall): - return authzr_challb.status + return authzr_challb raise errors.AuthorizationError( "Target challenge not found in authorization resource") @@ -277,8 +289,8 @@ class AuthHandler(object): """ for authzr in self.authzr.values(): - if (authzr.body.status != messages2.STATUS_VALID and - authzr.body.status != messages2.STATUS_INVALID): + if (authzr.body.status != messages.STATUS_VALID and + authzr.body.status != messages.STATUS_INVALID): raise errors.AuthorizationError("Incomplete authorizations") def _challenge_factory(self, domain, path): @@ -294,8 +306,7 @@ class AuthHandler(object): :class:`letsencrypt.achallenges.Indexed` :rtype: tuple - :raises errors.LetsEncryptClientError: If Challenge type is not - recognized + :raises .errors.Error: if challenge type is not recognized """ dv_chall = [] @@ -319,7 +330,7 @@ def challb_to_achall(challb, key, domain): """Converts a ChallengeBody object to an AnnotatedChallenge. :param challb: ChallengeBody - :type challb: :class:`acme.messages2.ChallengeBody` + :type challb: :class:`acme.messages.ChallengeBody` :param key: Key :type key: :class:`letsencrypt.le_util.Key` @@ -331,35 +342,28 @@ def challb_to_achall(challb, key, domain): """ chall = challb.chall + logging.info("%s challenge for %s", chall.typ, domain) if isinstance(chall, challenges.DVSNI): - logging.info(" DVSNI challenge for %s.", domain) return achallenges.DVSNI( challb=challb, domain=domain, key=key) elif isinstance(chall, challenges.SimpleHTTP): - logging.info(" SimpleHTTP challenge for %s.", domain) return achallenges.SimpleHTTP( challb=challb, domain=domain, key=key) elif isinstance(chall, challenges.DNS): - logging.info(" DNS challenge for %s.", domain) return achallenges.DNS(challb=challb, domain=domain) - elif isinstance(chall, challenges.RecoveryToken): - logging.info(" Recovery Token Challenge for %s.", domain) return achallenges.RecoveryToken(challb=challb, domain=domain) elif isinstance(chall, challenges.RecoveryContact): - logging.info(" Recovery Contact Challenge for %s.", domain) return achallenges.RecoveryContact( challb=challb, domain=domain) elif isinstance(chall, challenges.ProofOfPossession): - logging.info(" Proof-of-Possession Challenge for %s", domain) return achallenges.ProofOfPossession( challb=challb, domain=domain) else: - raise errors.LetsEncryptClientError( - "Received unsupported challenge of type: %s", - chall.typ) + raise errors.Error( + "Received unsupported challenge of type: %s", chall.typ) def gen_challenge_path(challbs, preferences, combinations): @@ -368,8 +372,8 @@ def gen_challenge_path(challbs, preferences, combinations): .. todo:: This can be possibly be rewritten to use resolved_combinations. :param tuple challbs: A tuple of challenges - (:class:`acme.messages2.Challenge`) from - :class:`acme.messages2.AuthorizationResource` to be + (:class:`acme.messages.Challenge`) from + :class:`acme.messages.AuthorizationResource` to be fulfilled by the client in order to prove possession of the identifier. @@ -480,3 +484,80 @@ def is_preferred(offered_challb, satisfied, different=True): return False return True + + +_ERROR_HELP_COMMON = ( + "To fix these errors, please make sure that your domain name was entered " + "correctly and the DNS A/AAAA record(s) for that domain contains the " + "right IP address.") + + +_ERROR_HELP = { + "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" : + _ERROR_HELP_COMMON + " Additionally, if you have DNSSEC enabled for " + "your domain, please ensure the signature is valid.", + "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" : + "Unfortunately, an error on the ACME server prevented you from completing " + "authorization. Please try again later.", + "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,} + + +def _report_failed_challs(failed_achalls): + """Notifies the user about failed challenges. + + :param set failed_achalls: A set of failed + :class:`letsencrypt.achallenges.AnnotatedChallenge`. + + """ + problems = dict() + for achall in failed_achalls: + if achall.error: + problems.setdefault(achall.error.typ, []).append(achall) + + reporter = zope.component.getUtility(interfaces.IReporter) + for achalls in problems.itervalues(): + reporter.add_message( + _generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY, True) + + +def _generate_failed_chall_msg(failed_achalls): + """Creates a user friendly error message about failed challenges. + + :param list failed_achalls: A list of failed + :class:`letsencrypt.achallenges.AnnotatedChallenge` with the same error + type. + + :returns: A formatted error message for the client. + :rtype: str + + """ + typ = failed_achalls[0].error.typ + msg = [ + "The following '{0}' errors were reported by the server:".format(typ)] + + problems = dict() + for achall in failed_achalls: + problems.setdefault(achall.error.description, set()).add(achall.domain) + for problem in problems: + msg.append("\n\nDomains: ") + msg.append(", ".join(sorted(problems[problem]))) + msg.append("\nError: {0}".format(problem)) + + if typ in _ERROR_HELP: + msg.append("\n\n") + msg.append(_ERROR_HELP[typ]) + + return "".join(msg) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index ba5a0de9e..8fab21f44 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -25,9 +25,49 @@ from letsencrypt import reporter from letsencrypt.display import util as display_util from letsencrypt.display import ops as display_ops - from letsencrypt.plugins import disco as plugins_disco +# Argparse's help formatting has a lot of unhelpful peculiarities, so we want +# to replace as much of it as we can... + +# This is the stub to include in help generated by argparse + +SHORT_USAGE = """ + letsencrypt [SUBCOMMAND] [options] [domains] + +The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates. By +default, it will attempt to use a webserver both for obtaining and installing +the cert. """ + +# This is the short help for letsencrypt --help, where we disable argparse +# altogether +USAGE = SHORT_USAGE + """Major SUBCOMMANDS are: + + (default) everything Obtain & install a cert in your current webserver + auth Authenticate & obtain cert, but do not install it + install Install a previously obtained cert in a server + revoke Revoke a previously obtained certificate + rollback Rollback server configuration changes made during install + config-changes Show changes made to server config during installation + +Choice of server for authentication/installation: + + --apache Use the Apache plugin for authentication & installation + --nginx Use the Nginx plugin for authentication & installation + --standalone Run a standalone HTTPS server (for authentication only) + OR: + --authenticator standalone --installer nginx + +More detailed help: + + -h, --help [topic] print this message, or detailed help on a topic; + the available topics are: + + all, apache, automation, nginx, paths, security, testing, or any of the + sucommands +""" + + def _account_init(args, config): le_util.make_or_verify_dir( @@ -115,13 +155,14 @@ def run(args, config, plugins): def auth(args, config, plugins): - """Obtain a certificate (no install).""" + """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 # supplied, check if CSR matches given domains? return "--domains and --csr are mutually exclusive" - # XXX: Update for renewer / RenewableCert acc = _account_init(args, config) if acc is None: return None @@ -150,7 +191,7 @@ def auth(args, config, plugins): return "Certificate could not be obtained" def install(args, config, plugins): - """Install (no auth).""" + """Install a previously obtained cert in a server.""" # XXX: Update for renewer/RenewableCert acc = _account_init(args, config) if acc is None: @@ -167,7 +208,7 @@ def install(args, config, plugins): def revoke(args, unused_config, unused_plugins): - """Revoke.""" + """Revoke a previously obtained certificate.""" if args.rev_cert is None and args.rev_key is None: return "At least one of --certificate or --key is required" @@ -179,12 +220,12 @@ def revoke(args, unused_config, unused_plugins): def rollback(args, config, plugins): - """Rollback.""" + """Rollback server configuration changes made during install.""" client.rollback(args.installer, args.checkpoints, config, plugins) def config_changes(unused_args, config, unused_plugins): - """View config changes. + """Show changes made to server config during installation View checkpoints and associated configuration changes. @@ -193,7 +234,7 @@ def config_changes(unused_args, config, unused_plugins): def plugins_cmd(args, config, plugins): # TODO: Use IDiplay rathern than print - """List plugins.""" + """List server software plugins.""" logging.debug("Expected interfaces: %s", args.ifaces) ifaces = [] if args.ifaces is None else args.ifaces @@ -240,51 +281,201 @@ def flag_default(name): """Default value for CLI flag.""" return constants.CLI_DEFAULTS[name] -def config_help(name): + +def config_help(name, hidden=False): """Help message for `.IConfig` attribute.""" - return interfaces.IConfig[name].__doc__ + if hidden: + return argparse.SUPPRESS + else: + return interfaces.IConfig[name].__doc__ -def create_parser(plugins): +class SilentParser(object): # pylint: disable=too-few-public-methods + """Silent wrapper around argparse. + + A mini parser wrapper that doesn't print help for its + arguments. This is needed for the use of callbacks to define + arguments within plugins. + + """ + 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"] + + +class HelpfulArgumentParser(object): + """Argparse Wrapper. + + This class wraps argparse, adding the ability to make --help less + verbose, and request help on specific subcategories at a time, eg + 'letsencrypt --help security' for security options. + + """ + def __init__(self, args, plugins): + self.args = args + plugin_names = [name for name, _p in plugins.iteritems()] + self.help_topics = HELP_TOPICS + plugin_names + self.parser = configargparse.ArgParser( + usage=SHORT_USAGE, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + args_for_setting_config_path=["-c", "--config"], + 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.silent_parser = SilentParser(self.parser) + + 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: + # just --help with no topic; avoid argparse altogether + print USAGE + sys.exit(0) + self.visible_topics = self.determine_help_topics(help_arg) + #print self.visible_topics + self.groups = {} # elements are added by .add_group() + self.add_plugin_args(plugins) + + def prescan_for_flag(self, flag, possible_arguments): + """Checks cli input for flags. + + Check for a flag, which accepts a fixed set of possible arguments, in + the command line; we will use this information to configure argparse's + help correctly. Return the flag's argument, if it has one that matches + the sequence @possible_arguments; otherwise return whether the flag is + present. + + """ + if flag not in self.args: + return False + pos = self.args.index(flag) + try: + nxt = self.args[pos + 1] + if nxt in possible_arguments: + return nxt + except IndexError: + pass + return True + + def add(self, topic, *args, **kwargs): + """Add a new command line argument. + + @topic is required, to indicate which part of the help will document + it, but can be None for `always documented'. + + """ + if topic and self.visible_topics[topic]: + group = self.groups[topic] + group.add_argument(*args, **kwargs) + else: + kwargs["help"] = argparse.SUPPRESS + self.parser.add_argument(*args, **kwargs) + + def add_group(self, topic, **kwargs): + """ + + This has to be called once for every topic; but we leave those calls + next to the argument definitions for clarity. Return something + arguments can be added to if necessary, either the parser or an argument + group. + + """ + if self.visible_topics[topic]: + #print "Adding visible group " + topic + group = self.parser.add_argument_group(topic, **kwargs) + self.groups[topic] = group + return group + else: + #print "Invisible group " + topic + return self.silent_parser + + def add_plugin_args(self, plugins): + """ + + Let each of the plugins add its own command line arguments, which + may or may not be displayed as help topics. + + """ + # TODO: plugin_parser should be called for every detected plugin + for name, plugin_ep in plugins.iteritems(): + parser_or_group = self.add_group(name, description=plugin_ep.description) + #print parser_or_group + plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name) + + def determine_help_topics(self, chosen_topic): + """ + + The user may have requested help on a topic, return a dict of which + topics to display. @chosen_topic has prescan_for_flag's return type + + :returns: dict + + """ + # topics maps each topic to whether it should be documented by + # argparse on the command line + if chosen_topic == "all": + return dict([(t, True) for t in self.help_topics]) + elif not chosen_topic: + return dict([(t, False) for t in self.help_topics]) + else: + return dict([(t, t == chosen_topic) for t in self.help_topics]) + + +def create_parser(plugins, args): """Create parser.""" - parser = configargparse.ArgParser( - description=__doc__, - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - args_for_setting_config_path=["-c", "--config"], - default_config_files=flag_default("config_files")) - add = parser.add_argument + helpful = HelpfulArgumentParser(args, plugins) - # --help is automatically provided by argparse - add("--version", action="version", version="%(prog)s {0}".format( - letsencrypt.__version__)) - add("-v", "--verbose", dest="verbose_count", action="count", + helpful.add( + None, "-v", "--verbose", dest="verbose_count", action="count", default=flag_default("verbose_count"), help="This flag can be used " "multiple times to incrementally increase the verbosity of output, " "e.g. -vvv.") - add("--no-confirm", dest="no_confirm", action="store_true", + # --help is automatically provided by argparse + + helpful.add_group( + "automation", + description="Arguments for automating execution & other tweaks") + helpful.add( + "automation", "--version", action="version", + 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") - add("-e", "--agree-tos", dest="tos", action="store_true", - help="Skip the end user license agreement screen.") - add("-t", "--text", dest="text_mode", action="store_true", + helpful.add( + "automation", "--agree-eula", "-e", dest="tos", action="store_true", + help="Agree to the Let's Encrypt Subscriber Agreement") + helpful.add( + None, "-t", "--text", dest="text_mode", action="store_true", help="Use the text output instead of the curses UI.") - add("--no-simple-http-tls", action="store_true", - help=config_help("no_simple_http_tls")) - testing_group = parser.add_argument_group( + helpful.add_group( "testing", description="The following flags are meant for " "testing purposes only! Do NOT change them, unless you " "really know what you're doing!") - testing_group.add_argument( - "--no-verify-ssl", action="store_true", + helpful.add( + "testing", "--no-verify-ssl", action="store_true", help=config_help("no_verify_ssl"), default=flag_default("no_verify_ssl")) # TODO: apache and nginx plugins do NOT respect it - testing_group.add_argument( - "--dvsni-port", type=int, help=config_help("dvsni_port"), - default=flag_default("dvsni_port")) + helpful.add( + "testing", "--dvsni-port", type=int, default=flag_default("dvsni_port"), + help=config_help("dvsni_port")) + + helpful.add("testing", "--no-simple-http-tls", action="store_true", + help=config_help("no_simple_http_tls")) + + subparsers = helpful.parser.add_subparsers(metavar="SUBCOMMAND") - subparsers = 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__) @@ -301,6 +492,12 @@ def create_parser(plugins): 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("cert_path"), + help="When using --csr this is where certificate is saved.") + parser_auth.add_argument( + "--chain-path", default=flag_default("chain_path"), + help="When using --csr this is where certificate chain is saved.") parser_plugins = add_subparser("plugins", plugins_cmd) parser_plugins.add_argument("--init", action="store_true") @@ -312,25 +509,28 @@ def create_parser(plugins): "--installers", action="append_const", dest="ifaces", const=interfaces.IInstaller) - parser.add_argument("--configurator") - parser.add_argument("-a", "--authenticator") - parser.add_argument("-i", "--installer") + helpful.add(None, "--configurator") + helpful.add(None, "-a", "--authenticator") + helpful.add(None, "-i", "--installer") # positional arg shadows --domains, instead of appending, and # --domains is useful, because it can be stored in config #for subparser in parser_run, parser_auth, parser_install: # subparser.add_argument("domains", nargs="*", metavar="domain") - add("-d", "--domains", metavar="DOMAIN", action="append") - add("-s", "--server", default=flag_default("server"), - help=config_help("server")) - add("-k", "--authkey", type=read_file, - help="Path to the authorized key file") - add("-m", "--email", help=config_help("email")) - add("-B", "--rsa-key-size", type=int, metavar="N", + helpful.add(None, "-d", "--domains", metavar="DOMAIN", action="append") + helpful.add(None, "-k", "--accountkey", type=read_file, + help="Path to the account key file") + helpful.add(None, "-m", "--email", help=config_help("email")) + + helpful.add_group( + "security", description="Security parameters & server settings") + helpful.add( + "security", "-B", "--rsa-key-size", type=int, metavar="N", default=flag_default("rsa_key_size"), help=config_help("rsa_key_size")) # TODO: resolve - assumes binary logic while client.py assumes ternary. - add("-r", "--redirect", action="store_true", + helpful.add( + "security", "-r", "--redirect", action="store_true", help="Automatically redirect all HTTP traffic to HTTPS for the newly " "authenticated vhost.") @@ -346,48 +546,37 @@ def create_parser(plugins): default=flag_default("rollback_checkpoints"), help="Revert configuration N number of checkpoints.") - _paths_parser(parser.add_argument_group("paths")) + _paths_parser(helpful) - # TODO: plugin_parser should be called for every detected plugin - for name, plugin_ep in plugins.iteritems(): - plugin_ep.plugin_cls.inject_parser_options( - parser.add_argument_group( - name, description=plugin_ep.description), name) - - return parser + return helpful.parser -def _paths_parser(parser): - add = parser.add_argument - add("--config-dir", default=flag_default("config_dir"), +def _paths_parser(helpful): + add = helpful.add + helpful.add_group("paths", description="Arguments changing execution paths & servers") + add("paths", "--config-dir", default=flag_default("config_dir"), help=config_help("config_dir")) - add("--work-dir", default=flag_default("work_dir"), + add("paths", "--work-dir", default=flag_default("work_dir"), help=config_help("work_dir")) - add("--backup-dir", default=flag_default("backup_dir"), + add("paths", "--backup-dir", default=flag_default("backup_dir"), help=config_help("backup_dir")) - add("--key-dir", default=flag_default("key_dir"), + add("paths", "--key-dir", default=flag_default("key_dir"), help=config_help("key_dir")) - add("--cert-dir", default=flag_default("certs_dir"), + add("paths", "--cert-dir", default=flag_default("certs_dir"), help=config_help("cert_dir")) - - add("--le-vhost-ext", default="-le-ssl.conf", + add("paths", "--le-vhost-ext", default="-le-ssl.conf", help=config_help("le_vhost_ext")) - add("--cert-path", default=flag_default("cert_path"), - help=config_help("cert_path")) - add("--chain-path", default=flag_default("chain_path"), - help=config_help("chain_path")) - - add("--renewer-config-file", default=flag_default("renewer_config_file"), + add("paths", "--renewer-config-file", default=flag_default("renewer_config_file"), help=config_help("renewer_config_file")) - - return parser + add("paths", "-s", "--server", default=flag_default("server"), + help=config_help("server")) def main(args=sys.argv[1:]): """Command line argument parsing and main script execution.""" # note: arg parser internally handles --help (and exits afterwards) plugins = plugins_disco.PluginsRegistry.find_all() - args = create_parser(plugins).parse_args(args) + args = create_parser(plugins, args).parse_args(args) config = configuration.NamespaceConfig(args) # Displayer diff --git a/letsencrypt/client.py b/letsencrypt/client.py index da225690d..5c54835f8 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -12,12 +12,14 @@ from acme.jose import jwk from letsencrypt import account from letsencrypt import auth_handler +from letsencrypt import configuration +from letsencrypt import constants from letsencrypt import continuity_auth from letsencrypt import crypto_util from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import le_util -from letsencrypt import network2 +from letsencrypt import network from letsencrypt import reverter from letsencrypt import revoker from letsencrypt import storage @@ -30,7 +32,7 @@ class Client(object): """ACME protocol client. :ivar network: Network object for sending and receiving messages - :type network: :class:`letsencrypt.network2.Network` + :type network: :class:`letsencrypt.network.Network` :ivar account: Account object used for registration :type account: :class:`letsencrypt.account.Account` @@ -63,7 +65,7 @@ class Client(object): self.installer = installer # TODO: Allow for other alg types besides RS256 - self.network = network2.Network( + self.network = network.Network( config.server, jwk.JWKRSA.load(self.account.key.pem), verify_ssl=(not config.no_verify_ssl)) @@ -97,7 +99,7 @@ class Client(object): self.account.regr = self.network.agree_to_tos(self.account.regr) else: # What is the proper response here... - raise errors.LetsEncryptClientError("Must agree to TOS") + raise errors.Error("Must agree to TOS") self.account.save() self._report_new_account() @@ -144,10 +146,9 @@ class Client(object): msg = ("Unable to obtain certificate because authenticator is " "not set.") logging.warning(msg) - raise errors.LetsEncryptClientError(msg) + raise errors.Error(msg) if self.account.regr is None: - raise errors.LetsEncryptClientError( - "Please register with the ACME server first.") + raise errors.Error("Please register with the ACME server first.") logging.debug("CSR: %s, domains: %s", csr, domains) @@ -232,23 +233,29 @@ class Client(object): # ideally should be a ConfigObj, but in this case a dict will be # accepted in practice.) params = vars(self.config.namespace) - config = {"renewer_config_file": - params["renewer_config_file"]} if "renewer_config_file" in params else None + config = {} + cli_config = configuration.RenewerConfiguration(self.config.namespace) + + if (cli_config.config_dir != constants.CLI_DEFAULTS["config_dir"] or + cli_config.work_dir != constants.CLI_DEFAULTS["work_dir"]): + logging.warning( + "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 chain.as_pem() - renewable_cert = storage.RenewableCert.new_lineage( - domains[0], certr.body.as_pem(), key.pem, chain_pem, params, config) - self._report_renewal_status(renewable_cert) - return renewable_cert + lineage = storage.RenewableCert.new_lineage( + domains[0], certr.body.as_pem(), key.pem, chain_pem, params, + config, cli_config) + self._report_renewal_status(lineage) + return lineage def _report_renewal_status(self, cert): # pylint: disable=no-self-use """Informs the user about automatic renewal and deployment. - :param cert: Newly issued certificate - :type cert: :class:`letsencrypt.storage.RenewableCert` + :param .RenewableCert cert: Newly issued certificate """ if ("autorenew" not in cert.configuration @@ -267,7 +274,7 @@ class Client(object): msg += ("been enabled for your certificate. These settings can be " "configured in the directories under {0}.").format( - cert.configuration["renewal_configs_dir"]) + cert.cli_config.renewal_configs_dir) reporter = zope.component.getUtility(interfaces.IReporter) reporter.add_message(msg, reporter.LOW_PRIORITY, True) @@ -279,9 +286,8 @@ class Client(object): :type certr: :class:`acme.messages.Certificate` :param chain_cert: - - :param str cert_path: Path to attempt to save the cert file - :param str chain_path: Path to attempt to save the chain file + :param str cert_path: Candidate path to a certificate. + :param str chain_path: Candidate path to a certificate chain. :returns: cert_path, chain_path (absolute paths to the actual files) :rtype: `tuple` of `str` @@ -334,7 +340,7 @@ class Client(object): if self.installer is None: logging.warning("No installer specified, client is unable to deploy" "the certificate") - raise errors.LetsEncryptClientError("No installer available") + raise errors.Error("No installer available") chain_path = None if chain_path is None else os.path.abspath(chain_path) @@ -363,14 +369,14 @@ class Client(object): :param redirect: If traffic should be forwarded from HTTP to HTTPS. :type redirect: bool or None - :raises letsencrypt.errors.LetsEncryptClientError: if - no installer is specified in the client. + :raises .errors.Error: if no installer is specified in the + client. """ if self.installer is None: logging.warning("No installer is specified, there isn't any " "configuration to enhance.") - raise errors.LetsEncryptClientError("No installer available") + raise errors.Error("No installer available") if redirect is None: redirect = enhancements.ask("redirect") @@ -388,7 +394,7 @@ class Client(object): for dom in domains: try: self.installer.enhance(dom, "redirect") - except errors.LetsEncryptConfiguratorError: + except errors.ConfiguratorError: logging.warn("Unable to perform redirect for %s", dom) self.installer.save("Add Redirects") @@ -409,8 +415,7 @@ def validate_key_csr(privkey, csr=None): :param .le_util.CSR csr: CSR - :raises letsencrypt.errors.LetsEncryptClientError: when - validation fails + :raises .errors.Error: when validation fails """ # TODO: Handle all of these problems appropriately @@ -419,8 +424,7 @@ def validate_key_csr(privkey, csr=None): # Key must be readable and valid. if privkey.pem and not crypto_util.valid_privkey(privkey.pem): - raise errors.LetsEncryptClientError( - "The provided key is not a valid key") + raise errors.Error("The provided key is not a valid key") if csr: if csr.form == "der": @@ -429,16 +433,14 @@ def validate_key_csr(privkey, csr=None): # If CSR is provided, it must be readable and valid. if csr.data and not crypto_util.valid_csr(csr.data): - raise errors.LetsEncryptClientError( - "The provided CSR is not a valid CSR") + raise errors.Error("The provided CSR is not a valid CSR") # If both CSR and key are provided, the key must be the same key used # in the CSR. if csr.data and privkey.pem: if not crypto_util.csr_matches_pubkey( csr.data, privkey.pem): - raise errors.LetsEncryptClientError( - "The key and CSR do not match") + raise errors.Error("The key and CSR do not match") def determine_account(config): diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 6a808a6a9..2a9e87ade 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -17,10 +17,15 @@ class NamespaceConfig(object): :attr:`~letsencrypt.interfaces.IConfig.work_dir` and relative paths defined in :py:mod:`letsencrypt.constants`: - - ``temp_checkpoint_dir`` - - ``in_progress_dir`` - - ``cert_key_backup`` - - ``rec_token_dir`` + - `accounts_dir` + - `account_keys_dir` + - `cert_dir` + - `cert_key_backup` + - `in_progress_dir` + - `key_dir` + - `rec_token_dir` + - `renewer_config_file` + - `temp_checkpoint_dir` :ivar namespace: Namespace typically produced by :meth:`argparse.ArgumentParser.parse_args`. @@ -35,27 +40,12 @@ class NamespaceConfig(object): def __getattr__(self, name): return getattr(self.namespace, name) - @property - def temp_checkpoint_dir(self): # pylint: disable=missing-docstring - return os.path.join( - self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR) - - @property - def in_progress_dir(self): # pylint: disable=missing-docstring - return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) - @property def server_path(self): """File path based on ``server``.""" parsed = urlparse.urlparse(self.namespace.server) return (parsed.netloc + parsed.path).replace('/', os.path.sep) - @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) - @property def accounts_dir(self): #pylint: disable=missing-docstring return os.path.join( @@ -63,11 +53,63 @@ class NamespaceConfig(object): @property def account_keys_dir(self): #pylint: disable=missing-docstring - return os.path.join( - self.namespace.config_dir, constants.ACCOUNTS_DIR, - self.server_path, constants.ACCOUNT_KEYS_DIR) + return os.path.join(self.accounts_dir, constants.ACCOUNT_KEYS_DIR) + + @property + def backup_dir(self): # pylint: disable=missing-docstring + 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) + + @property + def in_progress_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) + + @property + def key_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.KEY_DIR) # TODO: This should probably include the server name @property def rec_token_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.REC_TOKEN_DIR) + + @property + def temp_checkpoint_dir(self): # pylint: disable=missing-docstring + return os.path.join( + self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR) + + +class RenewerConfiguration(object): + """Configuration wrapper for renewer.""" + + def __init__(self, namespace): + self.namespace = namespace + + def __getattr__(self, name): + return getattr(self.namespace, name) + + @property + def archive_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.ARCHIVE_DIR) + + @property + def live_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.LIVE_DIR) + + @property + def renewal_configs_dir(self): # pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, constants.RENEWAL_CONFIGS_DIR) + + @property + def renewer_config_file(self): # pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, constants.RENEWER_CONFIG_FILENAME) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 47539615d..81ec4a90b 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -7,7 +7,6 @@ from acme import challenges SETUPTOOLS_PLUGINS_ENTRY_POINT = "letsencrypt.plugins" """Setuptools entry point group name for plugins.""" - CLI_DEFAULTS = dict( config_files=["/etc/letsencrypt/cli.ini"], verbose_count=-(logging.WARNING / 10), @@ -16,23 +15,21 @@ CLI_DEFAULTS = dict( rollback_checkpoints=0, config_dir="/etc/letsencrypt", work_dir="/var/lib/letsencrypt", - backup_dir="/var/lib/letsencrypt/backups", - key_dir="/etc/letsencrypt/keys", - certs_dir="/etc/letsencrypt/certs", - cert_path="/etc/letsencrypt/certs/cert-letsencrypt.pem", - chain_path="/etc/letsencrypt/certs/chain-letsencrypt.pem", - renewer_config_file="/etc/letsencrypt/renewer.conf", no_verify_ssl=False, dvsni_port=challenges.DVSNI.PORT, + cert_path="./cert.pem", + chain_path="./chain.pem", + + # TODO: blocked by #485, values ignored + backup_dir="not used", + key_dir="not used", + certs_dir="not used", + renewer_config_file="not used", ) """Defaults for CLI flags and `.IConfig` attributes.""" RENEWER_DEFAULTS = dict( - renewer_config_file="/etc/letsencrypt/renewer.conf", - renewal_configs_dir="/etc/letsencrypt/configs", - archive_dir="/etc/letsencrypt/archive", - live_dir="/etc/letsencrypt/live", renewer_enabled="yes", renew_before_expiry="30 days", deploy_before_expiry="20 days", @@ -57,31 +54,47 @@ List of expected options parameters: """ +ARCHIVE_DIR = "archive" +"""Archive directory, relative to `IConfig.config_dir`.""" CONFIG_DIRS_MODE = 0o755 """Directory mode for ``.IConfig.config_dir`` et al.""" -TEMP_CHECKPOINT_DIR = "temp_checkpoint" -"""Temporary checkpoint directory (relative to IConfig.work_dir).""" - -IN_PROGRESS_DIR = "IN_PROGRESS" -"""Directory used before a permanent checkpoint is finalized (relative to -IConfig.work_dir).""" - -CERT_KEY_BACKUP_DIR = "keys-certs" -"""Directory where all certificates and keys are stored (relative to -IConfig.work_dir. Used for easy revocation.""" - ACCOUNTS_DIR = "accounts" """Directory where all accounts are saved.""" ACCOUNT_KEYS_DIR = "keys" -"""Directory where account keys are saved. Relative to ACCOUNTS_DIR.""" +"""Directory where account keys are saved. Relative to `ACCOUNTS_DIR`.""" + +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.""" + +IN_PROGRESS_DIR = "IN_PROGRESS" +"""Directory used before a permanent checkpoint is finalized (relative to +`IConfig.work_dir`).""" + +KEY_DIR = "keys" +"""Directory (relative to `IConfig.config_dir`) where keys are saved.""" + +LIVE_DIR = "live" +"""Live directory, relative to `IConfig.config_dir`.""" + +TEMP_CHECKPOINT_DIR = "temp_checkpoint" +"""Temporary checkpoint directory (relative to `IConfig.work_dir`).""" REC_TOKEN_DIR = "recovery_tokens" """Directory where all recovery tokens are saved (relative to -IConfig.work_dir).""" +`IConfig.work_dir`).""" -NETSTAT = "/bin/netstat" -"""Location of netstat binary for checking whether a listener is already -running on the specified port (Linux-specific).""" +RENEWAL_CONFIGS_DIR = "configs" +"""Renewal configs directory, relative to `IConfig.config_dir`.""" + +RENEWER_CONFIG_FILENAME = "renewer.conf" +"""Renewer config file name (relative to `IConfig.config_dir`).""" diff --git a/letsencrypt/continuity_auth.py b/letsencrypt/continuity_auth.py index 739e33d43..2eb1c22bf 100644 --- a/letsencrypt/continuity_auth.py +++ b/letsencrypt/continuity_auth.py @@ -52,7 +52,7 @@ class ContinuityAuthenticator(object): elif isinstance(achall, achallenges.RecoveryToken): responses.append(self.rec_token.perform(achall)) else: - raise errors.LetsEncryptContAuthError("Unexpected Challenge") + raise errors.ContAuthError("Unexpected Challenge") return responses def cleanup(self, achalls): @@ -61,4 +61,4 @@ class ContinuityAuthenticator(object): if isinstance(achall, achallenges.RecoveryToken): self.rec_token.cleanup(achall) elif not isinstance(achall, achallenges.ProofOfPossession): - raise errors.LetsEncryptContAuthError("Unexpected Challenge") + raise errors.ContAuthError("Unexpected Challenge") diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index b2b5ecf58..e6f0ab8bb 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -40,7 +40,7 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): try: key_pem = make_key(key_size) except ValueError as err: - logging.fatal(str(err)) + logging.exception(err) raise err # Save file @@ -55,7 +55,7 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): return le_util.Key(key_path, key_pem) -def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"): +def init_save_csr(privkey, names, path, csrname="csr-letsencrypt.pem"): """Initialize a CSR with the given private key. :param privkey: Key to include in the CSR @@ -63,7 +63,7 @@ def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"): :param set names: `str` names to include in the CSR - :param str cert_dir: Certificate save directory. + :param str path: Certificate save directory. :returns: CSR :rtype: :class:`letsencrypt.le_util.CSR` @@ -72,9 +72,9 @@ def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"): csr_pem, csr_der = make_csr(privkey.pem, names) # Save CSR - le_util.make_or_verify_dir(cert_dir, 0o755, os.geteuid()) + le_util.make_or_verify_dir(path, 0o755, os.geteuid()) csr_f, csr_filename = le_util.unique_file( - os.path.join(cert_dir, csrname), 0o644) + os.path.join(path, csrname), 0o644) csr_f.write(csr_pem) csr_f.close() diff --git a/letsencrypt/display/enhancements.py b/letsencrypt/display/enhancements.py index 48f168441..6d7c78d7d 100644 --- a/letsencrypt/display/enhancements.py +++ b/letsencrypt/display/enhancements.py @@ -21,8 +21,7 @@ def ask(enhancement): :returns: True if feature is desired, False otherwise :rtype: bool - :raises letsencrypt.errors.LetsEncryptClientError: If - the enhancement provided is not supported. + :raises .errors.Error: if the enhancement provided is not supported """ try: @@ -30,7 +29,7 @@ def ask(enhancement): return DISPATCH[enhancement]() except KeyError: logging.error("Unsupported enhancement given to ask(): %s", enhancement) - raise errors.LetsEncryptClientError("Unsupported Enhancement") + raise errors.Error("Unsupported Enhancement") def redirect_by_default(): diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py index f5d9f5f44..f753a29c0 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -1,52 +1,63 @@ """Let's Encrypt client errors.""" -class LetsEncryptClientError(Exception): +class Error(Exception): """Generic Let's Encrypt client error.""" +LetsEncryptClientError = Error # TODO: blocked by #485 -class NetworkError(LetsEncryptClientError): - """Network error.""" - - -class UnexpectedUpdate(NetworkError): - """Unexpected update.""" - - -class LetsEncryptReverterError(LetsEncryptClientError): +class ReverterError(Error): """Let's Encrypt Reverter error.""" # Auth Handler Errors -class AuthorizationError(LetsEncryptClientError): +class AuthorizationError(Error): """Authorization error.""" -class LetsEncryptContAuthError(AuthorizationError): +class FailedChallenges(AuthorizationError): + """Failed challenges error. + + :ivar set failed_achalls: Failed `.AnnotatedChallenge` instances. + + """ + def __init__(self, failed_achalls): + assert failed_achalls + self.failed_achalls = failed_achalls + super(FailedChallenges, self).__init__() + + def __str__(self): + return "Failed authorization procedure. {0}".format( + ", ".join( + "{0} ({1}): {2}".format(achall.domain, achall.typ, achall.error) + for achall in self.failed_achalls if achall.error is not None)) + + +class ContAuthError(AuthorizationError): """Let's Encrypt Continuity Authenticator error.""" -class LetsEncryptDvAuthError(AuthorizationError): +class DvAuthError(AuthorizationError): """Let's Encrypt DV Authenticator error.""" # Authenticator - Challenge specific errors -class LetsEncryptDvsniError(LetsEncryptDvAuthError): +class DvsniError(DvAuthError): """Let's Encrypt DVSNI error.""" # Configurator Errors -class LetsEncryptConfiguratorError(LetsEncryptClientError): +class ConfiguratorError(Error): """Let's Encrypt Configurator error.""" -class LetsEncryptNoInstallationError(LetsEncryptConfiguratorError): +class NoInstallationError(ConfiguratorError): """Let's Encrypt No Installation error.""" -class LetsEncryptMisconfigurationError(LetsEncryptConfiguratorError): +class MisconfigurationError(ConfiguratorError): """Let's Encrypt Misconfiguration error.""" -class LetsEncryptRevokerError(LetsEncryptClientError): +class RevokerError(Error): """Let's Encrypt Revoker error.""" diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index c0d44a134..ce12c4a56 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -68,10 +68,10 @@ class IPlugin(zope.interface.Interface): Finish up any additional initialization. - :raises letsencrypt.errors.LetsEncryptMisconfigurationError: - when full initialization cannot be completed. Plugin will be - displayed on a list of available plugins. - :raises letsencrypt.errors.LetsEncryptNoInstallationError: + :raises .MisconfigurationError: + when full initialization cannot be completed. Plugin will + be displayed on a list of available plugins. + :raises .NoInstallationError: when the necessary programs/files cannot be located. Plugin will NOT be displayed on a list of available plugins. @@ -148,40 +148,36 @@ class IConfig(zope.interface.Interface): """ server = zope.interface.Attribute( - "CA hostname (and optionally :port). The server certificate must " - "be trusted in order to avoid further modifications to the client.") + "ACME new registration URI (including /acme/new-reg).") email = zope.interface.Attribute( "Email used for registration and recovery contact.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.") config_dir = zope.interface.Attribute("Configuration directory.") work_dir = zope.interface.Attribute("Working directory.") - backup_dir = zope.interface.Attribute("Configuration backups directory.") - temp_checkpoint_dir = zope.interface.Attribute( - "Temporary checkpoint directory.") - in_progress_dir = zope.interface.Attribute( - "Directory used before a permanent checkpoint is finalized.") - cert_key_backup = zope.interface.Attribute( - "Directory where all certificates and keys are stored. " - "Used for easy revocation.") + accounts_dir = zope.interface.Attribute( "Directory where all account information is stored.") account_keys_dir = zope.interface.Attribute( "Directory where all account keys are stored.") + backup_dir = zope.interface.Attribute("Configuration backups directory.") + cert_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.") + in_progress_dir = zope.interface.Attribute( + "Directory used before a permanent checkpoint is finalized.") + key_dir = zope.interface.Attribute("Keys storage.") rec_token_dir = zope.interface.Attribute( "Directory where all recovery tokens are saved.") - key_dir = zope.interface.Attribute("Keys storage.") - cert_dir = zope.interface.Attribute("Certificates storage.") - - le_vhost_ext = zope.interface.Attribute( - "SSL vhost configuration extension.") + temp_checkpoint_dir = zope.interface.Attribute( + "Temporary checkpoint directory.") renewer_config_file = zope.interface.Attribute( "Location of renewal configuration file.") - cert_path = zope.interface.Attribute("Let's Encrypt certificate file path.") - chain_path = zope.interface.Attribute("Let's Encrypt chain file path.") - no_verify_ssl = zope.interface.Attribute( "Disable SSL certificate verification.") dvsni_port = zope.interface.Attribute( @@ -192,6 +188,9 @@ class IConfig(zope.interface.Interface): no_simple_http_tls = zope.interface.Attribute( "Do not use TLS when solving SimpleHTTP challenges.") + # TODO: the following are not used, but blocked by #485 + le_vhost_ext = zope.interface.Attribute("not used") + class IInstaller(IPlugin): """Generic Let's Encrypt Installer Interface. diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index ba2427c79..0f8207b7a 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -19,7 +19,7 @@ def make_or_verify_dir(directory, mode=0o755, uid=0): :param int mode: Directory mode. :param int uid: Directory owner. - :raises LetsEncryptClientError: if a directory already exists, + :raises .errors.Error: if a directory already exists, but has wrong permissions or owner :raises OSError: if invalid or inaccessible file names and @@ -32,9 +32,8 @@ def make_or_verify_dir(directory, mode=0o755, uid=0): except OSError as exception: if exception.errno == errno.EEXIST: if not check_permissions(directory, mode, uid): - raise errors.LetsEncryptClientError( - "%s exists, but does not have the proper " - "permissions or owner" % directory) + raise errors.Error( + "%s exists, this client can't access it" % directory) else: raise diff --git a/letsencrypt/network.py b/letsencrypt/network.py index f59794971..0f4d9d29b 100644 --- a/letsencrypt/network.py +++ b/letsencrypt/network.py @@ -1,121 +1,26 @@ -"""Network Module.""" -import logging -import sys -import time - -import requests - -from acme import jose -from acme import messages - -from letsencrypt import errors +"""Networking for ACME protocol.""" +from acme import client -# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning -requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() +class Network(client.Client): + """ACME networking.""" -logging.getLogger("requests").setLevel(logging.WARNING) + def register_from_account(self, account): + """Register with server. + .. todo:: this should probably not be a part of network... -class Network(object): - """Class for communicating with ACME servers. + :param account: Account + :type account: :class:`letsencrypt.account.Account` - :ivar str server_url: Full URL of the ACME service - - """ - def __init__(self, server): - """Initialize Network instance. - - :param str server: ACME (CA) server[:port] + :returns: Updated account + :rtype: :class:`letsencrypt.account.Account` """ - self.server_url = "https://%s/acme/" % server - - def send(self, msg): - """Send ACME message to server. - - :param msg: ACME message. - :type msg: :class:`acme.messages.Message` - - :returns: Server response message. - :rtype: :class:`acme.messages.Message` - - :raises acme.errors.ValidationError: if `msg` is not - valid serializable ACME JSON message. - :raises errors.LetsEncryptClientError: in case of connection error - or if response from server is not a valid ACME message. - - """ - try: - response = requests.post( - self.server_url, - data=msg.json_dumps(), - headers={"Content-Type": "application/json"}, - verify=True - ) - except requests.exceptions.RequestException as error: - raise errors.LetsEncryptClientError( - 'Sending ACME message to server has failed: %s' % error) - - json_string = response.json() - try: - return messages.Message.from_json(json_string) - except jose.DeserializationError as error: - logging.error(json_string) - raise # TODO - - def send_and_receive_expected(self, msg, expected): - """Send ACME message to server and return expected message. - - :param msg: ACME message. - :type msg: :class:`acme.Message` - - :returns: ACME response message of expected type. - :rtype: :class:`acme.messages.Message` - - :raises errors.LetsEncryptClientError: An exception is thrown - - """ - response = self.send(msg) - return self.is_expected_msg(response, expected) - - - def is_expected_msg(self, response, expected, delay=3, rounds=20): - """Is response expected ACME message? - - :param response: ACME response message from server. - :type response: :class:`acme.messages.Message` - - :param expected: Expected response type. - :type expected: subclass of :class:`acme.messages.Message` - - :param int delay: Number of seconds to delay before next round - in case of ACME "defer" response message. - :param int rounds: Number of resend attempts in case of ACME "defer" - response message. - - :returns: ACME response message from server. - :rtype: :class:`acme.messages.Message` - - :raises LetsEncryptClientError: if server sent ACME "error" message - - """ - for _ in xrange(rounds): - if isinstance(response, expected): - return response - elif isinstance(response, messages.Error): - logging.error("%s", response) - raise errors.LetsEncryptClientError(response.error) - elif isinstance(response, messages.Defer): - logging.info("Waiting for %d seconds...", delay) - time.sleep(delay) - response = self.send( - messages.StatusRequest(token=response.token)) - else: - logging.fatal("Received unexpected message") - logging.fatal("Expected: %s", expected) - logging.fatal("Received: %s", response) - sys.exit(33) - - logging.error( - "Server has deferred past the max of %d seconds", rounds * delay) + details = ( + "mailto:" + account.email if account.email is not None else None, + "tel:" + account.phone if account.phone is not None else None, + ) + account.regr = self.register(contact=tuple( + det for det in details if det is not None)) + return account diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 32bee2b49..03ddf7df7 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -1,8 +1,14 @@ """Plugin common functions.""" +import os +import pkg_resources +import shutil +import tempfile + import zope.interface from acme.jose import util as jose_util +from letsencrypt import constants from letsencrypt import interfaces @@ -69,3 +75,127 @@ class Plugin(object): with unique plugin name prefix. """ + +# other + +class Addr(object): + r"""Represents an virtual host address. + + :param str addr: addr part of vhost address + :param str port: port number or \*, or "" + + """ + def __init__(self, tup): + self.tup = tup + + @classmethod + def fromstring(cls, str_addr): + """Initialize Addr from string.""" + tup = str_addr.partition(':') + return cls((tup[0], tup[2])) + + def __str__(self): + if self.tup[1]: + return "%s:%s" % self.tup + return self.tup[0] + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.tup == other.tup + return False + + def __hash__(self): + return hash(self.tup) + + def get_addr(self): + """Return addr part of Addr object.""" + return self.tup[0] + + def get_port(self): + """Return port.""" + return self.tup[1] + + def get_addr_obj(self, port): + """Return new address object with same addr and new port.""" + return self.__class__((self.tup[0], port)) + + +class Dvsni(object): + """Class that perform DVSNI challenges.""" + + def __init__(self, configurator): + self.configurator = configurator + self.achalls = [] + self.indices = [] + self.challenge_conf = os.path.join( + configurator.config.config_dir, "le_dvsni_cert_challenge.conf") + # self.completed = 0 + + def add_chall(self, achall, idx=None): + """Add challenge to DVSNI object to perform at once. + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.achallenges.DVSNI` + + :param int idx: index to challenge in a larger array + + """ + self.achalls.append(achall) + if idx is not None: + self.indices.append(idx) + + def get_cert_file(self, achall): + """Returns standardized name for challenge certificate. + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.achallenges.DVSNI` + + :returns: certificate file name + :rtype: str + + """ + return os.path.join( + self.configurator.config.work_dir, achall.nonce_domain + ".crt") + + def _setup_challenge_cert(self, achall, s=None): + # pylint: disable=invalid-name + """Generate and write out challenge certificate.""" + cert_path = self.get_cert_file(achall) + # Register the path before you write out the file + self.configurator.reverter.register_file_creation(True, cert_path) + + cert_pem, response = achall.gen_cert_and_response(s) + + # Write out challenge cert + with open(cert_path, "w") as cert_chall_fd: + cert_chall_fd.write(cert_pem) + + return response + + +# test utils + +def setup_ssl_options(config_dir, src, dest): + """Move the ssl_options into position and return the path.""" + option_path = os.path.join(config_dir, dest) + shutil.copyfile(src, option_path) + return option_path + + +def dir_setup(test_dir, pkg): + """Setup the directories necessary for the configurator.""" + temp_dir = tempfile.mkdtemp("temp") + config_dir = tempfile.mkdtemp("config") + work_dir = tempfile.mkdtemp("work") + + os.chmod(temp_dir, constants.CONFIG_DIRS_MODE) + os.chmod(config_dir, constants.CONFIG_DIRS_MODE) + os.chmod(work_dir, constants.CONFIG_DIRS_MODE) + + test_configs = pkg_resources.resource_filename( + pkg, os.path.join("testdata", test_dir)) + + shutil.copytree( + test_configs, os.path.join(temp_dir, test_dir), symlinks=True) + + return temp_dir, config_dir, work_dir diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py index 12dd18bdf..6de86f2b8 100644 --- a/letsencrypt/plugins/common_test.py +++ b/letsencrypt/plugins/common_test.py @@ -1,8 +1,16 @@ """Tests for letsencrypt.plugins.common.""" +import pkg_resources import unittest import mock +from acme import challenges + +from letsencrypt import achallenges +from letsencrypt import le_util + +from letsencrypt.tests import acme_util + class NamespaceFunctionsTest(unittest.TestCase): """Tests for letsencrypt.plugins.common.*_namespace functions.""" @@ -57,5 +65,103 @@ class PluginTest(unittest.TestCase): "--mock-foo-bar", dest="different_to_foo_bar", x=1, y=None) +class AddrTest(unittest.TestCase): + """Tests for letsencrypt.client.plugins.common.Addr.""" + + def setUp(self): + from letsencrypt.plugins.common import Addr + self.addr1 = Addr.fromstring("192.168.1.1") + self.addr2 = Addr.fromstring("192.168.1.1:*") + self.addr3 = Addr.fromstring("192.168.1.1:80") + + def test_fromstring(self): + self.assertEqual(self.addr1.get_addr(), "192.168.1.1") + self.assertEqual(self.addr1.get_port(), "") + self.assertEqual(self.addr2.get_addr(), "192.168.1.1") + self.assertEqual(self.addr2.get_port(), "*") + self.assertEqual(self.addr3.get_addr(), "192.168.1.1") + self.assertEqual(self.addr3.get_port(), "80") + + def test_str(self): + self.assertEqual(str(self.addr1), "192.168.1.1") + self.assertEqual(str(self.addr2), "192.168.1.1:*") + self.assertEqual(str(self.addr3), "192.168.1.1:80") + + def test_get_addr_obj(self): + self.assertEqual(str(self.addr1.get_addr_obj("443")), "192.168.1.1:443") + self.assertEqual(str(self.addr2.get_addr_obj("")), "192.168.1.1") + self.assertEqual(str(self.addr1.get_addr_obj("*")), "192.168.1.1:*") + + def test_eq(self): + self.assertEqual(self.addr1, self.addr2.get_addr_obj("")) + self.assertNotEqual(self.addr1, self.addr2) + self.assertFalse(self.addr1 == 3333) + + def test_set_inclusion(self): + from letsencrypt.plugins.common import Addr + set_a = set([self.addr1, self.addr2]) + addr1b = Addr.fromstring("192.168.1.1") + addr2b = Addr.fromstring("192.168.1.1:*") + set_b = set([addr1b, addr2b]) + + self.assertEqual(set_a, set_b) + + +class DvsniTest(unittest.TestCase): + """Tests for letsencrypt.plugins.common.DvsniTest.""" + + rsa256_file = pkg_resources.resource_filename( + "acme.jose", "testdata/rsa256_key.pem") + rsa256_pem = pkg_resources.resource_string( + "acme.jose", "testdata/rsa256_key.pem") + + auth_key = le_util.Key(rsa256_file, rsa256_pem) + achalls = [ + achallenges.DVSNI( + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9" + "\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", + nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", + ), "pending"), + domain="encryption-example.demo", key=auth_key), + achallenges.DVSNI( + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="\xba\xa9\xda? {response.URI_ROOT_PATH}/{response.path} +# run only once per server: +python -m SimpleHTTPServer 80""" + """Non-TLS command template.""" + + # https://www.piware.de/2011/01/creating-an-https-server-in-python/ + HTTPS_TEMPLATE = """\ +mkdir -p {response.URI_ROOT_PATH} # run only once per server +echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} +# run only once per server: +openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout key.pem -out cert.pem +python -c "import BaseHTTPServer, SimpleHTTPServer, ssl; \\ +s = BaseHTTPServer.HTTPServer(('', 443), SimpleHTTPServer.SimpleHTTPRequestHandler); \\ +s.socket = ssl.wrap_socket(s.socket, keyfile='key.pem', certfile='cert.pem'); \\ +s.serve_forever()" """ + """TLS command template. + + According to the ACME specification, "the ACME server MUST ignore + the certificate provided by the HTTPS server", so the first command + generates temporary self-signed certificate. For the same reason + ``requests.get`` in `_verify` sets ``verify=False``. Python HTTPS + server command serves the ``token`` on all URIs. + + """ + + def __init__(self, *args, **kwargs): + super(ManualAuthenticator, self).__init__(*args, **kwargs) + self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls + else self.HTTPS_TEMPLATE) + + def prepare(self): # pylint: disable=missing-docstring,no-self-use + pass # pragma: no cover + + def more_info(self): # pylint: disable=missing-docstring,no-self-use + return """\ +This plugin requires user's manual intervention in setting up a HTTP +server for solving SimpleHTTP challenges and thus does not need to be +run as a privilidged process. Alternatively shows instructions on how +to use Python's built-in HTTP server and, in case of HTTPS, openssl +binary for temporary key/certificate generation.""".replace("\n", "") + + def get_chall_pref(self, domain): + # pylint: disable=missing-docstring,no-self-use,unused-argument + return [challenges.SimpleHTTP] + + def perform(self, achalls): # pylint: disable=missing-docstring + responses = [] + # TODO: group achalls by the same socket.gethostbyname(_ex) + # and prompt only once per server (one "echo -n" per domain) + for achall in achalls: + responses.append(self._perform_single(achall)) + return responses + + 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 + # same server: default command doesn't support virtual hosts + response = challenges.SimpleHTTPResponse( + path=jose.b64encode(os.urandom(18)), + tls=(not self.config.no_simple_http_tls)) + assert response.good_path # is encoded os.urandom(18) good? + + self._notify_and_wait(self.MESSAGE_TEMPLATE.format( + achall=achall, response=response, + uri=response.uri(achall.domain), + command=self.template.format(achall=achall, response=response))) + + if self._verify(achall, response): + return response + else: + return None + + def _notify_and_wait(self, message): # pylint: disable=no-self-use + # TODO: IDisplay wraps messages, breaking the command + #answer = zope.component.getUtility(interfaces.IDisplay).notification( + # message=message, height=25, pause=True) + sys.stdout.write(message) + raw_input("Press ENTER to continue") + + def _verify(self, achall, chall_response): # pylint: disable=no-self-use + uri = chall_response.uri(achall.domain) + logging.debug("Verifying %s...", uri) + try: + response = requests.get(uri, verify=False) + except requests.exceptions.ConnectionError as error: + logging.exception(error) + return False + + ret = response.text == achall.token + if not ret: + logging.error("Unable to verify %s! Expected: %r, returned: %r.", + uri, achall.token, response.text) + + return ret + + def cleanup(self, achalls): # pylint: disable=missing-docstring,no-self-use + pass # pragma: no cover diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py new file mode 100644 index 000000000..c95654dec --- /dev/null +++ b/letsencrypt/plugins/manual_test.py @@ -0,0 +1,59 @@ +"""Tests for letsencrypt.plugins.manual.""" +import unittest + +import mock +import requests + +from acme import challenges + +from letsencrypt import achallenges +from letsencrypt.tests import acme_util + + +class ManualAuthenticatorTest(unittest.TestCase): + """Tests for letsencrypt.plugins.manual.ManualAuthenticator.""" + + def setUp(self): + from letsencrypt.plugins.manual import ManualAuthenticator + self.config = mock.MagicMock(no_simple_http_tls=True) + self.auth = ManualAuthenticator(config=self.config, name="manual") + self.achalls = [achallenges.SimpleHTTP( + challb=acme_util.SIMPLE_HTTP, domain="foo.com", key=None)] + + def test_more_info(self): + self.assertTrue(isinstance(self.auth.more_info(), str)) + + def test_get_chall_pref(self): + self.assertTrue(all(issubclass(pref, challenges.Challenge) + for pref in self.auth.get_chall_pref("foo.com"))) + + def test_perform_empty(self): + self.assertEqual([], self.auth.perform([])) + + @mock.patch("letsencrypt.plugins.manual.sys.stdout") + @mock.patch("letsencrypt.plugins.manual.os.urandom") + @mock.patch("letsencrypt.plugins.manual.requests.get") + @mock.patch("__builtin__.raw_input") + def test_perform(self, mock_raw_input, mock_get, mock_urandom, mock_stdout): + mock_urandom.return_value = "foo" + mock_get().text = self.achalls[0].token + + self.assertEqual( + [challenges.SimpleHTTPResponse(tls=False, path='Zm9v')], + self.auth.perform(self.achalls)) + mock_raw_input.assert_called_once() + mock_get.assert_called_with( + "http://foo.com/.well-known/acme-challenge/Zm9v", verify=False) + + message = mock_stdout.write.mock_calls[0][1][0] + self.assertTrue(self.achalls[0].token in message) + self.assertTrue('Zm9v' in message) + + mock_get().text = self.achalls[0].token + '!' + self.assertEqual([None], self.auth.perform(self.achalls)) + + mock_get.side_effect = requests.exceptions.ConnectionError + self.assertEqual([None], self.auth.perform(self.achalls)) + +if __name__ == "__main__": + unittest.main() # pragma: no cover diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 6e61fd893..4a1e96a65 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -7,15 +7,19 @@ within lineages of successor certificates, according to configuration. .. todo:: Call new installer API to restart servers after deployment """ +import argparse import os +import sys import configobj from letsencrypt import configuration +from letsencrypt import cli from letsencrypt import client from letsencrypt import crypto_util from letsencrypt import notify from letsencrypt import storage + from letsencrypt.plugins import disco as plugins_disco @@ -92,7 +96,23 @@ def renew(cert, old_version): # (where fewer than all names were renewed) -def main(config=None): +def _paths_parser(parser): + add = parser.add_argument_group("paths").add_argument + add("--config-dir", default=cli.flag_default("config_dir"), + help=cli.config_help("config_dir")) + add("--work-dir", default=cli.flag_default("work_dir"), + help=cli.config_help("work_dir")) + return parser + + +def _create_parser(): + parser = argparse.ArgumentParser() + #parser.add_argument("--cron", action="store_true", help="Run as cronjob.") + # pylint: disable=protected-access + return _paths_parser(parser) + + +def main(config=None, args=sys.argv[1:]): """Main function for autorenewer script.""" # TODO: Distinguish automated invocation from manual invocation, # perhaps by looking at sys.argv[0] and inhibiting automated @@ -100,6 +120,9 @@ def main(config=None): # turned it off. (The boolean parameter should probably be # called renewer_enabled.) + cli_config = configuration.RenewerConfiguration( + _create_parser().parse_args(args)) + config = storage.config_with_defaults(config) # Now attempt to read the renewer config file and augment or replace # the renewer defaults with any options contained in that file. If @@ -108,14 +131,15 @@ def main(config=None): # elaborate renewer command line, we will presumably also be able to # specify a config file on the command line, which, if provided, should # take precedence over this one. - config.merge(configobj.ConfigObj(config.get("renewer_config_file", ""))) + config.merge(configobj.ConfigObj(cli_config.renewer_config_file)) - for i in os.listdir(config["renewal_configs_dir"]): + for i in os.listdir(cli_config.renewal_configs_dir): print "Processing", i if not i.endswith(".conf"): continue - rc_config = configobj.ConfigObj( - os.path.join(config["renewal_configs_dir"], i)) + rc_config = configobj.ConfigObj(cli_config.renewer_config_file) + rc_config.merge(configobj.ConfigObj( + os.path.join(cli_config.renewal_configs_dir, i))) try: # TODO: Before trying to initialize the RenewableCert object, # we could check here whether the combination of the config @@ -125,7 +149,7 @@ def main(config=None): # RenewableCert object for this cert at all, which could # dramatically improve performance for large deployments # where autorenewal is widely turned off. - cert = storage.RenewableCert(rc_config) + cert = storage.RenewableCert(rc_config, cli_config=cli_config) except ValueError: # This indicates an invalid renewal configuration file, such # as one missing a required parameter (in the future, perhaps diff --git a/letsencrypt/reporter.py b/letsencrypt/reporter.py index 045c1befa..dc3859535 100644 --- a/letsencrypt/reporter.py +++ b/letsencrypt/reporter.py @@ -66,7 +66,8 @@ class Reporter(object): If there is an unhandled exception, only messages for which ``on_crash`` is ``True`` are printed. -""" + + """ bold_on = False if not self.messages.empty(): no_exception = sys.exc_info()[0] is None @@ -74,14 +75,21 @@ class Reporter(object): if bold_on: print self._BOLD print 'IMPORTANT NOTES:' - wrapper = textwrap.TextWrapper(initial_indent=' - ', - subsequent_indent=(' ' * 3)) + first_wrapper = textwrap.TextWrapper( + initial_indent=' - ', subsequent_indent=(' ' * 3)) + next_wrapper = textwrap.TextWrapper( + initial_indent=first_wrapper.subsequent_indent, + subsequent_indent=first_wrapper.subsequent_indent) while not self.messages.empty(): 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) bold_on = False - print wrapper.fill(msg.text) + lines = msg.text.splitlines() + print first_wrapper.fill(lines[0]) + if len(lines) > 1: + print "\n".join( + next_wrapper.fill(line) for line in lines[1:]) if bold_on: sys.stdout.write(self._RESET) diff --git a/letsencrypt/reverter.py b/letsencrypt/reverter.py index 604c3999a..72a6c0b67 100644 --- a/letsencrypt/reverter.py +++ b/letsencrypt/reverter.py @@ -30,19 +30,17 @@ class Reverter(object): This function should reinstall the users original configuration files for all saves with temporary=True - :raises letsencrypt.errors.LetsEncryptReverterError: when - unable to revert config + :raises .ReverterError: when unable to revert config """ if os.path.isdir(self.config.temp_checkpoint_dir): try: self._recover_checkpoint(self.config.temp_checkpoint_dir) - except errors.LetsEncryptReverterError: + except errors.ReverterError: # We have a partial or incomplete recovery logging.fatal("Incomplete or failed recovery for %s", self.config.temp_checkpoint_dir) - raise errors.LetsEncryptReverterError( - "Unable to revert temporary config") + raise errors.ReverterError("Unable to revert temporary config") def rollback_checkpoints(self, rollback=1): """Revert 'rollback' number of configuration checkpoints. @@ -50,20 +48,20 @@ class Reverter(object): :param int rollback: Number of checkpoints to reverse. A str num will be cast to an integer. So "2" is also acceptable. - :raises letsencrypt.errors.LetsEncryptReverterError: If - there is a problem with the input or if the function is unable to - correctly revert the configuration checkpoints. + :raises .ReverterError: + if there is a problem with the input or if the function is + unable to correctly revert the configuration checkpoints """ try: rollback = int(rollback) except ValueError: logging.error("Rollback argument must be a positive integer") - raise errors.LetsEncryptReverterError("Invalid Input") + raise errors.ReverterError("Invalid Input") # Sanity check input if rollback < 0: logging.error("Rollback argument must be a positive integer") - raise errors.LetsEncryptReverterError("Invalid Input") + raise errors.ReverterError("Invalid Input") backups = os.listdir(self.config.backup_dir) backups.sort() @@ -76,9 +74,9 @@ class Reverter(object): cp_dir = os.path.join(self.config.backup_dir, backups.pop()) try: self._recover_checkpoint(cp_dir) - except errors.LetsEncryptReverterError: + except errors.ReverterError: logging.fatal("Failed to load checkpoint during rollback") - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Unable to load checkpoint during rollback") rollback -= 1 @@ -104,7 +102,7 @@ class Reverter(object): for bkup in backups: float(bkup) except ValueError: - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Invalid directories in {0}".format(self.config.backup_dir)) output = [] @@ -161,9 +159,8 @@ class Reverter(object): :param set save_files: set of files to save :param str save_notes: notes about changes made during the save - :raises IOError: If unable to open cp_dir + FILEPATHS file - :raises letsencrypt.errors.LetsEncryptReverterError: If - unable to add checkpoint + :raises IOError: if unable to open cp_dir + FILEPATHS file + :raises .ReverterError: if unable to add checkpoint """ le_util.make_or_verify_dir( @@ -191,7 +188,7 @@ class Reverter(object): logging.error( "Unable to add file %s to checkpoint %s", filename, cp_dir) - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Unable to add file {0} to checkpoint " "{1}".format(filename, cp_dir)) idx += 1 @@ -224,7 +221,7 @@ class Reverter(object): :param str cp_dir: checkpoint directory file path - :raises errors.LetsEncryptReverterError: If unable to recover checkpoint + :raises errors.ReverterError: If unable to recover checkpoint """ if os.path.isfile(os.path.join(cp_dir, "FILEPATHS")): @@ -238,7 +235,7 @@ class Reverter(object): except (IOError, OSError): # This file is required in all checkpoints. logging.error("Unable to recover files from %s", cp_dir) - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Unable to recover files from %s" % cp_dir) # Remove any newly added files if they exist @@ -248,7 +245,7 @@ class Reverter(object): shutil.rmtree(cp_dir) except OSError: logging.error("Unable to remove directory: %s", cp_dir) - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Unable to remove directory: %s" % cp_dir) def _check_tempfile_saves(self, save_files): @@ -256,7 +253,7 @@ class Reverter(object): :param set save_files: Set of files about to be saved. - :raises letsencrypt.errors.LetsEncryptReverterError: + :raises letsencrypt.errors.ReverterError: when save is attempting to overwrite a temporary file. """ @@ -277,7 +274,7 @@ class Reverter(object): # Verify no save_file is in protected_files for filename in protected_files: if filename in save_files: - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Attempting to overwrite challenge " "file - %s" % filename) @@ -292,7 +289,7 @@ class Reverter(object): a temp or permanent save. :param \*files: file paths (str) to be registered - :raises letsencrypt.errors.LetsEncryptReverterError: If + :raises letsencrypt.errors.ReverterError: If call does not contain necessary parameters or if the file creation is unable to be registered. @@ -300,7 +297,7 @@ class Reverter(object): # Make sure some files are provided... as this is an error # Made this mistake in my initial implementation of apache.dvsni.py if not files: - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Forgot to provide files to registration call") if temporary: @@ -322,7 +319,7 @@ class Reverter(object): new_fd.write("{0}{1}".format(path, os.linesep)) except (IOError, OSError): logging.error("Unable to register file creation(s) - %s", files) - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Unable to register file creation(s) - {0}".format(files)) finally: if new_fd is not None: @@ -345,12 +342,12 @@ class Reverter(object): if os.path.isdir(self.config.in_progress_dir): try: self._recover_checkpoint(self.config.in_progress_dir) - except errors.LetsEncryptReverterError: + except errors.ReverterError: # We have a partial or incomplete recovery logging.fatal("Incomplete or failed recovery for IN_PROGRESS " "checkpoint - %s", self.config.in_progress_dir) - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Incomplete or failed recovery for IN_PROGRESS checkpoint " "- %s" % self.config.in_progress_dir) @@ -362,7 +359,7 @@ class Reverter(object): :returns: Success :rtype: bool - :raises letsencrypt.errors.LetsEncryptReverterError: If + :raises letsencrypt.errors.ReverterError: If all files within file_list cannot be removed """ @@ -386,7 +383,7 @@ class Reverter(object): except (IOError, OSError): logging.fatal( "Unable to remove filepaths contained within %s", file_list) - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Unable to remove filepaths contained within " "{0}".format(file_list)) @@ -400,7 +397,7 @@ class Reverter(object): :param str title: Title describing checkpoint - :raises letsencrypt.errors.LetsEncryptReverterError: when the + :raises letsencrypt.errors.ReverterError: when the checkpoint is not able to be finalized. """ @@ -426,7 +423,7 @@ class Reverter(object): shutil.move(changes_since_tmp_path, changes_since_path) except (IOError, OSError): logging.error("Unable to finalize checkpoint - adding title") - raise errors.LetsEncryptReverterError("Unable to add title") + raise errors.ReverterError("Unable to add title") self._timestamp_progress_dir() @@ -451,5 +448,5 @@ class Reverter(object): logging.error( "Unable to finalize checkpoint, %s -> %s", self.config.in_progress_dir, final_dir) - raise errors.LetsEncryptReverterError( + raise errors.ReverterError( "Unable to finalize checkpoint renaming") diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py index a1ea27e71..a3ea543fb 100644 --- a/letsencrypt/revoker.py +++ b/letsencrypt/revoker.py @@ -16,7 +16,6 @@ import tempfile import Crypto.PublicKey.RSA import M2Crypto -from acme import messages from acme.jose import util as jose_util from letsencrypt import errors @@ -45,7 +44,9 @@ class Revoker(object): """ def __init__(self, installer, config, no_confirm=False): - self.network = network.Network(config.server) + # XXX + self.network = network.Network(new_reg_uri=None, key=None, alg=None) + self.installer = installer self.config = config self.no_confirm = no_confirm @@ -70,7 +71,7 @@ class Revoker(object): authkey.pem).exportKey("PEM") # https://www.dlitz.net/software/pycrypto/api/current/Crypto.PublicKey.RSA-module.html except (IndexError, ValueError, TypeError): - raise errors.LetsEncryptRevokerError( + raise errors.RevokerError( "Invalid key file specified to revoke_from_key") with open(self.list_path, "rb") as csvfile: @@ -88,8 +89,7 @@ class Revoker(object): # 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.LetsEncryptRevokerError( - "%s - backup file is corrupted.") + raise errors.RevokerError("%s - backup file is corrupted.") if clean_pem == test_pem: certs.append( @@ -217,7 +217,7 @@ class Revoker(object): if self.no_confirm or revocation.confirm_revocation(cert): try: self._acme_revoke(cert) - except errors.LetsEncryptClientError: + except errors.Error: # TODO: Improve error handling when networking is set... logging.error( "Unable to revoke cert:%s%s", os.linesep, str(cert)) @@ -238,6 +238,8 @@ class Revoker(object): :returns: TODO """ + # XXX | pylint: disable=unused-variable + # These will both have to change in the future away from M2Crypto # pylint: disable=protected-access certificate = jose_util.ComparableX509(cert._cert) @@ -247,13 +249,10 @@ class Revoker(object): # If the key file doesn't exist... or is corrupted except (IndexError, ValueError, TypeError): - raise errors.LetsEncryptRevokerError( + raise errors.RevokerError( "Corrupted backup key file: %s" % cert.backup_key_path) - # TODO: Catch error associated with already revoked and proceed. - return self.network.send_and_receive_expected( - messages.RevocationRequest.create(certificate=certificate, key=key), - messages.Revocation) + return self.network.revoke(cert=None) # XXX def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use """Remove certificate and key. @@ -293,7 +292,7 @@ class Revoker(object): # This should never happen... if idx != len(cert_list): - raise errors.LetsEncryptRevokerError( + raise errors.RevokerError( "Did not find all cert_list items to remove from LIST") shutil.copy2(list_path2, self.list_path) @@ -398,7 +397,7 @@ class Cert(object): try: self._cert = M2Crypto.X509.load_cert(cert_path) except (IOError, M2Crypto.X509.X509Error): - raise errors.LetsEncryptRevokerError( + raise errors.RevokerError( "Error loading certificate: %s" % cert_path) self.idx = -1 diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 2648be3ba..4ad1216e6 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -78,14 +78,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes renewal configuration file and/or systemwide defaults. """ - def __init__(self, configfile, config_opts=None): + def __init__(self, configfile, config_opts=None, cli_config=None): """Instantiate a RenewableCert object from an existing lineage. :param configobj.ConfigObj configfile: an already-parsed - ConfigObj object made from reading the renewal config file - that defines this lineage. :param configobj.ConfigObj - config_opts: systemwide defaults for renewal properties not - otherwise specified in the individual renewal config file. + ConfigObj object made from reading the renewal config file + that defines this lineage. + + :param configobj.ConfigObj config_opts: systemwide defaults for + renewal properties not otherwise specified in the individual + renewal config file. + :param .RenewerConfiguration cli_config: :raises ValueError: if the configuration file's name didn't end in ".conf", or the file is missing or broken. @@ -93,6 +96,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes ConfigObj object. """ + self.cli_config = cli_config if isinstance(configfile, configobj.ConfigObj): if not os.path.basename(configfile.filename).endswith(".conf"): raise ValueError("renewal config file name must end in .conf") @@ -149,7 +153,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Each element's link must point within the cert lineage's # directory within the official archive directory desired_directory = os.path.join( - self.configuration["archive_dir"], self.lineagename) + self.cli_config.archive_dir, self.lineagename) if not os.path.samefile(os.path.dirname(target), desired_directory): return False @@ -499,7 +503,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes @classmethod def new_lineage(cls, lineagename, cert, privkey, chain, - renewalparams=None, config=None): + renewalparams=None, config=None, cli_config=None): # pylint: disable=too-many-locals,too-many-arguments """Create a new certificate lineage. @@ -536,17 +540,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # the renewer defaults with any options contained in that file. If # renewer_config_file is undefined or if the file is nonexistent or # empty, this .merge() will have no effect. - config.merge(configobj.ConfigObj(config.get("renewer_config_file", ""))) + config.merge(configobj.ConfigObj(cli_config.renewer_config_file)) # Examine the configuration and find the new lineage's name - configs_dir = config["renewal_configs_dir"] - archive_dir = config["archive_dir"] - live_dir = config["live_dir"] - for i in (configs_dir, archive_dir, live_dir): + for i in (cli_config.renewal_configs_dir, cli_config.archive_dir, + cli_config.live_dir): if not os.path.exists(i): os.makedirs(i, 0700) - config_file, config_filename = le_util.unique_lineage_name(configs_dir, - lineagename) + config_file, config_filename = le_util.unique_lineage_name( + cli_config.renewal_configs_dir, lineagename) if not config_filename.endswith(".conf"): raise ValueError("renewal config file name must end in .conf") @@ -554,8 +556,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # lineagename will now potentially be modified based on which # renewal configuration file could actually be created lineagename = os.path.basename(config_filename)[:-len(".conf")] - archive = os.path.join(archive_dir, lineagename) - live_dir = os.path.join(live_dir, lineagename) + archive = os.path.join(cli_config.archive_dir, lineagename) + live_dir = os.path.join(cli_config.live_dir, lineagename) if os.path.exists(archive): raise ValueError("archive directory exists for " + lineagename) if os.path.exists(live_dir): @@ -593,7 +595,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # TODO: add human-readable comments explaining other available # parameters new_config.write() - return cls(new_config, config) + return cls(new_config, config, cli_config) def save_successor(self, prior_version, new_cert, new_privkey, new_chain): @@ -624,7 +626,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Figure out what the new version is and hence where to save things target_version = self.next_free_version() - archive = self.configuration["archive_dir"] + archive = self.cli_config.archive_dir prefix = os.path.join(archive, self.lineagename) target = dict( [(kind, diff --git a/letsencrypt/tests/account_test.py b/letsencrypt/tests/account_test.py index d14610252..6b9fafe31 100644 --- a/letsencrypt/tests/account_test.py +++ b/letsencrypt/tests/account_test.py @@ -7,7 +7,7 @@ import shutil import tempfile import unittest -from acme import messages2 +from acme import messages from letsencrypt import configuration from letsencrypt import errors @@ -40,11 +40,11 @@ class AccountTest(unittest.TestCase): self.key = le_util.Key(key_file, key_pem) self.email = "client@letsencrypt.org" - self.regr = messages2.RegistrationResource( + self.regr = messages.RegistrationResource( uri="uri", new_authzr_uri="new_authzr_uri", terms_of_service="terms_of_service", - body=messages2.Registration( + body=messages.Registration( recovery_token="recovery_token", agreement="agreement") ) @@ -73,7 +73,7 @@ class AccountTest(unittest.TestCase): def test_prompts_bad_email(self, mock_from_email, mock_util): from letsencrypt.account import Account - mock_from_email.side_effect = (errors.LetsEncryptClientError, "acc") + mock_from_email.side_effect = (errors.Error, "acc") mock_util().input.return_value = (display_util.OK, self.email) self.assertEqual(Account.from_prompts(self.config), "acc") @@ -102,8 +102,8 @@ class AccountTest(unittest.TestCase): def test_from_email(self): from letsencrypt.account import Account - self.assertRaises(errors.LetsEncryptClientError, - Account.from_email, self.config, "not_valid...email") + self.assertRaises( + errors.Error, Account.from_email, self.config, "not_valid...email") def test_save_from_existing_account(self): from letsencrypt.account import Account @@ -170,10 +170,8 @@ class AccountTest(unittest.TestCase): def test_failed_existing_account(self): from letsencrypt.account import Account - self.assertRaises( - errors.LetsEncryptClientError, - Account.from_existing_account, - self.config, "non-existant@email.org") + self.assertRaises(errors.Error, Account.from_existing_account, + self.config, "non-existant@email.org") class SafeEmailTest(unittest.TestCase): """Test safe_email.""" diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py index 51bb3cfbb..7ac05c1fa 100644 --- a/letsencrypt/tests/acme_util.py +++ b/letsencrypt/tests/acme_util.py @@ -8,7 +8,7 @@ import Crypto.PublicKey.RSA from acme import challenges from acme import jose -from acme import messages2 +from acme import messages KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( @@ -16,7 +16,7 @@ KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( "acme.jose", os.path.join("testdata", "rsa512_key.pem")))) # Challenges -SIMPLE_HTTPS = challenges.SimpleHTTP( +SIMPLE_HTTP = challenges.SimpleHTTP( token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA") DVSNI = challenges.DVSNI( r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6\xbf'\xb3" @@ -47,7 +47,7 @@ POP = challenges.ProofOfPossession( ) ) -CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] +CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] DV_CHALLENGES = [chall for chall in CHALLENGES if isinstance(chall, challenges.DVChallenge)] CONT_CHALLENGES = [chall for chall in CHALLENGES @@ -78,21 +78,21 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name "status": status, } - if status == messages2.STATUS_VALID: + if status == messages.STATUS_VALID: kwargs.update({"validated": datetime.datetime.now()}) - return messages2.ChallengeBody(**kwargs) # pylint: disable=star-args + return messages.ChallengeBody(**kwargs) # pylint: disable=star-args # Pending ChallengeBody objects -DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING) -SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING) -DNS_P = chall_to_challb(DNS, messages2.STATUS_PENDING) -RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages2.STATUS_PENDING) -RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages2.STATUS_PENDING) -POP_P = chall_to_challb(POP, messages2.STATUS_PENDING) +DVSNI_P = chall_to_challb(DVSNI, messages.STATUS_PENDING) +SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, messages.STATUS_PENDING) +DNS_P = chall_to_challb(DNS, messages.STATUS_PENDING) +RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages.STATUS_PENDING) +RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages.STATUS_PENDING) +POP_P = chall_to_challb(POP, messages.STATUS_PENDING) -CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P, +CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P, RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P] DV_CHALLENGES_P = [challb for challb in CHALLENGES_P if isinstance(challb.chall, challenges.DVChallenge)] @@ -106,7 +106,7 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): """Generate an authorization resource. :param authz_status: Status object - :type authz_status: :class:`acme.messages2.Status` + :type authz_status: :class:`acme.messages.Status` :param list challs: Challenge objects :param list statuses: status of each challenge object :param bool combos: Whether or not to add combinations @@ -118,13 +118,13 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): for chall, status in itertools.izip(challs, statuses) ) authz_kwargs = { - "identifier": messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value=domain), + "identifier": messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value=domain), "challenges": challbs, } if combos: authz_kwargs.update({"combinations": gen_combos(challbs)}) - if authz_status == messages2.STATUS_VALID: + if authz_status == messages.STATUS_VALID: authz_kwargs.update({ "status": authz_status, "expires": datetime.datetime.now() + datetime.timedelta(days=31), @@ -135,8 +135,8 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): }) # pylint: disable=star-args - return messages2.AuthorizationResource( + return messages.AuthorizationResource( uri="https://trusted.ca/new-authz-resource", new_cert_uri="https://trusted.ca/new-cert", - body=messages2.Authorization(**authz_kwargs) + body=messages.Authorization(**authz_kwargs) ) diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index d7fd2c093..6a94baea7 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -6,11 +6,11 @@ import unittest import mock from acme import challenges -from acme import messages2 +from acme import messages from letsencrypt import errors from letsencrypt import le_util -from letsencrypt import network2 +from letsencrypt import network from letsencrypt.tests import acme_util @@ -37,8 +37,8 @@ class ChallengeFactoryTest(unittest.TestCase): self.dom = "test" self.handler.authzr[self.dom] = acme_util.gen_authzr( - messages2.STATUS_PENDING, self.dom, acme_util.CHALLENGES, - [messages2.STATUS_PENDING]*6, False) + messages.STATUS_PENDING, self.dom, acme_util.CHALLENGES, + [messages.STATUS_PENDING]*6, False) def test_all(self): cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) @@ -57,12 +57,12 @@ class ChallengeFactoryTest(unittest.TestCase): def test_unrecognized(self): self.handler.authzr["failure.com"] = acme_util.gen_authzr( - messages2.STATUS_PENDING, "failure.com", + messages.STATUS_PENDING, "failure.com", [mock.Mock(chall="chall", typ="unrecognized")], - [messages2.STATUS_PENDING]) + [messages.STATUS_PENDING]) - self.assertRaises(errors.LetsEncryptClientError, - self.handler._challenge_factory, "failure.com", [0]) + self.assertRaises( + errors.Error, self.handler._challenge_factory, "failure.com", [0]) class GetAuthorizationsTest(unittest.TestCase): @@ -86,7 +86,7 @@ class GetAuthorizationsTest(unittest.TestCase): self.mock_dv_auth.perform.side_effect = gen_auth_resp self.mock_account = mock.Mock(key=le_util.Key("file_path", "PEM")) - self.mock_net = mock.MagicMock(spec=network2.Network) + self.mock_net = mock.MagicMock(spec=network.Network) self.handler = AuthHandler( self.mock_dv_auth, self.mock_cont_auth, @@ -153,17 +153,17 @@ class GetAuthorizationsTest(unittest.TestCase): gen_dom_authzr, challs=acme_util.CHALLENGES) self.mock_dv_auth.perform.side_effect = errors.AuthorizationError - self.assertRaises(errors.AuthorizationError, - self.handler.get_authorizations, ["0"]) + self.assertRaises( + errors.AuthorizationError, self.handler.get_authorizations, ["0"]) def _validate_all(self, unused_1, unused_2): for dom in self.handler.authzr.keys(): azr = self.handler.authzr[dom] self.handler.authzr[dom] = acme_util.gen_authzr( - messages2.STATUS_VALID, + messages.STATUS_VALID, dom, [challb.chall for challb in azr.body.challenges], - [messages2.STATUS_VALID]*len(azr.body.challenges), + [messages.STATUS_VALID]*len(azr.body.challenges), azr.body.combinations) @@ -182,16 +182,16 @@ class PollChallengesTest(unittest.TestCase): self.doms = ["0", "1", "2"] self.handler.authzr[self.doms[0]] = acme_util.gen_authzr( - messages2.STATUS_PENDING, self.doms[0], - acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) + messages.STATUS_PENDING, self.doms[0], + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) self.handler.authzr[self.doms[1]] = acme_util.gen_authzr( - messages2.STATUS_PENDING, self.doms[1], - acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) + messages.STATUS_PENDING, self.doms[1], + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) self.handler.authzr[self.doms[2]] = acme_util.gen_authzr( - messages2.STATUS_PENDING, self.doms[2], - acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) + messages.STATUS_PENDING, self.doms[2], + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) self.chall_update = {} for dom in self.doms: @@ -205,7 +205,7 @@ class PollChallengesTest(unittest.TestCase): self.handler._poll_challenges(self.chall_update, False) for authzr in self.handler.authzr.values(): - self.assertEqual(authzr.body.status, messages2.STATUS_VALID) + self.assertEqual(authzr.body.status, messages.STATUS_VALID) @mock.patch("letsencrypt.auth_handler.time") def test_poll_challenges_failure_best_effort(self, unused_mock_time): @@ -213,14 +213,15 @@ class PollChallengesTest(unittest.TestCase): self.handler._poll_challenges(self.chall_update, True) for authzr in self.handler.authzr.values(): - self.assertEqual(authzr.body.status, messages2.STATUS_PENDING) + self.assertEqual(authzr.body.status, messages.STATUS_PENDING) @mock.patch("letsencrypt.auth_handler.time") - def test_poll_challenges_failure(self, unused_mock_time): + @mock.patch("letsencrypt.auth_handler.zope.component.getUtility") + def test_poll_challenges_failure(self, unused_mock_time, unused_mock_zope): self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid - self.assertRaises(errors.AuthorizationError, - self.handler._poll_challenges, - self.chall_update, False) + self.assertRaises( + errors.AuthorizationError, self.handler._poll_challenges, + self.chall_update, False) @mock.patch("letsencrypt.auth_handler.time") def test_unable_to_find_challenge_status(self, unused_mock_time): @@ -229,8 +230,8 @@ class PollChallengesTest(unittest.TestCase): self.chall_update[self.doms[0]].append( challb_to_achall(acme_util.RECOVERY_CONTACT_P, "key", self.doms[0])) self.assertRaises( - errors.AuthorizationError, - self.handler._poll_challenges, self.chall_update, False) + errors.AuthorizationError, self.handler._poll_challenges, + self.chall_update, False) def test_verify_authzr_failure(self): self.assertRaises( @@ -241,10 +242,10 @@ class PollChallengesTest(unittest.TestCase): # Basically it didn't raise an error and it stopped earlier than # Making all challenges invalid which would make mock_poll_solve_one # change authzr to invalid - return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_VALID) + return self._mock_poll_solve_one_chall(authzr, messages.STATUS_VALID) def _mock_poll_solve_one_invalid(self, authzr): - return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_INVALID) + return self._mock_poll_solve_one_chall(authzr, messages.STATUS_INVALID) def _mock_poll_solve_one_chall(self, authzr, desired_status): # pylint: disable=no-self-use @@ -269,10 +270,10 @@ class PollChallengesTest(unittest.TestCase): else: status_ = authzr.body.status - new_authzr = messages2.AuthorizationResource( + new_authzr = messages.AuthorizationResource( uri=authzr.uri, new_cert_uri=authzr.new_cert_uri, - body=messages2.Authorization( + body=messages.Authorization( identifier=authzr.body.identifier, challenges=new_challbs, combinations=authzr.body.combinations, @@ -300,7 +301,7 @@ class GenChallengePathTest(unittest.TestCase): def test_common_case(self): """Given DVSNI and SimpleHTTP with appropriate combos.""" - challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P) + challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTP_P) prefs = [challenges.DVSNI] combos = ((0,), (1,)) @@ -315,7 +316,7 @@ class GenChallengePathTest(unittest.TestCase): challbs = (acme_util.RECOVERY_TOKEN_P, acme_util.RECOVERY_CONTACT_P, acme_util.DVSNI_P, - acme_util.SIMPLE_HTTPS_P) + acme_util.SIMPLE_HTTP_P) prefs = [challenges.RecoveryToken, challenges.DVSNI] combos = acme_util.gen_combos(challbs) self.assertEqual(self._call(challbs, prefs, combos), (0, 2)) @@ -328,7 +329,7 @@ class GenChallengePathTest(unittest.TestCase): acme_util.RECOVERY_CONTACT_P, acme_util.POP_P, acme_util.DVSNI_P, - acme_util.SIMPLE_HTTPS_P, + acme_util.SIMPLE_HTTP_P, acme_util.DNS_P) # Typical webserver client that can do everything except DNS # Attempted to make the order realistic @@ -348,8 +349,8 @@ class GenChallengePathTest(unittest.TestCase): prefs = [challenges.DVSNI] combos = ((0, 1),) - self.assertRaises(errors.AuthorizationError, - self._call, challbs, prefs, combos) + self.assertRaises( + errors.AuthorizationError, self._call, challbs, prefs, combos) class MutuallyExclusiveTest(unittest.TestCase): @@ -413,13 +414,61 @@ class IsPreferredTest(unittest.TestCase): def test_mutually_exclusvie(self): self.assertFalse( self._call( - acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTPS_P]))) + acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTP_P]))) def test_mutually_exclusive_same_type(self): self.assertTrue( self._call(acme_util.DVSNI_P, frozenset([acme_util.DVSNI_P]))) +class ReportFailedChallsTest(unittest.TestCase): + """Tests for letsencrypt.auth_handler._report_failed_challs.""" + # pylint: disable=protected-access + + def setUp(self): + from letsencrypt import achallenges + + kwargs = { + "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 + domain="example.com", + key=acme_util.KEY) + + kwargs["chall"] = acme_util.DVSNI + self.dvsni_same = achallenges.DVSNI( + challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + domain="example.com", + key=acme_util.KEY) + + kwargs["error"] = messages.Error(typ="dnssec", detail="detail") + self.dvsni_diff = achallenges.DVSNI( + challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + domain="foo.bar", + key=acme_util.KEY) + + @mock.patch("letsencrypt.auth_handler.zope.component.getUtility") + def test_same_error_and_domain(self, mock_zope): + from letsencrypt import auth_handler + + auth_handler._report_failed_challs([self.simple_http, self.dvsni_same]) + call_list = mock_zope().add_message.call_args_list + self.assertTrue(len(call_list) == 1) + self.assertTrue("Domains: example.com\n" in call_list[0][0][0]) + + @mock.patch("letsencrypt.auth_handler.zope.component.getUtility") + def test_different_errors_and_domains(self, mock_zope): + from letsencrypt import auth_handler + + auth_handler._report_failed_challs([self.simple_http, self.dvsni_diff]) + self.assertTrue(mock_zope().add_message.call_count == 2) + + def gen_auth_resp(chall_list): """Generate a dummy authorization response.""" return ["%s%s" % (chall.__class__.__name__, chall.domain) @@ -429,8 +478,8 @@ def gen_auth_resp(chall_list): def gen_dom_authzr(domain, unused_new_authzr_uri, challs): """Generates new authzr for domains.""" return acme_util.gen_authzr( - messages2.STATUS_PENDING, domain, challs, - [messages2.STATUS_PENDING]*len(challs)) + messages.STATUS_PENDING, domain, challs, + [messages.STATUS_PENDING]*len(challs)) if __name__ == "__main__": diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 7216acea7..9b7634e20 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -26,12 +26,13 @@ class ClientTest(unittest.TestCase): """Tests for letsencrypt.client.Client.""" def setUp(self): - self.config = mock.MagicMock(no_verify_ssl=False) + self.config = mock.MagicMock( + no_verify_ssl=False, config_dir="/etc/letsencrypt") # pylint: disable=star-args self.account = mock.MagicMock(**{"key.pem": KEY}) from letsencrypt.client import Client - with mock.patch("letsencrypt.client.network2.Network") as network: + with mock.patch("letsencrypt.client.network.Network") as network: self.client = Client( config=self.config, account_=self.account, dv_auth=None, installer=None) @@ -85,7 +86,6 @@ class ClientTest(unittest.TestCase): @mock.patch("letsencrypt.client.zope.component.getUtility") def test_report_new_account(self, mock_zope): # pylint: disable=protected-access - self.config.config_dir = "/usr/bin/coffee" self.account.recovery_token = "ECCENTRIC INVISIBILITY RHINOCEROS" self.account.email = "rhino@jungle.io" @@ -100,32 +100,33 @@ class ClientTest(unittest.TestCase): # pylint: disable=protected-access cert = mock.MagicMock() cert.configuration = configobj.ConfigObj() - cert.configuration["renewal_configs_dir"] = "/etc/letsencrypt/configs" + cert.cli_config = configuration.RenewerConfiguration(self.config) cert.configuration["autorenew"] = "True" cert.configuration["autodeploy"] = "True" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal and deployment has been" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) cert.configuration["autorenew"] = "False" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("deployment but not automatic renewal" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) cert.configuration["autodeploy"] = "False" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal and deployment has not" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) cert.configuration["autorenew"] = "True" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal but not automatic deployment" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) + class DetermineAccountTest(unittest.TestCase): """Tests for letsencrypt.client.determine_authenticator.""" diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py index d25368feb..faf7021be 100644 --- a/letsencrypt/tests/configuration_test.py +++ b/letsencrypt/tests/configuration_test.py @@ -9,10 +9,10 @@ class NamespaceConfigTest(unittest.TestCase): """Tests for letsencrypt.configuration.NamespaceConfig.""" def setUp(self): - from letsencrypt.configuration import NamespaceConfig self.namespace = mock.MagicMock( config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar', server='https://acme-server.org:443/new') + from letsencrypt.configuration import NamespaceConfig self.config = NamespaceConfig(self.namespace) def test_proxy_getattr(self): @@ -30,23 +30,51 @@ class NamespaceConfigTest(unittest.TestCase): @mock.patch('letsencrypt.configuration.constants') def test_dynamic_dirs(self, constants): - constants.TEMP_CHECKPOINT_DIR = 't' - constants.IN_PROGRESS_DIR = '../p' - constants.CERT_KEY_BACKUP_DIR = 'c/' - constants.REC_TOKEN_DIR = '/r' constants.ACCOUNTS_DIR = 'acc' constants.ACCOUNT_KEYS_DIR = 'keys' + constants.BACKUP_DIR = 'backups' + constants.CERT_KEY_BACKUP_DIR = 'c/' + constants.CERT_DIR = 'certs' + constants.IN_PROGRESS_DIR = '../p' + constants.KEY_DIR = 'keys' + constants.REC_TOKEN_DIR = '/r' + constants.TEMP_CHECKPOINT_DIR = 't' - self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') - self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') - self.assertEqual( - self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new') - self.assertEqual(self.config.rec_token_dir, '/r') self.assertEqual( self.config.accounts_dir, '/tmp/config/acc/acme-server.org:443/new') self.assertEqual( self.config.account_keys_dir, '/tmp/config/acc/acme-server.org:443/new/keys') + 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.in_progress_dir, '/tmp/foo/../p') + self.assertEqual(self.config.key_dir, '/tmp/config/keys') + self.assertEqual(self.config.rec_token_dir, '/r') + self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') + + +class RenewerConfigurationTest(unittest.TestCase): + """Test for letsencrypt.configuration.RenewerConfiguration.""" + + def setUp(self): + self.namespace = mock.MagicMock(config_dir='/tmp/config') + from letsencrypt.configuration import RenewerConfiguration + self.config = RenewerConfiguration(self.namespace) + + @mock.patch('letsencrypt.configuration.constants') + def test_dynamic_dirs(self, constants): + constants.ARCHIVE_DIR = "a" + constants.LIVE_DIR = 'l' + constants.RENEWAL_CONFIGS_DIR = "renewal_configs" + constants.RENEWER_CONFIG_FILENAME = 'r.conf' + + self.assertEqual(self.config.archive_dir, '/tmp/config/a') + self.assertEqual(self.config.live_dir, '/tmp/config/l') + self.assertEqual( + self.config.renewal_configs_dir, '/tmp/config/renewal_configs') + self.assertEqual(self.config.renewer_config_file, '/tmp/config/r.conf') if __name__ == '__main__': diff --git a/letsencrypt/tests/continuity_auth_test.py b/letsencrypt/tests/continuity_auth_test.py index 829af736d..509dc8bdf 100644 --- a/letsencrypt/tests/continuity_auth_test.py +++ b/letsencrypt/tests/continuity_auth_test.py @@ -58,7 +58,7 @@ class PerformTest(unittest.TestCase): def test_unexpected(self): self.assertRaises( - errors.LetsEncryptContAuthError, self.auth.perform, [ + errors.ContAuthError, self.auth.perform, [ achallenges.DVSNI(challb=None, domain="0", key="invalid_key")]) def test_chall_pref(self): @@ -91,8 +91,8 @@ class CleanupTest(unittest.TestCase): token = achallenges.RecoveryToken(challb=None, domain="0") unexpected = achallenges.DVSNI(challb=None, domain="0", key="dummy_key") - self.assertRaises(errors.LetsEncryptContAuthError, - self.auth.cleanup, [token, unexpected]) + self.assertRaises( + errors.ContAuthError, self.auth.cleanup, [token, unexpected]) def gen_client_resp(chall): diff --git a/letsencrypt/tests/display/enhancements_test.py b/letsencrypt/tests/display/enhancements_test.py index 54e27aa01..6375316bf 100644 --- a/letsencrypt/tests/display/enhancements_test.py +++ b/letsencrypt/tests/display/enhancements_test.py @@ -27,8 +27,7 @@ class AskTest(unittest.TestCase): self.assertTrue(self._call("redirect")) def test_key_error(self): - self.assertRaises( - errors.LetsEncryptClientError, self._call, "unknown_enhancement") + self.assertRaises(errors.Error, self._call, "unknown_enhancement") class RedirectTest(unittest.TestCase): diff --git a/letsencrypt/tests/errors_test.py b/letsencrypt/tests/errors_test.py new file mode 100644 index 000000000..a99d84719 --- /dev/null +++ b/letsencrypt/tests/errors_test.py @@ -0,0 +1,26 @@ +"""Tests for letsencrypt.errors.""" +import unittest + +from acme import messages + +from letsencrypt import achallenges +from letsencrypt.tests import acme_util + + +class FaiiledChallengesTest(unittest.TestCase): + """Tests for letsencrypt.errors.FailedChallenges.""" + + def setUp(self): + from letsencrypt.errors import FailedChallenges + self.error = FailedChallenges(set([achallenges.DNS( + domain="example.com", challb=messages.ChallengeBody( + chall=acme_util.DNS, uri=None, + error=messages.Error(typ="tls", detail="detail")))])) + + def test_str(self): + self.assertTrue(str(self.error).startswith( + "Failed authorization procedure. example.com (dns): tls")) + + +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 1ad6968a1..7ce619d95 100644 --- a/letsencrypt/tests/le_util_test.py +++ b/letsencrypt/tests/le_util_test.py @@ -44,8 +44,7 @@ class MakeOrVerifyDirTest(unittest.TestCase): self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o400) def test_existing_wrong_mode_fails(self): - self.assertRaises( - errors.LetsEncryptClientError, self._call, self.path, 0o600) + self.assertRaises(errors.Error, self._call, self.path, 0o600) def test_reraises_os_error(self): with mock.patch.object(os, 'makedirs') as makedirs: diff --git a/letsencrypt/tests/network_test.py b/letsencrypt/tests/network_test.py new file mode 100644 index 000000000..6acb11315 --- /dev/null +++ b/letsencrypt/tests/network_test.py @@ -0,0 +1,50 @@ +"""Tests for letsencrypt.network.""" +import shutil +import tempfile +import unittest + +import mock + +from letsencrypt import account + + +class NetworkTest(unittest.TestCase): + """Tests for letsencrypt.network.Network.""" + + def setUp(self): + from letsencrypt.network import Network + self.net = Network( + new_reg_uri=None, key=None, alg=None, verify_ssl=None) + + self.config = mock.Mock(accounts_dir=tempfile.mkdtemp()) + self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') + + def tearDown(self): + shutil.rmtree(self.config.accounts_dir) + + def test_register_from_account(self): + self.net.register = mock.Mock() + acc = account.Account( + self.config, 'key', email='cert-admin@example.com', + phone='+12025551212') + + self.net.register_from_account(acc) + + self.net.register.assert_called_with(contact=self.contact) + + def test_register_from_account_partial_info(self): + self.net.register = mock.Mock() + acc = account.Account( + self.config, 'key', email='cert-admin@example.com') + acc2 = account.Account(self.config, 'key') + + self.net.register_from_account(acc) + self.net.register.assert_called_with( + contact=('mailto:cert-admin@example.com',)) + + self.net.register_from_account(acc2) + self.net.register.assert_called_with(contact=()) + + +if __name__ == '__main__': + unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/proof_of_possession_test.py b/letsencrypt/tests/proof_of_possession_test.py index 0a044810c..415e4caed 100644 --- a/letsencrypt/tests/proof_of_possession_test.py +++ b/letsencrypt/tests/proof_of_possession_test.py @@ -8,7 +8,7 @@ import mock from acme import challenges from acme import jose -from acme import messages2 +from acme import messages from letsencrypt import achallenges from letsencrypt import proof_of_possession @@ -48,8 +48,8 @@ class ProofOfPossessionTest(unittest.TestCase): issuers=(), authorized_for=()) chall = challenges.ProofOfPossession( alg=jose.RS256, nonce='zczv4HMLVe_0kimJ25Juig', hints=hints) - challb = messages2.ChallengeBody( - chall=chall, uri="http://example", status=messages2.STATUS_PENDING) + challb = messages.ChallengeBody( + chall=chall, uri="http://example", status=messages.STATUS_PENDING) self.achall = achallenges.ProofOfPossession( challb=challb, domain="example.com") @@ -60,8 +60,8 @@ class ProofOfPossessionTest(unittest.TestCase): issuers=(), authorized_for=()) chall = challenges.ProofOfPossession( alg=jose.HS512, nonce='zczv4HMLVe_0kimJ25Juig', hints=hints) - challb = messages2.ChallengeBody( - chall=chall, uri="http://example", status=messages2.STATUS_PENDING) + challb = messages.ChallengeBody( + chall=chall, uri="http://example", status=messages.STATUS_PENDING) self.achall = achallenges.ProofOfPossession( challb=challb, domain="example.com") self.assertEqual(self.proof_of_pos.perform(self.achall), None) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 0f85674d4..d68078c18 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -10,6 +10,7 @@ import configobj import mock import pytz +from letsencrypt import configuration from letsencrypt.storage import ALL_FOUR @@ -31,22 +32,24 @@ class RenewableCertTests(unittest.TestCase): def setUp(self): from letsencrypt import storage self.tempdir = tempfile.mkdtemp() + + self.cli_config = configuration.RenewerConfiguration( + namespace=mock.MagicMock(config_dir=self.tempdir)) + # 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")) - defaults = configobj.ConfigObj() - defaults["live_dir"] = os.path.join(self.tempdir, "live") - defaults["archive_dir"] = os.path.join(self.tempdir, "archive") - defaults["renewal_configs_dir"] = os.path.join(self.tempdir, - "configs") + 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", "example.org.conf") - self.defaults = defaults # for main() test - self.test_rc = storage.RenewableCert(config, defaults) + + self.defaults = configobj.ConfigObj() + self.test_rc = storage.RenewableCert( + config, self.defaults, self.cli_config) def tearDown(self): shutil.rmtree(self.tempdir) @@ -457,60 +460,57 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage(self): """Test for new_lineage() class method.""" from letsencrypt import storage - config_dir = self.defaults["renewal_configs_dir"] - archive_dir = self.defaults["archive_dir"] - live_dir = self.defaults["live_dir"] - result = storage.RenewableCert.new_lineage("the-lineage.com", "cert", - "privkey", "chain", None, - self.defaults) + result = storage.RenewableCert.new_lineage( + "the-lineage.com", "cert", "privkey", "chain", None, + self.defaults, self.cli_config) # This consistency check tests most relevant properties about the # newly created cert lineage. self.assertTrue(result.consistent()) - self.assertTrue(os.path.exists(os.path.join(config_dir, - "the-lineage.com.conf"))) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.renewal_configs_dir, "the-lineage.com.conf"))) with open(result.fullchain) as f: self.assertEqual(f.read(), "cert" + "chain") # Let's do it again and make sure it makes a different lineage - result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", None, - self.defaults) - self.assertTrue(os.path.exists( - os.path.join(config_dir, "the-lineage.com-0001.conf"))) + result = storage.RenewableCert.new_lineage( + "the-lineage.com", "cert2", "privkey2", "chain2", None, + self.defaults, self.cli_config) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.renewal_configs_dir, "the-lineage.com-0001.conf"))) # Now trigger the detection of already existing files - os.mkdir(os.path.join(live_dir, "the-lineage.com-0002")) + os.mkdir(os.path.join( + self.cli_config.live_dir, "the-lineage.com-0002")) self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "the-lineage.com", "cert3", "privkey3", "chain3", - None, self.defaults) - os.mkdir(os.path.join(archive_dir, "other-example.com")) + None, self.defaults, self.cli_config) + os.mkdir(os.path.join(self.cli_config.archive_dir, "other-example.com")) self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "other-example.com", "cert4", "privkey4", "chain4", - None, self.defaults) + None, self.defaults, self.cli_config) # Make sure it can accept renewal parameters params = {"stuff": "properties of stuff", "great": "awesome"} - result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", - params, self.defaults) + result = storage.RenewableCert.new_lineage( + "the-lineage.com", "cert2", "privkey2", "chain2", + params, self.defaults, self.cli_config) # TODO: Conceivably we could test that the renewal parameters actually # got saved def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" from letsencrypt import storage - config_dir = self.defaults["renewal_configs_dir"] - archive_dir = self.defaults["archive_dir"] - live_dir = self.defaults["live_dir"] - shutil.rmtree(config_dir) - shutil.rmtree(archive_dir) - shutil.rmtree(live_dir) - storage.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", - None, self.defaults) + shutil.rmtree(self.cli_config.renewal_configs_dir) + shutil.rmtree(self.cli_config.archive_dir) + shutil.rmtree(self.cli_config.live_dir) + + storage.RenewableCert.new_lineage( + "the-lineage.com", "cert2", "privkey2", "chain2", + None, self.defaults, self.cli_config) self.assertTrue(os.path.exists( - os.path.join(config_dir, "the-lineage.com.conf"))) - self.assertTrue(os.path.exists( - os.path.join(live_dir, "the-lineage.com", "privkey.pem"))) - self.assertTrue(os.path.exists( - os.path.join(archive_dir, "the-lineage.com", "privkey1.pem"))) + os.path.join( + self.cli_config.renewal_configs_dir, "the-lineage.com.conf"))) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.live_dir, "the-lineage.com", "privkey.pem"))) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.archive_dir, "the-lineage.com", "privkey1.pem"))) @mock.patch("letsencrypt.storage.le_util.unique_lineage_name") def test_invalid_config_filename(self, mock_uln): @@ -518,7 +518,7 @@ class RenewableCertTests(unittest.TestCase): mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "example.com", "cert", "privkey", "chain", - None, self.defaults) + None, self.defaults, self.cli_config) def test_bad_kind(self): self.assertRaises(ValueError, self.test_rc.current_target, "elephant") @@ -602,22 +602,23 @@ class RenewableCertTests(unittest.TestCase): mock_rc_instance.should_autorenew.return_value = True mock_rc_instance.latest_common_version.return_value = 10 mock_rc.return_value = mock_rc_instance - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "README"), "w") as f: f.write("This is a README file to make sure that the renewer is") f.write("able to correctly ignore files that don't end in .conf.") - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "example.org.conf"), "w") as f: # This isn't actually parsed in this test; we have a separate # test_initialization that tests the initialization, assuming # that configobj can correctly parse the config file. f.write("cert = cert.pem\nprivkey = privkey.pem\n") f.write("chain = chain.pem\nfullchain = fullchain.pem\n") - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "example.com.conf"), "w") as f: f.write("cert = cert.pem\nprivkey = privkey.pem\n") f.write("chain = chain.pem\nfullchain = fullchain.pem\n") - renewer.main(self.defaults) + renewer.main(self.defaults, args=[ + '--config-dir', self.cli_config.config_dir]) self.assertEqual(mock_rc.call_count, 2) self.assertEqual(mock_rc_instance.update_all_links_to.call_count, 2) self.assertEqual(mock_notify.notify.call_count, 4) @@ -630,7 +631,8 @@ class RenewableCertTests(unittest.TestCase): mock_happy_instance.should_autorenew.return_value = False mock_happy_instance.latest_common_version.return_value = 10 mock_rc.return_value = mock_happy_instance - renewer.main(self.defaults) + renewer.main(self.defaults, args=[ + '--config-dir', self.cli_config.config_dir]) self.assertEqual(mock_rc.call_count, 4) self.assertEqual(mock_happy_instance.update_all_links_to.call_count, 0) self.assertEqual(mock_notify.notify.call_count, 4) @@ -638,10 +640,11 @@ class RenewableCertTests(unittest.TestCase): def test_bad_config_file(self): from letsencrypt import renewer - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "bad.conf"), "w") as f: f.write("incomplete = configfile\n") - renewer.main(self.defaults) + renewer.main(self.defaults, args=[ + '--config-dir', self.cli_config.config_dir]) # The ValueError is caught inside and nothing happens. diff --git a/letsencrypt/tests/reverter_test.py b/letsencrypt/tests/reverter_test.py index 9da584f58..dda867e4f 100644 --- a/letsencrypt/tests/reverter_test.py +++ b/letsencrypt/tests/reverter_test.py @@ -50,10 +50,9 @@ class ReverterCheckpointLocalTest(unittest.TestCase): def test_add_to_checkpoint_copy_failure(self): with mock.patch("letsencrypt.reverter.shutil.copy2") as mock_copy2: mock_copy2.side_effect = IOError("bad copy") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.add_to_checkpoint, - self.sets[0], - "save1") + self.assertRaises( + errors.ReverterError, self.reverter.add_to_checkpoint, + self.sets[0], "save1") def test_checkpoint_conflict(self): """Make sure that checkpoint errors are thrown appropriately.""" @@ -65,17 +64,14 @@ class ReverterCheckpointLocalTest(unittest.TestCase): # This shouldn't throw an error self.reverter.add_to_temp_checkpoint(self.sets[0], "save2") # Raise error - self.assertRaises( - errors.LetsEncryptReverterError, self.reverter.add_to_checkpoint, - self.sets[2], "save3") + self.assertRaises(errors.ReverterError, self.reverter.add_to_checkpoint, + self.sets[2], "save3") # Should not cause an error self.reverter.add_to_checkpoint(self.sets[1], "save4") # Check to make sure new files are also checked... - self.assertRaises( - errors.LetsEncryptReverterError, - self.reverter.add_to_checkpoint, - set([config3]), "invalid save") + self.assertRaises(errors.ReverterError, self.reverter.add_to_checkpoint, + set([config3]), "invalid save") def test_multiple_saves_and_temp_revert(self): self.reverter.add_to_temp_checkpoint(self.sets[0], "save1") @@ -120,65 +116,64 @@ class ReverterCheckpointLocalTest(unittest.TestCase): m_open = mock.mock_open() with mock.patch("letsencrypt.reverter.open", m_open, create=True): m_open.side_effect = OSError("bad open") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.register_file_creation, - True, self.config1) + self.assertRaises( + errors.ReverterError, self.reverter.register_file_creation, + True, self.config1) def test_bad_registration(self): # Made this mistake and want to make sure it doesn't happen again... - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.register_file_creation, - "filepath") + self.assertRaises( + errors.ReverterError, self.reverter.register_file_creation, + "filepath") def test_recovery_routine_in_progress_failure(self): self.reverter.add_to_checkpoint(self.sets[0], "perm save") # pylint: disable=protected-access self.reverter._recover_checkpoint = mock.MagicMock( - side_effect=errors.LetsEncryptReverterError) - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.recovery_routine) + side_effect=errors.ReverterError) + 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.LetsEncryptReverterError("e")) + side_effect=errors.ReverterError("e")) # pylint: disable=protected-access self.reverter._recover_checkpoint = mock_recover self.reverter.add_to_temp_checkpoint(self.sets[0], "config1 save") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.revert_temporary_config) + self.assertRaises( + errors.ReverterError, self.reverter.revert_temporary_config) def test_recover_checkpoint_rollback_failure(self): mock_recover = mock.MagicMock( - side_effect=errors.LetsEncryptReverterError("e")) + side_effect=errors.ReverterError("e")) # pylint: disable=protected-access self.reverter._recover_checkpoint = mock_recover self.reverter.add_to_checkpoint(self.sets[0], "config1 save") self.reverter.finalize_checkpoint("Title") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.rollback_checkpoints, 1) + self.assertRaises( + errors.ReverterError, self.reverter.rollback_checkpoints, 1) def test_recover_checkpoint_copy_failure(self): self.reverter.add_to_temp_checkpoint(self.sets[0], "save1") with mock.patch("letsencrypt.reverter.shutil.copy2") as mock_copy2: mock_copy2.side_effect = OSError("bad copy") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.revert_temporary_config) + self.assertRaises( + errors.ReverterError, self.reverter.revert_temporary_config) def test_recover_checkpoint_rm_failure(self): self.reverter.add_to_temp_checkpoint(self.sets[0], "temp save") with mock.patch("letsencrypt.reverter.shutil.rmtree") as mock_rmtree: mock_rmtree.side_effect = OSError("Cannot remove tree") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.revert_temporary_config) + self.assertRaises( + errors.ReverterError, self.reverter.revert_temporary_config) @mock.patch("letsencrypt.reverter.logging.warning") def test_recover_checkpoint_missing_new_files(self, mock_warn): @@ -191,8 +186,8 @@ class ReverterCheckpointLocalTest(unittest.TestCase): def test_recover_checkpoint_remove_failure(self, mock_remove): self.reverter.register_file_creation(True, self.config1) mock_remove.side_effect = OSError("Can't remove") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.revert_temporary_config) + self.assertRaises( + errors.ReverterError, self.reverter.revert_temporary_config) def test_recovery_routine_temp_and_perm(self): # Register a new perm checkpoint file @@ -251,14 +246,11 @@ class TestFullCheckpointsReverter(unittest.TestCase): def test_rollback_improper_inputs(self): self.assertRaises( - errors.LetsEncryptReverterError, - self.reverter.rollback_checkpoints, "-1") + errors.ReverterError, self.reverter.rollback_checkpoints, "-1") self.assertRaises( - errors.LetsEncryptReverterError, - self.reverter.rollback_checkpoints, -1000) + errors.ReverterError, self.reverter.rollback_checkpoints, -1000) self.assertRaises( - errors.LetsEncryptReverterError, - self.reverter.rollback_checkpoints, "one") + errors.ReverterError, self.reverter.rollback_checkpoints, "one") def test_rollback_finalize_checkpoint_valid_inputs(self): # pylint: disable=invalid-name @@ -299,9 +291,8 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.reverter.add_to_checkpoint(self.sets[0], "perm save") mock_move.side_effect = OSError("cannot move") - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.finalize_checkpoint, - "Title") + self.assertRaises( + errors.ReverterError, self.reverter.finalize_checkpoint, "Title") @mock.patch("letsencrypt.reverter.os.rename") def test_finalize_checkpoint_no_rename_directory(self, mock_rename): @@ -309,9 +300,8 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.reverter.add_to_checkpoint(self.sets[0], "perm save") mock_rename.side_effect = OSError - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.finalize_checkpoint, - "Title") + self.assertRaises( + errors.ReverterError, self.reverter.finalize_checkpoint, "Title") @mock.patch("letsencrypt.reverter.logging") def test_rollback_too_many(self, mock_logging): @@ -347,8 +337,8 @@ class TestFullCheckpointsReverter(unittest.TestCase): # It must just be clean checkpoints os.makedirs(os.path.join(self.config.backup_dir, "in_progress")) - self.assertRaises(errors.LetsEncryptReverterError, - self.reverter.view_config_changes) + self.assertRaises( + errors.ReverterError, self.reverter.view_config_changes) def _setup_three_checkpoints(self): """Generate some finalized checkpoints.""" diff --git a/letsencrypt/tests/revoker_test.py b/letsencrypt/tests/revoker_test.py index ae04b5081..490ff9f01 100644 --- a/letsencrypt/tests/revoker_test.py +++ b/letsencrypt/tests/revoker_test.py @@ -63,7 +63,7 @@ class RevokerTest(RevokerBase): def tearDown(self): shutil.rmtree(self.backup_dir) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_key_all(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -80,16 +80,14 @@ class RevokerTest(RevokerBase): @mock.patch("letsencrypt.revoker.Crypto.PublicKey.RSA.importKey") def test_revoke_by_invalid_keys(self, mock_import): mock_import.side_effect = ValueError - self.assertRaises(errors.LetsEncryptRevokerError, - self.revoker.revoke_from_key, - self.key) + self.assertRaises( + errors.RevokerError, self.revoker.revoke_from_key, self.key) mock_import.side_effect = [mock.Mock(), IndexError] - self.assertRaises(errors.LetsEncryptRevokerError, - self.revoker.revoke_from_key, - self.key) + self.assertRaises( + errors.RevokerError, self.revoker.revoke_from_key, self.key) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_wrong_key(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -105,7 +103,7 @@ class RevokerTest(RevokerBase): # No revocation went through self.assertEqual(mock_net.call_count, 0) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_cert(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -122,7 +120,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 1) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_cert_not_found(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -141,7 +139,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 1) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_menu(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -165,7 +163,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_display.more_info_cert.call_count, 1) @mock.patch("letsencrypt.revoker.logging") - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_menu_delete_all(self, mock_display, mock_net, mock_log): mock_display().confirm_revocation.return_value = True @@ -188,7 +186,7 @@ class RevokerTest(RevokerBase): @mock.patch("letsencrypt.revoker.logging") def test_safe_revoke_acme_fail(self, mock_log, mock_revoke, mock_display): # pylint: disable=protected-access - mock_revoke.side_effect = errors.LetsEncryptClientError + mock_revoke.side_effect = errors.Error mock_display().confirm_revocation.return_value = True self.revoker._safe_revoke(self.certs) @@ -198,9 +196,8 @@ class RevokerTest(RevokerBase): def test_acme_revoke_failure(self, mock_crypto): # pylint: disable=protected-access mock_crypto.side_effect = ValueError - self.assertRaises(errors.LetsEncryptClientError, - self.revoker._acme_revoke, - self.certs[0]) + self.assertRaises( + errors.Error, self.revoker._acme_revoke, self.certs[0]) def test_remove_certs_from_list_bad_certs(self): # pylint: disable=protected-access @@ -215,9 +212,8 @@ class RevokerTest(RevokerBase): new_cert.orig = Cert.PathStatus("false path", "not here") new_cert.orig_key = Cert.PathStatus("false path", "not here") - self.assertRaises(errors.LetsEncryptRevokerError, - self.revoker._remove_certs_from_list, - [new_cert]) + self.assertRaises(errors.RevokerError, + self.revoker._remove_certs_from_list, [new_cert]) def _backups_exist(self, row): # pylint: disable=protected-access @@ -330,7 +326,7 @@ class CertTest(unittest.TestCase): def test_failed_load(self): from letsencrypt.revoker import Cert - self.assertRaises(errors.LetsEncryptRevokerError, Cert, self.key_path) + self.assertRaises(errors.RevokerError, Cert, self.key_path) def test_no_row(self): self.assertEqual(self.certs[0].get_row(), None) diff --git a/letsencrypt_apache/configurator.py b/letsencrypt_apache/configurator.py index 40f570f80..ee522d781 100644 --- a/letsencrypt_apache/configurator.py +++ b/letsencrypt_apache/configurator.py @@ -18,6 +18,8 @@ from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import le_util +from letsencrypt.plugins import common + from letsencrypt_apache import constants from letsencrypt_apache import dvsni from letsencrypt_apache import obj @@ -87,8 +89,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def add_parser_arguments(cls, add): add("server-root", default=constants.CLI_DEFAULTS["server_root"], help="Apache server root directory.") - add("mod-ssl-conf", default=constants.CLI_DEFAULTS["mod_ssl_conf"], - help="Contains standard Apache SSL directives.") add("ctl", default=constants.CLI_DEFAULTS["ctl"], help="Path to the 'apache2ctl' binary, used for 'configtest' and " "retrieving Apache2 version number.") @@ -97,6 +97,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): add("init-script", default=constants.CLI_DEFAULTS["init_script"], help="Path to the Apache init script (used for server " "reload/restart).") + add("le-vhost-ext", default=constants.CLI_DEFAULTS["le_vhost_ext"], + help="SSL vhost configuration extension.") + def __init__(self, *args, **kwargs): """Initialize an Apache Configurator. @@ -123,10 +126,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.vhosts = None self._enhance_func = {"redirect": self._enable_redirect} + @property + def mod_ssl_conf(self): + """Full absolute path to SSL configuration file.""" + return os.path.join(self.config.config_dir, constants.MOD_SSL_CONF_DEST) + def prepare(self): """Prepare the authenticator/installer.""" self.parser = parser.ApacheParser( - self.aug, self.conf('server-root'), self.conf('mod-ssl-conf')) + self.aug, self.conf('server-root'), self.mod_ssl_conf) # Check for errors in parsing files with Augeas self.check_parsing_errors("httpd.aug") @@ -144,7 +152,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # on initialization self._prepare_server_https() - temp_install(self.conf('mod-ssl-conf')) + temp_install(self.mod_ssl_conf) def deploy_cert(self, domain, cert_path, key_path, chain_path=None): """Deploys certificate to specified virtual host. @@ -179,8 +187,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not path["cert_path"] or not path["cert_key"]: # Throw some can't find all of the directives error" logging.warn( - "Cannot find a cert or key directive in %s", vhost.path) - logging.warn("VirtualHost was not modified") + "Cannot find a cert or key directive in %s. " + "VirtualHost was not modified", vhost.path) # Presumably break here so that the virtualhost is not modified return False @@ -230,7 +238,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return vhost # Checking for domain name in vhost address # This technique is not recommended by Apache but is technically valid - target_addr = obj.Addr((target_name, "443")) + target_addr = common.Addr((target_name, "443")) for vhost in self.vhosts: if target_addr in vhost.addrs: self.assoc[target_name] = vhost @@ -321,7 +329,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): addrs = set() args = self.aug.match(path + "/arg") for arg in args: - addrs.add(obj.Addr.fromstring(self.aug.get(arg))) + addrs.add(common.Addr.fromstring(self.aug.get(arg))) is_ssl = False if self.parser.find_dir( @@ -406,8 +414,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Note: This could be made to also look for ip:443 combo # TODO: Need to search only open directives and IfMod mod_ssl.c if len(self.parser.find_dir(parser.case_i("Listen"), "443")) == 0: - logging.debug("No Listen 443 directive found") - logging.debug("Setting the Apache Server to Listen on port 443") + logging.debug("No Listen 443 directive found. Setting the " + "Apache Server to Listen on port 443") path = self.parser.add_dir_to_ifmodssl( parser.get_aug_path(self.parser.loc["listen"]), "Listen", "443") self.save_notes += "Added Listen 443 directive to %s\n" % path @@ -443,7 +451,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Makes an ssl_vhost version of a nonssl_vhost. Duplicates vhost and adds default ssl options - New vhost will reside as (nonssl_vhost.path) + ``IConfig.le_vhost_ext`` + New vhost will reside as (nonssl_vhost.path) + + ``letsencrypt_apache.constants.CLI_DEFAULTS["le_vhost_ext"]`` .. note:: This function saves the configuration @@ -457,9 +466,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): avail_fp = nonssl_vhost.filep # Get filepath of new ssl_vhost if avail_fp.endswith(".conf"): - ssl_fp = avail_fp[:-(len(".conf"))] + self.config.le_vhost_ext + ssl_fp = avail_fp[:-(len(".conf"))] + self.conf("le_vhost_ext") else: - ssl_fp = avail_fp + self.config.le_vhost_ext + ssl_fp = avail_fp + self.conf("le_vhost_ext") # First register the creation so that it is properly removed if # configuration is rolled back @@ -486,7 +495,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): addr_match % (ssl_fp, parser.case_i("VirtualHost"))) for addr in ssl_addr_p: - old_addr = obj.Addr.fromstring( + old_addr = common.Addr.fromstring( str(self.aug.get(addr))) ssl_addr = old_addr.get_addr_obj("443") self.aug.set(addr, str(ssl_addr)) @@ -552,9 +561,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return self._enhance_func[enhancement]( self.choose_vhost(domain), options) except ValueError: - raise errors.LetsEncryptConfiguratorError( + raise errors.ConfiguratorError( "Unsupported enhancement: {}".format(enhancement)) - except errors.LetsEncryptConfiguratorError: + except errors.ConfiguratorError: logging.warn("Failed %s for %s", enhancement, domain) def _enable_redirect(self, ssl_vhost, unused_options): @@ -600,7 +609,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return else: logging.info("Unknown redirect exists for this vhost") - raise errors.LetsEncryptConfiguratorError( + raise errors.ConfiguratorError( "Unknown redirect already exists " "in {}".format(general_v.filep)) # Add directives to server @@ -671,9 +680,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Make sure adding the vhost will be safe conflict, host_or_addrs = self._conflicting_host(ssl_vhost) if conflict: - raise errors.LetsEncryptConfiguratorError( - "Unable to create a redirection vhost " - "- {}".format(host_or_addrs)) + raise errors.ConfiguratorError( + "Unable to create a redirection vhost - {}".format( + host_or_addrs)) redirect_addrs = host_or_addrs @@ -789,8 +798,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Instead... should look for vhost of the form *:80 # Should we prompt the user? ssl_addrs = ssl_vhost.addrs - if ssl_addrs == obj.Addr.fromstring("_default_:443"): - ssl_addrs = [obj.Addr.fromstring("*:443")] + if ssl_addrs == common.Addr.fromstring("_default_:443"): + ssl_addrs = [common.Addr.fromstring("*:443")] for vhost in self.vhosts: found = 0 @@ -920,9 +929,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if proc.returncode != 0: # Enter recovery routine... - logging.error("Configtest failed") - logging.error(stdout) - logging.error(stderr) + logging.error("Configtest failed\n%s\n%s", stdout, stderr) return False return True @@ -951,8 +958,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :returns: version :rtype: tuple - :raises errors.LetsEncryptConfiguratorError: - Unable to find Apache version + :raises .ConfiguratorError: if unable to find Apache version """ try: @@ -962,15 +968,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): stderr=subprocess.PIPE) text = proc.communicate()[0] except (OSError, ValueError): - raise errors.LetsEncryptConfiguratorError( + raise errors.ConfiguratorError( "Unable to run %s -v" % self.conf('ctl')) regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) matches = regex.findall(text) if len(matches) != 1: - raise errors.LetsEncryptConfiguratorError( - "Unable to find Apache version") + raise errors.ConfiguratorError("Unable to find Apache version") return tuple([int(i) for i in matches[0].split(".")]) @@ -1052,9 +1057,8 @@ def enable_mod(mod_name, apache_init_script, apache_enmod): stdout=open("/dev/null", "w"), stderr=open("/dev/null", "w")) apache_restart(apache_init_script) - except (OSError, subprocess.CalledProcessError) as err: - logging.error("Error enabling mod_%s", mod_name) - logging.error("Exception: %s", err) + except (OSError, subprocess.CalledProcessError): + logging.exception("Error enabling mod_%s", mod_name) sys.exit(1) @@ -1080,12 +1084,11 @@ def mod_loaded(module, apache_ctl): except (OSError, ValueError): logging.error( "Error accessing %s for loaded modules!", apache_ctl) - raise errors.LetsEncryptConfiguratorError( - "Error accessing loaded modules") + raise errors.ConfiguratorError("Error accessing loaded modules") # Small errors that do not impede if proc.returncode != 0: logging.warn("Error in checking loaded module list: %s", stderr) - raise errors.LetsEncryptMisconfigurationError( + raise errors.MisconfigurationError( "Apache is unable to check whether or not the module is " "loaded because Apache is misconfigured.") @@ -1117,9 +1120,7 @@ def apache_restart(apache_init_script): if proc.returncode != 0: # Enter recovery routine... - logging.error("Apache Restart Failed!") - logging.error(stdout) - logging.error(stderr) + logging.error("Apache Restart Failed!\n%s\n%s", stdout, stderr) return False except (OSError, ValueError): @@ -1167,4 +1168,4 @@ def temp_install(options_ssl): # Check to make sure options-ssl.conf is installed if not os.path.isfile(options_ssl): - shutil.copyfile(constants.MOD_SSL_CONF, options_ssl) + shutil.copyfile(constants.MOD_SSL_CONF_SRC, options_ssl) diff --git a/letsencrypt_apache/constants.py b/letsencrypt_apache/constants.py index bcc3a09bd..cb75276b2 100644 --- a/letsencrypt_apache/constants.py +++ b/letsencrypt_apache/constants.py @@ -4,15 +4,17 @@ import pkg_resources CLI_DEFAULTS = dict( server_root="/etc/apache2", - mod_ssl_conf="/etc/letsencrypt/options-ssl-apache.conf", ctl="apache2ctl", enmod="a2enmod", init_script="/etc/init.d/apache2", + le_vhost_ext="-le-ssl.conf", ) """CLI defaults.""" +MOD_SSL_CONF_DEST = "options-ssl-apache.conf" +"""Name of the mod_ssl config file as saved in `IConfig.config_dir`.""" -MOD_SSL_CONF = pkg_resources.resource_filename( +MOD_SSL_CONF_SRC = pkg_resources.resource_filename( "letsencrypt_apache", "options-ssl-apache.conf") """Path to the Apache mod_ssl config file found in the Let's Encrypt distribution.""" diff --git a/letsencrypt_apache/dvsni.py b/letsencrypt_apache/dvsni.py index 6865afe26..5ff09aa50 100644 --- a/letsencrypt_apache/dvsni.py +++ b/letsencrypt_apache/dvsni.py @@ -2,10 +2,12 @@ import logging import os +from letsencrypt.plugins import common + from letsencrypt_apache import parser -class ApacheDvsni(object): +class ApacheDvsni(common.Dvsni): """Class performs DVSNI challenges within the Apache configurator. :ivar configurator: ApacheConfigurator object @@ -42,26 +44,6 @@ class ApacheDvsni(object): """ - def __init__(self, configurator): - self.configurator = configurator - self.achalls = [] - self.indices = [] - self.challenge_conf = os.path.join( - configurator.config.config_dir, "le_dvsni_cert_challenge.conf") - # self.completed = 0 - - def add_chall(self, achall, idx=None): - """Add challenge to DVSNI object to perform at once. - - :param achall: Annotated DVSNI challenge. - :type achall: :class:`letsencrypt.achallenges.DVSNI` - - :param int idx: index to challenge in a larger array - - """ - self.achalls.append(achall) - if idx is not None: - self.indices.append(idx) def perform(self): """Peform a DVSNI challenge.""" @@ -77,10 +59,9 @@ class ApacheDvsni(object): vhost = self.configurator.choose_vhost(achall.domain) if vhost is None: logging.error( - "No vhost exists with servername or alias of: %s", - achall.domain) - logging.error("No _default_:443 vhost exists") - logging.error("Please specify servernames in the Apache config") + "No vhost exists with servername or alias of: %s. " + "No _default_:443 vhost exists. Please specify servernames " + "in the Apache config", achall.domain) return None # TODO - @jdkasten review this code to make sure it makes sense @@ -107,28 +88,12 @@ class ApacheDvsni(object): return responses - def _setup_challenge_cert(self, achall, s=None): - # pylint: disable=invalid-name - """Generate and write out challenge certificate.""" - cert_path = self.get_cert_file(achall) - # Register the path before you write out the file - self.configurator.reverter.register_file_creation(True, cert_path) - - cert_pem, response = achall.gen_cert_and_response(s) - - # Write out challenge cert - with open(cert_path, "w") as cert_chall_fd: - cert_chall_fd.write(cert_pem) - - return response - def _mod_config(self, ll_addrs): """Modifies Apache config files to include challenge vhosts. Result: Apache config includes virtual servers for issued challs - :param list ll_addrs: list of list of - :class:`letsencrypt.plugins.apache.obj.Addr` to apply + :param list ll_addrs: list of list of `~.common.Addr` to apply """ # TODO: Use ip address of existing vhost instead of relying on FQDN @@ -167,7 +132,7 @@ class ApacheDvsni(object): :type achall: :class:`letsencrypt.achallenges.DVSNI` :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`~apache.obj.Addr` + :class:`list` of type `~.common.Addr` :returns: virtual host configuration text :rtype: str @@ -175,7 +140,7 @@ class ApacheDvsni(object): """ ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( - self.configurator.config.config_dir, "dvsni_page/") + self.configurator.config.work_dir, "dvsni_page/") # TODO: Python docs is not clear how mutliline string literal # newlines are parsed on different platforms. At least on # Linux (Debian sid), when source file uses CRLF, Python still @@ -186,16 +151,3 @@ class ApacheDvsni(object): ssl_options_conf_path=self.configurator.parser.loc["ssl_options"], cert_path=self.get_cert_file(achall), key_path=achall.key.file, document_root=document_root).replace("\n", os.linesep) - - def get_cert_file(self, achall): - """Returns standardized name for challenge certificate. - - :param achall: Annotated DVSNI challenge. - :type achall: :class:`letsencrypt.achallenges.DVSNI` - - :returns: certificate file name - :rtype: str - - """ - return os.path.join( - self.configurator.config.work_dir, achall.nonce_domain + ".crt") diff --git a/letsencrypt_apache/obj.py b/letsencrypt_apache/obj.py index 905e3f192..fecf46ff9 100644 --- a/letsencrypt_apache/obj.py +++ b/letsencrypt_apache/obj.py @@ -1,54 +1,13 @@ """Module contains classes used by the Apache Configurator.""" -class Addr(object): - r"""Represents an Apache VirtualHost address. - - :param str addr: addr part of vhost address - :param str port: port number or \*, or "" - - """ - def __init__(self, tup): - self.tup = tup - - @classmethod - def fromstring(cls, str_addr): - """Initialize Addr from string.""" - tup = str_addr.partition(':') - return cls((tup[0], tup[2])) - - def __str__(self): - if self.tup[1]: - return "%s:%s" % self.tup - return self.tup[0] - - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.tup == other.tup - return False - - def __hash__(self): - return hash(self.tup) - - def get_addr(self): - """Return addr part of Addr object.""" - return self.tup[0] - - def get_port(self): - """Return port.""" - return self.tup[1] - - def get_addr_obj(self, port): - """Return new address object with same addr and new port.""" - return self.__class__((self.tup[0], port)) - - class VirtualHost(object): # pylint: disable=too-few-public-methods """Represents an Apache Virtualhost. :ivar str filep: file path of VH :ivar str path: Augeas path to virtual host - :ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`) + :ivar set addrs: Virtual Host addresses (:class:`set` of + :class:`common.Addr`) :ivar set names: Server names/aliases of vhost (:class:`list` of :class:`str`) diff --git a/letsencrypt_apache/parser.py b/letsencrypt_apache/parser.py index 9e6e9efe6..5483b96ba 100644 --- a/letsencrypt_apache/parser.py +++ b/letsencrypt_apache/parser.py @@ -347,8 +347,7 @@ class ApacheParser(object): if os.path.isfile(os.path.join(self.root, name)): return os.path.join(self.root, name) - raise errors.LetsEncryptNoInstallationError( - "Could not find configuration root") + raise errors.NoInstallationError("Could not find configuration root") def _set_user_config_file(self, root): """Set the appropriate user configuration file diff --git a/letsencrypt_apache/tests/configurator_test.py b/letsencrypt_apache/tests/configurator_test.py index 11b88f9e5..cfd8c0574 100644 --- a/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt_apache/tests/configurator_test.py @@ -12,10 +12,11 @@ from letsencrypt import achallenges from letsencrypt import errors from letsencrypt import le_util +from letsencrypt.plugins import common + from letsencrypt.tests import acme_util from letsencrypt_apache import configurator -from letsencrypt_apache import obj from letsencrypt_apache import parser from letsencrypt_apache.tests import util @@ -31,8 +32,7 @@ class TwoVhost80Test(util.ApacheTest): "mod_loaded") as mock_load: mock_load.return_value = True self.config = util.get_apache_configurator( - self.config_path, self.config_dir, self.work_dir, - self.ssl_options) + self.config_path, self.config_dir, self.work_dir) self.vh_truth = util.get_vh_truth( self.temp_dir, "debian_apache_2_4/two_vhost_80") @@ -112,7 +112,7 @@ class TwoVhost80Test(util.ApacheTest): self.vh_truth[1].filep) def test_is_name_vhost(self): - addr = obj.Addr.fromstring("*:80") + addr = common.Addr.fromstring("*:80") self.assertTrue(self.config.is_name_vhost(addr)) self.config.version = (2, 2) self.assertFalse(self.config.is_name_vhost(addr)) @@ -133,7 +133,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(ssl_vhost.path, "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") self.assertEqual(len(ssl_vhost.addrs), 1) - self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs) + self.assertEqual(set([common.Addr.fromstring("*:443")]), ssl_vhost.addrs) self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) self.assertTrue(ssl_vhost.ssl) self.assertFalse(ssl_vhost.enabled) @@ -196,17 +196,14 @@ class TwoVhost80Test(util.ApacheTest): mock_popen().communicate.return_value = ( "Server Version: Apache (Debian)", "") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) + self.assertRaises(errors.ConfiguratorError, self.config.get_version) mock_popen().communicate.return_value = ( "Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) + self.assertRaises(errors.ConfiguratorError, self.config.get_version) mock_popen.side_effect = OSError("Can't find program") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) + self.assertRaises(errors.ConfiguratorError, self.config.get_version) if __name__ == "__main__": diff --git a/letsencrypt_apache/tests/dvsni_test.py b/letsencrypt_apache/tests/dvsni_test.py index 321dce42c..27e9b2584 100644 --- a/letsencrypt_apache/tests/dvsni_test.py +++ b/letsencrypt_apache/tests/dvsni_test.py @@ -1,5 +1,4 @@ """Test for letsencrypt_apache.dvsni.""" -import pkg_resources import unittest import shutil @@ -7,18 +6,17 @@ import mock from acme import challenges -from letsencrypt import achallenges -from letsencrypt import le_util +from letsencrypt.plugins import common +from letsencrypt.plugins import common_test -from letsencrypt.tests import acme_util - -from letsencrypt_apache import obj from letsencrypt_apache.tests import util class DvsniPerformTest(util.ApacheTest): """Test the ApacheDVSNI challenge.""" + achalls = common_test.DvsniTest.achalls + def setUp(self): super(DvsniPerformTest, self).setUp() @@ -26,38 +24,11 @@ class DvsniPerformTest(util.ApacheTest): "mod_loaded") as mock_load: mock_load.return_value = True config = util.get_apache_configurator( - self.config_path, self.config_dir, self.work_dir, - self.ssl_options) + self.config_path, self.config_dir, self.work_dir) from letsencrypt_apache import dvsni self.sni = dvsni.ApacheDvsni(config) - rsa256_file = pkg_resources.resource_filename( - "acme.jose", "testdata/rsa256_key.pem") - rsa256_pem = pkg_resources.resource_string( - "acme.jose", "testdata/rsa256_key.pem") - - auth_key = le_util.Key(rsa256_file, rsa256_pem) - self.achalls = [ - achallenges.DVSNI( - challb=acme_util.chall_to_challb( - challenges.DVSNI( - r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9" - "\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", - nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", - ), "pending"), - domain="encryption-example.demo", key=auth_key), - achallenges.DVSNI( - challb=acme_util.chall_to_challb( - challenges.DVSNI( - r="\xba\xa9\xda? +# include_package_data=True, +# File "/opt/python/2.6.9/lib/python2.6/distutils/core.py", line 152, in setup +# dist.run_commands() +# File "/opt/python/2.6.9/lib/python2.6/distutils/dist.py", line 975, in run_commands +# self.run_command(cmd) +# File "/opt/python/2.6.9/lib/python2.6/distutils/dist.py", line 995, in run_command +# cmd_obj.run() +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 142, in run +# self.with_project_on_sys_path(self.run_tests) +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 122, in with_project_on_sys_path +# func() +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 163, in run_tests +# testRunner=self._resolve_as_ep(self.test_runner), +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 816, in __init__ +# self.parseArgs(argv) +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 843, in parseArgs +# self.createTests() +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 849, in createTests +# self.module) +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 613, in loadTestsFromNames +# suites = [self.loadTestsFromName(name, module) for name in names] +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 587, in loadTestsFromName +# return self.loadTestsFromModule(obj) +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 37, in loadTestsFromModule +# tests.append(self.loadTestsFromName(submodule)) +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 584, in loadTestsFromName +# parent, obj = obj, getattr(obj, part) +#AttributeError: 'module' object has no attribute 'continuity_auth' + +# the above error happens because letsencrypt.continuity_auth cannot import M2Crypto: + +#>>> import M2Crypto +#Traceback (most recent call last): +# File "", line 1, in +# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/__init__.py", line 22, in +# import m2crypto +# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/m2crypto.py", line 26, in +# _m2crypto = swig_import_helper() +# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/m2crypto.py", line 22, in swig_import_helper +# _mod = imp.load_module('_m2crypto', fp, pathname, description) +#ImportError: /root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/_m2crypto.so: undefined symbol: SSLv2_method + +# For more info see: + +# - https://github.com/martinpaljak/M2Crypto/commit/84977c532c2444c5487db57146d81bb68dd5431d +# - http://stackoverflow.com/questions/10547332/install-m2crypto-on-a-virtualenv-without-system-packages +# - http://stackoverflow.com/questions/8206546/undefined-symbol-sslv2-method + +# In short: Python has been built without SSLv2 support, and +# github.com/M2Crypto/M2Crypto version doesn't contain necessary +# patch, but it's the only one that has a patch for newer versions of +# swig... + +# Problem seems not exists on Python 2.7. It's unlikely that the +# target distribution has swig 3.0.5+ and doesn't have Python 2.7, so +# this file should only be used in conjuction with Python 2.6. diff --git a/requirements.txt b/requirements.txt index 0f0223dab..972e87eaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ # https://github.com/bw2/ConfigArgParse/issues/17 --e git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse --e . +git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse diff --git a/setup.py b/setup.py index 145b75a69..ef819f50b 100644 --- a/setup.py +++ b/setup.py @@ -28,11 +28,65 @@ meta = dict(re.findall(r"""__([a-z]+)__ = "([^"]+)""", read_file(init_fn))) readme = read_file(os.path.join(here, 'README.rst')) changes = read_file(os.path.join(here, 'CHANGES.rst')) +# #358: acme, letsencrypt, letsencrypt_apache, letsencrypt_nginx, etc. +# shall be distributed separately. Please make sure to keep the +# dependecy lists up to date: this is being somewhat checked below +# using an assert statement! Separate lists are helpful for OS package +# maintainers. and will make the future migration a lot easier. +acme_install_requires = [ + 'argparse', + #'letsencrypt' # TODO: uses testdata vectors + 'mock', + 'pycrypto', + 'pyrfc3339', + 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) + 'pyasn1', # urllib3 InsecurePlatformWarning (#304) + 'pytz', + 'requests', + 'werkzeug', + 'M2Crypto', +] +letsencrypt_install_requires = [ + #'acme', + 'argparse', + 'ConfigArgParse', + 'configobj', + 'M2Crypto', + 'mock', + 'parsedatetime', + 'psutil>=2.1.0', # net_connections introduced in 2.1.0 + 'pycrypto', + # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509Req.get_extensions + 'PyOpenSSL>=0.15', + 'pyrfc3339', + 'python-augeas', + 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 + 'pytz', + 'requests', + 'zope.component', + 'zope.interface', + 'M2Crypto', +] +letsencrypt_apache_install_requires = [ + #'acme', + #'letsencrypt', + 'mock', + 'python-augeas', + 'zope.component', + 'zope.interface', +] +letsencrypt_nginx_install_requires = [ + #'acme', + #'letsencrypt', + 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? + 'mock', + 'zope.interface', +] + install_requires = [ 'argparse', 'ConfigArgParse', 'configobj', - 'jsonschema', 'mock', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'parsedatetime', @@ -55,6 +109,13 @@ install_requires = [ 'M2Crypto', ] +assert set(install_requires) == set.union(*(set(ireq) for ireq in ( + acme_install_requires, + letsencrypt_install_requires, + letsencrypt_apache_install_requires, + letsencrypt_nginx_install_requires +))), "*install_requires don't match up!" + dev_extras = [ # Pin astroid==1.3.5, pylint==1.4.2 as a workaround for #289 'astroid==1.3.5', @@ -120,6 +181,7 @@ setup( 'jws = letsencrypt.acme.jose.jws:CLI.run', ], 'letsencrypt.plugins': [ + 'manual = letsencrypt.plugins.manual:ManualAuthenticator', 'standalone = letsencrypt.plugins.standalone.authenticator' ':StandaloneAuthenticator', diff --git a/tox.cover.sh b/tox.cover.sh index 80b6474d7..172d2e05b 100755 --- a/tox.cover.sh +++ b/tox.cover.sh @@ -15,8 +15,10 @@ cover () { "$1" --cover-min-percentage="$2" "$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 95 && cover acme 100 && \ - cover letsencrypt_apache 78 && cover letsencrypt_nginx 96 + cover letsencrypt_apache 76 && cover letsencrypt_nginx 96 diff --git a/tox.ini b/tox.ini index 0367b5498..aed60f454 100644 --- a/tox.ini +++ b/tox.ini @@ -22,12 +22,12 @@ setenv = [testenv:cover] basepython = python2.7 commands = - pip install -e .[testing] + pip install -r requirements.txt -e .[testing] ./tox.cover.sh [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) basepython = python2.7 commands = - pip install -e .[dev] + pip install -r requirements.txt -e .[dev] pylint --rcfile=.pylintrc letsencrypt acme letsencrypt_apache letsencrypt_nginx