summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKaarle Ritvanen <kaarle.ritvanen@datakunkku.fi>2012-12-16 19:10:38 +0200
committerKaarle Ritvanen <kaarle.ritvanen@datakunkku.fi>2012-12-16 19:10:38 +0200
commite4361842fcdec369fbd4466f2528b2815f504ff9 (patch)
tree4694ca56f9e9a4869763d8fe1f31a81f6f62ac0b
downloadaconf-e4361842fcdec369fbd4466f2528b2815f504ff9.tar.bz2
aconf-e4361842fcdec369fbd4466f2528b2815f504ff9.tar.xz
initial version
-rw-r--r--LICENSE29
l---------acf.lua1
-rw-r--r--acf/init.lua13
l---------acf/model.lua1
-rw-r--r--acf/model/field.lua120
-rw-r--r--acf/model/init.lua206
-rw-r--r--acf/model/model.lua102
-rw-r--r--acf/model/node.lua129
-rw-r--r--acf/modules/aaa.lua25
-rw-r--r--acf/modules/awall.lua138
-rw-r--r--acf/modules/generic.lua13
-rw-r--r--acf/object.lua64
-rw-r--r--acf/path.lua61
l---------acf/persistence.lua1
-rw-r--r--acf/persistence/backends/augeas.lua26
-rw-r--r--acf/persistence/backends/files.lua68
-rw-r--r--acf/persistence/backends/json.lua110
-rw-r--r--acf/persistence/init.lua47
-rw-r--r--acf/persistence/util.lua20
l---------acf/transaction.lua1
-rw-r--r--acf/transaction/backend.lua48
-rw-r--r--acf/transaction/init.lua174
-rw-r--r--acf/util.lua37
-rw-r--r--config/aaa.json1
-rw-r--r--config/awall.json1
-rwxr-xr-xinstall-deps.sh6
-rw-r--r--protocol.txt66
-rwxr-xr-xrun-server.sh8
-rw-r--r--server.lua115
29 files changed, 1631 insertions, 0 deletions
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: <txn_id>
+
+Abort transaction:
+req: DELETE /
+ X-ACF-Transaction-ID: <txn_id>
+
+Get object:
+req: GET /config/<obj_path>
+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/<obj_path>
+ - body shall contain the object serialized as the data attribute
+ in GET responses
+ - undefined model attributes are deleted
+
+Delete object:
+req: DELETE /config/<obj_path>
+
+Invoke object-specific action (not yet supported by server):
+req: POST /config/<obj_path>/<action>
+ - 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