From 55ca1b484fc18626e89b111ff5e960a4081694c1 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Fri, 1 Jan 2016 20:55:52 -0500 Subject: [PATCH 01/47] Initial verison of DNS-01 implementation --- acme/acme/challenges.py | 79 ++++++++++++++++++++++++++++++ acme/acme/challenges_test.py | 93 ++++++++++++++++++++++++++++++++++++ acme/setup.py | 1 + 3 files changed, 173 insertions(+) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 1e456d325..f48c20443 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -1,5 +1,8 @@ """ACME Identifier Validation Challenges.""" import abc +import base64 +import dns.resolver +import dns.exception import functools import hashlib import logging @@ -215,6 +218,82 @@ class KeyAuthorizationChallenge(_TokenDVChallenge): self.validation(account_key, *args, **kwargs)) +@ChallengeResponse.register +class DNS01Response(KeyAuthorizationChallengeResponse): + """ACME "dns-01" challenge response.""" + typ = "dns-01" + + def simple_verify(self, chall, domain, account_public_key): + """Simple verify. + + :param challenges.DNS01 chall: Corresponding challenge. + :param unicode domain: Domain name being verified. + :param account_public_key: Public key for the key pair + being authorized. If ``None`` key verification is not + performed! + :param JWK account_public_key: + + :returns: ``True`` iff validation is successful, ``False`` + otherwise. + :rtype: bool + + """ + if not self.verify(chall, account_public_key): + logger.debug("Verification of key authorization in response failed") + return False + + validation_name = chall.validation_domain_name(domain) + validation = chall.validation(account_public_key) + logger.debug("Verifying %s at %s...", chall.typ, validation_name) + txt_records = [] + try: + dns_response = dns.resolver.query(validation_name, 'TXT') + for rdata in dns_response: + for txt_record in rdata.strings: + txt_records.append(txt_record) + except dns.exception.DNSException as error: + logger.error("Unable to resolve %s: %s", validation_name, error) + return False + + for txt_record in txt_records: + if txt_record == validation: + return True + + logger.debug("Key authorization from response (%r) doesn't match any " + "DNS response in %r", self.key_authorization, txt_records) + return False + +@Challenge.register # pylint: disable=too-many-ancestors +class DNS01(KeyAuthorizationChallenge): + """ACME "dns-01" challenge.""" + + response_cls = DNS01Response + typ = response_cls.typ + + LABEL = "_acme-challenge" + """Label clients prepend to the domain name being validated.""" + + def validation(self, account_key, **unused_kwargs): + """Generate validation. + + :param JWK account_key: + :rtype: unicode + + """ + key_authorization = self.key_authorization(account_key) + # FIXME Once boulder response according to the spec this needs to be fixed + # return base64.b64encode(hashlib.sha256(key_authorization).digest()) + return hashlib.sha256(key_authorization).hexdigest() + + def validation_domain_name(self, name): + """Domain name for TXT validation record. + + :param unicode name: Domain name being validated. + + """ + return "{0}.{1}".format(self.LABEL, name) + + @ChallengeResponse.register class HTTP01Response(KeyAuthorizationChallengeResponse): """ACME http-01 challenge response.""" diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index a4e78ebe9..a587d3f61 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -1,6 +1,7 @@ """Tests for acme.challenges.""" import unittest +import dns.rrset import mock import OpenSSL import requests @@ -76,6 +77,98 @@ class KeyAuthorizationChallengeResponseTest(unittest.TestCase): key_authorization='.foo.oKGqedy-b-acd5eoybm2f-NVFxvyOoET5CNy3xnv8WY') self.assertFalse(response.verify(self.chall, KEY.public_key())) +class DNS01ResponseTest(unittest.TestCase): + # pylint: disable=too-many-instance-attributes + + def setUp(self): + from acme.challenges import DNS01Response + self.msg = DNS01Response(key_authorization=u'foo') + self.jmsg = { + 'resource': 'challenge', + 'type': 'dns-01', + 'keyAuthorization': u'foo', + } + + from acme.challenges import DNS01 + self.chall = DNS01(token=(b'x' * 16)) + self.response = self.chall.response(KEY) + + # This takes advantage of the fact that an answer object mostly behaves like + # an RRset + def create_txt_response(self, name, txt_record): + return dns.rrset.from_text(name, 60, "IN", "TXT", txt_record) + + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) + + def test_from_json(self): + from acme.challenges import DNS01Response + self.assertEqual( + self.msg, DNS01Response.from_json(self.jmsg)) + + def test_from_json_hashable(self): + from acme.challenges import DNS01Response + hash(DNS01Response.from_json(self.jmsg)) + + 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.dns.resolver.query") + def test_simple_verify_good_validation(self, mock_dns): + mock_dns.return_value = self.create_txt_response( + self.chall.validation_domain_name("local"), + self.chall.validation(KEY.public_key())) + self.assertTrue(self.response.simple_verify( + self.chall, "local", KEY.public_key())) + mock_dns.assert_called_once_with( + self.chall.validation_domain_name("local"), "TXT") + + @mock.patch("acme.challenges.dns.resolver.query") + def test_simple_verify_bad_validation(self, mock_dns): + mock_dns.return_value = self.create_txt_response( + self.chall.validation_domain_name("local"), "!") + self.assertFalse(self.response.simple_verify( + self.chall, "local", KEY.public_key())) + + @mock.patch("acme.challenges.dns.resolver.query") + def test_simple_verify_connection_error(self, mock_dns): + mock_dns.side_effect = dns.exception.DNSException + self.assertFalse(self.response.simple_verify( + self.chall, "local", KEY.public_key())) + +class DNS01Test(unittest.TestCase): + + def setUp(self): + from acme.challenges import DNS01 + self.msg = DNS01( + token=jose.decode_b64jose( + 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')) + self.jmsg = { + 'type': 'dns-01', + 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA', + } + + def test_validation_domain_name(self): + self.assertEqual('_acme-challenge.www.example.com', + self.msg.validation_domain_name('www.example.com')) + + def test_validation(self): + self.assertEqual( + "ac06bb8888382b6cbaddfbd48427f2f1d3f55e5ef0121990ab4a02853704dd99", + self.msg.validation(KEY)) + + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) + + def test_from_json(self): + from acme.challenges import DNS01 + self.assertEqual(self.msg, DNS01.from_json(self.jmsg)) + + def test_from_json_hashable(self): + from acme.challenges import DNS01 + hash(DNS01.from_json(self.jmsg)) + class HTTP01ResponseTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes diff --git a/acme/setup.py b/acme/setup.py index ba2c88394..dd2bce5d9 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -12,6 +12,7 @@ install_requires = [ 'cryptography>=0.8', # Connection.set_tlsext_host_name (>=0.13), X509Req.get_extensions (>=0.15) 'PyOpenSSL>=0.15', + 'dnspython', 'pyrfc3339', 'pytz', 'requests', From ffc2b1ee7864a848c0f2a22ca873ac970bb911bf Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sat, 2 Jan 2016 01:42:47 -0500 Subject: [PATCH 02/47] - Lint fixes - Add test for multiple TXT records returned - Add extra parameter in DNS01.validation to select hexdigit vs. bas64 encoded validation --- acme/acme/challenges.py | 23 +++++++++++------------ acme/acme/challenges_test.py | 36 +++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index f48c20443..6c13ec906 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -242,17 +242,15 @@ class DNS01Response(KeyAuthorizationChallengeResponse): logger.debug("Verification of key authorization in response failed") return False - validation_name = chall.validation_domain_name(domain) + validation_domain_name = chall.validation_domain_name(domain) validation = chall.validation(account_public_key) - logger.debug("Verifying %s at %s...", chall.typ, validation_name) - txt_records = [] + logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name) try: - dns_response = dns.resolver.query(validation_name, 'TXT') - for rdata in dns_response: - for txt_record in rdata.strings: - txt_records.append(txt_record) + dns_response = dns.resolver.query(validation_domain_name, 'TXT') + txt_records = sum([rdata.strings for rdata in dns_response], []) except dns.exception.DNSException as error: - logger.error("Unable to resolve %s: %s", validation_name, error) + logger.error("Unable to resolve %s: %s", validation_domain_name, + error) return False for txt_record in txt_records: @@ -273,7 +271,8 @@ class DNS01(KeyAuthorizationChallenge): LABEL = "_acme-challenge" """Label clients prepend to the domain name being validated.""" - def validation(self, account_key, **unused_kwargs): + # FIXME: Remove extra parameter once #2052 is integrated + def validation(self, account_key, dns01_hexdigit_response=True, **unused_kwargs): """Generate validation. :param JWK account_key: @@ -281,9 +280,9 @@ class DNS01(KeyAuthorizationChallenge): """ key_authorization = self.key_authorization(account_key) - # FIXME Once boulder response according to the spec this needs to be fixed - # return base64.b64encode(hashlib.sha256(key_authorization).digest()) - return hashlib.sha256(key_authorization).hexdigest() + if dns01_hexdigit_response: + return hashlib.sha256(key_authorization).hexdigest() + return base64.urlsafe_b64encode(hashlib.sha256(key_authorization).digest()) def validation_domain_name(self, name): """Domain name for TXT validation record. diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index a587d3f61..c2a629bb3 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -93,10 +93,15 @@ class DNS01ResponseTest(unittest.TestCase): self.chall = DNS01(token=(b'x' * 16)) self.response = self.chall.response(KEY) - # This takes advantage of the fact that an answer object mostly behaves like - # an RRset - def create_txt_response(self, name, txt_record): - return dns.rrset.from_text(name, 60, "IN", "TXT", txt_record) + def create_txt_response(self, name, txt_records): + """ + Returns an RRSet containing the 'txt_records' as the result of a DNS + query for 'name'. + + This takes advantage of the fact that an Answer object mostly behaves + like an RRset. + """ + return dns.rrset.from_text_list(name, 60, "IN", "TXT", txt_records) def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -118,7 +123,17 @@ class DNS01ResponseTest(unittest.TestCase): def test_simple_verify_good_validation(self, mock_dns): mock_dns.return_value = self.create_txt_response( self.chall.validation_domain_name("local"), - self.chall.validation(KEY.public_key())) + [self.chall.validation(KEY.public_key())]) + self.assertTrue(self.response.simple_verify( + self.chall, "local", KEY.public_key())) + mock_dns.assert_called_once_with( + self.chall.validation_domain_name("local"), "TXT") + + @mock.patch("acme.challenges.dns.resolver.query") + def test_simple_verify_good_validation_multiple_txts(self, mock_dns): + mock_dns.return_value = self.create_txt_response( + self.chall.validation_domain_name("local"), + ["!", self.chall.validation(KEY.public_key())]) self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) mock_dns.assert_called_once_with( @@ -127,7 +142,7 @@ class DNS01ResponseTest(unittest.TestCase): @mock.patch("acme.challenges.dns.resolver.query") def test_simple_verify_bad_validation(self, mock_dns): mock_dns.return_value = self.create_txt_response( - self.chall.validation_domain_name("local"), "!") + self.chall.validation_domain_name("local"), ["!"]) self.assertFalse(self.response.simple_verify( self.chall, "local", KEY.public_key())) @@ -153,10 +168,17 @@ class DNS01Test(unittest.TestCase): self.assertEqual('_acme-challenge.www.example.com', self.msg.validation_domain_name('www.example.com')) + # FIXME: Remove extra parameter once #2052 is integrated def test_validation(self): + self.assertEqual( + "rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk=", + self.msg.validation(KEY, dns01_hexdigit_response=False)) + + # FIXME: Remove this once #2052 is integrated + def test_validation_for_server_with_hexdigit_response(self): self.assertEqual( "ac06bb8888382b6cbaddfbd48427f2f1d3f55e5ef0121990ab4a02853704dd99", - self.msg.validation(KEY)) + self.msg.validation(KEY, dns01_hexdigit_response=True)) def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) From 7e2a1532ef5a3c48e3d1cf9c382a13da0ed62097 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sat, 2 Jan 2016 12:53:47 -0500 Subject: [PATCH 03/47] Move dns record retrieval into a separate method. --- acme/acme/challenges.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 6c13ec906..cc938dd31 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -223,6 +223,22 @@ class DNS01Response(KeyAuthorizationChallengeResponse): """ACME "dns-01" challenge response.""" typ = "dns-01" + def txt_records_for_name(self, name): + """Resolve the name and return the TXT records. + + :param unicode name: Domain name being verified. + + :returns: A list of txt records, or None if the name could not be resolved + :rtype: list of unicode + + """ + try: + dns_response = dns.resolver.query(name, 'TXT') + except dns.exception.DNSException as error: + logger.error("Unable to resolve %s: %s", name, error) + return None + return sum([rdata.strings for rdata in dns_response], []) + def simple_verify(self, chall, domain, account_public_key): """Simple verify. @@ -245,15 +261,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse): validation_domain_name = chall.validation_domain_name(domain) validation = chall.validation(account_public_key) logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name) - try: - dns_response = dns.resolver.query(validation_domain_name, 'TXT') - txt_records = sum([rdata.strings for rdata in dns_response], []) - except dns.exception.DNSException as error: - logger.error("Unable to resolve %s: %s", validation_domain_name, - error) - return False - - for txt_record in txt_records: + for txt_record in self.txt_records_for_domain(validation_domain_name): if txt_record == validation: return True From 64f3f53467b33d97359d0ba6f9795d5757d3f4a7 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sat, 2 Jan 2016 13:51:37 -0500 Subject: [PATCH 04/47] Fix --- acme/acme/challenges.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index cc938dd31..5c3c8e8aa 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -261,7 +261,11 @@ class DNS01Response(KeyAuthorizationChallengeResponse): validation_domain_name = chall.validation_domain_name(domain) validation = chall.validation(account_public_key) logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name) - for txt_record in self.txt_records_for_domain(validation_domain_name): + txt_records = self.txt_records_for_name(validation_domain_name) + if txt_records == None: + return False + + for txt_record in txt_records: if txt_record == validation: return True From 97fb1a03f99cd219cffe7e77ab6c616c03b9afec Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 3 Jan 2016 13:19:32 -0500 Subject: [PATCH 05/47] Documentation fixes. --- acme/acme/challenges.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 5c3c8e8aa..6ca36d482 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -249,8 +249,8 @@ class DNS01Response(KeyAuthorizationChallengeResponse): performed! :param JWK account_public_key: - :returns: ``True`` iff validation is successful, ``False`` - otherwise. + :returns: ``True`` iff validation with the TXT records resolved from a + DNS server is successful. :rtype: bool """ @@ -332,8 +332,8 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): :param JWK account_public_key: :param int port: Port used in the validation. - :returns: ``True`` iff validation is successful, ``False`` - otherwise. + :returns: ``True`` iff validation of the files currently server by the + HTTP server is successful. :rtype: bool """ @@ -504,7 +504,7 @@ class TLSSNI01Response(KeyAuthorizationChallengeResponse): :returns: ``True`` iff client's control of the domain has been - verified, ``False`` otherwise. + verified. :rtype: bool """ From 74a9703269a320a0180e8360218e6f8de599d0ba Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Mon, 4 Jan 2016 16:16:42 -0500 Subject: [PATCH 06/47] Style fix --- acme/acme/challenges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 6ca36d482..b783eee9c 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -262,7 +262,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse): validation = chall.validation(account_public_key) logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name) txt_records = self.txt_records_for_name(validation_domain_name) - if txt_records == None: + if txt_records is None: return False for txt_record in txt_records: From 7747dc8488f2ea67b21ad53ffe9e31cd8e7d1232 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Mon, 4 Jan 2016 19:46:28 -0500 Subject: [PATCH 07/47] Remove non-compliant hexdigit encoding for dns-01 challenges (#2052 is now merged). --- acme/acme/challenges.py | 8 ++------ acme/acme/challenges_test.py | 11 ++--------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 6ca36d482..77ca2e214 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -1,6 +1,5 @@ """ACME Identifier Validation Challenges.""" import abc -import base64 import dns.resolver import dns.exception import functools @@ -283,8 +282,7 @@ class DNS01(KeyAuthorizationChallenge): LABEL = "_acme-challenge" """Label clients prepend to the domain name being validated.""" - # FIXME: Remove extra parameter once #2052 is integrated - def validation(self, account_key, dns01_hexdigit_response=True, **unused_kwargs): + def validation(self, account_key, **unused_kwargs): """Generate validation. :param JWK account_key: @@ -292,9 +290,7 @@ class DNS01(KeyAuthorizationChallenge): """ key_authorization = self.key_authorization(account_key) - if dns01_hexdigit_response: - return hashlib.sha256(key_authorization).hexdigest() - return base64.urlsafe_b64encode(hashlib.sha256(key_authorization).digest()) + return jose.b64encode(hashlib.sha256(key_authorization).digest()) def validation_domain_name(self, name): """Domain name for TXT validation record. diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index c2a629bb3..b6c771d1b 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -168,17 +168,10 @@ class DNS01Test(unittest.TestCase): self.assertEqual('_acme-challenge.www.example.com', self.msg.validation_domain_name('www.example.com')) - # FIXME: Remove extra parameter once #2052 is integrated def test_validation(self): self.assertEqual( - "rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk=", - self.msg.validation(KEY, dns01_hexdigit_response=False)) - - # FIXME: Remove this once #2052 is integrated - def test_validation_for_server_with_hexdigit_response(self): - self.assertEqual( - "ac06bb8888382b6cbaddfbd48427f2f1d3f55e5ef0121990ab4a02853704dd99", - self.msg.validation(KEY, dns01_hexdigit_response=True)) + "rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk", + self.msg.validation(KEY)) def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) From b5bb90628c080d7719546438d73b01534c7db25a Mon Sep 17 00:00:00 2001 From: wteiken Date: Tue, 5 Jan 2016 20:33:30 -0500 Subject: [PATCH 08/47] Style changes. --- acme/acme/challenges.py | 30 ++++++++++++------------------ acme/acme/challenges_test.py | 4 ++-- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 77ca2e214..cb8873984 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -1,13 +1,13 @@ """ACME Identifier Validation Challenges.""" import abc -import dns.resolver -import dns.exception import functools import hashlib import logging import socket from cryptography.hazmat.primitives import hashes +import dns.resolver +import dns.exception import OpenSSL import requests @@ -217,17 +217,16 @@ class KeyAuthorizationChallenge(_TokenDVChallenge): self.validation(account_key, *args, **kwargs)) -@ChallengeResponse.register class DNS01Response(KeyAuthorizationChallengeResponse): """ACME "dns-01" challenge response.""" typ = "dns-01" - def txt_records_for_name(self, name): + def txt_records_for_name(name): """Resolve the name and return the TXT records. :param unicode name: Domain name being verified. - :returns: A list of txt records, or None if the name could not be resolved + :returns: A list of txt records, if empty the name could not be resolved :rtype: list of unicode """ @@ -235,17 +234,17 @@ class DNS01Response(KeyAuthorizationChallengeResponse): dns_response = dns.resolver.query(name, 'TXT') except dns.exception.DNSException as error: logger.error("Unable to resolve %s: %s", name, error) - return None - return sum([rdata.strings for rdata in dns_response], []) + return [] + return [txt_rec in dns_response for txt_rec in rdata.strings] +@ChallengeResponse.register def simple_verify(self, chall, domain, account_public_key): """Simple verify. :param challenges.DNS01 chall: Corresponding challenge. :param unicode domain: Domain name being verified. :param account_public_key: Public key for the key pair - being authorized. If ``None`` key verification is not - performed! + being authorized. :param JWK account_public_key: :returns: ``True`` iff validation with the TXT records resolved from a @@ -260,14 +259,9 @@ class DNS01Response(KeyAuthorizationChallengeResponse): validation_domain_name = chall.validation_domain_name(domain) validation = chall.validation(account_public_key) logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name) - txt_records = self.txt_records_for_name(validation_domain_name) - if txt_records == None: - return False - - for txt_record in txt_records: - if txt_record == validation: - return True + if validation in txt_records_for_name(validation_domain_name): + return True logger.debug("Key authorization from response (%r) doesn't match any " "DNS response in %r", self.key_authorization, txt_records) return False @@ -289,8 +283,8 @@ class DNS01(KeyAuthorizationChallenge): :rtype: unicode """ - key_authorization = self.key_authorization(account_key) - return jose.b64encode(hashlib.sha256(key_authorization).digest()) + return jose.b64encode(hashlib.sha256( + self.key_authorization(account_key)).digest()) def validation_domain_name(self, name): """Domain name for TXT validation record. diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index b6c771d1b..b744de024 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -77,6 +77,7 @@ class KeyAuthorizationChallengeResponseTest(unittest.TestCase): key_authorization='.foo.oKGqedy-b-acd5eoybm2f-NVFxvyOoET5CNy3xnv8WY') self.assertFalse(response.verify(self.chall, KEY.public_key())) + class DNS01ResponseTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes @@ -108,8 +109,7 @@ class DNS01ResponseTest(unittest.TestCase): def test_from_json(self): from acme.challenges import DNS01Response - self.assertEqual( - self.msg, DNS01Response.from_json(self.jmsg)) + self.assertEqual(self.msg, DNS01Response.from_json(self.jmsg)) def test_from_json_hashable(self): from acme.challenges import DNS01Response From 4403a78e52e684d5b94b8982f5b86609fc1fc20f Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Tue, 5 Jan 2016 22:25:24 -0500 Subject: [PATCH 09/47] Move txt_records_for_name out of class. --- acme/acme/challenges.py | 49 +++++++++++++++++++----------------- acme/acme/challenges_test.py | 1 + 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index cb8873984..e235a087e 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -217,27 +217,11 @@ class KeyAuthorizationChallenge(_TokenDVChallenge): self.validation(account_key, *args, **kwargs)) +@ChallengeResponse.register class DNS01Response(KeyAuthorizationChallengeResponse): """ACME "dns-01" challenge response.""" typ = "dns-01" - def txt_records_for_name(name): - """Resolve the name and return the TXT records. - - :param unicode name: Domain name being verified. - - :returns: A list of txt records, if empty the name could not be resolved - :rtype: list of unicode - - """ - try: - dns_response = dns.resolver.query(name, 'TXT') - except dns.exception.DNSException as error: - logger.error("Unable to resolve %s: %s", name, error) - return [] - return [txt_rec in dns_response for txt_rec in rdata.strings] - -@ChallengeResponse.register def simple_verify(self, chall, domain, account_public_key): """Simple verify. @@ -260,16 +244,18 @@ class DNS01Response(KeyAuthorizationChallengeResponse): validation = chall.validation(account_public_key) logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name) - if validation in txt_records_for_name(validation_domain_name): - return True - logger.debug("Key authorization from response (%r) doesn't match any " - "DNS response in %r", self.key_authorization, txt_records) - return False + txt_records = txt_records_for_name(validation_domain_name) + exists = validation in txt_records + if not exists: + logger.debug("Key authorization from response (%r) doesn't match " + "any DNS response in %r", self.key_authorization, + txt_records) + return exists + @Challenge.register # pylint: disable=too-many-ancestors class DNS01(KeyAuthorizationChallenge): """ACME "dns-01" challenge.""" - response_cls = DNS01Response typ = response_cls.typ @@ -716,3 +702,20 @@ class DNSResponse(ChallengeResponse): """ return chall.check_validation(self.validation, account_public_key) + + +def txt_records_for_name(name): + """Resolve the name and return the TXT records. + + :param unicode name: Domain name being verified. + + :returns: A list of txt records, if empty the name could not be resolved + :rtype: list of unicode + + """ + try: + dns_response = dns.resolver.query(name, 'TXT') + except dns.exception.DNSException as error: + logger.error("Unable to resolve %s: %s", name, error) + return [] + return [txt_rec for rdata in dns_response for txt_rec in rdata.strings] diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index b744de024..79a928456 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -152,6 +152,7 @@ class DNS01ResponseTest(unittest.TestCase): self.assertFalse(self.response.simple_verify( self.chall, "local", KEY.public_key())) + class DNS01Test(unittest.TestCase): def setUp(self): From fd2709a6fa2c2cc764aa2b1a50178ad62e71a1c5 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Tue, 5 Jan 2016 23:58:23 -0500 Subject: [PATCH 10/47] Move dnspython dependency to tests only and only import the dns.resolver when actually resolving the client. That way user code that does not call 'simple_verify' for DNS01 challenges does not depend on dnspython. --- acme/acme/challenges.py | 10 ++++++---- acme/setup.py | 3 +-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index e235a087e..aa7b20689 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -6,8 +6,6 @@ import logging import socket from cryptography.hazmat.primitives import hashes -import dns.resolver -import dns.exception import OpenSSL import requests @@ -714,8 +712,12 @@ def txt_records_for_name(name): """ try: + import dns.resolver dns_response = dns.resolver.query(name, 'TXT') - except dns.exception.DNSException as error: - logger.error("Unable to resolve %s: %s", name, error) + except ImportError as error: + raise ImportError("Local validation for 'dns-01' challenges requires " + "'dnspython'"); + except Exception as error: + logger.error("Unable to resolve %s: %s", name, str(error)) return [] return [txt_rec for rdata in dns_response for txt_rec in rdata.strings] diff --git a/acme/setup.py b/acme/setup.py index dd2bce5d9..76a2c1b72 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -12,7 +12,6 @@ install_requires = [ 'cryptography>=0.8', # Connection.set_tlsext_host_name (>=0.13), X509Req.get_extensions (>=0.15) 'PyOpenSSL>=0.15', - 'dnspython', 'pyrfc3339', 'pytz', 'requests', @@ -76,7 +75,7 @@ setup( install_requires=install_requires, extras_require={ 'docs': docs_extras, - 'testing': testing_extras, + 'testing': testing_extras + 'dnspython', }, entry_points={ 'console_scripts': [ From 57c265c7f36087bb3de0f96a25cfcd8ae62538ab Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 00:27:07 -0500 Subject: [PATCH 11/47] Setup.py and style fixes --- acme/acme/challenges.py | 5 +++-- acme/setup.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index aa7b20689..f4eb115c4 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -713,11 +713,12 @@ def txt_records_for_name(name): """ try: import dns.resolver + import dns.exception dns_response = dns.resolver.query(name, 'TXT') except ImportError as error: raise ImportError("Local validation for 'dns-01' challenges requires " - "'dnspython'"); - except Exception as error: + "'dnspython'") + except dns.exception.DNSException as error: logger.error("Unable to resolve %s: %s", name, str(error)) return [] return [txt_rec for rdata in dns_response for txt_rec in rdata.strings] diff --git a/acme/setup.py b/acme/setup.py index 76a2c1b72..e00476117 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -75,7 +75,7 @@ setup( install_requires=install_requires, extras_require={ 'docs': docs_extras, - 'testing': testing_extras + 'dnspython', + 'testing': testing_extras + ['dnspython'], }, entry_points={ 'console_scripts': [ From dc743fb57cc9c3c7fb805ece50681f1388440f9c Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 01:11:24 -0500 Subject: [PATCH 12/47] Move DNS resolver to separate module to decouple dependencies and testing. --- acme/acme/challenges.py | 30 ++++++---------------- acme/acme/challenges_test.py | 46 +++++++++------------------------- acme/acme/dns_resolver.py | 28 +++++++++++++++++++++ acme/acme/dns_resolver_test.py | 37 +++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 57 deletions(-) create mode 100644 acme/acme/dns_resolver.py create mode 100644 acme/acme/dns_resolver_test.py diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index f4eb115c4..221eb406c 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -15,7 +15,6 @@ from acme import fields from acme import jose from acme import other - logger = logging.getLogger(__name__) @@ -242,7 +241,13 @@ class DNS01Response(KeyAuthorizationChallengeResponse): validation = chall.validation(account_public_key) logger.debug("Verifying %s at %s...", chall.typ, validation_domain_name) - txt_records = txt_records_for_name(validation_domain_name) + try: + from acme import dns_resolver + txt_records = dns_resolver.txt_records_for_name( + validation_domain_name) + except ImportError as error: + raise ImportError("Local validation for 'dns-01' challenges " + "requires 'dnspython'") exists = validation in txt_records if not exists: logger.debug("Key authorization from response (%r) doesn't match " @@ -701,24 +706,3 @@ class DNSResponse(ChallengeResponse): """ return chall.check_validation(self.validation, account_public_key) - -def txt_records_for_name(name): - """Resolve the name and return the TXT records. - - :param unicode name: Domain name being verified. - - :returns: A list of txt records, if empty the name could not be resolved - :rtype: list of unicode - - """ - try: - import dns.resolver - import dns.exception - dns_response = dns.resolver.query(name, 'TXT') - except ImportError as error: - raise ImportError("Local validation for 'dns-01' challenges requires " - "'dnspython'") - except dns.exception.DNSException as error: - logger.error("Unable to resolve %s: %s", name, str(error)) - return [] - return [txt_rec for rdata in dns_response for txt_rec in rdata.strings] diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index 79a928456..0fc8fcef7 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -1,7 +1,6 @@ """Tests for acme.challenges.""" import unittest -import dns.rrset import mock import OpenSSL import requests @@ -94,16 +93,6 @@ class DNS01ResponseTest(unittest.TestCase): self.chall = DNS01(token=(b'x' * 16)) self.response = self.chall.response(KEY) - def create_txt_response(self, name, txt_records): - """ - Returns an RRSet containing the 'txt_records' as the result of a DNS - query for 'name'. - - This takes advantage of the fact that an Answer object mostly behaves - like an RRset. - """ - return dns.rrset.from_text_list(name, 60, "IN", "TXT", txt_records) - def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -119,36 +108,25 @@ class DNS01ResponseTest(unittest.TestCase): 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.dns.resolver.query") - def test_simple_verify_good_validation(self, mock_dns): - mock_dns.return_value = self.create_txt_response( - self.chall.validation_domain_name("local"), - [self.chall.validation(KEY.public_key())]) + @mock.patch("acme.dns_resolver.txt_records_for_name") + def test_simple_verify_good_validation(self, mock_resolver): + mock_resolver.return_value = [self.chall.validation(KEY.public_key())] self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) - mock_dns.assert_called_once_with( - self.chall.validation_domain_name("local"), "TXT") + mock_resolver.assert_called_once_with( + self.chall.validation_domain_name("local")) - @mock.patch("acme.challenges.dns.resolver.query") - def test_simple_verify_good_validation_multiple_txts(self, mock_dns): - mock_dns.return_value = self.create_txt_response( - self.chall.validation_domain_name("local"), - ["!", self.chall.validation(KEY.public_key())]) + @mock.patch("acme.dns_resolver.txt_records_for_name") + def test_simple_verify_good_validation_multiple_txts(self, mock_resolver): + mock_resolver.return_value = ["!", self.chall.validation(KEY.public_key())] self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) - mock_dns.assert_called_once_with( - self.chall.validation_domain_name("local"), "TXT") + mock_resolver.assert_called_once_with( + self.chall.validation_domain_name("local")) - @mock.patch("acme.challenges.dns.resolver.query") + @mock.patch("acme.dns_resolver.txt_records_for_name") def test_simple_verify_bad_validation(self, mock_dns): - mock_dns.return_value = self.create_txt_response( - self.chall.validation_domain_name("local"), ["!"]) - self.assertFalse(self.response.simple_verify( - self.chall, "local", KEY.public_key())) - - @mock.patch("acme.challenges.dns.resolver.query") - def test_simple_verify_connection_error(self, mock_dns): - mock_dns.side_effect = dns.exception.DNSException + mock_dns.return_value = ["!"] self.assertFalse(self.response.simple_verify( self.chall, "local", KEY.public_key())) diff --git a/acme/acme/dns_resolver.py b/acme/acme/dns_resolver.py new file mode 100644 index 000000000..04e52224e --- /dev/null +++ b/acme/acme/dns_resolver.py @@ -0,0 +1,28 @@ +"""DNS Resolver for ACME client. +Required only for local validation of 'dns-01' challenges. +""" +import logging + +import dns.resolver +import dns.exception + +logger = logging.getLogger(__name__) + +def txt_records_for_name(name): + """Resolve the name and return the TXT records. + + :param unicode name: Domain name being verified. + + :returns: A list of txt records, if empty the name could not be resolved + :rtype: list of unicode + + """ + try: + dns_response = dns.resolver.query(name, 'TXT') + except ImportError as error: + raise ImportError("Local validation for 'dns-01' challenges requires " + "'dnspython'") + except dns.exception.DNSException as error: + logger.error("Unable to resolve %s: %s", name, str(error)) + return [] + return [txt_rec for rdata in dns_response for txt_rec in rdata.strings] diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py new file mode 100644 index 000000000..d1daa2d37 --- /dev/null +++ b/acme/acme/dns_resolver_test.py @@ -0,0 +1,37 @@ +"""Tests for acme.dns_resolver.""" +import unittest + +import dns +import mock + +from acme import dns_resolver + +class TxtRecordsForNameTest(unittest.TestCase): + + def create_txt_response(self, name, txt_records): + """ + Returns an RRSet containing the 'txt_records' as the result of a DNS + query for 'name'. + + This takes advantage of the fact that an Answer object mostly behaves + like an RRset. + """ + return dns.rrset.from_text_list(name, 60, "IN", "TXT", txt_records) + + @mock.patch("acme.dns_resolver.dns.resolver.query") + def test_txt_records_for_name_test_with_single_response(self, mock_dns): + mock_dns.return_value = self.create_txt_response('name', ['response']) + self.assertEqual(['response'], + dns_resolver.txt_records_for_name('name')) + + @mock.patch("acme.dns_resolver.dns.resolver.query") + def test_txt_records_for_name_with_multiple_responses(self, mock_dns): + mock_dns.return_value = self.create_txt_response( + 'name', ['response1', 'response2']) + self.assertEqual(['response1', 'response2'], + dns_resolver.txt_records_for_name('name')) + + @mock.patch("acme.dns_resolver.dns.resolver.query") + def test_txt_records_for_name_domain_not_found(self, mock_dns): + mock_dns.side_effect = dns.exception.DNSException + self.assertEquals([], dns_resolver.txt_records_for_name('name')) From a9a5e60bc59a65fb8d02f60f88946f1bafcc655c Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 01:26:32 -0500 Subject: [PATCH 13/47] Added requirements for coverage and lint. --- acme/acme/challenges.py | 2 +- acme/acme/dns_resolver_test.py | 2 +- acme/setup.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 221eb406c..22bda6445 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -245,7 +245,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse): from acme import dns_resolver txt_records = dns_resolver.txt_records_for_name( validation_domain_name) - except ImportError as error: + except ImportError: raise ImportError("Local validation for 'dns-01' challenges " "requires 'dnspython'") exists = validation in txt_records diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index d1daa2d37..2202ce4e8 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -23,7 +23,7 @@ class TxtRecordsForNameTest(unittest.TestCase): mock_dns.return_value = self.create_txt_response('name', ['response']) self.assertEqual(['response'], dns_resolver.txt_records_for_name('name')) - + @mock.patch("acme.dns_resolver.dns.resolver.query") def test_txt_records_for_name_with_multiple_responses(self, mock_dns): mock_dns.return_value = self.create_txt_response( diff --git a/acme/setup.py b/acme/setup.py index e00476117..269d9e1fd 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -74,7 +74,9 @@ setup( include_package_data=True, install_requires=install_requires, extras_require={ + 'coverage': ['dnspython'], 'docs': docs_extras, + 'lint': ['dnspython'], 'testing': testing_extras + ['dnspython'], }, entry_points={ From d2ced2de6aa31d6fe2aab4b68a7feb89aad307d1 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 01:48:12 -0500 Subject: [PATCH 14/47] Dep fixes for lint/coverage. --- acme/setup.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/acme/setup.py b/acme/setup.py index 269d9e1fd..e4a525605 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -35,6 +35,10 @@ if sys.version_info < (2, 7, 9): install_requires.append('ndg-httpsclient') install_requires.append('pyasn1') +dev_extras = [ + 'dnspython', +] + docs_extras = [ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags 'sphinx_rtd_theme', @@ -42,6 +46,7 @@ docs_extras = [ ] testing_extras = [ + 'dnspython', 'nose', 'tox', ] @@ -74,10 +79,9 @@ setup( include_package_data=True, install_requires=install_requires, extras_require={ - 'coverage': ['dnspython'], + 'dev': dev_extras, 'docs': docs_extras, - 'lint': ['dnspython'], - 'testing': testing_extras + ['dnspython'], + 'testing': testing_extras, }, entry_points={ 'console_scripts': [ From 52c487f462067ca810f3f3b840876306dfd1e9b1 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 02:44:19 -0500 Subject: [PATCH 15/47] Add new 'test' extras and update tox.ini accordingly. --- acme/acme/challenges_test.py | 5 ++--- acme/setup.py | 5 ++--- tox.ini | 10 +++++----- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index 0fc8fcef7..ac94619b5 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -135,9 +135,8 @@ class DNS01Test(unittest.TestCase): def setUp(self): from acme.challenges import DNS01 - self.msg = DNS01( - token=jose.decode_b64jose( - 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')) + self.msg = DNS01(token=jose.decode_b64jose( + 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')) self.jmsg = { 'type': 'dns-01', 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA', diff --git a/acme/setup.py b/acme/setup.py index e4a525605..3cb69ce9e 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -35,7 +35,7 @@ if sys.version_info < (2, 7, 9): install_requires.append('ndg-httpsclient') install_requires.append('pyasn1') -dev_extras = [ +dns_extras = [ 'dnspython', ] @@ -46,7 +46,6 @@ docs_extras = [ ] testing_extras = [ - 'dnspython', 'nose', 'tox', ] @@ -79,7 +78,7 @@ setup( include_package_data=True, install_requires=install_requires, extras_require={ - 'dev': dev_extras, + 'dns': dns_extras, 'docs': docs_extras, 'testing': testing_extras, }, diff --git a/tox.ini b/tox.ini index 1abe1cf39..98e185cfa 100644 --- a/tox.ini +++ b/tox.ini @@ -33,23 +33,23 @@ setenv = [testenv:py33] commands = - pip install -e acme[testing] + pip install -e acme[dev,testing] nosetests -v acme [testenv:py34] commands = - pip install -e acme[testing] + pip install -e acme[dev,testing] nosetests -v acme [testenv:py35] commands = - pip install -e acme[testing] + pip install -e acme[dev,testing] nosetests -v acme [testenv:cover] basepython = python2.7 commands = - pip install -e acme -e .[testing] -e letsencrypt-apache -e letsencrypt-nginx -e letshelp-letsencrypt + pip install -e acme[dns] -e .[testing] -e letsencrypt-apache -e letsencrypt-nginx -e letshelp-letsencrypt ./tox.cover.sh [testenv:lint] @@ -59,7 +59,7 @@ basepython = python2.7 # duplicate code checking; if one of the commands fails, others will # continue, but tox return code will reflect previous error commands = - pip install -e acme -e .[dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt + pip install -e acme -e .[dns,dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt ./pep8.travis.sh pylint --rcfile=.pylintrc letsencrypt pylint --rcfile=.pylintrc acme/acme From cead22f4a7d46ff921a66bd049317c78733c596b Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 02:45:20 -0500 Subject: [PATCH 16/47] Add dns env to lint/cover --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 98e185cfa..a1ed23cf7 100644 --- a/tox.ini +++ b/tox.ini @@ -59,7 +59,7 @@ basepython = python2.7 # duplicate code checking; if one of the commands fails, others will # continue, but tox return code will reflect previous error commands = - pip install -e acme -e .[dns,dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt + pip install -e acme[dns] -e .[dns,dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt ./pep8.travis.sh pylint --rcfile=.pylintrc letsencrypt pylint --rcfile=.pylintrc acme/acme From e61e83f7e26803b556c7e6620cd014779d1f5e36 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 02:46:29 -0500 Subject: [PATCH 17/47] tox.ini fix --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index a1ed23cf7..cdc2c2396 100644 --- a/tox.ini +++ b/tox.ini @@ -59,7 +59,7 @@ basepython = python2.7 # duplicate code checking; if one of the commands fails, others will # continue, but tox return code will reflect previous error commands = - pip install -e acme[dns] -e .[dns,dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt + pip install -e acme[dns] -e .[dev] -e letsencrypt-apache -e letsencrypt-nginx -e letsencrypt-compatibility-test -e letshelp-letsencrypt ./pep8.travis.sh pylint --rcfile=.pylintrc letsencrypt pylint --rcfile=.pylintrc acme/acme From b8a9c2597c4ba6a02ed9b530c1286a1c24b7ccac Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 02:57:53 -0500 Subject: [PATCH 18/47] add dns environment to pyXX --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index cdc2c2396..4a7aea0d0 100644 --- a/tox.ini +++ b/tox.ini @@ -33,17 +33,17 @@ setenv = [testenv:py33] commands = - pip install -e acme[dev,testing] + pip install -e acme[dns,testing] nosetests -v acme [testenv:py34] commands = - pip install -e acme[dev,testing] + pip install -e acme[dns,testing] nosetests -v acme [testenv:py35] commands = - pip install -e acme[dev,testing] + pip install -e acme[dns,testing] nosetests -v acme [testenv:cover] From b73b410729e90bf0c6c1882130f6c0759675d7c3 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 02:59:25 -0500 Subject: [PATCH 19/47] Exclude import error case from coverage in dns_resolver --- acme/acme/dns_resolver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/acme/dns_resolver.py b/acme/acme/dns_resolver.py index 04e52224e..e28f132f1 100644 --- a/acme/acme/dns_resolver.py +++ b/acme/acme/dns_resolver.py @@ -19,7 +19,7 @@ def txt_records_for_name(name): """ try: dns_response = dns.resolver.query(name, 'TXT') - except ImportError as error: + except ImportError as error: # pragma: no cover raise ImportError("Local validation for 'dns-01' challenges requires " "'dnspython'") except dns.exception.DNSException as error: From 6bc3060fbb6a3d248654b6f97c004ce682e1e52f Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 03:11:09 -0500 Subject: [PATCH 20/47] More fixes for travis tests --- acme/acme/challenges.py | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 22bda6445..08d847627 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -245,7 +245,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse): from acme import dns_resolver txt_records = dns_resolver.txt_records_for_name( validation_domain_name) - except ImportError: + except ImportError: # pragma: no cover raise ImportError("Local validation for 'dns-01' challenges " "requires 'dnspython'") exists = validation in txt_records diff --git a/tox.ini b/tox.ini index 4a7aea0d0..c0a819b5e 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ envlist = py26,py27,py33,py34,py35,cover,lint # packages installed separately to ensure that dowstream deps problems # are detected, c.f. #1002 commands = - pip install -e acme[testing] + pip install -e acme[dns,testing] nosetests -v acme pip install -e .[testing] nosetests -v letsencrypt From 02a493011ef039ca6ec1d52d9c87d4317afe0d12 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Wed, 6 Jan 2016 22:56:59 -0500 Subject: [PATCH 21/47] Remove superfluous except: and change Exception returned if dnspython is not available. --- acme/acme/challenges.py | 4 ++-- acme/acme/dns_resolver.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 08d847627..3a78e403a 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -246,8 +246,8 @@ class DNS01Response(KeyAuthorizationChallengeResponse): txt_records = dns_resolver.txt_records_for_name( validation_domain_name) except ImportError: # pragma: no cover - raise ImportError("Local validation for 'dns-01' challenges " - "requires 'dnspython'") + raise errors.Error("Local validation for 'dns-01' challenges " + "requires 'dnspython'") exists = validation in txt_records if not exists: logger.debug("Key authorization from response (%r) doesn't match " diff --git a/acme/acme/dns_resolver.py b/acme/acme/dns_resolver.py index e28f132f1..3572ee277 100644 --- a/acme/acme/dns_resolver.py +++ b/acme/acme/dns_resolver.py @@ -19,9 +19,6 @@ def txt_records_for_name(name): """ try: dns_response = dns.resolver.query(name, 'TXT') - except ImportError as error: # pragma: no cover - raise ImportError("Local validation for 'dns-01' challenges requires " - "'dnspython'") except dns.exception.DNSException as error: logger.error("Unable to resolve %s: %s", name, str(error)) return [] From 446994e8ef11a860ac94a52751bc51a9fded6b04 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sat, 9 Jan 2016 14:58:19 -0500 Subject: [PATCH 22/47] Limit length of try block. --- acme/acme/challenges.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 3a78e403a..493d230d8 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -243,11 +243,10 @@ class DNS01Response(KeyAuthorizationChallengeResponse): try: from acme import dns_resolver - txt_records = dns_resolver.txt_records_for_name( - validation_domain_name) except ImportError: # pragma: no cover raise errors.Error("Local validation for 'dns-01' challenges " "requires 'dnspython'") + txt_records = dns_resolver.txt_records_for_name(validation_domain_name) exists = validation in txt_records if not exists: logger.debug("Key authorization from response (%r) doesn't match " From 56154f1301725dff575e22011a81ca18d5729307 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sat, 9 Jan 2016 16:43:43 -0500 Subject: [PATCH 23/47] - Use dnspython3 fir py3X environments. - Fix encoding for simple_verify. --- acme/acme/challenges.py | 4 ++-- acme/setup.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 493d230d8..3fa259b13 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -271,8 +271,8 @@ class DNS01(KeyAuthorizationChallenge): :rtype: unicode """ - return jose.b64encode(hashlib.sha256( - self.key_authorization(account_key)).digest()) + return jose.b64encode(hashlib.sha256(self.key_authorization( + account_key).encode("utf-8")).digest()).decode() def validation_domain_name(self, name): """Domain name for TXT validation record. diff --git a/acme/setup.py b/acme/setup.py index c54dc42e6..df5cfba0e 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -35,9 +35,14 @@ if sys.version_info < (2, 7, 9): install_requires.append('ndg-httpsclient') install_requires.append('pyasn1') -dns_extras = [ - 'dnspython', -] +if sys.version_info < (3, 0): + dns_extras = [ + 'dnspython', + ] +else: + dns_extras = [ + 'dnspython3', + ] docs_extras = [ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags From d842f268e5414f418e69913f6ac4fadffb327fb1 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sat, 9 Jan 2016 17:07:20 -0500 Subject: [PATCH 24/47] - Use dnspython3 fir py3X environments. - Fix encoding for simple_verify. --- acme/acme/challenges.py | 4 ++-- acme/setup.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 493d230d8..3fa259b13 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -271,8 +271,8 @@ class DNS01(KeyAuthorizationChallenge): :rtype: unicode """ - return jose.b64encode(hashlib.sha256( - self.key_authorization(account_key)).digest()) + return jose.b64encode(hashlib.sha256(self.key_authorization( + account_key).encode("utf-8")).digest()).decode() def validation_domain_name(self, name): """Domain name for TXT validation record. diff --git a/acme/setup.py b/acme/setup.py index c54dc42e6..df5cfba0e 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -35,9 +35,14 @@ if sys.version_info < (2, 7, 9): install_requires.append('ndg-httpsclient') install_requires.append('pyasn1') -dns_extras = [ - 'dnspython', -] +if sys.version_info < (3, 0): + dns_extras = [ + 'dnspython', + ] +else: + dns_extras = [ + 'dnspython3', + ] docs_extras = [ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags From f2b52bd830457200474e98394b8aff0315e9d51f Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 12:58:29 -0500 Subject: [PATCH 25/47] Fix dcumentation --- acme/acme/challenges.py | 1 - 1 file changed, 1 deletion(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 3fa259b13..a5b142c5b 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -226,7 +226,6 @@ class DNS01Response(KeyAuthorizationChallengeResponse): :param unicode domain: Domain name being verified. :param account_public_key: Public key for the key pair being authorized. - :param JWK account_public_key: :returns: ``True`` iff validation with the TXT records resolved from a DNS server is successful. From 49c40e7a584d1d8faeac4436c6c7787e44a12b6d Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 13:00:38 -0500 Subject: [PATCH 26/47] Skip dns_resolver tests if dnspython is not available. --- acme/acme/dns_resolver_test.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 2202ce4e8..0d5cc2543 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -1,11 +1,17 @@ """Tests for acme.dns_resolver.""" import unittest -import dns import mock +try: + import dns +except ImportError: + dns = None + from acme import dns_resolver +@unittest.skipIf(dns is None, + "dnspython is not available, skipping dns_resolver tests") class TxtRecordsForNameTest(unittest.TestCase): def create_txt_response(self, name, txt_records): From cfe56cbd925a4c90a051355f4f891ee8f15308d0 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 17:00:14 -0500 Subject: [PATCH 27/47] 2.6 compatible skipping of tests. --- acme/acme/dns_resolver_test.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 0d5cc2543..54f074a92 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -1,17 +1,15 @@ """Tests for acme.dns_resolver.""" import unittest - import mock -try: - import dns -except ImportError: - dns = None - from acme import dns_resolver -@unittest.skipIf(dns is None, - "dnspython is not available, skipping dns_resolver tests") +try: + import dns +except ImportError: # pragma: no cover + dns = None + + class TxtRecordsForNameTest(unittest.TestCase): def create_txt_response(self, name, txt_records): @@ -41,3 +39,9 @@ class TxtRecordsForNameTest(unittest.TestCase): def test_txt_records_for_name_domain_not_found(self, mock_dns): mock_dns.side_effect = dns.exception.DNSException self.assertEquals([], dns_resolver.txt_records_for_name('name')) + + def run(self, result=None): + if dns is None: + print self, "... SKIPPING, no dnspython available" + return + super(TxtRecordsForNameTest, self).run(result) From 0010610a4a038fd592c0544d02fd0f36a8ff3495 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 17:06:03 -0500 Subject: [PATCH 28/47] py3X fix --- acme/acme/dns_resolver_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 54f074a92..8e92cd4a3 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -42,6 +42,6 @@ class TxtRecordsForNameTest(unittest.TestCase): def run(self, result=None): if dns is None: - print self, "... SKIPPING, no dnspython available" + print(self, "... SKIPPING, no dnspython available") return super(TxtRecordsForNameTest, self).run(result) From 2d8de74f4abd659cad518ec5d74d8613fe9830ac Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 17:13:25 -0500 Subject: [PATCH 29/47] pcoverage fix --- acme/acme/dns_resolver_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 8e92cd4a3..770a95a77 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -7,7 +7,7 @@ from acme import dns_resolver try: import dns except ImportError: # pragma: no cover - dns = None + dns = Nones class TxtRecordsForNameTest(unittest.TestCase): @@ -41,7 +41,7 @@ class TxtRecordsForNameTest(unittest.TestCase): self.assertEquals([], dns_resolver.txt_records_for_name('name')) def run(self, result=None): - if dns is None: + if dns is None: # pragma: no cover print(self, "... SKIPPING, no dnspython available") return super(TxtRecordsForNameTest, self).run(result) From 1ff121b616c8d599549be19a07bd45fb8429fc49 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 18:08:16 -0500 Subject: [PATCH 30/47] pcoverage fix --- acme/acme/dns_resolver_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 770a95a77..5d160fc07 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -7,7 +7,7 @@ from acme import dns_resolver try: import dns except ImportError: # pragma: no cover - dns = Nones + dns = None class TxtRecordsForNameTest(unittest.TestCase): From 9179276cb982945f6d6083b26d7a55d0112894e9 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 20:59:11 -0500 Subject: [PATCH 31/47] Modify dns_resolver_test to skip tests if dnspython is not available. --- acme/acme/dns_resolver_test.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 2202ce4e8..5d160fc07 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -1,11 +1,15 @@ """Tests for acme.dns_resolver.""" import unittest - -import dns import mock from acme import dns_resolver +try: + import dns +except ImportError: # pragma: no cover + dns = None + + class TxtRecordsForNameTest(unittest.TestCase): def create_txt_response(self, name, txt_records): @@ -35,3 +39,9 @@ class TxtRecordsForNameTest(unittest.TestCase): def test_txt_records_for_name_domain_not_found(self, mock_dns): mock_dns.side_effect = dns.exception.DNSException self.assertEquals([], dns_resolver.txt_records_for_name('name')) + + def run(self, result=None): + if dns is None: # pragma: no cover + print(self, "... SKIPPING, no dnspython available") + return + super(TxtRecordsForNameTest, self).run(result) From 05a61c181b277d4a3f94abe038da0fda549faeda Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 10 Jan 2016 21:42:20 -0500 Subject: [PATCH 32/47] Lint fixes. --- acme/acme/challenges_test.py | 2 +- acme/acme/dns_resolver_test.py | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index ac94619b5..b32bebed0 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -114,7 +114,7 @@ class DNS01ResponseTest(unittest.TestCase): self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) mock_resolver.assert_called_once_with( - self.chall.validation_domain_name("local")) + self.chall.validation_domain_name("local")) @mock.patch("acme.dns_resolver.txt_records_for_name") def test_simple_verify_good_validation_multiple_txts(self, mock_resolver): diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 5d160fc07..39645c492 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -9,28 +9,28 @@ try: except ImportError: # pragma: no cover dns = None +def create_txt_response(name, txt_records): + """ + Returns an RRSet containing the 'txt_records' as the result of a DNS + query for 'name'. + + This takes advantage of the fact that an Answer object mostly behaves + like an RRset. + """ + return dns.rrset.from_text_list(name, 60, "IN", "TXT", txt_records) + class TxtRecordsForNameTest(unittest.TestCase): - def create_txt_response(self, name, txt_records): - """ - Returns an RRSet containing the 'txt_records' as the result of a DNS - query for 'name'. - - This takes advantage of the fact that an Answer object mostly behaves - like an RRset. - """ - return dns.rrset.from_text_list(name, 60, "IN", "TXT", txt_records) - @mock.patch("acme.dns_resolver.dns.resolver.query") - def test_txt_records_for_name_test_with_single_response(self, mock_dns): - mock_dns.return_value = self.create_txt_response('name', ['response']) + def test_txt_records_for_name_with_single_response(self, mock_dns): + mock_dns.return_value = create_txt_response('name', ['response']) self.assertEqual(['response'], dns_resolver.txt_records_for_name('name')) @mock.patch("acme.dns_resolver.dns.resolver.query") def test_txt_records_for_name_with_multiple_responses(self, mock_dns): - mock_dns.return_value = self.create_txt_response( + mock_dns.return_value = create_txt_response( 'name', ['response1', 'response2']) self.assertEqual(['response1', 'response2'], dns_resolver.txt_records_for_name('name')) From c15581bcfd4b9ffe8d1e1ceb03e316c337691524 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Thu, 14 Jan 2016 23:37:05 -0500 Subject: [PATCH 33/47] Fix lint problems. --- acme/acme/challenges_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index 39f7449f2..af4b81f80 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -119,7 +119,8 @@ class DNS01ResponseTest(unittest.TestCase): @mock.patch("acme.dns_resolver.txt_records_for_name") def test_simple_verify_good_validation_multiple_txts(self, mock_resolver): - mock_resolver.return_value = ["!", self.chall.validation(KEY.public_key())] + mock_resolver.return_value = [ + "!", self.chall.validation(KEY.public_key())] self.assertTrue(self.response.simple_verify( self.chall, "local", KEY.public_key())) mock_resolver.assert_called_once_with( From 7c3271545fe48de9c67b2781ab1fd58328e4e095 Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sat, 13 Feb 2016 01:05:35 -0500 Subject: [PATCH 34/47] Do not log an error when getting NXDOMAIN. --- acme/acme/dns_resolver.py | 4 +++- acme/acme/dns_resolver_test.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/acme/acme/dns_resolver.py b/acme/acme/dns_resolver.py index 3572ee277..badbf486a 100644 --- a/acme/acme/dns_resolver.py +++ b/acme/acme/dns_resolver.py @@ -19,7 +19,9 @@ def txt_records_for_name(name): """ try: dns_response = dns.resolver.query(name, 'TXT') + except dns.resolver.NXDOMAIN as error: + return [] except dns.exception.DNSException as error: - logger.error("Unable to resolve %s: %s", name, str(error)) + logger.error("Error resolving %s: %s", name, str(error)) return [] return [txt_rec for rdata in dns_response for txt_rec in rdata.strings] diff --git a/acme/acme/dns_resolver_test.py b/acme/acme/dns_resolver_test.py index 80481343d..53fc0cc77 100644 --- a/acme/acme/dns_resolver_test.py +++ b/acme/acme/dns_resolver_test.py @@ -38,6 +38,11 @@ class TxtRecordsForNameTest(unittest.TestCase): @mock.patch("acme.dns_resolver.dns.resolver.query") def test_txt_records_for_name_domain_not_found(self, mock_dns): + mock_dns.side_effect = dns.resolver.NXDOMAIN + self.assertEquals([], dns_resolver.txt_records_for_name('name')) + + @mock.patch("acme.dns_resolver.dns.resolver.query") + def test_txt_records_for_name_domain_other_error(self, mock_dns): mock_dns.side_effect = dns.exception.DNSException self.assertEquals([], dns_resolver.txt_records_for_name('name')) From 9396e92a966cbc07c0a69b7148f760b4337823dd Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Mon, 25 Apr 2016 00:46:45 -0400 Subject: [PATCH 35/47] Fix lint issues. --- acme/acme/challenges.py | 1 - acme/acme/dns_resolver.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index d27719449..f611578dd 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -599,4 +599,3 @@ class DNSResponse(ChallengeResponse): """ return chall.check_validation(self.validation, account_public_key) - diff --git a/acme/acme/dns_resolver.py b/acme/acme/dns_resolver.py index badbf486a..15638e5d0 100644 --- a/acme/acme/dns_resolver.py +++ b/acme/acme/dns_resolver.py @@ -8,6 +8,7 @@ import dns.exception logger = logging.getLogger(__name__) + def txt_records_for_name(name): """Resolve the name and return the TXT records. From 04fd293ec2bf91bb1fef80aeb43ab7140e531e3b Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 29 Jul 2016 18:56:03 -0700 Subject: [PATCH 36/47] add underscore to broken developer guide link --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index fe66f8af2..72188608b 100644 --- a/README.rst +++ b/README.rst @@ -39,4 +39,4 @@ Current Features .. Do not modify this comment unless you know what you're doing. tag:features-end -For extensive documentation on using and contributing to Certbot, go to https://certbot.eff.org/docs. If you would like to contribute to the project or run the latest code from git, you should read our `developer guide `. +For extensive documentation on using and contributing to Certbot, go to https://certbot.eff.org/docs. If you would like to contribute to the project or run the latest code from git, you should read our `developer guide `_. From 95fad04fdd69c8768ff126bdd7b43fa76c6151b1 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 30 Jul 2016 10:39:35 +0300 Subject: [PATCH 37/47] Added method to get the LIKE var contents as a list --- certbot/util.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/certbot/util.py b/certbot/util.py index 65aae59e2..1d3e5953c 100644 --- a/certbot/util.py +++ b/certbot/util.py @@ -268,6 +268,19 @@ def get_systemd_os_info(filepath="/etc/os-release"): return (os_name, os_version) +def get_systemd_os_like(filepath="/etc/os-release"): + """ + Get a list of strings that indicate the distribution likeness to + other distributions. + + :param str filepath: File path of os-release file + :returns: List of distribution acronyms + :rtype: `list` of `str` + """ + + return _get_systemd_os_release_var("LIKE", filepath).split(" ") + + def _get_systemd_os_release_var(varname, filepath="/etc/os-release"): """ Get single value from systemd /etc/os-release From a5859910e757667b573b9675f0e6c9d546e7c46c Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 30 Jul 2016 10:43:38 +0300 Subject: [PATCH 38/47] If os name is not found, try LIKE var from os-release --- certbot-apache/certbot_apache/constants.py | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/certbot-apache/certbot_apache/constants.py b/certbot-apache/certbot_apache/constants.py index 9252814c4..a65a12c5e 100644 --- a/certbot-apache/certbot_apache/constants.py +++ b/certbot-apache/certbot_apache/constants.py @@ -115,13 +115,36 @@ HEADER_ARGS = {"Strict-Transport-Security": HSTS_ARGS, def os_constant(key): - """Get a constant value for operating system + """ + Get a constant value for operating system + :param key: name of cli constant :return: value of constant for active os """ + os_info = util.get_os_info() try: constants = CLI_DEFAULTS[os_info[0].lower()] except KeyError: - constants = CLI_DEFAULTS["debian"] + constants = os_like_constants() + if not constants: + constants = CLI_DEFAULTS["default"] return constants[key] + + +def os_like_constants(): + """ + Try to get constants for distribution with + similar layout and configuration, indicated by + /etc/os-release variable "LIKE" + + :returns: Constants dictionary + :rtype: `dict` + """ + + os_like = util.get_systemd_os_like() + if os_like: + for os_name in os_like: + if os_name in CLI_DEFAULTS.keys(): + return CLI_DEFAULTS[os_name] + return {} From c87282d5aa8e0eb0762bbf5804edf0b32563721c Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 30 Jul 2016 10:44:46 +0300 Subject: [PATCH 39/47] Un-debian the defaults --- certbot-apache/certbot_apache/constants.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/certbot-apache/certbot_apache/constants.py b/certbot-apache/certbot_apache/constants.py index a65a12c5e..d4ec2d8a8 100644 --- a/certbot-apache/certbot_apache/constants.py +++ b/certbot-apache/certbot_apache/constants.py @@ -2,7 +2,23 @@ import pkg_resources from certbot import util - +CLI_DEFAULTS_DEFAULT = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/sites-available", + vhost_files="*", + version_cmd=['apache2ctl', '-v'], + define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'], + restart_cmd=['apache2ctl', 'graceful'], + conftest_cmd=['apache2ctl', 'configtest'], + enmod="a2enmod", + dismod="a2dismod", + le_vhost_ext="-le-ssl.conf", + handle_mods=False, + handle_sites=False, + challenge_location="/etc/apache2", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", "options-ssl-apache.conf") +) CLI_DEFAULTS_DEBIAN = dict( server_root="/etc/apache2", vhost_root="/etc/apache2/sites-available", @@ -72,6 +88,7 @@ CLI_DEFAULTS_DARWIN = dict( "certbot_apache", "options-ssl-apache.conf") ) CLI_DEFAULTS = { + "default": CLI_DEFAULTS_DEFAULT, "debian": CLI_DEFAULTS_DEBIAN, "ubuntu": CLI_DEFAULTS_DEBIAN, "centos": CLI_DEFAULTS_CENTOS, From 63a47f8b6baf53e194194798d992890f56bd30c2 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 30 Jul 2016 11:07:04 +0300 Subject: [PATCH 40/47] Fix variable name --- certbot/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certbot/util.py b/certbot/util.py index 1d3e5953c..73985719a 100644 --- a/certbot/util.py +++ b/certbot/util.py @@ -278,7 +278,7 @@ def get_systemd_os_like(filepath="/etc/os-release"): :rtype: `list` of `str` """ - return _get_systemd_os_release_var("LIKE", filepath).split(" ") + return _get_systemd_os_release_var("ID_LIKE", filepath).split(" ") def _get_systemd_os_release_var(varname, filepath="/etc/os-release"): From 093ebd2f0342c23e7d3e28fd0963e1ffc9f1e14a Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 30 Jul 2016 11:49:04 +0300 Subject: [PATCH 41/47] Tests --- .../certbot_apache/tests/constants_test.py | 17 +++++++++++++++++ certbot/tests/testdata/os-release | 2 +- certbot/tests/util_test.py | 9 +++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/certbot-apache/certbot_apache/tests/constants_test.py b/certbot-apache/certbot_apache/tests/constants_test.py index c040030df..1c842aee9 100644 --- a/certbot-apache/certbot_apache/tests/constants_test.py +++ b/certbot-apache/certbot_apache/tests/constants_test.py @@ -25,3 +25,20 @@ class ConstantsTest(unittest.TestCase): os_info.return_value = ('Nonexistent Linux', '', '') self.assertEqual(constants.os_constant("vhost_root"), "/etc/apache2/sites-available") + + @mock.patch("certbot.util.get_os_info") + def test_get_default_constants(self, os_info): + os_info.return_value = ('Nonexistent Linux', '', '') + with mock.patch("certbot.util.get_systemd_os_like") as os_like: + # Get defaults + os_like.return_value = False + c_hm = constants.os_constant("handle_mods") + c_sr = constants.os_constant("server_root") + self.assertFalse(c_hm) + self.assertEqual(c_sr, "/etc/apache2") + # Use darwin as like test target + os_like.return_value = ["something", "nonexistent", "darwin"] + d_vr = constants.os_constant("vhost_root") + d_em = constants.os_constant("enmod") + self.assertFalse(d_em) + self.assertEqual(d_vr, "/etc/apache2/other") diff --git a/certbot/tests/testdata/os-release b/certbot/tests/testdata/os-release index cd5297acf..15bc5fb3c 100644 --- a/certbot/tests/testdata/os-release +++ b/certbot/tests/testdata/os-release @@ -1,7 +1,7 @@ NAME="SystemdOS" VERSION="42.42.42 LTS, Unreal" ID=systemdos -ID_LIKE=debian +ID_LIKE="something nonexistent debian" VERSION_ID="42" HOME_URL="http://www.example.com/" SUPPORT_URL="http://help.example.com/" diff --git a/certbot/tests/util_test.py b/certbot/tests/util_test.py index 8e1b330ed..36676443a 100644 --- a/certbot/tests/util_test.py +++ b/certbot/tests/util_test.py @@ -359,6 +359,15 @@ class OsInfoTest(unittest.TestCase): with mock.patch('os.path.isfile', return_value=False): self.assertEqual(get_systemd_os_info(), ("", "")) + def test_systemd_os_release_like(self): + from certbot.util import get_systemd_os_like + + with mock.patch('os.path.isfile', return_value=True): + id_likes = get_systemd_os_like(test_util.vector_path( + "os-release")) + self.assertEqual(len(id_likes), 3) + self.assertTrue("debian" in id_likes) + @mock.patch("certbot.util.subprocess.Popen") def test_non_systemd_os_info(self, popen_mock): from certbot.util import (get_os_info, get_python_os_info, From 28fc02143bd9b235ba569c0045795e18c916fe66 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 30 Jul 2016 11:59:58 +0300 Subject: [PATCH 42/47] Remove enmod and dismod values as they are not needed --- certbot-apache/certbot_apache/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certbot-apache/certbot_apache/constants.py b/certbot-apache/certbot_apache/constants.py index d4ec2d8a8..5e296ba0a 100644 --- a/certbot-apache/certbot_apache/constants.py +++ b/certbot-apache/certbot_apache/constants.py @@ -10,8 +10,8 @@ CLI_DEFAULTS_DEFAULT = dict( define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'], restart_cmd=['apache2ctl', 'graceful'], conftest_cmd=['apache2ctl', 'configtest'], - enmod="a2enmod", - dismod="a2dismod", + enmod=None, + dismod=None, le_vhost_ext="-le-ssl.conf", handle_mods=False, handle_sites=False, From 8a09a7ed6715e345ee03041b933a9f815e815dd8 Mon Sep 17 00:00:00 2001 From: Yen Chi Hsuan Date: Sat, 30 Jul 2016 18:15:32 +0800 Subject: [PATCH 43/47] Python 3 support for certonly --- certbot/auth_handler.py | 6 +++--- certbot/cli.py | 6 ++++-- certbot/constants.py | 2 +- certbot/plugins/disco.py | 11 ++++++----- certbot/plugins/manual.py | 3 ++- certbot/plugins/selection.py | 2 +- certbot/util.py | 3 +++ setup.py | 7 ++++++- 8 files changed, 26 insertions(+), 14 deletions(-) diff --git a/certbot/auth_handler.py b/certbot/auth_handler.py index f5557d604..a94734572 100644 --- a/certbot/auth_handler.py +++ b/certbot/auth_handler.py @@ -1,8 +1,8 @@ """ACME AuthHandler.""" -import itertools import logging import time +import six import zope.component from acme import challenges @@ -141,7 +141,7 @@ class AuthHandler(object): """ active_achalls = [] - for achall, resp in itertools.izip(achalls, resps): + for achall, resp in six.moves.zip(achalls, resps): # This line needs to be outside of the if block below to # ensure failed challenges are cleaned up correctly active_achalls.append(achall) @@ -472,7 +472,7 @@ def _report_failed_challs(failed_achalls): problems.setdefault(achall.error.typ, []).append(achall) reporter = zope.component.getUtility(interfaces.IReporter) - for achalls in problems.itervalues(): + for achalls in six.itervalues(problems): reporter.add_message( _generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY) diff --git a/certbot/cli.py b/certbot/cli.py index 5813af730..b01b0a7f1 100644 --- a/certbot/cli.py +++ b/certbot/cli.py @@ -343,8 +343,10 @@ class HelpfulArgumentParser(object): self.determine_verb() 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" - self.help_arg = max(help1, help2) + if isinstance(help1, bool) and isinstance(help2, bool): + self.help_arg = help1 or help2 + else: + self.help_arg = help1 if isinstance(help1, str) else help2 if self.help_arg is True: # just --help with no topic; avoid argparse altogether print(usage) diff --git a/certbot/constants.py b/certbot/constants.py index fb278161d..1ddb9fedf 100644 --- a/certbot/constants.py +++ b/certbot/constants.py @@ -18,7 +18,7 @@ CLI_DEFAULTS = dict( os.path.join(os.environ.get("XDG_CONFIG_HOME", "~/.config"), "letsencrypt", "cli.ini"), ], - verbose_count=-(logging.INFO / 10), + verbose_count=-int(logging.INFO / 10), server="https://acme-v01.api.letsencrypt.org/directory", rsa_key_size=2048, rollback_checkpoints=1, diff --git a/certbot/plugins/disco.py b/certbot/plugins/disco.py index d88b871f6..59410757c 100644 --- a/certbot/plugins/disco.py +++ b/certbot/plugins/disco.py @@ -3,6 +3,7 @@ import collections import itertools import logging import pkg_resources +import six import zope.interface import zope.interface.verify @@ -194,12 +195,12 @@ class PluginsRegistry(collections.Mapping): def init(self, config): """Initialize all plugins in the registry.""" return [plugin_ep.init(config) for plugin_ep - in self._plugins.itervalues()] + in six.itervalues(self._plugins)] def filter(self, pred): """Filter plugins based on predicate.""" return type(self)(dict((name, plugin_ep) for name, plugin_ep - in self._plugins.iteritems() if pred(plugin_ep))) + in six.iteritems(self._plugins) if pred(plugin_ep))) def visible(self): """Filter plugins based on visibility.""" @@ -216,7 +217,7 @@ class PluginsRegistry(collections.Mapping): def prepare(self): """Prepare all plugins in the registry.""" - return [plugin_ep.prepare() for plugin_ep in self._plugins.itervalues()] + return [plugin_ep.prepare() for plugin_ep in six.itervalues(self._plugins)] def available(self): """Filter plugins based on availability.""" @@ -238,7 +239,7 @@ class PluginsRegistry(collections.Mapping): """ # use list instead of set because PluginEntryPoint is not hashable - candidates = [plugin_ep for plugin_ep in self._plugins.itervalues() + candidates = [plugin_ep for plugin_ep in six.itervalues(self._plugins) if plugin_ep.initialized and plugin_ep.init() is plugin] assert len(candidates) <= 1 if candidates: @@ -249,7 +250,7 @@ class PluginsRegistry(collections.Mapping): def __repr__(self): return "{0}({1})".format( self.__class__.__name__, ','.join( - repr(p_ep) for p_ep in self._plugins.itervalues())) + repr(p_ep) for p_ep in six.itervalues(self._plugins))) def __str__(self): if not self._plugins: diff --git a/certbot/plugins/manual.py b/certbot/plugins/manual.py index 9b722aef4..6c7b822ab 100644 --- a/certbot/plugins/manual.py +++ b/certbot/plugins/manual.py @@ -10,6 +10,7 @@ import sys import tempfile import time +import six import zope.component import zope.interface @@ -187,7 +188,7 @@ s.serve_forever()" """ #answer = zope.component.getUtility(interfaces.IDisplay).notification( # message=message, height=25, pause=True) sys.stdout.write(message) - raw_input("Press ENTER to continue") + six.moves.input("Press ENTER to continue") def cleanup(self, achalls): # pylint: disable=missing-docstring,no-self-use,unused-argument diff --git a/certbot/plugins/selection.py b/certbot/plugins/selection.py index 21ebe9029..b16515d8f 100644 --- a/certbot/plugins/selection.py +++ b/certbot/plugins/selection.py @@ -84,7 +84,7 @@ def pick_plugin(config, default, plugins, question, ifaces): else: return plugin_ep.init() elif len(prepared) == 1: - plugin_ep = prepared.values()[0] + plugin_ep = list(prepared.values())[0] logger.debug("Single candidate plugin: %s", plugin_ep) if plugin_ep.misconfigured: return None diff --git a/certbot/util.py b/certbot/util.py index 65aae59e2..2ad2c4535 100644 --- a/certbot/util.py +++ b/certbot/util.py @@ -409,6 +409,9 @@ def enforce_domain_sanity(domain): else: raise errors.ConfigurationError(str(error_fmt).format(domain)) + if six.PY3: + domain = domain.decode('ascii') + # Remove trailing dot domain = domain[:-1] if domain.endswith('.') else domain diff --git a/setup.py b/setup.py index 21cda901a..093cbc449 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,6 @@ install_requires = [ 'psutil>=2.2.1', # 2.1.0 for net_connections and 2.2.1 resolves #1080 'PyOpenSSL', 'pyrfc3339', - 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 'pytz', # For pkg_resources. >=1.0 so pip resolves it to a version cryptography # will tolerate; see #2599: @@ -53,6 +52,12 @@ install_requires = [ 'zope.interface', ] +# Debian squeeze support, cf. #280 +if sys.version_info[0] == 2: + install_requires.append('python2-pythondialog>=3.2.2rc1') +else: + install_requires.append('pythondialog>=3.2.2rc1') + # env markers in extras_require cause problems with older pip: #517 # Keep in sync with conditional_requirements.py. if sys.version_info < (2, 7): From b891cac3dc4661a8f4ff99c53955884b727b0975 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sun, 31 Jul 2016 01:10:28 +0300 Subject: [PATCH 44/47] Added suse constants --- certbot-apache/certbot_apache/constants.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/certbot-apache/certbot_apache/constants.py b/certbot-apache/certbot_apache/constants.py index 5e296ba0a..001ef1caa 100644 --- a/certbot-apache/certbot_apache/constants.py +++ b/certbot-apache/certbot_apache/constants.py @@ -87,6 +87,23 @@ CLI_DEFAULTS_DARWIN = dict( MOD_SSL_CONF_SRC=pkg_resources.resource_filename( "certbot_apache", "options-ssl-apache.conf") ) +CLI_DEFAULTS_SUSE = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/vhosts.d", + vhost_files="*.conf", + version_cmd=['apache2ctl', '-v'], + define_cmd=['apache2ctl', '-t', '-D', 'DUMP_RUN_CFG'], + restart_cmd=['apache2ctl', 'graceful'], + conftest_cmd=['apache2ctl', 'configtest'], + enmod="a2enmod", + dismod="a2dismod", + le_vhost_ext="-le-ssl.conf", + handle_mods=True, + handle_sites=False, + challenge_location="/etc/apache2/vhosts.d", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", "options-ssl-apache.conf") +) CLI_DEFAULTS = { "default": CLI_DEFAULTS_DEFAULT, "debian": CLI_DEFAULTS_DEBIAN, @@ -100,6 +117,8 @@ CLI_DEFAULTS = { "gentoo": CLI_DEFAULTS_GENTOO, "gentoo base system": CLI_DEFAULTS_GENTOO, "darwin": CLI_DEFAULTS_DARWIN, + "opensuse": CLI_DEFAULTS_SUSE, + "suse": CLI_DEFAULTS_SUSE, } """CLI defaults.""" From b2505b996fe4739739f2ec57bac0d6b9a99e226b Mon Sep 17 00:00:00 2001 From: Wilfried Teiken Date: Sun, 31 Jul 2016 20:36:00 -0400 Subject: [PATCH 45/47] Switch to always using dnspython (requires dnspthon>=1.12). Also, address some documentation nits. --- acme/acme/challenges.py | 8 ++++---- acme/acme/dns_resolver.py | 4 +++- acme/setup.py | 12 ++++-------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 23841c2b4..6242c376c 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -207,7 +207,7 @@ class KeyAuthorizationChallenge(_TokenChallenge): @ChallengeResponse.register class DNS01Response(KeyAuthorizationChallengeResponse): - """ACME "dns-01" challenge response.""" + """ACME dns-01 challenge response.""" typ = "dns-01" def simple_verify(self, chall, domain, account_public_key): @@ -215,7 +215,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse): :param challenges.DNS01 chall: Corresponding challenge. :param unicode domain: Domain name being verified. - :param account_public_key: Public key for the key pair + :param JWK account_public_key: Public key for the key pair being authorized. :returns: ``True`` iff validation with the TXT records resolved from a @@ -247,7 +247,7 @@ class DNS01Response(KeyAuthorizationChallengeResponse): @Challenge.register # pylint: disable=too-many-ancestors class DNS01(KeyAuthorizationChallenge): - """ACME "dns-01" challenge.""" + """ACME dns-01 challenge.""" response_cls = DNS01Response typ = response_cls.typ @@ -298,7 +298,7 @@ class HTTP01Response(KeyAuthorizationChallengeResponse): being authorized. :param int port: Port used in the validation. - :returns: ``True`` iff validation of the files currently server by the + :returns: ``True`` iff validation with the files currently served by the HTTP server is successful. :rtype: bool diff --git a/acme/acme/dns_resolver.py b/acme/acme/dns_resolver.py index 15638e5d0..f551c6095 100644 --- a/acme/acme/dns_resolver.py +++ b/acme/acme/dns_resolver.py @@ -25,4 +25,6 @@ def txt_records_for_name(name): except dns.exception.DNSException as error: logger.error("Error resolving %s: %s", name, str(error)) return [] - return [txt_rec for rdata in dns_response for txt_rec in rdata.strings] + + return [txt_rec.decode("utf-8") for rdata in dns_response + for txt_rec in rdata.strings] diff --git a/acme/setup.py b/acme/setup.py index 75565f7f8..94f78d4cd 100644 --- a/acme/setup.py +++ b/acme/setup.py @@ -35,14 +35,10 @@ if sys.version_info < (2, 7): else: install_requires.append('mock') -if sys.version_info < (3, 0): - dns_extras = [ - 'dnspython', - ] -else: - dns_extras = [ - 'dnspython3', - ] +# dnspython 1.12 is required to support both Python 2 and Python 3. +dns_extras = [ + 'dnspython>=1.12', +] dev_extras = [ 'nose', From c346cdf2f33e136950ef7ad324215f9d8752ba9f Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Tue, 2 Aug 2016 09:57:34 +0300 Subject: [PATCH 46/47] Changed SUSE mod handling constant --- certbot-apache/certbot_apache/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certbot-apache/certbot_apache/constants.py b/certbot-apache/certbot_apache/constants.py index 001ef1caa..ba545c613 100644 --- a/certbot-apache/certbot_apache/constants.py +++ b/certbot-apache/certbot_apache/constants.py @@ -98,7 +98,7 @@ CLI_DEFAULTS_SUSE = dict( enmod="a2enmod", dismod="a2dismod", le_vhost_ext="-le-ssl.conf", - handle_mods=True, + handle_mods=False, handle_sites=False, challenge_location="/etc/apache2/vhosts.d", MOD_SSL_CONF_SRC=pkg_resources.resource_filename( From e49a41be34b572a6a01160bf38fbfa1f10c46965 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Mon, 8 Aug 2016 12:17:42 -0700 Subject: [PATCH 47/47] Change "hacking" to "getting started" in the docs --- docs/contributing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index f1eec04df..a5b9b5688 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -6,9 +6,9 @@ Developer Guide :local: -.. _hacking: +.. _getting_started: -Hacking +Getting Started ======= Running a local copy of the client