summaryrefslogtreecommitdiffstats
path: root/modules/daf/daf.lua
diff options
context:
space:
mode:
Diffstat (limited to 'modules/daf/daf.lua')
-rw-r--r--modules/daf/daf.lua392
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