diff options
Diffstat (limited to '')
-rw-r--r-- | modules/daf/README.rst | 137 | ||||
-rw-r--r-- | modules/daf/daf.js | 294 | ||||
-rw-r--r-- | modules/daf/daf.lua | 353 | ||||
-rw-r--r-- | modules/daf/daf.mk | 3 |
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) |