filter.lua 11.8 KB
Newer Older
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
1 2
--[[
Filter module for Alpine Wall
3
Copyright (C) 2012-2017 Kaarle Ritvanen
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
4
See LICENSE file for license details
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
5 6 7
]]--


Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
8
local loadclass = require('awall').loadclass
9
local resolve = require('awall.host')
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
10

11
local model = require('awall.model')
12 13 14
local class = model.class
local Rule = model.Rule

15
local combinations = require('awall.optfrag').combinations
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
16

17
local util = require('awall.util')
18
local contains = util.contains
19
local extend = util.extend
20
local listpairs = util.listpairs
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
21 22


23 24 25 26
local RECENT_MAX_COUNT = 20

local FilterLimit = class(model.Limit)

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
27 28 29 30 31 32 33 34 35 36
function FilterLimit:initmask()
   if self.name then
      for _, attr in ipairs{'src-mask', 'dest-mask'} do
        if self[attr] then
           self:error('Attribute not allowed with a named limit: '..attr)
        end
      end

      local limits = self.root.limit
      self[(self.addr or 'src')..'-mask'] = limits and limits[self.name] or true
37 38 39

   elseif self.update ~= nil then
      self:error('Attribute allowed only with named limits: update')
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
40 41 42 43 44 45 46 47 48
   end

   FilterLimit.super(self):initmask()

   if self.name and not self:recentofrags() then
      self:error('Attribute allowed only with low-rate limits: name')
   end
end

49 50 51 52 53
function FilterLimit:recentofrags(name)
   local count = self.count
   local interval = self.interval

   if count > RECENT_MAX_COUNT then
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
54
      count = self:intrate()
55 56 57 58 59
      interval = 1
   end

   if count > RECENT_MAX_COUNT then return end

60
   local update = self.update ~= false
61

62 63
   local ofs = self:recentmask(name)
   if not ofs then return end
64

65 66 67 68 69 70 71
   return combinations(
      ofs,
      {
	 {match='--'..(update and 'update' or 'rcheck')..' --hitcount '..
	     count..' --seconds '..interval}
      }
   ), update and combinations(ofs, {{match='--set'}}) or nil
72 73 74
end


75 76
local TranslatingRule = class(Rule)

77 78 79 80 81
function TranslatingRule:init(...)
   TranslatingRule.super(self):init(...)
   if type(self.dnat) == 'string' then self.dnat = {addr=self.dnat} end
end

