Implement TSIG Support in mDNS

Implements support for scoped TSIG Keys in MiniDNS. When a non-tsig
signed request is received, we assume the default pool should be
used.

Change-Id: I0b5ab727fba526724e44894bb7b84855e3ec0351
Implements: blueprint mdns-designate-mdns-tsig
This commit is contained in:
Kiall Mac Innes 2015-03-02 22:36:58 +00:00
parent f10d10a724
commit 24b5762e28
9 changed files with 426 additions and 81 deletions

View File

@ -67,6 +67,24 @@ if is_service_enabled designate && [[ -r $DESIGNATE_PLUGINS/backend-$DESIGNATE_B
source $DESIGNATE_PLUGINS/backend-$DESIGNATE_BACKEND_DRIVER
fi
# Helper Functions
# ----------------
function setup_colorized_logging_designate {
local conf_file=$1
local conf_section=$2
local project_var=${3:-"project_name"}
local user_var=${4:-"user_name"}
setup_colorized_logging $conf_file $conf_section $project_var $user_var
# Override the logging_context_format_string value chosen by
# setup_colorized_logging.
iniset $conf_file $conf_section logging_context_format_string "%(asctime)s.%(msecs)03d %(color)s%(levelname)s %(name)s [%(request_id)s %(user_identity)s%(color)s] %(instance)s%(color)s%(message)s"
}
# DevStack Plugin
# ---------------
# cleanup_designate - Remove residual data files, anything left over from previous
# runs that a clean run would need to clean up
function cleanup_designate {
@ -115,7 +133,7 @@ function configure_designate {
# Format logging
if [ "$LOG_COLOR" == "True" ] && [ "$SYSLOG" == "False" ]; then
setup_colorized_logging $DESIGNATE_CONF DEFAULT "tenant" "user"
setup_colorized_logging_designate $DESIGNATE_CONF DEFAULT "tenant" "user"
fi
if is_service_enabled key; then

29
contrib/dns_dump_raw.py Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env 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 sys
import binascii
import dns
import dns.message
wire = sys.argv[1]
# Prepare the Message
message = dns.message.from_wire(binascii.a2b_hex(wire))
# Print the test representation of the message
print(message.to_text())

View File

@ -43,7 +43,6 @@ class Service(service.DNSService, service.Service):
def _dns_application(self):
# Create an instance of the RequestHandler class
application = handler.RequestHandler()
application = dnsutils.ContextMiddleware(application)
application = dnsutils.SerializationMiddleware(application)
return application

View File

@ -34,12 +34,10 @@ class DesignateContext(context.RequestContext):
user_domain=None, project_domain=None, is_admin=False,
read_only=False, show_deleted=False, request_id=None,
resource_uuid=None, overwrite=True, roles=None,
service_catalog=None, all_tenants=False, user_identity=None,
abandon=None):
service_catalog=None, all_tenants=False, abandon=None,
tsigkey_id=None, user_identity=None):
# NOTE: user_identity may be passed in, but will be silently dropped as
# it is a generated field based on several others.
roles = roles or []
super(DesignateContext, self).__init__(
auth_token=auth_token,
user=user,
@ -54,8 +52,9 @@ class DesignateContext(context.RequestContext):
resource_uuid=resource_uuid,
overwrite=overwrite)
self.roles = roles
self.roles = roles or []
self.service_catalog = service_catalog
self.tsigkey_id = tsigkey_id
self.all_tenants = all_tenants
self.abandon = abandon
@ -68,11 +67,29 @@ class DesignateContext(context.RequestContext):
def to_dict(self):
d = super(DesignateContext, self).to_dict()
# Override the user_identity field to account for TSIG. When a TSIG key
# is used as authentication e.g. via MiniDNS, it will act as a form
# of "user",
user = self.user or '-'
if self.tsigkey_id and not self.user:
user = 'TSIG:%s' % self.tsigkey_id
user_idt = (
self.user_idt_format.format(user=user,
tenant=self.tenant or '-',
domain=self.domain or '-',
user_domain=self.user_domain or '-',
p_domain=self.project_domain or '-'))
# Update the dict with Designate specific extensions and overrides
d.update({
'user_identity': user_idt,
'roles': self.roles,
'service_catalog': self.service_catalog,
'all_tenants': self.all_tenants,
'abandon': self.abandon,
'tsigkey_id': self.tsigkey_id
})
return copy.deepcopy(d)
@ -136,3 +153,7 @@ class DesignateContext(context.RequestContext):
if value:
policy.check('abandon_domain', self)
self._abandon = value
def get_current():
return context.get_current()

View File

@ -14,6 +14,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import socket
import base64
import dns
import dns.zone
@ -29,40 +30,6 @@ from designate.i18n import _LI
LOG = logging.getLogger(__name__)
class SerializationMiddleware(object):
"""DNS Middleware to serialize/deserialize DNS Packets"""
def __init__(self, application):
self.application = application
def __call__(self, request):
try:
message = dns.message.from_wire(request['payload'])
# Create + Attach the initial "environ" dict. This is similar to
# the environ dict used in typical WSGI middleware.
message.environ = {'addr': request['addr']}
except dns.exception.DNSException:
LOG.error(_LE("Failed to deserialize packet from %(host)s:"
"%(port)d") % {'host': request['addr'][0],
'port': request['addr'][1]})
# 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:
# Hand the Deserialized packet on
response = self.application(message)
# Serialize and return the response if present
if response is not None:
return response.to_wire()
class DNSMiddleware(object):
"""Base DNS Middleware class with some utility methods"""
def __init__(self, application):
@ -90,21 +57,119 @@ class DNSMiddleware(object):
response = self.application(request)
return self.process_response(response)
def _build_error_response(self):
response = dns.message.make_response(
dns.message.make_query('unknown', dns.rdatatype.A))
response.set_rcode(dns.rcode.FORMERR)
class ContextMiddleware(DNSMiddleware):
"""Temporary ContextMiddleware which attaches an admin context to every
request
return response
This will be replaced with a piece of middleware which generates, from
a TSIG signed request, an appropriate Request Context.
"""
def process_request(self, request):
class SerializationMiddleware(DNSMiddleware):
"""DNS Middleware to serialize/deserialize DNS Packets"""
def __init__(self, application, tsig_keyring=None):
self.application = application
self.tsig_keyring = tsig_keyring
def __call__(self, request):
# Generate the initial context. This may be updated by other middleware
# as we learn more information about the Request.
ctxt = context.DesignateContext.get_admin_context(all_tenants=True)
request.environ['context'] = ctxt
try:
message = dns.message.from_wire(request['payload'],
self.tsig_keyring)
if message.had_tsig:
LOG.debug('Request signed with TSIG key: %s', message.keyname)
# Create + Attach the initial "environ" dict. This is similar to
# the environ dict used in typical WSGI middleware.
message.environ = {
'context': ctxt,
'addr': request['addr'],
}
except dns.message.UnknownTSIGKey:
LOG.error(_LE("Unknown TSIG key from %(host)s:"
"%(port)d") % {'host': request['addr'][0],
'port': request['addr'][1]})
response = self._build_error_response()
except dns.tsig.BadSignature:
LOG.error(_LE("Invalid TSIG signature from %(host)s:"
"%(port)d") % {'host': request['addr'][0],
'port': request['addr'][1]})
response = self._build_error_response()
except dns.exception.DNSException:
LOG.error(_LE("Failed to deserialize packet from %(host)s:"
"%(port)d") % {'host': request['addr'][0],
'port': request['addr'][1]})
response = self._build_error_response()
else:
# Hand the Deserialized packet onto the Application
response = self.application(message)
# Serialize and return the response if present
if response is not None:
return response.to_wire()
class TsigInfoMiddleware(DNSMiddleware):
"""Middleware which looks up the information available for a TsigKey"""
def __init__(self, application, storage):
super(TsigInfoMiddleware, self).__init__(application)
self.storage = storage
def process_request(self, request):
if not request.had_tsig:
return None
try:
criterion = {'name': request.keyname.to_text(True)}
tsigkey = self.storage.find_tsigkey(
context.get_current(), criterion)
request.environ['tsigkey'] = tsigkey
request.environ['context'].tsigkey_id = tsigkey.id
except exceptions.TsigKeyNotFound:
# This should never happen, as we just validated the key.. Except
# for race conditions..
return self._build_error_response()
return None
class TsigKeyring(object):
"""Implements the DNSPython KeyRing API, backed by the Designate DB"""
def __init__(self, storage):
self.storage = storage
def __getitem__(self, key):
return self.get(key)
def get(self, key, default=None):
try:
criterion = {'name': key.to_text(True)}
tsigkey = self.storage.find_tsigkey(
context.get_current(), criterion)
return base64.decodestring(tsigkey.secret)
except exceptions.TsigKeyNotFound:
return default
def from_dnspython_zone(dnspython_zone):
# dnspython never builds a zone with more than one SOA, even if we give
# it a zonefile that contains more than one

View File

@ -32,6 +32,9 @@ OPTS = [
help='mDNS TCP Receive Timeout'),
cfg.BoolOpt('all-tcp', default=False,
help='Send all traffic over TCP'),
cfg.BoolOpt('query-enforce-tsig', default=False,
help='Enforce all incoming queries (including AXFR) are TSIG '
'signed'),
cfg.StrOpt('storage-driver', default='sqlalchemy',
help='The storage driver to use'),
]

View File

@ -24,27 +24,28 @@ from oslo.config import cfg
from oslo_log import log as logging
from designate import exceptions
from designate import storage
from designate.i18n import _LE
from designate.i18n import _LW
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
CONF.import_opt('default_pool_id', 'designate.central',
group='service:central')
class RequestHandler(object):
def __init__(self):
# Get a storage connection
storage_driver = cfg.CONF['service:mdns'].storage_driver
self.storage = storage.get_storage(storage_driver)
"""MiniDNS Request Handler"""
# TODO(kiall): This class is getting a little unwieldy, we should rework
# with a little more structure.
def __init__(self, storage):
self.storage = storage
def __call__(self, request):
"""
:param request: DNS Request Message
:return: DNS Response Message
"""
context = request.environ['context']
if request.opcode() == dns.opcode.QUERY:
# Currently we expect exactly 1 question in the section
# TSIG places the pseudo records into the additional section.
@ -58,9 +59,9 @@ class RequestHandler(object):
# receiving an IXFR request.
# TODO(Ron): send IXFR response when receiving IXFR request.
if q_rrset.rdtype in (dns.rdatatype.AXFR, dns.rdatatype.IXFR):
response = self._handle_axfr(context, request)
response = self._handle_axfr(request)
else:
response = self._handle_record_query(context, request)
response = self._handle_record_query(request)
else:
# Unhandled OpCode's include STATUS, IQUERY, NOTIFY, UPDATE
response = self._handle_query_error(request, dns.rcode.REFUSED)
@ -79,18 +80,39 @@ class RequestHandler(object):
return response
def _convert_to_rrset(self, context, recordset, domain=None):
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
elif domain is not None:
ttl = domain.ttl
else:
domain = self.storage.get_domain(context, recordset.domain_id)
if domain.ttl:
ttl = domain.ttl
else:
ttl = CONF.default_ttl
ttl = domain.ttl
# construct rdata from all the records
rdata = []
@ -113,18 +135,28 @@ class RequestHandler(object):
return r_rrset
def _handle_axfr(self, context, request):
def _handle_axfr(self, request):
context = request.environ['context']
response = dns.message.make_response(request)
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
criterion = {'name': q_rrset.name.to_text()}
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.exception(_LE("got exception while handling axfr request. "
"Question is %(qr)s") % {'qr': q_rrset})
LOG.warning(_LW("DomainNotFound while handling axfr request. "
"Question was %(qr)s") % {'qr': q_rrset})
return self._handle_query_error(request, dns.rcode.REFUSED)
except exceptions.Forbidden:
LOG.warning(_LW("Forbidden while handling axfr request. "
"Question was %(qr)s") % {'qr': q_rrset})
return self._handle_query_error(request, dns.rcode.REFUSED)
@ -135,20 +167,20 @@ class RequestHandler(object):
soa_recordsets = self.storage.find_recordsets(context, criterion)
for recordset in soa_recordsets:
r_rrsets.append(self._convert_to_rrset(context, recordset, domain))
r_rrsets.append(self._convert_to_rrset(domain, recordset))
# Get all the recordsets other than SOA
criterion = {'domain_id': domain.id, 'type': '!SOA'}
recordsets = self.storage.find_recordsets(context, criterion)
for recordset in recordsets:
r_rrset = self._convert_to_rrset(context, recordset, domain)
r_rrset = self._convert_to_rrset(domain, recordset)
if r_rrset:
r_rrsets.append(r_rrset)
# Append the SOA recordset at the end
for recordset in soa_recordsets:
r_rrsets.append(self._convert_to_rrset(context, recordset, domain))
r_rrsets.append(self._convert_to_rrset(domain, recordset))
response.set_rcode(dns.rcode.NOERROR)
# TODO(vinod) check if we dnspython has an upper limit on the number
@ -159,9 +191,11 @@ class RequestHandler(object):
return response
def _handle_record_query(self, context, request):
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,
@ -172,11 +206,30 @@ class RequestHandler(object):
'domains_deleted': False
}
recordset = self.storage.find_recordset(context, criterion)
r_rrset = self._convert_to_rrset(context, recordset)
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})
return self._handle_query_error(request, dns.rcode.REFUSED)
except exceptions.Forbidden:
LOG.warning(_LW("Forbidden while handling query request. "
"Question was %(qr)s") % {'qr': q_rrset})
return self._handle_query_error(request, dns.rcode.REFUSED)
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
@ -196,4 +249,7 @@ class RequestHandler(object):
# 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)
return response

