summaryrefslogtreecommitdiffstats
path: root/modules/daf
diff options
context:
space:
mode:
Diffstat (limited to 'modules/daf')
-rw-r--r--modules/daf/.packaging/test.config4
-rw-r--r--modules/daf/README.rst146
-rw-r--r--modules/daf/daf.js295
-rw-r--r--modules/daf/daf.lua391
-rw-r--r--modules/daf/daf.test.lua80
-rw-r--r--modules/daf/daf_http.test.lua216
-rw-r--r--modules/daf/meson.build21
-rw-r--r--modules/daf/test.integr/deckard.yaml12
-rw-r--r--modules/daf/test.integr/kresd_config.j265
-rw-r--r--modules/daf/test.integr/module_daf.rpl30
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