diff options
author | Michael Hood <mhood@ns1.com> | 2021-03-05 08:04:29 -0800 |
---|---|---|
committer | Michael Hood <mhood@ns1.com> | 2021-03-08 10:55:06 -0800 |
commit | 5aac48f08b441cbde8c2078a9ace0551d3d19058 (patch) | |
tree | 7414f0fda74cd155d9d1cbeb26f300888b133f97 | |
parent | 75668d084c2a27fcc25c67741ca309bfe43b1322 (diff) | |
download | designate-5aac48f08b441cbde8c2078a9ace0551d3d19058.tar.gz |
Add NS1 backend
Introduce an NS1 backend.
Signed-off-by: Michael Hood <mhood@ns1.com>
Change-Id: I80fe08238005a94161e2dbcc89e77c90cde0a715
-rw-r--r-- | designate/backend/impl_ns1.py | 136 | ||||
-rw-r--r-- | designate/tests/unit/backend/test_ns1.py | 217 | ||||
-rw-r--r-- | devstack/designate_plugins/backend-ns1 | 107 | ||||
-rw-r--r-- | doc/source/admin/backends/ns1.rst | 66 | ||||
-rw-r--r-- | doc/source/admin/backends/sample_yaml_snippets/ns1.yaml | 25 | ||||
-rw-r--r-- | doc/source/admin/support-matrix.ini | 6 | ||||
-rw-r--r-- | setup.cfg | 1 |
7 files changed, 558 insertions, 0 deletions
diff --git a/designate/backend/impl_ns1.py b/designate/backend/impl_ns1.py new file mode 100644 index 00000000..a23ad1ba --- /dev/null +++ b/designate/backend/impl_ns1.py @@ -0,0 +1,136 @@ +# Copyright 2021 NS1 Inc. https://www.ns1.com +# +# Author: Dragan Blagojevic <dblagojevic@daitan.com> +# +# 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 00000000..edb15de8 --- /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 <dblagojevic@daitan.com> +# +# 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 " + "<Zone id:'e2bed4dc-9d01-11e4-89d3-123b93f75cba' type:'None' " + "name:'example.com.' pool_id:'None' serial:'None' action:'None' " + "status:'None'> " + "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 00000000..844182ea --- /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 <<EOF +--- +- name: default + description: DevStack NS1 Pool + attributes: {} + + ns_records: + - hostname: $DESIGNATE_DEFAULT_NS_RECORD + priority: 1 + + nameservers: + - host: $DESIGNATE_NS1_DNS_IP + port: $DESIGNATE_NS1_DNS_PORT + + targets: + - type: ns1 + description: NS1 Managed DNS + + masters: + - host: $(ipv6_unquote $DESIGNATE_SERVICE_HOST) + port: $DESIGNATE_SERVICE_PORT_MDNS + + options: + host: $DESIGNATE_NS1_XFR_IP + port: $DESIGNATE_NS1_XFR_PORT + api_endpoint: $DESIGNATE_NS1_API_IP + api_token: $DESIGNATE_NS1_API_TOKEN + # NOTE: TSIG key has to be set manually if it's necessary + #tsigkey_name: testkey + #tsigkey_hash: hmac-sha512 + #tsigkey_value: 4EJz00m4ZWe005HjLiXRedJbSnCUx5Dt+4wVYsBweG5HKAV6cqSVJ/oem/6mLgDNFAlLP3Jg0npbg1SkP7RMDg== +EOF +} + +# init_designate_backend - initialize databases, etc. +function init_designate_backend { + : +} + +# start_designate_backend - start any external services +function start_designate_backend { + : +} + +# stop_designate_backend - stop any external services +function stop_designate_backend { + : +} + +# cleanup_designate_backend - remove transient data and cache +function cleanup_designate_backend { + : +} + +# Restore xtrace +$DP_NS1_XTRACE diff --git a/doc/source/admin/backends/ns1.rst b/doc/source/admin/backends/ns1.rst new file mode 100644 index 00000000..b3cedf04 --- /dev/null +++ b/doc/source/admin/backends/ns1.rst @@ -0,0 +1,66 @@ +.. + Copyright 2021 NS1 inc. https://www.ns1.com + + 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. + +.. _backend-ns1: + +NS1 Backend +=========== + +NS1 Configuration +----------------- + + +1. Configure the NS1 Backend using this sample target snippet + +.. literalinclude:: sample_yaml_snippets/ns1.yaml + :language: yaml + +2. Then update the pools in designate + +.. code-block:: console + + $ designate-manage pool update + +See :ref:`designate_manage_pool` for further details on +the ``designate-manage pool`` command, and :ref:`pools` +for information about the yaml file syntax + + +TSIG Key Configuration +---------------------- + +In some cases a deployer may need to use tsig keys to sign AXFR (zone transfer) +requests. As NS1 does not support a per host key setup, this needs to be set +on a per zone basis, on creation. + +To do this, generate a tsigkey using any of available utilities +(e.g. tsig-keygen): + +.. code-block:: bash + + $ tsig-keygen -a hmac-sha512 testkey + key "testkey" { + algorithm hmac-sha512; + secret "vQbMI3u5QGUyRu6FWRm16eL0F0dfOOmVJjWKCTg4mIMNnba0g2PLrV+0G92WcTfJrgqZ20a4hv3RWDICKCcJhw=="; + }; + +Then insert it into Designate. Make sure the pool id is correct +(the ``--resource-id`` below.) + +.. code-block:: bash + + openstack tsigkey create --name testkey --algorithm hmac-sha512 --secret 4EJz00m4ZWe005HjLiXRedJbSnCUx5Dt+4wVYsBweG5HKAV6cqSVJ/oem/6mLgDNFAlLP3Jg0npbg1SkP7RMDg== --scope POOL --resource-id 794ccc2c-d751-44fe-b57f-8894c9f5c842 + +Then add it to the ``pools.yaml`` file as shown in the example. diff --git a/doc/source/admin/backends/sample_yaml_snippets/ns1.yaml b/doc/source/admin/backends/sample_yaml_snippets/ns1.yaml new file mode 100644 index 00000000..4eddd1a0 --- /dev/null +++ b/doc/source/admin/backends/sample_yaml_snippets/ns1.yaml @@ -0,0 +1,25 @@ + targets: + - type: ns1 + description: NS1 DNS Server + + # List out the designate-mdns servers from which NS1 servers should + # request zone transfers (AXFRs) from. + masters: + - host: 192.0.2.1 + port: 5354 + + # NS1 Configuration options + options: + #NS1 XFR container ip and port + host: 192.0.2.2 + port: 5302 + #NS1 API enpoint IP address or name (Core container). Enter only base address or name. + #Plugin will generate full api address, e.g. https://192.0.2.2/v1/zones/<zone name> + 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 1af819a2..fbfea1b6 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] @@ -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 |