Source code for designate.mdns.handler

# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hpe.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 dns
import dns.flags
import dns.message
import dns.opcode
import dns.rcode
import dns.rdataclass
import dns.rdatatype
from oslo_config import cfg
from oslo_log import log as logging
import six

from designate import exceptions
from designate.central import rpcapi as central_api
from designate.mdns import xfr

LOG = logging.getLogger(__name__)
CONF = cfg.CONF

CONF.import_opt('default_pool_id', 'designate.central',
                group='service:central')

# 10 Bytes of RR metadata, 64 bytes of TSIG RR data, variable length TSIG Key
# name (restricted in designate to 160 chars), 1 byte for trailing dot.
TSIG_RRSIZE = 10 + 64 + 160 + 1


[docs]class RequestHandler(xfr.XFRMixin): def __init__(self, storage, tg): self.storage = storage self.tg = tg @property def central_api(self): if not hasattr(self, '_central_api'): self._central_api = central_api.CentralAPI.get_instance() return self._central_api def __call__(self, request): """ :param request: DNS Request Message :return: DNS Response Message """ if request.opcode() == dns.opcode.QUERY: # Currently we expect exactly 1 question in the section # TSIG places the pseudo records into the additional section. if (len(request.question) != 1 or request.question[0].rdclass != dns.rdataclass.IN): LOG.debug('Refusing due to numbers of questions or rdclass') yield self._handle_query_error(request, dns.rcode.REFUSED) return q_rrset = request.question[0] # Handle AXFR and IXFR requests with an AXFR responses for now. # It is permissible for a server to send an AXFR response when # receiving an IXFR request. if q_rrset.rdtype in (dns.rdatatype.AXFR, dns.rdatatype.IXFR): for response in self._handle_axfr(request): yield response return else: for response in self._handle_record_query(request): yield response return elif request.opcode() == dns.opcode.NOTIFY: for response in self._handle_notify(request): yield response return else: # Unhandled OpCode's include STATUS, IQUERY, UPDATE LOG.debug('Refusing unhandled opcode') yield self._handle_query_error(request, dns.rcode.REFUSED) return def _handle_notify(self, request): """ Constructs the response to a NOTIFY and acts accordingly on it. * Checks if the master sending the NOTIFY is in the Zone's masters, if not it is ignored. * Checks if SOA query response serial != local serial. """ context = request.environ['context'] response = dns.message.make_response(request) if len(request.question) != 1: response.set_rcode(dns.rcode.FORMERR) yield response return else: question = request.question[0] name = question.name.to_text() if six.PY3 and isinstance(name, bytes): name = name.decode('utf-8') criterion = { 'name': name, 'type': 'SECONDARY', 'deleted': False } try: zone = self.storage.find_zone(context, criterion) except exceptions.ZoneNotFound: response.set_rcode(dns.rcode.NOTAUTH) yield response return notify_addr = request.environ['addr'][0] # We check if the src_master which is the assumed master for the zone # that is sending this NOTIFY OP is actually the master. If it's not # We'll reply but don't do anything with the NOTIFY. master_addr = zone.get_master_by_ip(notify_addr) if not master_addr: LOG.warning( 'NOTIFY for %(name)s from non-master server %(addr)s, ' 'refusing.', { 'name': zone.name, 'addr': notify_addr } ) response.set_rcode(dns.rcode.REFUSED) yield response return resolver = dns.resolver.Resolver() # According to RFC we should query the server that sent the NOTIFY resolver.nameservers = [notify_addr] soa_answer = resolver.query(zone.name, 'SOA') soa_serial = soa_answer[0].serial if soa_serial == zone.serial: LOG.info( 'Serial %(serial)s is the same for master and us for ' '%(zone_id)s', { 'serial': soa_serial, 'zone_id': zone.id } ) else: LOG.info( 'Scheduling AXFR for %(zone_id)s from %(master_addr)s', { 'zone_id': zone.id, 'master_addr': master_addr.to_data() } ) self.tg.add_thread(self.zone_sync, context, zone, [master_addr]) response.flags |= dns.flags.AA yield response return def _zone_criterion_from_request(self, request, criterion=None): """Builds a bare criterion dict based on the request attributes""" criterion = criterion or {} tsigkey = request.environ.get('tsigkey') if tsigkey is None and CONF['service:mdns'].query_enforce_tsig: raise exceptions.Forbidden('Request is not TSIG signed') elif tsigkey is None: # Default to using the default_pool_id when no TSIG key is # available criterion['pool_id'] = CONF['service:central'].default_pool_id else: if tsigkey.scope == 'POOL': criterion['pool_id'] = tsigkey.resource_id elif tsigkey.scope == 'ZONE': criterion['id'] = tsigkey.resource_id else: raise NotImplementedError('Support for %s scoped TSIG Keys is ' 'not implemented') return criterion def _handle_axfr(self, request): context = request.environ['context'] q_rrset = request.question[0] # First check if there is an existing zone # TODO(vinod) once validation is separated from the api, # validate the parameters try: name = q_rrset.name.to_text() if six.PY3 and isinstance(name, bytes): name = name.decode('utf-8') criterion = self._zone_criterion_from_request( request, {'name': name}) zone = self.storage.find_zone(context, criterion) except exceptions.ZoneNotFound: LOG.warning('ZoneNotFound while handling axfr request. ' 'Question was %(qr)s', {'qr': q_rrset}) yield self._handle_query_error(request, dns.rcode.REFUSED) return except exceptions.Forbidden: LOG.warning('Forbidden while handling axfr request. ' 'Question was %(qr)s', {'qr': q_rrset}) yield self._handle_query_error(request, dns.rcode.REFUSED) return # The AXFR response needs to have a SOA at the beginning and end. criterion = {'zone_id': zone.id, 'type': 'SOA'} soa_records = self.storage.find_recordsets_axfr(context, criterion) # Get all the records other than SOA criterion = {'zone_id': zone.id, 'type': '!SOA'} records = self.storage.find_recordsets_axfr(context, criterion) # Place the SOA RRSet at the front and end of the RRSet list records.insert(0, soa_records[0]) records.append(soa_records[0]) # Render the results, yielding a packet after each TooBig exception. renderer = None while records: record = records.pop(0) rrname = str(record[3]) ttl = int(record[2]) if record[2] is not None else zone.ttl rrtype = str(record[1]) rdata = [str(record[4])] rrset = dns.rrset.from_text_list( rrname, ttl, dns.rdataclass.IN, rrtype, rdata, ) while True: try: if not renderer: renderer = self._create_axfr_renderer(request) renderer.add_rrset(dns.renderer.ANSWER, rrset) break except dns.exception.TooBig: if renderer.counts[dns.renderer.ANSWER] == 0: # We've received a TooBig from the first attempted # RRSet in this packet. Log a warning and abort the # AXFR. LOG.warning( 'Aborted AXFR of %(zone)s, a single RR ' '(%(rrset_type)s %(rrset_name)s) ' 'exceeded the max message size.', { 'zone': zone.name, 'rrset_type': rrtype, 'rrset_name': rrname, } ) yield self._handle_query_error( request, dns.rcode.SERVFAIL ) return yield self._finalize_packet(renderer, request) renderer = None if renderer: yield self._finalize_packet(renderer, request) return def _handle_record_query(self, request): """Handle a DNS QUERY request for a record""" context = request.environ['context'] response = dns.message.make_response(request) try: q_rrset = request.question[0] name = q_rrset.name.to_text() if six.PY3 and isinstance(name, bytes): name = name.decode('utf-8') # TODO(vinod) once validation is separated from the api, # validate the parameters criterion = { 'name': name, 'type': dns.rdatatype.to_text(q_rrset.rdtype), 'zones_deleted': False } recordset = self.storage.find_recordset(context, criterion) except exceptions.NotFound: # If an FQDN exists, like www.rackspace.com, but the specific # record type doesn't exist, like type SPF, then the return code # would be NOERROR and the SOA record is returned. This tells # caching nameservers that the FQDN does exist, so don't negatively # cache it, but the specific record doesn't exist. # # If an FQDN doesn't exist with any record type, that is NXDOMAIN. # However, an authoritative nameserver shouldn't return NXDOMAIN # for a zone it isn't authoritative for. It would be more # appropriate for it to return REFUSED. It should still return # NXDOMAIN if it is authoritative for a zone but the FQDN doesn't # exist, like abcdef.rackspace.com. Of course, a wildcard within a # zone would mean that NXDOMAIN isn't ever returned for a zone. # # To simply things currently this returns a REFUSED in all cases. # If zone transfers needs different errors, we could revisit this. LOG.info('NotFound, refusing. Question was %(qr)s', {'qr': q_rrset}) yield self._handle_query_error(request, dns.rcode.REFUSED) return except exceptions.Forbidden: LOG.info('Forbidden, refusing. Question was %(qr)s', {'qr': q_rrset}) yield self._handle_query_error(request, dns.rcode.REFUSED) return try: criterion = self._zone_criterion_from_request( request, {'id': recordset.zone_id}) zone = self.storage.find_zone(context, criterion) except exceptions.ZoneNotFound: LOG.warning('ZoneNotFound while handling query request. ' 'Question was %(qr)s', {'qr': q_rrset}) yield self._handle_query_error(request, dns.rcode.REFUSED) return except exceptions.Forbidden: LOG.warning('Forbidden while handling query request. ' 'Question was %(qr)s', {'qr': q_rrset}) yield self._handle_query_error(request, dns.rcode.REFUSED) return r_rrset = self._convert_to_rrset(zone, recordset) response.answer = [r_rrset] if r_rrset else [] response.set_rcode(dns.rcode.NOERROR) # For all the data stored in designate mdns is Authoritative response.flags |= dns.flags.AA yield response def _create_axfr_renderer(self, request): # Build up a dummy response, we're stealing it's logic for building # the Flags. response = dns.message.make_response(request) response.flags |= dns.flags.AA response.set_rcode(dns.rcode.NOERROR) max_message_size = self._get_max_message_size(request.had_tsig) renderer = dns.renderer.Renderer( response.id, response.flags, max_message_size) for q in request.question: renderer.add_question(q.name, q.rdtype, q.rdclass) return renderer @staticmethod def _convert_to_rrset(zone, recordset): # Fetch the zone or the config ttl if the recordset ttl is null ttl = recordset.ttl or zone.ttl # construct rdata from all the records # TODO(Ron): this should be handled in the Storage query where we # find the recordsets. rdata = [str(record.data) for record in recordset.records if record.action != 'DELETE'] # Now put the records into dnspython's RRsets # answer section has 1 RR set. If the RR set has multiple # records, DNSpython puts each record in a separate answer # section. # RRSet has name, ttl, class, type and rdata # The rdata has one or more records if not rdata: return None return dns.rrset.from_text_list( recordset.name, ttl, dns.rdataclass.IN, recordset.type, rdata) @staticmethod def _finalize_packet(renderer, request): renderer.write_header() if request.had_tsig: # Make the space we reserved for TSIG available for use renderer.max_size += TSIG_RRSIZE renderer.add_tsig( request.keyname, request.keyring[request.keyname], request.fudge, request.original_id, request.tsig_error, request.other_data, request.mac, request.keyalgorithm ) return renderer @staticmethod def _get_max_message_size(had_tsig): max_message_size = CONF['service:mdns'].max_message_size if max_message_size > 65535: LOG.warning('MDNS max message size must not be greater than 65535') max_message_size = 65535 if had_tsig: # Make some room for the TSIG RR to be appended at the end of the # rendered message. max_message_size = max_message_size - TSIG_RRSIZE return max_message_size @staticmethod def _handle_query_error(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