diff options
Diffstat (limited to 'modules/daf')
-rw-r--r-- | modules/daf/.packaging/test.config | 4 | ||||
-rw-r--r-- | modules/daf/README.rst | 146 | ||||
-rw-r--r-- | modules/daf/daf.js | 295 | ||||
-rw-r--r-- | modules/daf/daf.lua | 391 | ||||
-rw-r--r-- | modules/daf/daf.test.lua | 80 | ||||
-rw-r--r-- | modules/daf/daf_http.test.lua | 216 | ||||
-rw-r--r-- | modules/daf/meson.build | 21 | ||||
-rw-r--r-- | modules/daf/test.integr/deckard.yaml | 12 | ||||
-rw-r--r-- | modules/daf/test.integr/kresd_config.j2 | 65 | ||||
-rw-r--r-- | modules/daf/test.integr/module_daf.rpl | 30 |
10 files changed, 1260 insertions, 0 deletions
diff --git a/modules/daf/.packaging/test.config b/modules/daf/.packaging/test.config new file mode 100644 index 0000000..2fa1d8c --- /dev/null +++ b/modules/daf/.packaging/test.config @@ -0,0 +1,4 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +modules.load('daf') +assert(daf) +quit() diff --git a/modules/daf/README.rst b/modules/daf/README.rst new file mode 100644 index 0000000..a5e025e --- /dev/null +++ b/modules/daf/README.rst @@ -0,0 +1,146 @@ +.. SPDX-License-Identifier: GPL-3.0-or-later + +.. _mod-daf: + +DNS Application Firewall +======================== + +This module is a high-level interface for other powerful filtering modules and DNS views. It provides an easy interface to apply and monitor DNS filtering rules and a persistent memory for them. It also provides a restful service interface and an HTTP interface. + +Example configuration +--------------------- + +Firewall rules are declarative and consist of filters and actions. Filters have ``field operator operand`` notation (e.g. ``qname = example.com``), and may be chained using AND/OR keywords. Actions may or may not have parameters after the action name. + +.. code-block:: lua + + -- Let's write some daft rules! + modules = { 'daf' } + + -- Block all queries with QNAME = example.com + daf.add('qname = example.com deny') + + -- Filters can be combined using AND/OR... + -- Block all queries with QNAME match regex and coming from given subnet + daf.add('qname ~ %w+.example.com AND src = 192.0.2.0/24 deny') + + -- We also can reroute addresses in response to alternate target + -- This reroutes 192.0.2.1 to localhost + daf.add('src = 127.0.0.0/8 reroute 192.0.2.1-127.0.0.1') + + -- Subnets work too, this reroutes a whole subnet + -- e.g. 192.0.2.55 to 127.0.0.55 + daf.add('src = 127.0.0.0/8 reroute 192.0.2.0/24-127.0.0.0') + + -- This rewrites all A answers for 'example.com' from + -- whatever the original address was to 127.0.0.2 + daf.add('src = 127.0.0.0/8 rewrite example.com A 127.0.0.2') + + -- Mirror queries matching given name to DNS logger + daf.add('qname ~ %w+.example.com mirror 127.0.0.2') + daf.add('qname ~ example-%d.com mirror 127.0.0.3@5353') + + -- Forward queries from subnet + daf.add('src = 127.0.0.1/8 forward 127.0.0.1@5353') + -- Forward to multiple targets + daf.add('src = 127.0.0.1/8 forward 127.0.0.1@5353,127.0.0.2@5353') + + -- Truncate queries based on destination IPs + daf.add('dst = 192.0.2.51 truncate') + + -- Disable a rule + daf.disable(2) + -- Enable a rule + daf.enable(2) + -- Delete a rule + daf.del(2) + + -- Delete all rules and start from scratch + daf.clear() + +.. warning:: Only the first matching rule's action is executed. Defining + additional actions for the same matching rule, e.g. ``src = 127.0.0.1/8``, + will have no effect. + +If you're not sure what firewall rules are in effect, see ``daf.rules``: + +.. code-block:: text + + -- Show active rules + > daf.rules + [1] => { + [rule] => { + [count] => 42 + [id] => 1 + [cb] => function: 0x1a3eda38 + } + [info] => qname = example.com AND src = 127.0.0.1/8 deny + [policy] => function: 0x1a3eda38 + } + [2] => { + [rule] => { + [suspended] => true + [count] => 123522 + [id] => 2 + [cb] => function: 0x1a3ede88 + } + [info] => qname ~ %w+.facebook.com AND src = 127.0.0.1/8 deny... + [policy] => function: 0x1a3ede88 + } + +Web interface +------------- + +If you have :ref:`HTTP/2 <mod-http>` loaded, the firewall automatically loads as a snippet. +You can create, track, suspend and remove firewall rules from the web interface. +If you load both modules, you have to load `daf` after `http`. + +RESTful interface +----------------- + +The module also exports a RESTful API for operations over rule chains. + + +.. csv-table:: + :header: "URL", "HTTP Verb", "Action" + + "/daf", "GET", "Return JSON list of active rules." + "/daf", "POST", "Insert new rule, rule string is expected in body. Returns rule information in JSON." + "/daf/<id>", "GET", "Retrieve a rule matching given ID." + "/daf/<id>", "DELETE", "Delete a rule matching given ID." + "/daf/<id>/<prop>/<val>", "PATCH", "Modify given rule, for example /daf/3/active/false suspends rule 3." + +This interface is used by the web interface for all operations, but you can also use it directly +for testing. + +.. code-block:: bash + + # Get current rule set + $ curl -s -X GET http://localhost:8453/daf | jq . + {} + + # Create new rule + $ curl -s -X POST -d "src = 127.0.0.1 pass" http://localhost:8453/daf | jq . + { + "count": 0, + "active": true, + "info": "src = 127.0.0.1 pass", + "id": 1 + } + + # Disable rule + $ curl -s -X PATCH http://localhost:8453/daf/1/active/false | jq . + true + + # Retrieve a rule information + $ curl -s -X GET http://localhost:8453/daf/1 | jq . + { + "count": 4, + "active": true, + "info": "src = 127.0.0.1 pass", + "id": 1 + } + + # Delete a rule + $ curl -s -X DELETE http://localhost:8453/daf/1 | jq . + true diff --git a/modules/daf/daf.js b/modules/daf/daf.js new file mode 100644 index 0000000..05b171b --- /dev/null +++ b/modules/daf/daf.js @@ -0,0 +1,295 @@ +/* Filter grammar + * SPDX-License-Identifier: GPL-3.0-or-later */ +const dafg = { + key: {'qname': true, 'src': true, 'dst': true}, + op: {'=': true, '~': true}, + conj: {'and': true, 'or': true}, + action: {'pass': true, 'deny': true, 'drop': true, 'truncate': true, 'forward': true, 'reroute': true, 'rewrite': true, 'mirror': true}, + suggest: [ + 'QNAME = example.com', + 'QNAME ~ %d+.example.com', + 'SRC = 127.0.0.1', + 'SRC = 127.0.0.1/8', + 'DST = 127.0.0.1', + 'DST = 127.0.0.1/8', + /* Action examples */ + 'PASS', 'DENY', 'DROP', 'TRUNCATE', + 'FORWARD 127.0.0.1', + 'MIRROR 127.0.0.1', + 'REROUTE 127.0.0.1-192.168.1.1', + 'REROUTE 127.0.0.1/24-192.168.1.0', + 'REWRITE example.com A 127.0.0.1', + 'REWRITE example.com AAAA ::1', + ] +}; + +function setValidateHint(cls) { + var builderForm = $('#daf-builder-form'); + builderForm.removeClass('has-error has-warning has-success'); + if (cls) { + builderForm.addClass(cls); + } +} + +function validateToken(tok, tbl) { + if (tok.length > 0 && tok[0].length > 0) { + if (tbl[tok[0].toLowerCase()]) { + setValidateHint('has-success'); + return true; + } else { setValidateHint('has-error'); } + } else { setValidateHint('has-warning'); } + return false; +} + +function parseOption(tok) { + var key = tok.shift().toLowerCase(); + var op = null; + if (dafg.key[key]) { + op = tok.shift(); + if (op) { + op = op.toLowerCase(); + } + } + const item = { + text: key.toUpperCase() + ' ' + (op ? op.toUpperCase() : '') + ' ' + tok.join(' '), + }; + if (dafg.key[key]) { + item.class = 'tag-default'; + } else if (dafg.action[key]) { + item.class = 'tag-warning'; + } else if (dafg.conj[key]) { + item.class = 'tag-success'; + } + return item; +} + +function createOption(input) { + const item = parseOption(input.split(' ')); + item.value = input; + return item; +} + +function dafComplete(form) { + const items = form.items; + for (var i in items) { + const tok = items[i].split(' ')[0].toLowerCase(); + if (dafg.action[tok]) { + return true; + } + } + return false; +} + +function formatRule(input) { + const tok = input.split(' '); + var res = []; + while (tok.length > 0) { + const key = tok.shift().toLowerCase(); + if (dafg.key[key]) { + var item = parseOption([key, tok.shift(), tok.shift()]); + res.push('<span class="label tag '+item.class+'">'+item.text+'</span>'); + } else if (dafg.action[key]) { + var item = parseOption([key].concat(tok)); + res.push('<span class="label tag '+item.class+'">'+item.text+'</span>'); + tok.splice(0, tok.length); + } else if (dafg.conj[key]) { + var item = parseOption([key]); + res.push('<span class="label tag '+item.class+'">'+item.text+'</span>'); + } + } + return res.join(''); +} + +function toggleRule(row, span, enabled) { + if (!enabled) { + span.removeClass('glyphicon-pause'); + span.addClass('glyphicon-play'); + row.addClass('warning'); + } else { + span.removeClass('glyphicon-play'); + span.addClass('glyphicon-pause'); + row.removeClass('warning'); + } +} + +function ruleControl(cell, type, url, action) { + const row = cell.parent(); + $.ajax({ + url: 'daf/' + row.data('rule-id') + url, + type: type, + success: action, + error: function (data) { + row.show(); + const reason = data.responseText.length > 0 ? data.responseText : 'internal error'; + cell.find('.alert').remove(); + cell.append( + '<div class="alert alert-danger" role="alert">'+ + 'Failed (code: '+data.status+', reason: '+reason+').'+ + '</div>' + ); + }, + }); +} + +function bindRuleControl(cell) { + const row = cell.parent(); + cell.find('.daf-remove').click(function() { + row.hide(); + ruleControl(cell, 'DELETE', '', function (data) { + cell.parent().remove(); + }); + }); + cell.find('.daf-suspend').click(function() { + const span = $(this).find('span'); + ruleControl(cell, 'PATCH', span.hasClass('glyphicon-pause') ? '/active/false' : '/active/true'); + toggleRule(row, span, span.hasClass('glyphicon-play')); + }); +} + +function loadRule(rule, tbl) { + const row = $('<tr data-rule-id="'+rule.id+'" />'); + row.append('<td class="daf-rule">' + formatRule(rule.info) + '</td>'); + row.append('<td class="daf-count">' + rule.count + '</td>'); + row.append('<td class="daf-rate"><span class="badge"></span></td>'); + row.append('<td class="daf-ctl text-right">' + + '<div class="btn-group btn-group-xs">' + + '<button class="btn btn-default daf-suspend"><span class="glyphicon" aria="hidden" /></button>' + + '<button class="btn btn-default daf-remove"><span class="glyphicon glyphicon-remove" aria="hidden" /></button>' + + '</div></td>'); + tbl.append(row); + /* Bind rule controls */ + bindRuleControl(row.find('.daf-ctl')); + toggleRule(row, row.find('.daf-suspend span'), rule.active); +} + +/* Load the filter table from JSON */ +function loadTable(resp) { + const tbl = $('#daf-rules') + tbl.children().remove(); + tbl.append('<tr><th>Rule</th><th>Matches</th><th>Rate</th><th></th></tr>') + for (var i in resp) { + loadRule(resp[i], tbl); + } +} + +document.addEventListener("DOMContentLoaded", () => { + /* Load the filter table. */ + $.ajax({ + url: 'daf', + type: 'get', + dataType: 'json', + success: loadTable + }); + /* Listen for counter updates */ + const wsStats = ('https:' == document.location.protocol ? 'wss://' : 'ws://') + location.host + '/daf'; + const ws = new Socket(wsStats); + var lastRateUpdate = Date.now(); + ws.onmessage = function(evt) { + var data = JSON.parse(evt.data); + /* Update heartbeat clock */ + var now = Date.now(); + var dt = now - lastRateUpdate; + lastRateUpdate = now; + /* Update match counts and rates */ + $('#daf-rules .daf-rate span').text(''); + for (var key in data) { + const row = $('tr[data-rule-id="'+key+'"]'); + if (row) { + const cell = row.find('.daf-count'); + const diff = data[key] - parseInt(cell.text()); + cell.text(data[key]); + const badge = row.find('.daf-rate span'); + if (diff > 0) { + /* Normalize difference to heartbeat (in msecs) */ + const rate = Math.ceil((1000 * diff) / dt); + badge.text(rate + ' pps'); + } + } + } + }; + /* Rule builder UI */ + $('#daf-builder').selectize({ + delimiter: ',', + persist: true, + highlight: true, + closeAfterSelect: true, + onItemAdd: function (input, item) { + setValidateHint(); + /* Prevent new rules when action is specified */ + const tok = input.split(' '); + if (dafg.action[tok[0].toLowerCase()]) { + $('#daf-add').focus(); + } else if(dafComplete(this)) { + /* No more rules after query is complete. */ + item.remove(); + } + }, + createFilter: function (input) { + const tok = input.split(' '); + var key, op, expr; + /* If there are already filters, allow conjunctions. */ + if (tok.length > 0 && this.items.length > 0 && dafg.conj[tok[0]]) { + setValidateHint(); + return true; + } + /* First token is expected to be filter key, + * or any postrule with a parameter */ + if (validateToken(tok, dafg.key)) { + key = tok.shift(); + } else if (tok.length > 1 && validateToken(tok, dafg.action)) { + setValidateHint(); + return true; + } else { + return false; + } + /* Input is a filter - second token must be operator */ + if (validateToken(tok, dafg.op)) { + op = tok.shift(); + } else { + return false; + } + /* Input is a filter - the rest of the tokens are RHS arguments. */ + if (tok.length > 0 && tok[0].length > 0) { + expr = tok.join(' '); + } else { + setValidateHint('has-warning'); + return false; + } + setValidateHint('has-success'); + return true; + }, + create: createOption, + render: { + item: function(item, escape) { + return '<div class="name '+item.class+'">' + escape(item.text) + '</span>'; + }, + }, + }); + /* Add default suggestions. */ + const dafBuilder = $('#daf-builder')[0].selectize; + for (var i in dafg.suggest) { + dafBuilder.addOption(createOption(dafg.suggest[i])); + } + /* Rule builder submit */ + $('#daf-add').click(function () { + const form = $('#daf-builder-form').parent(); + if (dafBuilder.items.length == 0 || form.hasClass('has-error')) { + return; + } + /* Clear previous errors and resubmit. */ + form.parent().find('.alert').remove(); + $.post('daf', dafBuilder.items.join(' ')) + .done(function (data) { + dafBuilder.clear(); + loadRule(data, $('#daf-rules')); + }) + .fail(function (data) { + const reason = data.responseText.length > 0 ? data.responseText : 'internal error'; + form.after( + '<div class="alert alert-danger" role="alert">'+ + 'Couldn\'t add rule (code: '+data.status+', reason: '+reason+').'+ + '</div>' + ); + }); + }); +}); diff --git a/modules/daf/daf.lua b/modules/daf/daf.lua new file mode 100644 index 0000000..94c2f16 --- /dev/null +++ b/modules/daf/daf.lua @@ -0,0 +1,391 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later + +-- Load dependent modules +if not view then modules.load('view') end +if not policy then modules.load('policy') end + +-- Actions +local actions = { + pass = function() return policy.PASS end, + deny = function () return policy.DENY end, + drop = function() return policy.DROP end, + tc = function() return policy.TC end, + truncate = function() return policy.TC end, + forward = function (g) + local addrs = {} + local tok = g() + for addr in string.gmatch(tok, '[^,]+') do + table.insert(addrs, addr) + end + return policy.FORWARD(addrs) + end, + mirror = function (g) + return policy.MIRROR(g()) + end, + reroute = function (g) + local rules = {} + local tok = g() + while tok do + local from, to = tok:match '([^-]+)-(%S+)' + rules[from] = to + tok = g() + end + return policy.REROUTE(rules) + end, + rewrite = function (g) + local rules = {} + local tok = g() + while tok do + -- This is currently limited to A/AAAA rewriting + -- in fixed format '<owner> <type> <addr>' + local _, to = g(), g() + rules[tok] = to + tok = g() + end + return policy.REROUTE(rules, true) + end, +} + +-- Filter rules per column +local filters = { + -- Filter on QNAME (either pattern or suffix match) + qname = function (g) + local op, val = g(), todname(g()) + if op == '~' then return policy.pattern(true, val:sub(2)) -- Skip leading label length + elseif op == '=' then return policy.suffix(true, {val}) + else error(string.format('invalid operator "%s" on qname', op)) end + end, + -- Filter on source address + src = function (g) + local op = g() + if op ~= '=' then error('address supports only "=" operator') end + return view.rule_src(true, g()) + end, + -- Filter on destination address + dst = function (g) + local op = g() + if op ~= '=' then error('address supports only "=" operator') end + return view.rule_dst(true, g()) + end, +} + +local function parse_filter(tok, g, prev) + if not tok then error(string.format('expected filter after "%s"', prev)) end + local filter = filters[tok:lower()] + if not filter then error(string.format('invalid filter "%s"', tok)) end + return filter(g) +end + +local function parse_rule(g) + -- Allow action without filter + local tok = g() + if tok == nil then + error('empty rule is not allowed') + end + if not filters[tok:lower()] then + return tok, nil + end + local f = parse_filter(tok, g) + -- Compose filter functions on conjunctions + -- or terminate filter chain and return + tok = g() + while tok do + if tok:lower() == 'and' then + local fa, fb = f, parse_filter(g(), g, tok) + f = function (req, qry) return fa(req, qry) and fb(req, qry) end + elseif tok:lower() == 'or' then + local fa, fb = f, parse_filter(g(), g, tok) + f = function (req, qry) return fa(req, qry) or fb(req, qry) end + else + break + end + tok = g() + end + return tok, f +end + +local function parse_query(g) + local ok, actid, filter = pcall(parse_rule, g) + if not ok then return nil, actid end + actid = actid:lower() + if not actions[actid] then return nil, string.format('invalid action "%s"', actid) end + -- Parse and interpret action + local action = actions[actid] + if type(action) == 'function' then + action = action(g) + end + return actid, action, filter +end + +-- Compile a rule described by query language +-- The query language is modelled by iptables/nftables +-- conj = AND | OR +-- op = IS | NOT | LIKE | IN +-- filter = <key> <op> <expr> +-- rule = <filter> | <filter> <conj> <rule> +-- action = PASS | DENY | DROP | TC | FORWARD +-- query = <rule> <action> +local function compile(query) + local g = string.gmatch(query, '%S+') + return parse_query(g) +end + +-- @function Describe given rule for presentation +local function rule_info(r) + return {info=r.info, id=r.rule.id, active=(r.rule.suspended ~= true), count=r.rule.count} +end + +-- Module declaration +local M = { + rules = {} +} + +-- @function Remove a rule + +-- @function Cleanup module +function M.deinit() + if http then + local endpoints = http.configs._builtin.webmgmt.endpoints + endpoints['/daf'] = nil + endpoints['/daf.js'] = nil + http.snippets['/daf'] = nil + end +end + +-- @function Add rule +function M.add(rule) + -- Ignore duplicates + for _, r in ipairs(M.rules) do + if r.info == rule then return r end + end + local id, action, filter = compile(rule) + if not id then error(action) end + -- Combine filter and action into policy + local p + if filter then + p = function (req, qry) + return filter(req, qry) and action + end + else + p = function () + return action + end + end + local desc = {info=rule, policy=p} + -- Enforce in policy module, special actions are postrules + if id == 'reroute' or id == 'rewrite' then + desc.rule = policy.add(p, true) + else + desc.rule = policy.add(p) + end + table.insert(M.rules, desc) + return desc +end + +-- @function Remove a rule +function M.del(id) + for key, r in ipairs(M.rules) do + if r.rule.id == id then + policy.del(id) + table.remove(M.rules, key) + return true + end + end + return nil +end + +-- @function Remove all rules +function M.clear() + for _, r in ipairs(M.rules) do + policy.del(r.rule.id) + end + M.rules = {} + return true +end + +-- @function Find a rule +function M.get(id) + for _, r in ipairs(M.rules) do + if r.rule.id == id then + return r + end + end + return nil +end + +-- @function Enable/disable a rule +function M.toggle(id, val) + for _, r in ipairs(M.rules) do + if r.rule.id == id then + r.rule.suspended = not val + return true + end + end + return nil +end + +-- @function Enable/disable a rule +function M.disable(id) + return M.toggle(id, false) +end +function M.enable(id) + return M.toggle(id, true) +end + +local function consensus(op, ...) + local results = map(string.format(op, ...)) + local ret = results.n > 0 -- init to true for non-empty results + for idx=1, results.n do + ret = ret and results[idx] + end + return ret +end + +-- @function Public-facing API +local function api(h, stream) + local m = h:get(':method') + -- GET method + if m == 'GET' then + local path = h:get(':path') + local id = tonumber(path:match '/([^/]*)$') + if id then + local r = M.get(id) + if r then + return rule_info(r) + end + return 404, '"No such rule"' -- Not found + else + local ret = {} + for _, r in ipairs(M.rules) do + table.insert(ret, rule_info(r)) + end + return ret + end + -- DELETE method + elseif m == 'DELETE' then + local path = h:get(':path') + local id = tonumber(path:match '/([^/]*)$') + if id then + if consensus('daf.del(%s)', id) then + return tojson(true) + end + return 404, '"No such rule"' -- Not found + end + return 400 -- Request doesn't have numeric id + -- POST method + elseif m == 'POST' then + local query = stream:get_body_as_string() + if query then + local ok, r = pcall(M.add, query) + if not ok then return 500, string.format('"%s"', r:match('/([^/]+)$')) end + -- Dispatch to all other workers: + -- we ignore return values except error() because they are not serializable + consensus('daf.add "%s" and true', query) + return rule_info(r) + end + return 400 + -- PATCH method + elseif m == 'PATCH' then + local path = h:get(':path') + local id, action, val = path:match '(%d+)/([^/]*)/([^/]*)$' + id = tonumber(id) + if not id or not action or not val then + return 400 -- Request not well formatted + end + -- We do not support more actions + if action == 'active' then + if consensus('daf.toggle(%d, %s)', id, val == 'true' or 'false') then + return tojson(true) + else + return 404, '"No such rule"' + end + else + return 501, '"Action not implemented"' + end + end +end + +local function getmatches() + local update = {} + -- Must have string keys for JSON object and not an array + local inst_counters = map('ret = {} ' + .. 'for _, rule in ipairs(daf.rules) do ' + .. 'ret[tostring(rule.rule.id)] = rule.rule.count ' + .. 'end ' + .. 'return ret') + for inst_idx=1, inst_counters.n do + for r_id, r_cnt in pairs(inst_counters[inst_idx]) do + update[r_id] = (update[r_id] or 0) + r_cnt + end + end + return update +end + +-- @function Publish DAF statistics +local function publish(_, ws) + local ok, last = true, nil + while ok do + -- Check if we have new rule matches + local diff = {} + local has_update, update = pcall(getmatches) + if has_update then + if last then + for id, count in pairs(update) do + if not last[id] or last[id] < count then + diff[id] = count + end + end + end + last = update + end + -- Update counters when there is a new data + if next(diff) ~= nil then + ok = ws:send(tojson(diff)) + else + ok = ws:send_ping() + end + worker.sleep(1) + end +end + +function M.init() + -- avoid ordering problem between HTTP and daf module + event.after(0, M.config) +end + +-- @function Configure module +function M.config() + if not http then + if verbose() then + log('[daf ] HTTP API unavailable because HTTP module is not loaded, use modules.load("http")') + end + return + end + local endpoints = http.configs._builtin.webmgmt.endpoints + -- Export API and data publisher + endpoints['/daf.js'] = http.page('daf.js', 'daf') + endpoints['/daf'] = {'application/json', api, publish} + -- Export snippet + http.snippets['/daf'] = {'Application Firewall', [[ + <script type="text/javascript" src="daf.js"></script> + <div class="row" style="margin-bottom: 5px"> + <form id="daf-builder-form"> + <div class="col-md-11"> + <input type="text" id="daf-builder" class="form-control" aria-label="..." /> + </div> + <div class="col-md-1"> + <button type="button" id="daf-add" class="btn btn-default btn-sm">Add</button> + </div> + </form> + </div> + <div class="row"> + <div class="col-md-12"> + <table id="daf-rules" class="table table-striped table-responsive"> + <th><td>No rules here yet.</td></th> + </table> + </div> + </div> + ]]} +end + +return M diff --git a/modules/daf/daf.test.lua b/modules/daf/daf.test.lua new file mode 100644 index 0000000..2a46393 --- /dev/null +++ b/modules/daf/daf.test.lua @@ -0,0 +1,80 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later + +-- do not attempt to contact outside world, operate only on cache +net.ipv4 = false +net.ipv6 = false +-- do not listen, test is driven by config code +env.KRESD_NO_LISTEN = true + +local path = worker.cwd..'/control/'..worker.pid +same(true, net.listen(path, nil, {kind = 'control'}), + 'new control sockets were created so map() can work') + +modules.load('hints > iterate') +modules.load('daf') + +hints['pass.'] = '127.0.0.1' +hints['deny.'] = '127.0.0.1' +hints['deny.'] = '127.0.0.1' +hints['drop.'] = '127.0.0.1' +hints['del.'] = '127.0.0.1' +hints['del2.'] = '127.0.0.1' +hints['toggle.'] = '127.0.0.1' + +local check_answer = require('test_utils').check_answer + +local function test_sanity() + check_answer('daf sanity (no rules)', 'pass.', kres.type.A, kres.rcode.NOERROR) + check_answer('daf sanity (no rules)', 'deny.', kres.type.A, kres.rcode.NOERROR) + check_answer('daf sanity (no rules)', 'drop.', kres.type.A, kres.rcode.NOERROR) + check_answer('daf sanity (no rules)', 'del.', kres.type.A, kres.rcode.NOERROR) + check_answer('daf sanity (no rules)', 'del2.', kres.type.A, kres.rcode.NOERROR) + check_answer('daf sanity (no rules)', 'toggle.', kres.type.A, kres.rcode.NOERROR) +end + +local function test_basic_actions() + daf.add('qname = pass. pass') + daf.add('qname = deny. deny') + daf.add('qname = drop. drop') + + check_answer('daf pass action', 'pass.', kres.type.A, kres.rcode.NOERROR) + check_answer('daf deny action', 'deny.', kres.type.A, kres.rcode.NXDOMAIN) + check_answer('daf drop action', 'drop.', kres.type.A, kres.rcode.SERVFAIL) +end + +local function test_del() + -- first matching rule is used + local first = daf.add('qname = del. deny') + local second = daf.add('qname = del2. deny') + + check_answer('daf del - first rule active', + 'del.', kres.type.A, kres.rcode.NXDOMAIN) + check_answer('daf del - second rule active', + 'del2.', kres.type.A, kres.rcode.NXDOMAIN) + daf.del(first.rule.id) + check_answer('daf del - first rule deleted', + 'del.', kres.type.A, kres.rcode.NOERROR) + daf.del(second.rule.id) + check_answer('daf del - second rule deleted', + 'del2.', kres.type.A, kres.rcode.NOERROR) +end + +local function test_toggle() + local toggle = daf.add('qname = toggle. deny') + + check_answer('daf - toggle active', + 'toggle.', kres.type.A, kres.rcode.NXDOMAIN) + daf.disable(toggle.rule.id) + check_answer('daf - toggle disabled', + 'toggle.', kres.type.A, kres.rcode.NOERROR) + daf.enable(toggle.rule.id) + check_answer('daf - toggle enabled', + 'toggle.', kres.type.A, kres.rcode.NXDOMAIN) +end + +return { + test_sanity, -- must be first, expects no daf rules + test_basic_actions, + test_del, + test_toggle, +} diff --git a/modules/daf/daf_http.test.lua b/modules/daf/daf_http.test.lua new file mode 100644 index 0000000..d89b180 --- /dev/null +++ b/modules/daf/daf_http.test.lua @@ -0,0 +1,216 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +-- check prerequisites +local has_http = pcall(require, 'kres_modules.http') and pcall(require, 'http.request') +if not has_http then + -- skipping daf module test because http its not installed + os.exit(77) +else + local path = worker.cwd..'/control/'..worker.pid + same(true, net.listen(path, nil, {kind = 'control'}), + 'new control sockets were created so map() can work') + + local request = require('http.request') + + modules.load('http') + modules.load('daf') + + local bound + for _ = 1,1000 do + bound, _err = pcall(net.listen, '127.0.0.1', math.random(40000, 49999), { kind = 'webmgmt'}) + if bound then + break + end + end + assert(bound, 'unable to bind a port for HTTP module (1000 attempts)') + + -- globals for this module + local _, host, port, baseuri + local function start_server() + local server_fd = next(http.servers) + assert(server_fd) + local server = http.servers[server_fd].server + ok(server ~= nil, 'creates server instance') + _, host, port = server:localname() + ok(host and port, 'binds to an interface') + baseuri = string.format('http://%s:%d/daf', host, port) + end + + -- helper for returning useful values to test on +-- local function http_get(uri) +-- local headers, stream = assert(request.new_from_uri(uri):go()) +-- local body = assert(stream:get_body_as_string()) +-- return tonumber(headers:get(':status')), body, headers:get('content-type') +-- end + + local function http_req(uri, method, reqbody) + local req = assert(request.new_from_uri(baseuri .. uri)) + req.headers:upsert(':method', method) + req:set_body(reqbody) + local headers, stream = assert(req:go()) + local ansbody = assert(stream:get_body_as_string()) + return tonumber(headers:get(':status')), ansbody, headers:get('content-type') + end + + local function http_get(uri) + return http_req(uri, 'GET') + end + + -- compare two tables, expected value is specified as JSON + -- comparison relies on table_print which sorts table keys + local function compare_tables(expectedjson, gotjson, desc) + same( + table_print(fromjson(expectedjson)), + table_print(fromjson(gotjson)), + desc) + end + + -- test whether http interface responds and binds + local function test_daf_api() + local code, body, mime + -- rule listing /daf + code, body, mime = http_get('/') + same(code, 200, 'rule listing return 200 OK') + same(body, '{}', 'daf rule list is empty after start') + same(mime, 'application/json', 'daf rule list has application/json content type') + -- get non-existing rule + code, body = http_req('/0', 'GET') + same(code, 404, 'non-existing rule retrieval returns 404') + same(body, '"No such rule"', 'explanatory message is present') + + -- delete non-existing rule + code, body = http_req('/0', 'DELETE') + same(code, 404, 'non-existing rule deletion returns 404') + same(body, '"No such rule"', 'explanatory message is present') + + -- bad PATCH + code = http_req('/0', 'PATCH') + same(code, 400, 'PATCH detects missing parameters') + + -- bad POST + code = http_req('/', 'POST') + same(code, 500, 'POST without parameters is detected') + + -- POST first new rule + code, body, mime = http_req('/', 'POST', 'src = 192.0.2.0 pass') + same(code, 200, 'first POST succeeds') + compare_tables(body, + '{"count":0,"active":true,"id":0,"info":"src = 192.0.2.0 pass"}', + 'POST returns new rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- GET first rule + code, body, mime = http_req('/0', 'GET') + same(code, 200, 'GET for first rule succeeds') + compare_tables(body, + '{"count":0,"active":true,"id":0,"info":"src = 192.0.2.0 pass"}', + 'POST returns new rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- POST second new rule + code, body, mime = http_req('/', 'POST', 'src = 192.0.2.1 pass') + same(code, 200, 'second POST succeeds') + compare_tables(body, + '{"count":0,"active":true,"id":1,"info":"src = 192.0.2.1 pass"}', + 'POST returns new rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- GET second rule + code, body, mime = http_req('/1', 'GET') + same(code, 200, 'GET for second rule succeeds') + compare_tables(body, + '{"count":0,"active":true,"id":1,"info":"src = 192.0.2.1 pass"}', + 'POST returns new rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- PATCH first rule + code, body, mime = http_req('/0/active/false', 'PATCH') + same(code, 200, 'PATCH for first rule succeeds') + same(body, 'true', 'PATCH returns success in body') + same(mime, 'application/json', 'PATCH return value has application/json content type') + + -- GET modified first rule + code, body, mime = http_req('/0', 'GET') + same(code, 200, 'GET for first rule succeeds') + compare_tables(body, + '{"count":0,"active":false,"id":0,"info":"src = 192.0.2.0 pass"}', + 'GET returns modified rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- GET both rules + code, body, mime = http_req('/', 'GET') + same(code, 200, 'GET for both rule succeeds') + compare_tables(body, [[ + [ + {"count":0,"active":false,"info":"src = 192.0.2.0 pass","id":0}, + {"count":0,"active":true,"info":"src = 192.0.2.1 pass","id":1}] + ]], + 'GET returns both rules in JSON including modifications') + same(mime, 'application/json', 'rule list has application/json content type') + + -- PATCH first rule back to original state + code, body, mime = http_req('/0/active/true', 'PATCH') + same(code, 200, 'PATCH for first rule succeeds') + same(body, 'true', 'PATCH returns success in body') + same(mime, 'application/json', 'PATCH return value has application/json content type') + + -- GET modified (reversed) first rule + code, body, mime = http_req('/0', 'GET') + same(code, 200, 'GET for first rule succeeds') + compare_tables(body, + '{"count":0,"active":true,"id":0,"info":"src = 192.0.2.0 pass"}', + 'GET returns modified rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- DELETE first rule + code, body, mime = http_req('/0', 'DELETE') + same(code, 200, 'DELETE for first rule succeeds') + same(body, 'true', 'DELETE returns success in body') + same(mime, 'application/json', 'DELETE return value has application/json content type') + + -- GET deleted (first) rule + code, body = http_req('/0', 'GET') + same(code, 404, 'GET for deleted fails with 404') + same(body, '"No such rule"', 'failed GET contains explanatory message') + + -- GET second rule + code, body, mime = http_req('/1', 'GET') + same(code, 200, 'GET for second rule still succeeds') + compare_tables(body, + '{"count":0,"active":true,"id":1,"info":"src = 192.0.2.1 pass"}', + 'POST returns new rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- GET list of all rules + code, body, mime = http_req('/', 'GET') + same(code, 200, 'GET returns list with the remaining rule') + compare_tables(body, + '[{"count":0,"active":true,"id":1,"info":"src = 192.0.2.1 pass"}]', + 'rule list contains only the remaining rule in JSON') + same(mime, 'application/json', 'rule has application/json content type') + + -- try to DELETE first rule again + code, body = http_req('/0', 'DELETE') + same(code, 404, 'DELETE for already deleted rule fails with 404') + same(body, '"No such rule"', 'DELETE explains failure') + + -- DELETE second rule + code, body, mime = http_req('/1', 'DELETE') + same(code, 200, 'DELETE for second rule succeeds') + same(body, 'true', 'DELETE returns success in body') + same(mime, 'application/json', 'DELETE return value has application/json content type') + + -- GET (supposedly empty) list of all rules + code, body, mime = http_req('/', 'GET') + same(code, 200, 'GET returns list with the remaining rule') + compare_tables(body, '[]', 'rule list is now empty JSON list') + same(mime, 'application/json', 'rule has application/json content type') + end + + -- plan tests + local tests = { + start_server, + test_daf_api, + } + + return tests +end diff --git a/modules/daf/meson.build b/modules/daf/meson.build new file mode 100644 index 0000000..c46b749 --- /dev/null +++ b/modules/daf/meson.build @@ -0,0 +1,21 @@ +# LUA module: daf +# SPDX-License-Identifier: GPL-3.0-or-later + +config_tests += [ + ['daf', files('daf.test.lua')], + ['daf_http', files('daf_http.test.lua')], +] + +integr_tests += [ + ['daf', meson.current_source_dir() / 'test.integr'], +] + +lua_mod_src += [ + files('daf.lua'), +] + +# install daf.js +install_data( + 'daf.js', + install_dir: modules_dir / 'daf', +) diff --git a/modules/daf/test.integr/deckard.yaml b/modules/daf/test.integr/deckard.yaml new file mode 100644 index 0000000..455086f --- /dev/null +++ b/modules/daf/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/daf/test.integr/kresd_config.j2 + - tests/integration/hints_zone.j2 + configs: + - config + - hints diff --git a/modules/daf/test.integr/kresd_config.j2 b/modules/daf/test.integr/kresd_config.j2 new file mode 100644 index 0000000..c8f838d --- /dev/null +++ b/modules/daf/test.integr/kresd_config.j2 @@ -0,0 +1,65 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +{% raw %} +-- 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 + +modules.load('hints > iterate') +modules.load('daf') + +hints['hints.net.'] = '192.0.2.1' + +daf.add('src = 127.0.0.0/8 reroute 192.0.2.1-192.0.2.101') + +policy.add(policy.suffix(policy.PASS, {todname('test.')})) + +_hint_root_file('hints') +cache.size = 2*MB +verbose(true) +{% 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/daf/test.integr/module_daf.rpl b/modules/daf/test.integr/module_daf.rpl new file mode 100644 index 0000000..686f04c --- /dev/null +++ b/modules/daf/test.integr/module_daf.rpl @@ -0,0 +1,30 @@ +; SPDX-License-Identifier: GPL-3.0-or-later +; config options +; target-fetch-policy: "0 0 0 0 0" +; module-config: "iterator" +; name: "." + stub-addr: 193.0.14.129 # K.ROOT-SERVERS.NET. +CONFIG_END + +SCENARIO_BEGIN Test DNS Application Firewall + +STEP 11 QUERY +ENTRY_BEGIN +REPLY RD +SECTION QUESTION +hints.net. IN A +ENTRY_END + +; test rewrite rule applies to hints +STEP 12 CHECK_ANSWER +ENTRY_BEGIN +MATCH all +REPLY QR RD RA NOERROR +SECTION QUESTION +hints.net. IN A +SECTION ANSWER +hints.net. IN A 192.0.2.101 +ENTRY_END + + +SCENARIO_END |