Intercepting queries with Lua

To get a quick start, we have supplied a sample script that showcases all functionality described below. Please find it here.

Queries can be intercepted in many places:

  • before any packet parsing begins (ipfilter())
  • before any filtering policy have been applied (prerpz())
  • before the resolving logic starts to work (preresolve())
  • after the resolving process failed to find a correct answer for a domain (nodata(), nxdomain())
  • after the whole process is done and an answer is ready for the client (postresolve())
  • before an outgoing query is made to an authoritative server (preoutquery())

Writing Lua PowerDNS Recursor scripts

Addresses and DNS Names are not passed as strings but as native objects. This allows for easy checking against Netmasks and domain sets. It also means that to print such names, the :toString method must be used (or even :toStringWithPort for addresses).

Once a script is loaded, PowerDNS looks for several functions in the loaded script. All of these functions are optional.

If a function returns true, it will indicate that it handled a query. If it returns false, the Recursor will continue processing unchanged (with one minor exception).

Interception Functions

ipfilter(remoteip, localip, dh) → bool

This hook gets queried immediately after consulting the packet cache, but before parsing the DNS packet. If this hook returns something else than false, the packet is dropped. However, because this check is after the packet cache, the IP address might still receive answers that require no packet parsing.

With this hook, undesired traffic can be dropped rapidly before using precious CPU cycles for parsing. As an example, to filter all queries coming from 1.2.3.0/24, or with the AD bit set:

badips = newNMG()
badips:addMask("1.2.3.0/24")

function ipfilter(rem, loc, dh)
    return badips:match(rem) or dh:getAD()
end

This hook does not get the full DNSQuestion object, since filling out the fields would require packet parsing, which is what we are trying to prevent with this function.

Parameters:
  • remoteip (ComboAddress) – The IP(v6) address of the requestor
  • localip (ComboAddress) – The address on which the query arrived.
  • dh (DNSHeader) – The DNS Header of the query.
gettag(remote, ednssubnet, localip, qname, qtype, ednsoptions, tcp) → int
gettag(remote, ednssubnet, localip, qname, qtype, ednsoptions) → int

Changed in version 4.1.0: The tcp parameter was added.

The gettag function is invoked when the Recursor attempts to discover in which packetcache an answer is available.

This function must return an integer, which is the tag number of the packetcache. In addition to this integer, this function can return a table of policy tags. The resulting tag number can be accessed via dq.tag in the preresolve() hook, and the policy tags via dq:getPolicyTags() in every hook.

New in version 4.1.0: It can also return a table whose keys and values are strings to fill the DNSQuestion.data table, as well as a requestorId value to fill the DNSQuestion.requestorId field and a deviceId value to fill the DNSQuestion.deviceId field.

The tagged packetcache can e.g. be used to answer queries from cache that have e.g. been filtered for certain IPs (this logic should be implemented in gettag()). This ensure that queries are answered quickly compared to setting dq.variable to true. In the latter case, repeated queries will pass through the entire Lua script.

Parameters:
  • remote (ComboAddress) – The sender’s IP address
  • ednssubnet (Netmask) – The EDNS Client subnet that was extracted from the packet
  • localip (ComboAddress) – The IP address the query was received on
  • qname (DNSName) – The domain name the query is for
  • qtype (int) – The query type of the query
  • ednsoptions – A table whose keys are EDNS option codes and values are EDNSOptionView objects. This table is empty unless the gettag-needs-edns-options option is set.
  • tcp (bool) – Added in 4.1.0, a boolean indicating whether the query was received over UDP (false) or TCP (true).
prerpz(dq)

This hook is called before any filtering policy have been applied, making it possible to completely disable filtering by setting dq.wantsRPZ to false. Using the dq:discardPolicy() function, it is also possible to selectively disable one or more filtering policy, for example RPZ zones, based on the content of the dq object.

As an example, to disable the “malware” policy for example.com queries:

function prerpz(dq)
  -- disable the RPZ policy named 'malware' for example.com
  if dq.qname:equal('example.com') then
    dq:discardPolicy('malware')
  end
  return false
end
Parameters:dq (DNSQuestion) – The DNS question to handle
preresolve(dq)

This function is called before any DNS resolution is attempted, and if this function indicates it, it can supply a direct answer to the DNS query, overriding the internet. This is useful to combat botnets, or to disable domains unacceptable to an organization for whatever reason.

Parameters:dq (DNSQuestion) – The DNS question to handle
postresolve(dq)

