348 lines
8.8 KiB
JavaScript
348 lines
8.8 KiB
JavaScript
'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' }
|
|
, challenge_type: { type: 'string' }
|
|
}
|
|
}
|
|
};
|
|
// forward is basically the same as proxy, but specifies the relevant incoming port(s).
|
|
// only allows for the raw transport layers (TCP/UDP)
|
|
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: [ 'forward' ].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 }) }
|
|
|
|
// these are forbidden deprecated settings.
|
|
, acme: { not: {} }
|
|
, 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()
|
|
};
|
|
// We add these after instead of in the constructor to run the validation and manipulation
|
|
// in the ModList add function since these are all new modules.
|
|
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;
|