Files
cloudron-box/src/reverseproxy.js

449 lines
19 KiB
JavaScript
Raw Normal View History

2015-12-10 13:31:47 -08:00
'use strict';
exports = module.exports = {
ReverseProxyError: ReverseProxyError,
setFallbackCertificate: setFallbackCertificate,
getFallbackCertificate: getFallbackCertificate,
2017-01-17 09:57:15 -08:00
validateCertificate: validateCertificate,
2017-01-17 09:57:15 -08:00
getCertificate: getCertificate,
2016-06-22 13:48:07 -05:00
2017-01-17 09:57:15 -08:00
renewAll: renewAll,
configureDefaultServer: configureDefaultServer,
configureAdmin: configureAdmin,
configureApp: configureApp,
unconfigureApp: unconfigureApp,
reload: reload,
removeAppConfigs: removeAppConfigs,
2016-06-22 13:48:07 -05:00
// exported for testing
_getApi: getApi
};
2015-12-10 13:31:47 -08:00
var acme = require('./cert/acme.js'),
2016-03-17 12:20:02 -07:00
apps = require('./apps.js'),
2015-12-10 13:31:47 -08:00
assert = require('assert'),
2015-12-14 12:28:00 -08:00
async = require('async'),
2015-12-13 19:06:19 -08:00
caas = require('./cert/caas.js'),
2015-12-10 13:31:47 -08:00
config = require('./config.js'),
2015-12-11 13:52:21 -08:00
constants = require('./constants.js'),
2017-04-23 21:53:59 -07:00
debug = require('debug')('box:certificates'),
ejs = require('ejs'),
2016-04-30 22:27:33 -07:00
eventlog = require('./eventlog.js'),
2016-12-05 17:01:23 +01:00
fallback = require('./cert/fallback.js'),
2015-12-11 13:52:21 -08:00
fs = require('fs'),
2016-03-19 20:40:03 -07:00
mailer = require('./mailer.js'),
2015-12-11 13:52:21 -08:00
path = require('path'),
2015-12-10 13:31:47 -08:00
paths = require('./paths.js'),
platform = require('./platform.js'),
safe = require('safetydance'),
2015-12-11 22:25:22 -08:00
settings = require('./settings.js'),
shell = require('./shell.js'),
user = require('./user.js'),
util = require('util');
2015-12-10 13:31:47 -08:00
var NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/../setup/start/nginx/appconfig.ejs', { encoding: 'utf8' }),
RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh'),
NOOP_CALLBACK = function (error) { if (error) debug(error); };
function ReverseProxyError(reason, errorOrMessage) {
2015-12-11 13:43:33 -08:00
assert.strictEqual(typeof reason, 'string');
assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined');
Error.call(this);
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.reason = reason;
if (typeof errorOrMessage === 'undefined') {
this.message = reason;
} else if (typeof errorOrMessage === 'string') {
this.message = errorOrMessage;
} else {
this.message = 'Internal error';
this.nestedError = errorOrMessage;
}
}
util.inherits(ReverseProxyError, Error);
ReverseProxyError.INTERNAL_ERROR = 'Internal Error';
ReverseProxyError.INVALID_CERT = 'Invalid certificate';
ReverseProxyError.NOT_FOUND = 'Not Found';
2015-12-11 13:43:33 -08:00
function getApi(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2015-12-14 12:28:00 -08:00
settings.getTlsConfig(function (error, tlsConfig) {
if (error) return callback(error);
2016-12-06 17:26:54 +01:00
if (tlsConfig.provider === 'fallback') return callback(null, fallback, {});
2016-12-05 17:01:23 +01:00
2016-06-22 13:48:07 -05:00
// use acme if we have altDomain or the tlsConfig is not caas
2018-01-11 00:05:35 -08:00
var api = (app.altDomain || tlsConfig.provider !== 'caas') ? acme : caas;
2015-12-14 12:28:00 -08:00
2015-12-17 13:17:46 -08:00
var options = { };
2016-06-22 13:48:07 -05:00
if (tlsConfig.provider === 'caas') {
options.prod = true; // with altDomain, we will choose acme setting based on this
2016-06-22 13:48:07 -05:00
} else { // acme
options.prod = tlsConfig.provider.match(/.*-prod/) !== null;
}
2016-01-13 12:15:27 -08:00
// registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
// we cannot use admin@fqdn because the user might not have set it up.
// we simply update the account with the latest email we have each time when getting letsencrypt certs
// https://github.com/ietf-wg-acme/acme/issues/30
user.getOwner(function (error, owner) {
options.email = error ? 'support@cloudron.io' : (owner.fallbackEmail || owner.email); // can error if not activated yet
2015-12-17 13:17:46 -08:00
callback(null, api, options);
});
2015-12-14 12:28:00 -08:00
});
}
function isExpiringSync(certFilePath, hours) {
assert.strictEqual(typeof certFilePath, 'string');
2016-03-18 22:59:51 -07:00
assert.strictEqual(typeof hours, 'number');
2016-03-19 12:50:31 -07:00
if (!fs.existsSync(certFilePath)) return 2; // not found
2016-03-19 13:22:38 -07:00
var result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-checkend', String(60 * 60 * hours), '-in', certFilePath ]);
2016-03-18 22:59:51 -07:00
2016-03-21 08:25:10 -07:00
debug('isExpiringSync: %s %s %s', certFilePath, result.stdout.toString('utf8').trim(), result.status);
2016-03-14 22:50:06 -07:00
return result.status === 1; // 1 - expired 0 - not expired
}
// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
// servers certificate appears first (and not the intermediate cert)
function validateCertificate(domain, cert, key) {
2018-01-24 14:28:35 -08:00
assert.strictEqual(typeof domain, 'string');
2018-01-26 19:31:06 -08:00
assert.strictEqual(typeof cert, 'string');
assert.strictEqual(typeof key, 'string');
2018-01-24 14:28:35 -08:00
function matchesDomain(candidate) {
if (typeof candidate !== 'string') return false;
if (candidate === domain) return true;
if (candidate.indexOf('*') === 0 && candidate.slice(2) === domain.slice(domain.indexOf('.') + 1)) return true;
return false;
}
2018-01-26 19:31:06 -08:00
// check for empty cert and key strings
if (!cert && key) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'missing cert');
if (cert && !key) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'missing key');
2018-01-24 14:28:35 -08:00
var result = safe.child_process.execSync('openssl x509 -noout -checkhost "' + domain + '"', { encoding: 'utf8', input: cert });
if (!result) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'Unable to get certificate subject.');
// if no match, check alt names
if (result.indexOf('does match certificate') === -1) {
// https://github.com/drwetter/testssl.sh/pull/383
2017-11-27 10:39:42 -08:00
var cmd = 'openssl x509 -noout -text | grep -A3 "Subject Alternative Name" | \
grep "DNS:" | \
2017-11-27 10:39:42 -08:00
sed -e "s/DNS://g" -e "s/ //g" -e "s/,/ /g" -e "s/othername:<unsupported>//g"';
result = safe.child_process.execSync(cmd, { encoding: 'utf8', input: cert });
var altNames = result ? [ ] : result.trim().split(' '); // might fail if cert has no SAN
debug('validateCertificate: detected altNames as %j', altNames);
// check altNames
if (!altNames.some(matchesDomain)) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, util.format('Certificate is not valid for this domain. Expecting %s in %j', domain, altNames));
}
// http://httpd.apache.org/docs/2.0/ssl/ssl_faq.html#verify
var certModulus = safe.child_process.execSync('openssl x509 -noout -modulus', { encoding: 'utf8', input: cert });
var keyModulus = safe.child_process.execSync('openssl rsa -noout -modulus', { encoding: 'utf8', input: key });
if (certModulus !== keyModulus) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'Key does not match the certificate.');
2017-11-27 10:39:42 -08:00
// check expiration
result = safe.child_process.execSync('openssl x509 -checkend 0', { encoding: 'utf8', input: cert });
if (!result) return new ReverseProxyError(ReverseProxyError.INVALID_CERT, 'Certificate has expired.');
return null;
}
2015-12-11 13:52:21 -08:00
function reload(callback) {
if (process.env.BOX_ENV === 'test') return callback();
shell.sudo('reload', [ RELOAD_NGINX_CMD ], callback);
}
2018-01-26 20:31:48 -08:00
// We configure nginx to always use the fallback cert from the runtime directory (NGINX_CERT_DIR)
// This is done because Caas wildcard certs should not be part of the backup
function setFallbackCertificate(domain, fallback, callback) {
2018-01-24 14:28:35 -08:00
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof fallback, 'object');
2015-12-11 13:52:21 -08:00
assert.strictEqual(typeof callback, 'function');
const certFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`);
const keyFilePath = path.join(paths.APP_CERTS_DIR, `${domain}.host.key`);
2018-01-26 22:27:32 -08:00
if (fallback) {
// backup the cert
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`), fallback.cert)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.key`), fallback.key)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
2018-01-26 22:27:32 -08:00
} else if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { // generate it
var certCommand = util.format('openssl req -x509 -newkey rsa:2048 -keyout %s -out %s -days 3650 -subj /CN=*.%s -nodes', keyFilePath, certFilePath, domain);
if (!safe.child_process.execSync(certCommand)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
}
2015-12-11 13:52:21 -08:00
// copy over fallback cert
var fallbackCertFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
var fallbackKeyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
if (!safe.child_process.execSync(`cp ${certFilePath} ${fallbackCertFilePath}`)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
if (!safe.child_process.execSync(`cp ${keyFilePath} ${fallbackKeyFilePath}`)) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, safe.error.message));
2015-12-11 13:52:21 -08:00
platform.handleCertChanged('*.' + domain);
reload(function (error) {
if (error) return callback(new ReverseProxyError(ReverseProxyError.INTERNAL_ERROR, error));
2015-12-11 13:52:21 -08:00
return callback(null);
});
}
2018-01-24 14:28:35 -08:00
function getFallbackCertificate(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
callback(null, path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`));
}
function getCertificate(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var vhost = app.altDomain || app.intrinsicFqdn;
var certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.cert`);
var keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, certFilePath, keyFilePath);
certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, certFilePath, keyFilePath);
// any user fallback cert is always copied over to nginx cert dir
callback(null, path.join(paths.NGINX_CERT_DIR, `${app.domain}.host.cert`), path.join(paths.NGINX_CERT_DIR, `${app.domain}.host.key`));
}
2018-01-30 15:16:34 -08:00
function ensureCertificate(app, auditSource, callback) {
2016-04-19 08:13:44 -07:00
assert.strictEqual(typeof app, 'object');
2018-01-30 15:16:34 -08:00
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
var vhost = app.altDomain || app.intrinsicFqdn;
2016-04-19 08:13:44 -07:00
var certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.cert`);
var keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
debug('ensureCertificate: %s. user certificate already exists at %s', vhost, keyFilePath);
return callback(null, { certFilePath, keyFilePath, reason: 'user' });
}
certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.cert`);
keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.key`);
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
debug('ensureCertificate: %s. certificate already exists at %s', vhost, keyFilePath);
2016-03-19 13:22:38 -07:00
if (!isExpiringSync(certFilePath, 24 * 30)) return callback(null, { certFilePath, keyFilePath, reason: 'existing-le' });
debug('ensureCertificate: %s cert require renewal', vhost);
} else {
debug('ensureCertificate: %s cert does not exist', vhost);
}
getApi(app, function (error, api, apiOptions) {
if (error) return callback(error);
2015-12-11 22:25:22 -08:00
debug('ensureCertificate: getting certificate for %s with options %j', vhost, apiOptions);
api.getCertificate(vhost, apiOptions, function (error, certFilePath, keyFilePath) {
2018-01-30 15:16:34 -08:00
if (error) {
debug('ensureCertificate: could not get certificate. using fallback certs', error);
mailer.certificateRenewalError(vhost, errorMessage);
}
var errorMessage = error ? error.message : '';
eventlog.add(eventlog.ACTION_CERTIFICATE_RENEWAL, auditSource, { domain: vhost, errorMessage: errorMessage });
// if no cert was returned use fallback. the fallback/caas provider will not provide any for example
if (!certFilePath || !keyFilePath) {
certFilePath = path.join(paths.NGINX_CERT_DIR, `${app.domain}.host.cert`);
keyFilePath = path.join(paths.NGINX_CERT_DIR, `${app.domain}.host.key`);
return callback(null, { certFilePath, keyFilePath, reason: 'fallback' });
2015-12-15 00:23:57 -08:00
}
callback(null, { certFilePath, keyFilePath, reason: 'new-le' });
});
});
}
function configureAdminInternal(bundle, configFileName, vhost, callback) {
assert.strictEqual(typeof bundle, 'object');
assert.strictEqual(typeof configFileName, 'string');
assert.strictEqual(typeof vhost, 'string');
assert.strictEqual(typeof callback, 'function');
var data = {
sourceDir: path.resolve(__dirname, '..'),
adminOrigin: config.adminOrigin(),
vhost: vhost, // if vhost is empty it will become the default_server
hasIPv6: config.hasIPv6(),
endpoint: 'admin',
certFilePath: bundle.certFilePath,
keyFilePath: bundle.keyFilePath,
xFrameOptions: 'SAMEORIGIN',
robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n')
};
var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, configFileName);
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) return callback(safe.error);
reload(callback);
}
2018-01-30 15:16:34 -08:00
function configureAdmin(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'function');
assert.strictEqual(typeof callback, 'function');
2018-01-30 15:16:34 -08:00
var adminApp = { domain: config.adminDomain(), intrinsicFqdn: config.adminFqdn() };
ensureCertificate(auditSource, adminApp, function (error, bundle) {
if (error) return callback(error);
configureAdminInternal(bundle, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn(), callback);
});
}
function configureAppInternal(app, bundle, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof bundle, 'object');
assert.strictEqual(typeof callback, 'function');
var sourceDir = path.resolve(__dirname, '..');
var endpoint = 'app';
var vhost = app.altDomain || app.intrinsicFqdn;
var data = {
sourceDir: sourceDir,
adminOrigin: config.adminOrigin(),
vhost: vhost,
hasIPv6: config.hasIPv6(),
port: app.httpPort,
endpoint: endpoint,
certFilePath: bundle.certFilePath,
keyFilePath: bundle.keyFilePath,
robotsTxtQuoted: app.robotsTxt ? JSON.stringify(app.robotsTxt) : null,
xFrameOptions: app.xFrameOptions || 'SAMEORIGIN' // once all apps have been updated/
};
var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf');
debug('writing config for "%s" to %s with options %j', vhost, nginxConfigFilename, data);
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) {
debug('Error creating nginx config for "%s" : %s', vhost, safe.error.message);
return callback(safe.error);
}
reload(callback);
}
2018-01-30 15:16:34 -08:00
function configureApp(app, auditSource, callback) {
assert.strictEqual(typeof app, 'object');
2018-01-30 15:16:34 -08:00
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
ensureCertificate(app, auditSource, function (error, bundle) {
if (error) return callback(error);
configureAppInternal(app, bundle, callback);
});
}
function unconfigureApp(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var vhost = app.altDomain || app.intrinsicFqdn;
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf');
if (!safe.fs.unlinkSync(nginxConfigFilename)) {
if (safe.error.code !== 'ENOENT') debug('Error removing nginx configuration of "%s": %s', vhost, safe.error.message);
return callback(null);
}
reload(callback);
}
function renewAll(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof callback, 'function');
debug('renewAll: Checking certificates for renewal');
apps.getAll(function (error, allApps) {
if (error) return callback(error);
allApps.push({ domain: config.adminDomain(), intrinsicFqdn: config.adminFqdn() }); // inject fake webadmin app
async.eachSeries(allApps, function (app, iteratorCallback) {
ensureCertificate(app, auditSource, function (error, bundle) {
if (bundle.reason !== 'new-le' && bundle.reason !== 'fallback') return iteratorCallback();
// reconfigure for the case where we got a renewed cert after fallback
var configureFunc = app.intrinsicFqdn === config.adminFqdn() ?
configureAdminInternal.bind(null, bundle, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn())
: configureAppInternal.bind(null, app, bundle);
configureFunc(function (ignoredError) {
if (ignoredError) debug('fallbackExpiredCertificates: error reconfiguring app', ignoredError);
platform.handleCertChanged(app.intrinsicFqdn);
iteratorCallback(); // move to next app
});
});
});
});
}
function removeAppConfigs() {
for (var appConfigFile of fs.readdirSync(paths.NGINX_APPCONFIG_DIR)) {
fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, appConfigFile));
}
}
function configureDefaultServer(callback) {
callback = callback || NOOP_CALLBACK;
var certFilePath = path.join(paths.NGINX_CERT_DIR, 'default.cert');
var keyFilePath = path.join(paths.NGINX_CERT_DIR, 'default.key');
if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
debug('configureDefaultServer: create new cert');
var cn = 'cloudron-' + (new Date()).toISOString(); // randomize date a bit to keep firefox happy
var certCommand = util.format('openssl req -x509 -newkey rsa:2048 -keyout %s -out %s -days 3650 -subj /CN=%s -nodes', keyFilePath, certFilePath, cn);
safe.child_process.execSync(certCommand);
}
configureAdminInternal({ certFilePath, keyFilePath }, 'default.conf', '', function (error) {
if (error) return callback(error);
debug('configureDefaultServer: done');
callback(null);
});
}