is called right before returning a response to a client (and, unless dq.variable is set, to the packet cache too). It allows inspection and modification of almost any detail in the return packet.

Parameters:dq (DNSQuestion) – The DNS question to handle
nxdomain(dq)

is called after the DNS resolution process has run its course, but ended in an ‘NXDOMAIN’ situation, indicating that the domain does not exist. Works entirely like postresolve(), but saves a trip through Lua for answers which are not NXDOMAIN.

Parameters:dq (DNSQuestion) – The DNS question to handle
nodata(dq)

is just like nxdomain(), except it gets called when a domain exists, but the requested type does not. This is where one would implement DNS64.

Parameters:dq (DNSQuestion) – The DNS question to handle
preoutquery(dq)

This hook is not called in response to a client packet, but fires when the Recursor wants to talk to an authoritative server. When this hook sets the special result code -3, the whole DNS client query causing this outquery gets dropped.

However, this function can also return records like preresolve().

Parameters:dq (DNSQuestion) – The DNS question to handle

Semantics

The functions must return true if they have taken over the query and wish that the nameserver should not proceed with its regular query-processing. When a function returns false, the nameserver will process the query normally until a new function is called.

If a function has taken over a request, it should set an rcode (usually 0), and specify a table with records to be put in the answer section of a packet. An interesting rcode is NXDOMAIN (3, or pdns.NXDOMAIN), which specifies the non-existence of a domain.

The ipfilter() and preoutquery() hooks are different, in that ipfilter() can only return a true of false value, and that preoutquery() can also set rcode -3 to signify that the whole query should be terminated.

A minimal sample script:

function nxdomain(dq)
    print("Intercepting NXDOMAIN for: ",dq.qname:toString())
    if dq.qtype == pdns.A
    then
        dq.rcode=0 -- make it a normal answer
        dq:addAnswer(pdns.A, "192.168.1.1")
        return true
    end
    return false
end

Warning: Please do NOT use the above sample script in production! Responsible NXDomain redirection requires more attention to detail.

Useful ‘rcodes’ include 0 for “no error”, pdns.NXDOMAIN for “NXDOMAIN”, pdns.DROP to drop the question from further processing. Such a drop is accounted in the ‘policy-drops’ metric.

DNS64

The getFakeAAAARecords and getFakePTRRecords followupFunctions can be used to implement DNS64. See DNS64 support for more information.

To get fake AAAA records for DNS64 usage, set dq.followupFunction to getFakeAAAARecords, dq.followupPrefix to e.g. “64:ff9b::” and dq.followupName to the name you want to synthesize an IPv6 address for.

For fake reverse (PTR) records, set dq.followupFunction to getFakePTRRecords and set dq.followupName to the name to look up and dq.followupPrefix to the same prefix as used with getFakeAAAARecords.

Follow up actions

When modifying queries, it might be needed that the Recursor does some extra work after the function returns. The dq.followupFunction can be set in this case.

CNAME chain resolution

It may be useful to return a CNAME record for Lua, and then have the PowerDNS Recursor continue resolving that CNAME. This can be achieved by setting dq.followupFunction to followCNAMERecords and dq.followupDomain to “www.powerdns.com”. PowerDNS will do the rest.

UDP Query Response

The udpQueryResponse dq.followupFunction allows you to query a simple key-value store over UDP asynchronously.

Several dq variables can be set:

  • dq.udpQueryDest: destination IP address to send the UDP packet to
  • dq.udpQuery: The content of the UDP payload
  • dq.udpCallback: The name of the callback function that is called when an answer is received

The callback function must accept the dq object and can find the response to the UDP query in dq.udpAnswer.

In this callback function, dq.followupFunction can be set again to any of the available functions for further processing.

This example script queries a simple key/value store over UDP to decide on whether or not to filter a query:

--[[ 
This implements a two-step domain filtering solution where the status of an IP address
and a domain name need to be looked up.
To do so, we use the udpQuestionResponse answers which generically allows us to do asynchronous
lookups via UDP.
Such lookups can be slow, but they won't block PowerDNS while we wait for them.

To benefit from this hook, 
..

To test, use the 'kvresp' example program provided.
--]]

function preresolve (dq)
	print ("prereesolve handler called for: "..dq.remoteaddr:toString().. ", local: ".. dq.localaddr:toString()..", ".. dq.qname:toString()..", ".. dq.qtype)
	dq.followupFunction="udpQueryResponse"
	dq.udpCallback="gotdomaindetails"
	dq.udpQueryDest=newCA("127.0.0.1:5555")
	dq.udpQuery = "DOMAIN "..dq.qname:toString()
	return true;
