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 action
  • pattern(action, pattern) - applies the action if QNAME matches a regular expression
  • suffix(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, see policy.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 rules
  • DENY - reply NXDOMAIN authoritatively
  • DENY_MSG(msg) - reply NXDOMAIN authoritatively and add explanatory message to additional section
  • DROP - terminate query resolution and return SERVFAIL to the requestor
  • REFUSE - terminate query resolution and return REFUSED to the requestor
  • TC - set TC=1 if the request came through UDP, forcing client to retry with TCP
  • FORWARD(ip) - resolve a query via forwarding to an IP while validating and caching locally
  • TLS_FORWARD({{ip, authentication}}) - resolve a query via TLS connection forwarding to an IP while validating and caching locally
  • STUB(ip) - similar to FORWARD(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.

Example configuration: graft DNS sub-trees 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.

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 is DENY
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 a NODATA answer. For now, policy.DENY action doing NXDOMAIN is typically used instead.
Policy Trigger Support
QNAME yes
CLIENT-IP partial, may be done with views
IP no
NSDNAME no
NS-IP no
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). A slice_func is called to determine which slice a query belongs to. The corresponding action 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, use os.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' }