From 485a223f86fe48a0307e74e78feb7587f87d9460 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Tue, 10 Oct 2017 11:08:19 -0600 Subject: [PATCH] implemented better management of arrays in the config --- lib/admin/apis.js | 115 +++++++++++++++++++++++++++++-- lib/admin/config.js | 162 ++++++++++++++++++++++++++++++++++++++------ lib/storage.js | 20 ++++++ lib/worker.js | 23 +++++-- 4 files changed, 287 insertions(+), 33 deletions(-) diff --git a/lib/admin/apis.js b/lib/admin/apis.js index 902ec15..2d817f9 100644 --- a/lib/admin/apis.js +++ b/lib/admin/apis.js @@ -403,13 +403,108 @@ module.exports.create = function (deps, conf) { next(); } }; - config.restful.saveConfig = function (req, res) { - console.log('config POST body', req.body); - // Since we are sending the changes to another process we don't really - // have a good way of seeing if it worked, so always report success - deps.storage.config.save(req.body); - res.send({ success: true }); + config.restful.saveBaseConfig = function (req, res) { + console.log('config POST body', JSON.stringify(req.body)); + + deps.PromiseA.resolve().then(function () { + var update; + if (req.params.group) { + update = {}; + update[req.params.group] = req.body; + } else { + update = req.body; + } + + var changer = new (require('./config').ConfigChanger)(conf); + var errors = changer.update(update); + if (errors.length) { + throw Object.assign(new Error(), errors[0], {statusCode: 400}); + } + + return deps.storage.config.save(changer); + }).then(function (config) { + if (req.params.group) { + config = config[req.params.group]; + } + res.send(deps.recase.snakeCopy(config)); + }, function (err) { + res.statusCode = err.statusCode || 500; + err.message = err.message || err.toString(); + res.end(JSON.stringify({error: {message: err.message, code: err.code}})); + }); + }; + config.restful.createModule = function (req, res) { + var group = req.params.group; + var err; + deps.PromiseA.resolve().then(function () { + var changer = new (require('./config').ConfigChanger)(conf); + if (!changer[group] || !changer[group].modules) { + err = new Error("'"+group+"' is not a valid settings group or has not modules"); + err.statusCode = 404; + throw err; + } + + var modList; + if (req.params.id) { + if (changer[group].domains) { + modList = (changer[group].domains.find(req.params.id) || {}).modules; + } + } else { + modList = changer[group].modules; + } + if (!modList) { + err = new Error("'"+group+"' has no domains list or '"+req.params.id+"' does not exist"); + err.statusCode = 404; + throw err; + } + + modList.add(req.body); + var errors = changer.validate(); + if (errors.length) { + throw Object.assign(new Error(), errors[0], {statusCode: 400}); + } + + return deps.storage.config.save(changer); + }).then(function (config) { + var base; + if (!req.params.id) { + base = config[group]; + } else { + base = config[group].domains.find(function (dom) { return dom.id === req.params.id; }); + } + res.send(deps.recase.snakeCopy(base.modules)); + }, function (err) { + res.statusCode = err.statusCode || 500; + err.message = err.message || err.toString(); + res.end(JSON.stringify({error: {message: err.message, code: err.code}})); + }); + }; + config.restful.createDomain = function (req, res) { + var group = req.params.group; + var err; + deps.PromiseA.resolve().then(function () { + var changer = new (require('./config').ConfigChanger)(conf); + if (!changer[group] || !changer[group].domains) { + err = new Error("'"+group+"' is not a valid settings group or has no domains list"); + err.statusCode = 404; + throw err; + } + + changer[group].domains.add(req.body); + var errors = changer.validate(); + if (errors.length) { + throw Object.assign(new Error(), errors[0], {statusCode: 400}); + } + + return deps.storage.config.save(changer); + }).then(function (config) { + res.send(deps.recase.snakeCopy(config[group].domains)); + }, function (err) { + res.statusCode = err.statusCode || 500; + err.message = err.message || err.toString(); + res.end(JSON.stringify({error: {message: err.message, code: err.code}})); + }); }; var app = require('express')(); @@ -425,6 +520,12 @@ module.exports.create = function (deps, conf) { app.get( '/config/:group', config.restful.readConfig); app.get( '/config/:group/:name(modules|domains)/:id?', config.restful.readConfig); app.get( '/config/:group/:name(domains)/:id/:name2(modules)/:id2?', config.restful.readConfig); - app.post( '/config', config.restful.saveConfig); + + app.post( '/config', config.restful.saveBaseConfig); + app.post( '/config/:group', config.restful.saveBaseConfig); + app.post( '/config/:group/modules', config.restful.createModule); + app.post( '/config/:group/domains', config.restful.createDomain); + app.post( '/config/:group/domains/:id/modules', config.restful.createModule); + return app; }; diff --git a/lib/admin/config.js b/lib/admin/config.js index 9bcb688..4651f09 100644 --- a/lib/admin/config.js +++ b/lib/admin/config.js @@ -3,23 +3,6 @@ var validator = new (require('jsonschema').Validator)(); var recase = require('recase').create({}); -function deepCopy(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - var result; - if (Array.isArray(obj)) { - result = []; - } else { - result = {}; - } - Object.keys(obj).forEach(function (key) { - result[key] = deepCopy(obj[key]); - }); - return result; -} - var portSchema = { type: 'number', minimum: 1, maximum: 65535 }; var moduleSchemas = { @@ -67,7 +50,7 @@ var moduleSchemas = { } }; // forward is basically the name for the TCP proxy -moduleSchemas.forward = deepCopy(moduleSchemas.proxy); +moduleSchemas.forward = JSON.parse(JSON.stringify(moduleSchemas.proxy)); moduleSchemas.forward.required = [ 'ports' ]; moduleSchemas.forward.properties.ports = { type: 'array', items: portSchema }; @@ -180,6 +163,12 @@ var socks5Schema = { , port: portSchema } }; +var deviceSchema = { + type: 'object' +, properties: { + hostname: { type: 'string' } + } +}; var mainSchema = { type: 'object' @@ -192,10 +181,143 @@ var mainSchema = { , mdns: mdnsSchema , ddns: ddnsSchema , socks5: socks5Schema + , device: deviceSchema } , additionalProperties: false }; -module.exports.validate = function (config) { +function validate(config) { return validator.validate(recase.snakeCopy(config), mainSchema).errors; -}; +} +module.exports.validate = validate; + + +class ModuleList extends Array { + constructor(rawList) { + super(); + if (Array.isArray(rawList)) { + Object.assign(this, JSON.parse(JSON.stringify(rawList))); + } + } + + find(id) { + return Array.prototype.find.call(this, function (mod) { + return mod.id === id; + }); + } + add(mod) { + if (!mod.type) { + throw new Error("module must have a 'type' defined"); + } + if (!moduleSchemas[mod.type]) { + throw new Error("invalid module type '"+mod.type+"'"); + } + + mod.id = require('crypto').randomBytes(4).toString('hex'); + this.push(mod); + } +} +class DomainList extends Array { + constructor(rawList) { + super(); + if (Array.isArray(rawList)) { + Object.assign(this, JSON.parse(JSON.stringify(rawList))); + } + this.forEach(function (dom) { + dom.modules = new ModuleList(dom.modules); + }); + } + + find(id) { + return Array.prototype.find.call(this, function (dom) { + return dom.id === id; + }); + } + add(dom) { + if (!Array.isArray(dom.names) || !dom.names.length) { + throw new Error("domains must have a non-empty array for 'names'"); + } + if (dom.names.some(function (name) { return typeof name !== 'string'; })) { + throw new Error("all domain names must be strings"); + } + + var modList = new ModuleList(); + if (Array.isArray(dom.modules)) { + dom.modules.forEach(function (mod) { + modList.add(mod); + }); + } + + dom.id = require('crypto').randomBytes(4).toString('hex'); + dom.modules = modList; + this.push(dom); + } +} + +class ConfigChanger { + constructor(start) { + Object.assign(this, JSON.parse(JSON.stringify(start))); + delete this.device; + + this.http.modules = new ModuleList(this.http.modules); + this.http.domains = new DomainList(this.http.domains); + this.tls.modules = new ModuleList(this.tls.modules); + this.tls.domains = new DomainList(this.tls.domains); + this.tcp.modules = new ModuleList(this.tcp.modules); + this.dns.modules = new ModuleList(this.dns.modules); + } + + update(update) { + var self = this; + + if (update.http && update.http.modules) { + update.http.modules.forEach(self.http.modules.add.bind(self.http.modules)); + delete update.http.modules; + } + if (update.http && update.http.domains) { + update.http.domains.forEach(self.http.domains.add.bind(self.http.domains)); + delete update.http.domains; + } + + if (update.tls && update.tls.modules) { + update.tls.modules.forEach(self.tls.modules.add.bind(self.tls.modules)); + delete update.tls.modules; + } + if (update.tls && update.tls.domains) { + update.tls.domains.forEach(self.tls.domains.add.bind(self.tls.domains)); + delete update.tls.domains; + } + + if (update.tcp && update.tcp.modules) { + update.tcp.modules.forEach(self.tcp.modules.add.bind(self.tcp.modules)); + delete update.tcp.modules; + } + if (update.dns && update.dns.modules) { + update.dns.modules.forEach(self.dns.modules.add.bind(self.dns.modules)); + delete update.dns.modules; + } + + function mergeSettings(orig, changes) { + Object.keys(changes).forEach(function (key) { + // TODO: use an API that can properly handle updating arrays. + if (!changes[key] || (typeof changes[key] !== 'object') || Array.isArray(changes[key])) { + orig[key] = changes[key]; + } + else if (!orig[key] || typeof orig[key] !== 'object') { + orig[key] = changes[key]; + } + else { + mergeSettings(orig[key], changes[key]); + } + }); + } + mergeSettings(this, update); + + return validate(this); + } + + validate() { + return validate(this); + } +} +module.exports.ConfigChanger = ConfigChanger; diff --git a/lib/storage.js b/lib/storage.js index 4651f2d..33fc8f6 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -65,14 +65,33 @@ module.exports.create = function (deps, conf) { } }; + var confCb; var config = { save: function (changes) { deps.messenger.send({ type: 'com.daplie.goldilocks/config' , changes: changes }); + + return new deps.PromiseA(function (resolve, reject) { + var timeoutId = setTimeout(function () { + reject(new Error('Did not receive config update from main process in a reasonable time')); + confCb = null; + }, 15*1000); + + confCb = function (config) { + confCb = null; + clearTimeout(timeoutId); + resolve(config); + }; + }); } }; + function updateConf(config) { + if (confCb) { + confCb(config); + } + } var mdnsId = { _filename: 'mdns-id' @@ -99,6 +118,7 @@ module.exports.create = function (deps, conf) { return { owners: owners , config: config + , updateConf: updateConf , mdnsId: mdnsId }; }; diff --git a/lib/worker.js b/lib/worker.js index d7ad1c1..c219b96 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -1,6 +1,7 @@ 'use strict'; var config; +var modules; // Everything that uses the config should be reading it when relevant rather than // just at the beginning, so we keep the reference for the main object and just @@ -15,7 +16,13 @@ function update(conf) { config[key] = conf[key]; } }); - console.log('config', JSON.stringify(config)); + + console.log('config update', JSON.stringify(config)); + Object.values(modules).forEach(function (mod) { + if (typeof mod.updateConf === 'function') { + mod.updateConf(config); + } + }); } function create(conf) { @@ -38,11 +45,15 @@ function create(conf) { // HTTP proxying connection creation is not something we currently control. , net: require('net') }; - deps.storage = require('./storage').create(deps, conf); - deps.proxy = require('./proxy-conn').create(deps, conf); - deps.socks5 = require('./socks5-server').create(deps, conf); - deps.loopback = require('./loopback').create(deps, conf); - deps.ddns = require('./ddns').create(deps, conf); + + modules = { + storage: require('./storage').create(deps, conf) + , proxy: require('./proxy-conn').create(deps, conf) + , socks5: require('./socks5-server').create(deps, conf) + , loopback: require('./loopback').create(deps, conf) + , ddns: require('./ddns').create(deps, conf) + }; + Object.assign(deps, modules); require('./goldilocks.js').create(deps, conf); process.removeListener('message', create);