From e4361842fcdec369fbd4466f2528b2815f504ff9 Mon Sep 17 00:00:00 2001 From: Kaarle Ritvanen Date: Sun, 16 Dec 2012 19:10:38 +0200 Subject: initial version --- LICENSE | 29 +++++ acf.lua | 1 + acf/init.lua | 13 +++ acf/model.lua | 1 + acf/model/field.lua | 120 +++++++++++++++++++++ acf/model/init.lua | 206 ++++++++++++++++++++++++++++++++++++ acf/model/model.lua | 102 ++++++++++++++++++ acf/model/node.lua | 129 ++++++++++++++++++++++ acf/modules/aaa.lua | 25 +++++ acf/modules/awall.lua | 138 ++++++++++++++++++++++++ acf/modules/generic.lua | 13 +++ acf/object.lua | 64 +++++++++++ acf/path.lua | 61 +++++++++++ acf/persistence.lua | 1 + acf/persistence/backends/augeas.lua | 26 +++++ acf/persistence/backends/files.lua | 68 ++++++++++++ acf/persistence/backends/json.lua | 110 +++++++++++++++++++ acf/persistence/init.lua | 47 ++++++++ acf/persistence/util.lua | 20 ++++ acf/transaction.lua | 1 + acf/transaction/backend.lua | 48 +++++++++ acf/transaction/init.lua | 174 ++++++++++++++++++++++++++++++ acf/util.lua | 37 +++++++ config/aaa.json | 1 + config/awall.json | 1 + install-deps.sh | 6 ++ protocol.txt | 66 ++++++++++++ run-server.sh | 8 ++ server.lua | 115 ++++++++++++++++++++ 29 files changed, 1631 insertions(+) create mode 100644 LICENSE create mode 120000 acf.lua create mode 100644 acf/init.lua create mode 120000 acf/model.lua create mode 100644 acf/model/field.lua create mode 100644 acf/model/init.lua create mode 100644 acf/model/model.lua create mode 100644 acf/model/node.lua create mode 100644 acf/modules/aaa.lua create mode 100644 acf/modules/awall.lua create mode 100644 acf/modules/generic.lua create mode 100644 acf/object.lua create mode 100644 acf/path.lua create mode 120000 acf/persistence.lua create mode 100644 acf/persistence/backends/augeas.lua create mode 100644 acf/persistence/backends/files.lua create mode 100644 acf/persistence/backends/json.lua create mode 100644 acf/persistence/init.lua create mode 100644 acf/persistence/util.lua create mode 120000 acf/transaction.lua create mode 100644 acf/transaction/backend.lua create mode 100644 acf/transaction/init.lua create mode 100644 acf/util.lua create mode 100644 config/aaa.json create mode 100644 config/awall.json create mode 100755 install-deps.sh create mode 100644 protocol.txt create mode 100755 run-server.sh create mode 100644 server.lua diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a0b36bf --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2012 Kaarle Ritvanen + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted (subject to the limitations in the +disclaimer below) provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE +GRANTED BY THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT +HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/acf.lua b/acf.lua new file mode 120000 index 0000000..8a363eb --- /dev/null +++ b/acf.lua @@ -0,0 +1 @@ +acf/init.lua \ No newline at end of file diff --git a/acf/init.lua b/acf/init.lua new file mode 100644 index 0000000..3358571 --- /dev/null +++ b/acf/init.lua @@ -0,0 +1,13 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +require('acf.util').loadmods('modules') + +require 'acf.model' +require 'acf.object' +require 'acf.path' +require 'acf.transaction' diff --git a/acf/model.lua b/acf/model.lua new file mode 120000 index 0000000..033a948 --- /dev/null +++ b/acf/model.lua @@ -0,0 +1 @@ +model/init.lua \ No newline at end of file diff --git a/acf/model/field.lua b/acf/model/field.lua new file mode 100644 index 0000000..cfa14b9 --- /dev/null +++ b/acf/model/field.lua @@ -0,0 +1,120 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local node = require('acf.model.node') + +local object = require('acf.object') +local class = object.class +local super = object.super + +local map = require('acf.util').map + + +local function contains(list, value) + for k, v in ipairs(list) do if v == value then return true end end + return false +end + +local function auto_ui_name(name) + if not name then return end + return string.gsub(string.upper(string.sub(name, 1, 1))..string.sub(name, 2), + '_', ' ') +end + + +Field = class() + +function Field:init(params) + for k, v in pairs(params or {}) do if not self[k] then self[k] = v end end + + if self.choice and not self['ui-choice'] then + self['ui-choice'] = map(auto_ui_name, self.choice) + end + + if not self.widget then + self.widget = self.choice and 'combobox' or 'field' + end +end + +function Field:meta(txn, path, addr) + assert(self.dtype) + return { + name=self.name, + type=self.dtype, + required=self.required, + choice=self.choice, + description=self.description, + ['ui-name']=self['ui-name'] or auto_ui_name(self.name), + widget=self.widget, + ['ui-choice']=self['ui-choice'] + } +end + +function Field:load(txn, path, addr) + local value = txn:get(addr) + if value == nil then return self.default end + return value +end + +function Field:_validate(txn, path, value) + if self.required and value == nil then + error('Required value not set: '..path) + end + if self.choice and value ~= nil and not contains(self.choice, value) then + error('Invalid value for '..path..': '..value) + end + if value ~= nil then self:validate(txn, path, value) end + return value +end + +function Field:validate(txn, path, value) end + +function Field:save(txn, path, addr, value) + -- 2nd argument currenly not much used by backends + txn:set(addr, self.dtype, self:_validate(txn, path, value)) +end + +function Field:validate_saved(txn, path, addr) + self:save(txn, path, addr, self:load(txn, path, addr)) +end + + +TreeNode = class(Field) + +function TreeNode:save(txn, path, addr, value) + -- TODO hack, allow preserving old instance on parent update + if value == path then return end + + if object.isinstance(value, node.TreeNode) then + -- TODO clone if TreeNode has wrong path + assert(node.path(value) == path) + return + end + + txn:set(addr) + if value then + assert(type(value) == 'table') + txn:set(addr, 'table') + local new = self:load(txn, path, addr, true) + for k, v in pairs(value) do new[k] = v end + end +end + + +Model = class(TreeNode) + +function Model:init(params) + super(self, Model):init(params) + assert(self.model) + self.dtype = 'model' + self.widget = self.dtype +end + +function Model:load(txn, path, addr, create) + if not create and not txn:get(addr) then return end + return self.model(txn, path, addr) +end diff --git a/acf/model/init.lua b/acf/model/init.lua new file mode 100644 index 0000000..6934796 --- /dev/null +++ b/acf/model/init.lua @@ -0,0 +1,206 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local fld = require('acf.model.field') +local Field = fld.Field + +local model = require('acf.model.model') +new = model.new +to_field = model.to_field + +node = require('acf.model.node') + +local object = require('acf.object') +local class = object.class +local super = object.super + +local pth = require('acf.path') +local map = require('acf.util').map + + +-- TODO object-specific actions + +-- TODO access control + + +local Primitive = class(Field) + +function Primitive:validate(txn, path, value) + local t = self.dtype + if type(value) ~= t then error('Not a '..t..': '..tostring(value)) end +end + + +String = class(Primitive) + +function String:init(params) + super(self, String):init(params) + self.dtype = 'string' +end + +function String:validate(txn, path, value) + if self['max-length'] and string.len(value) > self['max-length'] then + error('Maximum length exceeded: '..value) + end +end + +function String:meta(txn, path, addr) + local res = super(self, String):meta(txn, path, addr) + res['max-length'] = self['max-length'] + return res +end + + +Number = class(Primitive) + +function Number:init(params) + super(self, Number):init(params) + self.dtype = 'number' +end + +function Number:_validate(txn, path, value) + return tonumber(super(self, Number):_validate(txn, path, value)) +end + + +Integer = class(Number) + +function Integer:validate(txn, path, value) + if math.floor(value) ~= value then error('Not an integer: '..value) end +end + + +Boolean = class(Primitive) + +function Boolean:init(params) + super(self, Boolean):init(params) + self.dtype = 'boolean' + self.widget = self.dtype +end + + +Reference = class(Field) + +function Reference:init(params) + super(self, Reference):init(params) + self.dtype = 'reference' + if not self.scope then self.scope = '/' end +end + +function Reference:abs_scope(path) + return pth.to_absolute(self.scope, pth.parent(pth.parent(path))) +end + +function Reference:meta(txn, path, addr) + local res = super(self, Reference):meta(txn, path, addr) + res.scope = self:abs_scope(path) + + local base = txn:search(res.scope) + local objs = base and txn:get(getmetatable(base).addr) or {} + res.choice = map(function(p) return pth.join(res.scope, p) end, objs) + res['ui-choice'] = objs + + return res +end + +function Reference:follow(txn, path, value) + return txn:search(pth.join(self:abs_scope(path), value)) +end + +function Reference:load(txn, path, addr) + local ref = super(self, Reference):load(txn, path, addr) + return ref and self:follow(txn, path, ref) or nil +end + +function Reference:_validate(txn, path, value) + super(self, Reference):_validate(txn, path, value) + + if value == nil then return end + + if object.isinstance(value, node.TreeNode) then + value = getmetatable(value).path + end + if value and pth.is_absolute(value) then + local scope = self:abs_scope(path) + local prefix = scope..'/' + if not stringy.startswith(value, prefix) then + error('Reference out of scope ('..scope..')') + end + value = string.sub(value, string.len(prefix) + 1, -1) + end + + -- assume one-level ref for now + assert(not string.find(value, '/')) + if not self:follow(txn, path, value) then + error('Does not exist: '..path) + end + -- TODO check instance type + + return value +end + + +Model = fld.Model + + +Collection = class(fld.TreeNode) + +function Collection:init(params, ctype) + super(self, Collection):init(params) + assert(self.type) + self.ctype = ctype or node.Collection + self.dtype = 'collection' + self.widget = self.dtype +end + +function Collection:load(txn, path, addr) + -- automatically create missing collection (TODO: make this configurable?) + return self.ctype(txn, path, addr, to_field(self.type), self.required) +end + + +PrimitiveList = class(Collection) + +function PrimitiveList:init(params) + super(self, PrimitiveList):init(params, node.PrimitiveList) +end + + +-- experimental +Mixed = class(Collection) + +function Mixed:init() + super(self, Mixed):init({type=Mixed}, node.Mixed) +end + +function Mixed:load(txn, path, addr) + local value = Primitive.load(self, txn, path, addr) + if type(value) == 'table' then + return super(self, Mixed):load(txn, path, addr) + end + return value +end + +function Mixed:save(txn, path, addr, value) + if type(value) == 'table' then + super(self, Mixed):save(txn, path, addr, value) + else Primitive.save(self, txn, path, addr, value) end +end + + + +RootModel = new() + +function RootModel:init(txn) super(self, RootModel):init(txn, '/') end + +function register(name, path, field) + local field = to_field(field) + function field:compute(txn, pth, addr) + return self:load(txn, '/'..name, path) + end + RootModel[name] = field +end diff --git a/acf/model/model.lua b/acf/model/model.lua new file mode 100644 index 0000000..2efcb6f --- /dev/null +++ b/acf/model/model.lua @@ -0,0 +1,102 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local fld = require('acf.model.field') +local Field = fld.Field + +local node = require('acf.model.node') + +local object = require('acf.object') +local class = object.class +local super = object.super +local isinstance = object.isinstance + +local util = require('acf.util') + + +function to_field(obj) + if object.issubclass(obj, Model) then return fld.Model{model=obj} end + return getmetatable(obj).class and obj or obj() +end + + +function new(base) + if not base then base = Model end + + local res = class(base) + res._fields = base == node.TreeNode and {} or util.copy(base._fields) + + local mt = util.copy(getmetatable(res)) + + function mt.__index(t, k) return base[k] end + + function mt.__newindex(t, k, v) + assert(v) + + local override = t[k] + if type(v) == 'table' then v = to_field(v) end + + rawset(t, k, v) + + if isinstance(v, Field) then + v.name = k + if not override then table.insert(t._fields, k) end + end + end + + setmetatable(res, mt) + + if isinstance(base, Model) then util.setdefaults(res, base) end + + return res +end + +Model = new(node.TreeNode) + +function Model:init(txn, path, addr) + super(self, Model):init(txn, path, addr) + + local mt = getmetatable(self) + + function mt.field(name) + local res = mt.class[name] + return isinstance(res, Field) and node.BoundField(self, res) or nil + end + + function mt.mmeta(name) return mt.field(name):meta() end + + mt.meta = {type='model', + fields=util.map(function(f) return mt.mmeta(f) end, + self._fields)} + + function mt.members() + return util.map(function(f) return f.name end, mt.meta.fields) + end + + function mt.__index(t, k) + local f = mt.field(k) + if f then + if f.compute then return f:compute() end + return f:load() + end + return mt.class[k] + end + + function mt.__newindex(t, k, v) + local f = mt.field(k) + if not f then error('Field named '..k..' does not exist') end + f:save(v) + txn.validate[mt.path] = function() self:validate() end + end +end + +function Model:validate() + local mt = getmetatable(self) + for _, name in ipairs(mt.members()) do + mt.field(name):validate_saved() + end +end diff --git a/acf/model/node.lua b/acf/model/node.lua new file mode 100644 index 0000000..39a0d92 --- /dev/null +++ b/acf/model/node.lua @@ -0,0 +1,129 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local object = require('acf.object') +local class = object.class +local super = object.super + +local pth = require('acf.path') + + +BoundField = class() + +function BoundField:init(parent, field) + local pmt = getmetatable(parent) + local mt = {} + + function mt.__index(t, k) + local member = field[k] + if type(member) ~= 'function' then return member end + return function(self, ...) + local name + if field.name then name = field.name + else + name = arg[1] + table.remove(arg, 1) + end + return member(field, + pmt.txn, + pth.join(pmt.path, name), + pmt.addr and pth.join(pmt.addr, name), + unpack(arg)) + end + end + + setmetatable(self, mt) +end + + +TreeNode = class() + +function TreeNode:init(txn, path, addr) + local mt = getmetatable(self) + mt.txn = txn + mt.path = path + mt.addr = addr +end + +function TreeNode:search(path) + if #path == 0 then return self end + local next = path[1] + table.remove(path, 1) + return TreeNode.search(self[next], path) +end + + +Collection = class(TreeNode) + +function Collection:init(txn, path, addr, field, required) + super(self, Collection):init(txn, path, addr) + + if required then + txn.validate[path] = function() + if #txn:get(addr) == 0 then + error('Collection cannot be empty: '..path) + end + end + end + + self.init = nil + self.search = nil + + local mt = getmetatable(self) + + mt.field = BoundField(self, field) + mt.meta = {type='collection', members=mt.field:meta()} + function mt.mmeta(name) return mt.meta.members end + function mt.members() return txn:get(addr) or {} end + + function mt.__index(t, k) return mt.field:load(k) end + function mt.__newindex(t, k, v) mt.field:save(k, v) end +end + + +PrimitiveList = class(Collection) + +function PrimitiveList:init(txn, path, addr, field, required) + super(self, PrimitiveList):init(txn, path, addr, field, required) + + local mt = getmetatable(self) + local index = mt.__index + + function mt.__index(t, k) + if type(k) == 'number' then return index(t, k) end + + for i, j in ipairs(txn:get(addr) or {}) do + assert(i == tonumber(j)) + if mt.field:load(i) == k then return k end + end + error('Value does not exist: '..k) + end +end + + +-- experimental +Mixed = class(Collection) + +function Mixed:init(txn, path, addr, field, required) + super(self, Mixed):init(txn, path, addr, field, required) + -- TODO dynamic meta: list non-leaf children + getmetatable(self).meta = {type='mixed'} +end + + +local function meta_func(attr) + return function(node, ...) + local res = getmetatable(node)[attr] + if type(res) == 'function' then return res(unpack(arg)) end + return res + end +end + +members = meta_func('members') +meta = meta_func('meta') +mmeta = meta_func('mmeta') +path = meta_func('path') diff --git a/acf/modules/aaa.lua b/acf/modules/aaa.lua new file mode 100644 index 0000000..f8e28ca --- /dev/null +++ b/acf/modules/aaa.lua @@ -0,0 +1,25 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local M = require('acf.model') + +Role = M.new() +Role.permissions = M.Collection{type=M.Reference{scope='../../permissions'}} + +User = M.new() +User.password = M.String +User.real_name = M.String +User.roles = M.Collection{type=M.Reference{scope='../../roles'}} + +Authentication = M.new() +Authentication.users = M.Collection{type=User} +Authentication.roles = M.Collection{type=Role} +Authentication.permissions = M.PrimitiveList{type=M.String} + +M.register('auth', + '/json'..require('lfs').currentdir()..'/config/aaa.json', + Authentication) diff --git a/acf/modules/awall.lua b/acf/modules/awall.lua new file mode 100644 index 0000000..8d25638 --- /dev/null +++ b/acf/modules/awall.lua @@ -0,0 +1,138 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local M = require('acf.model') + +local object = require('acf.object') +local class = object.class +local super = object.super + + +IPv4Addr = class(M.String) +function IPv4Addr:validate(txn, path, value) + local function test(...) + if #arg ~= 4 then return true end + for _, octet in ipairs(arg) do + if tonumber(octet) > 255 then return true end + end + end + if test(string.match(value, '(%d+)%.(%d+)%.(%d+)%.(%d+)')) then + error('Invalid IP address: '..value) + end +end + +Port = class(M.Integer) +function Port:validate(txn, path, value) + super(self, Port):validate(txn, path, value) + if value < 0 or value > 65535 then error('Invalid port: '..value) end +end + +Range = class(M.String) +function Range:validate(txn, path, value) + local comps = stringy.split(value, '-') + if #comps > 2 then error('Invalid range: '..value) end + for _, v in ipairs(comps) do + local num = tonumber(v) + if not num then error('Invalid range: ' ..value) end + M.to_field(self.type):validate(txn, path, num) + end +end + +PortRange = class(Range) +function PortRange:init() super(self, PortRange):init{type=Port} end + +Direction = class(M.String) +function Direction:init() + super(self, Direction):init{choice={'in', 'out'}} +end + + +-- TODO reference types? + +IPSet = M.new() +-- TODO choices +IPSet.type = M.String{required=true} +IPSet.family = M.String{required=true, choice={'inet', 'inet6'}} + +Service = M.new() +Service.proto = M.String{required=true} +Service.port = M.Collection{type=PortRange} +Service['icmp-type'] = M.String + +-- TODO fw zone + +Zone = M.new() +Zone.iface = M.PrimitiveList{type=M.String} +Zone.addr = M.PrimitiveList{type=M.String} +Zone['route-back'] = M.Boolean{default=false} + +LogClass = M.new() +LogClass.mode = M.String{default='log', choice={'log', 'nflog', 'ulog'}} +LogClass.limit = M.Integer +LogClass.prefix = M.String + +IPSetReference = M.new() +IPSetReference.name = M.Reference{scope='../../ipset', required=true} +IPSetReference.args = M.Collection{type=Direction, required=true} + +Rule = M.new() +Rule['in'] = M.Collection{type=M.Reference{scope='../../zone'}} +Rule.out = M.Collection{type=M.Reference{scope='../../zone'}} +Rule.src = M.Collection{type=M.String} +Rule.dest = M.Collection{type=M.String} +Rule.ipset = IPSetReference +Rule.ipsec = Direction +Rule.service = M.Collection{type=M.Reference{scope='../../service'}} +Rule.action = M.String{choice={'accept'}} + + +-- TODO no service field +PolicyRule = M.new(Rule) +PolicyRule.log = M.Reference{scope='../log'} +PolicyRule.action = M.String{required=true, + choice={'accept', 'drop', 'reject', 'tarpit'}} + +Limit = M.new() +Limit.count = M.Integer +Limit.interval = M.Integer +Limit.log = M.Reference{scope='../../log'} + +FilterRule = M.new(PolicyRule) +FilterRule['conn-limit'] = Limit +FilterRule['flow-limit'] = Limit +FilterRule.dnat = IPv4Addr +FilterRule['no-track'] = M.Boolean{default=false} + +NATRule = M.new(Rule) +NATRule['to-addr'] = Range{type=IPv4Addr} +NATRule['to-port'] = PortRange + +MarkRule = M.new(Rule) +MarkRule.mark = M.Integer{required=true} + +ClampMSSRule = M.new(Rule) +ClampMSSRule.mss = M.Integer + + +AWall = M.new() +-- TODO differentiate lists? +AWall.service = M.Collection{type=M.Collection{type=Service}} +AWall.zone = M.Collection{type=Zone} +AWall.log = M.Collection{type=LogClass} +AWall.policy = M.Collection{type=PolicyRule} +AWall.filter = M.Collection{type=FilterRule} +AWall.dnat = M.Collection{type=NATRule} +AWall.snat = M.Collection{type=NATRule} +AWall.mark = M.Collection{type=MarkRule} +AWall['route-track'] = M.Collection{type=MarkRule} +AWall['clamp-mss'] = M.Collection{type=ClampMSSRule} +AWall['no-track'] = M.Collection{type=Rule} +AWall.ipset = M.Collection{type=IPSet} + +M.register('awall', + '/json'..require('lfs').currentdir()..'/config/awall.json', + AWall) diff --git a/acf/modules/generic.lua b/acf/modules/generic.lua new file mode 100644 index 0000000..76c42fc --- /dev/null +++ b/acf/modules/generic.lua @@ -0,0 +1,13 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +-- provided as an example, to be removed from production version + +module(..., package.seeall) + +local M = require('acf.model') + +M.register('proc', '/files/proc', M.Mixed) +M.register('augeas', '/augeas/files', M.Mixed) diff --git a/acf/object.lua b/acf/object.lua new file mode 100644 index 0000000..cbccf70 --- /dev/null +++ b/acf/object.lua @@ -0,0 +1,64 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +function class(base) + local cls = {} + + local mt = { + __call=function(self, ...) + local obj = {} + setmetatable(obj, {__index=cls, class=cls}) + obj:init(unpack(arg)) + return obj + end + } + + if not base and Object then base = Object end + if base then + cls._base = base + mt.__index = base + end + + return setmetatable(cls, mt) +end + + +Object = class() + +function Object:init(...) end + + +function super(obj, cls) + assert(isinstance(obj, cls)) + + local mt = {} + + function mt.__index(t, k) + local v = cls._base[k] + if type(v) ~= 'function' then return v end + return function(...) + arg[1] = obj + return v(unpack(arg)) + end + end + + return setmetatable({}, mt) +end + + +function issubclass(cls1, cls2) + if cls1 == cls2 then return true end + if cls1._base then return issubclass(cls1._base, cls2) end + return false +end + +function isinstance(obj, cls) + if not obj or type(obj) ~= 'table' then return false end + local mt = getmetatable(obj) + if not mt then return false end + return type(obj) == 'table' and mt.class and issubclass(mt.class, cls) +end diff --git a/acf/path.lua b/acf/path.lua new file mode 100644 index 0000000..9b709e9 --- /dev/null +++ b/acf/path.lua @@ -0,0 +1,61 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +require 'stringy' + +local function filter(func, tbl) + local res = {} + for k, v in pairs(tbl) do if func(v) then res[k] = v end end + return res +end + + +function is_absolute(path) + return string.sub(path, 1, 1) == '/' +end + +function to_absolute(path, base) + if not is_absolute(path) then + path = base..(base ~= '/' and '/' or '')..path + end + local comps = split(path) + local i = 1 + while i <= #comps do + if comps[i] == '..' then + if i == 1 then error('Invalid path: '..path) end + table.remove(comps, i - 1) + table.remove(comps, i - 1) + i = i - 1 + else i = i + 1 end + end + return '/'..table.concat(comps, '/') +end + +function join(p1, p2, ...) + if not p2 then return p1 end + if not is_absolute(p2) then p2 = '/'..p2 end + return join((p1 == '/' and '' or p1)..p2, unpack(arg)) +end + +function split(path) + -- assume absolute paths + assert(is_absolute(path)) + if path == '/' then return {} end + return filter(function(s) return s > '' end, + stringy.split(string.sub(path, 2, -1), '/')) +end + +function parent(path) + local comps = split(path) + table.remove(comps) + return join('/', unpack(comps)) +end + +function name(path) + local comps = split(path) + return comps[#comps] +end diff --git a/acf/persistence.lua b/acf/persistence.lua new file mode 120000 index 0000000..4ab39ce --- /dev/null +++ b/acf/persistence.lua @@ -0,0 +1 @@ +persistence/init.lua \ No newline at end of file diff --git a/acf/persistence/backends/augeas.lua b/acf/persistence/backends/augeas.lua new file mode 100644 index 0000000..34d7df8 --- /dev/null +++ b/acf/persistence/backends/augeas.lua @@ -0,0 +1,26 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local pth = require('acf.path') +local map = require('acf.util').map + +backend = require('acf.object').class() + +function backend:init() self.aug = require('augeas').init() end + +function backend:get(path) + path = '/'..pth.join(unpack(path)) + local _, count = self.aug:match(path) + if count == 0 then return end + + local value = self.aug:get(path) + if value ~= nil then return value end + + return map(pth.name, self.aug:match(path..'/*')) +end + +-- TODO implement set function diff --git a/acf/persistence/backends/files.lua b/acf/persistence/backends/files.lua new file mode 100644 index 0000000..8e59ab9 --- /dev/null +++ b/acf/persistence/backends/files.lua @@ -0,0 +1,68 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local pth = require('acf.path') +local util = require('acf.persistence.util') + +require 'lfs' + + +backend = require('acf.object').class() + +-- TODO cache expiration +function backend:init() self.cache = {} end + +function backend:get(path) + local name = pth.join('/', unpack(path)) + + if not self.cache[name] then + local attrs = lfs.attributes(name) + if not attrs then return end + + if attrs.mode == 'file' then + self.cache[name] = util.read_file(name) + + elseif attrs.mode == 'directory' then + local res = {} + for fname in lfs.dir(name) do + if not ({['.']=true, ['..']=true})[fname] then + table.insert(res, fname) + end + end + return res + + else error('Unsupported file type: '..name) end + + -- TODO present symlinks as references + end + + return self.cache[name] +end + +function backend:set(mods) + for _, mod in pairs(mods) do + local path, t, value = unpack(mod) + local name = pth.join('/', unpack(path)) + + -- TODO save references (t == 'reference') as symlinks + + if not t then + -- TODO del files & dirs + print('DEL', name) + + elseif t == 'table' then + lfs.mkdir(name) + + else + local file = util.open_file(name, 'w') + file:write(value) + file:close() + + self.cache[name] = value + end + end +end diff --git a/acf/persistence/backends/json.lua b/acf/persistence/backends/json.lua new file mode 100644 index 0000000..7300ce9 --- /dev/null +++ b/acf/persistence/backends/json.lua @@ -0,0 +1,110 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local pth = require('acf.path') +local util = require('acf.persistence.util') +local copy = require('acf.util').copy + +require 'json' +require 'lfs' + + +local function keys(tbl) + local res = {} + for k, v in pairs(tbl) do table.insert(res, k) end + return res +end + + +backend = require('acf.object').class() + +function backend:init() + -- TODO cache expiration + self.cache = {} + self.dirty = {} +end + +function backend:split_path(path) + local fpath = copy(path) + local jpath = {} + local res + + while #fpath > 0 do + local fp = pth.join('/', unpack(fpath)) + if self.cache[fp] then return fp, jpath end + table.insert(jpath, 1, fpath[#fpath]) + table.remove(fpath) + end + + fpath = '/' + + while true do + fpath = pth.join(fpath, jpath[1]) + table.remove(jpath, 1) + + local attrs = lfs.attributes(fpath) + if not attrs or not ({directory=true, file=true})[attrs.mode] then + error('File or directory does not exist: '..fpath) + end + + if attrs.mode == 'file' then return fpath, jpath end + + assert(#jpath > 0) + end +end + +function backend:_get(path) + local fpath, jpath = self:split_path(path) + + if not self.cache[fpath] then + self.cache[fpath] = json.decode(util.read_file(fpath)) + end + + local res = self.cache[fpath] + + while #jpath > 0 do + if res == nil then return end + assert(type(res) == 'table') + local next = jpath[1] + res = res[tonumber(next) or next] + table.remove(jpath, 1) + end + + return res +end + +function backend:get(path) + local res = self:_get(path) + return type(res) == 'table' and keys(res) or res +end + +function backend:set(mods) + local dirty = {} + + for _, mod in ipairs(mods) do + local p, t, value = unpack(mod) + local fpath, jpath = self:split_path(p) + if t == 'table' then value = {} end + + if #jpath == 0 then self.cache[fpath] = value + + else + local comps = copy(p) + local name = comps[#comps] + table.remove(comps) + self:_get(comps)[tonumber(name) or name] = value + end + + dirty[fpath] = true + end + + for path, _ in pairs(dirty) do + local file = util.open_file(path, 'w') + file:write(json.encode(self.cache[path])) + file:close() + end +end diff --git a/acf/persistence/init.lua b/acf/persistence/init.lua new file mode 100644 index 0000000..f674e9c --- /dev/null +++ b/acf/persistence/init.lua @@ -0,0 +1,47 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local object = require('acf.object') +local super = object.super +local pth = require('acf.path') +local util = require('acf.util') + + +DataStore = object.class(require('acf.transaction.backend').TransactionBackend) + +function DataStore:init() + super(self, DataStore):init() + self.backends = util.map(function(m) return m.backend() end, + util.loadmods('persistence/backends')) +end + +function DataStore:split_path(path) + local comps = pth.split(path) + local backend = self.backends[comps[1]] + assert(backend) + table.remove(comps, 1) + return backend, comps +end + +function DataStore:get(path) + local backend, comps = self:split_path(path) + return util.copy(backend:get(comps)), self.mod_time[path] or 0 +end + +function DataStore:set_multiple(mods) + super(self, DataStore):set_multiple(mods) + + local bms = {} + for _, mod in ipairs(mods) do + local path, t, value = unpack(mod) + local backend, comps = self:split_path(path) + if not bms[backend] then bms[backend] = {} end + table.insert(bms[backend], {comps, t, value}) + end + + for backend, bm in pairs(bms) do backend:set(bm) end +end diff --git a/acf/persistence/util.lua b/acf/persistence/util.lua new file mode 100644 index 0000000..46de5eb --- /dev/null +++ b/acf/persistence/util.lua @@ -0,0 +1,20 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +function open_file(path, mode) + local file = io.open(path, mode) + if not file then error('Cannot open file: '..path) end + return file +end + +function read_file(path) + local file = open_file(path) + local data = '' + for line in file:lines() do data = data..line end + file:close() + return data +end diff --git a/acf/transaction.lua b/acf/transaction.lua new file mode 120000 index 0000000..d4bb5c7 --- /dev/null +++ b/acf/transaction.lua @@ -0,0 +1 @@ +transaction/init.lua \ No newline at end of file diff --git a/acf/transaction/backend.lua b/acf/transaction/backend.lua new file mode 100644 index 0000000..5b8c53f --- /dev/null +++ b/acf/transaction/backend.lua @@ -0,0 +1,48 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +-- TODO each transaction backend (i.e. persistence manager or +-- transaction proper) should be implemented as a thread or have its +-- internal state stored in shared storage (with appropriate locking) + + +local generation = 0 +function gen_number() + generation = generation + 1 + return generation +end + + +TransactionBackend = require('acf.object').class() + +function TransactionBackend:init() self.mod_time = {} end + +function TransactionBackend:get_if_older(path, timestamp) + local value, ts = self:get(path) + if ts > timestamp then error('Concurrent modification: '..path) end + return value, ts +end + +function TransactionBackend:set(path, t, value) + self:set_multiple{{path, t, value}} +end + +function TransactionBackend:set_multiple(mods) + -- TODO delegate to PM backends? + local timestamp = gen_number() + for _, mod in ipairs(mods) do + self.mod_time[mod[1]] = timestamp + end +end + +-- TODO should be atomic, mutex with set_multiple +function TransactionBackend:comp_and_setm(accessed, mods) + for path, timestamp in pairs(accessed) do + self:get_if_older(path, timestamp) + end + self:set_multiple(mods) +end diff --git a/acf/transaction/init.lua b/acf/transaction/init.lua new file mode 100644 index 0000000..7f2f6b9 --- /dev/null +++ b/acf/transaction/init.lua @@ -0,0 +1,174 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +local RootModel = require('acf.model').RootModel +local object = require('acf.object') +local super = object.super +local pth = require('acf.path') +local be_mod = require('acf.transaction.backend') +local copy = require('acf.util').copy + + +local function remove_list_value(list, value) + value = tostring(value) + + for i, v in ipairs(list) do + if tostring(v) == value then + table.remove(list, i) + return + end + end + + assert(false) +end + + +local Transaction = object.class(be_mod.TransactionBackend) + +function Transaction:init(backend) + super(self, Transaction):init() + + self.backend = backend + + self.started = be_mod.gen_number() + self.access_time = {} + + self.added = {} + self.modified = {} + self.deleted = {} + + self.validate = {} + + self.root = RootModel(self) +end + +function Transaction:check() + if not self.backend then error('Transaction already committed') end +end + +function Transaction:get(path) + self:check() + + if self.deleted[path] then return nil, self.mod_time[path] end + for _, tbl in ipairs{self.added, self.modified} do + if tbl[path] ~= nil then + return copy(tbl[path][2]), self.mod_time[path] + end + end + + local value, timestamp = self.backend:get_if_older(path, self.started) + self.access_time[path] = timestamp + return value, timestamp +end + +function Transaction:set_multiple(mods) + super(self, Transaction):set_multiple(mods) + + local function set(path, t, value, new) + local delete = value == nil + value = not delete and {t, value} or nil + + if self.added[path] == nil and (not new or self.deleted[path]) then + self.modified[path] = value + self.deleted[path] = delete + else self.added[path] = value end + end + + for _, mod in ipairs(mods) do + local path, t, value = unpack(mod) + + local ppath = pth.parent(path) + local parent = self:get(ppath) + if parent == nil then + self:set(ppath, 'table', true) + parent = {} + end + + local name = pth.name(path) + local old = self:get(path) + + local is_table = t == 'table' + local delete = not is_table and value == nil + + if type(old) == 'table' then + if delete then + for _, child in ipairs(old) do self:set(pth.join(path, child)) end + elseif is_table then return + elseif #old > 0 then + error('Cannot assign a primitive value to non-leaf node '..path) + end + end + + if is_table then value = {} end + set(path, not delete and t or nil, value, old == nil) + + if old == nil and not delete then + table.insert(parent, name) + set(ppath, 'table', parent) + elseif old ~= nil and delete then + remove_list_value(parent, name) + set(ppath, 'table', parent) + end + end +end + +function Transaction:search(path) return self.root:search(pth.split(path)) end + +function Transaction:commit() + self:check() + + for _, func in pairs(self.validate) do func() end + + local mods = {} + local handled = {} + + local function insert(path, t, value) + assert(not handled[path]) + table.insert(mods, {path, t, value}) + handled[path] = true + end + + local function insert_add(path) + if not handled[path] then + local pp = pth.parent(path) + if self.added[pp] then insert_add(pp) end + + local t, value = unpack(self.added[path]) + if t == 'table' then value = nil end + insert(path, t, value) + end + end + + local function insert_del(path) + local value = self.backend:get(path) + if type(value) == 'table' then + for _, child in ipairs(value) do + local cp = pth.join(path, child) + assert(self.deleted[cp]) + if not handled[cp] then insert_del(cp) end + end + end + insert(path) + end + + for path, deleted in pairs(self.deleted) do + if deleted then insert_del(path) end + end + + for path, value in pairs(self.modified) do + local t, v = unpack(value) + if t ~= 'table' then insert(path, t, v) end + end + + for path, _ in pairs(self.added) do insert_add(path) end + + self.backend:comp_and_setm(self.access_time, mods) + self.backend = nil +end + +local store = require('acf.persistence').DataStore() +function start(txn) return Transaction(txn or store) end diff --git a/acf/util.lua b/acf/util.lua new file mode 100644 index 0000000..cbea072 --- /dev/null +++ b/acf/util.lua @@ -0,0 +1,37 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +module(..., package.seeall) + +function setdefaults(dst, src) + for k, v in pairs(src) do if dst[k] == nil then dst[k] = v end end + return dst +end + +function copy(var) + return type(var) == 'table' and setdefaults({}, var) or var +end + +function map(func, tbl) + local res = {} + for k, v in pairs(tbl) do res[k] = func(copy(v)) end + return res +end + + +local pth = require('acf.path') +require 'lfs' + +function loadmods(subdir) + local comps = pth.split('/acf/'..subdir) + local res = {} + for modfile in lfs.dir(pth.join(unpack(comps))) do + if stringy.endswith(modfile, '.lua') then + local name = string.sub(modfile, 1, -5) + res[name] = require(table.concat(comps, '.')..'.'..name) + end + end + return res +end diff --git a/config/aaa.json b/config/aaa.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/config/aaa.json @@ -0,0 +1 @@ +{} diff --git a/config/awall.json b/config/awall.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/config/awall.json @@ -0,0 +1 @@ +{} diff --git a/install-deps.sh b/install-deps.sh new file mode 100755 index 0000000..7a1a708 --- /dev/null +++ b/install-deps.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# Copyright (c) 2012 Kaarle Ritvanen +# See LICENSE file for license details + +exec apk add lua-augeas lua-filesystem lua-json4 lua-stringy uwsgi uwsgi-lua diff --git a/protocol.txt b/protocol.txt new file mode 100644 index 0000000..2ac63b9 --- /dev/null +++ b/protocol.txt @@ -0,0 +1,66 @@ +ACF2 HTTP Protocol +================== + +Load JavaScript client: +req: GET / + +Start transaction: +req: POST / +resp: txn ID (in header as X-ACF-Transaction-ID) + - use X-ACF-Transaction-ID in the header of any subsequent + request to process it in the transaction's context + +Commit transaction: +req: POST / + X-ACF-Transaction-ID: + +Abort transaction: +req: DELETE / + X-ACF-Transaction-ID: + +Get object: +req: GET /config/ +resp: JSON object, with the following attributes: + data: JSON serialization of object + - primitive types as JSON primitives + - references as path names (relative to scope) + - models and collections as JSON objects or arrays with + members as attributes: + - primitive members as JSON primitives + - reference, model, and collection members as path names + meta: JSON object, with the following attributes + - name (last component of path name) + - ui-name (shown to user) + - description (optional help text) + - type (e.g. model, collection, reference, string, number, + boolean) + - widget (name of client-side JS module used to display + the data) + - required (boolean) + - max-length + - choices (optional array of allowed values) + - ui-choices (user-friendly choices to be shown in combo + boxes) + - fields (model only): array of field metadata JSON + objects + - members (collection only): metadata for members (JSON + object) + - scope (references only): subtree where the reference can + refer to + +Create/update object: +req: PUT /config/ + - body shall contain the object serialized as the data attribute + in GET responses + - undefined model attributes are deleted + +Delete object: +req: DELETE /config/ + +Invoke object-specific action (not yet supported by server): +req: POST /config// + - arguments passed as a JSON array in body; serialization as in + GET responses +resp: action-specific JSON + - for time-consuming actions, can return multiple JSON + documents, each containing a status update diff --git a/run-server.sh b/run-server.sh new file mode 100755 index 0000000..556f9d3 --- /dev/null +++ b/run-server.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +# Copyright (c) 2012 Kaarle Ritvanen +# See LICENSE file for license details + +cd $(dirname $0) + +exec uwsgi -M --http :${1:-80} --plugin /usr/lib/uwsgi/lua_plugin.so --lua server.lua --remap-modifier 6:0 --static-map /browser=web --static-index client.html diff --git a/server.lua b/server.lua new file mode 100644 index 0000000..11bfe55 --- /dev/null +++ b/server.lua @@ -0,0 +1,115 @@ +--[[ +Copyright (c) 2012 Kaarle Ritvanen +See LICENSE file for license details +--]] + +require 'acf' +local isinstance = acf.object.isinstance + +require 'json' +require 'stringy' + + +local function handle(env, txn, path) + local method = env.REQUEST_METHOD + local data + if env.CONTENT_LENGTH then + data = json.decode(env.input:read(env.CONTENT_LENGTH)) + end + + local parent, name + if path ~= '/' then + parent = txn:search(acf.path.parent(path)) + name = acf.path.name(path) + end + + if method == 'GET' then + local obj = txn:search(path) + if obj == nil then return 404 end + + local res + + if isinstance(obj, acf.model.node.TreeNode) then + local node = {} + for _, k in ipairs(acf.model.node.members(obj)) do + local v = obj[k] + if isinstance(v, acf.model.node.TreeNode) then + v = acf.model.node.path(v) + end + node[k] = v + end + res = {data=node, meta=acf.model.node.meta(obj)} + + else res = {data=obj, meta=acf.model.node.mmeta(parent, name)} end + + local function f() coroutine.yield(json.encode(res)) end + return 200, {['Content-Type']='application/json'}, coroutine.wrap(f) + end + + if method == 'DELETE' then + parent[name] = nil + + elseif method == 'PUT' then + parent[name] = data + + -- TODO implement POST for invoking object-specific actions + + else return 405 end + + return 200 +end + + +-- TODO implement transactions as threads or store their state in +-- shared storage +local last_id = 0 +local txns = {} + + +return function(env) + local method = env.REQUEST_METHOD + local path = env.REQUEST_URI + + -- TODO login session management + + local txn_id = tonumber(env.HTTP_X_ACF_TRANSACTION_ID) + local txn + if txn_id then + txn = txns[txn_id] + if not txn then return 404 end + else txn = acf.transaction.start() end + + if stringy.startswith(path, '/config/') then + -- TODO catch and forward relevant errors to the client + local code, hdr, body = handle(env, + txn, + string.sub(path, 8, -1)) + if not txn_id and method ~= 'GET' and code == 200 then + txn:commit() + end + return code, hdr, body + end + + if path == '/' then + if method == 'GET' then return 301, {['Location']='/browser/'} end + + if not ({DELETE=true, POST=true})[method] then + return 405 + end + + if txn_id then + if method == 'POST' then txn:commit() end + txns[txn_id] = nil + return 200 + end + + if method == 'DELETE' then return 405 end + + last_id = last_id + 1 + local txn_id = last_id + txns[txn_id] = txn + return 200, {['X-ACF-Transaction-ID']=txn_id} + end + + return 404 + end -- cgit v1.2.3