Dynamic Rule Generation¶
Dynamic Blocks can be seen are short-lived rules, automatically inserted based on configurable thresholds and the analysis of recently received traffic, and automatically removed after a configurable amount of time.
The analyzed traffic is the one kept by dnsdist in its in-memory ring buffers. The number of entries kept in these ring buffers can be set via the setRingBuffersSize() directive, and the impact in terms of CPU and memory consumption is described in Performance Tuning.
That number of entries is crucial for the rate-based rules, like DynBlockRulesGroup:setQueryRate(), as they will never match if the number of entries in the ring buffer is too small for the required rate, as explained in more details below.
To set dynamic rules, based on recent traffic, define a function called maintenance() in Lua.
It will get called every second, and from this function you can set rules to block traffic based on statistics.
More exactly, the thread handling the maintenance() function will sleep for one second between each invocation, so if the function takes several seconds to complete it will not be invoked exactly every second.
As an example:
local dbr = dynBlockRulesGroup()
dbr:setQueryRate(20, 10, "Exceeded query rate", 60)
function maintenance()
dbr:apply()
end
This will dynamically block all hosts that exceeded 20 queries/s as measured over the past 10 seconds, and the dynamic block will last for 60 seconds.
DynBlockRulesGroup is a very efficient way of processing dynamic blocks that was introduced in 1.3.0. Before that, it was possible to use addDynBlocks() instead:
-- this is a legacy method, please see above for DNSdist >= 1.3.0
function maintenance()
addDynBlocks(exceedQRate(20, 10), "Exceeded query rate", 60)
end
Dynamic blocks in force are displayed with showDynBlocks() and can be cleared with clearDynBlocks().
They return a table whose key is a ComboAddress object, representing the client’s source address, and whose value is an integer representing the number of queries matching the corresponding condition (for example the qtype for exceedQTypeRate(), rcode for exceedServFails()).
All exceed-functions are documented in the Configuration Reference.
Dynamic blocks drop matched queries by default, but this behavior can be changed with setDynBlocksAction().
For example, to send a REFUSED code instead of dropping the query:
setDynBlocksAction(DNSAction.Refused)
Please see the documentation for setDynBlocksAction() to confirm which actions are supported.
DynBlockRulesGroup¶
Starting with dnsdist 1.3.0, a new dynBlockRulesGroup() function can be used to return a DynBlockRulesGroup instance,
designed to make the processing of multiple rate-limiting rules faster by walking the query and response buffers only once
for each invocation, instead of once per existing exceed*() invocation.
The new syntax would be:
local dbr = dynBlockRulesGroup()
dbr:setQueryRate(30, 10, "Exceeded query rate", 60)
dbr:setRCodeRate(DNSRCode.NXDOMAIN, 20, 10, "Exceeded NXD rate", 60)
dbr:setRCodeRate(DNSRCode.SERVFAIL, 20, 10, "Exceeded ServFail rate", 60)
dbr:setQTypeRate(DNSQType.ANY, 5, 10, "Exceeded ANY rate", 60)
dbr:setResponseByteRate(10000, 10, "Exceeded resp BW rate", 60)
function maintenance()
dbr:apply()
end
Before 1.3.0 the legacy syntax was:
function maintenance()
-- this example is using legacy methods, please see above for DNSdist >= 1.3.0
addDynBlocks(exceedQRate(30, 10), "Exceeded query rate", 60)
addDynBlocks(exceedNXDOMAINs(20, 10), "Exceeded NXD rate", 60)
addDynBlocks(exceedServFails(20, 10), "Exceeded ServFail rate", 60)
addDynBlocks(exceedQTypeRate(DNSQType.ANY, 5, 10), "Exceeded ANY rate", 60)
addDynBlocks(exceedRespByterate(1000000, 10), "Exceeded resp BW rate", 60)
end
The old syntax would walk the query buffer 2 times and the response one 3 times, while the new syntax does it only once for each. It also reuse the same internal table to keep track of the source IPs, reducing the CPU usage.
DynBlockRulesGroup also offers the ability to specify that some network ranges should be excluded from dynamic blocking:
-- do not add dynamic blocks for hosts in the 192.0.2.0/24 and 2001:db8::/32 ranges
dbr:excludeRange({"192.0.2.0/24", "2001:db8::/32" })
-- except for 192.0.2.1
dbr:includeRange("192.0.2.1/32")
Since 1.3.3, it’s also possible to define a warning rate. When the query or response rate raises above the warning level but below the trigger level, a warning message will be issued along with a no-op block. If the rate reaches the trigger level, the regular action is applied.
local dbr = dynBlockRulesGroup()
-- Generate a warning if we detect a query rate above 100 qps for at least 10s.
-- If the query rate raises above 300 qps for 10 seconds, we'll block the client for 60s.
dbr:setQueryRate(300, 10, "Exceeded query rate", 60, DNSAction.Drop, 100)
Since 1.6.0, if a default eBPF filter has been set via setDefaultBPFFilter() dnsdist will automatically try to use it when a “drop” dynamic block is inserted via a DynBlockRulesGroup. eBPF blocks are applied in kernel space and are much more efficient than user space ones. Note that a regular block is also inserted so that any failure will result in a regular block being used instead of the eBPF one.
Rate rules and size of the ring buffers¶
As explained in the introduction, the whole dynamic block feature is based on analyzing the recent traffic kept in dnsdist’s in-memory ring buffers, whose content can be inspected via grepq().
The sizing of the buffers, in addition to having performance impacts explained in Performance Tuning, directly impacts some of the dynamic block rules, like the rate and ratio-based ones.
For example, if DynBlockRulesGroup:setQueryRate is used to request the blocking for 60s of any client exceeding 1000 qps over 10s, like this:
dbr:setQueryRate(1000, 10, "Exceeded query rate", 60, DNSAction.Drop)
For this rule to trigger, dnsdist will need to scan the ring buffers and find 1000 * 10 = 10000 queries, not older than 10s, from that client. Since a ring buffer has a fixed size, and new entries override the oldest ones when the buffer is full, that only works if there are enough entries in the buffer.
This is even more obvious for the ratio-based rules, when they have a minimum number of responses set, because in that case they clearly require that number of responses to fit in the buffer.
That requirement could be lifted a bit by the use of sampling, meaning that only one query out of 10 would be recorded, for example, and the total amount would be inferred from the queries present in the buffer. As of 1.7.0, sampling as unfortunately not been implemented yet.
Triggering Dynamic Blocks from an External System¶
In some deployments an external detection system (a traffic analyzer, monitoring platform, or similar) identifies abusive sources and needs to push blocks into dnsdist over the network.
The built-in webserver combined with registerWebHandler() makes this possible without any additional software.
The following Lua snippet exposes two custom HTTP endpoints: one to add a dynamic block and one to remove it.
It expects the webserver to already be configured (see Built-in webserver) and uses addDynamicBlock() which is available since dnsdist 1.9.0.
-- Extract a quoted value for a given key from a JSON string.
-- This is intentionally minimal; for complex payloads consider
-- installing a Lua JSON library such as lua-cjson.
local function jsonValue(body, key)
return body:match('"' .. key .. '"%s*:%s*"([^"]+)"')
end
local function jsonNumberValue(body, key)
return tonumber(body:match('"' .. key .. '"%s*:%s*(%d+)'))
end
-- Constant-time string comparison to prevent timing attacks on the
-- API key. Iterates every byte regardless of where a mismatch occurs.
local API_KEY = 'your-secret-key-here'
local function safeEquals(a, b)
if #a ~= #b then return false end
local mismatch = 0
for i = 1, #a do
if string.byte(a, i) ~= string.byte(b, i) then
mismatch = mismatch + 1
end
end
return mismatch == 0
end
local function checkAuth(req, resp)
local key = req.headers['x-api-key'] or ''
if not safeEquals(key, API_KEY) then
resp.status = 403
resp.body = '{"error":"forbidden"}'
return false
end
return true
end
function blockHandler(req, resp)
if req.method ~= 'POST' then
resp.status = 405
resp.body = '{"error":"method not allowed"}'
return
end
if not checkAuth(req, resp) then return end
local cidr = jsonValue(req.body, 'cidr')
local duration = jsonNumberValue(req.body, 'duration')
local reason = jsonValue(req.body, 'reason') or 'external block'
if not cidr or not duration then
resp.status = 400
resp.body = '{"error":"missing cidr or duration"}'
return
end
addDynamicBlock(newCA(cidr), reason, DNSAction.Refused, duration)
resp.status = 200
resp.body = '{"status":"blocked","cidr":"' .. cidr .. '"}'
resp.headers = {['Content-Type']='application/json'}
end
function unblockHandler(req, resp)
if req.method ~= 'POST' then
resp.status = 405
resp.body = '{"error":"method not allowed"}'
return
end
if not checkAuth(req, resp) then return end
local cidr = jsonValue(req.body, 'cidr')
if not cidr then
resp.status = 400
resp.body = '{"error":"missing cidr"}'
return
end
-- There is no per-entry removal function, so we snapshot active
-- blocks, clear all, and re-add every entry except the target.
-- The brief window between clear and re-add is normally sub-ms.
local now = os.time()
local current = getDynamicBlocks()
clearDynBlocks()
for addr, block in pairs(current) do
if addr ~= cidr then
local remaining = block.until - now
if remaining > 0 then
addDynamicBlock(newCA(addr), block.reason, block.action, remaining)
end
end
end
resp.status = 200
resp.body = '{"status":"unblocked","cidr":"' .. cidr .. '"}'
resp.headers = {['Content-Type']='application/json'}
end
registerWebHandler('/api/v1/dynblock', blockHandler)
registerWebHandler('/api/v1/dynunblock', unblockHandler)
With the above in place, an external system can insert a 300-second block via a single HTTP call:
curl -s -X POST http://192.0.2.1:8083/api/v1/dynblock \
-H 'X-API-Key: your-secret-key-here' \
-H 'Content-Type: application/json' \
-d '{"cidr": "198.51.100.0/24", "duration": 300, "reason": "DDoS source"}'
And remove it early:
curl -s -X POST http://192.0.2.1:8083/api/v1/dynunblock \
-H 'X-API-Key: your-secret-key-here' \
-H 'Content-Type: application/json' \
-d '{"cidr": "198.51.100.0/24"}'
Note
The safeEquals function above is a Lua workaround for constant-time comparison.
The standard ~= operator returns early on the first mismatched byte, which can
theoretically leak the key via timing analysis. Restricting access to the webserver
via the acl option of setWebserverConfig() and placing dnsdist behind a
TLS-terminating reverse proxy are still recommended for production deployments.
Note
The unblock handler snapshots all active blocks with getDynamicBlocks(), clears
them with clearDynBlocks(), then re-adds every entry except the target. There is
a brief window (typically sub-millisecond) where no blocks are enforced. If this is
unacceptable, consider letting blocks expire naturally instead of removing them early.