daf.lua 8.42 KB
Newer Older
1 2 3 4 5 6
-- Load dependent modules
if not view then modules.load('view') end
if not policy then modules.load('policy') end

-- Actions
local actions = {
7
	pass = 1, deny = 2, drop = 3, tc = 4, truncate = 4,
8
	forward = function (g)
9 10 11 12 13 14
		local addrs = {}
		local tok = g()
		for addr in string.gmatch(tok, '[^,]+') do
			table.insert(addrs, addr)
		end
		return policy.FORWARD(addrs)
15
	end,
16 17 18
	mirror = function (g)
		return policy.MIRROR(g())
	end,
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
	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,
41 42 43 44 45 46 47
}

-- Filter rules per column
local filters = {
	-- Filter on QNAME (either pattern or suffix match)
	qname = function (g)
		local op, val = g(), todname(g())
48
		if     op == '~' then return policy.pattern(true, val:sub(2)) -- Skip leading label length
49 50 51 52 53 54
		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()
55 56 57 58 59 60 61 62 63
		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,
64 65
}

66 67
local function parse_filter(tok, g, prev)
	if not tok then error(string.format('expected filter after "%s"', prev)) end
68
	local filter = filters[tok:lower()]
69 70 71 72 73
	if not filter then error(string.format('invalid filter "%s"', tok)) end
	return filter(g)
end

local function parse_rule(g)
74 75 76 77 78 79
	-- Allow action without filter
	local tok = g()
	if not filters[tok:lower()] then
		return tok, nil
	end
	local f = parse_filter(tok, g)
80 81
	-- Compose filter functions on conjunctions
	-- or terminate filter chain and return
82
	tok = g()
83
	while tok do
84 85
		if tok:lower() == 'and' then
			local fa, fb = f, parse_filter(g(), g, tok)
86
			f = function (req, qry) return fa(req, qry) and fb(req, qry) end
87 88
		elseif tok:lower() == 'or' then
			local fa, fb = f, parse_filter(g(), g, tok)
89
			f = function (req, qry) return fa(req, qry) or fb(req, qry) end
90 91 92 93 94 95 96 97 98
		else
			break
		end
		tok = g()
	end
	return tok, f
end

local function parse_query(g)
99 100
	local ok, actid, filter = pcall(parse_rule, g)
	if not ok then return nil, actid end
101
	actid = actid:lower()
102
	if not actions[actid] then return nil, string.format('invalid action "%s"', actid) end
103
	-- Parse and interpret action
104
	local action = actions[actid]
105
	if type(action) == 'function' then
106
		action = action(g)
107
	end
108
	return actid, action, filter
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
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

124 125 126 127 128
-- @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

129 130
-- Module declaration
local M = {
131
	rules = {}
132 133
}

134 135 136 137
-- @function Remove a rule

-- @function Cleanup module
function M.deinit()
138
	if http and http.endpoints then
139 140 141 142 143 144 145 146
		http.endpoints['/daf'] = nil
		http.endpoints['/daf.js'] = nil
		http.snippets['/daf'] = nil
	end
end

-- @function Add rule
function M.add(rule)
147 148 149 150
	-- Ignore duplicates
	for _, r in ipairs(M.rules) do
		if r.info == rule then return r end
	end
151 152 153 154 155 156 157 158 159
	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
160
		p = function ()
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
			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)
177
	for _, r in ipairs(M.rules) do
178 179 180 181 182 183 184 185
		if r.rule.id == id then
			policy.del(id)
			table.remove(M.rules, id)
			return true
		end
	end
end

186 187
-- @function Find a rule
function M.get(id)
188
	for _, r in ipairs(M.rules) do
189 190 191 192 193 194
		if r.rule.id == id then
			return r
		end
	end
end

195 196
-- @function Enable/disable a rule
function M.toggle(id, val)
197
	for _, r in ipairs(M.rules) do
198 199 200 201 202 203 204 205
		if r.rule.id == id then
			r.rule.suspended = not val
			return true
		end
	end
end

-- @function Enable/disable a rule
206
function M.disable(id)
207 208
	return M.toggle(id, false)
end
209
function M.enable(id)
210 211 212
	return M.toggle(id, true)
end

213 214 215 216 217 218 219 220 221
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

222 223
-- @function Public-facing API
local function api(h, stream)
224 225 226
	local m = h:get(':method')
	-- GET method
	if m == 'GET' then
227 228 229 230 231 232 233 234 235 236 237 238 239 240
		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
241
		end
242 243 244 245 246
	-- DELETE method
	elseif m == 'DELETE' then
		local path = h:get(':path')
		local id = tonumber(path:match '/([^/]*)$')
		if id then
247
			if consensus('daf.del "%s"', id) then
248 249
				return tojson(true)
			end
250
			return 404, '"No such rule"' -- Not found
251 252
		end
		return 400 -- Request doesn't have numeric id
253 254 255 256
	-- POST method
	elseif m == 'POST' then
		local query = stream:get_body_as_string()
		if query then
257 258 259 260
			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)
261
			return rule_info(r)
262 263
		end
		return 400
264 265 266 267 268 269 270 271 272 273
	-- 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
274
			if consensus('daf.toggle(%d, %s)', id, val == 'true' or 'false') then
275 276
				return tojson(true)
			else
277
				return 404, '"No such rule"'
278 279
			end
		else
280
			return 501, '"Action not implemented"'
281
		end
282
	end
283 284
end

285 286 287 288 289 290 291 292 293 294 295 296
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

297
-- @function Publish DAF statistics
298
local function publish(_, ws)
299
	local ok, last = true, nil
300
	while ok do
301
		-- Check if we have new rule matches
302 303 304 305 306 307 308 309 310
		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
311
			end
312
			last = update
313 314
		end
		-- Update counters when there is a new data
315 316
		if next(diff) ~= nil then
			ok = ws:send(tojson(diff))
317 318
		else
			ok = ws:send_ping()
319
		end
320
		worker.sleep(1)
321 322 323 324
	end
end

-- @function Configure module
325
function M.config()
326
	if not http or not http.endpoints then return end
327
	-- Export API and data publisher
328
	http.endpoints['/daf.js'] = http.page('daf.js', 'daf')
329 330 331
	http.endpoints['/daf'] = {'application/json', api, publish}
	-- Export snippet
	http.snippets['/daf'] = {'Application Firewall', [[
332
		<script type="text/javascript" src="daf.js"></script>
333
		<div class="row" style="margin-bottom: 5px">
334
			<form id="daf-builder-form">
335
				<div class="col-md-11">
336 337
					<input type="text" id="daf-builder" class="form-control" aria-label="..." />
				</div>
338 339 340
				<div class="col-md-1">
					<button type="button" id="daf-add" class="btn btn-default btn-sm">Add</button>
				</div>
341 342 343
			</form>
		</div>
		<div class="row">
344 345 346 347 348
			<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>
349
		</div>
350
	]]}
351 352
end

353
return M