end

function gotdomaindetails(dq)
	print("gotdomaindetails called, got: "..dq.udpAnswer)
        if(dq.udpAnswer == "0") 
        then
                print("This domain needs no filtering, not looking up this domain")
                dq.followupFunction=""   
                return false
        end
        print("Domain might need filtering for some users")
        dq.variable = true -- disable packet cache
	local data={}
	data["domaindetails"]= dq.udpAnswer
	dq.data=data 
	dq.udpQuery="IP "..dq.remoteaddr:toString()
	dq.udpCallback="gotipdetails"
	print("returning true in gotipdetails")
	return true
end

function gotipdetails(dq)
        dq.followupFunction=""
	print("So status of IP is "..dq.udpAnswer.." and status of domain is "..dq.data.domaindetails)
	if(dq.data.domaindetails=="1" and dq.udpAnswer=="1")
	then
		print("IP wants filtering and domain is of the filtered kind")
		dq:addAnswer(pdns.CNAME, "blocked.powerdns.com")
		return true
	else
                print("Returning false (normal resolution should proceed, for this user)")
		return false
	end
end

Example Script

pdnslog("pdns-recursor Lua script starting!", pdns.loglevels.Warning)

blockset = newDS()
blockset:add{"powerdns.org", "xxx"}

dropset = newDS();
dropset:add("123.cn")

malwareset = newDS()
malwareset:add("nl")

magic2 = newDN("www.magic2.com")


magicMetric = getMetric("magic")

-- shows the various ways of blocking, dropping, changing questions
-- return false to say you did not take over the question, but we'll still listen to 'variable'
-- to selectively disable the cache
function preresolve(dq)
	print("Got question for "..dq.qname:toString().." from "..dq.remoteaddr:toString().." to "..dq.localaddr:toString())

        local ednssubnet=dq:getEDNSSubnet()
	if(ednssubnet) then
        	print("Packet EDNS subnet source: "..ednssubnet:toString()..", "..ednssubnet:getNetwork():toString())
        end
                                        

	local a=dq:getEDNSOption(3)
	if(a) then
		print("There is an EDNS option 3 present: "..a)
	end

	loc = newCA("127.0.0.1")
	if(dq.remoteaddr:equal(loc))
	then
		print("Query from loopback")
	end

	-- note that the comparisons below are CaSe InSensiTivE and you don't have to worry about trailing dots
	if(dq.qname:equal("magic.com"))
	then
		magicMetric:inc()
		print("Magic!")
	else
		print("not magic..")
	end

	if(dq.qname:__eq(magic2)) -- we hope to improve this syntax
	then
		print("Faster magic") -- compares against existing DNSName
	end                           -- sadly, dq.qname == magic2 won't work yet
        
        if blockset:check(dq.qname) then
                dq.variable = true  -- disable packet cache in any case
                if dq.qtype == pdns.A then
	        	dq:addAnswer(pdns.A, "1.2.3.4")
        		dq:addAnswer(pdns.TXT, "\"Hello!\"", 3601) -- ttl    	
        		return true;
        	end
        end
        
        if dropset:check(dq.qname) then
        	dq.rcode = pdns.DROP  
        	return true;
        end

	        
        
        if malwareset:check(dq.qname) then
		dq:addAnswer(pdns.CNAME, "xs.powerdns.com.")
        	dq.rcode = 0
        	dq.followupFunction="followCNAMERecords"    -- this makes PowerDNS lookup your CNAME
        	return true;
        end        
        
	return false; 
end


-- this implements DNS64

function nodata(dq)
        if dq.qtype == pdns.AAAA then
        	dq.followupFunction="getFakeAAAARecords"
        	dq.followupName=dq.qname
        	dq.followupPrefix="fe80::"
        	return true
        end
        
        if dq.qtype == pdns.PTR then
        	dq.followupFunction="getFakePTRRecords"
        	dq.followupName=dq.qname
        	dq.followupPrefix="fe80::"
        	return true
        end        
	return false
end


badips = newNMG()
badips:addMask("127.1.0.0/16")

-- this check is applied before any packet parsing is done
function ipfilter(rem, loc, dh)
	print("ipfilter called, rem: ", rem:toStringWithPort(), "loc: ",loc:toStringWithPort(),"match:", badips:match(rem))
	print("id: ",dh:getID(), "aa: ", dh:getAA(), "ad: ", dh:getAD(), "arcount: ", dh:getARCOUNT())
	print("ports: ",rem:getPort(),loc:getPort())
	return badips:match(rem)
