392 lines
9.4 KiB
Lua
392 lines
9.4 KiB
Lua
-- 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
|