diff --git a/.travis.yml b/.travis.yml index 86a0d3e7d..96e28b1b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,12 @@ language: python services: - rabbitmq + - mariadb # http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS # gimme has to be kept in sync with Boulder's Go version setting in .travis.yml before_install: - - sudo apt-get install -y mariadb-server mariadb-server-10.0 + - 'dpkg -s libaugeas0' - '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || eval "$(gimme 1.5.1)"' # using separate envs with different TOXENVs creates 4x1 Travis build @@ -31,9 +32,8 @@ branches: - master - /^test-.*$/ -# enable Trusty beta on travis -sudo: required -dist: trusty +# container-based infrastructure +sudo: false addons: # make sure simplehttp simple verification works (custom /etc/hosts) @@ -41,6 +41,8 @@ addons: - le.wtf mariadb: "10.0" apt: + sources: + - augeas packages: # keep in sync with bootstrap/ubuntu.sh and Boulder - python - python-dev diff --git a/Dockerfile b/Dockerfile index d7aca784b..02aa0f0d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,8 @@ WORKDIR /opt/letsencrypt # If doesn't exist, it is created along with all missing # directories in its path. -COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ + +COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ubuntu.sh RUN /opt/letsencrypt/src/ubuntu.sh && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* \ diff --git a/Dockerfile-dev b/Dockerfile-dev index 028366b2c..838b60e8b 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -22,7 +22,7 @@ WORKDIR /opt/letsencrypt # TODO: Install non-default Python versions for tox. # TODO: Install Apache/Nginx for plugin development. -COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ +COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ubuntu.sh RUN /opt/letsencrypt/src/ubuntu.sh && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* \ diff --git a/README.rst b/README.rst index d3e89c939..ce0d1b686 100644 --- a/README.rst +++ b/README.rst @@ -35,11 +35,11 @@ It's all automated: All you need to do to sign a single domain is:: - user@www:~$ sudo letsencrypt -d www.example.org auth + user@www:~$ sudo letsencrypt -d www.example.org certonly For multiple domains (SAN) use:: - user@www:~$ sudo letsencrypt -d www.example.org -d example.org auth + user@www:~$ sudo letsencrypt -d www.example.org -d example.org certonly and if you have a compatible web server (Apache or Nginx), Let's Encrypt can not only get a new certificate, but also deploy it and configure your diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index c5855a7ca..976d7ab12 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -187,7 +187,7 @@ class KeyAuthorizationChallenge(_TokenDVChallenge): key_authorization=self.key_authorization(account_key)) @abc.abstractmethod - def validation(self, account_key): + def validation(self, account_key, **kwargs): """Generate validation for the challenge. Subclasses must implement this method, but they are likely to @@ -201,7 +201,7 @@ class KeyAuthorizationChallenge(_TokenDVChallenge): """ raise NotImplementedError() # pragma: no cover - def response_and_validation(self, account_key): + def response_and_validation(self, account_key, *args, **kwargs): """Generate response and validation. Convenience function that return results of `response` and @@ -211,7 +211,8 @@ class KeyAuthorizationChallenge(_TokenDVChallenge): :rtype: tuple """ - return (self.response(account_key), self.validation(account_key)) + return (self.response(account_key), + self.validation(account_key, *args, **kwargs)) @ChallengeResponse.register @@ -220,6 +221,12 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): typ = "http-01" PORT = 80 + """Verification port as defined by the protocol. + + You can override it (e.g. for testing) by passing ``port`` to + `simple_verify`. + + """ def simple_verify(self, chall, domain, account_public_key, port=None): """Simple verify. @@ -246,7 +253,7 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): # request URI, if it's standard. if port is not None and port != self.PORT: logger.warning( - "Using non-standard port for SimpleHTTP verification: %s", port) + "Using non-standard port for http-01 verification: %s", port) domain += ":{0}".format(port) uri = chall.uri(domain) @@ -308,7 +315,7 @@ class HTTP01(KeyAuthorizationChallenge): """ return "http://" + domain + self.path - def validation(self, account_key): + def validation(self, account_key, **unused_kwargs): """Generate validation. :param JWK account_key: @@ -318,89 +325,50 @@ class HTTP01(KeyAuthorizationChallenge): return self.key_authorization(account_key) -@Challenge.register # pylint: disable=too-many-ancestors -class DVSNI(_TokenDVChallenge): - """ACME "dvsni" challenge. - - :ivar bytes token: Random data, **not** base64-encoded. - - """ - typ = "dvsni" - - PORT = 443 - """Port to perform DVSNI challenge.""" - - def gen_response(self, account_key, alg=jose.RS256, **kwargs): - """Generate response. - - :param .JWK account_key: Private account key. - :rtype: .DVSNIResponse - - """ - return DVSNIResponse(validation=jose.JWS.sign( - payload=self.json_dumps(sort_keys=True).encode('utf-8'), - key=account_key, alg=alg, **kwargs)) - - @ChallengeResponse.register -class DVSNIResponse(ChallengeResponse): - """ACME "dvsni" challenge response. - - :param bytes s: Random data, **not** base64-encoded. - - """ - typ = "dvsni" +class TLSSNI01Response(KeyAuthorizationChallengeResponse): + """ACME tls-sni-01 challenge response.""" + typ = "tls-sni-01" DOMAIN_SUFFIX = b".acme.invalid" """Domain name suffix.""" - PORT = DVSNI.PORT - """Port to perform DVSNI challenge.""" + PORT = 443 + """Verification port as defined by the protocol. - validation = jose.Field("validation", decoder=jose.JWS.from_json) + You can override it (e.g. for testing) by passing ``port`` to + `simple_verify`. + + """ @property - def z(self): # pylint: disable=invalid-name - """The ``z`` parameter. + def z(self): + """``z`` value used for verification. - :rtype: bytes + :rtype bytes: """ - # Instance of 'Field' has no 'signature' member - # pylint: disable=no-member - return hashlib.sha256(self.validation.signature.encode( - "signature").encode("utf-8")).hexdigest().encode() + return hashlib.sha256( + self.key_authorization.encode("utf-8")).hexdigest().lower().encode() @property def z_domain(self): - """Domain name for certificate subjectAltName. + """Domain name used for verification, generated from `z`. - :rtype: bytes + :rtype bytes: """ - z = self.z # pylint: disable=invalid-name - return z[:32] + b'.' + z[32:] + self.DOMAIN_SUFFIX - - @property - def chall(self): - """Get challenge encoded in the `validation` payload. - - :rtype: challenges.DVSNI - - """ - # pylint: disable=no-member - return DVSNI.json_loads(self.validation.payload.decode('utf-8')) + return self.z[:32] + b'.' + self.z[32:] + self.DOMAIN_SUFFIX def gen_cert(self, key=None, bits=2048): - """Generate DVSNI certificate. + """Generate tls-sni-01 certificate. :param OpenSSL.crypto.PKey key: Optional private key used in certificate generation. If not provided (``None``), then fresh key will be generated. :param int bits: Number of bits for newly generated key. - :rtype: `tuple` of `OpenSSL.crypto.X509` and - `OpenSSL.crypto.PKey` + :rtype: `tuple` of `OpenSSL.crypto.X509` and `OpenSSL.crypto.PKey` """ if key is None: @@ -411,11 +379,12 @@ class DVSNIResponse(ChallengeResponse): 'dummy', self.z_domain.decode()], force_san=True), key def probe_cert(self, domain, **kwargs): - """Probe DVSNI challenge certificate. + """Probe tls-sni-01 challenge certificate. :param unicode domain: """ + # TODO: domain is not necessary if host is provided if "host" not in kwargs: host = socket.gethostbyname(domain) logging.debug('%s resolved to %s', domain, host) @@ -428,7 +397,7 @@ class DVSNIResponse(ChallengeResponse): return crypto_util.probe_sni(**kwargs) def verify_cert(self, cert): - """Verify DVSNI challenge certificate.""" + """Verify tls-sni-01 challenge certificate.""" # pylint: disable=protected-access sans = crypto_util._pyopenssl_cert_or_req_san(cert) logging.debug('Certificate %s. SANs: %s', cert.digest('sha1'), sans) @@ -439,14 +408,15 @@ class DVSNIResponse(ChallengeResponse): """Simple verify. Verify ``validation`` using ``account_public_key``, optionally - probe DVSNI certificate and check using `verify_cert`. + probe tls-sni-01 certificate and check using `verify_cert`. - :param .challenges.DVSNI chall: Corresponding challenge. + :param .challenges.TLSSNI01 chall: Corresponding challenge. :param str domain: Domain name being validated. :param JWK account_public_key: :param OpenSSL.crypto.X509 cert: Optional certificate. If not provided (``None``) certificate will be retrieved using `probe_cert`. + :param int port: Port used to probe the certificate. :returns: ``True`` iff client's control of the domain has been @@ -454,20 +424,8 @@ class DVSNIResponse(ChallengeResponse): :rtype: bool """ - # pylint: disable=no-member - if not self.validation.verify(key=account_public_key): - return False - - # TODO: it's not checked that payload has exectly 2 fields! - try: - decoded_chall = self.chall - except jose.DeserializationError as error: - logger.debug(error, exc_info=True) - return False - - if decoded_chall.token != chall.token: - logger.debug("Wrong token: expected %r, found %r", - chall.token, decoded_chall.token) + if not self.verify(chall, account_public_key): + logger.debug("Verification of key authorization in response failed") return False if cert is None: @@ -480,6 +438,29 @@ class DVSNIResponse(ChallengeResponse): return self.verify_cert(cert) +@Challenge.register # pylint: disable=too-many-ancestors +class TLSSNI01(KeyAuthorizationChallenge): + """ACME tls-sni-01 challenge.""" + response_cls = TLSSNI01Response + typ = response_cls.typ + + # boulder#962, ietf-wg-acme#22 + #n = jose.Field("n", encoder=int, decoder=int) + + def validation(self, account_key, **kwargs): + """Generate validation. + + :param JWK account_key: + :param OpenSSL.crypto.PKey cert_key: Optional private key used + in certificate generation. If not provided (``None``), then + fresh key will be generated. + + :rtype: `tuple` of `OpenSSL.crypto.X509` and `OpenSSL.crypto.PKey` + + """ + return self.response(account_key).gen_cert(key=kwargs.get('cert_key')) + + @Challenge.register class RecoveryContact(ContinuityChallenge): """ACME "recoveryContact" challenge. diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index 0708f3782..c4f3d6c61 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -186,14 +186,112 @@ class HTTP01Test(unittest.TestCase): self.msg.update(token=b'..').good_token) -class DVSNITest(unittest.TestCase): +class TLSSNI01ResponseTest(unittest.TestCase): + # pylint: disable=too-many-instance-attributes def setUp(self): - from acme.challenges import DVSNI - self.msg = DVSNI( + from acme.challenges import TLSSNI01 + self.chall = TLSSNI01( + token=jose.b64decode(b'a82d5ff8ef740d12881f6d3c2277ab2e')) + + self.response = self.chall.response(KEY) + self.jmsg = { + 'resource': 'challenge', + 'type': 'tls-sni-01', + 'keyAuthorization': self.response.key_authorization, + } + + # pylint: disable=invalid-name + label1 = b'dc38d9c3fa1a4fdcc3a5501f2d38583f' + label2 = b'b7793728f084394f2a1afd459556bb5c' + self.z = label1 + label2 + self.z_domain = label1 + b'.' + label2 + b'.acme.invalid' + self.domain = 'foo.com' + + def test_z_and_domain(self): + self.assertEqual(self.z, self.response.z) + self.assertEqual(self.z_domain, self.response.z_domain) + + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.response.to_partial_json()) + + def test_from_json(self): + from acme.challenges import TLSSNI01Response + self.assertEqual(self.response, TLSSNI01Response.from_json(self.jmsg)) + + def test_from_json_hashable(self): + from acme.challenges import TLSSNI01Response + hash(TLSSNI01Response.from_json(self.jmsg)) + + @mock.patch('acme.challenges.socket.gethostbyname') + @mock.patch('acme.challenges.crypto_util.probe_sni') + def test_probe_cert(self, mock_probe_sni, mock_gethostbyname): + mock_gethostbyname.return_value = '127.0.0.1' + self.response.probe_cert('foo.com') + mock_gethostbyname.assert_called_once_with('foo.com') + mock_probe_sni.assert_called_once_with( + host='127.0.0.1', port=self.response.PORT, + name=self.z_domain) + + self.response.probe_cert('foo.com', host='8.8.8.8') + mock_probe_sni.assert_called_with( + host='8.8.8.8', port=mock.ANY, name=mock.ANY) + + self.response.probe_cert('foo.com', port=1234) + mock_probe_sni.assert_called_with( + host=mock.ANY, port=1234, name=mock.ANY) + + self.response.probe_cert('foo.com', bar='baz') + mock_probe_sni.assert_called_with( + host=mock.ANY, port=mock.ANY, name=mock.ANY, bar='baz') + + self.response.probe_cert('foo.com', name=b'xxx') + mock_probe_sni.assert_called_with( + host=mock.ANY, port=mock.ANY, + name=self.z_domain) + + def test_gen_verify_cert(self): + key1 = test_util.load_pyopenssl_private_key('rsa512_key.pem') + cert, key2 = self.response.gen_cert(key1) + self.assertEqual(key1, key2) + self.assertTrue(self.response.verify_cert(cert)) + + def test_gen_verify_cert_gen_key(self): + cert, key = self.response.gen_cert() + self.assertTrue(isinstance(key, OpenSSL.crypto.PKey)) + self.assertTrue(self.response.verify_cert(cert)) + + def test_verify_bad_cert(self): + self.assertFalse(self.response.verify_cert( + test_util.load_cert('cert.pem'))) + + def test_simple_verify_bad_key_authorization(self): + key2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem')) + self.response.simple_verify(self.chall, "local", key2.public_key()) + + @mock.patch('acme.challenges.TLSSNI01Response.verify_cert', autospec=True) + def test_simple_verify(self, mock_verify_cert): + mock_verify_cert.return_value = mock.sentinel.verification + self.assertEqual(mock.sentinel.verification, self.response.simple_verify( + self.chall, self.domain, KEY.public_key(), + cert=mock.sentinel.cert)) + mock_verify_cert.assert_called_once_with(self.response, mock.sentinel.cert) + + @mock.patch('acme.challenges.TLSSNI01Response.probe_cert') + def test_simple_verify_false_on_probe_error(self, mock_probe_cert): + mock_probe_cert.side_effect = errors.Error + self.assertFalse(self.response.simple_verify( + self.chall, self.domain, KEY.public_key())) + + +class TLSSNI01Test(unittest.TestCase): + + def setUp(self): + from acme.challenges import TLSSNI01 + self.msg = TLSSNI01( token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e')) self.jmsg = { - 'type': 'dvsni', + 'type': 'tls-sni-01', 'token': 'a82d5ff8ef740d12881f6d3c2277ab2e', } @@ -201,144 +299,25 @@ class DVSNITest(unittest.TestCase): self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): - from acme.challenges import DVSNI - self.assertEqual(self.msg, DVSNI.from_json(self.jmsg)) + from acme.challenges import TLSSNI01 + self.assertEqual(self.msg, TLSSNI01.from_json(self.jmsg)) def test_from_json_hashable(self): - from acme.challenges import DVSNI - hash(DVSNI.from_json(self.jmsg)) + from acme.challenges import TLSSNI01 + hash(TLSSNI01.from_json(self.jmsg)) def test_from_json_invalid_token_length(self): - from acme.challenges import DVSNI + from acme.challenges import TLSSNI01 self.jmsg['token'] = jose.encode_b64jose(b'abcd') self.assertRaises( - jose.DeserializationError, DVSNI.from_json, self.jmsg) + jose.DeserializationError, TLSSNI01.from_json, self.jmsg) - def test_gen_response(self): - from acme.challenges import DVSNI - self.assertEqual(self.msg, DVSNI.json_loads( - self.msg.gen_response(KEY).validation.payload.decode())) - - -class DVSNIResponseTest(unittest.TestCase): - # pylint: disable=too-many-instance-attributes - - def setUp(self): - from acme.challenges import DVSNI - self.chall = DVSNI( - token=jose.b64decode(b'a82d5ff8ef740d12881f6d3c2277ab2e')) - - from acme.challenges import DVSNIResponse - self.validation = jose.JWS.sign( - payload=self.chall.json_dumps(sort_keys=True).encode(), - key=KEY, alg=jose.RS256) - self.msg = DVSNIResponse(validation=self.validation) - self.jmsg_to = { - 'resource': 'challenge', - 'type': 'dvsni', - 'validation': self.validation, - } - self.jmsg_from = { - 'resource': 'challenge', - 'type': 'dvsni', - 'validation': self.validation.to_json(), - } - - # pylint: disable=invalid-name - label1 = b'e2df3498860637c667fedadc5a8494ec' - label2 = b'09dcc75553c9b3bd73662b50e71b1e42' - self.z = label1 + label2 - self.z_domain = label1 + b'.' + label2 + b'.acme.invalid' - self.domain = 'foo.com' - - def test_z_and_domain(self): - self.assertEqual(self.z, self.msg.z) - self.assertEqual(self.z_domain, self.msg.z_domain) - - def test_to_partial_json(self): - self.assertEqual(self.jmsg_to, self.msg.to_partial_json()) - - def test_from_json(self): - from acme.challenges import DVSNIResponse - self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg_from)) - - def test_from_json_hashable(self): - from acme.challenges import DVSNIResponse - hash(DVSNIResponse.from_json(self.jmsg_from)) - - @mock.patch('acme.challenges.socket.gethostbyname') - @mock.patch('acme.challenges.crypto_util.probe_sni') - def test_probe_cert(self, mock_probe_sni, mock_gethostbyname): - mock_gethostbyname.return_value = '127.0.0.1' - self.msg.probe_cert('foo.com') - mock_gethostbyname.assert_called_once_with('foo.com') - mock_probe_sni.assert_called_once_with( - host='127.0.0.1', port=self.msg.PORT, - name=self.z_domain) - - self.msg.probe_cert('foo.com', host='8.8.8.8') - mock_probe_sni.assert_called_with( - host='8.8.8.8', port=mock.ANY, name=mock.ANY) - - self.msg.probe_cert('foo.com', port=1234) - mock_probe_sni.assert_called_with( - host=mock.ANY, port=1234, name=mock.ANY) - - self.msg.probe_cert('foo.com', bar='baz') - mock_probe_sni.assert_called_with( - host=mock.ANY, port=mock.ANY, name=mock.ANY, bar='baz') - - self.msg.probe_cert('foo.com', name=b'xxx') - mock_probe_sni.assert_called_with( - host=mock.ANY, port=mock.ANY, - name=self.z_domain) - - def test_gen_verify_cert(self): - key1 = test_util.load_pyopenssl_private_key('rsa512_key.pem') - cert, key2 = self.msg.gen_cert(key1) - self.assertEqual(key1, key2) - self.assertTrue(self.msg.verify_cert(cert)) - - def test_gen_verify_cert_gen_key(self): - cert, key = self.msg.gen_cert() - self.assertTrue(isinstance(key, OpenSSL.crypto.PKey)) - self.assertTrue(self.msg.verify_cert(cert)) - - def test_verify_bad_cert(self): - self.assertFalse(self.msg.verify_cert(test_util.load_cert('cert.pem'))) - - def test_simple_verify_wrong_account_key(self): - self.assertFalse(self.msg.simple_verify( - self.chall, self.domain, jose.JWKRSA.load( - test_util.load_vector('rsa256_key.pem')).public_key())) - - def test_simple_verify_wrong_payload(self): - for payload in b'', b'{}': - msg = self.msg.update(validation=jose.JWS.sign( - payload=payload, key=KEY, alg=jose.RS256)) - self.assertFalse(msg.simple_verify( - self.chall, self.domain, KEY.public_key())) - - def test_simple_verify_wrong_token(self): - msg = self.msg.update(validation=jose.JWS.sign( - payload=self.chall.update(token=(b'b' * 20)).json_dumps().encode(), - key=KEY, alg=jose.RS256)) - self.assertFalse(msg.simple_verify( - self.chall, self.domain, KEY.public_key())) - - @mock.patch('acme.challenges.DVSNIResponse.verify_cert', autospec=True) - def test_simple_verify(self, mock_verify_cert): - mock_verify_cert.return_value = mock.sentinel.verification - self.assertEqual(mock.sentinel.verification, self.msg.simple_verify( - self.chall, self.domain, KEY.public_key(), - cert=mock.sentinel.cert)) - mock_verify_cert.assert_called_once_with(self.msg, mock.sentinel.cert) - - @mock.patch('acme.challenges.DVSNIResponse.probe_cert') - def test_simple_verify_false_on_probe_error(self, mock_probe_cert): - mock_probe_cert.side_effect = errors.Error - self.assertFalse(self.msg.simple_verify( - self.chall, self.domain, KEY.public_key())) + @mock.patch('acme.challenges.TLSSNI01Response.gen_cert') + def test_validation(self, mock_gen_cert): + mock_gen_cert.return_value = ('cert', 'key') + self.assertEqual(('cert', 'key'), self.msg.validation( + KEY, cert_key=mock.sentinel.cert_key)) + mock_gen_cert.assert_called_once_with(key=mock.sentinel.cert_key) class RecoveryContactTest(unittest.TestCase): @@ -571,8 +550,6 @@ class ProofOfPossessionResponseTest(unittest.TestCase): class DNSTest(unittest.TestCase): def setUp(self): - self.account_key = jose.JWKRSA.load( - test_util.load_vector('rsa512_key.pem')) from acme.challenges import DNS self.msg = DNS(token=jose.b64decode( b'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA')) @@ -594,34 +571,33 @@ class DNSTest(unittest.TestCase): def test_gen_check_validation(self): self.assertTrue(self.msg.check_validation( - self.msg.gen_validation(self.account_key), - self.account_key.public_key())) + self.msg.gen_validation(KEY), KEY.public_key())) def test_gen_check_validation_wrong_key(self): key2 = jose.JWKRSA.load(test_util.load_vector('rsa1024_key.pem')) self.assertFalse(self.msg.check_validation( - self.msg.gen_validation(self.account_key), key2.public_key())) + self.msg.gen_validation(KEY), key2.public_key())) def test_check_validation_wrong_payload(self): validations = tuple( - jose.JWS.sign(payload=payload, alg=jose.RS256, key=self.account_key) + jose.JWS.sign(payload=payload, alg=jose.RS256, key=KEY) for payload in (b'', b'{}') ) for validation in validations: self.assertFalse(self.msg.check_validation( - validation, self.account_key.public_key())) + validation, KEY.public_key())) def test_check_validation_wrong_fields(self): bad_validation = jose.JWS.sign( payload=self.msg.update(token=b'x' * 20).json_dumps().encode('utf-8'), - alg=jose.RS256, key=self.account_key) + alg=jose.RS256, key=KEY) self.assertFalse(self.msg.check_validation( - bad_validation, self.account_key.public_key())) + bad_validation, KEY.public_key())) def test_gen_response(self): with mock.patch('acme.challenges.DNS.gen_validation') as mock_gen: mock_gen.return_value = mock.sentinel.validation - response = self.msg.gen_response(self.account_key) + response = self.msg.gen_response(KEY) from acme.challenges import DNSResponse self.assertTrue(isinstance(response, DNSResponse)) self.assertEqual(response.validation, mock.sentinel.validation) diff --git a/acme/acme/client.py b/acme/acme/client.py index 4c89458fb..0e9319f9c 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -481,11 +481,13 @@ class ClientNetwork(object): JSON_ERROR_CONTENT_TYPE = 'application/problem+json' REPLAY_NONCE_HEADER = 'Replay-Nonce' - def __init__(self, key, alg=jose.RS256, verify_ssl=True): + def __init__(self, key, alg=jose.RS256, verify_ssl=True, + user_agent='acme-python'): self.key = key self.alg = alg self.verify_ssl = verify_ssl self._nonces = set() + self.user_agent = user_agent def _wrap_in_jws(self, obj, nonce): """Wrap `JSONDeSerializable` object in JWS. @@ -578,6 +580,8 @@ class ClientNetwork(object): logging.debug('Sending %s request to %s. args: %r, kwargs: %r', method, url, args, kwargs) kwargs['verify'] = self.verify_ssl + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('User-Agent', self.user_agent) response = requests.request(method, url, *args, **kwargs) logging.debug('Received %s. Headers: %s. Content: %r', response, response.headers, response.content) diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index 7e895218c..2df7b5313 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -396,7 +396,8 @@ class ClientNetworkTest(unittest.TestCase): from acme.client import ClientNetwork self.net = ClientNetwork( - key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl) + key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl, + user_agent='acme-python-test') self.response = mock.MagicMock(ok=True, status_code=http_client.OK) self.response.headers = {} @@ -479,7 +480,7 @@ class ClientNetworkTest(unittest.TestCase): self.assertEqual(self.response, self.net._send_request( 'HEAD', 'url', 'foo', bar='baz')) mock_requests.request.assert_called_once_with( - 'HEAD', 'url', 'foo', verify=mock.ANY, bar='baz') + 'HEAD', 'url', 'foo', verify=mock.ANY, bar='baz', headers=mock.ANY) @mock.patch('acme.client.requests') def test_send_request_verify_ssl(self, mock_requests): @@ -492,7 +493,20 @@ class ClientNetworkTest(unittest.TestCase): self.assertEqual( self.response, self.net._send_request('GET', 'url')) mock_requests.request.assert_called_once_with( - 'GET', 'url', verify=verify) + 'GET', 'url', verify=verify, headers=mock.ANY) + + @mock.patch('acme.client.requests') + def test_send_request_user_agent(self, mock_requests): + mock_requests.request.return_value = self.response + # pylint: disable=protected-access + self.net._send_request('GET', 'url', headers={'bar': 'baz'}) + mock_requests.request.assert_called_once_with( + 'GET', 'url', verify=mock.ANY, + headers={'User-Agent': 'acme-python-test', 'bar': 'baz'}) + + self.net._send_request('GET', 'url', headers={'User-Agent': 'foo2'}) + mock_requests.request.assert_called_with( + 'GET', 'url', verify=mock.ANY, headers={'User-Agent': 'foo2'}) @mock.patch('acme.client.requests') def test_requests_error_passthrough(self, mock_requests): diff --git a/acme/acme/crypto_util.py b/acme/acme/crypto_util.py index 5f24e9d9e..72a93141a 100644 --- a/acme/acme/crypto_util.py +++ b/acme/acme/crypto_util.py @@ -13,7 +13,7 @@ from acme import errors logger = logging.getLogger(__name__) -# DVSNI certificate serving and probing is not affected by SSL +# TLSSNI01 certificate serving and probing is not affected by SSL # vulnerabilities: prober needs to check certificate for expected # contents anyway. Working SNI is the only thing that's necessary for # the challenge and thus scoping down SSL/TLS method (version) would @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) # https://www.openssl.org/docs/ssl/SSLv23_method.html). _serve_sni # should be changed to use "set_options" to disable SSLv2 and SSLv3, # in case it's used for things other than probing/serving! -_DEFAULT_DVSNI_SSL_METHOD = OpenSSL.SSL.SSLv23_METHOD +_DEFAULT_TLSSNI01_SSL_METHOD = OpenSSL.SSL.SSLv23_METHOD class SSLSocket(object): # pylint: disable=too-few-public-methods @@ -35,7 +35,7 @@ class SSLSocket(object): # pylint: disable=too-few-public-methods :ivar method: See `OpenSSL.SSL.Context` for allowed values. """ - def __init__(self, sock, certs, method=_DEFAULT_DVSNI_SSL_METHOD): + def __init__(self, sock, certs, method=_DEFAULT_TLSSNI01_SSL_METHOD): self.sock = sock self.certs = certs self.method = method @@ -103,7 +103,7 @@ class SSLSocket(object): # pylint: disable=too-few-public-methods def probe_sni(name, host, port=443, timeout=300, - method=_DEFAULT_DVSNI_SSL_METHOD, source_address=('0', 0)): + method=_DEFAULT_TLSSNI01_SSL_METHOD, source_address=('0', 0)): """Probe SNI server for SSL certificate. :param bytes name: Byte string to send as the server name in the diff --git a/acme/acme/jose/jwa.py b/acme/acme/jose/jwa.py index 4ce5ca3f5..1853e0107 100644 --- a/acme/acme/jose/jwa.py +++ b/acme/acme/jose/jwa.py @@ -176,5 +176,5 @@ PS384 = JWASignature.register(_JWAPS('PS384', hashes.SHA384)) PS512 = JWASignature.register(_JWAPS('PS512', hashes.SHA512)) ES256 = JWASignature.register(_JWAES('ES256')) -ES256 = JWASignature.register(_JWAES('ES384')) -ES256 = JWASignature.register(_JWAES('ES512')) +ES384 = JWASignature.register(_JWAES('ES384')) +ES512 = JWASignature.register(_JWAES('ES512')) diff --git a/acme/acme/standalone.py b/acme/acme/standalone.py index 1466671e3..3ddb21beb 100644 --- a/acme/acme/standalone.py +++ b/acme/acme/standalone.py @@ -6,7 +6,6 @@ import logging import os import sys -import six from six.moves import BaseHTTPServer # pylint: disable=import-error from six.moves import http_client # pylint: disable=import-error from six.moves import socketserver # pylint: disable=import-error @@ -30,7 +29,7 @@ class TLSServer(socketserver.TCPServer): self.certs = kwargs.pop("certs", {}) self.method = kwargs.pop( # pylint: disable=protected-access - "method", crypto_util._DEFAULT_DVSNI_SSL_METHOD) + "method", crypto_util._DEFAULT_TLSSNI01_SSL_METHOD) self.allow_reuse_address = kwargs.pop("allow_reuse_address", True) socketserver.TCPServer.__init__(self, *args, **kwargs) @@ -50,12 +49,25 @@ class ACMEServerMixin: # pylint: disable=old-style-class allow_reuse_address = True -class DVSNIServer(TLSServer, ACMEServerMixin): - """DVSNI Server.""" +class TLSSNI01Server(TLSServer, ACMEServerMixin): + """TLSSNI01 Server.""" def __init__(self, server_address, certs): TLSServer.__init__( - self, server_address, socketserver.BaseRequestHandler, certs=certs) + self, server_address, BaseRequestHandlerWithLogging, certs=certs) + + +class BaseRequestHandlerWithLogging(socketserver.BaseRequestHandler): + """BaseRequestHandler with logging.""" + + def log_message(self, format, *args): # pylint: disable=redefined-builtin + """Log arbitrary message.""" + logger.debug("%s - - %s", self.client_address[0], format % args) + + def handle(self): + """Handle request.""" + self.log_message("Incoming request") + socketserver.BaseRequestHandler.handle(self) class HTTP01Server(BaseHTTPServer.HTTPServer, ACMEServerMixin): @@ -83,6 +95,15 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): self.simple_http_resources = kwargs.pop("simple_http_resources", set()) socketserver.BaseRequestHandler.__init__(self, *args, **kwargs) + def log_message(self, format, *args): # pylint: disable=redefined-builtin + """Log arbitrary message.""" + logger.debug("%s - - %s", self.client_address[0], format % args) + + def handle(self): + """Handle request.""" + self.log_message("Incoming request") + BaseHTTPServer.BaseHTTPRequestHandler.handle(self) + def do_GET(self): # pylint: disable=invalid-name,missing-docstring if self.path == "/": self.handle_index() @@ -109,17 +130,17 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): """Handle HTTP01 provisioned resources.""" for resource in self.simple_http_resources: if resource.chall.path == self.path: - logger.debug("Serving HTTP01 with token %r", - resource.chall.encode("token")) + self.log_message("Serving HTTP01 with token %r", + resource.chall.encode("token")) self.send_response(http_client.OK) self.send_header("Content-type", resource.chall.CONTENT_TYPE) self.end_headers() self.wfile.write(resource.validation.encode()) return else: # pylint: disable=useless-else-on-loop - logger.debug("No resources to serve") - logger.debug("%s does not correspond to any resource. ignoring", - self.path) + self.log_message("No resources to serve") + self.log_message("%s does not correspond to any resource. ignoring", + self.path) @classmethod def partial_init(cls, simple_http_resources): @@ -134,8 +155,8 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): cls, simple_http_resources=simple_http_resources) -def simple_dvsni_server(cli_args, forever=True): - """Run simple standalone DVSNI server.""" +def simple_tls_sni_01_server(cli_args, forever=True): + """Run simple standalone TLSSNI01 server.""" logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() @@ -158,9 +179,8 @@ def simple_dvsni_server(cli_args, forever=True): OpenSSL.crypto.load_certificate( OpenSSL.crypto.FILETYPE_PEM, cert_contents)) - server = DVSNIServer(('', int(args.port)), certs=certs) - six.print_("Serving at https://localhost:{0}...".format( - server.socket.getsockname()[1])) + server = TLSSNI01Server(('', int(args.port)), certs=certs) + logger.info("Serving at https://%s:%s...", *server.socket.getsockname()[:2]) if forever: # pragma: no cover server.serve_forever() else: @@ -168,4 +188,4 @@ def simple_dvsni_server(cli_args, forever=True): if __name__ == "__main__": - sys.exit(simple_dvsni_server(sys.argv)) # pragma: no cover + sys.exit(simple_tls_sni_01_server(sys.argv)) # pragma: no cover diff --git a/acme/acme/standalone_test.py b/acme/acme/standalone_test.py index 85ef6ab14..02b1f69d3 100644 --- a/acme/acme/standalone_test.py +++ b/acme/acme/standalone_test.py @@ -28,8 +28,8 @@ class TLSServerTest(unittest.TestCase): server.server_close() # pylint: disable=no-member -class DVSNIServerTest(unittest.TestCase): - """Test for acme.standalone.DVSNIServer.""" +class TLSSNI01ServerTest(unittest.TestCase): + """Test for acme.standalone.TLSSNI01Server.""" def setUp(self): self.certs = { @@ -37,8 +37,8 @@ class DVSNIServerTest(unittest.TestCase): # pylint: disable=protected-access test_util.load_cert('cert.pem')._wrapped), } - from acme.standalone import DVSNIServer - self.server = DVSNIServer(("", 0), certs=self.certs) + from acme.standalone import TLSSNI01Server + self.server = TLSSNI01Server(("", 0), certs=self.certs) # pylint: disable=no-member self.thread = threading.Thread(target=self.server.serve_forever) self.thread.start() @@ -106,8 +106,8 @@ class HTTP01ServerTest(unittest.TestCase): self.assertFalse(self._test_http01(add=False)) -class TestSimpleDVSNIServer(unittest.TestCase): - """Tests for acme.standalone.simple_dvsni_server.""" +class TestSimpleTLSSNI01Server(unittest.TestCase): + """Tests for acme.standalone.simple_tls_sni_01_server.""" def setUp(self): # mirror ../examples/standalone @@ -118,12 +118,14 @@ class TestSimpleDVSNIServer(unittest.TestCase): shutil.copy(test_util.vector_path('rsa512_key.pem'), os.path.join(localhost_dir, 'key.pem')) - from acme.standalone import simple_dvsni_server + from acme.standalone import simple_tls_sni_01_server self.port = 1234 - self.thread = threading.Thread(target=simple_dvsni_server, kwargs={ - 'cli_args': ('xxx', '--port', str(self.port)), - 'forever': False, - }) + self.thread = threading.Thread( + target=simple_tls_sni_01_server, kwargs={ + 'cli_args': ('xxx', '--port', str(self.port)), + 'forever': False, + }, + ) self.old_cwd = os.getcwd() os.chdir(self.test_cwd) self.thread.start() diff --git a/acme/docs/conf.py b/acme/docs/conf.py index 1448aaea3..55f5eee3f 100644 --- a/acme/docs/conf.py +++ b/acme/docs/conf.py @@ -227,25 +227,25 @@ htmlhelp_basename = 'acme-pythondoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'acme-python.tex', u'acme-python Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'acme-python.tex', u'acme-python Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -289,9 +289,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'acme-python', u'acme-python Documentation', - author, 'acme-python', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'acme-python', u'acme-python Documentation', + author, 'acme-python', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/bootstrap/_deb_common.sh b/bootstrap/_deb_common.sh index 71144ce40..4c6b91a33 100755 --- a/bootstrap/_deb_common.sh +++ b/bootstrap/_deb_common.sh @@ -44,7 +44,7 @@ apt-get install -y --no-install-recommends \ libffi-dev \ ca-certificates \ -if ! which virtualenv > /dev/null ; then +if ! command -v virtualenv > /dev/null ; then echo Failed to install a working \"virtualenv\" command, exiting exit 1 fi diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh index 26b91b8c4..9f670da6e 100755 --- a/bootstrap/_rpm_common.sh +++ b/bootstrap/_rpm_common.sh @@ -16,10 +16,12 @@ else fi # "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails) +# Amazon Linux 2015.03 needs python27-virtualenv rather than python-virtualenv $tool install -y \ git-core \ python \ python-devel \ + python27-virtualenv \ python-virtualenv \ gcc \ dialog \ diff --git a/bootstrap/_suse_common.sh b/bootstrap/_suse_common.sh new file mode 100755 index 000000000..4b41bac36 --- /dev/null +++ b/bootstrap/_suse_common.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# SLE12 dont have python-virtualenv + +zypper -nq in -l git-core \ + python \ + python-devel \ + python-virtualenv \ + gcc \ + dialog \ + augeas-lenses \ + libopenssl-devel \ + libffi-devel \ + ca-certificates \ diff --git a/bootstrap/install-deps.sh b/bootstrap/install-deps.sh index 3cb0fc274..e907e7035 100755 --- a/bootstrap/install-deps.sh +++ b/bootstrap/install-deps.sh @@ -29,6 +29,9 @@ elif [ -f /etc/gentoo-release ] ; then elif uname | grep -iq FreeBSD ; then echo "Bootstrapping dependencies for FreeBSD..." $SUDO $BOOTSTRAP/freebsd.sh +elif `grep -qs openSUSE /etc/os-release` ; then + echo "Bootstrapping dependencies for openSUSE.." + $SUDO $BOOTSTRAP/suse.sh elif uname | grep -iq Darwin ; then echo "Bootstrapping dependencies for Mac OS X..." echo "WARNING: Mac support is very experimental at present..." diff --git a/bootstrap/suse.sh b/bootstrap/suse.sh new file mode 120000 index 000000000..fc4c1dee4 --- /dev/null +++ b/bootstrap/suse.sh @@ -0,0 +1 @@ +_suse_common.sh \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 62a7cea07..21bcc6817 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -230,25 +230,25 @@ htmlhelp_basename = 'LetsEncryptdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'LetsEncrypt.tex', u'Let\'s Encrypt Documentation', - u'Let\'s Encrypt Project', 'manual'), + ('index', 'LetsEncrypt.tex', u'Let\'s Encrypt Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -295,9 +295,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'LetsEncrypt', u'Let\'s Encrypt Documentation', - u'Let\'s Encrypt Project', 'LetsEncrypt', 'One line description of project.', - 'Miscellaneous'), + ('index', 'LetsEncrypt', u'Let\'s Encrypt Documentation', + u'Let\'s Encrypt Project', 'LetsEncrypt', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/docs/contributing.rst b/docs/contributing.rst index efc6c27ae..c71aefeec 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -151,7 +151,7 @@ certificate for some domain name by solving challenges received from the ACME server. From the protocol, there are essentially two different types of challenges. Challenges that must be solved by individual plugins in order to satisfy domain validation (subclasses -of `~.DVChallenge`, i.e. `~.challenges.DVSNI`, +of `~.DVChallenge`, i.e. `~.challenges.TLSSNI01`, `~.challenges.HTTP01`, `~.challenges.DNS`) and continuity specific challenges (subclasses of `~.ContinuityChallenge`, i.e. `~.challenges.RecoveryToken`, `~.challenges.RecoveryContact`, @@ -160,7 +160,7 @@ always handled by the `~.ContinuityAuthenticator`, while plugins are expected to handle `~.DVChallenge` types. Right now, we have two authenticator plugins, the `~.ApacheConfigurator` and the `~.StandaloneAuthenticator`. The Standalone and Apache -authenticators only solve the `~.challenges.DVSNI` challenge currently. +authenticators only solve the `~.challenges.TLSSNI01` challenge currently. (You can set which challenges your authenticator can handle through the :meth:`~.IAuthenticator.get_chall_pref`. diff --git a/docs/using.rst b/docs/using.rst index 0dec2cfb9..45db2bbad 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -86,8 +86,15 @@ in ``/etc/letsencrypt/live`` on the host. .. _`install Docker`: https://docs.docker.com/userguide/ -Distro packages ---------------- +Operating System Packages +-------------------------- + +**FreeBSD** + + * Port: ``cd /usr/ports/security/py-letsencrypt && make install clean`` + * Package: ``pkg install py27-letsencrypt`` + +**Other Operating Systems** Unfortunately, this is an ongoing effort. If you'd like to package Let's Encrypt client for your distribution of choice please have a @@ -128,7 +135,7 @@ Plugin A I Notes and status ========== = = ================================================================ standalone Y N Very stable. Uses port 80 (force by ``--standalone-supported-challenges http-01``) or 443 - (force by ``--standalone-supported-challenges dvsni``). + (force by ``--standalone-supported-challenges tls-sni-01``). apache Y Y Alpha. Automates Apache installation, works fairly well but on Debian-based distributions only for now. webroot Y N Works with already running webserver, by writing necessary files @@ -197,7 +204,7 @@ The following files are available: .. warning:: This **must be kept secret at all times**! Never share it with anyone, including Let's Encrypt developers. You cannot - put it into safe, however - your server still needs to access + put it into a safe, however - your server still needs to access this file in order for SSL/TLS to work. This is what Apache needs for `SSLCertificateKeyFile diff --git a/examples/cli.ini b/examples/cli.ini index 34fb8ab02..a20764ed8 100644 --- a/examples/cli.ini +++ b/examples/cli.ini @@ -16,7 +16,7 @@ server = https://acme-staging.api.letsencrypt.org/directory # Uncomment to use the standalone authenticator on port 443 # authenticator = standalone -# standalone-supported-challenges = dvsni +# standalone-supported-challenges = tls-sni-01 # Uncomment to use the webroot authenticator. Replace webroot-path with the # path to the public_html / webroot folder being served by your web server. diff --git a/letsencrypt-apache/docs/conf.py b/letsencrypt-apache/docs/conf.py index ddbf09262..aa58038cd 100644 --- a/letsencrypt-apache/docs/conf.py +++ b/letsencrypt-apache/docs/conf.py @@ -232,25 +232,25 @@ htmlhelp_basename = 'letsencrypt-apachedoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'letsencrypt-apache.tex', u'letsencrypt-apache Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'letsencrypt-apache.tex', u'letsencrypt-apache Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -293,9 +293,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'letsencrypt-apache', u'letsencrypt-apache Documentation', - author, 'letsencrypt-apache', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'letsencrypt-apache', u'letsencrypt-apache Documentation', + author, 'letsencrypt-apache', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py b/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py index b0b15d649..9e0948f12 100644 --- a/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py @@ -73,7 +73,8 @@ class AugeasConfigurator(common.Plugin): This function first checks for save errors, if none are found, all configuration changes made will be saved. According to the - function parameters. + function parameters. If an exception is raised, a new checkpoint + was not created. :param str title: The title of the save. If a title is given, the configuration will be saved as a new checkpoint and put in a @@ -82,8 +83,9 @@ class AugeasConfigurator(common.Plugin): :param bool temporary: Indicates whether the changes made will be quickly reversed in the future (ie. challenges) - :raises .errors.PluginError: If there was an error in Augeas, in an - attempt to save the configuration, or an error creating a checkpoint + :raises .errors.PluginError: If there was an error in Augeas, in + an attempt to save the configuration, or an error creating a + checkpoint """ save_state = self.aug.get("/augeas/save") @@ -122,16 +124,16 @@ class AugeasConfigurator(common.Plugin): except errors.ReverterError as err: raise errors.PluginError(str(err)) + self.aug.set("/augeas/save", save_state) + self.save_notes = "" + self.aug.save() + if title and not temporary: try: self.reverter.finalize_checkpoint(title) except errors.ReverterError as err: raise errors.PluginError(str(err)) - self.aug.set("/augeas/save", save_state) - self.save_notes = "" - self.aug.save() - def _log_save_errors(self, ex_errs): """Log errors due to bad Augeas save. diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index e3adfa927..f6286dccc 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -13,7 +13,6 @@ import zope.interface from acme import challenges -from letsencrypt import achallenges from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import le_util @@ -163,7 +162,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Get all of the available vhosts self.vhosts = self.get_virtual_hosts() - temp_install(self.mod_ssl_conf) + install_ssl_options_conf(self.mod_ssl_conf) def deploy_cert(self, domain, cert_path, key_path, chain_path=None, fullchain_path=None): # pylint: disable=unused-argument @@ -308,6 +307,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): best_points = 0 for vhost in self.vhosts: + if vhost.modmacro is True: + continue if target_name in vhost.get_names(): points = 2 elif any(addr.get_addr() == target_name for addr in vhost.addrs): @@ -327,7 +328,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # No winners here... is there only one reasonable vhost? if best_candidate is None: # reasonable == Not all _default_ addrs - reasonable_vhosts = self._non_default_vhosts() + vhosts = self._non_default_vhosts() + # remove mod_macro hosts from reasonable vhosts + reasonable_vhosts = [vh for vh + in vhosts if vh.modmacro is False] if len(reasonable_vhosts) == 1: best_candidate = reasonable_vhosts[0] @@ -349,8 +353,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ all_names = set() + vhost_macro = [] + for vhost in self.vhosts: all_names.update(vhost.get_names()) + if vhost.modmacro: + vhost_macro.append(vhost.filep) for addr in vhost.addrs: if common.hostname_regex.match(addr.get_addr()): @@ -360,6 +368,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if name: all_names.add(name) + if len(vhost_macro) > 0: + zope.component.getUtility(interfaces.IDisplay).notification( + "Apache mod_macro seems to be in use in file(s):\n{0}" + "\n\nUnfortunately mod_macro is not yet supported".format( + "\n ".join(vhost_macro))) + return all_names def get_name_from_ip(self, addr): # pylint: disable=no-self-use @@ -396,11 +410,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "ServerAlias", None, start=host.path, exclude=False) for alias in serveralias_match: - host.aliases.add(self.parser.get_arg(alias)) + serveralias = self.parser.get_arg(alias) + if not host.modmacro: + host.aliases.add(serveralias) if servername_match: # Get last ServerName as each overwrites the previous - host.name = self.parser.get_arg(servername_match[-1]) + servername = self.parser.get_arg(servername_match[-1]) + if not host.modmacro: + host.name = servername def _create_vhost(self, path): """Used by get_virtual_hosts to create vhost objects @@ -423,7 +441,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): filename = get_file_path(path) is_enabled = self.is_site_enabled(filename) - vhost = obj.VirtualHost(filename, path, addrs, is_ssl, is_enabled) + macro = False + if "/macro/" in path.lower(): + macro = True + + vhost = obj.VirtualHost(filename, path, addrs, is_ssl, + is_enabled, modmacro=macro) self._add_servernames(vhost) return vhost @@ -1179,7 +1202,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use """Return list of challenge preferences.""" - return [challenges.DVSNI] + return [challenges.TLSSNI01] def perform(self, achalls): """Perform the configuration related challenge. @@ -1194,11 +1217,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): apache_dvsni = dvsni.ApacheDvsni(self) for i, achall in enumerate(achalls): - if isinstance(achall, achallenges.DVSNI): - # Currently also have dvsni hold associated index - # of the challenge. This helps to put all of the responses back - # together when they are all complete. - apache_dvsni.add_chall(achall, i) + # Currently also have dvsni hold associated index + # of the challenge. This helps to put all of the responses back + # together when they are all complete. + apache_dvsni.add_chall(achall, i) sni_response = apache_dvsni.perform() if sni_response: @@ -1298,7 +1320,7 @@ def get_file_path(vhost_path): avail_fp = vhost_path[6:] # This can be optimized... while True: - # Cast both to lowercase to be case insensitive + # Cast all to lowercase to be case insensitive find_if = avail_fp.lower().find("/ifmodule") if find_if != -1: avail_fp = avail_fp[:find_if] @@ -1307,16 +1329,26 @@ def get_file_path(vhost_path): if find_vh != -1: avail_fp = avail_fp[:find_vh] continue + find_macro = avail_fp.lower().find("/macro") + if find_macro != -1: + avail_fp = avail_fp[:find_macro] + continue break return avail_fp -def temp_install(options_ssl): - """Temporary install for convenience.""" - # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY - # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER - # AND TAKEN OUT BEFORE RELEASE, INSTEAD - # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM. +def install_ssl_options_conf(options_ssl): + """ + Copy Let's Encrypt's SSL options file into the system's config dir if + required. + """ + # XXX if we ever try to enforce a local privilege boundary (eg, running + # letsencrypt for unprivileged users via setuid), this function will need + # to be modified. + + # XXX if the user is in security-autoupdate mode, we should be willing to + # overwrite the options_ssl file at least if it's unmodified: + # https://github.com/letsencrypt/letsencrypt/issues/1123 # Check to make sure options-ssl.conf is installed if not os.path.isfile(options_ssl): diff --git a/letsencrypt-apache/letsencrypt_apache/dvsni.py b/letsencrypt-apache/letsencrypt_apache/dvsni.py index ed88bf8a7..2f9e9ed18 100644 --- a/letsencrypt-apache/letsencrypt_apache/dvsni.py +++ b/letsencrypt-apache/letsencrypt_apache/dvsni.py @@ -7,14 +7,14 @@ from letsencrypt_apache import obj from letsencrypt_apache import parser -class ApacheDvsni(common.Dvsni): +class ApacheDvsni(common.TLSSNI01): """Class performs DVSNI challenges within the Apache configurator. :ivar configurator: ApacheConfigurator object :type configurator: :class:`~apache.configurator.ApacheConfigurator` - :ivar list achalls: Annotated :class:`~letsencrypt.achallenges.DVSNI` - challenges. + :ivar list achalls: Annotated tls-sni-01 + (`.KeyAuthorizationAnnotatedChallenge`) challenges. :param list indices: Meant to hold indices of challenges in a larger array. ApacheDvsni is capable of solving many challenges @@ -62,7 +62,7 @@ class ApacheDvsni(common.Dvsni): # Prepare the server for HTTPS self.configurator.prepare_server_https( - str(self.configurator.config.dvsni_port), True) + str(self.configurator.config.tls_sni_01_port), True) responses = [] @@ -114,14 +114,15 @@ class ApacheDvsni(common.Dvsni): # TODO: Checkout _default_ rules. dvsni_addrs = set() - default_addr = obj.Addr(("*", str(self.configurator.config.dvsni_port))) + default_addr = obj.Addr(("*", str( + self.configurator.config.tls_sni_01_port))) for addr in vhost.addrs: if "_default_" == addr.get_addr(): dvsni_addrs.add(default_addr) else: dvsni_addrs.add( - addr.get_sni_addr(self.configurator.config.dvsni_port)) + addr.get_sni_addr(self.configurator.config.tls_sni_01_port)) return dvsni_addrs @@ -144,8 +145,8 @@ class ApacheDvsni(common.Dvsni): def _get_config_text(self, achall, ip_addrs): """Chocolate virtual server configuration text - :param achall: Annotated DVSNI challenge. - :type achall: :class:`letsencrypt.achallenges.DVSNI` + :param .KeyAuthorizationAnnotatedChallenge achall: Annotated + DVSNI challenge. :param list ip_addrs: addresses of challenged domain :class:`list` of type `~.obj.Addr` @@ -164,7 +165,7 @@ class ApacheDvsni(common.Dvsni): # https://docs.python.org/2.7/reference/lexical_analysis.html return self.VHOST_TEMPLATE.format( vhost=ips, - server_name=achall.gen_response(achall.account_key).z_domain, + server_name=achall.response(achall.account_key).z_domain, ssl_options_conf_path=self.configurator.mod_ssl_conf, cert_path=self.get_cert_path(achall), key_path=self.get_key_path(achall), diff --git a/letsencrypt-apache/letsencrypt_apache/obj.py b/letsencrypt-apache/letsencrypt_apache/obj.py index 58a6c740e..175ce3f92 100644 --- a/letsencrypt-apache/letsencrypt_apache/obj.py +++ b/letsencrypt-apache/letsencrypt_apache/obj.py @@ -102,6 +102,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods :ivar bool ssl: SSLEngine on in vhost :ivar bool enabled: Virtual host is enabled + :ivar bool modmacro: VirtualHost is using mod_macro https://httpd.apache.org/docs/2.4/vhosts/details.html @@ -112,7 +113,9 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods # ?: is used for not returning enclosed characters strip_name = re.compile(r"^(?:.+://)?([^ :$]*)") - def __init__(self, filep, path, addrs, ssl, enabled, name=None, aliases=None): + def __init__(self, filep, path, addrs, ssl, enabled, name=None, + aliases=None, modmacro=False): + # pylint: disable=too-many-arguments """Initialize a VH.""" self.filep = filep @@ -122,6 +125,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods self.aliases = aliases if aliases is not None else set() self.ssl = ssl self.enabled = enabled + self.modmacro = modmacro def get_names(self): """Return a set of all names.""" @@ -141,21 +145,25 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods "Name: {name}\n" "Aliases: {aliases}\n" "TLS Enabled: {tls}\n" - "Site Enabled: {active}".format( + "Site Enabled: {active}\n" + "mod_macro Vhost: {modmacro}".format( filename=self.filep, vhpath=self.path, addrs=", ".join(str(addr) for addr in self.addrs), name=self.name if self.name is not None else "", aliases=", ".join(name for name in self.aliases), tls="Yes" if self.ssl else "No", - active="Yes" if self.enabled else "No")) + active="Yes" if self.enabled else "No", + modmacro="Yes" if self.modmacro else "No")) def __eq__(self, other): if isinstance(other, self.__class__): return (self.filep == other.filep and self.path == other.path and self.addrs == other.addrs and self.get_names() == other.get_names() and - self.ssl == other.ssl and self.enabled == other.enabled) + self.ssl == other.ssl and + self.enabled == other.enabled and + self.modmacro == other.modmacro) return False diff --git a/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf b/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf index 8c28c86a5..2a724d7ec 100644 --- a/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf +++ b/letsencrypt-apache/letsencrypt_apache/options-ssl-apache.conf @@ -8,12 +8,6 @@ SSLCipherSuite ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA25 SSLHonorCipherOrder on SSLCompression off - -ServerSignature Off -AcceptPathInfo Off -AddOutputFilterByType DEFLATE text/html text/plain text/xml application/pdf -AddDefaultCharset UTF-8 - SSLOptions +StrictRequire # Add vhost name to log entries: diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 4dd1350ac..36a3f13fa 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -59,14 +59,20 @@ class TwoVhost80Test(util.ApacheTest): # Weak test.. ApacheConfigurator.add_parser_arguments(mock.MagicMock()) - def test_get_all_names(self): + @mock.patch("zope.component.getUtility") + def test_get_all_names(self, mock_getutility): + mock_getutility.notification = mock.MagicMock(return_value=True) names = self.config.get_all_names() self.assertEqual(names, set( ["letsencrypt.demo", "encryption-example.demo", "ip-172-30-0-17"])) + @mock.patch("zope.component.getUtility") @mock.patch("letsencrypt_apache.configurator.socket.gethostbyaddr") - def test_get_all_names_addrs(self, mock_gethost): + def test_get_all_names_addrs(self, mock_gethost, mock_getutility): mock_gethost.side_effect = [("google.com", "", ""), socket.error] + notification = mock.Mock() + notification.notification = mock.Mock(return_value=True) + mock_getutility.return_value = notification vhost = obj.VirtualHost( "fp", "ap", set([obj.Addr(("8.8.8.8", "443")), @@ -97,7 +103,7 @@ class TwoVhost80Test(util.ApacheTest): """ vhs = self.config.get_virtual_hosts() - self.assertEqual(len(vhs), 4) + self.assertEqual(len(vhs), 5) found = 0 for vhost in vhs: @@ -108,7 +114,7 @@ class TwoVhost80Test(util.ApacheTest): else: raise Exception("Missed: %s" % vhost) # pragma: no cover - self.assertEqual(found, 4) + self.assertEqual(found, 5) @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_none_avail(self, mock_select): @@ -174,7 +180,7 @@ class TwoVhost80Test(util.ApacheTest): def test_non_default_vhosts(self): # pylint: disable=protected-access - self.assertEqual(len(self.config._non_default_vhosts()), 3) + self.assertEqual(len(self.config._non_default_vhosts()), 4) def test_is_site_enabled(self): """Test if site is enabled. @@ -345,7 +351,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), self.config.is_name_vhost(ssl_vhost)) - self.assertEqual(len(self.config.vhosts), 5) + self.assertEqual(len(self.config.vhosts), 6) def test_make_vhost_ssl_extra_vhs(self): self.config.aug.match = mock.Mock(return_value=["p1", "p2"]) @@ -382,8 +388,8 @@ class TwoVhost80Test(util.ApacheTest): account_key, achall1, achall2 = self.get_achalls() dvsni_ret_val = [ - achall1.gen_response(account_key), - achall2.gen_response(account_key), + achall1.response(account_key), + achall2.response(account_key), ] mock_dvsni_perform.return_value = dvsni_ret_val @@ -492,10 +498,10 @@ class TwoVhost80Test(util.ApacheTest): def test_get_chall_pref(self): self.assertTrue(isinstance(self.config.get_chall_pref(""), list)) - def test_temp_install(self): - from letsencrypt_apache.configurator import temp_install + def test_install_ssl_options_conf(self): + from letsencrypt_apache.configurator import install_ssl_options_conf path = os.path.join(self.work_dir, "test_it") - temp_install(path) + install_ssl_options_conf(path) self.assertTrue(os.path.isfile(path)) # TEST ENHANCEMENTS @@ -665,20 +671,20 @@ class TwoVhost80Test(util.ApacheTest): self.vh_truth[1].aliases = set(["yes.default.com"]) self.config._enable_redirect(self.vh_truth[1], "") # pylint: disable=protected-access - self.assertEqual(len(self.config.vhosts), 5) + self.assertEqual(len(self.config.vhosts), 6) def get_achalls(self): """Return testing achallenges.""" account_key = self.rsa512jwk - achall1 = achallenges.DVSNI( + achall1 = achallenges.KeyAuthorizationAnnotatedChallenge( challb=acme_util.chall_to_challb( - challenges.DVSNI( + challenges.TLSSNI01( token="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q"), "pending"), domain="encryption-example.demo", account_key=account_key) - achall2 = achallenges.DVSNI( + achall2 = achallenges.KeyAuthorizationAnnotatedChallenge( challb=acme_util.chall_to_challb( - challenges.DVSNI( + challenges.TLSSNI01( token="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU"), "pending"), domain="letsencrypt.demo", account_key=account_key) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py b/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py index d7cfb09b3..6db319d87 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py @@ -57,7 +57,7 @@ class SelectVhostTest(unittest.TestCase): @mock.patch("letsencrypt_apache.display_ops.zope.component.getUtility") def test_multiple_names(self, mock_util): - mock_util().menu.return_value = (display_util.OK, 4) + mock_util().menu.return_value = (display_util.OK, 5) self.vhosts.append( obj.VirtualHost( @@ -65,7 +65,7 @@ class SelectVhostTest(unittest.TestCase): False, False, "wildcard.com", set(["*.wildcard.com"]))) - self.assertEqual(self.vhosts[4], self._call(self.vhosts)) + self.assertEqual(self.vhosts[5], self._call(self.vhosts)) if __name__ == "__main__": diff --git a/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py b/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py index c362d4115..911c2a36b 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/dvsni_test.py @@ -13,15 +13,15 @@ from letsencrypt_apache.tests import util class DvsniPerformTest(util.ApacheTest): """Test the ApacheDVSNI challenge.""" - auth_key = common_test.DvsniTest.auth_key - achalls = common_test.DvsniTest.achalls + auth_key = common_test.TLSSNI01Test.auth_key + achalls = common_test.TLSSNI01Test.achalls def setUp(self): # pylint: disable=arguments-differ super(DvsniPerformTest, self).setUp() config = util.get_apache_configurator( self.config_path, self.config_dir, self.work_dir) - config.config.dvsni_port = 443 + config.config.tls_sni_01_port = 443 from letsencrypt_apache import dvsni self.sni = dvsni.ApacheDvsni(config) @@ -46,7 +46,7 @@ class DvsniPerformTest(util.ApacheTest): achall = self.achalls[0] self.sni.add_chall(achall) - response = self.achalls[0].gen_response(self.auth_key) + response = self.achalls[0].response(self.auth_key) mock_setup_cert = mock.MagicMock(return_value=response) # pylint: disable=protected-access self.sni._setup_challenge_cert = mock_setup_cert @@ -72,7 +72,7 @@ class DvsniPerformTest(util.ApacheTest): acme_responses = [] for achall in self.achalls: self.sni.add_chall(achall) - acme_responses.append(achall.gen_response(self.auth_key)) + acme_responses.append(achall.response(self.auth_key)) mock_setup_cert = mock.MagicMock(side_effect=acme_responses) # pylint: disable=protected-access @@ -100,7 +100,7 @@ class DvsniPerformTest(util.ApacheTest): z_domains = [] for achall in self.achalls: self.sni.add_chall(achall) - z_domain = achall.gen_response(self.auth_key).z_domain + z_domain = achall.response(self.auth_key).z_domain z_domains.append(set([z_domain])) self.sni._mod_config() # pylint: disable=protected-access diff --git a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py index d2e4dec14..bc1f316f9 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py @@ -52,7 +52,7 @@ class BasicParserTest(util.ParserTest): test2 = self.parser.find_dir("documentroot") self.assertEqual(len(test), 1) - self.assertEqual(len(test2), 3) + self.assertEqual(len(test2), 4) def test_add_dir(self): aug_default = "/files" + self.parser.loc["default"] diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/mod_macro-example.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/mod_macro-example.conf new file mode 100644 index 000000000..6a6579007 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/mod_macro-example.conf @@ -0,0 +1,15 @@ + + + ServerName $domain + ServerAlias www.$domain + DocumentRoot /var/www/html + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + +Use VHost macro1 test.com +Use VHost macro2 hostname.org +Use VHost macro3 apache.org + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/mod_macro-example.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/mod_macro-example.conf new file mode 120000 index 000000000..44f254304 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/mod_macro-example.conf @@ -0,0 +1 @@ +../sites-available/mod_macro-example.conf \ No newline at end of file diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index 2594ba773..a8bfe0e4b 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -124,6 +124,11 @@ def get_vh_truth(temp_dir, config_name): os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), set([obj.Addr.fromstring("*:80")]), False, True, "letsencrypt.demo"), + obj.VirtualHost( + os.path.join(prefix, "mod_macro-example.conf"), + os.path.join(aug_pre, + "mod_macro-example.conf/Macro/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), False, True, modmacro=True) ] return vh_truth diff --git a/letsencrypt-auto b/letsencrypt-auto index 2391a7c0b..b3e380f9d 100755 --- a/letsencrypt-auto +++ b/letsencrypt-auto @@ -8,17 +8,16 @@ # without requiring specific versions of its dependencies from the operating # system. +# Note: you can set XDG_DATA_HOME or VENV_PATH before running this script, +# if you want to change where the virtual environment will be installed XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share} VENV_NAME="letsencrypt" VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"} VENV_BIN=${VENV_PATH}/bin -if test "`id -u`" -ne "0" ; then - SUDO=sudo -else - SUDO= -fi - +# This script takes the same arguments as the main letsencrypt program, but it +# additionally responds to --verbose (more output) and --debug (allow support +# for experimental platforms) for arg in "$@" ; do # This first clause is redundant with the third, but hedging on portability if [ "$arg" = "-v" ] || [ "$arg" = "--verbose" ] || echo "$arg" | grep -E -- "-v+$" ; then @@ -28,12 +27,52 @@ for arg in "$@" ; do fi done +# letsencrypt-auto needs root access to bootstrap OS dependencies, and +# letsencrypt itself needs root access for almost all modes of operation +# The "normal" case is that sudo is used for the steps that need root, but +# this script *can* be run as root (not recommended), or fall back to using +# `su` +if test "`id -u`" -ne "0" ; then + if command -v sudo 1>/dev/null 2>&1; then + SUDO=sudo + else + echo \"sudo\" is not available, will use \"su\" for installation steps... + # Because the parameters in `su -c` has to be a string, + # we need properly escape it + su_sudo() { + args="" + # This `while` loop iterates over all parameters given to this function. + # For each parameter, all `'` will be replace by `'"'"'`, and the escaped string + # will be wrap in a pair of `'`, then append to `$args` string + # For example, `echo "It's only 1\$\!"` will be escaped to: + # 'echo' 'It'"'"'s only 1$!' + # │ │└┼┘│ + # │ │ │ └── `'s only 1$!'` the literal string + # │ │ └── `\"'\"` is a single quote (as a string) + # │ └── `'It'`, to be concatenated with the strings followed it + # └── `echo` wrapped in a pair of `'`, it's totally fine for the shell command itself + while [ $# -ne 0 ]; do + args="$args'$(printf "%s" "$1" | sed -e "s/'/'\"'\"'/g")' " + shift + done + su root -c "$args" + } + SUDO=su_sudo + fi +else + SUDO= +fi + ExperimentalBootstrap() { # Arguments: Platform name, boostrap script name, SUDO command (iff needed) if [ "$DEBUG" = 1 ] ; then if [ "$2" != "" ] ; then echo "Bootstrapping dependencies for $1..." - "$3" "$BOOTSTRAP/$2" + if [ "$3" != "" ] ; then + "$3" "$BOOTSTRAP/$2" + else + "$BOOTSTRAP/$2" + fi fi else echo "WARNING: $1 support is very experimental at present..." @@ -43,13 +82,12 @@ ExperimentalBootstrap() { fi } - DeterminePythonVersion() { - if which python2 > /dev/null ; then - export LE_PYTHON=${LE_PYTHON:-python2} - elif which python2.7 > /dev/null ; then + if command -v python2.7 > /dev/null ; then export LE_PYTHON=${LE_PYTHON:-python2.7} - elif which python > /dev/null ; then + elif command -v python2 > /dev/null ; then + export LE_PYTHON=${LE_PYTHON:-python2} + elif command -v python > /dev/null ; then export LE_PYTHON=${LE_PYTHON:-python} else echo "Cannot find any Pythons... please install one!" @@ -82,6 +120,9 @@ then elif [ -f /etc/redhat-release ] ; then echo "Bootstrapping dependencies for RedHat-based OSes..." $SUDO $BOOTSTRAP/_rpm_common.sh + elif `grep -q openSUSE /etc/os-release` ; then + echo "Bootstrapping dependencies for openSUSE-based OSes..." + $SUDO $BOOTSTRAP/_suse_common.sh elif [ -f /etc/arch-release ] ; then echo "Bootstrapping dependencies for Archlinux..." $SUDO $BOOTSTRAP/archlinux.sh @@ -93,6 +134,8 @@ then ExperimentalBootstrap "FreeBSD" freebsd.sh "$SUDO" elif uname | grep -iq Darwin ; then ExperimentalBootstrap "Mac OS X" mac.sh + elif grep -iq "Amazon Linux" /etc/issue ; then + ExperimentalBootstrap "Amazon Linux" amazon_linux.sh else echo "Sorry, I don't know how to bootstrap Let's Encrypt on your operating system!" echo @@ -130,7 +173,7 @@ else $VENV_BIN/pip install -U pip > /dev/null printf . # nginx is buggy / disabled for now... - $VENV_BIN/pip install -r py26reqs.txt + $VENV_BIN/pip install -r py26reqs.txt > /dev/null printf . $VENV_BIN/pip install -U letsencrypt > /dev/null printf . diff --git a/letsencrypt-compatibility-test/docs/conf.py b/letsencrypt-compatibility-test/docs/conf.py index 7e9f0d5a4..3ee161efb 100644 --- a/letsencrypt-compatibility-test/docs/conf.py +++ b/letsencrypt-compatibility-test/docs/conf.py @@ -226,25 +226,26 @@ htmlhelp_basename = 'letsencrypt-compatibility-testdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'letsencrypt-compatibility-test.tex', u'letsencrypt-compatibility-test Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'letsencrypt-compatibility-test.tex', + u'letsencrypt-compatibility-test Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -273,7 +274,8 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'letsencrypt-compatibility-test', u'letsencrypt-compatibility-test Documentation', + (master_doc, 'letsencrypt-compatibility-test', + u'letsencrypt-compatibility-test Documentation', [author], 1) ] @@ -287,9 +289,10 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'letsencrypt-compatibility-test', u'letsencrypt-compatibility-test Documentation', - author, 'letsencrypt-compatibility-test', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'letsencrypt-compatibility-test', + u'letsencrypt-compatibility-test Documentation', + author, 'letsencrypt-compatibility-test', + 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. @@ -309,6 +312,8 @@ intersphinx_mapping = { 'python': ('https://docs.python.org/', None), 'acme': ('https://acme-python.readthedocs.org/en/latest/', None), 'letsencrypt': ('https://letsencrypt.readthedocs.org/en/latest/', None), - 'letsencrypt-apache': ('https://letsencrypt-apache.readthedocs.org/en/latest/', None), - 'letsencrypt-nginx': ('https://letsencrypt-nginx.readthedocs.org/en/latest/', None), + 'letsencrypt-apache': ( + 'https://letsencrypt-apache.readthedocs.org/en/latest/', None), + 'letsencrypt-nginx': ( + 'https://letsencrypt-nginx.readthedocs.org/en/latest/', None), } diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index b91322c3c..5765003b9 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -60,7 +60,7 @@ def test_authenticator(plugin, config, temp_dir): "Plugin failed to complete %s for %s in %s", type(achalls[i]), achalls[i].domain, config) success = False - elif isinstance(responses[i], challenges.DVSNIResponse): + elif isinstance(responses[i], challenges.TLSSNI01): verify = functools.partial(responses[i].simple_verify, achalls[i], achalls[i].domain, util.JWK.public_key(), @@ -68,10 +68,10 @@ def test_authenticator(plugin, config, temp_dir): port=plugin.https_port) if _try_until_true(verify): logger.info( - "DVSNI verification for %s succeeded", achalls[i].domain) + "tls-sni-01 verification for %s succeeded", achalls[i].domain) else: logger.error( - "DVSNI verification for %s in %s failed", + "tls-sni-01 verification for %s in %s failed", achalls[i].domain, config) success = False @@ -99,12 +99,12 @@ def _create_achalls(plugin): for domain in names: prefs = plugin.get_chall_pref(domain) for chall_type in prefs: - if chall_type == challenges.DVSNI: - chall = challenges.DVSNI( - token=os.urandom(challenges.DVSNI.TOKEN_SIZE)) + if chall_type == challenges.TLSSNI01: + chall = challenges.TLSSNI01( + token=os.urandom(challenges.TLSSNI01.TOKEN_SIZE)) challb = acme_util.chall_to_challb( chall, messages.STATUS_PENDING) - achall = achallenges.DVSNI( + achall = achallenges.KeyAuthorizationAnnotatedChallenge( challb=challb, domain=domain, account_key=util.JWK) achalls.append(achall) diff --git a/letsencrypt-nginx/docs/conf.py b/letsencrypt-nginx/docs/conf.py index cdb3490a0..14713a4b2 100644 --- a/letsencrypt-nginx/docs/conf.py +++ b/letsencrypt-nginx/docs/conf.py @@ -225,25 +225,25 @@ htmlhelp_basename = 'letsencrypt-nginxdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'letsencrypt-nginx.tex', u'letsencrypt-nginx Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'letsencrypt-nginx.tex', u'letsencrypt-nginx Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -286,9 +286,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'letsencrypt-nginx', u'letsencrypt-nginx Documentation', - author, 'letsencrypt-nginx', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'letsencrypt-nginx', u'letsencrypt-nginx Documentation', + author, 'letsencrypt-nginx', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/letsencrypt-nginx/letsencrypt_nginx/configurator.py b/letsencrypt-nginx/letsencrypt_nginx/configurator.py index 29e69e498..d97cf7397 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/configurator.py +++ b/letsencrypt-nginx/letsencrypt_nginx/configurator.py @@ -14,7 +14,6 @@ import zope.interface from acme import challenges from acme import crypto_util as acme_crypto_util -from letsencrypt import achallenges from letsencrypt import constants as core_constants from letsencrypt import crypto_util from letsencrypt import errors @@ -108,6 +107,10 @@ class NginxConfigurator(common.Plugin): # This is called in determine_authenticator and determine_installer def prepare(self): """Prepare the authenticator/installer.""" + # Verify Nginx is installed + if not le_util.exe_exists(self.conf('ctl')): + raise errors.NoInstallationError + self.parser = parser.NginxParser( self.conf('server-root'), self.mod_ssl_conf) @@ -297,7 +300,7 @@ class NginxConfigurator(common.Plugin): """Make a server SSL. Make a server SSL based on server_name and filename by adding a - ``listen IConfig.dvsni_port ssl`` directive to the server block. + ``listen IConfig.tls_sni_01_port ssl`` directive to the server block. .. todo:: Maybe this should create a new block instead of modifying the existing one? @@ -307,7 +310,7 @@ class NginxConfigurator(common.Plugin): """ snakeoil_cert, snakeoil_key = self._get_snakeoil_paths() - ssl_block = [['listen', '{0} ssl'.format(self.config.dvsni_port)], + ssl_block = [['listen', '{0} ssl'.format(self.config.tls_sni_01_port)], # access and error logs necessary for integration # testing (non-root) ['access_log', os.path.join( @@ -321,7 +324,8 @@ class NginxConfigurator(common.Plugin): vhost.filep, vhost.names, ssl_block) vhost.ssl = True vhost.raw.extend(ssl_block) - vhost.addrs.add(obj.Addr('', str(self.config.dvsni_port), True, False)) + vhost.addrs.add(obj.Addr( + '', str(self.config.tls_sni_01_port), True, False)) def get_all_certs_keys(self): """Find all existing keys, certs from configuration. @@ -536,7 +540,7 @@ class NginxConfigurator(common.Plugin): ########################################################################### def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use """Return list of challenge preferences.""" - return [challenges.DVSNI] + return [challenges.TLSSNI01] # Entry point in main.py for performing challenges def perform(self, achalls): @@ -552,11 +556,10 @@ class NginxConfigurator(common.Plugin): nginx_dvsni = dvsni.NginxDvsni(self) for i, achall in enumerate(achalls): - if isinstance(achall, achallenges.DVSNI): - # Currently also have dvsni hold associated index - # of the challenge. This helps to put all of the responses back - # together when they are all complete. - nginx_dvsni.add_chall(achall, i) + # Currently also have dvsni hold associated index + # of the challenge. This helps to put all of the responses back + # together when they are all complete. + nginx_dvsni.add_chall(achall, i) sni_response = nginx_dvsni.perform() # Must restart in order to activate the challenges. diff --git a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py b/letsencrypt-nginx/letsencrypt_nginx/dvsni.py index 662f10889..8fd705f08 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py +++ b/letsencrypt-nginx/letsencrypt_nginx/dvsni.py @@ -13,7 +13,7 @@ from letsencrypt_nginx import nginxparser logger = logging.getLogger(__name__) -class NginxDvsni(common.Dvsni): +class NginxDvsni(common.TLSSNI01): """Class performs DVSNI challenges within the Nginx configurator. :ivar configurator: NginxConfigurator object @@ -48,7 +48,7 @@ class NginxDvsni(common.Dvsni): addresses = [] default_addr = "{0} default_server ssl".format( - self.configurator.config.dvsni_port) + self.configurator.config.tls_sni_01_port) for achall in self.achalls: vhost = self.configurator.choose_vhost(achall.domain) @@ -99,8 +99,8 @@ class NginxDvsni(common.Dvsni): for key, body in main: if key == ['http']: found_bucket = False - for key, _ in body: - if key == bucket_directive[0]: + for k, _ in body: + if k == bucket_directive[0]: found_bucket = True if not found_bucket: body.insert(0, bucket_directive) @@ -141,7 +141,7 @@ class NginxDvsni(common.Dvsni): block = [['listen', str(addr)] for addr in addrs] block.extend([['server_name', - achall.gen_response(achall.account_key).z_domain], + achall.response(achall.account_key).z_domain], ['include', self.configurator.parser.loc["ssl_options"]], # access and error logs necessary for # integration testing (non-root) diff --git a/letsencrypt-nginx/letsencrypt_nginx/parser.py b/letsencrypt-nginx/letsencrypt_nginx/parser.py index 19483821a..fb79703dc 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/parser.py +++ b/letsencrypt-nginx/letsencrypt_nginx/parser.py @@ -246,7 +246,7 @@ class NginxParser(object): # Can't be a server block return False - if item[0] == 'server_name': + if len(item) > 0 and item[0] == 'server_name': server_names.update(_get_servernames(item[1])) return server_names == names @@ -425,7 +425,7 @@ def _is_include_directive(entry): """ return (isinstance(entry, list) and - entry[0] == 'include' and len(entry) == 2 and + len(entry) == 2 and entry[0] == 'include' and isinstance(entry[1], str)) diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py index d8bdf8355..913c5de27 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-public-methods """Test for letsencrypt_nginx.configurator.""" import os import shutil @@ -29,6 +30,12 @@ class NginxConfiguratorTest(util.NginxTest): shutil.rmtree(self.config_dir) shutil.rmtree(self.work_dir) + @mock.patch("letsencrypt_nginx.configurator.le_util.exe_exists") + def test_prepare_no_install(self, mock_exe_exists): + mock_exe_exists.return_value = False + self.assertRaises( + errors.NoInstallationError, self.config.prepare) + def test_prepare(self): self.assertEquals((1, 6, 2), self.config.version) self.assertEquals(5, len(self.config.parser.parsed)) @@ -51,7 +58,7 @@ class NginxConfiguratorTest(util.NginxTest): errors.PluginError, self.config.enhance, 'myhost', 'redirect') def test_get_chall_pref(self): - self.assertEqual([challenges.DVSNI], + self.assertEqual([challenges.TLSSNI01], self.config.get_chall_pref('myhost')) def test_save(self): @@ -210,22 +217,22 @@ class NginxConfiguratorTest(util.NginxTest): def test_perform(self, mock_restart, mock_dvsni_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded - achall1 = achallenges.DVSNI( + achall1 = achallenges.KeyAuthorizationAnnotatedChallenge( challb=messages.ChallengeBody( - chall=challenges.DVSNI(token="kNdwjwOeX0I_A8DXt9Msmg"), + chall=challenges.TLSSNI01(token="kNdwjwOeX0I_A8DXt9Msmg"), uri="https://ca.org/chall0_uri", status=messages.Status("pending"), ), domain="localhost", account_key=self.rsa512jwk) - achall2 = achallenges.DVSNI( + achall2 = achallenges.KeyAuthorizationAnnotatedChallenge( challb=messages.ChallengeBody( - chall=challenges.DVSNI(token="m8TdO1qik4JVFtgPPurJmg"), + chall=challenges.TLSSNI01(token="m8TdO1qik4JVFtgPPurJmg"), uri="https://ca.org/chall1_uri", status=messages.Status("pending"), ), domain="example.com", account_key=self.rsa512jwk) dvsni_ret_val = [ - achall1.gen_response(self.rsa512jwk), - achall2.gen_response(self.rsa512jwk), + achall1.response(self.rsa512jwk), + achall2.response(self.rsa512jwk), ] mock_dvsni_perform.return_value = dvsni_ret_val diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py index 9fc0a1ad7..d32e3d98f 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/dvsni_test.py @@ -19,22 +19,22 @@ from letsencrypt_nginx.tests import util class DvsniPerformTest(util.NginxTest): """Test the NginxDVSNI challenge.""" - account_key = common_test.DvsniTest.auth_key + account_key = common_test.TLSSNI01Test.auth_key achalls = [ - achallenges.DVSNI( + achallenges.KeyAuthorizationAnnotatedChallenge( challb=acme_util.chall_to_challb( - challenges.DVSNI(token="kNdwjwOeX0I_A8DXt9Msmg"), "pending"), + challenges.TLSSNI01(token="kNdwjwOeX0I_A8DXt9Msmg"), "pending"), domain="www.example.com", account_key=account_key), - achallenges.DVSNI( + achallenges.KeyAuthorizationAnnotatedChallenge( challb=acme_util.chall_to_challb( - challenges.DVSNI( + challenges.TLSSNI01( token="\xba\xa9\xda? 1 < 7 chars + # first and last char is not "-" + fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(?