]> git.ipfire.org Git - thirdparty/knot-resolver.git/commitdiff
modules/daf: a functional web interface
authorMarek Vavrusa <marek@vavrusa.com>
Mon, 13 Jun 2016 17:21:26 +0000 (10:21 -0700)
committerMarek Vavrusa <marek@vavrusa.com>
Wed, 6 Jul 2016 06:33:38 +0000 (23:33 -0700)
the interface has a declarative rule builder that
assists in building and validating rules, as well
as seeing how much traffic do they match

modules/daf/daf.js
modules/daf/daf.lua

index 82b8b8d0eca3ca71622b45d446f49f3c65ee1e25..ea75b9607e662af9a60fce7462367ba2166b420a 100644 (file)
@@ -1 +1,229 @@
-console.log('Hello from DAF!')
+/* Filter grammar */
+const dafg = {
+       key: {'qname': true, 'src': true},
+       op: {'=': true, '~': true},
+       conj: {'and': true, 'or': true},
+       action: {'pass': true, 'deny': true, 'drop': true, 'truncate': true, 'forward': true, 'reroute': true, 'rewrite': true},
+       suggest: [
+               'QNAME = example.com',
+               'QNAME ~ %d+.example.com',
+               'SRC = 127.0.0.1',
+               'SRC = 127.0.0.1/8',
+               /* Action examples */
+               'PASS', 'DENY', 'DROP', 'TRUNCATE',
+               'FORWARD 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 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"></td>');
+       tbl.append(row);
+}
+
+/* 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 = $.parseJSON(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(Rickshaw.Fixtures.Number.formatKMBT(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 (tok.length > 0 && this.items.length > 0 && dafg.conj[tok[0]]) {
+                               setValidateHint();
+                               return true;
+                       }
+                       if (validateToken(tok, dafg.key)) {
+                               key = tok.shift();
+                       } else {
+                               return false;
+                       }
+                       if (validateToken(tok, dafg.op)) {
+                               op = tok.shift();
+                       } else {
+                               return false;
+                       }
+                       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');
+               if (dafBuilder.items.length == 0 || form.hasClass('has-error')) {
+                       return;
+               }
+               /* Clear previous errors and resubmit. */
+               form.find('.alert').remove();
+               $.post('daf', dafBuilder.items.join(' '))
+                       .done(function (data) {
+                               dafBuilder.clear();
+                               loadRule(data, $('#daf-rules'));
+                       })
+                       .fail(function (data) {
+                               form.append(
+                                       '<div class="alert alert-danger" role="alert">'+
+                                      'Couldn\'t add rule (code: '+data.status+', reason: '+data.responseText+').'+
+                                   '</div>'
+                               );
+                       });
+       });
+});
\ No newline at end of file
index e3b8269d3e282f659f7b3c40e510d4371c05a192..ff629c3bb2c3b9bff0680ae81865b8e44777b1c0 100644 (file)
@@ -52,7 +52,7 @@ local filters = {
 }
 
 local function parse_filter(tok, g)
-       local filter = filters[tok]
+       local filter = filters[tok:lower()]
        if not filter then error(string.format('invalid filter "%s"', tok)) end
        return filter(g)
 end
@@ -80,6 +80,7 @@ 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]
@@ -109,18 +110,45 @@ local M = {
 
 -- @function Public-facing API
 local function api(h, stream)
-       print('DAF: ')
-       for k,v in h:each() do print(k,v) end
+       local m = h:get(':method')
+       -- GET method
+       if m == 'GET' then
+               local ret = {}
+               for _, r in ipairs(M.rules) do
+                       table.insert(ret, {info=r.info, id=r.rule.id, count=r.rule.count})
+               end
+               return ret
+       -- 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 505 end
+                       return {info=r.info, id=r.rule.id, count=r.rule.count}
+               end
+               return 400
+       end
 end
 
 -- @function Publish DAF statistics
 local function publish(h, ws)
-       local ok = true
+       local ok, counters = true, {}
        while ok do
-               -- Publish stats updates periodically
-               local push = tojson({})
-               ok = ws:send(push)
-               cqueues.sleep(0.5)
+               -- Check if we have new rule matches
+               local update = {}
+               for _, r in ipairs(M.rules) do
+                       local id = r.rule.id
+                       if counters[id] ~= r.rule.count then
+                               -- Must have string keys for JSON object and not an array
+                               update[tostring(id)] = r.rule.count
+                               counters[id] = r.rule.count
+                       end
+               end
+               -- Update counters when there is a new data
+               if next(update) ~= nil then
+                       ws:send(tojson(update))
+               end
+               cqueues.sleep(2)
        end
        ws:close()
 end
@@ -143,7 +171,21 @@ function M.config(conf)
        -- Export snippet
        http.snippets['/daf'] = {'Application Firewall', [[
                <script type="text/javascript" src="daf.js"></script>
-               <table id="daf-rules"><th><td>No rules here yet.</td></th></table>
+               <div class="row">
+                       <form id="daf-builder-form">
+                               <div class="input-group">
+                                       <input type="text" id="daf-builder" class="form-control" aria-label="..." />
+                                       <div class="input-group-btn">
+                                               <button type="button" id="daf-add" class="btn btn-default" style="margin-top: -5px;">Add</button>
+                                       </div>
+                               </div>
+                       </form>
+               </div>
+               <div class="row">
+                       <table id="daf-rules" class="table table-striped table-responsive">
+                       <th><td>No rules here yet.</td></th>
+                       </table>
+               </div>
        ]]}
 end
 
@@ -155,13 +197,15 @@ function M.add(rule)
        local p = function (req, qry)
                return filter(req, qry) and action
        end
-       table.insert(M.rules, {rule=rule, action=id, policy=p})
+       local desc = {info=rule, policy=p}
        -- Enforce in policy module, special actions are postrules
        if id == 'reroute' or id == 'rewrite' then
-               table.insert(policy.postrules, p)
+               desc.rule = policy:add(p, true)
        else
-               table.insert(policy.rules, p)
+               desc.rule = policy:add(p)
        end
+       table.insert(M.rules, desc)
+       return desc
 end
 
 return M
\ No newline at end of file