diff options
Diffstat (limited to 'modules/view')
-rw-r--r-- | modules/view/.packaging/test.config | 4 | ||||
-rw-r--r-- | modules/view/README.rst | 92 | ||||
-rw-r--r-- | modules/view/addr.test.integr/deckard.yaml | 12 | ||||
-rw-r--r-- | modules/view/addr.test.integr/kresd_config.j2 | 62 | ||||
-rw-r--r-- | modules/view/addr.test.integr/module_view_addr.rpl | 79 | ||||
-rw-r--r-- | modules/view/meson.build | 11 | ||||
-rw-r--r-- | modules/view/tsig.test.integr/deckard.yaml | 12 | ||||
-rw-r--r-- | modules/view/tsig.test.integr/kresd_config.j2 | 64 | ||||
-rw-r--r-- | modules/view/tsig.test.integr/module_view_tsig.rpl | 114 | ||||
-rw-r--r-- | modules/view/view.lua | 122 |
10 files changed, 572 insertions, 0 deletions
diff --git a/modules/view/.packaging/test.config b/modules/view/.packaging/test.config new file mode 100644 index 0000000..b639fda --- /dev/null +++ b/modules/view/.packaging/test.config @@ -0,0 +1,4 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +modules.load('view') +assert(view) +quit() diff --git a/modules/view/README.rst b/modules/view/README.rst new file mode 100644 index 0000000..daffd30 --- /dev/null +++ b/modules/view/README.rst @@ -0,0 +1,92 @@ +.. SPDX-License-Identifier: GPL-3.0-or-later + +.. _mod-view: + +Views and ACLs +============== + +The :ref:`policy <mod-policy>` module implements policies for global query matching, e.g. solves "how to react to certain query". +This module combines it with query source matching, e.g. "who asked the query". This allows you to create personalized blacklists, filters and ACLs. + +There are two identification mechanisms: + +* ``addr`` + - identifies the client based on his subnet +* ``tsig`` + - identifies the client based on a TSIG key name (only for testing purposes, TSIG signature is not verified!) + +View module allows you to combine query source information with :ref:`policy <mod-policy>` rules. + +.. code-block:: lua + + view:addr('10.0.0.1', policy.suffix(policy.TC, policy.todnames({'example.com'}))) + +This example will force given client to TCP for names in ``example.com`` subtree. +You can combine view selectors with RPZ_ to create personalized filters for example. + +.. warning:: + + Beware that cache is shared by *all* requests. For example, it is safe + to refuse answer based on who asks the resolver, but trying to serve + different data to different clients will result in unexpected behavior. + Setups like **split-horizon** which depend on isolated DNS caches + are explicitly not supported. + + +Example configuration +--------------------- + +.. code-block:: lua + + -- Load modules + modules = { 'view' } + -- Whitelist queries identified by TSIG key + view:tsig('\5mykey', policy.all(policy.PASS)) + -- Block local IPv4 clients (ACL like) + view:addr('127.0.0.1', policy.all(policy.DENY)) + -- Block local IPv6 clients (ACL like) + view:addr('::1', policy.all(policy.DENY)) + -- Drop queries with suffix match for remote client + view:addr('10.0.0.0/8', policy.suffix(policy.DROP, policy.todnames({'xxx'}))) + -- RPZ for subset of clients + view:addr('192.168.1.0/24', policy.rpz(policy.PASS, 'whitelist.rpz')) + -- Do not try this - it will pollute cache and surprise you! + -- view:addr('10.0.0.0/8', policy.all(policy.FORWARD('2001:DB8::1'))) + -- Drop all IPv4 that hasn't matched + view:addr('0.0.0.0/0', policy.all(policy.DROP)) + +Rule order +---------- + +The current implementation is best understood as three separate rule chains: +vanilla ``policy.add``, ``view:tsig`` and ``view:addr``. +For each request the rules in these chains get tried one by one until a :ref:`non-chain policy action <mod-policy-actions>` gets executed. + +By default :ref:`policy module <mod-policy>` acts before ``view`` module due to ``policy`` being loaded by default. If you want to intermingle universal rules with ``view:addr``, you may simply wrap the universal policy rules in view closure like this: + +.. code-block:: lua + + view:addr('0.0.0.0/0', policy.<rule>) -- and + view:addr('::0/0', policy.<rule>) + + +Properties +---------- + +.. function:: view:addr(subnet, rule) + + :param subnet: client subnet, e.g. ``10.0.0.1`` + :param rule: added rule, e.g. ``policy.pattern(policy.DENY, '[0-9]+\2cz')`` + + Apply rule to clients in given subnet. + +.. function:: view:tsig(key, rule) + + :param key: client TSIG key domain name, e.g. ``\5mykey`` + :param rule: added rule, e.g. ``policy.pattern(policy.DENY, '[0-9]+\2cz')`` + + Apply rule to clients with given TSIG key. + + .. warning:: This just selects rule based on the key name, it doesn't verify the key or signature yet. + +.. _RPZ: https://dnsrpz.info/ diff --git a/modules/view/addr.test.integr/deckard.yaml b/modules/view/addr.test.integr/deckard.yaml new file mode 100644 index 0000000..8170ffd --- /dev/null +++ b/modules/view/addr.test.integr/deckard.yaml @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +programs: +- name: kresd + binary: kresd + additional: + - --noninteractive + templates: + - modules/view/addr.test.integr/kresd_config.j2 + - tests/integration/hints_zone.j2 + configs: + - config + - hints diff --git a/modules/view/addr.test.integr/kresd_config.j2 b/modules/view/addr.test.integr/kresd_config.j2 new file mode 100644 index 0000000..3dd8d92 --- /dev/null +++ b/modules/view/addr.test.integr/kresd_config.j2 @@ -0,0 +1,62 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +{% raw %} +modules.load('view < policy') + +view:addr('127.127.0.0/16', policy.suffix(policy.DENY_MSG("addr 127.127.0.0/16 matched com"),{"\3com\0"})) +view:addr('127.127.0.0/16', policy.suffix(policy.DENY_MSG("addr 127.127.0.0/16 matched net"),{"\3net\0"})) +policy.add(policy.all(policy.FORWARD('1.2.3.4'))) + +-- make sure DNSSEC is turned off for tests +trust_anchors.remove('.') + +-- Disable RFC5011 TA update +if ta_update then + modules.unload('ta_update') +end + +-- Disable RFC8145 signaling, scenario doesn't provide expected answers +if ta_signal_query then + modules.unload('ta_signal_query') +end + +-- Disable RFC8109 priming, scenario doesn't provide expected answers +if priming then + modules.unload('priming') +end + +-- Disable this module because it make one priming query +if detect_time_skew then + modules.unload('detect_time_skew') +end + +_hint_root_file('hints') +cache.size = 2*MB +log_level('debug') +{% endraw %} + +net = { '{{SELF_ADDR}}' } + + +{% if QMIN == "false" %} +option('NO_MINIMIZE', true) +{% else %} +option('NO_MINIMIZE', false) +{% endif %} + + +-- Self-checks on globals +assert(help() ~= nil) +assert(worker.id ~= nil) +-- Self-checks on facilities +assert(cache.count() == 0) +assert(cache.stats() ~= nil) +assert(cache.backends() ~= nil) +assert(worker.stats() ~= nil) +assert(net.interfaces() ~= nil) +-- Self-checks on loaded stuff +assert(net.list()[1].transport.ip == '{{SELF_ADDR}}') +assert(#modules.list() > 0) +-- Self-check timers +ev = event.recurrent(1 * sec, function (ev) return 1 end) +event.cancel(ev) +ev = event.after(0, function (ev) return 1 end) diff --git a/modules/view/addr.test.integr/module_view_addr.rpl b/modules/view/addr.test.integr/module_view_addr.rpl new file mode 100644 index 0000000..f9370da --- /dev/null +++ b/modules/view/addr.test.integr/module_view_addr.rpl @@ -0,0 +1,79 @@ +; SPDX-License-Identifier: GPL-3.0-or-later +; config options + stub-addr: 1.2.3.4 + query-minimization: off +CONFIG_END + +SCENARIO_BEGIN view:addr test + +RANGE_BEGIN 0 110 + ADDRESS 1.2.3.4 +ENTRY_BEGIN +MATCH opcode qtype qname +ADJUST copy_id +REPLY QR RD RA NOERROR +SECTION QUESTION +example.cz. IN A +SECTION ANSWER +example.cz. IN A 5.6.7.8 +ENTRY_END + +RANGE_END + +; policy module loaded before view module must take precedence before view +STEP 10 QUERY +ENTRY_BEGIN +REPLY RD +SECTION QUESTION +example.cz. IN A +ENTRY_END + +STEP 20 CHECK_ANSWER +ENTRY_BEGIN +MATCH flags rcode question answer +REPLY QR RD RA NOERROR +SECTION QUESTION +example.cz. IN A +SECTION ANSWER +example.cz. IN A 5.6.7.8 +ENTRY_END + +; blocked by view:addr + inner policy.suffix com +; NXDOMAIN expected +STEP 30 QUERY +ENTRY_BEGIN +REPLY RD +SECTION QUESTION +example.com. IN A +ENTRY_END + +STEP 31 CHECK_ANSWER +ENTRY_BEGIN +MATCH opcode question rcode additional +REPLY QR RD RA AA NXDOMAIN +SECTION QUESTION +example.com. IN A +SECTION ADDITIONAL +explanation.invalid. 10800 IN TXT "addr 127.127.0.0/16 matched com" +ENTRY_END + +; blocked by view:addr + inner policy.suffix net +; second view rule gets executed if policy in preceding view rule did not match +STEP 32 QUERY +ENTRY_BEGIN +REPLY RD +SECTION QUESTION +example.net. IN A +ENTRY_END + +STEP 33 CHECK_ANSWER +ENTRY_BEGIN +MATCH opcode question rcode additional +REPLY QR RD RA AA NXDOMAIN +SECTION QUESTION +example.net. IN A +SECTION ADDITIONAL +explanation.invalid. 10800 IN TXT "addr 127.127.0.0/16 matched net" +ENTRY_END + +SCENARIO_END diff --git a/modules/view/meson.build b/modules/view/meson.build new file mode 100644 index 0000000..233448b --- /dev/null +++ b/modules/view/meson.build @@ -0,0 +1,11 @@ +# LUA module: view +# SPDX-License-Identifier: GPL-3.0-or-later + +lua_mod_src += [ + files('view.lua'), +] + +integr_tests += [ + ['view.tsig', meson.current_source_dir() / 'tsig.test.integr'], + ['view.addr', meson.current_source_dir() / 'addr.test.integr'], +] diff --git a/modules/view/tsig.test.integr/deckard.yaml b/modules/view/tsig.test.integr/deckard.yaml new file mode 100644 index 0000000..06792be --- /dev/null +++ b/modules/view/tsig.test.integr/deckard.yaml @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +programs: +- name: kresd + binary: kresd + additional: + - --noninteractive + templates: + - modules/view/tsig.test.integr/kresd_config.j2 + - tests/integration/hints_zone.j2 + configs: + - config + - hints diff --git a/modules/view/tsig.test.integr/kresd_config.j2 b/modules/view/tsig.test.integr/kresd_config.j2 new file mode 100644 index 0000000..f04dce2 --- /dev/null +++ b/modules/view/tsig.test.integr/kresd_config.j2 @@ -0,0 +1,64 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +{% raw %} +modules.load('view') +print(table_print(modules.list())) + +view:tsig('\8testkey1\0', policy.suffix(policy.DENY_MSG("TSIG key testkey1 matched com"),{"\3com\0"})) +view:tsig('\8testkey1\0', policy.suffix(policy.DENY_MSG("TSIG key testkey1 matched net"),{"\3net\0"})) +view:tsig('\7testkey\0', policy.suffix(policy.DENY_MSG("TSIG key testkey matched example"),{"\7example\0"})) +policy.add(policy.all(policy.FORWARD('1.2.3.4'))) + +-- Disable RFC5011 TA update +if ta_update then + modules.unload('ta_update') +end + +-- Disable RFC8145 signaling, scenario doesn't provide expected answers +if ta_signal_query then + modules.unload('ta_signal_query') +end + +-- Disable RFC8109 priming, scenario doesn't provide expected answers +if priming then + modules.unload('priming') +end + +-- Disable this module because it make one priming query +if detect_time_skew then + modules.unload('detect_time_skew') +end + +-- make sure DNSSEC is turned off for tests +trust_anchors.remove('.') + +_hint_root_file('hints') +cache.size = 2*MB +log_level('debug') +{% endraw %} + +net = { '{{SELF_ADDR}}' } + + +{% if QMIN == "false" %} +option('NO_MINIMIZE', true) +{% else %} +option('NO_MINIMIZE', false) +{% endif %} + + +-- Self-checks on globals +assert(help() ~= nil) +assert(worker.id ~= nil) +-- Self-checks on facilities +assert(cache.count() == 0) +assert(cache.stats() ~= nil) +assert(cache.backends() ~= nil) +assert(worker.stats() ~= nil) +assert(net.interfaces() ~= nil) +-- Self-checks on loaded stuff +assert(net.list()[1].transport.ip == '{{SELF_ADDR}}') +assert(#modules.list() > 0) +-- Self-check timers +ev = event.recurrent(1 * sec, function (ev) return 1 end) +event.cancel(ev) +ev = event.after(0, function (ev) return 1 end) diff --git a/modules/view/tsig.test.integr/module_view_tsig.rpl b/modules/view/tsig.test.integr/module_view_tsig.rpl new file mode 100644 index 0000000..fd6d291 --- /dev/null +++ b/modules/view/tsig.test.integr/module_view_tsig.rpl @@ -0,0 +1,114 @@ +; SPDX-License-Identifier: GPL-3.0-or-later +; config options + stub-addr: 1.2.3.4 +CONFIG_END + +SCENARIO_BEGIN view:tsig test + +RANGE_BEGIN 0 110 + ADDRESS 1.2.3.4 +ENTRY_BEGIN +MATCH opcode qtype qname +ADJUST copy_id +REPLY QR RD RA NOERROR +SECTION QUESTION +example.cz. IN A +SECTION ANSWER +example.cz. IN A 5.6.7.8 +ENTRY_END + +RANGE_END + +RANGE_BEGIN 0 110 + ADDRESS 192.0.2.1 +ENTRY_BEGIN +MATCH opcode qtype qname +ADJUST copy_id +REPLY QR RD RA NOERROR +SECTION QUESTION +example.net. IN A +SECTION ANSWER +example.net. IN A 6.6.6.6 +ENTRY_END +RANGE_END + +; policy fallback (no view matched, policy is behind view module) +STEP 10 QUERY +ENTRY_BEGIN +REPLY RD +TSIG testkey +Cdjlkef9ZTSeixERZ433Q== +SECTION QUESTION +example.cz. IN A +ENTRY_END + +STEP 20 CHECK_ANSWER +ENTRY_BEGIN +MATCH flags rcode question answer +REPLY QR RD RA NOERROR +SECTION QUESTION +example.cz. IN A +SECTION ANSWER +example.cz. IN A 5.6.7.8 +ENTRY_END + +; blocked by view:tsig testkey1 + inner policy.suffix com +; NXDOMAIN expected +STEP 30 QUERY +ENTRY_BEGIN +REPLY RD +TSIG testkey1 +Cdjlkef9ZTSeixERZ433Q== +SECTION QUESTION +example.com. IN A +ENTRY_END + +STEP 31 CHECK_ANSWER +ENTRY_BEGIN +MATCH opcode question rcode additional +REPLY QR RD RA AA NXDOMAIN +SECTION QUESTION +example.com. IN A +SECTION ADDITIONAL +explanation.invalid. 10800 IN TXT "TSIG key testkey1 matched com" +ENTRY_END + +; blocked by view:tsig testkey1 + inner policy.suffix net +; second view rule gets executed if policy in preceding view rule did not match +STEP 32 QUERY +ENTRY_BEGIN +REPLY RD +TSIG testkey1 +Cdjlkef9ZTSeixERZ433Q== +SECTION QUESTION +example.net. IN A +ENTRY_END + +STEP 33 CHECK_ANSWER +ENTRY_BEGIN +MATCH opcode question rcode additional +REPLY QR RD RA AA NXDOMAIN +SECTION QUESTION +example.net. IN A +SECTION ADDITIONAL +explanation.invalid. 10800 IN TXT "TSIG key testkey1 matched net" +ENTRY_END + +; blocked by view:tsig testkey + inner policy.suffix example (different key) +; third view rule gets executed if policy in preceding view rule did not match +STEP 34 QUERY +ENTRY_BEGIN +REPLY RD +TSIG testkey +Cdjlkef9ZTSeixERZ433Q== +SECTION QUESTION +example. IN A +ENTRY_END + +STEP 35 CHECK_ANSWER +ENTRY_BEGIN +MATCH opcode question rcode additional +REPLY QR RD RA AA NXDOMAIN +SECTION QUESTION +example. IN A +SECTION ADDITIONAL +explanation.invalid. 10800 IN TXT "TSIG key testkey matched example" +ENTRY_END + +SCENARIO_END diff --git a/modules/view/view.lua b/modules/view/view.lua new file mode 100644 index 0000000..d704384 --- /dev/null +++ b/modules/view/view.lua @@ -0,0 +1,122 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +local kres = require('kres') +local ffi = require('ffi') +local C = ffi.C + +-- Module declaration +local view = { + key = {}, -- map from :owner() to list of policy rules + src = {}, + dst = {}, +} + +-- @function View based on TSIG key name. +function view.tsig(_, tsig, rule) + if view.key[tsig] == nil then + view.key[tsig] = { rule } + else + table.insert(view.key[tsig], rule) + end +end + +-- @function View based on source IP subnet. +function view.addr(_, subnet, rules, dst) + local subnet_cd = ffi.new('char[16]') + local family = C.kr_straddr_family(subnet) + local bitlen = C.kr_straddr_subnet(subnet_cd, subnet) + if bitlen < 0 then + error(string.format('failed to parse subnet %s', subnet)) + end + local t = {family, subnet_cd, bitlen, rules} + table.insert(dst and view.dst or view.src, t) + return t +end + +-- @function Match IP against given subnet +local function match_subnet(family, subnet, bitlen, addr) + return (family == addr:family()) and (C.kr_bitcmp(subnet, addr:ip(), bitlen) == 0) +end + +-- @function Execute a policy callback (may be nil); +-- return boolean: whether to continue trying further rules. +local function execute(state, req, match_cb) + if match_cb == nil then return false end + local action = match_cb(req, req:current()) + if action == nil then return false end + local next_state = action(state, req) + if next_state then -- Not a chain rule, + req.state = next_state + return true + else + return false + end +end + +-- @function Try all the rules in order, until a non-chain rule gets executed. +local function evaluate(state, req) + -- Try :tsig rules first. + local client_key = req.qsource.packet.tsig_rr + local match_cbs = (client_key ~= nil) and view.key[client_key:owner()] or {} + for _, match_cb in ipairs(match_cbs) do + if execute(state, req, match_cb) then return end + end + -- Then try :addr by the source. + if req.qsource.addr ~= nil then + for i = 1, #view.src do + local pair = view.src[i] + if match_subnet(pair[1], pair[2], pair[3], req.qsource.addr) then + local match_cb = pair[4] + if execute(state, req, match_cb) then return end + end + end + end + -- Finally try :addr by the destination. + if req.qsource.dst_addr ~= nil then + for i = 1, #view.dst do + local pair = view.dst[i] + if match_subnet(pair[1], pair[2], pair[3], req.qsource.dst_addr) then + local match_cb = pair[4] + if execute(state, req, match_cb) then return end + end + end + end +end + +-- @function Return policy based on source address +function view.rule_src(action, subnet) + local subnet_cd = ffi.new('char[16]') + local family = C.kr_straddr_family(subnet) + local bitlen = C.kr_straddr_subnet(subnet_cd, subnet) + return function(req, _) + local addr = req.qsource.addr + if addr ~= nil and match_subnet(family, subnet_cd, bitlen, addr) then + return action + end + end +end + +-- @function Return policy based on destination address +function view.rule_dst(action, subnet) + local subnet_cd = ffi.new('char[16]') + local family = C.kr_straddr_family(subnet) + local bitlen = C.kr_straddr_subnet(subnet_cd, subnet) + return function(req, _) + local addr = req.qsource.dst_addr + if addr ~= nil and match_subnet(family, subnet_cd, bitlen, addr) then + return action + end + end +end + +-- @function Module layers +view.layer = { + begin = function(state, req) + -- Don't act on "finished" cases. + if bit.band(state, bit.bor(kres.FAIL, kres.DONE)) ~= 0 then return state end + + evaluate(state, req) + return req.state + end +} + +return view |