diff --git a/MANIFEST.in b/MANIFEST.in index 18f018c..5a661ce 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ include LICENSE.txt include README.rst recursive-include docs * +recursive-include tests * +global-exclude __pycache__ +global-exclude *.py[cod] diff --git a/PKG-INFO b/PKG-INFO index 9179d39..c208e5d 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: certbot-dns-rfc2136 -Version: 0.35.1 +Version: 1.3.0 Summary: RFC 2136 DNS Authenticator plugin for Certbot Home-page: https://github.com/certbot/certbot Author: Certbot Project @@ -8,7 +8,7 @@ License: Apache License 2.0 Description: UNKNOWN Platform: UNKNOWN -Classifier: Development Status :: 3 - Alpha +Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Plugins Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License @@ -17,15 +17,15 @@ Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 Classifier: Topic :: Internet :: WWW/HTTP Classifier: Topic :: Security Classifier: Topic :: System :: Installation/Setup Classifier: Topic :: System :: Networking Classifier: Topic :: System :: Systems Administration Classifier: Topic :: Utilities -Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* +Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* Provides-Extra: docs diff --git a/certbot_dns_rfc2136/_internal/__init__.py b/certbot_dns_rfc2136/_internal/__init__.py new file mode 100644 index 0000000..44894bb --- /dev/null +++ b/certbot_dns_rfc2136/_internal/__init__.py @@ -0,0 +1 @@ +"""Internal implementation of `~certbot_dns_rfc2136.dns_rfc2136` plugin.""" diff --git a/certbot_dns_rfc2136/_internal/dns_rfc2136.py b/certbot_dns_rfc2136/_internal/dns_rfc2136.py new file mode 100644 index 0000000..3bb4f44 --- /dev/null +++ b/certbot_dns_rfc2136/_internal/dns_rfc2136.py @@ -0,0 +1,226 @@ +"""DNS Authenticator using RFC 2136 Dynamic Updates.""" +import logging + +import dns.flags +import dns.message +import dns.name +import dns.query +import dns.rdataclass +import dns.rdatatype +import dns.tsig +import dns.tsigkeyring +import dns.update +import zope.interface + +from certbot import errors +from certbot import interfaces +from certbot.plugins import dns_common + +logger = logging.getLogger(__name__) + + +@zope.interface.implementer(interfaces.IAuthenticator) +@zope.interface.provider(interfaces.IPluginFactory) +class Authenticator(dns_common.DNSAuthenticator): + """DNS Authenticator using RFC 2136 Dynamic Updates + + This Authenticator uses RFC 2136 Dynamic Updates to fulfull a dns-01 challenge. + """ + + ALGORITHMS = { + 'HMAC-MD5': dns.tsig.HMAC_MD5, + 'HMAC-SHA1': dns.tsig.HMAC_SHA1, + 'HMAC-SHA224': dns.tsig.HMAC_SHA224, + 'HMAC-SHA256': dns.tsig.HMAC_SHA256, + 'HMAC-SHA384': dns.tsig.HMAC_SHA384, + 'HMAC-SHA512': dns.tsig.HMAC_SHA512 + } + + PORT = 53 + + description = 'Obtain certificates using a DNS TXT record (if you are using BIND for DNS).' + ttl = 120 + + def __init__(self, *args, **kwargs): + super(Authenticator, self).__init__(*args, **kwargs) + self.credentials = None + + @classmethod + def add_parser_arguments(cls, add): # pylint: disable=arguments-differ + super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=60) + add('credentials', help='RFC 2136 credentials INI file.') + + def more_info(self): # pylint: disable=missing-function-docstring + return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ + 'RFC 2136 Dynamic Updates.' + + def _validate_algorithm(self, credentials): + algorithm = credentials.conf('algorithm') + if algorithm: + if not self.ALGORITHMS.get(algorithm.upper()): + raise errors.PluginError("Unknown algorithm: {0}.".format(algorithm)) + + def _setup_credentials(self): + self.credentials = self._configure_credentials( + 'credentials', + 'RFC 2136 credentials INI file', + { + 'name': 'TSIG key name', + 'secret': 'TSIG key secret', + 'server': 'The target DNS server' + }, + self._validate_algorithm + ) + + def _perform(self, _domain, validation_name, validation): + self._get_rfc2136_client().add_txt_record(validation_name, validation, self.ttl) + + def _cleanup(self, _domain, validation_name, validation): + self._get_rfc2136_client().del_txt_record(validation_name, validation) + + def _get_rfc2136_client(self): + return _RFC2136Client(self.credentials.conf('server'), + int(self.credentials.conf('port') or self.PORT), + self.credentials.conf('name'), + self.credentials.conf('secret'), + self.ALGORITHMS.get(self.credentials.conf('algorithm'), + dns.tsig.HMAC_MD5)) + + +class _RFC2136Client(object): + """ + Encapsulates all communication with the target DNS server. + """ + def __init__(self, server, port, key_name, key_secret, key_algorithm): + self.server = server + self.port = port + self.keyring = dns.tsigkeyring.from_text({ + key_name: key_secret + }) + self.algorithm = key_algorithm + + def add_txt_record(self, record_name, record_content, record_ttl): + """ + Add a TXT record using the supplied information. + + :param str record_name: The record name (typically beginning with '_acme-challenge.'). + :param str record_content: The record content (typically the challenge validation). + :param int record_ttl: The record TTL (number of seconds that the record may be cached). + :raises certbot.errors.PluginError: if an error occurs communicating with the DNS server + """ + + domain = self._find_domain(record_name) + + n = dns.name.from_text(record_name) + o = dns.name.from_text(domain) + rel = n.relativize(o) + + update = dns.update.Update( + domain, + keyring=self.keyring, + keyalgorithm=self.algorithm) + update.add(rel, record_ttl, dns.rdatatype.TXT, record_content) + + try: + response = dns.query.tcp(update, self.server, port=self.port) + except Exception as e: + raise errors.PluginError('Encountered error adding TXT record: {0}' + .format(e)) + rcode = response.rcode() + + if rcode == dns.rcode.NOERROR: + logger.debug('Successfully added TXT record %s', record_name) + else: + raise errors.PluginError('Received response from server: {0}' + .format(dns.rcode.to_text(rcode))) + + def del_txt_record(self, record_name, record_content): + """ + Delete a TXT record using the supplied information. + + :param str record_name: The record name (typically beginning with '_acme-challenge.'). + :param str record_content: The record content (typically the challenge validation). + :param int record_ttl: The record TTL (number of seconds that the record may be cached). + :raises certbot.errors.PluginError: if an error occurs communicating with the DNS server + """ + + domain = self._find_domain(record_name) + + n = dns.name.from_text(record_name) + o = dns.name.from_text(domain) + rel = n.relativize(o) + + update = dns.update.Update( + domain, + keyring=self.keyring, + keyalgorithm=self.algorithm) + update.delete(rel, dns.rdatatype.TXT, record_content) + + try: + response = dns.query.tcp(update, self.server, port=self.port) + except Exception as e: + raise errors.PluginError('Encountered error deleting TXT record: {0}' + .format(e)) + rcode = response.rcode() + + if rcode == dns.rcode.NOERROR: + logger.debug('Successfully deleted TXT record %s', record_name) + else: + raise errors.PluginError('Received response from server: {0}' + .format(dns.rcode.to_text(rcode))) + + def _find_domain(self, record_name): + """ + Find the closest domain with an SOA record for a given domain name. + + :param str record_name: The record name for which to find the closest SOA record. + :returns: The domain, if found. + :rtype: str + :raises certbot.errors.PluginError: if no SOA record can be found. + """ + + domain_name_guesses = dns_common.base_domain_name_guesses(record_name) + + # Loop through until we find an authoritative SOA record + for guess in domain_name_guesses: + if self._query_soa(guess): + return guess + + raise errors.PluginError('Unable to determine base domain for {0} using names: {1}.' + .format(record_name, domain_name_guesses)) + + def _query_soa(self, domain_name): + """ + Query a domain name for an authoritative SOA record. + + :param str domain_name: The domain name to query for an SOA record. + :returns: True if found, False otherwise. + :rtype: bool + :raises certbot.errors.PluginError: if no response is received. + """ + + domain = dns.name.from_text(domain_name) + + request = dns.message.make_query(domain, dns.rdatatype.SOA, dns.rdataclass.IN) + # Turn off Recursion Desired bit in query + request.flags ^= dns.flags.RD + + try: + try: + response = dns.query.tcp(request, self.server, port=self.port) + except OSError as e: + logger.debug('TCP query failed, fallback to UDP: %s', e) + response = dns.query.udp(request, self.server, port=self.port) + rcode = response.rcode() + + # Authoritative Answer bit should be set + if (rcode == dns.rcode.NOERROR and response.get_rrset(response.answer, + domain, dns.rdataclass.IN, dns.rdatatype.SOA) and response.flags & dns.flags.AA): + logger.debug('Received authoritative SOA response for %s', domain_name) + return True + + logger.debug('No authoritative SOA record found for %s', domain_name) + return False + except Exception as e: + raise errors.PluginError('Encountered error when making query: {0}' + .format(e)) diff --git a/certbot_dns_rfc2136/dns_rfc2136.py b/certbot_dns_rfc2136/dns_rfc2136.py deleted file mode 100644 index 2061374..0000000 --- a/certbot_dns_rfc2136/dns_rfc2136.py +++ /dev/null @@ -1,222 +0,0 @@ -"""DNS Authenticator using RFC 2136 Dynamic Updates.""" -import logging - -import dns.flags -import dns.message -import dns.name -import dns.query -import dns.rdataclass -import dns.rdatatype -import dns.tsig -import dns.tsigkeyring -import dns.update -import zope.interface - -from certbot import errors -from certbot import interfaces -from certbot.plugins import dns_common - -logger = logging.getLogger(__name__) - - -@zope.interface.implementer(interfaces.IAuthenticator) -@zope.interface.provider(interfaces.IPluginFactory) -class Authenticator(dns_common.DNSAuthenticator): - """DNS Authenticator using RFC 2136 Dynamic Updates - - This Authenticator uses RFC 2136 Dynamic Updates to fulfull a dns-01 challenge. - """ - - ALGORITHMS = { - 'HMAC-MD5': dns.tsig.HMAC_MD5, - 'HMAC-SHA1': dns.tsig.HMAC_SHA1, - 'HMAC-SHA224': dns.tsig.HMAC_SHA224, - 'HMAC-SHA256': dns.tsig.HMAC_SHA256, - 'HMAC-SHA384': dns.tsig.HMAC_SHA384, - 'HMAC-SHA512': dns.tsig.HMAC_SHA512 - } - - PORT = 53 - - description = 'Obtain certificates using a DNS TXT record (if you are using BIND for DNS).' - ttl = 120 - - def __init__(self, *args, **kwargs): - super(Authenticator, self).__init__(*args, **kwargs) - self.credentials = None - - @classmethod - def add_parser_arguments(cls, add): # pylint: disable=arguments-differ - super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=60) - add('credentials', help='RFC 2136 credentials INI file.') - - def more_info(self): # pylint: disable=missing-docstring,no-self-use - return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ - 'RFC 2136 Dynamic Updates.' - - def _validate_algorithm(self, credentials): - algorithm = credentials.conf('algorithm') - if algorithm: - if not self.ALGORITHMS.get(algorithm.upper()): - raise errors.PluginError("Unknown algorithm: {0}.".format(algorithm)) - - def _setup_credentials(self): - self.credentials = self._configure_credentials( - 'credentials', - 'RFC 2136 credentials INI file', - { - 'name': 'TSIG key name', - 'secret': 'TSIG key secret', - 'server': 'The target DNS server' - }, - self._validate_algorithm - ) - - def _perform(self, _domain, validation_name, validation): - self._get_rfc2136_client().add_txt_record(validation_name, validation, self.ttl) - - def _cleanup(self, _domain, validation_name, validation): - self._get_rfc2136_client().del_txt_record(validation_name, validation) - - def _get_rfc2136_client(self): - return _RFC2136Client(self.credentials.conf('server'), - int(self.credentials.conf('port') or self.PORT), - self.credentials.conf('name'), - self.credentials.conf('secret'), - self.ALGORITHMS.get(self.credentials.conf('algorithm'), - dns.tsig.HMAC_MD5)) - - -class _RFC2136Client(object): - """ - Encapsulates all communication with the target DNS server. - """ - def __init__(self, server, port, key_name, key_secret, key_algorithm): - self.server = server - self.port = port - self.keyring = dns.tsigkeyring.from_text({ - key_name: key_secret - }) - self.algorithm = key_algorithm - - def add_txt_record(self, record_name, record_content, record_ttl): - """ - Add a TXT record using the supplied information. - - :param str record_name: The record name (typically beginning with '_acme-challenge.'). - :param str record_content: The record content (typically the challenge validation). - :param int record_ttl: The record TTL (number of seconds that the record may be cached). - :raises certbot.errors.PluginError: if an error occurs communicating with the DNS server - """ - - domain = self._find_domain(record_name) - - n = dns.name.from_text(record_name) - o = dns.name.from_text(domain) - rel = n.relativize(o) - - update = dns.update.Update( - domain, - keyring=self.keyring, - keyalgorithm=self.algorithm) - update.add(rel, record_ttl, dns.rdatatype.TXT, record_content) - - try: - response = dns.query.tcp(update, self.server, port=self.port) - except Exception as e: - raise errors.PluginError('Encountered error adding TXT record: {0}' - .format(e)) - rcode = response.rcode() - - if rcode == dns.rcode.NOERROR: - logger.debug('Successfully added TXT record') - else: - raise errors.PluginError('Received response from server: {0}' - .format(dns.rcode.to_text(rcode))) - - def del_txt_record(self, record_name, record_content): - """ - Delete a TXT record using the supplied information. - - :param str record_name: The record name (typically beginning with '_acme-challenge.'). - :param str record_content: The record content (typically the challenge validation). - :param int record_ttl: The record TTL (number of seconds that the record may be cached). - :raises certbot.errors.PluginError: if an error occurs communicating with the DNS server - """ - - domain = self._find_domain(record_name) - - n = dns.name.from_text(record_name) - o = dns.name.from_text(domain) - rel = n.relativize(o) - - update = dns.update.Update( - domain, - keyring=self.keyring, - keyalgorithm=self.algorithm) - update.delete(rel, dns.rdatatype.TXT, record_content) - - try: - response = dns.query.tcp(update, self.server, port=self.port) - except Exception as e: - raise errors.PluginError('Encountered error deleting TXT record: {0}' - .format(e)) - rcode = response.rcode() - - if rcode == dns.rcode.NOERROR: - logger.debug('Successfully deleted TXT record') - else: - raise errors.PluginError('Received response from server: {0}' - .format(dns.rcode.to_text(rcode))) - - def _find_domain(self, record_name): - """ - Find the closest domain with an SOA record for a given domain name. - - :param str record_name: The record name for which to find the closest SOA record. - :returns: The domain, if found. - :rtype: str - :raises certbot.errors.PluginError: if no SOA record can be found. - """ - - domain_name_guesses = dns_common.base_domain_name_guesses(record_name) - - # Loop through until we find an authoritative SOA record - for guess in domain_name_guesses: - if self._query_soa(guess): - return guess - - raise errors.PluginError('Unable to determine base domain for {0} using names: {1}.' - .format(record_name, domain_name_guesses)) - - def _query_soa(self, domain_name): - """ - Query a domain name for an authoritative SOA record. - - :param str domain_name: The domain name to query for an SOA record. - :returns: True if found, False otherwise. - :rtype: bool - :raises certbot.errors.PluginError: if no response is received. - """ - - domain = dns.name.from_text(domain_name) - - request = dns.message.make_query(domain, dns.rdatatype.SOA, dns.rdataclass.IN) - # Turn off Recursion Desired bit in query - request.flags ^= dns.flags.RD - - try: - response = dns.query.udp(request, self.server, port=self.port) - rcode = response.rcode() - - # Authoritative Answer bit should be set - if (rcode == dns.rcode.NOERROR and response.get_rrset(response.answer, - domain, dns.rdataclass.IN, dns.rdatatype.SOA) and response.flags & dns.flags.AA): - logger.debug('Received authoritative SOA response for %s', domain_name) - return True - - logger.debug('No authoritative SOA record found for %s', domain_name) - return False - except Exception as e: - raise errors.PluginError('Encountered error when making query: {0}' - .format(e)) diff --git a/certbot_dns_rfc2136/dns_rfc2136_test.py b/certbot_dns_rfc2136/dns_rfc2136_test.py deleted file mode 100644 index d800f1e..0000000 --- a/certbot_dns_rfc2136/dns_rfc2136_test.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Tests for certbot_dns_rfc2136.dns_rfc2136.""" - -import unittest - -import dns.flags -import dns.rcode -import dns.tsig -import mock - -from certbot import errors -from certbot.compat import os -from certbot.plugins import dns_test_common -from certbot.plugins.dns_test_common import DOMAIN -from certbot.tests import util as test_util - -SERVER = '192.0.2.1' -PORT = 53 -NAME = 'a-tsig-key.' -SECRET = 'SSB3b25kZXIgd2hvIHdpbGwgYm90aGVyIHRvIGRlY29kZSB0aGlzIHRleHQK' -VALID_CONFIG = {"rfc2136_server": SERVER, "rfc2136_name": NAME, "rfc2136_secret": SECRET} - - -class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest): - - def setUp(self): - from certbot_dns_rfc2136.dns_rfc2136 import Authenticator - - super(AuthenticatorTest, self).setUp() - - path = os.path.join(self.tempdir, 'file.ini') - dns_test_common.write(VALID_CONFIG, path) - - self.config = mock.MagicMock(rfc2136_credentials=path, - rfc2136_propagation_seconds=0) # don't wait during tests - - self.auth = Authenticator(self.config, "rfc2136") - - self.mock_client = mock.MagicMock() - # _get_rfc2136_client | pylint: disable=protected-access - self.auth._get_rfc2136_client = mock.MagicMock(return_value=self.mock_client) - - def test_perform(self): - self.auth.perform([self.achall]) - - expected = [mock.call.add_txt_record('_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] - self.assertEqual(expected, self.mock_client.mock_calls) - - def test_cleanup(self): - # _attempt_cleanup | pylint: disable=protected-access - self.auth._attempt_cleanup = True - self.auth.cleanup([self.achall]) - - expected = [mock.call.del_txt_record('_acme-challenge.'+DOMAIN, mock.ANY)] - self.assertEqual(expected, self.mock_client.mock_calls) - - def test_invalid_algorithm_raises(self): - config = VALID_CONFIG.copy() - config["rfc2136_algorithm"] = "INVALID" - dns_test_common.write(config, self.config.rfc2136_credentials) - - self.assertRaises(errors.PluginError, - self.auth.perform, - [self.achall]) - - def test_valid_algorithm_passes(self): - config = VALID_CONFIG.copy() - config["rfc2136_algorithm"] = "HMAC-sha512" - dns_test_common.write(config, self.config.rfc2136_credentials) - - self.auth.perform([self.achall]) - - -class RFC2136ClientTest(unittest.TestCase): - - def setUp(self): - from certbot_dns_rfc2136.dns_rfc2136 import _RFC2136Client - - self.rfc2136_client = _RFC2136Client(SERVER, PORT, NAME, SECRET, dns.tsig.HMAC_MD5) - - @mock.patch("dns.query.tcp") - def test_add_txt_record(self, query_mock): - query_mock.return_value.rcode.return_value = dns.rcode.NOERROR - # _find_domain | pylint: disable=protected-access - self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") - - self.rfc2136_client.add_txt_record("bar", "baz", 42) - - query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) - self.assertTrue("bar. 42 IN TXT \"baz\"" in str(query_mock.call_args[0][0])) - - @mock.patch("dns.query.tcp") - def test_add_txt_record_wraps_errors(self, query_mock): - query_mock.side_effect = Exception - # _find_domain | pylint: disable=protected-access - self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") - - self.assertRaises( - errors.PluginError, - self.rfc2136_client.add_txt_record, - "bar", "baz", 42) - - @mock.patch("dns.query.tcp") - def test_add_txt_record_server_error(self, query_mock): - query_mock.return_value.rcode.return_value = dns.rcode.NXDOMAIN - # _find_domain | pylint: disable=protected-access - self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") - - self.assertRaises( - errors.PluginError, - self.rfc2136_client.add_txt_record, - "bar", "baz", 42) - - @mock.patch("dns.query.tcp") - def test_del_txt_record(self, query_mock): - query_mock.return_value.rcode.return_value = dns.rcode.NOERROR - # _find_domain | pylint: disable=protected-access - self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") - - self.rfc2136_client.del_txt_record("bar", "baz") - - query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) - self.assertTrue("bar. 0 NONE TXT \"baz\"" in str(query_mock.call_args[0][0])) - - @mock.patch("dns.query.tcp") - def test_del_txt_record_wraps_errors(self, query_mock): - query_mock.side_effect = Exception - # _find_domain | pylint: disable=protected-access - self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") - - self.assertRaises( - errors.PluginError, - self.rfc2136_client.del_txt_record, - "bar", "baz") - - @mock.patch("dns.query.tcp") - def test_del_txt_record_server_error(self, query_mock): - query_mock.return_value.rcode.return_value = dns.rcode.NXDOMAIN - # _find_domain | pylint: disable=protected-access - self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") - - self.assertRaises( - errors.PluginError, - self.rfc2136_client.del_txt_record, - "bar", "baz") - - def test_find_domain(self): - # _query_soa | pylint: disable=protected-access - self.rfc2136_client._query_soa = mock.MagicMock(side_effect=[False, False, True]) - - # _find_domain | pylint: disable=protected-access - domain = self.rfc2136_client._find_domain('foo.bar.'+DOMAIN) - - self.assertTrue(domain == DOMAIN) - - def test_find_domain_wraps_errors(self): - # _query_soa | pylint: disable=protected-access - self.rfc2136_client._query_soa = mock.MagicMock(return_value=False) - - self.assertRaises( - errors.PluginError, - # _find_domain | pylint: disable=protected-access - self.rfc2136_client._find_domain, - 'foo.bar.'+DOMAIN) - - @mock.patch("dns.query.udp") - def test_query_soa_found(self, query_mock): - query_mock.return_value = mock.MagicMock(answer=[mock.MagicMock()], flags=dns.flags.AA) - query_mock.return_value.rcode.return_value = dns.rcode.NOERROR - - # _query_soa | pylint: disable=protected-access - result = self.rfc2136_client._query_soa(DOMAIN) - - query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) - self.assertTrue(result) - - @mock.patch("dns.query.udp") - def test_query_soa_not_found(self, query_mock): - query_mock.return_value.rcode.return_value = dns.rcode.NXDOMAIN - - # _query_soa | pylint: disable=protected-access - result = self.rfc2136_client._query_soa(DOMAIN) - - query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) - self.assertFalse(result) - - @mock.patch("dns.query.udp") - def test_query_soa_wraps_errors(self, query_mock): - query_mock.side_effect = Exception - - self.assertRaises( - errors.PluginError, - # _query_soa | pylint: disable=protected-access - self.rfc2136_client._query_soa, - DOMAIN) - - -if __name__ == "__main__": - unittest.main() # pragma: no cover diff --git a/certbot_dns_rfc2136.egg-info/PKG-INFO b/certbot_dns_rfc2136.egg-info/PKG-INFO index 9179d39..c208e5d 100644 --- a/certbot_dns_rfc2136.egg-info/PKG-INFO +++ b/certbot_dns_rfc2136.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: certbot-dns-rfc2136 -Version: 0.35.1 +Version: 1.3.0 Summary: RFC 2136 DNS Authenticator plugin for Certbot Home-page: https://github.com/certbot/certbot Author: Certbot Project @@ -8,7 +8,7 @@ License: Apache License 2.0 Description: UNKNOWN Platform: UNKNOWN -Classifier: Development Status :: 3 - Alpha +Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Plugins Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License @@ -17,15 +17,15 @@ Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 Classifier: Topic :: Internet :: WWW/HTTP Classifier: Topic :: Security Classifier: Topic :: System :: Installation/Setup Classifier: Topic :: System :: Networking Classifier: Topic :: System :: Systems Administration Classifier: Topic :: Utilities -Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* +Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* Provides-Extra: docs diff --git a/certbot_dns_rfc2136.egg-info/SOURCES.txt b/certbot_dns_rfc2136.egg-info/SOURCES.txt index a0db16f..d497f0c 100644 --- a/certbot_dns_rfc2136.egg-info/SOURCES.txt +++ b/certbot_dns_rfc2136.egg-info/SOURCES.txt @@ -4,18 +4,18 @@ setup.cfg setup.py certbot_dns_rfc2136/__init__.py -certbot_dns_rfc2136/dns_rfc2136.py -certbot_dns_rfc2136/dns_rfc2136_test.py certbot_dns_rfc2136.egg-info/PKG-INFO certbot_dns_rfc2136.egg-info/SOURCES.txt certbot_dns_rfc2136.egg-info/dependency_links.txt certbot_dns_rfc2136.egg-info/entry_points.txt certbot_dns_rfc2136.egg-info/requires.txt certbot_dns_rfc2136.egg-info/top_level.txt +certbot_dns_rfc2136/_internal/__init__.py +certbot_dns_rfc2136/_internal/dns_rfc2136.py docs/.gitignore docs/Makefile docs/api.rst docs/conf.py docs/index.rst docs/make.bat -docs/api/dns_rfc2136.rst \ No newline at end of file +tests/dns_rfc2136_test.py \ No newline at end of file diff --git a/certbot_dns_rfc2136.egg-info/entry_points.txt b/certbot_dns_rfc2136.egg-info/entry_points.txt index 2df39a7..4dd75d0 100644 --- a/certbot_dns_rfc2136.egg-info/entry_points.txt +++ b/certbot_dns_rfc2136.egg-info/entry_points.txt @@ -1,3 +1,3 @@ [certbot.plugins] -dns-rfc2136 = certbot_dns_rfc2136.dns_rfc2136:Authenticator +dns-rfc2136 = certbot_dns_rfc2136._internal.dns_rfc2136:Authenticator diff --git a/certbot_dns_rfc2136.egg-info/requires.txt b/certbot_dns_rfc2136.egg-info/requires.txt index 0c8dcb6..1ffaac3 100644 --- a/certbot_dns_rfc2136.egg-info/requires.txt +++ b/certbot_dns_rfc2136.egg-info/requires.txt @@ -1,5 +1,5 @@ acme>=0.29.0 -certbot>=0.34.0 +certbot>=1.1.0 dnspython mock setuptools diff --git a/docs/api/dns_rfc2136.rst b/docs/api/dns_rfc2136.rst deleted file mode 100644 index f5e9845..0000000 --- a/docs/api/dns_rfc2136.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`certbot_dns_rfc2136.dns_rfc2136` --------------------------------------- - -.. automodule:: certbot_dns_rfc2136.dns_rfc2136 - :members: diff --git a/docs/api.rst b/docs/api.rst index 8668ec5..ac13c3d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2,7 +2,4 @@ API Documentation ================= -.. toctree:: - :glob: - - api/** +Certbot plugins implement the Certbot plugins API, and do not otherwise have an external API. diff --git a/docs/conf.py b/docs/conf.py index 8cc5d59..731b9cb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os + # import sys # sys.path.insert(0, os.path.abspath('.')) @@ -37,7 +38,7 @@ 'sphinx.ext.viewcode'] autodoc_member_order = 'bysource' -autodoc_default_flags = ['show-inheritance', 'private-members'] +autodoc_default_flags = ['show-inheritance'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -83,7 +84,7 @@ pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True +todo_include_todos = False # -- Options for HTML output ---------------------------------------------- diff --git a/setup.py b/setup.py index cc406aa..42c6f11 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,16 @@ +import sys + +from setuptools import find_packages from setuptools import setup -from setuptools import find_packages +from setuptools.command.test import test as TestCommand - -version = '0.35.1' +version = '1.3.0' # Remember to update local-oldest-requirements.txt when changing the minimum # acme/certbot version. install_requires = [ 'acme>=0.29.0', - 'certbot>=0.34.0', + 'certbot>=1.1.0', 'dnspython', 'mock', 'setuptools', @@ -20,6 +22,20 @@ 'sphinx_rtd_theme', ] +class PyTest(TestCommand): + user_options = [] + + def initialize_options(self): + TestCommand.initialize_options(self) + self.pytest_args = '' + + def run_tests(self): + import shlex + # import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main(shlex.split(self.pytest_args)) + sys.exit(errno) + setup( name='certbot-dns-rfc2136', version=version, @@ -28,9 +44,9 @@ author="Certbot Project", author_email='client-dev@letsencrypt.org', license='Apache License 2.0', - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 5 - Production/Stable', 'Environment :: Plugins', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: Apache Software License', @@ -39,10 +55,10 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', 'Topic :: System :: Installation/Setup', @@ -59,8 +75,10 @@ }, entry_points={ 'certbot.plugins': [ - 'dns-rfc2136 = certbot_dns_rfc2136.dns_rfc2136:Authenticator', + 'dns-rfc2136 = certbot_dns_rfc2136._internal.dns_rfc2136:Authenticator', ], }, + tests_require=["pytest"], test_suite='certbot_dns_rfc2136', + cmdclass={"test": PyTest}, ) diff --git a/tests/dns_rfc2136_test.py b/tests/dns_rfc2136_test.py new file mode 100644 index 0000000..c767dba --- /dev/null +++ b/tests/dns_rfc2136_test.py @@ -0,0 +1,212 @@ +"""Tests for certbot_dns_rfc2136._internal.dns_rfc2136.""" + +import unittest + +import dns.flags +import dns.rcode +import dns.tsig +import mock + +from certbot import errors +from certbot.compat import os +from certbot.plugins import dns_test_common +from certbot.plugins.dns_test_common import DOMAIN +from certbot.tests import util as test_util + +SERVER = '192.0.2.1' +PORT = 53 +NAME = 'a-tsig-key.' +SECRET = 'SSB3b25kZXIgd2hvIHdpbGwgYm90aGVyIHRvIGRlY29kZSB0aGlzIHRleHQK' +VALID_CONFIG = {"rfc2136_server": SERVER, "rfc2136_name": NAME, "rfc2136_secret": SECRET} + + +class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest): + + def setUp(self): + from certbot_dns_rfc2136._internal.dns_rfc2136 import Authenticator + + super(AuthenticatorTest, self).setUp() + + path = os.path.join(self.tempdir, 'file.ini') + dns_test_common.write(VALID_CONFIG, path) + + self.config = mock.MagicMock(rfc2136_credentials=path, + rfc2136_propagation_seconds=0) # don't wait during tests + + self.auth = Authenticator(self.config, "rfc2136") + + self.mock_client = mock.MagicMock() + # _get_rfc2136_client | pylint: disable=protected-access + self.auth._get_rfc2136_client = mock.MagicMock(return_value=self.mock_client) + + def test_perform(self): + self.auth.perform([self.achall]) + + expected = [mock.call.add_txt_record('_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] + self.assertEqual(expected, self.mock_client.mock_calls) + + def test_cleanup(self): + # _attempt_cleanup | pylint: disable=protected-access + self.auth._attempt_cleanup = True + self.auth.cleanup([self.achall]) + + expected = [mock.call.del_txt_record('_acme-challenge.'+DOMAIN, mock.ANY)] + self.assertEqual(expected, self.mock_client.mock_calls) + + def test_invalid_algorithm_raises(self): + config = VALID_CONFIG.copy() + config["rfc2136_algorithm"] = "INVALID" + dns_test_common.write(config, self.config.rfc2136_credentials) + + self.assertRaises(errors.PluginError, + self.auth.perform, + [self.achall]) + + def test_valid_algorithm_passes(self): + config = VALID_CONFIG.copy() + config["rfc2136_algorithm"] = "HMAC-sha512" + dns_test_common.write(config, self.config.rfc2136_credentials) + + self.auth.perform([self.achall]) + + +class RFC2136ClientTest(unittest.TestCase): + + def setUp(self): + from certbot_dns_rfc2136._internal.dns_rfc2136 import _RFC2136Client + + self.rfc2136_client = _RFC2136Client(SERVER, PORT, NAME, SECRET, dns.tsig.HMAC_MD5) + + @mock.patch("dns.query.tcp") + def test_add_txt_record(self, query_mock): + query_mock.return_value.rcode.return_value = dns.rcode.NOERROR + # _find_domain | pylint: disable=protected-access + self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") + + self.rfc2136_client.add_txt_record("bar", "baz", 42) + + query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) + self.assertTrue("bar. 42 IN TXT \"baz\"" in str(query_mock.call_args[0][0])) + + @mock.patch("dns.query.tcp") + def test_add_txt_record_wraps_errors(self, query_mock): + query_mock.side_effect = Exception + # _find_domain | pylint: disable=protected-access + self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") + + self.assertRaises( + errors.PluginError, + self.rfc2136_client.add_txt_record, + "bar", "baz", 42) + + @mock.patch("dns.query.tcp") + def test_add_txt_record_server_error(self, query_mock): + query_mock.return_value.rcode.return_value = dns.rcode.NXDOMAIN + # _find_domain | pylint: disable=protected-access + self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") + + self.assertRaises( + errors.PluginError, + self.rfc2136_client.add_txt_record, + "bar", "baz", 42) + + @mock.patch("dns.query.tcp") + def test_del_txt_record(self, query_mock): + query_mock.return_value.rcode.return_value = dns.rcode.NOERROR + # _find_domain | pylint: disable=protected-access + self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") + + self.rfc2136_client.del_txt_record("bar", "baz") + + query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) + self.assertTrue("bar. 0 NONE TXT \"baz\"" in str(query_mock.call_args[0][0])) + + @mock.patch("dns.query.tcp") + def test_del_txt_record_wraps_errors(self, query_mock): + query_mock.side_effect = Exception + # _find_domain | pylint: disable=protected-access + self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") + + self.assertRaises( + errors.PluginError, + self.rfc2136_client.del_txt_record, + "bar", "baz") + + @mock.patch("dns.query.tcp") + def test_del_txt_record_server_error(self, query_mock): + query_mock.return_value.rcode.return_value = dns.rcode.NXDOMAIN + # _find_domain | pylint: disable=protected-access + self.rfc2136_client._find_domain = mock.MagicMock(return_value="example.com") + + self.assertRaises( + errors.PluginError, + self.rfc2136_client.del_txt_record, + "bar", "baz") + + def test_find_domain(self): + # _query_soa | pylint: disable=protected-access + self.rfc2136_client._query_soa = mock.MagicMock(side_effect=[False, False, True]) + + # _find_domain | pylint: disable=protected-access + domain = self.rfc2136_client._find_domain('foo.bar.'+DOMAIN) + + self.assertTrue(domain == DOMAIN) + + def test_find_domain_wraps_errors(self): + # _query_soa | pylint: disable=protected-access + self.rfc2136_client._query_soa = mock.MagicMock(return_value=False) + + self.assertRaises( + errors.PluginError, + # _find_domain | pylint: disable=protected-access + self.rfc2136_client._find_domain, + 'foo.bar.'+DOMAIN) + + @mock.patch("dns.query.tcp") + def test_query_soa_found(self, query_mock): + query_mock.return_value = mock.MagicMock(answer=[mock.MagicMock()], flags=dns.flags.AA) + query_mock.return_value.rcode.return_value = dns.rcode.NOERROR + + # _query_soa | pylint: disable=protected-access + result = self.rfc2136_client._query_soa(DOMAIN) + + query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) + self.assertTrue(result) + + @mock.patch("dns.query.tcp") + def test_query_soa_not_found(self, query_mock): + query_mock.return_value.rcode.return_value = dns.rcode.NXDOMAIN + + # _query_soa | pylint: disable=protected-access + result = self.rfc2136_client._query_soa(DOMAIN) + + query_mock.assert_called_with(mock.ANY, SERVER, port=PORT) + self.assertFalse(result) + + @mock.patch("dns.query.tcp") + def test_query_soa_wraps_errors(self, query_mock): + query_mock.side_effect = Exception + + self.assertRaises( + errors.PluginError, + # _query_soa | pylint: disable=protected-access + self.rfc2136_client._query_soa, + DOMAIN) + + @mock.patch("dns.query.udp") + @mock.patch("dns.query.tcp") + def test_query_soa_fallback_to_udp(self, tcp_mock, udp_mock): + tcp_mock.side_effect = OSError + udp_mock.return_value = mock.MagicMock(answer=[mock.MagicMock()], flags=dns.flags.AA) + udp_mock.return_value.rcode.return_value = dns.rcode.NOERROR + + # _query_soa | pylint: disable=protected-access + result = self.rfc2136_client._query_soa(DOMAIN) + + tcp_mock.assert_called_with(mock.ANY, SERVER, port=PORT) + udp_mock.assert_called_with(mock.ANY, SERVER, port=PORT) + self.assertTrue(result) + + +if __name__ == "__main__": + unittest.main() # pragma: no cover