View File

@ -18,6 +18,7 @@ from oslo_log import log as logging
from designate import utils
from designate import service
from designate import storage
from designate import dnsutils
from designate.mdns import handler
from designate.mdns import notify
@ -30,6 +31,9 @@ class Service(service.DNSService, service.RPCService, service.Service):
def __init__(self, threads=None):
super(Service, self).__init__(threads=threads)
# Get a storage connection
self.storage = storage.get_storage(CONF['service:mdns'].storage_driver)
@property
def service_name(self):
return 'mdns'
@ -42,9 +46,11 @@ class Service(service.DNSService, service.RPCService, service.Service):
@property
@utils.cache_result
def _dns_application(self):
# Create an instance of the RequestHandler class
application = handler.RequestHandler()
application = dnsutils.ContextMiddleware(application)
application = dnsutils.SerializationMiddleware(application)
# Create an instance of the RequestHandler class and wrap with
# necessary middleware.
application = handler.RequestHandler(self.storage)
application = dnsutils.TsigInfoMiddleware(application, self.storage)
application = dnsutils.SerializationMiddleware(
application, dnsutils.TsigKeyring(self.storage))
return application

View File

@ -16,20 +16,42 @@
import binascii
import dns
from oslo.config import cfg
from designate import context
from designate.tests.test_mdns import MdnsTestCase
from designate.mdns import handler
CONF = cfg.CONF
default_pool_id = CONF['service:central'].default_pool_id
class MdnsRequestHandlerTest(MdnsTestCase):
def setUp(self):
super(MdnsRequestHandlerTest, self).setUp()
self.handler = handler.RequestHandler()
self.handler = handler.RequestHandler(self.storage)
self.addr = ["0.0.0.0", 5556]
self.context = context.DesignateContext.get_admin_context(
all_tenants=True)
# Create a TSIG Key for the default pool, and another for some other
# pool.
self.tsigkey_pool_default = self.create_tsigkey(
name='default-pool',
scope='POOL',
resource_id=default_pool_id)
self.tsigkey_pool_unknown = self.create_tsigkey(
name='unknown-pool',
scope='POOL',
resource_id='628e55a0-c724-4767-8c59-0a61c15d3444')
self.tsigkey_zone_unknown = self.create_tsigkey(
name='unknown-zone',
scope='ZONE',
resource_id='82fd08be-9eb7-4d94-8267-a26f8348671d')
def test_dispatch_opcode_iquery(self):
# DNS packet with IQUERY opcode
payload = "271109000001000000000000076578616d706c6503636f6d0000010001"
@ -284,3 +306,129 @@ class MdnsRequestHandlerTest(MdnsTestCase):
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
def test_dispatch_opcode_query_tsig_scope_pool(self):
# Create a domain/recordset/record to query
domain = self.create_domain(name='example.com.')
recordset = self.create_recordset(
domain, name='example.com.', type='A')
self.create_record(
domain, recordset, data='192.0.2.5')
# DNS packet with QUERY opcode for A example.com.
payload = ("c28901200001000000000001076578616d706c6503636f6d0000010001"
"0000291000000000000000")
request = dns.message.from_wire(binascii.a2b_hex(payload))
request.environ = {
'addr': self.addr,
'context': self.context,
'tsigkey': self.tsigkey_pool_default,
}
# Ensure the Query, with the correct pool's TSIG, gives a NOERROR.
# id 49801
# opcode QUERY
# rcode NOERROR
# flags QR AA RD
# edns 0
# payload 8192
# ;QUESTION
# example.com. IN A
# ;ANSWER
# example.com. 3600 IN A 192.0.2.5
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("c28985000001000100000001076578616d706c6503636f6d"
"0000010001c00c0001000100000e100004c0000205000029"
"2000000000000000")
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
# Ensure the Query, with the incorrect pool's TSIG, gives a REFUSED
request.environ['tsigkey'] = self.tsigkey_pool_unknown
# id 49801
# opcode QUERY
# rcode REFUSED
# flags QR RD
# edns 0
# payload 8192
# ;QUESTION
# example.com. IN A
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("c28981050001000000000001076578616d706c6503636f6d"
"00000100010000292000000000000000")
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
def test_dispatch_opcode_query_tsig_scope_zone(self):
# Create a domain/recordset/record to query
domain = self.create_domain(name='example.com.')
recordset = self.create_recordset(
domain, name='example.com.', type='A')
self.create_record(
domain, recordset, data='192.0.2.5')
# Create a TSIG Key Matching the zone
tsigkey_zone_known = self.create_tsigkey(
name='known-zone',
scope='ZONE',
resource_id=domain.id)
# DNS packet with QUERY opcode for A example.com.
payload = ("c28901200001000000000001076578616d706c6503636f6d0000010001"
"0000291000000000000000")
request = dns.message.from_wire(binascii.a2b_hex(payload))
request.environ = {
'addr': self.addr,
'context': self.context,
'tsigkey': tsigkey_zone_known,
}
# Ensure the Query, with the correct zone's TSIG, gives a NOERROR.
# id 49801
# opcode QUERY
# rcode NOERROR
# flags QR AA RD
# edns 0
# payload 8192
# ;QUESTION
# example.com. IN A
# ;ANSWER
# example.com. 3600 IN A 192.0.2.5
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("c28985000001000100000001076578616d706c6503636f6d"
"0000010001c00c0001000100000e100004c0000205000029"
"2000000000000000")
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))
# Ensure the Query, with the incorrect zone's TSIG, gives a REFUSED
request.environ['tsigkey'] = self.tsigkey_zone_unknown
# id 49801
# opcode QUERY
# rcode REFUSED
# flags QR RD
# edns 0
# payload 8192
# ;QUESTION
# example.com. IN A
# ;ANSWER
# ;AUTHORITY
# ;ADDITIONAL
expected_response = ("c28981050001000000000001076578616d706c6503636f6d"
"00000100010000292000000000000000")
response = self.handler(request).to_wire()
self.assertEqual(expected_response, binascii.b2a_hex(response))