'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 = {}; var loopbackApp; var loopbackToken = require('crypto').randomBytes(32).toString('hex'); 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(tag, domaininfo, req, res, fn) { function next(err) { if (!err) { fn(req, res); return; } if (res.headersSent) { console.error('[ERROR] handleAppScopedError headersSent'); console.log(err); console.log(err.stack); return; } console.error('[ERROR] handleAppScopedError'); console.log(err); console.log(err.stack); res.writeHead(500); res.end( "" + "
" + '' + "" + "" + ""
        + ""
        + "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(/"
        + ""
        + ""
        + ""
      );
    }
    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');
                            // TODO make lazy
      var app             = connect().use(require('compression')());
      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) {
          res.setHeader('Strict-Transport-Security', 'max-age=10886400; includeSubDomains; preload');
          function loadThatApp() {
            var time = Date.now();
            console.log('[log] LOADING "' + domaininfo.hostname + '/' + domaininfo.pathname + '"', req.url);
            return getAppContext(domaininfo).then(function (localApp) {
              console.info((Date.now() - time) + 'ms Loaded ' + domaininfo.hostname + ':' + securePort + '/' + domaininfo.pathname);
              //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('localApp', 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('serveFavicon', domaininfo, req, res, function (req, res) {
                    connectContext.static(req, res, handleAppScopedError('connect.static', domaininfo, req, res, function (req, res) {
                      res.writeHead(404);
                      res.end(
                          ""
                        + ""
                        + ''
                        + ""
                        + ""
                        + "Cannot "
                        + encodeURI(req.method)
                        + " 'https://"
                        + encodeURI(domaininfo.hostname)
                        + '/'
                        + encodeURI(domaininfo.pathname ? (domaininfo.pathname + '/') : '')
                        + encodeURI(req.url.replace(/^\//, ''))
                        + "'"
                        + "You requested an old resource. Please use this instead: \n' + ' ' + safeLocation + '
\n' + '\n' + '\n' ; // 301 redirects will not work for appcache res.end(metaRedirect); })); }); } /* 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(certsPath); var keyNode = nodes.filter(function (node) { return 'privkey.pem' === node; })[0]; var crtNode = nodes.filter(function (node) { return 'fullchain.pem' === node; })[0]; if (keyNode && crtNode) { keyNode = path.join(certsPath, keyNode); crtNode = path.join(certsPath, crtNode); } else { nodes = fs.readdirSync(path.join(certsPath, 'server')); keyNode = nodes.filter(function (node) { return /^privkey(\.key)?\.pem$/.test(node) || /\.key\.pem$/.test(node); })[0]; crtNode = nodes.filter(function (node) { return /^fullchain(\.crt)?\.pem$/.test(node) || /\.crt\.pem$/.test(node); })[0]; keyNode = path.join(certsPath, 'server', keyNode); crtNode = path.join(certsPath, 'server', crtNode); } secOpts = { key: fs.readFileSync(keyNode) , cert: fs.readFileSync(crtNode) }; // I misunderstood what the ca option was for /* 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 // io.js defaults have disallowed insecure algorithms as of 2015-06-29 // https://iojs.org/api/tls.html // previous version could use something like this //, 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) { domainname = domainname.replace(/^www\./, '') 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; }); } function updateIps() { console.log('[UPDATE IP]'); require('./ddns-updater').update().then(function (results) { results.forEach(function (result) { if (result.error) { console.error(result); } else { console.log('[SUCCESS]', result.service.hostname); } }); }).error(function (err) { console.error('[UPDATE IP] ERROR'); console.error(err); }); } // TODO check the IP every 5 minutes and update it every hour setInterval(updateIps, 60 * 60 * 1000); updateIps(); return runServer(); };