diff --git a/designate/agent/__init__.py b/designate/agent/__init__.py new file mode 100644 index 000000000..c5aa0f863 --- /dev/null +++ b/designate/agent/__init__.py @@ -0,0 +1,39 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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. +from oslo.config import cfg + +cfg.CONF.register_group(cfg.OptGroup( + name='service:agent', title="Configuration for the Agent Service" +)) + +OPTS = [ + cfg.IntOpt('workers', default=None, + help='Number of agent worker processes to spawn'), + cfg.StrOpt('host', default='0.0.0.0', + help='The Agent Bind Host'), + cfg.IntOpt('port', default=5358, + help='mDNS Port Number'), + cfg.IntOpt('tcp-backlog', default=100, + help='The Agent TCP Backlog'), + cfg.ListOpt('allow-notify', default=[], + help='List of IP addresses allowed to NOTIFY The Agent'), + 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'), +] + +cfg.CONF.register_opts(OPTS, group='service:agent') diff --git a/designate/agent/axfr.py b/designate/agent/axfr.py new file mode 100644 index 000000000..71d8358cc --- /dev/null +++ b/designate/agent/axfr.py @@ -0,0 +1,63 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 dns +import dns.zone +from oslo.config import cfg +from oslo_log import log as logging + +from designate.i18n import _LI +from designate.i18n import _LE + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class AXFR(object): + + def __init__(self): + self.masters = [] + for server in CONF['service:agent'].masters: + raw_server = server.split(':') + master = {'ip': raw_server[0], 'port': int(raw_server[1])} + self.masters.append(master) + + LOG.info(_LI("Agent masters: %(masters)s") % + {'masters': self.masters}) + + def do_axfr(self, zone_name): + """ + Performs an AXFR for a given zone name + """ + # TODO(Tim): Try the first master, try others if they exist + master = self.masters[0] + + LOG.info(_LI("Doing AXFR for %(name)s from %(host)s") % + {'name': zone_name, 'host': master}) + + xfr = dns.query.xfr(master['ip'], zone_name, relativize=False, + port=master['port']) + + try: + # TODO(Tim): Add a timeout to this function + raw_zone = dns.zone.from_xfr(xfr, relativize=False) + except Exception: + LOG.exception(_LE("There was a problem with the AXFR")) + raise + + LOG.debug("AXFR Successful for %s" % raw_zone.origin.to_text()) + + return raw_zone diff --git a/designate/agent/handler.py b/designate/agent/handler.py new file mode 100644 index 000000000..9f27f7a30 --- /dev/null +++ b/designate/agent/handler.py @@ -0,0 +1,212 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 dns +from oslo.config import cfg +from oslo_log import log as logging + +from designate.agent import axfr +from designate.backend import agent_backend +from designate.i18n import _LW + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + +# Command and Control OPCODE +CC = 14 + +# Private DNS CLASS Uses +ClassCC = 65280 + +# Private RR Code Uses +SUCCESS = 65280 +FAILURE = 65281 +CREATE = 65282 +DELETE = 65283 + + +class RequestHandler(object): + def __init__(self): + self.xfr = axfr.AXFR() + self.allow_notify = CONF['service:agent'].allow_notify + backend_driver = cfg.CONF['service:agent'].backend_driver + self.backend = agent_backend.get_backend(backend_driver, self) + + def __call__(self, request): + """ + :param request: DNS Request Message + :return: DNS Response Message + """ + # TODO(Tim): Handle multiple questions + rdtype = request.question[0].rdtype + rdclass = request.question[0].rdclass + opcode = request.opcode() + if opcode == dns.opcode.NOTIFY: + response = self._handle_notify(request) + elif opcode == CC: + if rdclass == ClassCC: + if rdtype == CREATE: + response = self._handle_create(request) + elif rdtype == DELETE: + response = self._handle_delete(request) + else: + response = self._handle_query_error(request, + dns.rcode.REFUSED) + else: + response = self._handle_query_error(request, dns.rcode.REFUSED) + else: + # Unhandled OpCodes include STATUS, QUERY, IQUERY, UPDATE + response = self._handle_query_error(request, dns.rcode.REFUSED) + + # TODO(Tim): Answer Type 65XXX queries + return response + + def _handle_query_error(self, request, rcode): + """ + Construct an error response with the rcode passed in. + :param request: The decoded request from the wire. + :param rcode: The response code to send back. + :return: A dns response message with the response code set to rcode + """ + response = dns.message.make_response(request) + response.set_rcode(rcode) + + return response + + def _handle_create(self, request): + response = dns.message.make_response(request) + + question = request.question[0] + requester = request.environ['addr'][0] + domain_name = question.name.to_text() + + if not self._allowed(request, requester, "CREATE", domain_name): + response.set_rcode(dns.rcode.from_text("REFUSED")) + return response + + serial = self.backend.find_domain_serial(domain_name) + + if serial is not None: + LOG.warn(_LW("Refusing CREATE for %(name)s, zone already exists") % + {'name': domain_name}) + response.set_rcode(dns.rcode.from_text("REFUSED")) + return response + + LOG.debug("Received %(verb)s for %(name)s from %(host)s" % + {'verb': "CREATE", 'name': domain_name, 'host': requester}) + + try: + zone = self.xfr.do_axfr(domain_name) + self.backend.create_domain(zone) + except Exception: + response.set_rcode(dns.rcode.from_text("SERVFAIL")) + return response + + # Provide an authoritative answer + response.flags |= dns.flags.AA + + return response + + def _handle_notify(self, request): + """ + Constructs the response to a NOTIFY and acts accordingly on it. + + * Decodes the NOTIFY + * Checks if the master sending the NOTIFY is allowed to notify + * Does a serial check to see if further action needs to be taken + * Kicks off an AXFR and returns a valid response + """ + response = dns.message.make_response(request) + + question = request.question[0] + requester = request.environ['addr'][0] + domain_name = question.name.to_text() + + if not self._allowed(request, requester, "NOTIFY", domain_name): + response.set_rcode(dns.rcode.from_text("REFUSED")) + return response + + serial = self.backend.find_domain_serial(domain_name) + + if serial is None: + LOG.warn(_LW("Refusing NOTIFY for %(name)s, doesn't exist") % + {'name': domain_name}) + response.set_rcode(dns.rcode.from_text("REFUSED")) + return response + + LOG.debug("Received %(verb)s for %(name)s from %(host)s" % + {'verb': "NOTIFY", 'name': domain_name, 'host': requester}) + + # According to RFC we should query the server that sent the NOTIFY + # TODO(Tim): Reenable this when it makes more sense + # resolver = dns.resolver.Resolver() + # resolver.nameservers = [requester] + # This assumes that the Master is running on port 53 + # soa_answer = resolver.query(domain_name, 'SOA') + # Check that the serial is < serial above + + try: + zone = self.xfr.do_axfr(domain_name) + self.backend.update_domain(zone) + except Exception: + response.set_rcode(dns.rcode.from_text("SERVFAIL")) + return response + + # Provide an authoritative answer + response.flags |= dns.flags.AA + + return response + + def _handle_delete(self, request): + """ + Constructs the response to a DELETE and acts accordingly on it. + + * Decodes the message for zone name + * Checks if the master sending the DELETE is in the allowed notify list + * Checks if the zone exists (maybe?) + * Kicks a call to the backend to delete the zone in question + """ + response = dns.message.make_response(request) + + question = request.question[0] + requester = request.environ['addr'][0] + domain_name = question.name.to_text() + + if not self._allowed(request, requester, "DELETE", domain_name): + response.set_rcode(dns.rcode.from_text("REFUSED")) + return response + + LOG.debug("Received DELETE for %(name)s from %(host)s" % + {'name': domain_name, 'host': requester}) + + # Provide an authoritative answer + response.flags |= dns.flags.AA + + # Call into the backend to Delete + try: + self.backend.delete_domain(domain_name) + except Exception: + response.set_rcode(dns.rcode.from_text("SERVFAIL")) + return response + + return response + + def _allowed(self, request, requester, op, domain_name): + if requester not in self.allow_notify: + LOG.warn(_LW("%(verb)s for %(name)s from %(server)s refused") % + {'verb': op, 'name': domain_name, 'server': requester}) + return False + + return True diff --git a/designate/agent/middleware.py b/designate/agent/middleware.py new file mode 100644 index 000000000..fc0f15495 --- /dev/null +++ b/designate/agent/middleware.py @@ -0,0 +1,42 @@ +# Copyright 2014 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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. + + +class Middleware(object): + def __init__(self, application): + self.application = application + + def process_request(self, request): + """Called on each request. + + If this returns None, the next application down the stack will be + executed. If it returns a response then that response will be returned + and execution will stop here. + """ + return None + + def process_response(self, response): + """Do whatever you'd like to the response.""" + return response + + def __call__(self, request): + response = self.process_request(request) + + if response: + return response + + response = self.application(request) + return self.process_response(response) diff --git a/designate/agent/service.py b/designate/agent/service.py new file mode 100644 index 000000000..e41fad275 --- /dev/null +++ b/designate/agent/service.py @@ -0,0 +1,180 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 socket +import struct + +import dns +from oslo.config import cfg +from oslo_log import log as logging + +from designate import service +from designate.agent import handler +from designate.agent import middleware +from designate.backend import agent_backend +from designate.i18n import _LE +from designate.i18n import _LI +from designate.i18n import _LW + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class Service(service.TCPService): + def __init__(self, *args, **kwargs): + super(Service, self).__init__(*args, **kwargs) + + backend_driver = cfg.CONF['service:agent'].backend_driver + self.backend = agent_backend.get_backend(backend_driver, self) + + # Create an instance of the RequestHandler class + self.application = handler.RequestHandler() + + # Wrap the application in any middleware required + # TODO(kiall): In the future, we want to allow users to pick+choose + # the middleware to be applied, similar to how we do this + # in the API. + self.application = middleware.Middleware(self.application) + + # Bind to the TCP port + LOG.info(_LI('Opening TCP Listening Socket on %(host)s:%(port)d') % + {'host': CONF['service:agent'].host, + 'port': CONF['service:agent'].port}) + self._sock_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock_tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock_tcp.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + self._sock_tcp.bind((CONF['service:agent'].host, + CONF['service:agent'].port)) + self._sock_tcp.listen(CONF['service:agent'].tcp_backlog) + + # Bind to the UDP port + LOG.info(_LI('Opening UDP Listening Socket on %(host)s:%(port)d') % + {'host': CONF['service:agent'].host, + 'port': CONF['service:agent'].port}) + self._sock_udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock_udp.bind((CONF['service:agent'].host, + CONF['service:agent'].port)) + + def start(self): + super(Service, self).start() + self.backend.start() + self.tg.add_thread(self._handle_tcp) + self.tg.add_thread(self._handle_udp) + LOG.info(_LI("Started Agent Service")) + + def stop(self): + super(Service, self).stop() + LOG.info(_LI("Stopped Agent Service")) + + def _deserialize_request(self, payload, addr): + """ + Deserialize a DNS Request Packet + + :param payload: Raw DNS query payload + :param addr: Tuple of the client's (IP, Port) + """ + try: + request = dns.message.from_wire(payload) + except dns.exception.DNSException: + LOG.error(_LE("Failed to deserialize packet from " + "%(host)s:%(port)d") % + {'host': addr[0], 'port': addr[1]}) + return None + else: + # Create + Attach the initial "environ" dict. This is similar to + # the environ dict used in typical WSGI middleware. + request.environ = {'addr': addr} + return request + + def _serialize_response(self, response): + """ + Serialize a DNS Response Packet + + :param response: DNS Response Message + """ + return response.to_wire() + + def _handle_tcp(self): + LOG.info(_LI("_handle_tcp thread started")) + while True: + client, addr = self._sock_tcp.accept() + LOG.debug("Handling TCP Request from: %(host)s:%(port)d" % + {'host': addr[0], 'port': addr[1]}) + + payload = client.recv(65535) + (expected_length,) = struct.unpack('!H', payload[0:2]) + actual_length = len(payload[2:]) + + # For now we assume all requests are one packet + # TODO(vinod): Handle multipacket requests + if (expected_length != actual_length): + LOG.warn(_LW("got a packet with unexpected length from " + "%(host)s:%(port)d. Expected length=%(elen)d. " + "Actual length=%(alen)d.") % + {'host': addr[0], 'port': addr[1], + 'elen': expected_length, 'alen': actual_length}) + client.close() + else: + self.tg.add_thread(self._handle, addr, payload[2:], client) + + def _handle_udp(self): + LOG.info(_LI("_handle_udp thread started")) + while True: + # TODO(kiall): Determine the appropriate default value for + # UDP recvfrom. + payload, addr = self._sock_udp.recvfrom(8192) + LOG.debug("Handling UDP Request from: %(host)s:%(port)d" % + {'host': addr[0], 'port': addr[1]}) + + self.tg.add_thread(self._handle, addr, payload) + + def _handle(self, addr, payload, client=None): + """ + Handle a DNS Query + + :param addr: Tuple of the client's (IP, Port) + :param payload: Raw DNS query payload + :param client: Client socket (for TCP only) + """ + try: + request = self._deserialize_request(payload, addr) + + if request is None: + # We failed to deserialize the request, generate a failure + # response using a made up request. + response = dns.message.make_response( + dns.message.make_query('unknown', dns.rdatatype.A)) + response.set_rcode(dns.rcode.FORMERR) + else: + response = self.application(request) + + # send back a response only if present + if response: + response = self._serialize_response(response) + + if client is not None: + # Handle TCP Responses + msg_length = len(response) + tcp_response = struct.pack("!H", msg_length) + response + client.send(tcp_response) + client.close() + else: + # Handle UDP Responses + self._sock_udp.sendto(response, addr) + except Exception: + LOG.exception(_LE("Unhandled exception while processing request " + "from %(host)s:%(port)d") % + {'host': addr[0], 'port': addr[1]}) diff --git a/designate/backend/agent_backend/__init__.py b/designate/backend/agent_backend/__init__.py new file mode 100644 index 000000000..9c7c9a0d5 --- /dev/null +++ b/designate/backend/agent_backend/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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. +from oslo_log import log as logging + +from designate.backend.agent_backend.base import AgentBackend + +LOG = logging.getLogger(__name__) + + +def get_backend(backend_driver, agent_service): + LOG.debug("Loading backend driver: %s" % backend_driver) + + cls = AgentBackend.get_driver(backend_driver) + + return cls(agent_service) diff --git a/designate/backend/agent_backend/base.py b/designate/backend/agent_backend/base.py new file mode 100644 index 000000000..d30573029 --- /dev/null +++ b/designate/backend/agent_backend/base.py @@ -0,0 +1,57 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 abc + +from oslo_log import log as logging + +from designate.plugin import DriverPlugin + + +LOG = logging.getLogger(__name__) + + +class AgentBackend(DriverPlugin): + """Base class for backend implementations""" + __plugin_type__ = 'backend' + __plugin_ns__ = 'designate.backend.agent_backend' + + def __init__(self, agent_service): + super(AgentBackend, self).__init__() + self.agent_service = agent_service + + def start(self): + pass + + def stop(self): + pass + + @abc.abstractmethod + def find_domain_serial(self, domain_name): + """Find a DNS Domain""" + + @abc.abstractmethod + def create_domain(self, domain): + """Create a DNS domain""" + """Domain is a DNSPython Zone object""" + + @abc.abstractmethod + def update_domain(self, domain): + """Update a DNS domain""" + """Domain is a DNSPython Zone object""" + + @abc.abstractmethod + def delete_domain(self, domain_name): + """Delete a DNS domain""" diff --git a/designate/backend/agent_backend/impl_bind9.py b/designate/backend/agent_backend/impl_bind9.py new file mode 100644 index 000000000..54a585231 --- /dev/null +++ b/designate/backend/agent_backend/impl_bind9.py @@ -0,0 +1,149 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 time +import os + +import dns +from oslo.config import cfg +from oslo.concurrency import lockutils +from oslo_log import log as logging + +from designate.backend.agent_backend import base +from designate import exceptions +from designate import utils +from designate.i18n import _LI + +LOG = logging.getLogger(__name__) +CFG_GROUP = 'backend:agent:bind9' + + +class Bind9Backend(base.AgentBackend): + __plugin_name__ = 'bind9' + + @classmethod + def get_cfg_opts(cls): + group = cfg.OptGroup( + name='backend:agent:bind9', title="Configuration for bind9 backend" + ) + + opts = [ + cfg.StrOpt('rndc-host', default='127.0.0.1', help='RNDC Host'), + cfg.IntOpt('rndc-port', default=953, help='RNDC Port'), + cfg.StrOpt('rndc-config-file', default=None, + help='RNDC Config File'), + cfg.StrOpt('rndc-key-file', default=None, help='RNDC Key File'), + cfg.StrOpt('zone-file-path', default='$state_path/zones', + help='Path where zone files are stored') + ] + + return [(group, opts)] + + def start(self): + LOG.info(_LI("Started bind9 backend")) + + def find_domain_serial(self, domain_name): + LOG.debug("Finding %s" % domain_name) + resolver = dns.resolver.Resolver() + resolver.nameservers = ['127.0.0.1'] + try: + rdata = resolver.query(domain_name, 'SOA')[0] + except Exception: + return None + return rdata.serial + + def create_domain(self, domain): + LOG.debug("Creating %s" % domain.origin.to_text()) + self._sync_domain(domain, new_domain_flag=True) + + def update_domain(self, domain): + LOG.debug("Updating %s" % domain.origin.to_text()) + self._sync_domain(domain) + + def delete_domain(self, domain_name): + LOG.debug('Delete Domain: %s' % domain_name) + + rndc_op = 'delzone' + # RNDC doesn't like the trailing dot on the domain name + rndc_call = self._rndc_base() + [rndc_op, domain_name[:-1]] + + utils.execute(*rndc_call) + + def _rndc_base(self): + rndc_call = [ + 'rndc', + '-s', cfg.CONF[CFG_GROUP].rndc_host, + '-p', str(cfg.CONF[CFG_GROUP].rndc_port), + ] + + if cfg.CONF[CFG_GROUP].rndc_config_file: + rndc_call.extend(['-c', + cfg.CONF[CFG_GROUP].rndc_config_file]) + + if cfg.CONF[CFG_GROUP].rndc_key_file: + rndc_call.extend(['-k', + cfg.CONF[CFG_GROUP].rndc_key_file]) + + return rndc_call + + def _sync_domain(self, domain, new_domain_flag=False): + """Sync a single domain's zone file and reload bind config""" + + domain_name = domain.origin.to_text() + + # NOTE: Only one thread should be working with the Zonefile at a given + # time. The sleep(1) below introduces a not insignificant risk + # of more than 1 thread working with a zonefile at a given time. + with lockutils.lock('bind9-%s' % domain_name): + LOG.debug('Synchronising Domain: %s' % domain_name) + + zone_path = cfg.CONF[CFG_GROUP].zone_file_path + + output_path = os.path.join(zone_path, + '%szone' % domain_name) + + domain.to_file(output_path) + + rndc_call = self._rndc_base() + + if new_domain_flag: + rndc_op = [ + 'addzone', + '%s { type master; file "%s"; };' % (domain_name, + output_path), + ] + rndc_call.extend(rndc_op) + else: + rndc_op = 'reload' + rndc_call.extend([rndc_op]) + rndc_call.extend([domain_name]) + + if not new_domain_flag: + # NOTE: Bind9 will only ever attempt to re-read a zonefile if + # the file's timestamp has changed since the previous + # reload. A one second sleep ensures we cross over a + # second boundary before allowing the next change. + time.sleep(1) + + LOG.debug('Calling RNDC with: %s' % " ".join(rndc_call)) + self._execute_rndc(rndc_call) + + def _execute_rndc(self, rndc_call): + try: + LOG.debug('Executing RNDC call: %s' % " ".join(rndc_call)) + utils.execute(*rndc_call) + except utils.processutils.ProcessExecutionError as e: + LOG.debug('RNDC call failure: %s' % e) + raise exceptions.Backend(e) diff --git a/designate/backend/agent_backend/impl_fake.py b/designate/backend/agent_backend/impl_fake.py new file mode 100644 index 000000000..f4adb8400 --- /dev/null +++ b/designate/backend/agent_backend/impl_fake.py @@ -0,0 +1,44 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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. +from oslo_log import log as logging + +from designate.backend.agent_backend import base +from designate.i18n import _LI + +LOG = logging.getLogger(__name__) + + +class FakeBackend(base.AgentBackend): + __plugin_name__ = 'fake' + + def start(self): + LOG.info(_LI("Started fake backend, Pool Manager will not work!")) + + def stop(self): + LOG.info(_LI("Stopped fake backend")) + + def find_domain_serial(self, domain_name): + LOG.debug("Finding %s" % domain_name) + return 0 + + def create_domain(self, domain): + LOG.debug("Creating %s" % domain.origin.to_text()) + + def update_domain(self, domain): + LOG.debug("Updating %s" % domain.origin.to_text()) + + def delete_domain(self, domain_name): + LOG.debug('Delete Domain: %s' % domain_name) diff --git a/designate/cmd/agent.py b/designate/cmd/agent.py new file mode 100644 index 000000000..7304a4bb7 --- /dev/null +++ b/designate/cmd/agent.py @@ -0,0 +1,37 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 sys + +from oslo.config import cfg +from oslo_log import log as logging + +from designate import service +from designate import utils +from designate.agent import service as agent_service + + +CONF = cfg.CONF +CONF.import_opt('workers', 'designate.agent', group='service:agent') + + +def main(): + utils.read_config('designate', sys.argv) + logging.setup(CONF, 'designate') + + server = agent_service.Service.create( + binary='designate-agent') + service.serve(server, workers=CONF['service:agent'].workers) + service.wait() diff --git a/designate/service.py b/designate/service.py index 3b293af02..590f464c4 100644 --- a/designate/service.py +++ b/designate/service.py @@ -128,6 +128,52 @@ class RPCService(Service): super(RPCService, self).wait() +class TCPService(Service): + """ + Service class to be used for a service that only works in TCP + """ + def __init__(self, host=None, binary=None, service_name=None, + endpoints=None, threads=1000): + super(TCPService, self).__init__(threads) + + self.host = host + self.binary = binary + self.service_name = service_name + + self.endpoints = endpoints or [self] + + @classmethod + def create(cls, host=None, binary=None, service_name=None, + endpoints=None): + """Instantiates class and passes back application object. + + :param host: defaults to CONF.host + :param binary: defaults to basename of executable + """ + if not host: + host = CONF.host + if not binary: + binary = os.path.basename(inspect.stack()[-1][1]) + + service_obj = cls(host, binary, service_name=service_name, + endpoints=endpoints) + return service_obj + + def start(self): + for e in self.endpoints: + if e != self and hasattr(e, 'start'): + e.start() + + super(TCPService, self).start() + + def stop(self): + for e in self.endpoints: + if e != self and hasattr(e, 'stop'): + e.stop() + + super(TCPService, self).stop() + + class WSGIService(wsgi.Service, Service): """ Service class to be shared by all Designate WSGI Services diff --git a/designate/tests/test_agent/__init__.py b/designate/tests/test_agent/__init__.py new file mode 100644 index 000000000..477969cde --- /dev/null +++ b/designate/tests/test_agent/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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. +from designate.tests import TestCase + + +class AgentTestCase(TestCase): + pass diff --git a/designate/tests/test_agent/test_backends/__init__.py b/designate/tests/test_agent/test_backends/__init__.py new file mode 100644 index 000000000..aaa1573a4 --- /dev/null +++ b/designate/tests/test_agent/test_backends/__init__.py @@ -0,0 +1,26 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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. +from oslo.config import cfg + +from designate.backend import agent_backend +from designate.agent import service + + +class BackendTestMixin(object): + def get_backend_driver(self): + return agent_backend.get_backend( + cfg.CONF['service:agent'].backend_driver, + agent_service=service.Service()) diff --git a/designate/tests/test_agent/test_backends/test_bind9.py b/designate/tests/test_agent/test_backends/test_bind9.py new file mode 100644 index 000000000..7b5f8a03d --- /dev/null +++ b/designate/tests/test_agent/test_backends/test_bind9.py @@ -0,0 +1,69 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 mock +import dns.zone + +from designate.agent import service +from designate.backend import agent_backend +from designate.tests import TestCase +from designate.tests.test_agent.test_backends import BackendTestMixin + + +class Bind9AgentBackendTestCase(TestCase, BackendTestMixin): + + def setUp(self): + super(Bind9AgentBackendTestCase, self).setUp() + # Use a random port + self.config(port=0, group='service:agent') + self.backend = agent_backend.get_backend('bind9', + agent_service=service.Service()) + self.backend.start() + + def tearDown(self): + super(Bind9AgentBackendTestCase, self).tearDown() + self.backend.agent_service.stop() + self.backend.stop() + + def test_find_domain_serial(self): + self.backend.find_domain_serial('example.org.') + + @mock.patch('designate.utils.execute') + @mock.patch(('designate.backend.agent_backend.impl_bind9.Bind9Backend' + '._sync_domain')) + def test_create_domain(self, execute, sync): + domain = self._create_dnspy_zone('example.org') + self.backend.create_domain(domain) + + @mock.patch('designate.utils.execute') + @mock.patch(('designate.backend.agent_backend.impl_bind9.Bind9Backend' + '._sync_domain')) + def test_update_domain(self, execute, sync): + domain = self._create_dnspy_zone('example.org') + self.backend.update_domain(domain) + + @mock.patch('designate.utils.execute') + @mock.patch(('designate.backend.agent_backend.impl_bind9.Bind9Backend' + '._sync_domain')) + def test_delete_domain(self, execute, sync): + self.backend.delete_domain('example.org.') + + # Helper + 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) diff --git a/designate/tests/test_agent/test_backends/test_fake.py b/designate/tests/test_agent/test_backends/test_fake.py new file mode 100644 index 000000000..ba0c04928 --- /dev/null +++ b/designate/tests/test_agent/test_backends/test_fake.py @@ -0,0 +1,59 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 dns.zone + +from designate.agent import service +from designate.backend import agent_backend +from designate.tests import TestCase +from designate.tests.test_agent.test_backends import BackendTestMixin + + +class FakeAgentBackendTestCase(TestCase, BackendTestMixin): + + def setUp(self): + super(FakeAgentBackendTestCase, self).setUp() + # Use a random port + self.config(port=0, group='service:agent') + self.backend = agent_backend.get_backend('fake', + agent_service=service.Service()) + self.backend.start() + + def tearDown(self): + super(FakeAgentBackendTestCase, self).tearDown() + self.backend.agent_service.stop() + self.backend.stop() + + def test_find_domain_serial(self): + self.backend.find_domain_serial('example.org.') + + def test_create_domain(self): + domain = self._create_dnspy_zone('example.org') + self.backend.create_domain(domain) + + def test_update_domain(self): + domain = self._create_dnspy_zone('example.org') + self.backend.update_domain(domain) + + def test_delete_domain(self): + self.backend.delete_domain('example.org.') + + # Helper + 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) diff --git a/designate/tests/test_agent/test_handler.py b/designate/tests/test_agent/test_handler.py new file mode 100644 index 000000000..f17224521 --- /dev/null +++ b/designate/tests/test_agent/test_handler.py @@ -0,0 +1,181 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 binascii + +import dns +import mock + +import designate +from designate.agent import handler +from designate.tests.test_agent import AgentTestCase + + +class AgentRequestHandlerTest(AgentTestCase): + def setUp(self): + super(AgentRequestHandlerTest, self).setUp() + self.config(allow_notify=["0.0.0.0"], + backend_driver="fake", + group='service:agent') + self.handler = handler.RequestHandler() + self.addr = ["0.0.0.0", 5558] + + @mock.patch.object(dns.resolver.Resolver, 'query') + @mock.patch('designate.agent.axfr.AXFR.do_axfr') + def test_receive_notify(self, func, axfrfunc): + """ + Get a NOTIFY and ensure the response is right, + and an AXFR is triggered + """ + payload = ("1a7220000001000000000000076578616d706c6503636f6d000006" + "0001") + # expected response is NOERROR, other fields are + # opcode NOTIFY + # rcode NOERROR + # flags QR AA + # ;QUESTION + # example.com. IN SOA + # ;ANSWER + # ;AUTHORITY + # ;ADDITIONAL + expected_response = ("1a72a4000001000000000000076578616d706c650363" + "6f6d0000060001") + request = dns.message.from_wire(binascii.a2b_hex(payload)) + request.environ = {'addr': ["0.0.0.0", 1234]} + response = self.handler(request).to_wire() + self.assertEqual(expected_response, binascii.b2a_hex(response)) + + def test_receive_notify_bad_notifier(self): + """ + Get a NOTIFY from a bad master and refuse it + """ + payload = "243520000001000000000000076578616d706c6503636f6d0000060001" + # expected response is REFUSED, other fields are + # opcode NOTIFY + # rcode REFUSED + # flags QR + # ;QUESTION + # example.com. IN SOA + # ;ANSWER + # ;AUTHORITY + # ;ADDITIONAL + expected_response = ("2435a0050001000000000000076578616d706c6503636f6d" + "0000060001") + request = dns.message.from_wire(binascii.a2b_hex(payload)) + # Bad 'requester' + request.environ = {'addr': ["6.6.6.6", 1234]} + response = self.handler(request).to_wire() + + self.assertEqual(expected_response, binascii.b2a_hex(response)) + + @mock.patch.object(dns.resolver.Resolver, 'query') + @mock.patch('designate.agent.axfr.AXFR.do_axfr') + def test_receive_create(self, func, func2): + """ + Get a CREATE and ensure the response is right, + and an AXFR is triggered, and the proper backend + call is made + """ + payload = "735d70000001000000000000076578616d706c6503636f6d00ff02ff00" + # Expected NOERROR other fields are + # opcode 14 + # rcode NOERROR + # flags QR AA + # ;QUESTION + # example.com. CLASS65280 TYPE65282 + # ;ANSWER + # ;AUTHORITY + # ;ADDITIONAL + expected_response = ("735df4000001000000000000076578616d706c6503636f6d" + "00ff02ff00") + request = dns.message.from_wire(binascii.a2b_hex(payload)) + request.environ = {'addr': ["0.0.0.0", 1234]} + with mock.patch.object( + designate.backend.agent_backend.impl_fake.FakeBackend, + 'find_domain_serial', return_value=None): + + response = self.handler(request).to_wire() + self.assertEqual(expected_response, binascii.b2a_hex(response)) + + def test_receive_create_bad_notifier(self): + """ + Get a NOTIFY from a bad master and refuse it + """ + payload = "8dfd70000001000000000000076578616d706c6503636f6d00ff02ff00" + # expected response is REFUSED, other fields are + # opcode 14 + # rcode REFUSED + # flags QR + # ;QUESTION + # example.com. CLASS65280 TYPE65282 + # ;ANSWER + # ;AUTHORITY + # ;ADDITIONAL + expected_response = ("8dfdf0050001000000000000076578616d706c6503636f6d" + "00ff02ff00") + request = dns.message.from_wire(binascii.a2b_hex(payload)) + # Bad 'requester' + request.environ = {'addr': ["6.6.6.6", 1234]} + response = self.handler(request).to_wire() + + self.assertEqual(expected_response, binascii.b2a_hex(response)) + + @mock.patch('designate.utils.execute') + def test_receive_delete(self, func): + """ + Get a DELETE and ensure the response is right, + and that the proper backend call is made + """ + payload = "3b9970000001000000000000076578616d706c6503636f6d00ff03ff00" + # Expected NOERROR other fields are + # opcode 14 + # rcode NOERROR + # flags QR AA + # ;QUESTION + # example.com. CLASS65280 TYPE65283 + # ;ANSWER + # ;AUTHORITY + # ;ADDITIONAL + expected_response = ("3b99f4000001000000000000076578616d706c6503636f6d" + "00ff03ff00") + request = dns.message.from_wire(binascii.a2b_hex(payload)) + request.environ = {'addr': ["0.0.0.0", 1234]} + response = self.handler(request).to_wire() + + self.assertEqual(expected_response, binascii.b2a_hex(response)) + + def test_receive_delete_bad_notifier(self): + """ + Get a message with an unsupported OPCODE and make + sure that it is refused + """ + payload = "e6da70000001000000000000076578616d706c6503636f6d00ff03ff00" + # expected response is REFUSED, other fields are + # opcode 14 + # rcode REFUSED + # flags QR + # ;QUESTION + # example.com. CLASS65280 TYPE65283 + # ;ANSWER + # ;AUTHORITY + # ;ADDITIONAL + expected_response = ("e6daf0050001000000000000076578616d706c6503636f6d" + "00ff03ff00") + request = dns.message.from_wire(binascii.a2b_hex(payload)) + # Bad 'requester' + request.environ = {'addr': ["6.6.6.6", 1234]} + response = self.handler(request).to_wire() + + self.assertEqual(expected_response, binascii.b2a_hex(response)) diff --git a/designate/tests/test_agent/test_service.py b/designate/tests/test_agent/test_service.py new file mode 100644 index 000000000..e99a6dad0 --- /dev/null +++ b/designate/tests/test_agent/test_service.py @@ -0,0 +1,30 @@ +# Copyright 2014 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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. +from designate.tests.test_agent import AgentTestCase + + +class AgentServiceTest(AgentTestCase): + def setUp(self): + super(AgentServiceTest, self).setUp() + + # Use a random port + self.config(port=0, group='service:agent') + + self.service = self.start_service('agent') + + def test_stop(self): + # NOTE: Start is already done by the fixture in start_service() + self.service.stop() diff --git a/designate/utils.py b/designate/utils.py index f562064cf..fc403b76d 100644 --- a/designate/utils.py +++ b/designate/utils.py @@ -101,6 +101,8 @@ def register_plugin_opts(): # Register Backend Plugin Config Options plugin.Plugin.register_cfg_opts('designate.backend') plugin.Plugin.register_extra_cfg_opts('designate.backend') + plugin.Plugin.register_cfg_opts('designate.backend.agent_backend') + plugin.Plugin.register_extra_cfg_opts('designate.backend.agent_backend') def resource_string(*args): diff --git a/etc/designate/designate.conf.sample b/etc/designate/designate.conf.sample index 2663c0bd0..6483104a9 100644 --- a/etc/designate/designate.conf.sample +++ b/etc/designate/designate.conf.sample @@ -118,6 +118,19 @@ debug = False #port = 5354 #tcp_backlog =1 00 +#----------------------- +# Agent Service +#----------------------- +[service:agent] +#workers = None +#host = 0.0.0.0 +#port = 5358 +#tcp_backlog = 100 +#allow_notify = 127.0.0.1 +#masters = 127.0.0.1:5354 +#backend_driver = fake + + #----------------------- # Pool Manager Service #----------------------- @@ -253,3 +266,13 @@ debug = False [backend:bind9:6a5032b6-2d96-43ee-b25b-7d784e2bf3b2] # host = 127.0.0.1 # port = 53 + +############################# +## Agent Backend Configuration +############################# +[backend:agent:bind9] +#rndc_host = 127.0.0.1 +#rndc_port = 953 +#rndc_config_file = /etc/rndc.conf +#rndc_key_file = /etc/rndc.key +#zone_file_path = $state_path/zones diff --git a/setup.cfg b/setup.cfg index 4a3a28931..f0f3411d9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,6 +37,7 @@ console_scripts = designate-mdns = designate.cmd.mdns:main designate-pool-manager = designate.cmd.pool_manager:main designate-sink = designate.cmd.sink:main + designate-agent = designate.cmd.agent:main designate.api.v1 = domains = designate.api.v1.domains:blueprint @@ -75,6 +76,10 @@ designate.backend = #dynect = designate.backend.impl_dynect:DynECTBackend #ipa = designate.backend.impl_ipa:IPABackend +designate.backend.agent_backend = + bind9 = designate.backend.agent_backend.impl_bind9:Bind9Backend + fake = designate.backend.agent_backend.impl_fake:FakeBackend + designate.network_api = fake = designate.network_api.fake:FakeNetworkAPI neutron = designate.network_api.neutron:NeutronNetworkAPI