diff --git a/designate/backend/impl_ns1.py b/designate/backend/impl_ns1.py new file mode 100644 index 000000000..a23ad1ba6 --- /dev/null +++ b/designate/backend/impl_ns1.py @@ -0,0 +1,136 @@ +# Copyright 2021 NS1 Inc. https://www.ns1.com +# +# Author: Dragan Blagojevic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import requests +from oslo_config import cfg +from oslo_log import log as logging + +from designate import exceptions +from designate.backend import base + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class NS1Backend(base.Backend): + __plugin_name__ = 'ns1' + + __backend_status__ = 'untested' + + def __init__(self, target): + super(NS1Backend, self).__init__(target) + + self.api_endpoint = "https://" + self.options.get('api_endpoint') + self.api_token = self.options.get('api_token') + self.tsigkey_name = self.options.get('tsigkey_name', None) + self.tsigkey_hash = self.options.get('tsigkey_hash', None) + self.tsigkey_value = self.options.get('tsigkey_value', None) + + self.headers = { + "X-NSONE-Key": self.api_token + } + + def _build_url(self, zone): + return "%s/v1/zones/%s" % (self.api_endpoint, zone.name.rstrip('.')) + + def _get_master(self): + try: + return self.masters[0] + except IndexError as e: + LOG.error('No masters host set in pools.yaml') + raise exceptions.Backend(e) + + def _check_zone_exists(self, zone): + + try: + requests.get( + self._build_url(zone), + headers=self.headers + ).raise_for_status() + except requests.HTTPError as e: + if e.response.status_code == 404: + return False + else: + LOG.error('HTTP error in check zone exists. Zone %s', zone) + raise exceptions.Backend(e) + except requests.ConnectionError as e: + LOG.error('Connection error in check zone exists. Zone %s', zone) + raise exceptions.Backend(e) + + return True + + def create_zone(self, context, zone): + + master = self._get_master() + # designate requires "." at end of zone name, NS1 requires omitting + data = { + "zone": zone.name.rstrip('.'), + "secondary": { + "enabled": True, + "primary_ip": master.host, + "primary_port": master.port + } + } + if self.tsigkey_name: + tsig = { + "enabled": True, + "hash": self.tsigkey_hash, + "name": self.tsigkey_name, + "key": self.tsigkey_value + } + data['secondary']['tsig'] = tsig + + if not self._check_zone_exists(zone): + try: + requests.put( + self._build_url(zone), + json=data, + headers=self.headers + ).raise_for_status() + except requests.HTTPError as e: + # check if the zone was actually created + if self._check_zone_exists(zone): + LOG.info("%s was created with an error. Deleting zone", + zone.name) + try: + self.delete_zone(context, zone) + except exceptions.Backend: + LOG.error('Could not delete errored zone %s', + zone.name) + raise exceptions.Backend(e) + else: + LOG.info("Can't create zone %s because it already exists", + zone.name) + + self.mdns_api.notify_zone_changed( + context, zone, self.host, self.port, self.timeout, + self.retry_interval, self.max_retries, self.delay) + + def delete_zone(self, context, zone): + """Delete a DNS zone""" + + # First verify that the zone exists + if self._check_zone_exists(zone): + try: + requests.delete( + self._build_url(zone), + headers=self.headers + ).raise_for_status() + except requests.HTTPError as e: + raise exceptions.Backend(e) + else: + LOG.warning("Trying to delete zone %s but that zone is not " + "present in the ns1 backend. Assuming success.", + zone) diff --git a/designate/tests/unit/backend/test_ns1.py b/designate/tests/unit/backend/test_ns1.py new file mode 100644 index 000000000..edb15de81 --- /dev/null +++ b/designate/tests/unit/backend/test_ns1.py @@ -0,0 +1,217 @@ +# Copyright 2021 NS1 Inc. https://www.ns1.com +# +# Author: Dragan Blagojevic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import requests_mock +from designate import exceptions +from designate import objects +from designate.backend import impl_ns1 +import designate.tests +from designate.tests import fixtures + + +class NS1BackendTestCase(designate.tests.TestCase): + def setUp(self): + super(NS1BackendTestCase, self).setUp() + self.stdlog = fixtures.StandardLogging() + self.useFixture(self.stdlog) + + self.api_address = 'https://192.0.2.3/v1/zones/example.com' + self.context = self.get_context() + self.zone = objects.Zone( + id='e2bed4dc-9d01-11e4-89d3-123b93f75cba', + name='example.com.', + email='example@example.com', + ) + + self.target = { + 'id': '4588652b-50e7-46b9-b688-a9bad40a873e', + 'type': 'ns1', + 'masters': [ + {'host': '192.0.2.1', 'port': 53}, + {'host': '192.0.2.2', 'port': 35}, + ], + 'options': [ + {'key': 'api_endpoint', 'value': '192.0.2.3'}, + {'key': 'api_token', 'value': 'test_key'}, + ], + } + self.target_tsig = { + 'id': '4588652b-50e7-46b9-b688-a9bad40a873e', + 'type': 'ns1', + 'masters': [ + {'host': '192.0.2.1', 'port': 53}, + {'host': '192.0.2.2', 'port': 35}, + ], + 'options': [ + {'key': 'api_endpoint', 'value': '192.0.2.3'}, + {'key': 'api_token', 'value': 'test_key'}, + {'key': 'tsigkey_name', 'value': 'test_key'}, + {'key': 'tsigkey_hash', 'value': 'hmac-sha512'}, + {'key': 'tsigkey_value', 'value': 'aaaabbbbccc'}, + ], + } + self.put_request_json = { + 'zone': u'example.com', + 'secondary': { + 'enabled': True, + 'primary_ip': '192.0.2.1', + 'primary_port': 53 + } + } + self.put_request_tsig_json = { + 'zone': u'example.com', + 'secondary': { + 'enabled': True, + 'primary_ip': '192.0.2.1', + 'primary_port': 53, + 'tsig': { + 'enabled': True, + 'hash': 'hmac-sha512', + 'name': 'test_key', + 'key': 'aaaabbbbccc' + } + } + } + + self.backend = impl_ns1.NS1Backend( + objects.PoolTarget.from_dict(self.target) + ) + self.backend_tsig = impl_ns1.NS1Backend( + objects.PoolTarget.from_dict(self.target_tsig) + ) + + @requests_mock.mock() + def test_create_zone_success(self, req_mock): + req_mock.put(self.api_address) + req_mock.get( + self.api_address, + status_code=404 + ) + + self.backend.create_zone(self.context, self.zone) + + self.assertEqual( + req_mock.last_request.json(), + self.put_request_json + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_create_zone_with_tsig_success(self, req_mock): + req_mock.put(self.api_address) + req_mock.get( + self.api_address, + status_code=404 + ) + + self.backend_tsig.create_zone(self.context, self.zone) + + self.assertEqual( + req_mock.last_request.json(), + self.put_request_tsig_json + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_create_zone_already_exists(self, req_mock): + + req_mock.get(self.api_address, status_code=200) + req_mock.put(self.api_address) + + self.backend.create_zone(self.context, self.zone) + + self.assertIn( + "Can't create zone example.com. because it already exists", + self.stdlog.logger.output + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_create_zone_fail(self, req_mock): + req_mock.put( + self.api_address, + status_code=500, + ) + req_mock.get( + self.api_address, + status_code=404, + ) + + self.assertRaisesRegexp( + exceptions.Backend, + '500 Server Error: None for url: ' + '%s' % self.api_address, + self.backend.create_zone, self.context, self.zone + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_delete_zone_success(self, req_mock): + req_mock.delete(self.api_address, status_code=200) + req_mock.get(self.api_address, status_code=200) + + self.backend.delete_zone(self.context, self.zone) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_delete_zone_missing(self, req_mock): + req_mock.delete(self.api_address, status_code=200) + req_mock.get(self.api_address, status_code=404) + + self.backend.delete_zone(self.context, self.zone) + + self.assertIn( + "Trying to delete zone " + " " + "but that zone is not " + "present in the ns1 backend. Assuming success.", + self.stdlog.logger.output + ) + + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) + + @requests_mock.mock() + def test_delete_zone_fail(self, req_mock): + req_mock.delete(self.api_address, status_code=500) + req_mock.get(self.api_address, status_code=200) + + self.assertRaisesRegexp( + exceptions.Backend, + '500 Server Error: None for url: ' + '%s' % self.api_address, + self.backend.delete_zone, self.context, self.zone + ) + self.assertEqual( + req_mock.last_request.headers.get('X-NSONE-Key'), 'test_key' + ) diff --git a/devstack/designate_plugins/backend-ns1 b/devstack/designate_plugins/backend-ns1 new file mode 100644 index 000000000..844182ea2 --- /dev/null +++ b/devstack/designate_plugins/backend-ns1 @@ -0,0 +1,107 @@ +# Configure the NS1 backend + +# Requirements: +# A working NS1 managed DNS / DDI environment is needed to use this DevStack plugin. + +# Enable with: +# DESIGNATE_BACKEND_DRIVER=ns1 + +# Dependencies: +# ``functions`` file +# ``designate`` configuration + +# install_designate_backend - install any external requirements +# configure_designate_backend - make configuration changes, including those to other services +# init_designate_backend - initialize databases, etc. +# start_designate_backend - start any external services +# stop_designate_backend - stop any external services +# cleanup_designate_backend - remove transient data and cache + +# Save trace setting +DP_NS1_XTRACE=$(set +o | grep xtrace) +set +o xtrace + +# Defaults +# -------- + +DESIGNATE_NS1_DNS_IP=${DESIGNATE_NS1_DNS_IP:-172.31.45.104} +DESIGNATE_NS1_DNS_PORT=${DESIGNATE_NS1_DNS_PORT:-5333} +DESIGNATE_NS1_XFR_IP=${DESIGNATE_NS1_XFR_IP:-172.31.45.104} +DESIGNATE_NS1_XFR_PORT=${DESIGNATE_NS1_XFR_PORT:-5400} +DESIGNATE_NS1_API_IP=${DESIGNATE_NS1_API_IP:-172.31.45.104} +DESIGNATE_NS1_API_TOKEN=${DESIGNATE_NS1_API_TOKEN:-default} + + +# Entry Points +# ------------ + + +# install_designate_backend - install any external requirements +function install_designate_backend { + if is_ubuntu; then + install_package python-dev libxslt1-dev libxslt1.1 libxml2-dev libxml2 libssl-dev + elif is_fedora; then + install_package python-devel libxslt1-devel libxslt1.1 libxml2-devel libxml2 libssl-devel + fi +} + +# configure_designate_backend - make configuration changes, including those to other services +function configure_designate_backend { + + # Generate Designate pool.yaml file + sudo tee $DESIGNATE_CONF_DIR/pools.yaml > /dev/null < + api_endpoint: 192.0.2.2 + #NS1 API key + api_token: changeme + # If a tsigkey is needed, uncomment the line below and insert the key name, algorithm and value + # NOTE: TSIG key has to be set manually + #tsigkey_name: testkey + #tsigkey_hash: hmac-sha512 + #tsigkey_value: 4EJz00m4ZWe005HjLiXRedJbSnCUx5Dt+4wVYsBweG5HKAV6cqSVJ/oem/6mLgDNFAlLP3Jg0npbg1SkP7RMDg== diff --git a/doc/source/admin/support-matrix.ini b/doc/source/admin/support-matrix.ini index 1af819a2b..fbfea1b68 100644 --- a/doc/source/admin/support-matrix.ini +++ b/doc/source/admin/support-matrix.ini @@ -52,6 +52,7 @@ backend-impl-akamai=Akamai eDNS backend-impl-akamai_v2=Akamai DNS v2 backend-impl-infoblox-xfr=Infoblox (XFR) backend-impl-nsd4=NSD4 +backend-impl-ns1=NS1 DNS backend-impl-agent=Agent backend-impl-bind9-agent=Bind9 (Agent) backend-impl-denominator=Denominator @@ -80,6 +81,11 @@ notes=Akamai has turned off the eDNS API - see https://community.akamai.com/cust [backends.backend-impl-akamai_v2] docs=akamai_v2_backend_docs +[backends.backend-impl-ns1] +docs=ns1_backend_docs +status=untested +config=backends/sample_yaml_snippets/ns1.yaml + [backends.backend-impl-agent] [backends.backend-impl-bind9-agent] diff --git a/setup.cfg b/setup.cfg index dbf5aab4c..a1f82ad67 100644 --- a/setup.cfg +++ b/setup.cfg @@ -80,6 +80,7 @@ designate.backend = infoblox = designate.backend.impl_infoblox:InfobloxBackend fake = designate.backend.impl_fake:FakeBackend agent = designate.backend.agent:AgentPoolBackend + ns1 = designate.backend.impl_ns1:NS1Backend designate.backend.agent_backend = bind9 = designate.backend.agent_backend.impl_bind9:Bind9Backend