498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
module.exports.create = function (securePort, certsPath, vhostsdir) {
|
|
var PromiseA = require('bluebird').Promise;
|
|
var serveStatic;
|
|
var https = require('https');
|
|
var fs = require('fs');
|
|
var path = require('path');
|
|
var dummyCerts;
|
|
var serveFavicon;
|
|
var secureContexts = {};
|
|
|
|
function loadDummyCerts() {
|
|
if (dummyCerts) {
|
|
return dummyCerts;
|
|
}
|
|
|
|
dummyCerts = {
|
|
key: fs.readFileSync(path.join(certsPath, 'server', 'dummy-server.key.pem'))
|
|
, cert: fs.readFileSync(path.join(certsPath, 'server', 'dummy-server.crt.pem'))
|
|
, ca: fs.readdirSync(path.join(certsPath, 'ca')).filter(function (node) {
|
|
return /crt\.pem$/.test(node);
|
|
}).map(function (node) {
|
|
console.log('[log dummy ca]', node);
|
|
return fs.readFileSync(path.join(certsPath, 'ca', node));
|
|
})
|
|
};
|
|
|
|
return dummyCerts;
|
|
}
|
|
|
|
function handleAppScopedError(domaininfo, req, res, fn) {
|
|
function next(err) {
|
|
if (!err) {
|
|
fn(req, res);
|
|
return;
|
|
}
|
|
|
|
console.error(err);
|
|
res.writeHead(500);
|
|
res.end(
|
|
"<html>"
|
|
+ "<head>"
|
|
+ '<link rel="icon" href="favicon.ico" />'
|
|
+ "</head>"
|
|
+ "<body>"
|
|
+ "<pre>"
|
|
+ "<code>"
|
|
+ "Method: " + encodeURI(req.method)
|
|
+ '\n'
|
|
+ "Hostname: " + encodeURI(domaininfo.hostname)
|
|
+ '\n'
|
|
+ "App: " + encodeURI(domaininfo.pathname ? (domaininfo.pathname + '/') : '')
|
|
+ '\n'
|
|
+ "Route: " + encodeURI(req.url)//.replace(/^\//, '')
|
|
+ '\n'
|
|
// TODO better sanatization
|
|
+ 'Error: ' + (err.message || err.toString()).replace(/</g, '<')
|
|
+ "</code>"
|
|
+ "</pre>"
|
|
+ "</body>"
|
|
+ "</html>"
|
|
);
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
function createSecureContext(certs) {
|
|
// workaround for v0.12 / v1.2 backwards compat
|
|
try {
|
|
return require('tls').createSecureContext(certs);
|
|
} catch(e) {
|
|
return require('crypto').createCredentials(certs).context;
|
|
}
|
|
}
|
|
|
|
function createPromiseApps(secureServer) {
|
|
return new PromiseA(function (resolve) {
|
|
var forEachAsync = require('foreachasync').forEachAsync.create(PromiseA);
|
|
var connect = require('connect');
|
|
var app = connect();
|
|
var vhost = require('vhost');
|
|
|
|
var domainMergeMap = {};
|
|
var domainMerged = [];
|
|
|
|
function getDomainInfo(apppath) {
|
|
var parts = apppath.split(/[#%]+/);
|
|
var hostname = parts.shift();
|
|
var pathname = parts.join('/').replace(/\/+/g, '/').replace(/^\//, '');
|
|
|
|
return {
|
|
hostname: hostname
|
|
, pathname: pathname
|
|
, dirpathname: parts.join('#')
|
|
, dirname: apppath
|
|
, isRoot: apppath === hostname
|
|
};
|
|
}
|
|
|
|
function loadDomainMounts(domaininfo) {
|
|
var connectContext = {};
|
|
var appContext;
|
|
|
|
// should order and group by longest domain, then longest path
|
|
if (!domainMergeMap[domaininfo.hostname]) {
|
|
// create an connect / express app exclusive to this domain
|
|
// TODO express??
|
|
domainMergeMap[domaininfo.hostname] = {
|
|
hostname: domaininfo.hostname
|
|
, apps: connect()
|
|
, mountsMap: {}
|
|
};
|
|
domainMerged.push(domainMergeMap[domaininfo.hostname]);
|
|
}
|
|
|
|
if (domainMergeMap[domaininfo.hostname].mountsMap['/' + domaininfo.dirpathname]) {
|
|
return;
|
|
}
|
|
|
|
console.log('[log] [once] Preparing mount for', domaininfo.hostname + '/' + domaininfo.dirpathname);
|
|
domainMergeMap[domaininfo.hostname].mountsMap['/' + domaininfo.dirpathname] = function (req, res, next) {
|
|
function nextify() {
|
|
if (appContext) {
|
|
appContext(req, res, next);
|
|
return;
|
|
}
|
|
|
|
console.log('[log] LOADING "' + domaininfo.hostname + '/' + domaininfo.pathname + '"', req.url);
|
|
getAppContext(domaininfo).then(function (localApp) {
|
|
//if (localApp.arity >= 2) { /* connect uses .apply(null, arguments)*/ }
|
|
if ('function' !== typeof localApp) {
|
|
localApp = getDummyAppContext(null, "[ERROR] no connect-style export from " + domaininfo.dirname);
|
|
}
|
|
|
|
// Note: pathname should NEVER have a leading '/' on its own
|
|
// we always add it explicitly
|
|
function localAppWrapped(req, res) {
|
|
console.log('[debug]', domaininfo.hostname + '/' + domaininfo.pathname, req.url);
|
|
localApp(req, res, handleAppScopedError(domaininfo, req, res, function (req, res) {
|
|
if (!serveFavicon) {
|
|
serveFavicon = require('serve-favicon')(path.join(__dirname, '..', 'public', 'favicon.ico'));
|
|
}
|
|
|
|
// TODO redirect GET /favicon.ico to GET (req.headers.referer||'') + /favicon.ico
|
|
// TODO other common root things - robots.txt, app-icon, etc
|
|
serveFavicon(req, res, handleAppScopedError(domaininfo, req, res, function (req, res) {
|
|
res.writeHead(404);
|
|
res.end(
|
|
"<html>"
|
|
+ "<head>"
|
|
+ '<link rel="icon" href="favicon.ico" />'
|
|
+ "</head>"
|
|
+ "<body>"
|
|
+ "Cannot "
|
|
+ encodeURI(req.method)
|
|
+ " 'https://"
|
|
+ encodeURI(domaininfo.hostname)
|
|
+ '/'
|
|
+ encodeURI(domaininfo.pathname ? (domaininfo.pathname + '/') : '')
|
|
+ encodeURI(req.url.replace(/^\//, ''))
|
|
+ "'"
|
|
+ "<br/>"
|
|
+ "<br/>"
|
|
+ "Domain: " + encodeURI(domaininfo.hostname)
|
|
+ "<br/>"
|
|
+ "App: " + encodeURI(domaininfo.pathname)
|
|
+ "<br/>"
|
|
+ "Route : " + encodeURI(req.url)
|
|
+ "</body>"
|
|
+ "</html>"
|
|
);
|
|
/*
|
|
res.end('{ "error": { "messages": "Route matched '
|
|
+ domaininfo.hostname + '/' + domaininfo.pathname
|
|
+ ', but was not handled. Forcing hard stop to prevent fallthru." } }');
|
|
*/
|
|
}));
|
|
}));
|
|
}
|
|
try {
|
|
domainMergeMap[domaininfo.hostname].apps.use('/' + domaininfo.pathname, localAppWrapped);
|
|
console.info('Loaded ' + domaininfo.hostname + ':' + securePort + '/' + domaininfo.pathname);
|
|
appContext = localAppWrapped;
|
|
appContext(req, res, next);
|
|
} catch(e) {
|
|
console.error('[ERROR] '
|
|
+ domaininfo.hostname + ':' + securePort
|
|
+ '/' + domaininfo.pathname
|
|
);
|
|
console.error(e);
|
|
// TODO this may not work in web apps (due to 500), probably okay
|
|
res.writeHead(500);
|
|
res.end('{ "error": { "message": "[ERROR] could not load '
|
|
+ encodeURI(domaininfo.hostname) + ':' + securePort + '/' + encodeURI(domaininfo.pathname)
|
|
+ 'or default error app." } }');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!serveStatic) {
|
|
serveStatic = require('serve-static');
|
|
}
|
|
|
|
if (!connectContext.static) {
|
|
console.log('[static]', path.join(vhostsdir, domaininfo.dirname, 'public'));
|
|
connectContext.static = serveStatic(path.join(vhostsdir, domaininfo.dirname, 'public'));
|
|
}
|
|
|
|
if (/^\/api\//.test(req.url)) {
|
|
nextify();
|
|
return;
|
|
}
|
|
|
|
connectContext.static(req, res, nextify);
|
|
};
|
|
domainMergeMap[domaininfo.hostname].apps.use(
|
|
'/' + domaininfo.pathname
|
|
, domainMergeMap[domaininfo.hostname].mountsMap['/' + domaininfo.dirpathname]
|
|
);
|
|
|
|
return PromiseA.resolve();
|
|
}
|
|
|
|
function readNewVhosts() {
|
|
return fs.readdirSync(vhostsdir).filter(function (node) {
|
|
// not a hidden or private file
|
|
return '.' !== node[0] && '_' !== node[0];
|
|
}).map(getDomainInfo).sort(function (a, b) {
|
|
var hlen = b.hostname.length - a.hostname.length;
|
|
var plen = b.pathname.length - a.pathname.length;
|
|
|
|
// A directory could be named example.com, example.com# example.com##
|
|
// to indicate order of preference (for API addons, for example)
|
|
var dlen = b.dirname.length - a.dirname.length;
|
|
if (!hlen) {
|
|
if (!plen) {
|
|
return dlen;
|
|
}
|
|
return plen;
|
|
}
|
|
return plen;
|
|
});
|
|
}
|
|
|
|
function getDummyAppContext(err, msg) {
|
|
console.error('[ERROR] getDummyAppContext');
|
|
console.error(err);
|
|
console.error(msg);
|
|
return function (req, res) {
|
|
res.writeHead(500);
|
|
res.end('{ "error": { "message": "' + msg + '" } }');
|
|
}
|
|
}
|
|
|
|
function getAppContext(domaininfo) {
|
|
var localApp;
|
|
|
|
try {
|
|
// TODO live reload required modules
|
|
localApp = require(path.join(vhostsdir, domaininfo.dirname, 'app.js'));
|
|
if (localApp.create) {
|
|
// TODO read local config.yml and pass it in
|
|
// TODO pass in websocket
|
|
localApp = localApp.create(secureServer, {
|
|
dummyCerts: dummyCerts
|
|
, hostname: domaininfo.hostname
|
|
, port: securePort
|
|
, url: domaininfo.pathname
|
|
});
|
|
|
|
if (!localApp) {
|
|
localApp = getDummyAppContext(null, "[ERROR] no app was returned by app.js for " + domaininfo.dirname);
|
|
}
|
|
}
|
|
|
|
if (!localApp.then) {
|
|
localApp = PromiseA.resolve(localApp);
|
|
} else {
|
|
return localApp.catch(function (e) {
|
|
return getDummyAppContext(e, "[ERROR] initialization failed during create() for " + domaininfo.dirname);
|
|
});
|
|
}
|
|
} catch(e) {
|
|
localApp = getDummyAppContext(e, "[ERROR] could not load app.js for " + domaininfo.dirname);
|
|
localApp = PromiseA.resolve(localApp);
|
|
}
|
|
|
|
return localApp;
|
|
}
|
|
|
|
function loadDomainVhosts() {
|
|
domainMerged.forEach(function (domainApp) {
|
|
if (domainApp._loaded) {
|
|
return;
|
|
}
|
|
|
|
console.log('[log] [once] Loading all mounts for ' + domainApp.hostname);
|
|
domainApp._loaded = true;
|
|
app.use(vhost(domainApp.hostname, domainApp.apps));
|
|
app.use(vhost('www.' + domainApp.hostname, domainApp.apps));
|
|
});
|
|
}
|
|
|
|
function hotloadApp(req, res, next) {
|
|
var forEachAsync = require('foreachasync').forEachAsync.create(PromiseA);
|
|
var vhost = (req.headers.host || '').split(':')[0];
|
|
|
|
// the matching domain didn't catch it
|
|
console.log('[log] vhost:', vhost);
|
|
if (domainMergeMap[vhost]) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
return forEachAsync(readNewVhosts(), loadDomainMounts).then(loadDomainVhosts).then(function () {
|
|
// no matching domain was added
|
|
if (!domainMergeMap[vhost]) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
return forEachAsync(domainMergeMap[vhost].apps, function (fn) {
|
|
return new PromiseA(function (resolve, reject) {
|
|
function next(err) {
|
|
if (err) {
|
|
reject(err);
|
|
}
|
|
resolve();
|
|
}
|
|
|
|
try {
|
|
fn(req, res, next);
|
|
} catch(e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
}).catch(function (e) {
|
|
next(e);
|
|
});
|
|
});
|
|
|
|
/*
|
|
// TODO loop through mounts and see if any fit
|
|
domainMergeMap[vhost].mountsMap['/' + domaininfo.dirpathname]
|
|
if (!domainMergeMap[domaininfo.hostname]) {
|
|
// TODO reread directories
|
|
}
|
|
*/
|
|
}
|
|
|
|
|
|
// TODO pre-cache these once the server has started?
|
|
// return forEachAsync(rootDomains, loadCerts);
|
|
// TODO load these even more lazily
|
|
return forEachAsync(readNewVhosts(), loadDomainMounts).then(loadDomainVhosts).then(function () {
|
|
console.log('[log] TODO fix and use hotload');
|
|
//app.use(hotloadApp);
|
|
resolve(app);
|
|
return;
|
|
});
|
|
});
|
|
}
|
|
|
|
function loadCerts(domainname) {
|
|
// TODO make async
|
|
// WARNING: This must be SYNC until we KNOW we're not going to be running on v0.10
|
|
// Also, once we load Let's Encrypt, it's lights out for v0.10
|
|
|
|
var certsPath = path.join(vhostsdir, domainname, 'certs');
|
|
var secOpts;
|
|
|
|
try {
|
|
var nodes = fs.readdirSync(path.join(certsPath, 'server'));
|
|
var keyNode = nodes.filter(function (node) { return /\.key\.pem$/.test(node); })[0];
|
|
var crtNode = nodes.filter(function (node) { return /\.crt\.pem$/.test(node); })[0];
|
|
secOpts = {
|
|
key: fs.readFileSync(path.join(certsPath, 'server', keyNode))
|
|
, cert: fs.readFileSync(path.join(certsPath, 'server', crtNode))
|
|
};
|
|
|
|
if (fs.existsSync(path.join(certsPath, 'ca'))) {
|
|
secOpts.ca = fs.readdirSync(path.join(certsPath, 'ca')).filter(function (node) {
|
|
console.log('[log ca]', node);
|
|
return /crt\.pem$/.test(node);
|
|
}).map(function (node) {
|
|
return fs.readFileSync(path.join(certsPath, 'ca', node));
|
|
});
|
|
}
|
|
} catch(err) {
|
|
// TODO Let's Encrypt / ACME HTTPS
|
|
console.error("[ERROR] Couldn't READ HTTPS certs from '" + certsPath + "':");
|
|
// this will be a simple file-read error
|
|
console.error(err.message);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
secureContexts[domainname] = createSecureContext(secOpts);
|
|
} catch(err) {
|
|
console.error("[ERROR] Certificates in '" + certsPath + "' could not be used:");
|
|
console.error(err);
|
|
return null;
|
|
}
|
|
|
|
if (!secureContexts[domainname]) {
|
|
console.error("[ERROR] Sanity check fail, no cert for '" + domainname + "'");
|
|
return null;
|
|
}
|
|
|
|
return secureContexts[domainname];
|
|
}
|
|
|
|
function createSecureServer() {
|
|
var localDummyCerts = loadDummyCerts();
|
|
var secureOpts = {
|
|
// fallback / default dummy certs
|
|
key: localDummyCerts.key
|
|
, cert: localDummyCerts.cert
|
|
, ca: localDummyCerts.ca
|
|
// changes from default: disallow RC4
|
|
, ciphers: "ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:AES128-GCM-SHA256:!RC4:HIGH:!MD5:!aNULL"
|
|
};
|
|
|
|
function addSniWorkaroundCallback() {
|
|
//SNICallback is passed the domain name, see NodeJS docs on TLS
|
|
secureOpts.SNICallback = function (domainname, cb) {
|
|
if (/(^|\.)proxyable\./.test(domainname)) {
|
|
// device-id-12345678.proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|
// proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|
// TODO myapp.mydomain.com.proxyable.com => myapp.mydomain.com
|
|
domainname = domainname.replace(/.*\.?proxyable\./, '');
|
|
}
|
|
|
|
if (!secureContexts.dummy) {
|
|
console.log('[log] Loading dummy certs');
|
|
secureContexts.dummy = createSecureContext(localDummyCerts);
|
|
}
|
|
|
|
if (!secureContexts[domainname]) {
|
|
console.log('[log] Loading certs for', domainname);
|
|
// TODO keep trying to find the cert in case it's uploaded late?
|
|
secureContexts[domainname] = loadCerts(domainname) || secureContexts.dummy;
|
|
}
|
|
|
|
// workaround for v0.12 / v1.2 backwards compat bug
|
|
if ('function' === typeof cb) {
|
|
cb(null, secureContexts[domainname] || secureContexts.dummy);
|
|
} else {
|
|
return secureContexts[domainname] || secureContexts.dummy;
|
|
}
|
|
};
|
|
}
|
|
|
|
addSniWorkaroundCallback();
|
|
return https.createServer(secureOpts);
|
|
}
|
|
|
|
function runServer() {
|
|
return new PromiseA(function (resolve) {
|
|
var secureServer = createSecureServer();
|
|
var promiseApps;
|
|
|
|
function loadPromise() {
|
|
if (!promiseApps) {
|
|
promiseApps = createPromiseApps(secureServer);
|
|
}
|
|
return promiseApps;
|
|
}
|
|
|
|
secureServer.listen(securePort, function () {
|
|
resolve(secureServer);
|
|
console.log("Listening on https://localhost:" + secureServer.address().port, '\n');
|
|
loadPromise();
|
|
});
|
|
|
|
// Get up and listening as absolutely quickly as possible
|
|
secureServer.on('request', function (req, res) {
|
|
if (/(^|\.)proxyable\./.test(req.headers.host)) {
|
|
// device-id-12345678.proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|
// proxyable.myapp.mydomain.com => myapp.mydomain.com
|
|
// TODO myapp.mydomain.com.proxyable.com => myapp.mydomain.com
|
|
req.headers.host = req.headers.host.replace(/.*\.?proxyable\./, '');
|
|
}
|
|
|
|
loadPromise().then(function (app) {
|
|
app(req, res);
|
|
});
|
|
});
|
|
|
|
return secureServer;
|
|
});
|
|
}
|
|
|
|
return runServer();
|
|
};
|