From 24b5762e28be90130306c82ed4bfbf885d1581ef Mon Sep 17 00:00:00 2001 From: Kiall Mac Innes Date: Mon, 2 Mar 2015 22:36:58 +0000 Subject: [PATCH] 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 --- contrib/devstack/lib/designate | 20 ++- contrib/dns_dump_raw.py | 29 +++++ designate/agent/service.py | 1 - designate/context.py | 31 ++++- designate/dnsutils.py | 149 +++++++++++++++------ designate/mdns/__init__.py | 3 + designate/mdns/handler.py | 110 ++++++++++++---- designate/mdns/service.py | 14 +- designate/tests/test_mdns/test_handler.py | 150 +++++++++++++++++++++- 9 files changed, 426 insertions(+), 81 deletions(-) create mode 100755 contrib/dns_dump_raw.py diff --git a/contrib/devstack/lib/designate b/contrib/devstack/lib/designate index f2c12ad5..48092a90 100644 --- a/contrib/devstack/lib/designate +++ b/contrib/devstack/lib/designate @@ -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 diff --git a/contrib/dns_dump_raw.py b/contrib/dns_dump_raw.py new file mode 100755 index 00000000..e89835fc --- /dev/null +++ b/contrib/dns_dump_raw.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# 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. +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()) diff --git a/designate/agent/service.py b/designate/agent/service.py index 81ed9f0d..18a34f5b 100644 --- a/designate/agent/service.py +++ b/designate/agent/service.py @@ -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 diff --git a/designate/context.py b/designate/context.py index 9c40719e..88093d4f 100644 --- a/designate/context.py +++ b/designate/context.py @@ -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() diff --git a/designate/dnsutils.py b/designate/dnsutils.py index 1d4552ef..52b25c8c 100644 --- a/designate/dnsutils.py +++ b/designate/dnsutils.py @@ -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 diff --git a/designate/mdns/__init__.py b/designate/mdns/__init__.py index 0296a628..6e602c25 100644 --- a/designate/mdns/__init__.py +++ b/designate/mdns/__init__.py @@ -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'), ] diff --git a/designate/mdns/handler.py b/designate/mdns/handler.py index 380928db..c28706e5 100644 --- a/designate/mdns/handler.py +++ b/designate/mdns/handler.py @@ -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 diff --git a/designate/mdns/service.py b/designate/mdns/service.py index 8a873087..f9b58430 100644 --- a/designate/mdns/service.py +++ b/designate/mdns/service.py @@ -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 diff --git a/designate/tests/test_mdns/test_handler.py b/designate/tests/test_mdns/test_handler.py index b01e71da..01828cbc 100644 --- a/designate/tests/test_mdns/test_handler.py +++ b/designate/tests/test_mdns/test_handler.py @@ -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))