'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 toSchemaRef(name) { return { '$ref': '/modules/'+name }; } var moduleRefs = { http: [ 'proxy', 'static', 'redirect' ].map(toSchemaRef) , tls: [ 'proxy', 'acme' ].map(toSchemaRef) , tcp: [ 'forward' ].map(toSchemaRef) , udp: [ 'proxy' ].map(toSchemaRef) }; function addDomainRequirement(itemSchema) { itemSchema.required = (itemSchema.required || []).concat('domains'); itemSchema.properties = itemSchema.properties || {}; itemSchema.domains = { type: 'array', items: { type: 'string' }, minLength: 1}; return itemSchema; } var domainSchema = { type: 'array' , items: { type: 'object' , properties: { id: { type: 'string' } , names: { type: 'array', items: { type: 'string' }, minLength: 1} , modules: { type: 'object' , properties: { tls: { type: 'array', items: { oneOf: moduleRefs.tls }} , http: { type: 'array', items: { oneOf: moduleRefs.http }} } , additionalProperties: false } } } }; var httpSchema = { type: 'object' , properties: { modules: { type: 'array', items: addDomainRequirement({ oneOf: moduleRefs.http }) } // 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' } // these are forbidden deprecated settings. , bind: { not: {} } , domains: { not: {} } } }; var tlsSchema = { type: 'object' , properties: { modules: { type: 'array', items: addDomainRequirement({ oneOf: moduleRefs.tls }) } , 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} // these are forbidden deprecated settings. , bind: { not: {} } , domains: { not: {} } } } } }; var tcpSchema = { type: 'object' , required: [ 'bind' ] , properties: { bind: { type: 'array', items: portSchema, minLength: 1 } , modules: { type: 'array', items: { oneOf: moduleRefs.tcp }} } }; var udpSchema = { type: 'object' , properties: { bind: { type: 'array', items: portSchema } , modules: { type: 'array', items: { oneOf: moduleRefs.udp }} } }; 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: [ 'domains', 'http', 'tls', 'tcp', 'udp', 'mdns', 'ddns' ] , properties: { domains:domainSchema , http: httpSchema , tls: tlsSchema , tcp: tcpSchema , udp: udpSchema , 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 IdList extends Array { constructor(rawList) { super(); if (Array.isArray(rawList)) { Object.assign(this, JSON.parse(JSON.stringify(rawList))); } this._itemName = 'item'; } findId(id) { return Array.prototype.find.call(this, function (dom) { return dom.id === id; }); } add(item) { item.id = require('crypto').randomBytes(4).toString('hex'); this.push(item); } update(id, update) { var item = this.findId(id); if (!item) { var error = new Error("no "+this._itemName+" with ID '"+id+"'"); error.statusCode = 404; throw error; } Object.assign(this.findId(id), update); } remove(id) { var index = this.findIndex(function (dom) { return dom.id === id; }); if (index < 0) { var error = new Error("no "+this._itemName+" with ID '"+id+"'"); error.statusCode = 404; throw error; } this.splice(index, 1); } } class ModuleList extends IdList { constructor(rawList) { super(rawList); this._itemName = 'module'; } 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 IdList { constructor(rawList) { super(rawList); this._itemName = 'domain'; this.forEach(function (dom) { dom.modules = { http: new ModuleList((dom.modules || {}).http), tls: new ModuleList((dom.modules || {}).tls), }; }); } 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 modLists = { http: new ModuleList(), tls: new ModuleList() }; if (dom.modules && Array.isArray(dom.modules.http)) { dom.modules.http.forEach(modLists.http.add, modLists.http); } if (dom.modules && Array.isArray(dom.modules.tls)) { dom.modules.tls.forEach(modLists.tls.add, modLists.tls); } dom.id = require('crypto').randomBytes(4).toString('hex'); dom.modules = modLists; this.push(dom); } } class ConfigChanger { constructor(start) { Object.assign(this, JSON.parse(JSON.stringify(start))); delete this.device; this.domains = new DomainList(this.domains); this.http.modules = new ModuleList(this.http.modules); this.tls.modules = new ModuleList(this.tls.modules); this.tcp.modules = new ModuleList(this.tcp.modules); this.udp.modules = new ModuleList(this.udp.modules); } update(update) { var self = this; if (update.domains) { update.domains.forEach(self.domains.add, self.domains); } [ 'http', 'tls', 'tcp', 'udp' ].forEach(function (name) { if (update[name] && update[name].modules) { update[name].modules.forEach(self[name].modules.add, self[name].modules); delete update[name].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;