'use strict'; var validator = new (require('jsonschema').Validator)(); var recase = require('recase').create({}); var portSchema = { type: 'number', minimum: 1, maximum: 65535 }; var moduleSchemas = { // the proxy module is common to basically all categories. proxy: { type: 'object' , oneOf: [ { required: [ 'address' ] } , { required: [ 'port' ] } ] , properties: { address: { type: 'string' } , host: { type: 'string' } , port: portSchema } } // redirect and static modules are for HTTP , redirect: { type: 'object' , required: [ 'to', 'from' ] , properties: { to: { type: 'string'} , from: { type: 'string'} , status: { type: 'integer', minimum: 1, maximum: 999 } , } } , static: { type: 'object' , required: [ 'root' ] , properties: { root: { type: 'string' } } } // the acme module is for TLS , acme: { type: 'object' , required: [ 'email' ] , properties: { email: { type: 'string' } , server: { type: 'string' } , challengeType: { type: 'string' } } } }; // forward is basically the name for the TCP proxy moduleSchemas.forward = JSON.parse(JSON.stringify(moduleSchemas.proxy)); moduleSchemas.forward.required = [ 'ports' ]; moduleSchemas.forward.properties.ports = { type: 'array', items: portSchema }; Object.keys(moduleSchemas).forEach(function (name) { var schema = moduleSchemas[name]; schema.id = '/modules/'+name; schema.required = ['id', 'type'].concat(schema.required || []); schema.properties.id = { type: 'string' }; schema.properties.type = { type: 'string', const: name }; validator.addSchema(schema, schema.id); }); function addDomainsSchema(base, modList) { var modSchemas = modList.map(function (name) { return { '$ref': '/modules/'+name }; }); base.required = [ 'modules', 'domains' ].concat(base.required || []); base.properties.modules = { type: 'array' , items: { type: 'object' , required: [ 'domains' ] , properties: { domains: { type: 'array', items: { type: 'string' }, minLength: 1} } , oneOf: modSchemas } }; base.properties.domains = { type: 'array' , items: { type: 'object' , required: [ 'id', 'names', ] , properties: { id: { type: 'string' } , names: { type: 'array', items: { type: 'string' }, minLength: 1} , modules: { type: 'array', items: { oneOf: modSchemas }} } } }; } var httpSchema = { type: 'object' , properties: { // These properties should be snake_case to match the API and config format primary_domain: { type: 'string' } , allow_insecure: { type: 'boolean' } , trust_proxy: { type: 'boolean' } , } }; addDomainsSchema(httpSchema, ['proxy', 'static', 'redirect']); var tlsSchema = { type: 'object' , properties: { acme: { type: 'object' // These properties should be snake_case to match the API and config format , required: [ 'email', 'approved_domains' ] , properties: { email: { type: 'string' } , server: { type: 'string' } , challenge_type: { type: 'string' } , approved_domains: { type: 'array', items: { type: 'string' }, minLength: 1} } } } }; addDomainsSchema(tlsSchema, ['proxy', 'acme']); var tcpSchema = { type: 'object' , required: [ 'bind' ] , properties: { bind: { type: 'array', items: portSchema, minLength: 1 } , modules: { type: 'array', items: { '$ref': '/modules/forward' }} } }; var dnsSchema = { type: 'object' , properties: { bind: { type: 'array', items: portSchema } , modules: { type: 'array', items: { '$ref': '/modules/proxy' }} } }; var mdnsSchema = { type: 'object' , required: [ 'port', 'broadcast', 'ttl' ] , properties: { port: portSchema , broadcast: { type: 'string' } , ttl: { type: 'integer', minimum: 0, maximum: 2147483647 } } }; var ddnsSchema = { type: 'object' , properties: { enabled: { type: 'boolean' } } }; var socks5Schema = { type: 'object' , properties: { enabled: { type: 'boolean' } , port: portSchema } }; var deviceSchema = { type: 'object' , properties: { hostname: { type: 'string' } } }; var mainSchema = { type: 'object' , required: [ 'http', 'tls', 'tcp', 'dns', 'mdns', 'ddns' ] , properties: { http: httpSchema , tls: tlsSchema , tcp: tcpSchema , dns: dnsSchema , mdns: mdnsSchema , ddns: ddnsSchema , socks5: socks5Schema , device: deviceSchema } , additionalProperties: false }; 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;