summaryrefslogtreecommitdiffstats
path: root/modules/daf
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/daf/README.rst137
-rw-r--r--modules/daf/daf.js294
-rw-r--r--modules/daf/daf.lua353
-rw-r--r--modules/daf/daf.mk3
4 files changed, 787 insertions, 0 deletions
diff --git a/modules/daf/README.rst b/modules/daf/README.rst
new file mode 100644
index 0000000..24ca504
--- /dev/null
+++ b/modules/daf/README.rst
@@ -0,0 +1,137 @@
+.. _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 1.2.3.4 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
+
+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:8053/daf | jq .
+ {}
+
+ # Create new rule
+ $ curl -s -X POST -d "src = 127.0.0.1 pass" http://localhost:8053/daf | jq .
+ {
+ "count": 0,
+ "active": true,
+ "info": "src = 127.0.0.1 pass",
+ "id": 1
+ }
+
+ # Disable rule
+ $ curl -s -X PATCH http://localhost:8053/daf/1/active/false | jq .
+ true
+
+ # Retrieve a rule information
+ $ curl -s -X GET http://localhost:8053/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:8053/daf/1 | jq .
+ true
diff --git a/modules/daf/daf.js b/modules/daf/daf.js
new file mode 100644
index 0000000..a614118
--- /dev/null
+++ b/modules/daf/daf.js
@@ -0,0 +1,294 @@
+/* Filter grammar */
+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,
+ fail: 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);
+ }
+}
+
+$(function() {
+ /* Load the filter table. */
+ $.ajax({
+ url: 'daf',
+ type: 'get',
+ dataType: 'json',
+ success: loadTable
+ });
+ /* Listen for counter updates */
+ const wsStats = (secure ? '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>'
+ );
+ });
+ });
+}); \ No newline at end of file
diff --git a/modules/daf/daf.lua b/modules/daf/daf.lua
new file mode 100644
index 0000000..28b6342
--- /dev/null
+++ b/modules/daf/daf.lua
@@ -0,0 +1,353 @@
+-- Load dependent modules
+if not view then modules.load('view') end
+if not policy then modules.load('policy') end
+
+-- Actions
+local actions = {
+ pass = 1, deny = 2, drop = 3, tc = 4, truncate = 4,
+ 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 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 and http.endpoints then
+ http.endpoints['/daf'] = nil
+ http.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 _, r in ipairs(M.rules) do
+ if r.rule.id == id then
+ policy.del(id)
+ table.remove(M.rules, id)
+ return true
+ end
+ end
+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
+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
+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 ret = true
+ local results = map(string.format(op, ...))
+ for _, r in ipairs(results) do
+ ret = ret and r
+ 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
+ consensus('daf.add "%s"', 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 = {}
+ for _, rules in ipairs(map 'daf.rules') do
+ for _, r in ipairs(rules) do
+ local id = tostring(r.rule.id)
+ -- Must have string keys for JSON object and not an array
+ update[id] = (update[id] or 0) + r.rule.count
+ 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 Configure module
+function M.config()
+ if not http or not http.endpoints then return end
+ -- Export API and data publisher
+ http.endpoints['/daf.js'] = http.page('daf.js', 'daf')
+ http.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 \ No newline at end of file
diff --git a/modules/daf/daf.mk b/modules/daf/daf.mk
new file mode 100644
index 0000000..0f02675
--- /dev/null
+++ b/modules/daf/daf.mk
@@ -0,0 +1,3 @@
+daf_SOURCES := daf.lua
+daf_INSTALL := modules/daf/daf.js
+$(call make_lua_module,daf)