diff options
Diffstat (limited to 'modules/daf/daf.lua')
-rw-r--r-- | modules/daf/daf.lua | 392 |
1 files changed, 392 insertions, 0 deletions
diff --git a/modules/daf/daf.lua b/modules/daf/daf.lua new file mode 100644 index 0000000..c3b089b --- /dev/null +++ b/modules/daf/daf.lua @@ -0,0 +1,392 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later + +local ffi = require('ffi') + +-- 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 + log_warn(ffi.C.LOG_GRP_DAF, + 'HTTP API unavailable because HTTP module is not loaded, use modules.load("http")') + 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 |