From: Peter van Dijk Date: Tue, 24 Aug 2021 08:44:44 +0000 (+0200) Subject: auth: incoming PROXY support for: X-Git-Tag: dnsdist-1.7.0-alpha1~14^2~12 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=4172a5b2eb4bbfb4bd9eead3c56cbf6334a695c0;p=thirdparty%2Fpdns.git auth: incoming PROXY support for: * AXFR ACLs * NOTIFY sources * getting the remote address in LUA records --- diff --git a/modules/remotebackend/Makefile.am b/modules/remotebackend/Makefile.am index c544005905..f1aaa95e0c 100644 --- a/modules/remotebackend/Makefile.am +++ b/modules/remotebackend/Makefile.am @@ -4,6 +4,10 @@ AM_CPPFLAGS += \ $(LIBCRYPTO_CFLAGS) \ $(LIBZMQ_CFLAGS) +if LUA +AM_CPPFLAGS +=$(LUA_CFLAGS) +endif + AM_LDFLAGS = $(THREADFLAGS) JSON11_LIBS = $(top_builddir)/ext/json11/libjson11.la diff --git a/pdns/Makefile.am b/pdns/Makefile.am index a17aa95e0a..20113ebeeb 100644 --- a/pdns/Makefile.am +++ b/pdns/Makefile.am @@ -239,6 +239,7 @@ pdns_server_SOURCES = \ packetcache.hh \ packethandler.cc packethandler.hh \ pdnsexception.hh \ + proxy-protocol.cc proxy-protocol.hh \ qtype.cc qtype.hh \ query-local-address.hh query-local-address.cc \ rcpgenerator.cc \ diff --git a/pdns/common_startup.cc b/pdns/common_startup.cc index afc9b0b685..e92fc3d3d3 100644 --- a/pdns/common_startup.cc +++ b/pdns/common_startup.cc @@ -64,6 +64,8 @@ double avg_latency{0.0}; unique_ptr TN; static vector g_distributors; vector > g_udpReceivers; +NetmaskGroup g_proxyProtocolACL; +size_t g_proxyProtocolMaximumSize; ArgvMap &arg() { @@ -93,6 +95,8 @@ void declareArguments() ::arg().setSwitch("dnsupdate","Enable/Disable DNS update (RFC2136) support. Default is no.")="no"; ::arg().setSwitch("write-pid","Write a PID file")="yes"; ::arg().set("allow-dnsupdate-from","A global setting to allow DNS updates from these IP ranges.")="127.0.0.0/8,::1"; + ::arg().set("proxy-protocol-from","A Proxy Protocol header is only allowed from these subnets, and is mandatory then too.")=""; + ::arg().set("proxy-protocol-maximum-size", "The maximum size of a proxy protocol payload, including the TLV values")="512"; ::arg().setSwitch("send-signed-notify", "Send TSIG secured NOTIFY if TSIG key is configured for a zone") = "yes"; ::arg().set("allow-unsigned-notify", "Allow unsigned notifications for TSIG secured zones") = "yes"; //FIXME: change to 'no' later ::arg().set("allow-unsigned-supermaster", "Allow supermasters to create zones without TSIG signed NOTIFY")="yes"; @@ -435,7 +439,6 @@ try bool logDNSQueries = ::arg().mustDo("log-dns-queries"); shared_ptr NS; std::string buffer; - buffer.resize(DNSPacket::s_udpTruncationThreshold); // If we have SO_REUSEPORT then create a new port for all receiver threads // other than the first one. @@ -449,6 +452,13 @@ try } for(;;) { + if (g_proxyProtocolACL.empty()) { + buffer.resize(DNSPacket::s_udpTruncationThreshold); + } + else { + buffer.resize(DNSPacket::s_udpTruncationThreshold + g_proxyProtocolMaximumSize); + } + if(!NS->receive(question, buffer)) { // receive a packet inline continue; // packet was broken, try again } @@ -469,12 +479,7 @@ try S.ringAccount("queries", question.qdomain, question.qtype); S.ringAccount("remotes", question.d_remote); if(logDNSQueries) { - string remote; - if(question.hasEDNSSubnet()) - remote = question.getRemote().toString() + "<-" + question.getRealRemote().toString(); - else - remote = question.getRemote().toString(); - g_log << Logger::Notice<<"Remote "<< remote <<" wants '" << question.qdomain<<"|"<toString() + ")"; + } + + if(hasEDNSSubnet()) { + ret += "<-" + getRealRemote().toString(); + } + + return ret; +} + +string DNSPacket::getRemoteStringWithPort() const +{ + string ret; + + ret = getRemote().toStringWithPort(); + + if (d_inner_remote) { + ret += "(" + d_inner_remote->toStringWithPort() + ")"; + } + + if(hasEDNSSubnet()) { + ret += "<-" + getRealRemote().toString(); + } + + return ret; +} + ComboAddress DNSPacket::getRemote() const { return d_remote; } +ComboAddress DNSPacket::getInnerRemote() const +{ + if (d_inner_remote) + return *d_inner_remote; + return d_remote; +} + uint16_t DNSPacket::getRemotePort() const { return d_remote.sin4.sin_port; @@ -369,6 +410,7 @@ std::unique_ptr DNSPacket::replyPacket() const r->setSocket(d_socket); r->d_anyLocal=d_anyLocal; r->setRemote(&d_remote); + r->d_inner_remote=d_inner_remote; r->setAnswer(true); // this implies the allocation of the header r->setA(true); // and we are authoritative r->setRA(false); // no recursion available @@ -423,7 +465,7 @@ int DNSPacket::noparse(const char *mesg, size_t length) d_rawpacket.assign(mesg,length); if(length < 12) { g_log << Logger::Debug << "Ignoring packet: too short ("< inner) { - d_remote=*s; + d_remote=*outer; + if (inner) { + d_inner_remote=*inner; + } + else { + d_inner_remote.reset(); + } } bool DNSPacket::hasEDNSSubnet() const @@ -612,7 +660,7 @@ Netmask DNSPacket::getRealRemote() const { if(d_haveednssubnet) return d_eso.source; - return Netmask(d_remote); + return Netmask(getInnerRemote()); } void DNSPacket::setSocket(Utility::sock_t sock) diff --git a/pdns/dnspacket.hh b/pdns/dnspacket.hh index 4456847d6d..0300b8e53c 100644 --- a/pdns/dnspacket.hh +++ b/pdns/dnspacket.hh @@ -61,8 +61,9 @@ public: const string& getString(); //!< for serialization - just passes the whole packet // address & socket manipulation - void setRemote(const ComboAddress*); + void setRemote(const ComboAddress*, std::optional = std::nullopt); ComboAddress getRemote() const; + ComboAddress getInnerRemote() const; // for proxy protocol Netmask getRealRemote() const; ComboAddress getLocal() const { @@ -73,6 +74,9 @@ public: } uint16_t getRemotePort() const; + string getRemoteString() const; + string getRemoteStringWithPort() const; + boost::optional d_anyLocal; Utility::sock_t getSocket() const @@ -81,7 +85,6 @@ public: } void setSocket(Utility::sock_t sock); - // these manipulate 'd' void setA(bool); //!< make this packet authoritative - manipulates 'd' void setID(uint16_t); //!< set the DNS id of this packet - manipulates 'd' @@ -144,6 +147,7 @@ public: TSIGRecordContent d_trc; //72 ComboAddress d_remote; //28 + std::optional d_inner_remote; // for proxy protocol TSIGHashEnum d_tsig_algo{TSIG_MD5}; //4 int d_ednsRawPacketSizeLimit{-1}; // only used for Lua record diff --git a/pdns/lua-record.cc b/pdns/lua-record.cc index 49dd69fd4e..60584d2183 100644 --- a/pdns/lua-record.cc +++ b/pdns/lua-record.cc @@ -978,7 +978,7 @@ std::vector> luaSynth(const std::string& code, cons lua.writeVariable("qname", query); lua.writeVariable("zone", zone); lua.writeVariable("zoneid", zoneid); - lua.writeVariable("who", dnsp.getRemote()); + lua.writeVariable("who", dnsp.getInnerRemote()); lua.writeVariable("dh", (dnsheader*)&dnsp.d); lua.writeVariable("dnssecOK", dnsp.d_dnssecOk); lua.writeVariable("tcp", dnsp.d_tcp); @@ -989,7 +989,7 @@ std::vector> luaSynth(const std::string& code, cons } else { lua.writeVariable("ecswho", nullptr); - s_lua_record_ctx->bestwho = dnsp.getRemote(); + s_lua_record_ctx->bestwho = dnsp.getInnerRemote(); } lua.writeVariable("bestwho", s_lua_record_ctx->bestwho); diff --git a/pdns/nameserver.cc b/pdns/nameserver.cc index c52341bce4..8f2bcd5778 100644 --- a/pdns/nameserver.cc +++ b/pdns/nameserver.cc @@ -32,6 +32,7 @@ #include #include "responsestats.hh" +#include "common_startup.hh" #include "dns.hh" #include "dnsbackend.hh" #include "dnspacket.hh" @@ -40,6 +41,7 @@ #include "logger.hh" #include "arguments.hh" #include "statbag.hh" +#include "proxy-protocol.hh" #include "namespaces.hh" @@ -301,7 +303,27 @@ bool UDPNameserver::receive(DNSPacket& packet, std::string& buffer) packet.d_dt.setTimeval(recvtv); } else - packet.d_dt.set(); // timing + packet.d_dt.set(); // timing + + if (g_proxyProtocolACL.match(remote)) { + ComboAddress psource, pdestination; + bool proxyProto, tcp; + std::vector ppvalues; + + buffer.resize(len); + ssize_t used = parseProxyHeader(buffer, proxyProto, psource, pdestination, tcp, ppvalues); + if (used <= 0 || (size_t) used > g_proxyProtocolMaximumSize || (len - used) > DNSPacket::s_udpTruncationThreshold) { + S.inc("corrupt-packets"); + S.ringAccount("remotes-corrupt", packet.d_remote); + return false; + } + buffer.erase(0, used); + packet.d_inner_remote = psource; + packet.d_tcp = tcp; + } + else { + packet.d_inner_remote.reset(); + } if(packet.parse(&buffer.at(0), (size_t) len)<0) { S.inc("corrupt-packets"); diff --git a/pdns/packethandler.cc b/pdns/packethandler.cc index 45164c35d8..fee18b5a2c 100644 --- a/pdns/packethandler.cc +++ b/pdns/packethandler.cc @@ -942,9 +942,14 @@ int PacketHandler::trySuperMaster(const DNSPacket& p, const DNSName& tsigkeyname int PacketHandler::trySuperMasterSynchronous(const DNSPacket& p, const DNSName& tsigkeyname) { ComboAddress remote = p.getRemote(); + // this uses the outer (non-PROXY) remote on purpose if(p.hasEDNSSubnet() && pdns::isAddressTrustedNotificationProxy(remote)) { remote = p.getRealRemote().getNetwork(); } + else { + // but we fall back to the inner (PROXY) remote if there is no ECS forwarded by a trusted proxy + remote = p.getInnerRemote(); + } remote.setPort(53); Resolver::res_t nsset; @@ -1019,34 +1024,34 @@ int PacketHandler::processNotify(const DNSPacket& p) if master is higher -> do stuff */ - g_log< meta; if (B.getDomainMetadata(p.qdomain,"AXFR-MASTER-TSIG",meta) && meta.size() > 0) { DNSName expected{meta[0]}; if (p.getTSIGKeyname() != expected) { - g_log< forwardNotify(s_forwardNotify); for(const auto & j : forwardNotify) { - g_log< +#include "iputils.hh" struct ProxyProtocolValue { diff --git a/pdns/tcpreceiver.cc b/pdns/tcpreceiver.cc index b9c619eb2d..dfd5340cb3 100644 --- a/pdns/tcpreceiver.cc +++ b/pdns/tcpreceiver.cc @@ -58,6 +58,8 @@ #include "namespaces.hh" #include "signingpipe.hh" #include "stubresolver.hh" +#include "proxy-protocol.hh" +#include "noinitvector.hh" extern AuthPacketCache PC; extern StatBag S; @@ -240,9 +242,59 @@ void TCPNameserver::doConnection(int fd) try { int mesgsize=65535; boost::scoped_array mesg(new char[mesgsize]); - + std::optional inner_remote; + bool inner_tcp = false; + DLOG(g_log<<"TCP Connection accepted on fd "<(used) > g_proxyProtocolMaximumSize) { + throw NetworkError("Error reading PROXYv2 header from TCP client "+remote.toString()+": PROXYv2 header too big"); + } + else { // used > 0 && used <= g_proxyProtocolMaximumSize + break; + } + } + ComboAddress psource, pdestination; + bool proxyProto, tcp; + std::vector ppvalues; + + used = parseProxyHeader(proxyData, proxyProto, psource, pdestination, tcp, ppvalues); + if (used <= 0) { + throw NetworkError("Error reading PROXYv2 header from TCP client "+remote.toString()+": PROXYv2 header was invalid"); + } + if (static_cast(used) > g_proxyProtocolMaximumSize) { + throw NetworkError("Error reading PROXYv2 header from TCP client "+remote.toString()+": PROXYv2 header was oversized"); + } + inner_remote = psource; + inner_tcp = tcp; + } + for(;;) { unsigned int remainingTime = 0; transactions++; @@ -288,6 +340,10 @@ void TCPNameserver::doConnection(int fd) packet=make_unique(true); packet->setRemote(&remote); packet->d_tcp=true; + if (inner_remote) { + packet->d_inner_remote = inner_remote; + packet->d_tcp = inner_tcp; + } packet->setSocket(fd); if(packet->parse(mesg.get(), pktlen)<0) break; @@ -305,12 +361,7 @@ void TCPNameserver::doConnection(int fd) std::unique_ptr reply; auto cached = make_unique(false); if(logDNSQueries) { - string remote_text; - if(packet->hasEDNSSubnet()) - remote_text = packet->getRemote().toString() + "<-" + packet->getRealRemote().toString(); - else - remote_text = packet->getRemote().toString(); - g_log << Logger::Notice<<"TCP Remote "<< remote_text <<" wants '" << packet->qdomain<<"|"<qtype.toString() << + g_log << Logger::Notice<<"TCP Remote "<< packet->getRemoteString() <<" wants '" << packet->qdomain<<"|"<qtype.toString() << "', do = " <d_dnssecOk <<", bufsize = "<< packet->getMaxReplyLen(); } @@ -383,7 +434,7 @@ bool TCPNameserver::canDoAXFR(std::unique_ptr& q, bool isAXFR) if(::arg().mustDo("disable-axfr")) return false; - string logPrefix=string(isAXFR ? "A" : "I")+"XFR-out zone '"+q->qdomain.toLogString()+"', client '"+q->getRemote().toStringWithPort()+"', "; + string logPrefix=string(isAXFR ? "A" : "I")+"XFR-out zone '"+q->qdomain.toLogString()+"', client '"+q->getInnerRemote().toStringWithPort()+"', "; if(q->d_havetsig) { // if you have one, it must be good TSIGRecordContent trc; @@ -407,7 +458,7 @@ bool TCPNameserver::canDoAXFR(std::unique_ptr& q, bool isAXFR) } // cerr<<"checking allow-axfr-ips"<d_remote )) { + if(!(::arg()["allow-axfr-ips"].empty()) && d_ng.match( q->getInnerRemote() )) { g_log<& q, bool isAXFR) vector nsips=fns.lookup(j, s_P->getBackend()); for(const auto & nsip : nsips) { // cerr<<"got "<<*k<<" from AUTO-NS"<getRemote().toString()) + if(nsip == q->getInnerRemote().toString()) { // cerr<<"got AUTO-NS hit"<& q, bool isAXFR) else { Netmask nm = Netmask(i); - if(nm.match( (ComboAddress *) &q->d_remote )) + if(nm.match( q->getInnerRemote() )) { g_log<& q, bool isAXFR) extern CommunicatorClass Communicator; - if(Communicator.justNotified(q->qdomain, q->getRemote().toString())) { // we just notified this ip + if(Communicator.justNotified(q->qdomain, q->getInnerRemote().toString())) { // we just notified this ip g_log<& q, int outsock) { - string logPrefix="AXFR-out zone '"+target.toLogString()+"', client '"+q->getRemote().toStringWithPort()+"', "; + string logPrefix="AXFR-out zone '"+target.toLogString()+"', client '"+q->getRemoteString()+"', "; std::unique_ptr outpacket= getFreshAXFRPacket(q); if(q->d_dnssecOk) @@ -1023,7 +1074,7 @@ int TCPNameserver::doAXFR(const DNSName &target, std::unique_ptr& q, int TCPNameserver::doIXFR(std::unique_ptr& q, int outsock) { - string logPrefix="IXFR-out zone '"+q->qdomain.toLogString()+"', client '"+q->getRemote().toStringWithPort()+"', "; + string logPrefix="IXFR-out zone '"+q->qdomain.toLogString()+"', client '"+q->getRemoteString()+"', "; std::unique_ptr outpacket=getFreshAXFRPacket(q); if(q->d_dnssecOk) diff --git a/pdns/tcpreceiver.hh b/pdns/tcpreceiver.hh index 366921a91e..368682fa3d 100644 --- a/pdns/tcpreceiver.hh +++ b/pdns/tcpreceiver.hh @@ -49,7 +49,6 @@ public: private: static void sendPacket(std::unique_ptr& p, int outsock, bool last=true); - static int readLength(int fd, ComboAddress *remote); static void getQuestion(int fd, char *mesg, int pktlen, const ComboAddress& remote, unsigned int totalTime); static int doAXFR(const DNSName &target, std::unique_ptr& q, int outsock); static int doIXFR(std::unique_ptr& q, int outsock); diff --git a/pdns/test-nameserver_cc.cc b/pdns/test-nameserver_cc.cc index 2f6044dbbb..0517b076f6 100644 --- a/pdns/test-nameserver_cc.cc +++ b/pdns/test-nameserver_cc.cc @@ -11,6 +11,8 @@ #include extern vector g_localaddresses; +NetmaskGroup g_proxyProtocolACL; +size_t g_proxyProtocolMaximumSize = 512; BOOST_AUTO_TEST_SUITE(test_nameserver_cc) diff --git a/regression-tests.auth-py/proxyprotocol.py b/regression-tests.auth-py/proxyprotocol.py new file mode 120000 index 0000000000..2a3d79b075 --- /dev/null +++ b/regression-tests.auth-py/proxyprotocol.py @@ -0,0 +1 @@ +../regression-tests.common/proxyprotocol.py \ No newline at end of file diff --git a/regression-tests.auth-py/test_ProxyProtocol.py b/regression-tests.auth-py/test_ProxyProtocol.py new file mode 100644 index 0000000000..c3861cee25 --- /dev/null +++ b/regression-tests.auth-py/test_ProxyProtocol.py @@ -0,0 +1,227 @@ +import dns +import os +import socket +import struct +import threading +import time +import unittest + +from authtests import AuthTest +from proxyprotocol import ProxyProtocol + +class TestProxyProtocolLuaRecords(AuthTest): + _config_template = """ +launch=bind +any-to-tcp=no +proxy-protocol-from=127.0.0.1 +""" + + _zones = { + 'example.org': """ +example.org. 3600 IN SOA {soa} +example.org. 3600 IN NS ns1.example.org. +example.org. 3600 IN NS ns2.example.org. +ns1.example.org. 3600 IN A {prefix}.10 +ns2.example.org. 3600 IN A {prefix}.11 + +myip.example.org. 3600 IN LUA A "who:toString()" + """ + } + + @classmethod + def setUpClass(cls): + super(TestProxyProtocolLuaRecords, cls).setUpClass() + + def testWhoAmI(self): + """ + See if LUA who picks up the inner address from the PROXY protocol + """ + + # first test with an unproxied query - should get ignored + query = dns.message.make_query('myip.example.org', 'A') + + res = self.sendUDPQuery(query) + + self.assertEqual(res, None) # query was ignored correctly + + + # now send a proxied query + queryPayload = query.to_wire() + ppPayload = ProxyProtocol.getPayload(False, False, False, "192.0.2.1", "10.1.2.3", 12345, 53, []) + payload = ppPayload + queryPayload + + # UDP + self._sock.settimeout(2.0) + + try: + self._sock.send(payload) + data = self._sock.recv(4096) + except socket.timeout: + data = None + finally: + self._sock.settimeout(None) + + res = None + if data: + res = dns.message.from_wire(data) + + expected = [dns.rrset.from_text('myip.example.org.', 0, dns.rdataclass.IN, 'A', '192.0.2.1')] + self.assertRcodeEqual(res, dns.rcode.NOERROR) + self.assertEqual(res.answer, expected) + + # TCP + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2.0) + sock.connect(("127.0.0.1", self._authPort)) + + try: + sock.send(ppPayload) + sock.send(struct.pack("!H", len(queryPayload))) + sock.send(queryPayload) + data = sock.recv(2) + if data: + (datalen,) = struct.unpack("!H", data) + data = sock.recv(datalen) + except socket.timeout as e: + print("Timeout: %s" % (str(e))) + data = None + except socket.error as e: + print("Network error: %s" % (str(e))) + data = None + finally: + sock.close() + + res = None + if data: + res = dns.message.from_wire(data) + + self.assertRcodeEqual(res, dns.rcode.NOERROR) + self.assertEqual(res.answer, expected) + +class TestProxyProtocolNOTIFY(AuthTest): + _config_template = """ +launch=bind +any-to-tcp=no +proxy-protocol-from=127.0.0.1 +secondary +""" + + _zones = { 'example.org': '192.0.2.1', + 'example.com': '192.0.2.2' + } + + @classmethod + def generateAuthZone(cls, confdir, zonename, zonecontent): + try: + os.unlink(os.path.join(confdir, '%s.zone' % zonename)) + except: + pass + + @classmethod + def generateAuthNamedConf(cls, confdir, zones): + with open(os.path.join(confdir, 'named.conf'), 'w') as namedconf: + namedconf.write(""" +options { + directory "%s"; +};""" % confdir) + for zonename in zones: + zone = '.' if zonename == 'ROOT' else zonename + + namedconf.write(""" + zone "%s" { + type slave; + file "%s.zone"; + masters { %s; }; + };""" % (zone, zonename, cls._zones[zone])) + + + @classmethod + def setUpClass(cls): + super(TestProxyProtocolNOTIFY, cls).setUpClass() + + def testNOTIFY(self): + """ + Check that NOTIFY is properly accepted/rejected based on the PROXY header inner address + """ + + query = dns.message.make_query('example.org', 'SOA') + query.set_opcode(dns.opcode.NOTIFY) + + queryPayload = query.to_wire() + + for task in ('192.0.2.1', dns.rcode.NOERROR), ('192.0.2.2', dns.rcode.REFUSED): + ip, expectedrcode = task + + ppPayload = ProxyProtocol.getPayload(False, False, False, ip, "10.1.2.3", 12345, 53, []) + payload = ppPayload + queryPayload + + self._sock.settimeout(2.0) + + try: + self._sock.send(payload) + data = self._sock.recv(4096) + except socket.timeout: + data = None + finally: + self._sock.settimeout(None) + + res = None + if data: + res = dns.message.from_wire(data) + + self.assertRcodeEqual(res, expectedrcode) + + +class TestProxyProtocolAXFRACL(AuthTest): + _config_template = """ +launch=bind +any-to-tcp=no +proxy-protocol-from=127.0.0.1 +allow-axfr-ips=192.0.2.53 +""" + + @classmethod + def setUpClass(cls): + super(TestProxyProtocolAXFRACL, cls).setUpClass() + + def testAXFR(self): + """ + Check that AXFR is properly accepted/rejected based on the PROXY header inner address + """ + + query = dns.message.make_query('example.org', 'AXFR') + + queryPayload = query.to_wire() + + for task in ('192.0.2.1', dns.rcode.NOTAUTH), ('127.0.0.1', dns.rcode.NOTAUTH), ('192.0.2.53', dns.rcode.NOERROR): + ip, expectedrcode = task + + ppPayload = ProxyProtocol.getPayload(False, True, False, ip, "10.1.2.3", 12345, 53, []) + + # TCP + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2.0) + sock.connect(("127.0.0.1", self._authPort)) + + try: + sock.send(ppPayload) + sock.send(struct.pack("!H", len(queryPayload))) + sock.send(queryPayload) + data = sock.recv(2) + if data: + (datalen,) = struct.unpack("!H", data) + data = sock.recv(datalen) + except socket.timeout as e: + print("Timeout: %s" % (str(e))) + data = None + except socket.error as e: + print("Network error: %s" % (str(e))) + data = None + finally: + sock.close() + + res = None + if data: + res = dns.message.from_wire(data) + + self.assertRcodeEqual(res, expectedrcode)