summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Hood <mhood@ns1.com>2021-03-05 08:04:29 -0800
committerMichael Hood <mhood@ns1.com>2021-03-08 10:55:06 -0800
commit5aac48f08b441cbde8c2078a9ace0551d3d19058 (patch)
tree7414f0fda74cd155d9d1cbeb26f300888b133f97
parent75668d084c2a27fcc25c67741ca309bfe43b1322 (diff)
downloaddesignate-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.py136
-rw-r--r--designate/tests/unit/backend/test_ns1.py217
-rw-r--r--devstack/designate_plugins/backend-ns1107
-rw-r--r--doc/source/admin/backends/ns1.rst66
-rw-r--r--doc/source/admin/backends/sample_yaml_snippets/ns1.yaml25
-rw-r--r--doc/source/admin/support-matrix.ini6
-rw-r--r--setup.cfg1
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]
diff --git a/setup.cfg b/setup.cfg
index dbf5aab4..a1f82ad6 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