end

-- postresolve runs after the packet has been answered, and can be used to change things
-- or still drop
function postresolve(dq)
	print("postresolve called for ",dq.qname:toString())
	local records = dq:getRecords()
	for k,v in pairs(records) do
		print(k, v.name:toString(), v:getContent())
		if v.type == pdns.A and v:getContent() == "185.31.17.73"
		then
			print("Changing content!")
			v:changeContent("130.161.252.29")
			v.ttl=1
		end
	end
	dq:setRecords(records)
	return true
end

nxdomainsuffix=newDN("com")

function nxdomain(dq)
	print("Hooking: ",dq.qname:toString())
	if dq.qname:isPartOf(nxdomainsuffix)
	then
		dq.rcode=0 -- make it a normal answer
		dq:addAnswer(pdns.CNAME, "ourhelpfulservice.com")
		dq:addAnswer(pdns.A, "1.2.3.4", 60, "ourhelpfulservice.com")
		return true
	end
	return false
end

Dropping all traffic from botnet-infected users

Frequently, DoS attacks are performed where specific IP addresses are attacked, often by queries coming in from open resolvers. These queries then lead to a lot of queries to ‘authoritative servers’ which actually often aren’t nameservers at all, but just targets of attack.

The following script will add a requestor’s IP address to a blocking set if they’ve sent a query that caused PowerDNS to attempt to talk to a certain subnet.

This specific script is, as of January 2015, useful to prevent traffic to ezdns.it related traffic from creating CPU load. This script requires PowerDNS Recursor 4.x or later.

lethalgroup=newNMG()
lethalgroup:addMask("192.121.121.0/24") -- touch these nameservers and you die

function preoutquery(dq)
    print("pdns wants to ask "..dq.remoteaddr:toString().." about "..dq.qname:toString().." "..dq.qtype.." on behalf of requestor "..dq.localaddr:toString())
    if(lethalgroup:match(dq.remoteaddr))
    then
        print("We matched the group "..lethalgroup:tostring().."!", "killing query dead & adding requestor "..dq.localaddr:toString().." to block list")
        dq.rcode = -3 -- "kill"
        return true
    end
    return false
end

Modifying Policy Decisions

The PowerDNS Recursor has a policy engine based on Response Policy Zones (RPZ). Starting with version 4.0.1 of the recursor, it is possible to alter this decision inside the Lua hooks.

If the decision is modified in a Lua hook, false should be returned, as the query is not actually handled by Lua so the decision is picked up by the Recursor. The result of the policy decision is checked after preresolve() and postresolve().

For example, if a decision is set to pdns.policykinds.NODATA by the policy engine and is unchanged in preresolve(), the query is replied to with a NODATA response immediately after preresolve().

Example script

-- Dont ever block my own domain and IPs
myDomain = newDN("example.com")

myNetblock = newNMG()
myNetblock:addMasks({"192.0.2.0/24"})

function preresolve(dq)
  if dq.qname:isPartOf(myDomain) and dq.appliedPolicy.policyKind ~= pdns.policykinds.NoAction then
    pdnslog("Not blocking our own domain!")
    dq.appliedPolicy.policyKind = pdns.policykinds.NoAction
  end
  return false
end

function postresolve(dq)
  if dq.appliedPolicy.policyKind ~= pdns.policykinds.NoAction then
    local records = dq:getRecords()
    for k,v in pairs(records) do
      if v.type == pdns.A then
        local blockedIP = newCA(v:getContent())
        if myNetblock:match(blockedIP) then
          pdnslog("Not blocking our IP space")
          dq.appliedPolicy.policyKind = pdns.policykinds.NoAction
        end
      end
    end
  end
  return false
end

SNMP Traps

PowerDNS Recursor, when compiled with SNMP support, has the ability to act as a SNMP agent to provide SNMP statistics and to be able to send traps from Lua.

For example, to send a custom SNMP trap containing the qname from the preresolve hook:

function preresolve(dq)
  sendCustomSNMPTrap('Trap from preresolve, qname is '..dq.qname:toString())
  return false
end

Maintenance callback

Starting with version 4.1.4 of the recursor, it is possible to define a maintenance() callback function that will be called periodically. This function expects no argument and doesn’t return any value

function maintenance()
    -- This would be called every second
    -- Perform here your maintenance
end

The interval can be configured through the lua-maintenance-interval setting.