1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
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
|