Query policies¶
This module can block, rewrite, or alter inbound queries based on user-defined policies.
Each policy rule has two parts: a filter and an action. A filter selects which queries will be affected by the policy, and action which modifies queries matching the associated filter.
Typically a rule is defined as follows: filter(action(action parameters), filter parameters)
. For example, a filter can be suffix
which matches queries whose suffix part is in specified set, and one of possible actions is DENY
, which denies resolution. These are combined together into policy.suffix(policy.DENY, {todname('badguy.example.')})
. The rule is effective when it is added into rule table using policy.add()
, please see Policy examples.
This module is enabled by default because it implements mandatory RFC 6761 logic.
When no rule applies to a query, built-in rules for special-use and locally-served domain names are applied.
These rules can be overriden by action PASS
, see Policy examples below. For debugging purposes you can also add modules.unload('policy')
to your config to unload the module.
Filters¶
A filter selects which queries will be affected by specified action. There are several policy filters available in the policy.
table:
all(action)
- always applies the actionpattern(action, pattern)
- applies the action if QNAME matches a regular expressionsuffix(action, table)
- applies the action if QNAME suffix matches one of suffixes in the table (useful for “is domain in zone” rules), uses Aho-Corasick string matching algorithm from CloudFlare (BSD 3-clause)policy.suffix_common
rpz(default_action, path)
- implements a subset of RPZ in zonefile format. See below for details:policy.rpz
.slice(slice_func, action, action, ...)
- splits the entire domain space into multiple slices, uses the slicing function to determine to which slice does the query belong, and perfroms the corresponding action. For details, seepolicy.slice
.- custom filter function
Actions¶
An action is function which modifies DNS query, and is either of type chain or non-chain. So-called chain actions modify the query and allow other rules to evaluate and modify the same query. Non-chain actions have opposite behavior, i.e. modify the query and stop rule processing.
Resolver comes with several actions available in the policy.
table:
Non-chain actions¶
Following actions stop the policy matching on the query, i.e. other rules are not evaluated once rule with following actions matches:
PASS
- let the query pass through; it’s useful to make exceptions before wider rulesDENY
- reply NXDOMAIN authoritativelyDENY_MSG(msg)
- reply NXDOMAIN authoritatively and add explanatory message to additional sectionDROP
- terminate query resolution and return SERVFAIL to the requestorREFUSE
- terminate query resolution and return REFUSED to the requestorTC
- set TC=1 if the request came through UDP, forcing client to retry with TCPFORWARD(ip)
- resolve a query via forwarding to an IP while validating and caching locallyTLS_FORWARD({{ip, authentication}})
- resolve a query via TLS connection forwarding to an IP while validating and caching locallySTUB(ip)
- similar toFORWARD(ip)
but without attempting DNSSEC validation. Each request may be either answered from cache or simply sent to one of the IPs with proxying back the answer.REROUTE({{subnet,target}, ...})
- reroute addresses in response matching given subnet to given target, e.g.{'192.0.2.0/24', '127.0.0.0'}
will rewrite ‘192.0.2.55’ to ‘127.0.0.55’, see renumber module for more information.
FORWARD
, TLS_FORWARD
and STUB
support up to four IP addresses “in a single call”.
Chain actions¶
Following actions allow to keep trying to match other rules, until a non-chain action is triggered:
MIRROR(ip)
- mirror query to given IP and continue solving it (useful for partial snooping).QTRACE
- pretty-print DNS response packets into the log for the query and its sub-queries. It’s useful for debugging weird DNS servers.FLAGS(set, clear)
- set and/or clear some flags for the query. There can be multiple flags to set/clear. You can just pass a single flag name (string) or a set of names.
Also, it is possible to write your own action (i.e. Lua function). It is possible to implement complex heuristics, e.g. to deflect Slow drip DNS attacks or gray-list resolution of misbehaving zones.
Warning
The policy module currently only looks at whole DNS requests. The rules won’t be re-applied e.g. when following CNAMEs.
Note
The module (and kres
) expects domain names in wire format, not textual representation. So each label in name is prefixed with its length, e.g. “example.com” equals to "\7example\3com"
. You can use convenience function todname('example.com')
for automatic conversion.
Forwarding over TLS protocol (DNS-over-TLS)¶
Policy TLS_FORWARD allows you to forward queries using Transport Layer Security protocol, which hides the content of your queries from an attacker observing the network traffic. Further details about this protocol can be found in RFC 7858 and IETF draft dprive-dtls-and-tls-profiles.
Queries affected by TLS_FORWARD policy will always be resolved over TLS connection. Knot Resolver does not implement fallback to non-TLS connection, so if TLS connection cannot be established or authenticated according to the configuration, the resolution will fail.
To test this feature you need to either configure Knot Resolver as DNS-over-TLS server, or pick some public DNS-over-TLS server. Please see DNS Privacy Project homepage for list of public servers.
Note
Some public DNS-over-TLS providers may apply rate-limiting which makes their service incompatible with Knot Resolver’s TLS forwarding. Notably, Google Public DNS doesn’t work as of 2019-07-10.
When multiple servers are specified, the one with the lowest round-trip time is used.
CA+hostname authentication¶
Traditional PKI authentication requires server to present certificate with specified hostname, which is issued by one of trusted CAs. Example policy is:
policy.TLS_FORWARD({
{'2001:DB8::d0c', hostname='res.example.com'}})
hostname
must be a valid domain name matching server’s certificate. It will also be sent to the server as SNI.ca_file
optionally contains a path to a CA certificate (or certificate bundle) in PEM format. If you omit that, the system CA certificate store will be used instead (usually sufficient). A list of paths is also accepted, but all of them must be valid PEMs.
Key-pinned authentication¶
Instead of CAs, you can specify hashes of accepted certificates in pin_sha256
.
They are in the usual format – base64 from sha256.
You may still specify hostname
if you want SNI to be sent.
TLS Examples¶
modules = { 'policy' }
-- forward all queries over TLS to the specified server
policy.add(policy.all(policy.TLS_FORWARD({{'192.0.2.1', pin_sha256='YQ=='}})))
-- for brevity, other TLS examples omit policy.add(policy.all())
-- single server authenticated using its certificate pin_sha256
policy.TLS_FORWARD({{'192.0.2.1', pin_sha256='YQ=='}}) -- pin_sha256 is base64-encoded
-- single server authenticated using hostname and system-wide CA certificates
policy.TLS_FORWARD({{'192.0.2.1', hostname='res.example.com'}})
-- single server using non-standard port
policy.TLS_FORWARD({{'192.0.2.1@443', pin_sha256='YQ=='}}) -- use @ or # to specify port
-- single server with multiple valid pins (e.g. anycast)
policy.TLS_FORWARD({{'192.0.2.1', pin_sha256={'YQ==', 'Wg=='}})
-- multiple servers, each with own authenticator
policy.TLS_FORWARD({ -- please note that { here starts list of servers
{'192.0.2.1', pin_sha256='Wg=='},
-- server must present certificate issued by specified CA and hostname must match
{'2001:DB8::d0c', hostname='res.example.com', ca_file='/etc/knot-resolver/tlsca.crt'}
})
Forwarding to multiple targets¶
With the use of policy.slice
function, it is possible to split the
entire DNS namespace into distinct slices. When used in conjuction with
policy.TLS_FORWARD
, it’s possible to forward different queries to different
targets.
policy.add(policy.slice(
policy.slice_randomize_psl(),
policy.TLS_FORWARD({{'192.0.2.1', hostname='res.example.com'}}),
policy.TLS_FORWARD({
-- multiple servers can be specified for a single slice
-- the one with lowest round-trip time will be used
{'193.17.47.1', hostname='odvr.nic.cz'},
{'185.43.135.1', hostname='odvr.nic.cz'},
})
))
Note
The privacy implications of using this feature aren’t clear. Since websites often make requests to multiple domains, these might be forwarded to different targets. This could result in decreased privacy (e.g. when the remote targets are both logging or otherwise processing your DNS traffic). The intended use-case is to use this feature with semi-trusted resolvers which claim to do no logging (such as those listed on dnsprivacy.org), to decrease the potential exposure of your DNS data to a malicious resolver operator.
Policy examples¶
-- Whitelist 'www[0-9].badboy.cz'
policy.add(policy.pattern(policy.PASS, '\4www[0-9]\6badboy\2cz'))
-- Block all names below badboy.cz
policy.add(policy.suffix(policy.DENY, {todname('badboy.cz.')}))
-- Custom rule
local ffi = require('ffi')
local function genRR (state, req)
local answer = req.answer
local qry = req:current()
if qry.stype ~= kres.type.A then
return state
end
ffi.C.kr_pkt_make_auth_header(answer)
answer:rcode(kres.rcode.NOERROR)
answer:begin(kres.section.ANSWER)
answer:put(qry.sname, 900, answer:qclass(), kres.type.A, '\192\168\1\3')
return kres.DONE
end
policy.add(policy.suffix(genRR, { todname('my.example.cz.') }))
-- Disallow ANY queries
policy.add(function (req, query)
if query.stype == kres.type.ANY then
return policy.DROP
end
end)
-- Enforce local RPZ
policy.add(policy.rpz(policy.DENY, 'blacklist.rpz'))
-- Forward all queries below 'company.se' to given resolver;
-- beware: typically this won't work due to DNSSEC - see "Replacing part..." below
policy.add(policy.suffix(policy.FORWARD('192.168.1.1'), {todname('company.se')}))
-- Forward reverse queries about the 192.168.1.1/24 space to .1 port 5353
-- and do it directly without attempts to validate DNSSEC etc.
policy.add(policy.suffix(policy.STUB('192.168.1.1@5353'), {todname('1.168.192.in-addr.arpa')}))
-- Forward all queries matching pattern
policy.add(policy.pattern(policy.FORWARD('2001:DB8::1'), '\4bad[0-9]\2cz'))
-- Forward all queries (to public resolvers https://www.nic.cz/odvr)
policy.add(policy.all(policy.FORWARD({'2001:148f:fffe::1', '193.14.47.1'})))
-- Print all responses with matching suffix
policy.add(policy.suffix(policy.QTRACE, {todname('rhybar.cz.')}))
-- Print all responses
policy.add(policy.all(policy.QTRACE))
-- Mirror all queries and retrieve information
local rule = policy.add(policy.all(policy.MIRROR('127.0.0.2')))
-- Print information about the rule
print(string.format('id: %d, matched queries: %d', rule.id, rule.count)
-- Reroute all addresses found in answer from 192.0.2.0/24 to 127.0.0.x
-- this policy is enforced on answers, therefore 'postrule'
local rule = policy.add(policy.REROUTE({'192.0.2.0/24', '127.0.0.0'}), true)
-- Delete rule that we just created
policy.del(rule.id)
Replacing part of the DNS tree¶
You may want to resolve most of the DNS namespace by usual means while letting some other resolver solve specific subtrees.
Such data would typically be rejected by DNSSEC validation starting from the ICANN root keys. Therefore, if you trust the resolver and your link to it, you can simply use the STUB
action instead of FORWARD
to avoid validation only for those subtrees.
Another issue is caused by caching, because Knot Resolver only keeps a single cache for everything. For example, if you add an alternative top-level domain while using the ICANN root zone for the rest, at some point the cache may obtain records proving that your top-level domain does not exist, and those records could then be used when the positive records fall out of cache. The easiest work-around is to disable reading from cache for those subtrees; the other resolver is often very close anyway.
faketldtest
, sld.example
, and internal.example.com
into existing namespace¶extraTrees = policy.todnames({'faketldtest', 'sld.example', 'internal.example.com'})
-- Beware: the rule order is important, as STUB is not a chain action.
policy.add(policy.suffix(policy.FLAGS({'NO_CACHE'}), extraTrees))
policy.add(policy.suffix(policy.STUB({'2001:db8::1'}), extraTrees))
Additional properties¶
Most properties (actions, filters) are described above.
-
policy.add
(rule, postrule)¶ Parameters: - rule – added rule, i.e.
policy.pattern(policy.DENY, '[0-9]+\2cz')
- postrule – boolean, if true the rule will be evaluated on answer instead of query
Returns: rule description
Add a new policy rule that is executed either or queries or answers, depending on the
postrule
parameter. You can then use the returned rule description to get information and unique identifier for the rule, as well as match count.- rule – added rule, i.e.
-
policy.del
(id)¶ Parameters: - id – identifier of a given rule
Returns: boolean
Remove a rule from policy list.
-
policy.suffix_common
(action, suffix_table[, common_suffix])¶ Parameters: - action – action if the pattern matches QNAME
- suffix_table – table of valid suffixes
- common_suffix – common suffix of entries in suffix_table
Like suffix match, but you can also provide a common suffix of all matches for faster processing (nil otherwise). This function is faster for small suffix tables (in the order of “hundreds”).
-
policy.rpz
(action, path, watch)¶ Parameters: - action – the default action for match in the zone; typically you want
policy.DENY
- path – path to zone file | database
- watch – boolean, if not false, the file will be reparsed and the ruleset reloaded on file change
Enforce RPZ rules. This can be used in conjunction with published blocklist feeds. The RPZ operation is well described in this Jan-Piet Mens’s post, or the Pro DNS and BIND book. Here’s compatibility table:
Policy Action RH Value Support action
is used.
yes, if action
isDENY
action
is used*.
partial [1] policy.PASS
rpz-passthru.
yes policy.DROP
rpz-drop.
yes policy.TC
rpz-tcp-only.
yes Modified anything no [1] The specification for *.
wants aNODATA
answer. For now,policy.DENY
action doingNXDOMAIN
is typically used instead.Policy Trigger Support QNAME yes CLIENT-IP partial, may be done with views IP no NSDNAME no NS-IP no - action – the default action for match in the zone; typically you want
-
policy.slice
(slice_func, action[, action[, ...])¶ Parameters: - slice_func – slicing function that returns index based on query
- action – action to be performed for the slice
This function splits the entire domain space into multiple slices (determined by the number of provided
actions
). Aslice_func
is called to determine which slice a query belongs to. The correspondingaction
is then executed.
-
policy.slice_randomize_psl
(seed = os.time() / (3600 * 24 * 7))¶ Parameters: - seed – seed for random assignment
The function initializes and returns a slicing function, which deterministically assigns
query
to a slice based on the QNAME.It utilizes the Public Suffix List to ensure domains under the same registrable domain end up in a single slice. (see example below)
seed
can be used to re-shuffle the slicing algorhitm when the slicing function is initialized. By default, the assigment is re-shuffled after one week (when resolver restart / reloads config). To force a stable distribution, pass a fixed value. To re-shuffle on every resolver restart, useos.time()
.The following example demonstrates a distribution among 3 slices:
slice 1/3: example.com a.example.com b.example.com x.b.example.com example3.com slice 2/3: example2.co.uk slice 3/3: example.co.uk a.example.co.uk
-
policy.todnames
({name, ...})¶ Param: names table of domain names in textual format Returns table of domain names in wire format converted from strings.
-- Convert single name assert(todname('example.com') == '\7example\3com\0') -- Convert table of names policy.todnames({'example.com', 'me.cz'}) { '\7example\3com\0', '\2me\2cz\0' }