Add NS1 backend

Introduce an NS1 backend.

Signed-off-by: Michael Hood <mhood@ns1.com>
Change-Id: I80fe08238005a94161e2dbcc89e77c90cde0a715
This commit is contained in:
Michael Hood 2021-03-05 08:04:29 -08:00
parent 75668d084c
commit 5aac48f08b
7 changed files with 558 additions and 0 deletions

View File

@ -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)

View File

@ -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'
)

View File

@ -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

View File

@ -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.

View File

@ -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==

View File

@ -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]

View File

@ -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