82 83 84 85 86 87
function TranslatingRule:destoptfrags()
   local ofrags = TranslatingRule.super(self):destoptfrags()
   if not self.dnat then return ofrags end

   ofrags = combinations(ofrags, {{family='inet6'}})
   local natof = self:create(
88
      model.Zone, {addr=self.dnat.addr}
89 90 91 92 93 94
   ):optfrags(self:direction('out'))
   assert(#natof == 1)
   table.insert(ofrags, natof[1])
   return ofrags
end

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
function TranslatingRule:servoptfrags()
   local ofrags = TranslatingRule.super(self):servoptfrags()
   if not (self.dnat and self.dnat.port) then return ofrags end

   ofrags = combinations(ofrags, {{family='inet6'}})

   local protos = {}
   for _, serv in listpairs(self.service) do
      for _, sdef in listpairs(serv) do
	 if sdef.family ~= 'inet6' then
	    if not contains({'tcp', 'udp'}, sdef.proto) then
	       self:error('Cannot do port translation for '..sdef.proto)
	    end
	    protos[sdef.proto] = true
	 end
      end
   end
   for proto, _ in pairs(protos) do
      extend(
	 ofrags,
	 combinations(
	    self:create(
	       model.Rule, {service={proto=proto, port=self.dnat.port}}
	    ):servoptfrags(),
	    {{family='inet'}}
	 )
      )
   end

   return ofrags
end

127

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
128 129 130 131
local LoggingRule = class(TranslatingRule)

function LoggingRule:init(...)
   LoggingRule.super(self):init(...)
132
   util.setdefault(self, 'action', 'accept')
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
133 134

   local custom = self:customtarget()
135
   if type(self.log) ~= 'table' then
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
136
      self.log = loadclass('log').get(
137
	 self, self.log, not custom and self:logdefault()
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
138 139 140 141
      )
   end
   if custom and self.log then
      self:error('Logging not allowed with custom action: '..self.action)
142
   end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
143 144
end

145 146
function LoggingRule:logdefault() return false end

147
function LoggingRule:target() return 'ACCEPT' end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
148

149 150 151 152 153
function LoggingRule:actofrags(log, target)
   local res = log and log:optfrags() or {}
   if target ~= nil then table.insert(res, {target=target}) end
   return res
end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
154

155 156 157 158
function LoggingRule:combinelog(ofrags, log, action, target)
   local actions = self:actofrags(log, target)
   return actions[1] and
      self:combine(ofrags, actions, 'log'..action, log) or ofrags
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
159 160
end

161
function LoggingRule:mangleoptfrags(ofrags)
162
   return self:combinelog(ofrags, self.log, self.action, self:target())
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
163 164 165
end


166
local RelatedRule = class(TranslatingRule)
167 168 169 170 171 172 173 174

function RelatedRule:servoptfrags()
   local helpers = {}
   for i, serv in listpairs(self.service) do
      for i, sdef in listpairs(serv) do
	 local helper = sdef['ct-helper']
	 if helper then
	    helpers[helper] = {
175
	       family=sdef.family,
176 177
	       match='-m conntrack --ctstate RELATED -m helper --helper '..
	          helper
178 179 180 181 182 183 184
	    }
	 end
      end
   end
   return util.values(helpers)
end

185 186
function RelatedRule:target() return 'ACCEPT' end

187

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
188
local Filter = class(LoggingRule)
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
189

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
190
function Filter:init(...)
191
   Filter.super(self):init(...)
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
192 193

   -- alpine v2.4 compatibility
194
   if contains({'logdrop', 'logreject'}, self.action) then
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
195
      self:warning('Deprecated action: '..self.action)
196
      self.action = self.action:sub(4, -1)
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
197 198 199
   end

   local limit = self:limit()
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
200
   if limit then
201 202 203
      if limit == 'conn-limit' and self['no-track'] then
	 self:error('Tracking required with connection limit')
      end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
204 205 206
      if type(self[limit]) ~= 'table' then
	 self[limit] = {count=self[limit]}
      end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
207
      self[limit].log = loadclass('log').get(self, self[limit].log, true)
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
208
   end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
209 210
end

211
function Filter:extratrules()
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
212 213
   local res = {}

214 215
   local function extrarules(label, cls, options)
      options = options or {}
216 217
      options.attrs = 'dnat'
      extend(res, self:extrarules(label, cls, options))
218 219
   end

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
220
   if self.dnat then
221 222 223
      if self.action ~= 'accept' then
	 self:error('dnat option not allowed with '..self.action..' action')
      end
224 225 226
      if self['no-track'] then
	 self:error('dnat option not allowed with no-track')
      end
227 228
      if self.ipset then
	 self:error('dnat and ipset options cannot be used simultaneously')
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
229 230
      end

231 232 233 234
      if self.dnat.addr:find('/') then
	 self:error('DNAT target cannot be a network address')
      end

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
235
      local dnataddr
236
      for i, addr in ipairs(resolve(self.dnat.addr, self)) do
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
237 238
	 if addr[1] == 'inet' then
	    if dnataddr then
239 240 241
	       self:error(
		  self.dnat.addr..' resolves to multiple IPv4 addresses'
	       )
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
242 243 244 245 246
	    end
	    dnataddr = addr[2]
	 end
      end
      if not dnataddr then
247
	 self:error(self.dnat.addr..' does not resolve to any IPv4 address')
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
248 249
      end

250 251 252 253 254 255 256 257
      extrarules(
	 'dnat',
	 'dnat',
	 {
	    update={['to-addr']=dnataddr, ['to-port']=self.dnat.port},
	    discard='out'
	 }
      )
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
258 259
   end

260
   if self.action == 'tarpit' or self['no-track'] then
261
      extrarules('no-track', 'no-track')
262
   end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
263

264
   if self.action == 'accept' then
265
      if self:position() == 'prepend' then
266
	 extrarules('final', LoggingRule, {update={log=self.log}})
267 268
      end

269 270 271 272
      local nr = #res

      if self.related then
	 for i, rule in listpairs(self.related) do
273
	    extrarules(
274 275 276
	       'related',
	       RelatedRule,
	       {index=i, src=rule, update={service=self.service}}
277
	    )
278 279 280 281
	 end
      else
	 -- TODO avoid creating unnecessary RELATED rules by introducing
	 -- helper direction attributes to service definitions
282
	 extrarules('related', RelatedRule)
283
	 extrarules('related-reply', RelatedRule, {update={reverse=true}})
284 285 286 287 288 289
      end

      if self['no-track'] then
	 if #res > nr then
	    self:error('Tracking required by service')
	 end
290 291
	 extrarules('no-track-reply', 'no-track', {update={reverse=true}})
	 extrarules('reply', 'filter', {update={reverse=true}})
292
      end
293 294
   end

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
295 296 297
   return res
end

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
298 299 300 301 302
function Filter:limit()
   local res
   for i, limit in ipairs({'conn-limit', 'flow-limit'}) do
      if self[limit] then
	 if res then
303
	    self:error('Cannot specify multiple limits for a single filter rule')
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
304 305 306 307 308 309 310 311
	 end
	 res = limit
      end
   end
   return res
end

function Filter:position()
312 313
   return not self['no-track'] and self:limit() == 'flow-limit'
      and 'prepend' or 'append'
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
314 315
end

316 317 318 319
function Filter:logdefault()
   return contains({'drop', 'reject', 'tarpit'}, self.action)
end

320
function Filter:target()
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
321
   if self.action == 'pass' then return end
322 323
   if self.action ~= 'accept' and not self:logdefault() then
      self:error('Invalid filter action: '..self.action)
324
   end
325
   return self.action == 'tarpit' and 'tarpit' or self.action:upper()
326 327
end

328
function Filter:mangleoptfrags(ofrags)
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
329
   local limit = self:limit()
330
   if not limit then return Filter.super(self):mangleoptfrags(ofrags) end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
331

332 333 334 335 336 337
   local function incompatible(item)
      self:error('Limit incompatible with '..item)
   end

   if self:customtarget() or self:logdefault() then
      incompatible('action: '..self.action)
338
   end
339

340 341 342
   local limitchain = self:uniqueid('limit')
   local limitlog = self[limit].log
   local limitobj = self:create(FilterLimit, self[limit], 'limit')
343

344
   local ofs
345
   local conn = limit == 'conn-limit'
346 347 348
   local target = self:target()
   local ct = conn and target
   local pl = not target and self.log
349

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
350
   local cofs, sofs = limitobj:recentofrags(limitchain)
351

Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
352 353
   if cofs then
      ofs = self:combinelog(cofs, limitlog, 'drop', 'DROP')
354 355 356 357 358

      local nxt
      if ct then
	 extend(ofs, self:actofrags(self.log))
	 nxt = target
359
      elseif sofs and not pl then nxt = false end
360
      extend(ofs, combinations(sofs, self:actofrags(pl, nxt)))
361

362
   else
363 364
      if pl then incompatible('action or log') end

365
      local limofs = limitobj:limitofrags(limitchain)
366
      ofs = ct and Filter.super(self):mangleoptfrags(limofs) or
367
	 combinations(limofs, {{target='RETURN'}})
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
368

369
      extend(ofs, self:actofrags(limitlog, 'DROP'))
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
370
   end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
371

372
   return self:combine(ofrags, ofs, 'limit', true)
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
373 374 375
end


376
local Policy = class(Filter)
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
377 378 379 380

function Policy:servoptfrags() return nil end


381 382
local fchains = {{chain='FORWARD'}, {chain='INPUT'}, {chain='OUTPUT'}}

383
local function stateful(config)
384 385
   local res = {}

386 387 388 389
   for i, family in ipairs{'inet', 'inet6'} do

      local er = combinations(
	 fchains,
390
	 {{match='-m conntrack --ctstate ESTABLISHED'}}
391
      )
392 393
      for i, chain in ipairs({'INPUT', 'OUTPUT'}) do
	 table.insert(
394
	    er, {chain=chain, match='-'..chain:sub(1, 1):lower()..' lo'}
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411
	 )
      end
      extend(
	 res,
	 combinations(er, {{family=family, table='filter', target='ACCEPT'}})
      )

      -- TODO avoid creating unnecessary CT rules by inspecting the
      -- filter rules' target families and chains
      local visited = {}
      local ofrags = {}
      for i, rule in listpairs(config.filter) do
	 for i, serv in listpairs(rule.service) do
	    if not visited[serv] then
	       for i, sdef in listpairs(serv) do
		  if sdef['ct-helper'] then
		     local of = combinations(
412
			Rule.morph{service={sdef}}:servoptfrags(),
413 414 415 416 417 418 419 420
			{{family=family}}
		     )
		     if of[1] then
			assert(#of == 1)
			of[1].target = 'CT --helper '..sdef['ct-helper']
			table.insert(ofrags, of[1])
		     end
		  end
421
	       end
422
	       visited[serv] = true
423 424 425
	    end
	 end
      end
426 427 428 429 430 431 432
      extend(
	 res,
	 combinations(
	    {{table='raw'}},
	    {{chain='PREROUTING'}, {chain='OUTPUT'}},
	    ofrags
	 )
433
      )
434
   end
435 436

   return res
437
end
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
438

439 440
local icmp = {{family='inet', table='filter', match='-p icmp'}}
local icmp6 = {{family='inet6', table='filter', match='-p icmpv6'}}
441 442 443 444 445 446 447
local ir = combinations(
   icmp6,
   {{chain='INPUT'}, {chain='OUTPUT'}},
   {{target='ACCEPT'}}
)
extend(ir, combinations(icmp6, {{chain='FORWARD', target='icmp-routing'}}))
extend(ir, combinations(icmp, fchains, {{target='icmp-routing'}}))
448 449

local function icmprules(ofrag, oname, types)
450 451
   extend(
      ir,
452 453 454 455 456
      combinations(
         ofrag,
	 {{chain='icmp-routing', target='ACCEPT'}},
	 util.map(types, function(t) return {match='--'..oname..' '..t} end)
      )
457
   )
458 459 460
end
icmprules(icmp, 'icmp-type', {3, 11, 12})
icmprules(icmp6, 'icmpv6-type', {1, 2, 3, 4})
Kaarle Ritvanen's avatar
Kaarle Ritvanen committed
461

462 463 464 465 466 467 468 469
return {
   export={
      filter={class=Filter, before={'dnat', 'no-track'}},
      policy={class=Policy, after='%filter-after'},
      ['%filter-before']={rules=stateful, before='filter'},
      ['%filter-after']={rules=ir, after='filter'}
   },
   achains=combinations(
470
      {{chain='tarpit'}}, {{match='-p tcp', target='TARPIT'}, {target='DROP'}}
471
   )
472
}