Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: gluon-config-api #2296

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
5 changes: 5 additions & 0 deletions package/features
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ when(_'web-advanced' and _'autoupdater', {
'gluon-web-autoupdater',
})

feature('config-api', {
'gluon-config-api-contact-info',
'gluon-config-api-geo-location',
})


when(_'mesh-batman-adv-15', {
'gluon-ebtables-limit-arp',
Expand Down
16 changes: 16 additions & 0 deletions package/gluon-config-api-contact-info/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright (C) 2021 Leonardo Moerlein <me at irrelefant.net>
# This is free software, licensed under the Apache 2.0 license.

include $(TOPDIR)/rules.mk

PKG_NAME:=gluon-config-api-contact-info
PKG_VERSION:=1

include ../gluon.mk

define Package/gluon-config-api-contact-info
TITLE:=Allows the user to provide contact information to be distributed in the mesh
DEPENDS:=+gluon-config-api-core
endef

$(eval $(call BuildPackageGluon,gluon-config-api-contact-info))
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@

local M = {}

function M.schema(site, platform)
return {
properties = {
wizard = {
properties = {
contact = {
type = 'string'
}
}
}
}
}
end

function M.set(config, uci)
local owner = uci:get_first("gluon-node-info", "owner")

uci:set("gluon-node-info", owner, "contact", config.wizard.contact)
uci:save("gluon-node-info")
end

function M.get(uci, config)
local owner = uci:get_first("gluon-node-info", "owner")

config.wizard = config.wizard or {}
config.wizard.contact = uci:get("gluon-node-info", owner, "contact")
end

return M
16 changes: 16 additions & 0 deletions package/gluon-config-api-core/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright (C) 2021 Leonardo Moerlein <me at irrelefant.net>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spdx

# This is free software, licensed under the Apache 2.0 license.

include $(TOPDIR)/rules.mk

PKG_NAME:=gluon-config-api-core
PKG_VERSION:=1

include ../gluon.mk

define Package/gluon-config-api-core
TITLE:=Provides a REST API to configure the gluon node
DEPENDS:=+gluon-web +uhttpd +libucl +gluon-config-mode-core
endef

$(eval $(call BuildPackageGluon,gluon-config-api-core))
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
local os = require 'os'
local json = require 'jsonc'
local site = require 'gluon.site'
local glob = require 'posix.glob'
local libgen = require 'posix.libgen'
local simpleuci = require 'simple-uci'
local schema = dofile('/lib/gluon/config-api/controller/schema.lua')
local ucl = require "ucl"

package 'gluon-config-api'

function load_parts()
local parts = {}
for _, f in pairs(glob.glob('/lib/gluon/config-api/parts/*.lua')) do
table.insert(parts, dofile(f))
end
return parts
end

function config_get(parts)
local config = {}
local uci = simpleuci.cursor()

for _, part in pairs(parts) do
part.get(uci, config)
end

return config
end

function schema_get(parts)
local total_schema = {}
for _, part in pairs(parts) do
total_schema = schema.merge_schemas(total_schema, part.schema(site, nil))
end
return total_schema
end

function config_set(parts, config)
local uci = simpleuci.cursor()

for _, part in pairs(parts) do
part.set(config, uci)
end

-- commit all uci configs
os.execute('uci commit')
end

local function pump(src, snk)
while true do
local chunk, src_err = src()
local ret, snk_err = snk(chunk, src_err)

if not (chunk and ret) then
local err = src_err or snk_err
if err then
return nil, err
else
return true
end
end
end
end

local function json_response(http, obj)
local result = json.stringify(obj, true)
http:header('Content-Type', 'application/json; charset=utf-8')
-- Content-Length is needed, as the transfer encoding is not chunked for
-- http method OPTIONS.
http:header('Content-Length', tostring(#result))
http:write(result..'\n')
end

local function get_request_body_as_json(http)
local request_body = ""
pump(http.input, function (data)
if data then
request_body = request_body .. data
end
end)

-- Verify that we really have JSON input. UCL is able to parse other
-- config formats as well. Those config formats allow includes and so on.
-- This may be a security issue.

local data = json.parse(request_body)

if not data then
http:status(400, 'Bad Request')
json_response(http, { status = 400, error = "Bad JSON in Body" })
http:close()
return
end

return data
end

local function verify_schema(schema, config)
local parser = ucl.parser()
local res, err = parser:parse_string(json.stringify(config))

assert(res, "Internal UCL Parsing Failed. This should not happen at all.")

res, err = parser:validate(schema)
return res
end

entry({"v1", "config"}, call(function(http, renderer)
local parts = load_parts()

if http.request.env.REQUEST_METHOD == 'GET' then
json_response(http, config_get(parts))
elseif http.request.env.REQUEST_METHOD == 'POST' then
local config = get_request_body_as_json(http)

-- Verify schema
if not verify_schema(schema_get(parts), config) then
http:status(400, 'Bad Request')
json_response(http, { status = 400, error = "Schema mismatch" })
http:close()
return
end

-- Apply config
config_set(parts, config)

-- Write result
json_response(http, { status = 200, error = "Accepted" })
elseif http.request.env.REQUEST_METHOD == 'OPTIONS' then
json_response(http, {
schema = schema_get(parts),
allowed_methods = {'GET', 'POST', 'OPTIONS'}
})
else
http:status(501, 'Not Implemented')
http:header('Content-Length', '0')
http:write('Not Implemented\n')
end

http:close()
end))
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@

local util = require 'gluon.util'

local M = {}

local function merge_types(Ta, Tb)
-- T == nil means "any" type is allowed

if not Ta then return Tb end
if not Tb then return Ta end

-- convert scalar types to arrays
if type(Ta) ~= 'table' then Ta = { Ta } end
if type(Tb) ~= 'table' then Tb = { Tb } end

local Tnew = {}

for _, t in pairs(Ta) do
if util.contains(Tb, t) then
table.insert(Tnew, t)
end
end

assert(#Tnew > 0, 'ERROR: The schema does not match anything at all.')

if #Tnew == 1 then
return Tnew[1] -- convert to scalar
else
return Tnew
end
end

local function keys(tab)
local keys = {}
if tab then
for k, _ in pairs(tab) do
table.insert(keys, k)
end
end
return keys
end

local function merge_array(table1, table2)
local values = {}
if table1 then
for _, v in pairs(table1) do
table.insert(values, v)
end
end
if table2 then
for _, v in pairs(table2) do
if not util.contains(values, v) then
table.insert(values, v)
end
end
end
return values
end

local function deepcopy(o, seen)
seen = seen or {}
if o == nil then return nil end
if seen[o] then return seen[o] end

local no
if type(o) == 'table' then
no = {}
seen[o] = no

for k, v in next, o, nil do
no[deepcopy(k, seen)] = deepcopy(v, seen)
end
setmetatable(no, deepcopy(getmetatable(o), seen))
else -- number, string, boolean, etc
no = o
end
return no
end

function M.merge_schemas(schema1, schema2)
local merged = {}

merged.type = merge_types(schema1.type, schema2.type)

function add_property(pkey, pdef)
merged.properties = merged.properties or {}
merged.properties[pkey] = pdef
end

if not merged.type or merged.type == 'object' then
-- generate merged.properties
local properties1 = schema1.properties or {}
local properties2 = schema2.properties or {}

for _, pkey in pairs(merge_array(keys(properties1), keys(properties2))) do
local pdef1 = properties1[pkey]
local pdef2 = properties2[pkey]

if pdef1 and pdef2 then
add_property(pkey, M.merge_schemas(pdef1, pdef2))
elseif pdef1 then
add_property(pkey, deepcopy(pdef1))
elseif pdef2 then
add_property(pkey, deepcopy(pdef2))
end
end

-- generate merged.additionalProperties
if schema1.additionalProperties and schema2.additionalProperties then
merged.additionalProperties = M.merge_schemas(
schema1.additionalProperties, schema2.additionalProperties)
else
merged.additionalProperties = false
end

-- generate merged.required
merged.required = merge_array(schema1.required, schema2.required)
if #merged.required == 0 then
merged.required = nil
end
end

-- TODO: implement array
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found it


-- generate merged.default
merged.default = schema2.default or schema1.default

return merged
end

return M
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

local M = {}

function M.schema(site, platform)
return {
type = 'object',
properties = {
wizard = {
type = 'object',
additionalProperties = false
}
},
additionalProperties = false,
required = { 'wizard' }
}
end

function M.set(config, uci)
end

function M.get(uci, config)
end

return M
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/lua

require 'gluon.web.cgi' {
base_path = '/lib/gluon/config-api',

layout_package = 'gluon-config-api',
layout_template = 'theme/layout', -- only used for error pages
}
Loading