deb-designate/designate/mdns/handler.py

434 lines
16 KiB
Python

# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hp.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.opcode
import dns.rcode
import dns.rdataclass
import dns.rdatatype
import dns.message
from oslo_config import cfg
from oslo_log import log as logging
from designate import exceptions
from designate.mdns import xfr
from designate.central import rpcapi as central_api
from designate.i18n import _LI
from designate.i18n import _LW
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
class RequestHandler(xfr.XFRMixin):
def __init__(self, storage, tg):
# Get a storage connection
self.storage = storage
self.tg = tg
@property
def central_api(self):
return central_api.CentralAPI.get_instance()
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):
yield self._handle_query_error(request, dns.rcode.REFUSED)
raise StopIteration
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.
# TODO(Ron): send IXFR response when receiving IXFR request.
if q_rrset.rdtype in (dns.rdatatype.AXFR, dns.rdatatype.IXFR):
for response in self._handle_axfr(request):
yield response
raise StopIteration
else:
for response in self._handle_record_query(request):
yield response
raise StopIteration
elif request.opcode() == dns.opcode.NOTIFY:
for response in self._handle_notify(request):
yield response
raise StopIteration
else:
# Unhandled OpCode's include STATUS, IQUERY, NOTIFY, UPDATE
yield self._handle_query_error(request, dns.rcode.REFUSED)
raise StopIteration
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
raise StopIteration
else:
question = request.question[0]
criterion = {
'name': question.name.to_text(),
'type': 'SECONDARY',
'deleted': False
}
try:
domain = self.storage.find_domain(context, criterion)
except exceptions.DomainNotFound:
response.set_rcode(dns.rcode.NOTAUTH)
yield response
raise StopIteration
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 = domain.get_master_by_ip(notify_addr)
if not master_addr:
msg = _LW("NOTIFY for %(name)s from non-master server "
"%(addr)s, ignoring.")
LOG.warn(msg % {"name": domain.name, "addr": notify_addr})
response.set_rcode(dns.rcode.REFUSED)
yield response
raise StopIteration
resolver = dns.resolver.Resolver()
# According to RFC we should query the server that sent the NOTIFY
resolver.nameservers = [notify_addr]
soa_answer = resolver.query(domain.name, 'SOA')
soa_serial = soa_answer[0].serial
if soa_serial == domain.serial:
msg = _LI("Serial %(serial)s is the same for master and us for "
"%(domain_id)s")
LOG.info(msg % {"serial": soa_serial, "domain_id": domain.id})
else:
msg = _LI("Scheduling AXFR for %(domain_id)s from %(master_addr)s")
info = {"domain_id": domain.id, "master_addr": master_addr}
LOG.info(msg % info)
self.tg.add_thread(self.domain_sync, context, domain,
[master_addr])
response.flags |= dns.flags.AA
yield response
raise StopIteration
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 _domain_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 _convert_to_rrset(self, domain, recordset):
# Fetch the domain or the config ttl if the recordset ttl is null
if recordset.ttl:
ttl = recordset.ttl
else:
ttl = domain.ttl
# construct rdata from all the records
rdata = []
for record in recordset.records:
# TODO(Ron): this should be handled in the Storage query where we
# find the recordsets.
if record.action != 'DELETE':
rdata.append(str(record.data))
# 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
r_rrset = None
if rdata:
r_rrset = dns.rrset.from_text_list(
recordset.name, ttl, dns.rdataclass.IN, recordset.type, rdata)
return r_rrset
def _prep_rrsets(self, raw_records, domain_ttl):
rrsets = []
rrset_id = None
current_rrset = None
for record in raw_records:
# If we're looking at the first, or a new rrset
if record[0] != rrset_id:
if current_rrset is not None:
# If this isn't the first iteration
rrsets.append(current_rrset)
# Set up a new rrset
rrset_id = record[0]
rrtype = str(record[1])
# gross
ttl = int(record[2]) if record[2] is not None else domain_ttl
name = str(record[3])
rdata = str(record[4])
current_rrset = dns.rrset.from_text_list(
name, ttl, dns.rdataclass.IN, rrtype, [rdata])
else:
# We've already got an rrset, add the rdata
rrtype = str(record[1])
rdata = str(record[4])
rd = dns.rdata.from_text(dns.rdataclass.IN,
dns.rdatatype.from_text(rrtype), rdata)
current_rrset.add(rd)
# If the last record examined was a new rrset, or there is only 1 rrset
if rrsets == [] or (rrsets != [] and rrsets[-1] != current_rrset):
if current_rrset is not None:
rrsets.append(current_rrset)
return rrsets
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:
criterion = self._domain_criterion_from_request(
request, {'name': q_rrset.name.to_text()})
domain = self.storage.find_domain(context, criterion)
except exceptions.DomainNotFound:
LOG.warning(_LW("DomainNotFound while handling axfr request. "
"Question was %(qr)s") % {'qr': q_rrset})
yield self._handle_query_error(request, dns.rcode.REFUSED)
raise StopIteration
except exceptions.Forbidden:
LOG.warning(_LW("Forbidden while handling axfr request. "
"Question was %(qr)s") % {'qr': q_rrset})
yield self._handle_query_error(request, dns.rcode.REFUSED)
raise StopIteration
# The AXFR response needs to have a SOA at the beginning and end.
criterion = {'domain_id': domain.id, 'type': 'SOA'}
soa_records = self.storage.find_recordsets_axfr(context, criterion)
# Get all the records other than SOA
criterion = {'domain_id': domain.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])
# Build the DNSPython RRSets from the Records
rrsets = self._prep_rrsets(records, domain.ttl)
# 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 = CONF['service:mdns'].max_message_size
if max_message_size > 65535:
LOG.warning(_LW('MDNS max message size must not be greater than '
'65535'))
max_message_size = 65535
if request.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
# Render the results, yielding a packet after each TooBig exception.
i, renderer = 0, None
while i < len(rrsets):
# No renderer? Build one
if renderer is None:
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)
try:
renderer.add_rrset(dns.renderer.ANSWER, rrsets[i])
i += 1
except dns.exception.TooBig:
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.request_mac,
request.keyalgorithm)
yield renderer
renderer = None
if renderer is not None:
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.request_mac,
request.keyalgorithm)
yield renderer
raise StopIteration
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]
# TODO(vinod) once validation is separated from the api,
# validate the parameters
criterion = {
'name': q_rrset.name.to_text(),
'type': dns.rdatatype.to_text(q_rrset.rdtype),
'domains_deleted': False
}
recordset = self.storage.find_recordset(context, criterion)
try:
criterion = self._domain_criterion_from_request(
request, {'id': recordset.domain_id})
domain = self.storage.find_domain(context, criterion)
except exceptions.DomainNotFound:
LOG.warning(_LW("DomainNotFound while handling query request"
". Question was %(qr)s") % {'qr': q_rrset})
yield self._handle_query_error(request, dns.rcode.REFUSED)
raise StopIteration
except exceptions.Forbidden:
LOG.warning(_LW("Forbidden while handling query request. "
"Question was %(qr)s") % {'qr': q_rrset})
yield self._handle_query_error(request, dns.rcode.REFUSED)
raise StopIteration
r_rrset = self._convert_to_rrset(domain, recordset)
response.set_rcode(dns.rcode.NOERROR)
response.answer = [r_rrset]
# For all the data stored in designate mdns is Authoritative
response.flags |= dns.flags.AA
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 domain but the FQDN doesn't
# exist, like abcdef.rackspace.com. Of course, a wildcard within a
# domain would mean that NXDOMAIN isn't ever returned for a domain.
#
# To simply things currently this returns a REFUSED in all cases.
# If zone transfers needs different errors, we could revisit this.
response.set_rcode(dns.rcode.REFUSED)
except exceptions.Forbidden:
response.set_rcode(dns.rcode.REFUSED)
yield response