From: Kevin P. Fleming Date: Fri, 12 Nov 2021 12:20:19 +0000 (-0500) Subject: rec: Add support for NOTIFY operations to wipe cache entries X-Git-Tag: dnsdist-1.7.0-beta1~9^2~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=35189b7db15bb6f9a38f2b54bedd6be001531168;p=thirdparty%2Fpdns.git rec: Add support for NOTIFY operations to wipe cache entries NOTIFY operations can be sent to trigger removal of cache entries which match the zone specified in the operation. All entries, regardless of type, in or below the specified zone, are removed. Control over permission to send such operations is provided by an ACL, and control over zones which can be wiped is provided by a new configuration setting. The default configuration ignores all NOTIFY operations. This patch adds: * 'allow-notify-from' and 'allow-notify-from-file' settings, operating almost identically to 'allow-from' and 'allow-from-file' (the only difference being the default value). * 'allow-notify-for' and 'allow-notify-for-file' settings, which provide a list of zones for which NOTIFY operations are allowed. * modification to 'forward-zones-file' setting, allowing zones specified there to optionally allow NOTIFY operations. * 'source-disallowed-notify' metric, counting the number of NOTIFY operations which have been denied by the ACL. * 'zone-disallowed-notify' metric, counting the number of NOTIFY operations which have been denied by the zone list. * API support for modifying 'allow-notify-from' ACL. * Regression tests for new ACL settings. --- diff --git a/pdns/pdns_recursor.cc b/pdns/pdns_recursor.cc index 346b437f33..f3ac0a08dc 100644 --- a/pdns/pdns_recursor.cc +++ b/pdns/pdns_recursor.cc @@ -143,6 +143,8 @@ thread_local FDMultiplexer* t_fdm{nullptr}; thread_local std::unique_ptr t_remotes, t_servfailremotes, t_largeanswerremotes, t_bogusremotes; thread_local std::unique_ptr > > t_queryring, t_servfailqueryring, t_bogusqueryring; thread_local std::shared_ptr t_allowFrom; +thread_local std::shared_ptr t_allowNotifyFrom; +thread_local std::shared_ptr t_allowNotifyFor; #ifdef NOD_ENABLED thread_local std::shared_ptr t_nodDBp; thread_local std::shared_ptr t_udrDBp; @@ -204,6 +206,8 @@ static set g_fromtosockets; // listen sockets that use 'sendfromto()' mecha static std::atomic counter; static std::shared_ptr g_initialDomainMap; // new threads needs this to be setup static std::shared_ptr g_initialAllowFrom; // new thread needs to be setup with this +static std::shared_ptr g_initialAllowNotifyFrom; // new threads need this to be setup +static std::shared_ptr g_initialAllowNotifyFor; // new threads need this to be setup static NetmaskGroup g_XPFAcl; static NetmaskGroup g_proxyProtocolACL; static NetmaskGroup g_paddingFrom; @@ -1602,6 +1606,21 @@ static bool answerIsNOData(uint16_t requestedType, int rcode, const std::vector< return true; } +static bool isAllowNotifyForZone(DNSName qname) +{ + if (t_allowNotifyFor->empty()) { + return false; + } + + notifyset_t::const_iterator ret; + do { + ret = t_allowNotifyFor->find(qname); + if (ret != t_allowNotifyFor->end()) + return true; + } while (qname.chopOff()); + return false; +} + static void startDoResolve(void *p) { auto dc=std::unique_ptr(reinterpret_cast(p)); @@ -1693,7 +1712,7 @@ static void startDoResolve(void *p) checkFrameStreamExport(luaconfsLocal); #endif - DNSPacketWriter pw(packet, dc->d_mdp.d_qname, dc->d_mdp.d_qtype, dc->d_mdp.d_qclass); + DNSPacketWriter pw(packet, dc->d_mdp.d_qname, dc->d_mdp.d_qtype, dc->d_mdp.d_qclass, dc->d_mdp.d_header.opcode); pw.getHeader()->aa=0; pw.getHeader()->ra=1; @@ -1779,7 +1798,7 @@ static void startDoResolve(void *p) dq.extendedErrorExtra = &dc->d_extendedErrorExtra; dq.meta = std::move(dc->d_meta); - if(ednsExtRCode != 0) { + if(ednsExtRCode != 0 || dc->d_mdp.d_header.opcode == Opcode::Notify) { goto sendit; } @@ -2614,6 +2633,27 @@ static bool checkForCacheHit(bool qnameParsed, unsigned int tag, const string& d return cacheHit; } +static void* pleaseWipeCaches(const DNSName& canon, bool subtree, uint16_t qtype) +{ + auto res = wipeCaches(canon, subtree, qtype); + g_log<func = [=]{ return pleaseWipeCaches(canon, true, 0xffff); }; + tmsg->wantAnswer = false; + if(write(s_threadInfos[0].pipes.writeToThread, &tmsg, sizeof(tmsg)) != sizeof(tmsg)) { + delete tmsg; + + unixDie("write to thread pipe returned wrong size or error"); + } +} + static void handleRunningTCPQuestion(int fd, FDMultiplexer::funcparam_t& var) { shared_ptr conn=boost::any_cast >(var); @@ -2792,7 +2832,7 @@ static void handleRunningTCPQuestion(int fd, FDMultiplexer::funcparam_t& var) checkFrameStreamExport(luaconfsLocal); #endif - if(needECS || needXPF || (t_pdl && (t_pdl->d_gettag_ffi || t_pdl->d_gettag))) { + if(needECS || needXPF || (t_pdl && (t_pdl->d_gettag_ffi || t_pdl->d_gettag)) || dc->d_mdp.d_header.opcode == Opcode::Notify) { try { EDNSOptionViewMap ednsOptions; @@ -2882,10 +2922,10 @@ static void handleRunningTCPQuestion(int fd, FDMultiplexer::funcparam_t& var) terminateTCPConnection(fd); return; } - if (dc->d_mdp.d_header.opcode) { + if (dc->d_mdp.d_header.opcode != Opcode::Query && dc->d_mdp.d_header.opcode != Opcode::Notify) { g_stats.ignoredCount++; if (g_logCommonErrors) { - g_log<getRemote() <<" on server socket!"<d_mdp.d_header.opcode)<<" from TCP client "<< dc->getRemote() <<" on server socket!"<d_mdp.d_header.opcode == Opcode::Notify) { + if(!t_allowNotifyFrom || !t_allowNotifyFrom->match(dc->d_source)) { + if(!g_quiet) { + g_log<getTid()<<"] dropping TCP NOTIFY from "<d_source.toString()<<", address not matched by allow-notify-from"<getTid()<<"] dropping TCP NOTIFY from "<d_source.toString()<<", for "<d_eventTrace.add(RecEventTrace::PCacheCheck); - bool cacheHit = checkForCacheHit(qnameParsed, dc->d_tag, conn->data, qname, qtype, qclass, g_now, response, dc->d_qhash, pbData, true, dc->d_source); - dc->d_eventTrace.add(RecEventTrace::PCacheCheck, cacheHit, false); + if(dc->d_mdp.d_header.opcode == Opcode::Query) { + /* It might seem like a good idea to skip the packet cache lookup if we know that the answer is not cacheable, + but it means that the hash would not be computed. If some script decides at a later time to mark back the answer + as cacheable we would cache it with a wrong tag, so better safe than sorry. */ + dc->d_eventTrace.add(RecEventTrace::PCacheCheck); + bool cacheHit = checkForCacheHit(qnameParsed, dc->d_tag, conn->data, qname, qtype, qclass, g_now, response, dc->d_qhash, pbData, true, dc->d_source); + dc->d_eventTrace.add(RecEventTrace::PCacheCheck, cacheHit, false); + + if (cacheHit) { + if (!g_quiet) { + g_log<d_source.toStringWithPort()<<(dc->d_source != dc->d_remote ? " (via "+dc->d_remote.toStringWithPort()+")" : "")<d_eventTrace.add(RecEventTrace::AnswerSent); + + if (t_protobufServers && dc->d_logResponse && !(luaconfsLocal->protobufExportConfig.taggedOnly && pbData && !pbData->d_tagged)) { + struct timeval tv{0, 0}; + protobufLogResponse(dh, luaconfsLocal, pbData, tv, true, dc->d_source, dc->d_destination, dc->d_ednssubnet, dc->d_uuid, dc->d_requestorId, dc->d_deviceId, dc->d_deviceName, dc->d_meta, dc->d_eventTrace); + } + if (dc->d_eventTrace.enabled() && SyncRes::s_event_trace_enabled & SyncRes::event_trace_to_log) { + g_log << Logger::Info << dc->d_eventTrace.toString() << endl; + } + } // cache hit + } // query opcode + + if(dc->d_mdp.d_header.opcode == Opcode::Notify) { if (!g_quiet) { - g_log<d_source.toStringWithPort()<<(dc->d_source != dc->d_remote ? " (via "+dc->d_remote.toStringWithPort()+")" : "")<d_source.toStringWithPort()<<(dc->d_source != dc->d_remote ? " (via "+dc->d_remote.toStringWithPort()+")" : "")<d_eventTrace.add(RecEventTrace::AnswerSent); - - if (t_protobufServers && dc->d_logResponse && !(luaconfsLocal->protobufExportConfig.taggedOnly && pbData && !pbData->d_tagged)) { - struct timeval tv{0, 0}; - protobufLogResponse(dh, luaconfsLocal, pbData, tv, true, dc->d_source, dc->d_destination, dc->d_ednssubnet, dc->d_uuid, dc->d_requestorId, dc->d_deviceId, dc->d_deviceName, dc->d_meta, dc->d_eventTrace); - } + requestWipeCaches(qname); - if (dc->d_eventTrace.enabled() && SyncRes::s_event_trace_enabled & SyncRes::event_trace_to_log) { - g_log << Logger::Info << dc->d_eventTrace.toString() << endl; - } + // the operation will now be treated as a Query, generating + // a normal response, as the rest of the code does not + // check dh->opcode, but we need to ensure that the response + // to this request does not get put into the packet cache + dc->d_variable = true; + } + + // setup for startDoResolve() in an mthread + ++conn->d_requestsInFlight; + if (conn->d_requestsInFlight >= TCPConnection::s_maxInFlight) { + t_fdm->removeReadFD(fd); // should no longer awake ourselves when there is data to read } else { - // No cache hit, setup for startDoResolve() in an mthread - ++conn->d_requestsInFlight; - if (conn->d_requestsInFlight >= TCPConnection::s_maxInFlight) { - t_fdm->removeReadFD(fd); // should no longer awake ourselves when there is data to read - } else { - Utility::gettimeofday(&g_now, nullptr); // needed? - struct timeval ttd = g_now; - t_fdm->setReadTTD(fd, ttd, g_tcpTimeout); - } - MT->makeThread(startDoResolve, dc.release()); // deletes dc - } // Cache hit or not + Utility::gettimeofday(&g_now, nullptr); // needed? + struct timeval ttd = g_now; + t_fdm->setReadTTD(fd, ttd, g_tcpTimeout); + } + MT->makeThread(startDoResolve, dc.release()); // deletes dc } // good query } // read full query } // reading query @@ -3086,8 +3161,8 @@ static string* doProcessUDPQuestion(const std::string& question, const ComboAddr bool variable = false; bool followCNAMEs = false; bool responsePaddingDisabled = false; + DNSName qname; try { - DNSName qname; uint16_t qtype=0; uint16_t qclass=0; bool qnameParsed=false; @@ -3103,7 +3178,7 @@ static string* doProcessUDPQuestion(const std::string& question, const ComboAddr */ #endif - if(needECS || needXPF || (t_pdl && (t_pdl->d_gettag || t_pdl->d_gettag_ffi))) { + if(needECS || needXPF || (t_pdl && (t_pdl->d_gettag || t_pdl->d_gettag_ffi)) || dh->opcode == Opcode::Notify) { try { EDNSOptionViewMap ednsOptions; bool xpfFound = false; @@ -3157,45 +3232,48 @@ static string* doProcessUDPQuestion(const std::string& question, const ComboAddr if (ctag == 0 && !responsePaddingDisabled && g_paddingFrom.match(fromaddr)) { ctag = g_paddingTag; } - /* It might seem like a good idea to skip the packet cache lookup if we know that the answer is not cacheable, - but it means that the hash would not be computed. If some script decides at a later time to mark back the answer - as cacheable we would cache it with a wrong tag, so better safe than sorry. */ - eventTrace.add(RecEventTrace::PCacheCheck); - bool cacheHit = checkForCacheHit(qnameParsed, ctag, question, qname, qtype, qclass, g_now, response, qhash, pbData, false, source); - eventTrace.add(RecEventTrace::PCacheCheck, cacheHit, false); - if (cacheHit) { - if (!g_quiet) { - g_log<(&fromaddr)); - msgh.msg_control=NULL; - if(g_fromtosockets.count(fd)) { - addCMsgSrcAddr(&msgh, &cbuf, &destaddr, 0); - } - int sendErr = sendOnNBSocket(fd, &msgh); - eventTrace.add(RecEventTrace::AnswerSent); + if(dh->opcode == Opcode::Query) { + /* It might seem like a good idea to skip the packet cache lookup if we know that the answer is not cacheable, + but it means that the hash would not be computed. If some script decides at a later time to mark back the answer + as cacheable we would cache it with a wrong tag, so better safe than sorry. */ + eventTrace.add(RecEventTrace::PCacheCheck); + bool cacheHit = checkForCacheHit(qnameParsed, ctag, question, qname, qtype, qclass, g_now, response, qhash, pbData, false, source); + eventTrace.add(RecEventTrace::PCacheCheck, cacheHit, false); + if (cacheHit) { + if (!g_quiet) { + g_log<(&fromaddr)); + msgh.msg_control=NULL; + + if(g_fromtosockets.count(fd)) { + addCMsgSrcAddr(&msgh, &cbuf, &destaddr, 0); + } + int sendErr = sendOnNBSocket(fd, &msgh); + eventTrace.add(RecEventTrace::AnswerSent); - if (t_protobufServers && logResponse && !(luaconfsLocal->protobufExportConfig.taggedOnly && pbData && !pbData->d_tagged)) { - protobufLogResponse(dh, luaconfsLocal, pbData, tv, false, source, destination, ednssubnet, uniqueId, requestorId, deviceId, deviceName, meta, eventTrace); - } + if (t_protobufServers && logResponse && !(luaconfsLocal->protobufExportConfig.taggedOnly && pbData && !pbData->d_tagged)) { + protobufLogResponse(dh, luaconfsLocal, pbData, tv, false, source, destination, ednssubnet, uniqueId, requestorId, deviceId, deviceName, meta, eventTrace); + } - if (eventTrace.enabled() && SyncRes::s_event_trace_enabled & SyncRes::event_trace_to_log) { - g_log << Logger::Info << eventTrace.toString() << endl; - } - if (sendErr && g_logCommonErrors) { - g_log << Logger::Warning << "Sending UDP reply to client " << source.toStringWithPort() - << (source != fromaddr ? " (via " + fromaddr.toStringWithPort() + ")" : "") << " failed with: " - << strerror(sendErr) << endl; + if (eventTrace.enabled() && SyncRes::s_event_trace_enabled & SyncRes::event_trace_to_log) { + g_log << Logger::Info << eventTrace.toString() << endl; + } + if (sendErr && g_logCommonErrors) { + g_log << Logger::Warning << "Sending UDP reply to client " << source.toStringWithPort() + << (source != fromaddr ? " (via " + fromaddr.toStringWithPort() + ")" : "") << " failed with: " + << strerror(sendErr) << endl; + } + struct timeval now; + Utility::gettimeofday(&now, nullptr); + uint64_t spentUsec = uSec(now - tv); + g_stats.cumulativeAnswers(spentUsec); + return 0; } - struct timeval now; - Utility::gettimeofday(&now, nullptr); - uint64_t spentUsec = uSec(now - tv); - g_stats.cumulativeAnswers(spentUsec); - return 0; } } catch (const std::exception& e) { @@ -3211,13 +3289,36 @@ static string* doProcessUDPQuestion(const std::string& question, const ComboAddr eventTrace.add(RecEventTrace::LuaIPFilter, ipf, false); if (ipf) { if (!g_quiet) { - g_log<getTid()<<"/"<numProcesses()<<"] DROPPED question from "<getTid()<<"/"<numProcesses()<<"] DROPPED question from "<opcode == Opcode::Notify) { + if(!isAllowNotifyForZone(qname)) { + if(!g_quiet) { + g_log<getTid()<<"] dropping UDP NOTIFY from "<opcode, but we need to ensure that the response + // to this request does not get put into the packet cache + variable = true; + } + if(MT->numProcesses() > g_maxMThreads) { if(!g_quiet) g_log<getTid()<<"/"<numProcesses()<<"] DROPPED question from "<opcode) { + else if(dh->opcode != Opcode::Query && dh->opcode != Opcode::Notify) { g_stats.ignoredCount++; if(g_logCommonErrors) { - g_log<opcode<<" from "<opcode)<<" from "<qdcount == 0) { @@ -3388,6 +3489,17 @@ static void handleNewUDPQuestion(int fd, FDMultiplexer::funcparam_t& var) } } else { + if(dh->opcode == Opcode::Notify) { + if(!t_allowNotifyFrom || !t_allowNotifyFrom->match(&source)) { + if(!g_quiet) { + g_log<getTid()<<"] dropping UDP NOTIFY from "< ng) +static void* pleaseSupplantAllowFrom(std::shared_ptr ng) { t_allowFrom = ng; return nullptr; } +static void* pleaseSupplantAllowNotifyFrom(std::shared_ptr ng) +{ + t_allowNotifyFrom = ng; + return nullptr; +} + +void* pleaseSupplantAllowNotifyFor(std::shared_ptr ns) +{ + t_allowNotifyFor = ns; + return nullptr; +} + int g_argc; char** g_argv; +static std::shared_ptr parseACL(const std::string& aclFile, const std::string& aclSetting) +{ + auto result = std::make_shared(); + + if(!::arg()[aclFile].empty()) { + string line; + ifstream ifs(::arg()[aclFile].c_str()); + if(!ifs) { + throw runtime_error("Could not open '"+::arg()[aclFile]+"': "+stringerror()); + } + + string::size_type pos; + while(getline(ifs,line)) { + pos=line.find('#'); + if(pos!=string::npos) + line.resize(pos); + boost::trim(line); + if(line.empty()) + continue; + + result->addMask(line); + } + g_log<size()<<" "< ips; + stringtok(ips, ::arg()[aclSetting], ", "); + + g_log<::const_iterator i = ips.begin(); i!= ips.end(); ++i) { + result->addMask(*i); + if(i!=ips.begin()) + g_log< oldAllowFrom = t_allowFrom; - std::shared_ptr allowFrom = std::make_shared(); - - if(!::arg()["allow-from-file"].empty()) { - string line; - ifstream ifs(::arg()["allow-from-file"].c_str()); - if(!ifs) { - throw runtime_error("Could not open '"+::arg()["allow-from-file"]+"': "+stringerror()); - } - - string::size_type pos; - while(getline(ifs,line)) { - pos=line.find('#'); - if(pos!=string::npos) - line.resize(pos); - boost::trim(line); - if(line.empty()) - continue; - allowFrom->addMask(line); - } - g_log<size() <<" allow-from ranges from file '"<<::arg()["allow-from-file"]<<"' - overriding 'allow-from' setting"< ips; - stringtok(ips, ::arg()["allow-from"], ", "); - g_log<::const_iterator i = ips.begin(); i!= ips.end(); ++i) { - allowFrom->addMask(*i); - if(i!=ips.begin()) - g_log<size() == 0) { if(::arg()["local-address"]!="127.0.0.1" && ::arg().asNum("local-port")==53) g_log<(); t_tcpClientCounts = std::make_unique(); @@ -5914,6 +6068,10 @@ int main(int argc, char **argv) ::arg().set("version-string", "string reported on version.pdns or version.bind")=fullVersionString(); ::arg().set("allow-from", "If set, only allow these comma separated netmasks to recurse")=LOCAL_NETS; ::arg().set("allow-from-file", "If set, load allowed netmasks from this file")=""; + ::arg().set("allow-notify-for", "If set, NOTIFY requests for these zones will be allowed")=""; + ::arg().set("allow-notify-for-file", "If set, load NOTIFY-allowed zones from this file")=""; + ::arg().set("allow-notify-from", "If set, NOTIFY requests from these comma separated netmasks will be allowed")=""; + ::arg().set("allow-notify-from-file", "If set, load NOTIFY-allowed netmasks from this file")=""; ::arg().set("entropy-source", "If set, read entropy from this file")="/dev/urandom"; ::arg().set("dont-query", "If set, do not query these netmasks for DNS data")=DONT_QUERY; ::arg().set("max-tcp-per-client", "If set, maximum number of TCP sessions per client (IP address)")="0"; diff --git a/pdns/rec-snmp.cc b/pdns/rec-snmp.cc index 3bb7783a3b..d6867d86a4 100644 --- a/pdns/rec-snmp.cc +++ b/pdns/rec-snmp.cc @@ -143,6 +143,8 @@ static const oid udp6NoportErrorsOID[] = {RECURSOR_STATS_OID, 121}; static const oid udp6InErrorsOID[] = {RECURSOR_STATS_OID, 122}; static const oid udp6InCsumErrorsOID[] = {RECURSOR_STATS_OID, 123}; #endif /* __linux__ */ +static const oid sourceDisallowedNotifyOID[] = {RECURSOR_STATS_OID, 124}; +static const oid zoneDisallowedNotifyOID[] = {RECURSOR_STATS_OID, 125}; static std::unordered_map s_statsMap; @@ -267,6 +269,8 @@ RecursorSNMPAgent::RecursorSNMPAgent(const std::string& name, const std::string& registerCounter64Stat("noerror-answers", noerrorAnswersOID, OID_LENGTH(noerrorAnswersOID)); registerCounter64Stat("unauthorized-udp", unauthorizedUdpOID, OID_LENGTH(unauthorizedUdpOID)); registerCounter64Stat("unauthorized-tcp", unauthorizedTcpOID, OID_LENGTH(unauthorizedTcpOID)); + registerCounter64Stat("source-disallowed-notify", sourceDisallowedNotifyOID, OID_LENGTH(sourceDisallowedNotifyOID)); + registerCounter64Stat("zone-disallowed-notify", zoneDisallowedNotifyOID, OID_LENGTH(zoneDisallowedNotifyOID)); registerCounter64Stat("tcp-client-overflow", tcpClientOverflowOID, OID_LENGTH(tcpClientOverflowOID)); registerCounter64Stat("client-parse-errors", clientParseErrorsOID, OID_LENGTH(clientParseErrorsOID)); registerCounter64Stat("server-parse-errors", serverParseErrorsOID, OID_LENGTH(serverParseErrorsOID)); diff --git a/pdns/rec_channel_rec.cc b/pdns/rec_channel_rec.cc index 3d74674d92..c87c4cc56b 100644 --- a/pdns/rec_channel_rec.cc +++ b/pdns/rec_channel_rec.cc @@ -1195,6 +1195,8 @@ static void registerAllStats1() addGetStat("unauthorized-udp", &g_stats.unauthorizedUDP); addGetStat("unauthorized-tcp", &g_stats.unauthorizedTCP); + addGetStat("source-disallowed-notify", &g_stats.sourceDisallowedNotify); + addGetStat("zone-disallowed-notify", &g_stats.zoneDisallowedNotify); addGetStat("tcp-client-overflow", &g_stats.tcpClientOverflow); addGetStat("client-parse-errors", &g_stats.clientParseError); @@ -2108,7 +2110,7 @@ RecursorControlChannel::Answer RecursorControlParser::getAnswer(int s, const str g_log << Logger::Error << "Unable to reload zones and forwards when chroot()'ed, requested via control channel" << endl; return {1, "Unable to reload zones and forwards when chroot()'ed, please restart\n"}; } - return {0, reloadAuthAndForwards()}; + return {0, reloadZoneConfiguration()}; } if (cmd == "set-ecs-minimum-ttl") { return {0, setMinimumECSTTL(begin, end)}; diff --git a/pdns/recursordist/RECURSOR-MIB.txt b/pdns/recursordist/RECURSOR-MIB.txt index d9fea57ec7..83359c7319 100644 --- a/pdns/recursordist/RECURSOR-MIB.txt +++ b/pdns/recursordist/RECURSOR-MIB.txt @@ -42,6 +42,9 @@ rec MODULE-IDENTITY REVISION "202110270000Z" DESCRIPTION "Added more UDP errors metric." + REVISION "202111090000Z" + DESCRIPTION "Added NOTIFY-related metrics." + ::= { powerdns 2 } powerdns OBJECT IDENTIFIER ::= { enterprises 43315 } @@ -1032,6 +1035,21 @@ udp6InCsumErrors OBJECT-TYPE "Number of UDP6 in checksum errors (Linux only)" ::= { stats 123 } +sourceDisallowedNotify OBJECT-TYPE + SYNTAX Counter64 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of NOTIFY operations not authorized by allow-notify-from" + ::= { stats 124 } + +zoneDisallowedNotify OBJECT-TYPE + SYNTAX Counter64 + MAX-ACCESS read-only + STATUS current + DESCRIPTION + "Number of NOTIFY operations not allowed by allow-notify-for" + ::= { stats 125 } --- --- Traps / Notifications --- diff --git a/pdns/recursordist/docs/http-api/endpoint-servers-config.rst b/pdns/recursordist/docs/http-api/endpoint-servers-config.rst index 6fe6d88333..713402d577 100644 --- a/pdns/recursordist/docs/http-api/endpoint-servers-config.rst +++ b/pdns/recursordist/docs/http-api/endpoint-servers-config.rst @@ -5,7 +5,7 @@ Change a single setting .. note:: - Only :ref:`setting-allow-from` can be set. + Only :ref:`setting-allow-from` and :ref:`setting-allow-notify-from` can be set. :param server_id: The name of the server :param config_setting_name: The name of the setting to change diff --git a/pdns/recursordist/docs/metrics.rst b/pdns/recursordist/docs/metrics.rst index 8491485d8f..5223f78c86 100644 --- a/pdns/recursordist/docs/metrics.rst +++ b/pdns/recursordist/docs/metrics.rst @@ -763,6 +763,14 @@ unauthorized-udp ^^^^^^^^^^^^^^^^ number of UDP questions denied because of allow-from restrictions +source-disallowed-notify +^^^^^^^^^^^^^^^^^^^^^^^^ +number of NOTIFY operations denied because of allow-notify-from restrictions + +zone-disallowed-notify +^^^^^^^^^^^^^^^^^^^^^^ +number of NOTIFY operations denied because of allow-notify-for restrictions + unexpected-packets ^^^^^^^^^^^^^^^^^^ number of answers from remote servers that were unexpected (might point to spoofing) diff --git a/pdns/recursordist/docs/settings.rst b/pdns/recursordist/docs/settings.rst index d14c27dafa..156ee99308 100644 --- a/pdns/recursordist/docs/settings.rst +++ b/pdns/recursordist/docs/settings.rst @@ -58,6 +58,69 @@ Note that specifying an IP address without a netmask uses an implicit netmask of Like `allow-from`_, except reading from file. Overrides the `allow-from`_ setting. To use this feature, supply one netmask per line, with optional comments preceded by a "#". +.. _setting-allow-notify-for: + +``allow-notify-for`` +--------------------- +.. versionadded:: 4.6.0 + +- Comma separated list of domain-names +- Default: (empty) + +Domain names specified in this list are used to permit incoming +NOTIFY operations to wipe any cache entries that match the domain +name. If this list is empty, all NOTIFY operations will be ignored. + +.. _setting-allow-notify-for-file: + +``allow-notify-for-file`` +------------------------- +.. versionadded:: 4.6.0 + +- Path + +Like `allow-notify-for`_, except reading from file. To use this +feature, supply one domain name per line, with optional comments +preceded by a "#". + +.. _setting-allow-notify-from: + +``allow-notify-from`` +--------------------- +.. versionadded:: 4.6.0 + +- IP addresses or netmasks, separated by commas +- Default: unset + +Netmasks (both IPv4 and IPv6) that are allowed to issue NOTIFY operations +to the server. NOTIFY operations from IP addresses not listed here are +ignored and do not get an answer. + +When the Proxy Protocol is enabled (see `proxy-protocol-from`_), the +recursor will check the address of the client IP advertised in the +Proxy Protocol header instead of the one of the proxy. + +Note that specifying an IP address without a netmask uses an implicit +netmask of /32 or /128. + +NOTIFY operations received from a client listed in one of these netmasks +will be accepted and used to wipe any cache entries whose zones match +the zone specified in the NOTIFY operation, but only if that zone (or +one of its parents) is included in `allow-notify-for`_, +`allow-notify-for-file`_, or `forward-zones-file_` with a '^' prefix. + +.. _setting-allow-notify-from-file: + +``allow-notify-from-file`` +-------------------------- +.. versionadded:: 4.6.0 + +- Path + +Like `allow-notify-from`_, except reading from file. To use this +feature, supply one netmask per line, with optional comments preceded +by a "#". + .. _setting-any-to-tcp: ``any-to-tcp`` @@ -785,8 +848,9 @@ Same as `forward-zones`_, parsed from a file. Only 1 zone is allowed per line, s example.org=203.0.113.210, 192.0.2.4:5300 -Zones prefixed with a '+' are forwarded with the recursion-desired bit set, for which see `forward-zones-recurse`_. -Default behaviour without '+' is as with `forward-zones`_. +Zones prefixed with a '+' are treated as with +`forward-zones-recurse`_. Default behaviour without '+' is as with +`forward-zones`_. .. versionchanged:: 4.0.0 @@ -794,6 +858,11 @@ Default behaviour without '+' is as with `forward-zones`_. The DNSSEC notes from `forward-zones`_ apply here as well. +.. versionchanged:: 4.6.0 + +Zones prefixed with a '^' are added to the `allow-notify-for`_ +list. Both prefix characters can be used if desired, in any order. + .. _setting-forward-zones-recurse: ``forward-zones-recurse`` diff --git a/pdns/reczones.cc b/pdns/reczones.cc index f5d417e3c8..10418a6b2d 100644 --- a/pdns/reczones.cc +++ b/pdns/reczones.cc @@ -295,7 +295,7 @@ static void* pleaseUseNewSDomainsMap(std::shared_ptr newma return 0; } -string reloadAuthAndForwards() +string reloadZoneConfiguration() { std::shared_ptr original = SyncRes::getDomainMap(); @@ -313,6 +313,8 @@ string reloadAuthAndForwards() ::arg().preParseFile(configname.c_str(), "forward-zones-file"); ::arg().preParseFile(configname.c_str(), "forward-zones-recurse"); ::arg().preParseFile(configname.c_str(), "auth-zones"); + ::arg().preParseFile(configname.c_str(), "allow-notify-for"); + ::arg().preParseFile(configname.c_str(), "allow-notify-for-file"); ::arg().preParseFile(configname.c_str(), "export-etc-hosts", "off"); ::arg().preParseFile(configname.c_str(), "serve-rfc1918"); ::arg().preParseFile(configname.c_str(), "include-dir"); @@ -328,6 +330,8 @@ string reloadAuthAndForwards() ::arg().preParseFile(fn.c_str(), "forward-zones-file", ::arg()["forward-zones-file"]); ::arg().preParseFile(fn.c_str(), "forward-zones-recurse", ::arg()["forward-zones-recurse"]); ::arg().preParseFile(fn.c_str(), "auth-zones", ::arg()["auth-zones"]); + ::arg().preParseFile(fn.c_str(), "allow-notify-for", ::arg()["allow-notify-for"]); + ::arg().preParseFile(fn.c_str(), "allow-notify-for-file", ::arg()["allow-notify-for-file"]); ::arg().preParseFile(fn.c_str(), "export-etc-hosts", ::arg()["export-etc-hosts"]); ::arg().preParseFile(fn.c_str(), "serve-rfc1918", ::arg()["serve-rfc1918"]); } @@ -336,10 +340,12 @@ string reloadAuthAndForwards() ::arg().preParse(g_argc, g_argv, "forward-zones-file"); ::arg().preParse(g_argc, g_argv, "forward-zones-recurse"); ::arg().preParse(g_argc, g_argv, "auth-zones"); + ::arg().preParse(g_argc, g_argv, "allow-notify-for"); + ::arg().preParse(g_argc, g_argv, "allow-notify-for-file"); ::arg().preParse(g_argc, g_argv, "export-etc-hosts"); ::arg().preParse(g_argc, g_argv, "serve-rfc1918"); - std::shared_ptr newDomainMap = parseAuthAndForwards(); + auto [newDomainMap, newNotifySet] = parseZoneConfiguration(); // purge both original and new names std::set oldAndNewDomains; @@ -357,7 +363,11 @@ string reloadAuthAndForwards() wipeCaches(i, true, 0xffff); } - broadcastFunction([=] { return pleaseUseNewSDomainsMap(newDomainMap); }); + // these explicitly-named captures should not be necessary, as lambda + // capture of tuple-like structured bindings is permitted, but some + // compilers still don't allow it + broadcastFunction([dm = newDomainMap] { return pleaseUseNewSDomainsMap(dm); }); + broadcastFunction([ns = newNotifySet] { return pleaseSupplantAllowNotifyFor(ns); }); return "ok\n"; } catch (std::exception& e) { @@ -372,12 +382,13 @@ string reloadAuthAndForwards() return "reloading failed, see log\n"; } -std::shared_ptr parseAuthAndForwards() +std::tuple, std::shared_ptr> parseZoneConfiguration() { TXTRecordContent::report(); OPTRecordContent::report(); auto newMap = std::make_shared(); + auto newSet = std::make_shared(); typedef vector parts_t; parts_t parts; @@ -439,7 +450,6 @@ std::shared_ptr parseAuthAndForwards() if (!::arg()["forward-zones-file"].empty()) { g_log << Logger::Warning << "Reading zone forwarding information from '" << ::arg()["forward-zones-file"] << "'" << endl; - SyncRes::AuthDomain ad; auto fp = std::unique_ptr(fopen(::arg()["forward-zones-file"].c_str(), "r"), fclose); if (!fp) { throw PDNSException("Error opening forward-zones-file '" + ::arg()["forward-zones-file"] + "': " + stringerror()); @@ -449,6 +459,7 @@ std::shared_ptr parseAuthAndForwards() int linenum = 0; uint64_t before = newMap->size(); while (linenum++, stringfgets(fp.get(), line)) { + SyncRes::AuthDomain ad; boost::trim(line); if (line[0] == '#') // Comment line, skip to the next line continue; @@ -457,15 +468,27 @@ std::shared_ptr parseAuthAndForwards() instructions = splitField(instructions, '#').first; // Remove EOL comments boost::trim(domain); boost::trim(instructions); - if (domain.empty() && instructions.empty()) { // empty line - continue; + if (domain.empty()) { + if (instructions.empty()) { // empty line + continue; + } + throw PDNSException("Error parsing line " + std::to_string(linenum) + " of " + ::arg()["forward-zones-file"]); } - if (boost::starts_with(domain, "+")) { - domain = domain.c_str() + 1; - ad.d_rdForward = true; + + bool allowNotifyFor = false; + + for (; !domain.empty(); domain.erase(0, 1)) { + switch (domain[0]) { + case '+': + ad.d_rdForward = true; + continue; + case '^': + allowNotifyFor = true; + continue; + } + break; } - else - ad.d_rdForward = false; + if (domain.empty()) { throw PDNSException("Error parsing line " + std::to_string(linenum) + " of " + ::arg()["forward-zones-file"]); } @@ -479,6 +502,9 @@ std::shared_ptr parseAuthAndForwards() ad.d_name = DNSName(domain); (*newMap)[ad.d_name] = ad; + if (allowNotifyFor) { + newSet->insert(ad.d_name); + } } g_log << Logger::Warning << "Done parsing " << newMap->size() - before << " forwarding instructions from file '" << ::arg()["forward-zones-file"] << "'" << endl; } @@ -520,6 +546,7 @@ std::shared_ptr parseAuthAndForwards() } } } + if (::arg().mustDo("serve-rfc1918")) { g_log << Logger::Warning << "Inserting rfc 1918 private space zones" << endl; parts.clear(); @@ -535,5 +562,30 @@ std::shared_ptr parseAuthAndForwards() makeIPToNamesZone(newMap, parts); } } - return newMap; + + parts.clear(); + stringtok(parts, ::arg()["allow-notify-for"], " ,\t\n\r"); + for (parts_t::const_iterator iter = parts.begin(); iter != parts.end(); ++iter) { + newSet->insert(DNSName(*iter)); + } + + if (auto anff = ::arg()["allow-notify-for-file"]; !anff.empty()) { + g_log << Logger::Warning << "Reading NOTIFY-allowed zones from '" << anff << "'" << endl; + auto fp = std::unique_ptr(fopen(anff.c_str(), "r"), fclose); + if (!fp) { + throw PDNSException("Error opening allow-notify-for-file '" + anff + "': " + stringerror()); + } + + string line; + uint64_t before = newSet->size(); + while (stringfgets(fp.get(), line)) { + boost::trim(line); + if (line[0] == '#') // Comment line, skip to the next line + continue; + newSet->insert(DNSName(line)); + } + g_log << Logger::Warning << "Done parsing " << newSet->size() - before << " NOTIFY-allowed zones from file '" << anff << "'" << endl; + } + + return {newMap, newSet}; } diff --git a/pdns/syncres.hh b/pdns/syncres.hh index 0506fdabf8..5d51b4e5bb 100644 --- a/pdns/syncres.hh +++ b/pdns/syncres.hh @@ -1047,6 +1047,8 @@ struct RecursorStats pdns::stat_t tcpqcounter; pdns::stat_t unauthorizedUDP; // when this is increased, qcounter isn't pdns::stat_t unauthorizedTCP; // when this is increased, qcounter isn't + pdns::stat_t sourceDisallowedNotify; // when this is increased, qcounter is also + pdns::stat_t zoneDisallowedNotify; // when this is increased, qcounter is also pdns::stat_t policyDrops; pdns::stat_t tcpClientOverflow; pdns::stat_t clientParseError; @@ -1155,6 +1157,7 @@ extern thread_local std::unique_ptr t_servfailremotes, t_largeans extern thread_local std::unique_ptr > > t_queryring, t_servfailqueryring, t_bogusqueryring; extern thread_local std::shared_ptr t_allowFrom; +extern thread_local std::shared_ptr t_allowNotifyFrom; string doTraceRegex(vector::const_iterator begin, vector::const_iterator end); void parseACLs(); extern RecursorStats g_stats; @@ -1165,7 +1168,7 @@ extern std::atomic g_maxCacheEntries, g_maxPacketCacheEntries; extern bool g_lowercaseOutgoing; -std::string reloadAuthAndForwards(); +std::string reloadZoneConfiguration(); typedef boost::function pipefunc_t; void broadcastFunction(const pipefunc_t& func); void distributeAsyncFunction(const std::string& question, const pipefunc_t& func); @@ -1178,7 +1181,10 @@ int getFakePTRRecords(const DNSName& qname, vector& ret); template T broadcastAccFunction(const boost::function& func); -std::shared_ptr parseAuthAndForwards(); +typedef std::unordered_set notifyset_t; +std::tuple, std::shared_ptr> parseZoneConfiguration(); +void* pleaseSupplantAllowNotifyFor(std::shared_ptr ns); + uint64_t* pleaseGetNsSpeedsSize(); uint64_t* pleaseGetFailedServersSize(); uint64_t* pleaseGetEDNSStatusesSize(); diff --git a/pdns/ws-recursor.cc b/pdns/ws-recursor.cc index 3c2ad0c083..84feae9286 100644 --- a/pdns/ws-recursor.cc +++ b/pdns/ws-recursor.cc @@ -79,7 +79,7 @@ static void apiWriteConfigFile(const string& filebasename, const string& content ofconf.close(); } -static void apiServerConfigAllowFrom(HttpRequest* req, HttpResponse* resp) +static void apiServerConfigACL(const std::string& aclType, HttpRequest* req, HttpResponse* resp) { if (req->method == "PUT") { Json document = req->json(); @@ -101,14 +101,14 @@ static void apiServerConfigAllowFrom(HttpRequest* req, HttpResponse* resp) ostringstream ss; - // Clear allow-from-file if set, so our changes take effect - ss << "allow-from-file=" << endl; + // Clear -from-file if set, so our changes take effect + ss << aclType << "-file=" << endl; - // Clear allow-from, and provide a "parent" value - ss << "allow-from=" << endl; - ss << "allow-from+=" << nmg.toString() << endl; + // Clear ACL setting, and provide a "parent" value + ss << aclType << "=" << endl; + ss << aclType << "+=" << nmg.toString() << endl; - apiWriteConfigFile("allow-from", ss.str()); + apiWriteConfigFile(aclType, ss.str()); parseACLs(); @@ -120,14 +120,29 @@ static void apiServerConfigAllowFrom(HttpRequest* req, HttpResponse* resp) // Return currently configured ACLs vector entries; - t_allowFrom->toStringVector(&entries); + if (aclType == "allow-from") { + t_allowFrom->toStringVector(&entries); + } + else if (aclType == "allow-notify-from") { + t_allowNotifyFrom->toStringVector(&entries); + } resp->setJsonBody(Json::object{ - {"name", "allow-from"}, + {"name", aclType}, {"value", entries}, }); } +static void apiServerConfigAllowFrom(HttpRequest* req, HttpResponse* resp) +{ + apiServerConfigACL("allow-from", req, resp); +} + +static void apiServerConfigAllowNotifyFrom(HttpRequest* req, HttpResponse* resp) +{ + apiServerConfigACL("allow-notify-from", req, resp); +} + static void fillZone(const DNSName& zonename, HttpResponse* resp) { auto iter = SyncRes::t_sstorage.domainmap->find(zonename); @@ -277,7 +292,7 @@ static void apiServerZones(HttpRequest* req, HttpResponse* resp) throw ApiException("Zone already exists"); doCreateZone(document); - reloadAuthAndForwards(); + reloadZoneConfiguration(); fillZone(zonename, resp); resp->status = 201; return; @@ -319,7 +334,7 @@ static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp) doDeleteZone(zonename); doCreateZone(document); - reloadAuthAndForwards(); + reloadZoneConfiguration(); resp->body = ""; resp->status = 204; // No Content, but indicate success } @@ -328,7 +343,7 @@ static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp) throw ApiException("Deleting domain failed"); } - reloadAuthAndForwards(); + reloadZoneConfiguration(); // empty body on success resp->body = ""; resp->status = 204; // No Content: declare that the zone is gone now @@ -609,6 +624,9 @@ const std::map MetricDefinitionStorage::d_metrics MetricDefinition(PrometheusMetricType::multicounter, "Number of milliseconds spent in thread n")}, + {"zone-disallowed-notify", + MetricDefinition(PrometheusMetricType::counter, + "Number of NOTIFY operations denied because of allow-notify-for restrictions")}, {"dnssec-authentic-data-queries", MetricDefinition(PrometheusMetricType::counter, "Number of queries received with the AD bit set")}, @@ -835,6 +853,9 @@ const std::map MetricDefinitionStorage::d_metrics {"unauthorized-udp", MetricDefinition(PrometheusMetricType::counter, "Number of UDP questions denied because of allow-from restrictions")}, + {"source-disallowed-notify", + MetricDefinition(PrometheusMetricType::counter, + "Number of NOTIFY operations denied because of allow-notify-from restrictions")}, {"unexpected-packets", MetricDefinition(PrometheusMetricType::counter, "Number of answers from remote servers that were unexpected (might point to spoofing)")}, @@ -1169,6 +1190,7 @@ RecursorWebServer::RecursorWebServer(FDMultiplexer* fdm) "/jsonstat", [this](HttpRequest* req, HttpResponse* resp) { jsonstat(req, resp); }, true); d_ws->registerApiHandler("/api/v1/servers/localhost/cache/flush", apiServerCacheFlush); d_ws->registerApiHandler("/api/v1/servers/localhost/config/allow-from", apiServerConfigAllowFrom); + d_ws->registerApiHandler("/api/v1/servers/localhost/config/allow-notify-from", &apiServerConfigAllowNotifyFrom); d_ws->registerApiHandler("/api/v1/servers/localhost/config", apiServerConfig); d_ws->registerApiHandler("/api/v1/servers/localhost/rpzstatistics", apiServerRPZStats); d_ws->registerApiHandler("/api/v1/servers/localhost/search-data", apiServerSearchData); diff --git a/regression-tests.api/runtests.py b/regression-tests.api/runtests.py index 956047e993..bf362eb4d6 100755 --- a/regression-tests.api/runtests.py +++ b/regression-tests.api/runtests.py @@ -87,6 +87,13 @@ ACL_LIST_TPL = """ ::1 """ +ACL_NOTIFY_LIST_TPL = """ +# Generated by runtests.py +# local host +127.0.0.1 +::1 +""" + REC_EXAMPLE_COM_CONF_TPL = """ # Generated by runtests.py auth-zones+=example.com=../regression-tests/zones/example.com @@ -98,6 +105,7 @@ auth-zones= forward-zones= forward-zones-recurse= allow-from-file=acl.list +allow-notify-from-file=acl-notify.list api-config-dir=%(conf_dir)s include-dir=%(conf_dir)s """ @@ -214,6 +222,8 @@ else: ensure_empty_dir(conf_dir) with open('acl.list', 'w') as acl_list: acl_list.write(ACL_LIST_TPL) + with open('acl-notify.list', 'w') as acl_notify_list: + acl_notify_list.write(ACL_NOTIFY_LIST_TPL) with open('recursor.conf', 'w') as recursor_conf: recursor_conf.write(REC_CONF_TPL % locals()) with open(conf_dir+'/example.com..conf', 'w') as conf_file: diff --git a/regression-tests.api/test_RecursorConfig.py b/regression-tests.api/test_RecursorConfig.py index d8cba15138..774d366ad0 100644 --- a/regression-tests.api/test_RecursorConfig.py +++ b/regression-tests.api/test_RecursorConfig.py @@ -4,7 +4,7 @@ from test_helper import ApiTestCase, is_recursor @unittest.skipIf(not is_recursor(), "Only applicable to recursors") -class RecursorConfig(ApiTestCase): +class RecursorAllowFromConfig(ApiTestCase): def test_config_allow_from_get(self): r = self.session.get(self.url("/api/v1/servers/localhost/config/allow-from")) @@ -32,3 +32,34 @@ class RecursorConfig(ApiTestCase): self.assertEqual(r.status_code, 422) data = r.json() self.assertIn('Unable to convert', data['error']) + + +@unittest.skipIf(not is_recursor(), "Only applicable to recursors") +class RecursorAllowNotifyFromConfig(ApiTestCase): + + def test_config_allow_notify_from_get(self): + r = self.session.get(self.url("/api/v1/servers/localhost/config/allow-notify-from")) + self.assert_success_json(r) + + def test_config_allow_notify_from_replace(self): + payload = {'value': ["127.0.0.1"]} + r = self.session.put( + self.url("/api/v1/servers/localhost/config/allow-notify-from"), + data=json.dumps(payload), + headers={'content-type': 'application/json'}) + self.assert_success_json(r) + data = r.json() + self.assertIn("value", data) + self.assertEqual(len(data["value"]), 1) + self.assertEqual("127.0.0.1/32", data["value"][0]) + + def test_config_allow_notify_from_replace_error(self): + """Test the error case, should return 422.""" + payload = {'value': ["abcdefgh"]} + r = self.session.put( + self.url("/api/v1/servers/localhost/config/allow-notify-from"), + data=json.dumps(payload), + headers={'content-type': 'application/json'}) + self.assertEqual(r.status_code, 422) + data = r.json() + self.assertIn('Unable to convert', data['error'])