From be7e32dfaa8f2884ac89bf7335da9b309fcdc861 Mon Sep 17 00:00:00 2001 From: Federico Ceratto Date: Tue, 26 Apr 2016 16:47:04 +0100 Subject: [PATCH] Add djbdns backend Add docs and basic tests Update config sample file and support matrix Change-Id: I709cea4e321f6bbee3b0f9f718fa6a9836af3ca5 --- contrib/djbdns/tinydns.init | 110 ++++++ contrib/djbdns/tinydns.service | 44 +++ designate/agent/__init__.py | 2 +- .../backend/agent_backend/impl_djbdns.py | 350 ++++++++++++++++++ .../test_agent/test_backends/test_djbdns.py | 127 +++++++ .../test_agent/test_backends/test_djbdns.py | 126 +++++++ doc/source/backend.rst | 9 + doc/source/backends/djbdns_agent.rst | 132 +++++++ doc/source/support-matrix.ini | 4 + etc/designate/designate.conf.sample | 9 + etc/designate/rootwrap.d/djbdns.filters | 4 + ...djbdns-agent-backend-c84e9eeab48d2e01.yaml | 4 + setup.cfg | 1 + 13 files changed, 921 insertions(+), 1 deletion(-) create mode 100755 contrib/djbdns/tinydns.init create mode 100644 contrib/djbdns/tinydns.service create mode 100644 designate/backend/agent_backend/impl_djbdns.py create mode 100644 designate/tests/test_agent/test_backends/test_djbdns.py create mode 100644 designate/tests/unit/test_agent/test_backends/test_djbdns.py create mode 100644 doc/source/backends/djbdns_agent.rst create mode 100644 etc/designate/rootwrap.d/djbdns.filters create mode 100644 releasenotes/notes/djbdns-agent-backend-c84e9eeab48d2e01.yaml diff --git a/contrib/djbdns/tinydns.init b/contrib/djbdns/tinydns.init new file mode 100755 index 00000000..7bcbb477 --- /dev/null +++ b/contrib/djbdns/tinydns.init @@ -0,0 +1,110 @@ +#! /bin/bash +### BEGIN INIT INFO +# Provides: tinydns +# Required-Start: $local_fs $remote_fs $network +# Required-Stop: $local_fs $remote_fs $network +# Should-Start: $syslog +# Should-Stop: $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: tinydns daemon processes +# Description: Start the TinyDNS resolver +### END INIT INFO + +# Documentation +# man tinydns + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +. /lib/lsb/init-functions + +NAME=tinydns +DAEMON=/usr/bin/$NAME +DAEMON_USER=djbdns +DESC="the tinydns daemon" +ROOTDIR=/var/lib/djbdns +PATH=/sbin:/bin:/usr/sbin:/usr/bin +LAUNCHER=/usr/bin/envuidgid +LAUNCHER_ARGS="$DAEMON_USER envdir ./env softlimit -d300000 $DAEMON" + +PIDFILE=/run/$NAME.pid + +# Exit if executable is not installed +[ -x "$DAEMON" ] || exit 0 + +set -x + +case "$1" in + start) + if [ ! -d "$ROOTDIR" ]; then + log_action_msg "Not starting $DESC: $ROOTDIR is missing." + exit 0 + fi + + log_action_begin_msg "Starting $DESC" + + if start-stop-daemon --stop --signal 0 --quiet --pidfile $PIDFILE --exec $DAEMON; then + log_action_end_msg 0 "already running" + else + if start-stop-daemon --start --verbose --make-pidfile --chdir $ROOTDIR --pidfile $PIDFILE --exec $LAUNCHER -- $LAUNCHER_ARGS + then + log_action_end_msg 0 + else + log_action_end_msg 1 + exit 1 + fi + fi + ;; + stop) + log_action_begin_msg "Stopping $DESC" + pid=$(cat $PIDFILE 2>/dev/null) || true + if test ! -f $PIDFILE -o -z "$pid"; then + log_action_end_msg 0 "not running - there is no $PIDFILE" + exit 0 + fi + + if start-stop-daemon --stop --signal INT --quiet --pidfile $PIDFILE --exec $DAEMON; then + rm -f $PIDFILE + elif kill -0 $pid 2>/dev/null; then + log_action_end_msg 1 "Is $pid not $NAME? Is $DAEMON a different binary now?" + exit 1 + else + log_action_end_msg 1 "$DAEMON died: process $pid not running; or permission denied" + exit 1 + fi + ;; + reload) + echo "Not implemented, use restart" + exit 1 + ;; + restart|force-reload) + $0 stop + $0 start + ;; + status) + if test ! -r $(dirname $PIDFILE); then + log_failure_msg "cannot read PID file $PIDFILE" + exit 4 + fi + pid=$(cat $PIDFILE 2>/dev/null) || true + if test ! -f $PIDFILE -o -z "$pid"; then + log_failure_msg "$NAME is not running" + exit 3 + fi + if ps "$pid" >/dev/null 2>&1; then + log_success_msg "$NAME is running" + exit 0 + else + log_failure_msg "$NAME is not running" + exit 1 + fi + ;; + *) + log_action_msg "Usage: $0 {start|stop|restart|force-reload|status}" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/contrib/djbdns/tinydns.service b/contrib/djbdns/tinydns.service new file mode 100644 index 00000000..2fcf9d2a --- /dev/null +++ b/contrib/djbdns/tinydns.service @@ -0,0 +1,44 @@ +# +# Replace /var/lib/djbdns if needed +# + +[Unit] +Description=tinydns DNS resolver +Documentation=man:tinydns +Documentation=https://cr.yp.to/djbdns.html +After=network.target +Requires=network.target +Wants=network.target +ConditionPathExists=/var/lib/djbdns + +[Service] +Type=forking +PIDFile=/run/tinydns.pid +Environment="ROOT=/var/lib/djbdns" +ExecStart=/usr/bin/tinydns +ExecStop=-/sbin/start-stop-daemon --quiet --stop --retry=TERM/5/KILL/5 --pidfile /run/tinydns.pid +TimeoutStopSec=30 +KillMode=mixed + +PermissionsStartOnly=true +Restart=on-abnormal +RestartSec=2s +LimitNOFILE=65536 + +WorkingDirectory=/var/lib/djbdns +User=$ug_name +Group=$ug_name + +# Hardening +# CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_FOWNER +NoNewPrivileges=yes +PrivateDevices=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=full +# TODO: restrict ReadOnlyDirectories +ReadOnlyDirectories=/ +ReadWriteDirectories=-/var/lib/djbdns + +[Install] +WantedBy=multi-user.target diff --git a/designate/agent/__init__.py b/designate/agent/__init__.py index 5dacb1d1..3dbb8d08 100644 --- a/designate/agent/__init__.py +++ b/designate/agent/__init__.py @@ -47,7 +47,7 @@ OPTS = [ cfg.ListOpt('masters', default=[], help='List of masters for the Agent, format ip:port'), cfg.StrOpt('backend-driver', default='bind9', - help='The backend driver to use: bind9 or knot2'), + help='The backend driver to use, e.g. bind9, djbdns, knot2'), cfg.StrOpt('transfer-source', help='An IP address to be used to fetch zones transferred in'), cfg.FloatOpt('notify-delay', default=0.0, diff --git a/designate/backend/agent_backend/impl_djbdns.py b/designate/backend/agent_backend/impl_djbdns.py new file mode 100644 index 00000000..bf33bf5d --- /dev/null +++ b/designate/backend/agent_backend/impl_djbdns.py @@ -0,0 +1,350 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# Author: Federico Ceratto +# +# 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.agent_backend.impl_djbdns +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Djbdns DNS agent backend + +Create, update, delete zones locally on a Djbdns DNS resolver using the +axfr-get utility. + +`User documentation `_ + +.. WARNING:: + + Untested, do not use in production. + + +Configured in [service:agent:djbdns] + +Requires rootwrap (or equivalent sudo privileges) to execute: + - tcpclient + - axfr-get + - tinydns-data + +""" + +import glob +import os +import random +import tempfile + +import dns +import dns.resolver +from oslo_concurrency import lockutils +from oslo_concurrency.processutils import ProcessExecutionError +from oslo_config import cfg +from oslo_log import log as logging + +from designate import exceptions +from designate import utils +from designate.backend.agent_backend import base +from designate.i18n import _LI +from designate.i18n import _LE +from designate.utils import execute + +LOG = logging.getLogger(__name__) +CFG_GROUP = 'backend:agent:djbdns' +# rootwrap requires a command name instead of full path +TCPCLIENT_DEFAULT_PATH = 'tcpclient' +AXFR_GET_DEFAULT_PATH = 'axfr-get' +TINYDNS_DATA_DEFAULT_PATH = 'tinydns-data' + +TINYDNS_DATADIR_DEFAULT_PATH = '/var/lib/djbdns' +SOA_QUERY_TIMEOUT = 1 + + +# TODO(Federico) on zone creation and update, agent.handler unnecessarily +# perfors AXFR from MiniDNS to the Agent to populate the `zone` argument +# (needed by the Bind backend) + + +def filter_exceptions(fn): + # Let Backend() exceptions pass through, log out every other exception + # and re-raise it as Backend() + def wrapper(*a, **kw): + try: + return fn(*a, **kw) + except exceptions.Backend as e: + raise e + except Exception as e: + LOG.error(_LE("Unhandled exception %s"), str(e), exc_info=True) + raise exceptions.Backend(str(e)) + + return wrapper + + +class DjbdnsBackend(base.AgentBackend): + __plugin_name__ = 'djbdns' + __backend_status__ = 'experimental' + + @classmethod + def get_cfg_opts(cls): + group = cfg.OptGroup( + name='backend:agent:djbdns', + title="Configuration for Djbdns backend" + ) + opts = [ + cfg.StrOpt( + 'tcpclient-cmd-name', + help='tcpclient executable path or rootwrap command name', + default=TCPCLIENT_DEFAULT_PATH + ), + cfg.StrOpt( + 'axfr-get-cmd-name', + help='axfr-get executable path or rootwrap command name', + default=AXFR_GET_DEFAULT_PATH + ), + cfg.StrOpt( + 'tinydns-data-cmd-name', + help='tinydns-data executable path or rootwrap command name', + default=TINYDNS_DATA_DEFAULT_PATH + ), + cfg.StrOpt( + 'tinydns-datadir', + help='TinyDNS data directory', + default=TINYDNS_DATADIR_DEFAULT_PATH + ), + cfg.StrOpt('query-destination', default='127.0.0.1', + help='Host to query when finding zones') + ] + return [(group, opts)] + + def __init__(self, *a, **kw): + """Configure the backend""" + super(DjbdnsBackend, self).__init__(*a, **kw) + + self._resolver = dns.resolver.Resolver(configure=False) + self._resolver.timeout = SOA_QUERY_TIMEOUT + self._resolver.lifetime = SOA_QUERY_TIMEOUT + self._resolver.nameservers = [cfg.CONF[CFG_GROUP].query_destination] + self._masters = [utils.split_host_port(ns) + for ns in cfg.CONF['service:agent'].masters] + LOG.info(_LI("Resolvers: %r"), self._resolver.nameservers) + LOG.info(_LI("AXFR masters: %r"), self._masters) + if not self._masters: + raise exceptions.Backend("Missing agent AXFR masters") + + self._tcpclient_cmd_name = cfg.CONF[CFG_GROUP].tcpclient_cmd_name + self._axfr_get_cmd_name = cfg.CONF[CFG_GROUP].axfr_get_cmd_name + + # Directory where data.cdb lives, usually /var/lib/djbdns/root + tinydns_root_dir = os.path.join(cfg.CONF[CFG_GROUP].tinydns_datadir, + 'root') + + # Usually /var/lib/djbdns/root/data.cdb + self._tinydns_cdb_filename = os.path.join(tinydns_root_dir, 'data.cdb') + LOG.info(_LI("data.cdb path: %r"), self._tinydns_cdb_filename) + + # Where the agent puts the zone datafiles, + # usually /var/lib/djbdns/datafiles + self._datafiles_dir = datafiles_dir = os.path.join( + cfg.CONF[CFG_GROUP].tinydns_datadir, + 'datafiles') + self._datafiles_tmp_path_tpl = os.path.join(datafiles_dir, "%s.ztmp") + self._datafiles_path_tpl = os.path.join(datafiles_dir, "%s.zonedata") + self._datafiles_path_glob = self._datafiles_path_tpl % '*' + + self._check_dirs(tinydns_root_dir, datafiles_dir) + + @staticmethod + def _check_dirs(*dirnames): + """Check if directories are writable + """ + for dn in dirnames: + if not os.path.isdir(dn): + raise exceptions.Backend("Missing directory %s" % dn) + if not os.access(dn, os.W_OK): + raise exceptions.Backend("Directory not writable: %s" % dn) + + def start(self): + """Start the backend""" + LOG.info(_LI("Started djbdns backend")) + + def find_zone_serial(self, zone_name): + """Query the local resolver for a zone + Times out after SOA_QUERY_TIMEOUT + """ + LOG.debug("Finding %s", zone_name) + try: + rdata = self._resolver.query( + zone_name, rdtype=dns.rdatatype.SOA)[0] + return rdata.serial + except Exception: + return None + + @staticmethod + def _concatenate_zone_datafiles(data_fn, path_glob): + """Concatenate all zone datafiles into 'data' + """ + with open(data_fn, 'w') as data_f: + zone_cnt = 0 + for zone_fn in glob.glob(path_glob): + zone_cnt += 1 + with open(zone_fn) as zf: + data_f.write(zf.read()) + + LOG.info(_LI("Loaded %d zone datafiles."), zone_cnt) + + def _rebuild_data_cdb(self): + """Rebuild data.cdb file from zone datafiles + Requires global lock + + On zone creation, axfr-get creates datafiles atomically by doing + rename. On zone deletion, os.remove deletes the file atomically + Globbing and reading the datafiles can be done without locking on + them. + The data and data.cdb files are written into a unique temp directory + """ + + tmpdir = tempfile.mkdtemp(dir=self._datafiles_dir) + data_fn = os.path.join(tmpdir, 'data') + tmp_cdb_fn = os.path.join(tmpdir, 'data.cdb') + + try: + self._concatenate_zone_datafiles(data_fn, + self._datafiles_path_glob) + # Generate the data.cdb file + LOG.info(_LI("Updating data.cdb")) + LOG.debug("Convert %s to %s", data_fn, tmp_cdb_fn) + try: + out, err = execute( + cfg.CONF[CFG_GROUP].tinydns_data_cmd_name, + cwd=tmpdir + ) + except ProcessExecutionError as e: + LOG.error(_LE("Failed to generate data.cdb")) + LOG.error(_LE("Command output: %(out)r Stderr: %(err)r"), { + 'out': e.stdout, 'err': e.stderr + }) + raise exceptions.Backend("Failed to generate data.cdb") + + LOG.debug("Move %s to %s", tmp_cdb_fn, self._tinydns_cdb_filename) + try: + os.rename(tmp_cdb_fn, self._tinydns_cdb_filename) + except OSError: + os.remove(tmp_cdb_fn) + LOG.error(_LE("Unable to move data.cdb to %s"), + self._tinydns_cdb_filename) + raise exceptions.Backend("Unable to move data.cdb") + + finally: + try: + os.remove(data_fn) + except OSError: + pass + try: + os.removedirs(tmpdir) + except OSError: + pass + + def _perform_axfr_from_minidns(self, zone_name): + """Instruct axfr-get to request an AXFR from MiniDNS. + + :raises: exceptions.Backend on error + """ + zone_fn = self._datafiles_path_tpl % zone_name + zone_tmp_fn = self._datafiles_tmp_path_tpl % zone_name + + # Perform AXFR, create or update a zone datafile + # No need to lock globally here. + # Axfr-get creates the datafile atomically by doing rename + mdns_hostname, mdns_port = random.choice(self._masters) + with lockutils.lock("%s.lock" % zone_name): + LOG.debug("writing to %s", zone_fn) + cmd = ( + self._tcpclient_cmd_name, + mdns_hostname, + "%d" % mdns_port, + self._axfr_get_cmd_name, + zone_name, + zone_fn, + zone_tmp_fn + ) + + LOG.debug("Executing AXFR as %r", ' '.join(cmd)) + try: + out, err = execute(*cmd) + except ProcessExecutionError as e: + LOG.error(_LE("Error executing AXFR as %r"), ' '.join(cmd)) + LOG.error(_LE("Command output: %(out)r Stderr: %(err)r"), { + 'out': e.stdout, 'err': e.stderr + }) + raise exceptions.Backend(str(e)) + + finally: + try: + os.remove(zone_tmp_fn) + except OSError: + pass + + @filter_exceptions + def create_zone(self, zone): + """Create a new Zone + Do not raise exceptions if the zone already exists. + + :param zone: zone to be created + :type zone: raw pythondns Zone + :raises: exceptions.Backend on error + """ + zone_name = zone.origin.to_text().rstrip('.') + LOG.debug("Creating %s", zone_name) + # The zone might be already in place due to a race condition between + # checking if the zone is there and creating it across different + # greenlets + + LOG.debug("Triggering initial AXFR from MiniDNS to Djbdns for %s", + zone_name) + self._perform_axfr_from_minidns(zone_name) + self._rebuild_data_cdb() + + @filter_exceptions + def update_zone(self, zone): + """Instruct Djbdns DNS to perform AXFR from MiniDNS + + :param zone: zone to be created + :type zone: raw pythondns Zone + :raises: exceptions.Backend on error + """ + zone_name = zone.origin.to_text().rstrip('.') + LOG.debug("Triggering AXFR from MiniDNS to Djbdns for %s", zone_name) + self._perform_axfr_from_minidns(zone_name) + self._rebuild_data_cdb() + + @filter_exceptions + def delete_zone(self, zone_name): + """Delete a new Zone + Do not raise exceptions if the zone does not exist. + + :param zone_name: zone name + :type zone_name: str + :raises: exceptions.Backend on error + """ + zone_name = zone_name.rstrip('.') + LOG.debug('Deleting Zone: %s', zone_name) + zone_fn = self._datafiles_path_tpl % zone_name + try: + os.remove(zone_fn) + LOG.debug('Deleted Zone: %s', zone_name) + except OSError as e: + if os.errno.ENOENT == e.errno: + LOG.info(_LI("Zone datafile %s was already deleted"), zone_fn) + return + + raise + + self._rebuild_data_cdb() diff --git a/designate/tests/test_agent/test_backends/test_djbdns.py b/designate/tests/test_agent/test_backends/test_djbdns.py new file mode 100644 index 00000000..f955ef41 --- /dev/null +++ b/designate/tests/test_agent/test_backends/test_djbdns.py @@ -0,0 +1,127 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# Author: Federico Ceratto +# +# 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. + +""" + Test the Djbdns agent backend + + These tests *do* rely on creating directories and files or running + executables from the djbdns suite + + If djbdns is not available some tests are skipped. +""" + +import os +import tempfile +import unittest + +import fixtures +import mock + +from designate import exceptions +from designate.backend.agent_backend.impl_djbdns import DjbdnsBackend +from designate.tests import TestCase +import designate.backend.agent_backend.impl_djbdns + +TINYDNSDATA_PATH = '/usr/bin/tinydns-data' + + +class DjbdnsAgentBackendSimpleTestCase(TestCase): + + def test__check_dirs(self): + DjbdnsBackend._check_dirs('/tmp') + + def test__check_dirs_not_found(self): + self.assertRaises( + exceptions.Backend, + DjbdnsBackend._check_dirs, + '/nonexistent_dir_name' + ) + + +class DjbdnsAgentBackendTestCase(TestCase): + + def setUp(self): + super(DjbdnsAgentBackendTestCase, self).setUp() + self.CONF.set_override('masters', ('127.0.0.1:5354',), 'service:agent') + tmp_datafiles_dir = tempfile.mkdtemp() + os.mkdir(os.path.join(tmp_datafiles_dir, 'datafiles')) + self.CONF.set_override( + 'tinydns_datadir', + tmp_datafiles_dir, + designate.backend.agent_backend.impl_djbdns.CFG_GROUP + ) + self.useFixture(fixtures.MockPatchObject( + DjbdnsBackend, '_check_dirs' + )) + self.backend = DjbdnsBackend('foo') + self.patch_ob(self.backend._resolver, 'query') + + def tearDown(self): + super(DjbdnsAgentBackendTestCase, self).tearDown() + + def patch_ob(self, *a, **kw): + self.useFixture(fixtures.MockPatchObject(*a, **kw)) + + @mock.patch('designate.backend.agent_backend.impl_djbdns.os.remove') + @mock.patch('designate.backend.agent_backend.impl_djbdns.execute') + def test__perform_axfr_from_minidns(self, mock_exe, mock_rm): + mock_exe.return_value = (None, None) + + self.backend._perform_axfr_from_minidns('foo') + + mock_exe.assert_called_once_with( + 'tcpclient', '127.0.0.1', '5354', 'axfr-get', 'foo', + os.path.join(self.backend._datafiles_dir, 'foo.zonedata'), + os.path.join(self.backend._datafiles_dir, 'foo.ztmp') + ) + + def test_delete_zone_no_file(self): + self.patch_ob(self.backend, '_rebuild_data_cdb') + # Should not raise exceptions + self.backend.delete_zone('non_existent_zone_file') + + @unittest.skipIf(not os.path.isfile(TINYDNSDATA_PATH), + "tinydns-data not installed") + def test__rebuild_data_cdb_empty(self): + # Check that tinydns-data can be run and the required files are + # generated / renamed as needed + self.CONF.set_override('root_helper', ' ') # disable rootwrap + self.backend._tinydns_cdb_filename = tempfile.mkstemp()[1] + + self.backend._rebuild_data_cdb() + + assert os.path.isfile(self.backend._tinydns_cdb_filename) + os.remove(self.backend._tinydns_cdb_filename) + + @unittest.skipIf(not os.path.isfile(TINYDNSDATA_PATH), + "tinydns-data not installed") + def test__rebuild_data_cdb(self): + # Check that tinydns-data can be run and the required files are + # generated / renamed as needed + self.CONF.set_override('root_helper', ' ') # disable rootwrap + self.backend._tinydns_cdb_filename = tempfile.mkstemp()[1] + + fn = os.path.join(self.backend._datafiles_dir, 'example.org.zonedata') + with open(fn, 'w') as f: + f.write(""".example.org::ns1.example.org ++ns1.example.org:127.0.0.1 ++www.example.org:127.0.0.1 +""") + + self.backend._rebuild_data_cdb() + + assert os.path.isfile(self.backend._tinydns_cdb_filename) + os.remove(self.backend._tinydns_cdb_filename) diff --git a/designate/tests/unit/test_agent/test_backends/test_djbdns.py b/designate/tests/unit/test_agent/test_backends/test_djbdns.py new file mode 100644 index 00000000..a6979430 --- /dev/null +++ b/designate/tests/unit/test_agent/test_backends/test_djbdns.py @@ -0,0 +1,126 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# Author: Federico Ceratto +# +# 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. + +""" + Unit-test the Djbdns agent backend + + These tests do not rely on creating directories and files or running + executables from the djbdns suite +""" + +import dns.zone +import fixtures +import mock + +from designate import exceptions +from designate.backend.agent_backend.impl_djbdns import DjbdnsBackend +from designate.tests import TestCase +import designate.backend.agent_backend.impl_djbdns # noqa + + +class DjbdnsAgentBackendUnitTestCase(TestCase): + + def setUp(self): + super(DjbdnsAgentBackendUnitTestCase, self).setUp() + self.CONF.set_override('masters', ('127.0.0.1:5354',), 'service:agent') + self.useFixture(fixtures.MockPatchObject( + DjbdnsBackend, '_check_dirs' + )) + self.backend = DjbdnsBackend('foo') + self.patch_ob(self.backend._resolver, 'query') + + def tearDown(self): + super(DjbdnsAgentBackendUnitTestCase, self).tearDown() + + def _create_dnspy_zone(self, name): + zone_text = ( + '$ORIGIN %(name)s\n%(name)s 3600 IN SOA %(ns)s ' + 'email.email.com. 1421777854 3600 600 86400 3600\n%(name)s ' + '3600 IN NS %(ns)s\n') % {'name': name, 'ns': 'ns1.designate.com'} + + return dns.zone.from_text(zone_text, check_origin=False) + + def patch_ob(self, *a, **kw): + self.useFixture(fixtures.MockPatchObject(*a, **kw)) + + def test_init(self): + self.assertTrue(hasattr(self.backend, '_resolver')) + self.assertEqual(1, self.backend._resolver.timeout) + self.assertEqual(1, self.backend._resolver.lifetime) + self.assertEqual(['127.0.0.1'], self.backend._resolver.nameservers) + self.assertEqual('/var/lib/djbdns/root/data.cdb', + self.backend._tinydns_cdb_filename) + self.assertEqual('/var/lib/djbdns/datafiles', + self.backend._datafiles_dir) + self.assertEqual('/var/lib/djbdns/datafiles/%s.zonedata', + self.backend._datafiles_path_tpl) + self.assertEqual([('127.0.0.1', 5354)], self.backend._masters) + + def test_find_zone_serial(self): + class Data(object): + serial = 3 + + self.backend._resolver.query.return_value = [Data(), ] + serial = self.backend.find_zone_serial('example.com') + self.assertEqual(3, serial) + + def test_find_zone_serial_error(self): + self.backend._resolver.query.side_effect = RuntimeError('foo') + + serial = self.backend.find_zone_serial('example.com') + self.assertEqual(None, serial) + + @mock.patch('designate.backend.agent_backend.impl_djbdns.execute') + def test_create_zone(self, mock_exe): + self.patch_ob(self.backend, '_perform_axfr_from_minidns') + self.patch_ob(self.backend, '_rebuild_data_cdb') + zone = self._create_dnspy_zone('example.org') + self.backend.create_zone(zone) + + def test_update_zone(self): + self.patch_ob(self.backend, '_perform_axfr_from_minidns') + self.patch_ob(self.backend, '_rebuild_data_cdb') + zone = self._create_dnspy_zone('example.org') + self.backend.update_zone(zone) + + @mock.patch('designate.backend.agent_backend.impl_djbdns.os.remove') + def test_delete_zone(self, mock_rm): + self.patch_ob(self.backend, '_rebuild_data_cdb') + + self.backend.delete_zone('foo') + + mock_rm.assert_called_once_with( + '/var/lib/djbdns/datafiles/foo.zonedata' + ) + + @mock.patch('designate.backend.agent_backend.impl_djbdns.os.remove') + def test_exception_filter(self, *mocks): + self.patch_ob(self.backend, '_rebuild_data_cdb') + self.assertRaises( + exceptions.Backend, + self.backend.delete_zone, + None + ) + + @mock.patch('designate.backend.agent_backend.impl_djbdns.os.remove') + def test_exception_filter_pass_through(self, mock_rm): + self.patch_ob(self.backend, '_rebuild_data_cdb') + mock_rm.side_effect = exceptions.Backend + self.assertRaises( + exceptions.Backend, + self.backend.delete_zone, + 'foo' + ) diff --git a/doc/source/backend.rst b/doc/source/backend.rst index f3499ed8..59945c34 100644 --- a/doc/source/backend.rst +++ b/doc/source/backend.rst @@ -87,3 +87,12 @@ Agent Backend KnotDNS :undoc-members: :show-inheritance: +Agent Backend Djbdns +==================== + +.. automodule:: designate.backend.agent_backend.impl_djbdns + :members: + :special-members: + :private-members: + :undoc-members: + :show-inheritance: diff --git a/doc/source/backends/djbdns_agent.rst b/doc/source/backends/djbdns_agent.rst new file mode 100644 index 00000000..983f59b4 --- /dev/null +++ b/doc/source/backends/djbdns_agent.rst @@ -0,0 +1,132 @@ +.. + Copyright 2016 Hewlett Packard Enterprise Development Company LP + + Author: Federico Ceratto + + 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. + +Djbdns Agent backend +******************** + + +User documentation +================== + +This page documents the Agent backend for `djbdns `_. + +The agent runs on the same host as the `tinydns `_ resolver. +It receives DNS messages from Mini DNS using private DNS OPCODEs and classes and creates or deletes +zones in the data.cdb file using `axfr-get `_ and +`tinydns-data `_ + +Setting up Djbdns on Ubuntu Trusty +------------------------------------ + +Assuming no DNS resolver is already installed, run as root: + +.. code-block:: bash + + set -u + datadir=/var/lib/djbdns + ug_name=djbdns + tinydns_ipaddr=127.0.0.1 + + [[ -d $datadir ]] && echo "$datadir already exists" && exit 1 + set -e + apt-get update + apt-get install dbndns daemontools + if ! getent passwd $ug_name >/dev/null; then + adduser --quiet --system --group --no-create-home --home /nonexistent $ug_name + fi + tinydns-conf $ug_name $ug_name $datadir $tinydns_ipaddr + cd $datadir/root + tinydns-data data + chown -Rv $ug_name:$ug_name $datadir + +Setup the a Systemd service or, alternatively, an initfile to start TinyDNS. + +In the contrib/djbdns directory there are example files for both. + +.. code-block:: bash + + systemctl daemon-reload + service tinydns start + service tinydns status + + +If needed, create the rootwrap filters, as root: + +.. code-block:: bash + + cat > /etc/designate/rootwrap.d/djbdns.filters < + port: 5354 + options: {} + options: + - host: + port: 5358 + type: agent + + +Testing +^^^^^^^ + +Create new zones and records. Monitor the agent logfile and the contents of the +TinyDNS datadir. The data.cdb file should be receiving updates. + +.. code-block:: bash + + openstack zone create --email example@example.org example.org. + openstack recordset create example.org. --type A foo --records 1.2.3.4 + dig example.org @ SOA + dig foo.example.org @ A + +Developer documentation +======================= + +Devstack testbed +---------------- + +Follow "Setting up Djbdns on Ubuntu Trusty" + +Configure Tinydns to do AXFR from MiniDNS on 192.168.121.131 diff --git a/doc/source/support-matrix.ini b/doc/source/support-matrix.ini index 29202f00..a9698b5f 100644 --- a/doc/source/support-matrix.ini +++ b/doc/source/support-matrix.ini @@ -54,6 +54,7 @@ backend-impl-agent=Agent backend-impl-bind9-agent=Bind9 (Agent) backend-impl-denominator=Denominator backend-impl-knot2-agent=Knot2 (Agent) +backend-impl-djbdns-agent=Djbdns (Agent) [backends.backend-impl-bind9] @@ -79,6 +80,9 @@ type=agent [backends.backend-impl-knot2-agent] type=agent +[backends.backend-impl-djbdns-agent] +type=agent + [backends.backend-impl-infoblox-xfr] status=release-compatible maintainers=Infoblox OpenStack Team diff --git a/etc/designate/designate.conf.sample b/etc/designate/designate.conf.sample index e1032355..3062f86f 100644 --- a/etc/designate/designate.conf.sample +++ b/etc/designate/designate.conf.sample @@ -465,6 +465,15 @@ debug = False # knotc command name when rootwrap is used. Location of the knotc executable # on the resolver host if rootwrap is not used #knotc_cmd_name = /usr/sbin/knotc +# +[backend:agent:djbdns] +# Command names when rootwrap is used or location of the executables +# on the resolver host when rootwrap is not used +# tcpclient_cmd_name = +# axfr_get_cmd_name = +# tinydns_data_cmd_name = +# tinydns_datadir = +#query_destination = 127.0.0.1 [backend:agent:denominator] #name = dynect diff --git a/etc/designate/rootwrap.d/djbdns.filters b/etc/designate/rootwrap.d/djbdns.filters new file mode 100644 index 00000000..1471c932 --- /dev/null +++ b/etc/designate/rootwrap.d/djbdns.filters @@ -0,0 +1,4 @@ +[Filters] +tcpclient: CommandFilter, /usr/bin/tcpclient, root +axfr-get: CommandFilter, /usr/bin/axfr-get, root +tinydns-data: CommandFilter, /usr/bin/tinydns-data, root diff --git a/releasenotes/notes/djbdns-agent-backend-c84e9eeab48d2e01.yaml b/releasenotes/notes/djbdns-agent-backend-c84e9eeab48d2e01.yaml new file mode 100644 index 00000000..4105a6c0 --- /dev/null +++ b/releasenotes/notes/djbdns-agent-backend-c84e9eeab48d2e01.yaml @@ -0,0 +1,4 @@ +--- +features: + - An experimental agent backend to support TinyDNS, the DNS resolver + from the djbdns tools. diff --git a/setup.cfg b/setup.cfg index 96413b78..1b4f3fe4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -92,6 +92,7 @@ designate.backend = designate.backend.agent_backend = bind9 = designate.backend.agent_backend.impl_bind9:Bind9Backend knot2 = designate.backend.agent_backend.impl_knot2:Knot2Backend + djbdns = designate.backend.agent_backend.impl_djbdns:DjbdnsBackend denominator = designate.backend.agent_backend.impl_denominator:DenominatorBackend fake = designate.backend.agent_backend.impl_fake:FakeBackend