From 531044a8e903fea084b5e516aad84e93ae056dfe Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Mon, 9 Mar 2020 14:35:55 -0400 Subject: [PATCH 1/8] Initial commit of PowerDNS dns provider. --- sewer/__init__.py | 1 + sewer/dns_providers/__init__.py | 1 + sewer/dns_providers/powerdns.py | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 sewer/dns_providers/powerdns.py diff --git a/sewer/__init__.py b/sewer/__init__.py index 88d49101..4cafcd2b 100644 --- a/sewer/__init__.py +++ b/sewer/__init__.py @@ -11,3 +11,4 @@ from .dns_providers import DuckDNSDns # noqa:F401 from .dns_providers import ClouDNSDns # noqa:F401 from .dns_providers import Route53Dns # noqa:F401 +from .dns_providers import PowerDNSDns diff --git a/sewer/dns_providers/__init__.py b/sewer/dns_providers/__init__.py index e5e737fa..78352b1f 100644 --- a/sewer/dns_providers/__init__.py +++ b/sewer/dns_providers/__init__.py @@ -9,3 +9,4 @@ from .duckdns import DuckDNSDns # noqa: F401 from .cloudns import ClouDNSDns # noqa: F401 from .route53 import Route53Dns # noqa: F401 +from .powerdns import PowerDNSDns diff --git a/sewer/dns_providers/powerdns.py b/sewer/dns_providers/powerdns.py new file mode 100644 index 00000000..12a3245d --- /dev/null +++ b/sewer/dns_providers/powerdns.py @@ -0,0 +1,50 @@ +import json +import requests + +from . import common + + +class PowerDNSDns(common.BaseDns): + dns_provider_name = "powerdns" + + def __init__(self, powerdns_api_key, powerdns_api_url): + self.powerdns_api_key = powerdns_api_key + self.powerdns_api_url = powerdns_api_url + super(PowerDNSDns, self).__init__() + + + def _common_dns_record(self, domain_name, domain_dns_value, changetype): + if changetype not in ('REPLACE', 'DELETE'): + raise ValueError("changetype is not valid.") + + payload = { + "rrsets": [ + { + 'name': '_acme-challenge' + '.' + domain_name + '.', + "type": "TXT", + "ttl": 60, + "changetype": changetype, + "records": [ + { + "content": f'"{domain_dns_value}"', + "disabled": False + } + ] + } + ] + } + + powerdns_response = requests.patch( + self.powerdns_api_url + '/' + domain_name, + data=json.dumps(payload), + headers={'X-API-Key': self.powerdns_api_key} + ) + + + def create_dns_record(self, domain_name, domain_dns_value): + self._common_dns_record(domain_name, domain_dns_value, "REPLACE") + + + + def delete_dns_record(self, domain_name, domain_dns_value): + self._common_dns_record(domain_name, domain_dns_value, "DELETE") From 40a61aafd37964a87019ef79034276f641fe17eb Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Mon, 9 Mar 2020 14:42:58 -0400 Subject: [PATCH 2/8] Remove unused variable `powedns_response` --- sewer/dns_providers/powerdns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sewer/dns_providers/powerdns.py b/sewer/dns_providers/powerdns.py index 12a3245d..3aeaeec3 100644 --- a/sewer/dns_providers/powerdns.py +++ b/sewer/dns_providers/powerdns.py @@ -34,7 +34,7 @@ def _common_dns_record(self, domain_name, domain_dns_value, changetype): ] } - powerdns_response = requests.patch( + requests.patch( self.powerdns_api_url + '/' + domain_name, data=json.dumps(payload), headers={'X-API-Key': self.powerdns_api_key} From e63bb197d306b81aba14035c061d194ef00affd5 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Mon, 9 Mar 2020 14:43:12 -0400 Subject: [PATCH 3/8] Remove extra blank line --- sewer/dns_providers/powerdns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sewer/dns_providers/powerdns.py b/sewer/dns_providers/powerdns.py index 3aeaeec3..1641915d 100644 --- a/sewer/dns_providers/powerdns.py +++ b/sewer/dns_providers/powerdns.py @@ -45,6 +45,5 @@ def create_dns_record(self, domain_name, domain_dns_value): self._common_dns_record(domain_name, domain_dns_value, "REPLACE") - def delete_dns_record(self, domain_name, domain_dns_value): self._common_dns_record(domain_name, domain_dns_value, "DELETE") From ca5032945c3615cac069b874fd952f6114ec0c36 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Mon, 9 Mar 2020 14:47:30 -0400 Subject: [PATCH 4/8] Formatting with `black` --- sewer/dns_providers/powerdns.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/sewer/dns_providers/powerdns.py b/sewer/dns_providers/powerdns.py index 1641915d..72068ab0 100644 --- a/sewer/dns_providers/powerdns.py +++ b/sewer/dns_providers/powerdns.py @@ -12,38 +12,30 @@ def __init__(self, powerdns_api_key, powerdns_api_url): self.powerdns_api_url = powerdns_api_url super(PowerDNSDns, self).__init__() - def _common_dns_record(self, domain_name, domain_dns_value, changetype): - if changetype not in ('REPLACE', 'DELETE'): + if changetype not in ("REPLACE", "DELETE"): raise ValueError("changetype is not valid.") payload = { "rrsets": [ { - 'name': '_acme-challenge' + '.' + domain_name + '.', + "name": "_acme-challenge" + "." + domain_name + ".", "type": "TXT", "ttl": 60, "changetype": changetype, - "records": [ - { - "content": f'"{domain_dns_value}"', - "disabled": False - } - ] + "records": [{"content": f'"{domain_dns_value}"', "disabled": False}], } ] } requests.patch( - self.powerdns_api_url + '/' + domain_name, + self.powerdns_api_url + "/" + domain_name, data=json.dumps(payload), - headers={'X-API-Key': self.powerdns_api_key} + headers={"X-API-Key": self.powerdns_api_key}, ) - def create_dns_record(self, domain_name, domain_dns_value): self._common_dns_record(domain_name, domain_dns_value, "REPLACE") - def delete_dns_record(self, domain_name, domain_dns_value): self._common_dns_record(domain_name, domain_dns_value, "DELETE") From f04b25d693248ff5526de437db108faba89d4ab9 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 10 Mar 2020 16:02:59 -0400 Subject: [PATCH 5/8] Handle PowerDNS records for subdomains. --- sewer/dns_providers/powerdns.py | 64 ++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/sewer/dns_providers/powerdns.py b/sewer/dns_providers/powerdns.py index 72068ab0..ad487c37 100644 --- a/sewer/dns_providers/powerdns.py +++ b/sewer/dns_providers/powerdns.py @@ -5,6 +5,20 @@ class PowerDNSDns(common.BaseDns): + """ + For PowerDNS, all subdomains for a given domain live under the apex zone `zone_id`. + For example, if you want a cert for domain.tld and www.domain.tld, you need + to create two DNS records: + 1) `acme-challenge.domain.tld. IN TXT` + 2) `acme-challenge.www.domain.tld. IN TXT` + + However, both of these records must be created under `/servers/{server_id}/zones/{zone_id}`, + where `zone_id` is the apex domain (`domain.tld`) + + So, we must be smart about stripping out subdomains as part of the URL passed + to `requests.patch`, but must maintain the FQDN in the `name` field of the `payload`. + """ + dns_provider_name = "powerdns" def __init__(self, powerdns_api_key, powerdns_api_url): @@ -12,6 +26,29 @@ def __init__(self, powerdns_api_key, powerdns_api_url): self.powerdns_api_url = powerdns_api_url super(PowerDNSDns, self).__init__() + def validate_powerdns_zone(self, domain_name): + """ + Walk `domain_name` backwards, trying to find the apex domain. + E.g.: For `fu.bar.baz.domain.com`, `response.status_code` will only be + `200` for `domain.com` + """ + d = "." + count = domain_name.count(d) + + while True: + url = self.powerdns_api_url + "/" + domain_name + response = requests.get(url, headers={"X-API-Key": self.powerdns_api_key}) + + if response.status_code == 200: + return domain_name + elif count <= 0: + raise ValueError("Something went wrong...") + else: + split = domain_name.split(d) + split.pop(0) + domain_name = d.join(split) + count -= 1 + def _common_dns_record(self, domain_name, domain_dns_value, changetype): if changetype not in ("REPLACE", "DELETE"): raise ValueError("changetype is not valid.") @@ -27,12 +64,29 @@ def _common_dns_record(self, domain_name, domain_dns_value, changetype): } ] } + self.logger.debug(f"PowerDNS domain name: {domain_name}") + self.logger.debug(f"PowerDNS payload: {payload}") + + apex_domain = self.validate_powerdns_zone(domain_name) + url = self.powerdns_api_url + "/" + apex_domain + self.logger.debug(f"apex_domain: {apex_domain}") + self.logger.debug(f"url: {url}") + + try: + response = requests.patch( + url, data=json.dumps(payload), headers={"X-API-Key": self.powerdns_api_key} + ) + self.logger.debug(f"PowerDNS response: {response.status_code}, {response.text}") + except Exception as e: + self.logger.error(f"Unable to communicate with PowerDNS API: {e}") + raise e - requests.patch( - self.powerdns_api_url + "/" + domain_name, - data=json.dumps(payload), - headers={"X-API-Key": self.powerdns_api_key}, - ) + # Per https://doc.powerdns.com/authoritative/http-api/zone.html: + # PATCH /servers/{server_id}/zones/{zone_id} + # Creates/modifies/deletes RRsets present in the payload and their comments. + # Returns 204 No Content on success. + if response.status_code != 204: + raise ValueError(f"Error creating or deleting PowerDNS record: {response.text}") def create_dns_record(self, domain_name, domain_dns_value): self._common_dns_record(domain_name, domain_dns_value, "REPLACE") From 7fbcbccfdb56753aac8aed1c80c2810c88191971 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Thu, 12 Mar 2020 11:21:43 -0400 Subject: [PATCH 6/8] tests for powerdns provider --- sewer/dns_providers/powerdns.py | 4 +- sewer/dns_providers/tests/test_powerdns.py | 69 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 sewer/dns_providers/tests/test_powerdns.py diff --git a/sewer/dns_providers/powerdns.py b/sewer/dns_providers/powerdns.py index ad487c37..7edda248 100644 --- a/sewer/dns_providers/powerdns.py +++ b/sewer/dns_providers/powerdns.py @@ -42,7 +42,9 @@ def validate_powerdns_zone(self, domain_name): if response.status_code == 200: return domain_name elif count <= 0: - raise ValueError("Something went wrong...") + raise ValueError( + f"Could not determine apex domain: (count: {count}, domain_name: {domain_name})" + ) else: split = domain_name.split(d) split.pop(0) diff --git a/sewer/dns_providers/tests/test_powerdns.py b/sewer/dns_providers/tests/test_powerdns.py new file mode 100644 index 00000000..4a7a53f2 --- /dev/null +++ b/sewer/dns_providers/tests/test_powerdns.py @@ -0,0 +1,69 @@ +import mock +from unittest import TestCase + +import sewer + +class TestPowerDNS(TestCase): + """ + Tests for PowerDNS DNS provider class. + """ + + def setUp(self): + self.domain_name = "example.com" + self.domain_dns_value = "mock-domain_dns_value" + self.powerdns_api_key = "mock-api-key" + self.powerdns_api_url = "https://some-mock-url.com" + self.powerdns_common_response = 204 + self.powerdns_apex_response = 200 + self.dns_class = sewer.PowerDNSDns( + powerdns_api_key=self.powerdns_api_key, powerdns_api_url=self.powerdns_api_url + ) + + def tearDown(self): + pass + + def test_validate_powerdns_zone(self): + fqdn = f"fu.bar.baz.{self.domain_name}" + + with mock.patch("requests.get") as mock_requests_get, mock.patch( + "sewer.PowerDNSDns.validate_powerdns_zone" + ) as mock_validate_powerdns_zone: + + mock_requests_get.return_value.status_code = self.powerdns_apex_response + mock_validate_powerdns_zone.return_value = self.domain_name + + response = self.dns_class.validate_powerdns_zone(fqdn) + + self.assertEqual(response, self.domain_name) + mock_validate_powerdns_zone.assert_called_with(fqdn) + + def test_powerdns_is_called_by_create_dns_record(self): + with mock.patch("requests.patch") as mock_requests_patch, mock.patch( + "sewer.PowerDNSDns.create_dns_record" + ) as mock_create_dns_record: + + mock_requests_patch.return_value = self.powerdns_common_response + + mock_requests_patch.return_value = ( + mock_create_dns_record.return_value + ) = self.powerdns_common_response + + self.dns_class.create_dns_record(self.domain_name, self.domain_dns_value) + + self.assertEqual(mock_requests_patch.return_value, self.powerdns_common_response) + mock_create_dns_record.assert_called_with(self.domain_name, self.domain_dns_value) + + def test_powerdns_is_called_by_delete_dns_record(self): + with mock.patch("requests.patch") as mock_requests_patch, mock.patch( + "sewer.PowerDNSDns.delete_dns_record" + ) as mock_delete_dns_record: + mock_requests_patch.return_value = self.powerdns_common_response + + mock_requests_patch.return_value = ( + mock_delete_dns_record.return_value + ) = self.powerdns_common_response + + self.dns_class.delete_dns_record(self.domain_name, self.domain_dns_value) + + self.assertEqual(mock_requests_patch.return_value, self.powerdns_common_response) + mock_delete_dns_record.assert_called_with(self.domain_name, self.domain_dns_value) From d8f2b3b1a331712aa30f8528cef9a894caf4f345 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 24 Mar 2020 13:21:33 -0400 Subject: [PATCH 7/8] Add PowerDNS to client, setup and README files. --- README.md | 8 ++++++-- setup.py | 2 ++ sewer/cli.py | 12 ++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2cba98d6..7844daa6 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ The currently supported DNS providers are: 7. [DNSPod](https://www.dnspod.cn/) 8. [DuckDNS](https://www.duckdns.org/) 9. [ClouDNS](https://www.cloudns.net) -10. [AWS rout353](https://aws.amazon.com/route53/) -11. [Bring your own dns provider](#bring-your-own-dns-provider) +10. [AWS route53](https://aws.amazon.com/route53/) +11. [PowerDNS](https://doc.powerdns.com/authoritative/http-api/index.html) +12. [Bring your own dns provider](#bring-your-own-dns-provider) ... Sewer can be used very easliy programmatically as a library from code. @@ -69,6 +70,9 @@ pip3 install sewer # with AWS route53 DNS Support # pip3 install sewer[route53] + +# with PowerDNS DNS Support +# pip3 install sewer[powerdns] ``` sewer(since version 0.5.0) is now python3 only. To install the (now unsupported) python2 version, run; diff --git a/setup.py b/setup.py index 4f68bdc7..ab7fdb21 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ "duckdns": [""], "cloudns": ["cloudns-api"], "route53": ["boto3"], + "powerdns": [""], } all_deps_of_all_dns_provider = [] @@ -111,6 +112,7 @@ "duckdns": dns_provider_deps_map["duckdns"], "cloudns": dns_provider_deps_map["cloudns"], "route53": dns_provider_deps_map["route53"], + "powerdns": dns_provider_deps_map["powerdns"], "alldns": all_deps_of_all_dns_provider, }, # If there are data files included in your packages that need to be diff --git a/sewer/cli.py b/sewer/cli.py index eb77ee2a..7ee6138f 100644 --- a/sewer/cli.py +++ b/sewer/cli.py @@ -306,6 +306,18 @@ def main(): except KeyError as e: logger.error("ERROR:: Please supply {0} as an environment variable.".format(str(e))) raise + elif dns_provider == "powerdns": + from . import PowerDNSDns + + try: + powerdns_api_key = os.environ["POWERDNS_API_KEY"] + powerdns_api_url = os.environ["POWERDNS_API_URL"] + + dns_class = PowerDNSDns(powerdns_api_key, powerdns_api_url) + logger.info("chosen_dns_provider. Using {0} as dns provider.".format(dns_provider)) + except KeyError as e: + logger.error("ERROR:: Please supply {0} as an environment variable.".format(str(e))) + raise else: raise ValueError("The dns provider {0} is not recognised.".format(dns_provider)) From 7fb1603eb2fcd271945a9a94012285213c146c1d Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 24 Mar 2020 16:24:06 -0400 Subject: [PATCH 8/8] Improved tests for powerdns provider --- sewer/dns_providers/powerdns.py | 4 +- sewer/dns_providers/tests/test_powerdns.py | 84 +++++++++++++++------- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/sewer/dns_providers/powerdns.py b/sewer/dns_providers/powerdns.py index 7edda248..ae76100a 100644 --- a/sewer/dns_providers/powerdns.py +++ b/sewer/dns_providers/powerdns.py @@ -79,9 +79,9 @@ def _common_dns_record(self, domain_name, domain_dns_value, changetype): url, data=json.dumps(payload), headers={"X-API-Key": self.powerdns_api_key} ) self.logger.debug(f"PowerDNS response: {response.status_code}, {response.text}") - except Exception as e: + except requests.exceptions.RequestException as e: self.logger.error(f"Unable to communicate with PowerDNS API: {e}") - raise e + raise # Per https://doc.powerdns.com/authoritative/http-api/zone.html: # PATCH /servers/{server_id}/zones/{zone_id} diff --git a/sewer/dns_providers/tests/test_powerdns.py b/sewer/dns_providers/tests/test_powerdns.py index 4a7a53f2..55703f67 100644 --- a/sewer/dns_providers/tests/test_powerdns.py +++ b/sewer/dns_providers/tests/test_powerdns.py @@ -1,8 +1,10 @@ import mock from unittest import TestCase - import sewer +from . import test_utils + + class TestPowerDNS(TestCase): """ Tests for PowerDNS DNS provider class. @@ -13,11 +15,14 @@ def setUp(self): self.domain_dns_value = "mock-domain_dns_value" self.powerdns_api_key = "mock-api-key" self.powerdns_api_url = "https://some-mock-url.com" - self.powerdns_common_response = 204 - self.powerdns_apex_response = 200 - self.dns_class = sewer.PowerDNSDns( - powerdns_api_key=self.powerdns_api_key, powerdns_api_url=self.powerdns_api_url - ) + + self.common_response = test_utils.MockResponse(status_code=204) + self.apex_response = test_utils.MockResponse(status_code=200) + with mock.patch("requests.patch") as mock_requests_get: + mock_requests_get.return_value = self.common_response + self.dns_class = sewer.PowerDNSDns( + powerdns_api_key=self.powerdns_api_key, powerdns_api_url=self.powerdns_api_url + ) def tearDown(self): pass @@ -29,7 +34,7 @@ def test_validate_powerdns_zone(self): "sewer.PowerDNSDns.validate_powerdns_zone" ) as mock_validate_powerdns_zone: - mock_requests_get.return_value.status_code = self.powerdns_apex_response + mock_requests_get.return_value.status_code = self.apex_response mock_validate_powerdns_zone.return_value = self.domain_name response = self.dns_class.validate_powerdns_zone(fqdn) @@ -37,33 +42,60 @@ def test_validate_powerdns_zone(self): self.assertEqual(response, self.domain_name) mock_validate_powerdns_zone.assert_called_with(fqdn) - def test_powerdns_is_called_by_create_dns_record(self): - with mock.patch("requests.patch") as mock_requests_patch, mock.patch( - "sewer.PowerDNSDns.create_dns_record" - ) as mock_create_dns_record: - - mock_requests_patch.return_value = self.powerdns_common_response + def test_could_not_determine_apex_domain(self): + with mock.patch("requests.get") as mock_requests_get: + mock_requests_get.return_value.status_code = 666 + + self.assertRaises( + ValueError, self.dns_class.validate_powerdns_zone, domain_name=self.domain_name + ) + + def test_powerdns_has_correct_changetype(self): + self.assertRaises( + ValueError, + self.dns_class._common_dns_record, + domain_name=self.domain_name, + domain_dns_value=self.domain_dns_value, + changetype="fubar", + ) - mock_requests_patch.return_value = ( - mock_create_dns_record.return_value - ) = self.powerdns_common_response + def test_powerdns_returns_correct_status_code(self): + with mock.patch("requests.patch") as mock_requests_patch, mock.patch( + "requests.get" + ) as mock_requests_get: - self.dns_class.create_dns_record(self.domain_name, self.domain_dns_value) + mock_requests_get.return_value = self.apex_response + mock_requests_patch.return_value.status_code = 666 - self.assertEqual(mock_requests_patch.return_value, self.powerdns_common_response) - mock_create_dns_record.assert_called_with(self.domain_name, self.domain_dns_value) + self.assertRaises( + ValueError, + self.dns_class.create_dns_record, + domain_name=self.domain_name, + domain_dns_value=self.domain_dns_value, + ) - def test_powerdns_is_called_by_delete_dns_record(self): + def test_powerdns_is_called_by_create_dns_record(self): with mock.patch("requests.patch") as mock_requests_patch, mock.patch( "sewer.PowerDNSDns.delete_dns_record" - ) as mock_delete_dns_record: - mock_requests_patch.return_value = self.powerdns_common_response + ) as mock_delete_dns_record, mock.patch("requests.get") as mock_requests_get: + mock_requests_get.return_value = self.apex_response mock_requests_patch.return_value = ( mock_delete_dns_record.return_value - ) = self.powerdns_common_response + ) = self.common_response - self.dns_class.delete_dns_record(self.domain_name, self.domain_dns_value) + self.dns_class.create_dns_record( + domain_name=self.domain_name, domain_dns_value=self.domain_dns_value + ) - self.assertEqual(mock_requests_patch.return_value, self.powerdns_common_response) - mock_delete_dns_record.assert_called_with(self.domain_name, self.domain_dns_value) + def test_powerdns_is_called_by_delete_dns_record(self): + with mock.patch("requests.patch") as mock_requests_patch, mock.patch( + "requests.get" + ) as mock_requests_get: + + mock_requests_get.return_value = self.apex_response + mock_requests_patch.return_value = self.common_response + self.dns_class.delete_dns_record( + domain_name=self.domain_name, domain_dns_value=self.domain_dns_value + ) + self.assertTrue(mock_requests_patch.called)