Files
cloudron-box/src/certificates.js

327 lines
13 KiB
JavaScript
Raw Normal View History

2015-12-10 13:31:47 -08:00
/* jslint node:true */
'use strict';
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'),
cloudron = require('./cloudron.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'),
2015-12-11 21:49:24 -08:00
debug = require('debug')('box:src/certificates'),
2015-12-11 13:52:21 -08:00
fs = require('fs'),
nginx = require('./nginx.js'),
2015-12-11 13:52:21 -08:00
path = require('path'),
2015-12-10 13:31:47 -08:00
paths = require('./paths.js'),
safe = require('safetydance'),
2015-12-11 22:25:22 -08:00
settings = require('./settings.js'),
sysinfo = require('./sysinfo.js'),
2016-03-08 09:52:13 -08:00
tld = require('tldjs'),
user = require('./user.js'),
util = require('util'),
waitForDns = require('./waitfordns.js'),
x509 = require('x509');
2015-12-10 13:31:47 -08:00
exports = module.exports = {
2015-12-11 14:02:58 -08:00
installAdminCertificate: installAdminCertificate,
autoRenew: autoRenew,
2015-12-11 14:13:24 -08:00
setFallbackCertificate: setFallbackCertificate,
2015-12-11 13:52:21 -08:00
setAdminCertificate: setAdminCertificate,
CertificatesError: CertificatesError,
validateCertificate: validateCertificate,
ensureCertificate: ensureCertificate,
2016-03-19 12:11:28 -07:00
fallbackExpiredCertificates: fallbackExpiredCertificates
2015-12-10 13:31:47 -08:00
};
2015-12-14 12:28:00 -08:00
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
2015-12-11 13:43:33 -08:00
function CertificatesError(reason, errorOrMessage) {
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(CertificatesError, Error);
CertificatesError.INTERNAL_ERROR = 'Internal Error';
CertificatesError.INVALID_CERT = 'Invalid certificate';
2015-12-14 12:28:00 -08:00
function getApi(callback) {
settings.getTlsConfig(function (error, tlsConfig) {
if (error) return callback(error);
var api = tlsConfig.provider === 'caas' ? caas : acme;
2015-12-17 13:17:46 -08:00
var options = { };
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 ? 'admin@cloudron.io' : 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
});
}
2015-12-11 14:02:58 -08:00
function installAdminCertificate(callback) {
if (cloudron.isConfiguredSync()) return callback();
2015-12-11 22:25:22 -08:00
settings.getTlsConfig(function (error, tlsConfig) {
if (error) return callback(error);
if (tlsConfig.provider === 'caas') return callback();
2015-12-10 13:31:47 -08:00
2016-01-05 12:23:07 +01:00
sysinfo.getIp(function (error, ip) {
if (error) return callback(error);
2016-03-08 09:52:13 -08:00
var zoneName = tld.getDomain(config.fqdn());
waitForDns(config.adminFqdn(), ip, zoneName, function (error) {
2016-01-05 12:23:07 +01:00
if (error) return callback(error); // this cannot happen because we retry forever
2015-12-10 13:31:47 -08:00
2016-01-05 12:23:07 +01:00
ensureCertificate(config.adminFqdn(), function (error, certFilePath, keyFilePath) {
if (error) { // currently, this can never happen
debug('Error obtaining certificate. Proceed anyway', error);
return callback();
}
nginx.configureAdmin(certFilePath, keyFilePath, callback);
});
2015-12-11 22:25:22 -08:00
});
});
});
2015-12-10 13:31:47 -08:00
}
2016-03-18 22:59:51 -07:00
function isExpiringSync(hours, certFilePath) {
assert.strictEqual(typeof hours, 'number');
assert.strictEqual(typeof certFilePath, 'string');
2016-03-18 22:59:51 -07:00
var result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-checkend', new String(60 * 60 * hours), '-in', certFilePath ]);
debug('isExpiringSync: %s %s %s', certFilePath, result.stdout.toString('utf8'), result.status);
2016-03-14 22:50:06 -07:00
return result.status === 1; // 1 - expired 0 - not expired
}
2015-12-14 12:40:39 -08:00
function autoRenew(callback) {
debug('autoRenew: Checking certificates for renewal');
callback = callback || NOOP_CALLBACK;
2016-03-17 12:20:02 -07:00
apps.getAll(function (error, allApps) {
if (error) return callback(error);
2015-12-14 12:40:39 -08:00
allApps.push({ location: 'my', id: 'admin', accessRestriction: null }); // inject fake webadmin app
2016-03-17 12:20:02 -07:00
var expiringApps = [ ];
for (var i = 0; i < allApps.length; i++) {
var appDomain = config.appFqdn(allApps[i].location);
var certFile = path.join(paths.APP_CERTS_DIR, appDomain + '.cert');
if (!safe.fs.existsSync(certFile)) {
debug('autoRenew: no existing certificate for %s. skipping', appDomain);
continue;
}
2015-12-14 12:40:39 -08:00
2016-03-18 22:59:51 -07:00
if (!isExpiringSync(24 * 30, certFile)) {
2016-03-17 12:20:02 -07:00
debug('autoRenew: %s does not need renewal', appDomain);
continue;
}
2015-12-14 12:40:39 -08:00
2016-03-17 12:20:02 -07:00
expiringApps.push(allApps[i]);
}
debug('autoRenew: %j needs to be renewed', expiringApps.map(function (a) { return config.appFqdn(a.location); }));
2015-12-14 12:40:39 -08:00
2016-03-17 12:20:02 -07:00
getApi(function (error, api, apiOptions) {
if (error) return callback(error);
async.eachSeries(expiringApps, function iterator(app, iteratorCallback) {
var domain = config.appFqdn(app.location);
debug('autoRenew: renewing cert for %s with options %j', domain, apiOptions);
2015-12-14 12:40:39 -08:00
2016-03-17 12:20:02 -07:00
api.getCertificate(domain, apiOptions, function (error) {
if (error) {
debug('autoRenew: could not renew cert for %s because %s. using fallback certs', domain, error);
} else {
2016-03-17 12:20:02 -07:00
debug('autoRenew: certificate for %s renewed', domain);
}
2015-12-19 13:47:48 -08:00
iteratorCallback(); // move to next app
});
});
});
});
}
2015-12-14 12:40:39 -08:00
// switch certs to fallback to keep nginx happy
2016-03-19 12:14:23 -07:00
function fallbackExpiredCertificates(callback) {
callback = callback || NOOP_CALLBACK;
2016-03-19 12:14:23 -07:00
debug('fallbackExpiredCertificates: Checking for expired certs');
2016-03-17 12:20:02 -07:00
apps.getAll(function (error, allApps) {
if (error) return callback(error);
allApps.push({ location: 'my', id: 'admin', accessRestriction: null }); // inject fake webadmin app
var expiringApps = [ ];
for (var i = 0; i < allApps.length; i++) {
var appDomain = config.appFqdn(allApps[i].location);
var certFile = path.join(paths.APP_CERTS_DIR, appDomain + '.cert');
if (!safe.fs.existsSync(certFile)) {
2016-03-19 12:14:23 -07:00
debug('fallbackExpiredCertificates: no existing certificate for %s. skipping', appDomain);
continue;
}
if (!isExpiringSync(1, certFile)) { // expiring in the next hour
2016-03-19 12:31:48 -07:00
debug('fallbackExpiredCertificates: %s does not need to be switched', appDomain);
continue;
}
expiringApps.push(allApps[i]);
}
2016-03-19 12:14:23 -07:00
debug('fallbackExpiredCertificates: %j needs to be switched', expiringApps.map(function (a) { return config.appFqdn(a.location); }));
async.eachSeries(expiringApps, function iterator(app, iteratorCallback) {
var domain = config.appFqdn(app.location);
2016-03-19 12:14:23 -07:00
debug('fallbackExpiredCertificates: replacing cert for %s', domain);
nginx.configureApp(app, 'cert/host.cert', 'cert/host.key', function (ignoredError) {
2016-03-19 12:14:23 -07:00
if (ignoredError) debug('fallbackExpiredCertificates: error reconfiguring app', ignoredError);
iteratorCallback(); // move to next app
2015-12-14 12:40:39 -08:00
});
});
});
2015-12-10 13:31:47 -08:00
}
// 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(cert, key, fqdn) {
assert(cert === null || typeof cert === 'string');
assert(key === null || typeof key === 'string');
assert.strictEqual(typeof fqdn, 'string');
if (cert === null && key === null) return null;
if (!cert && key) return new Error('missing cert');
if (cert && !key) return new Error('missing key');
var content;
try {
content = x509.parseCert(cert);
} catch (e) {
return new Error('invalid cert: ' + e.message);
}
// check expiration
if (content.notAfter < new Date()) return new Error('cert expired');
function matchesDomain(domain) {
if (domain === fqdn) return true;
if (domain.indexOf('*') === 0 && domain.slice(2) === fqdn.slice(fqdn.indexOf('.') + 1)) return true;
return false;
}
// check domain
var domains = content.altNames.concat(content.subject.commonName);
if (!domains.some(matchesDomain)) return new Error(util.format('cert is not valid for this domain. Expecting %s in %j', fqdn, domains));
// 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 Error('key does not match the cert');
return null;
}
2015-12-11 13:52:21 -08:00
2015-12-11 14:13:24 -08:00
function setFallbackCertificate(cert, key, callback) {
2015-12-11 13:52:21 -08:00
assert.strictEqual(typeof cert, 'string');
assert.strictEqual(typeof key, 'string');
assert.strictEqual(typeof callback, 'function');
var error = validateCertificate(cert, key, '*.' + config.fqdn());
if (error) return callback(new CertificatesError(CertificatesError.INVALID_CERT, error.message));
// backup the cert
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, 'host.cert'), cert)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, 'host.key'), key)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
// copy over fallback cert
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert'), cert)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key'), key)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
nginx.reload(function (error) {
2015-12-11 13:52:21 -08:00
if (error) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, error));
return callback(null);
});
}
function setAdminCertificate(cert, key, callback) {
assert.strictEqual(typeof cert, 'string');
assert.strictEqual(typeof key, 'string');
assert.strictEqual(typeof callback, 'function');
var vhost = config.appFqdn(constants.ADMIN_LOCATION);
var certFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.cert');
var keyFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.key');
var error = validateCertificate(cert, key, vhost);
if (error) return callback(new CertificatesError(CertificatesError.INVALID_CERT, error.message));
// backup the cert
if (!safe.fs.writeFileSync(certFilePath, cert)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
if (!safe.fs.writeFileSync(keyFilePath, key)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
nginx.configureAdmin(certFilePath, keyFilePath, callback);
2015-12-11 13:52:21 -08:00
}
function ensureCertificate(domain, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof callback, 'function');
// check if user uploaded a specific cert. ideally, we should not mix user certs and automatic certs as we do here...
var userCertFilePath = path.join(paths.APP_CERTS_DIR, domain + '.cert');
var userKeyFilePath = path.join(paths.APP_CERTS_DIR, domain + '.key');
if (fs.existsSync(userCertFilePath) && fs.existsSync(userKeyFilePath)) {
debug('ensureCertificate: %s. certificate already exists at %s', domain, userKeyFilePath);
2016-03-19 12:06:13 -07:00
// if expiring in coming 5 days, try to renew it now
if (!isExpiringSync(24 * 5, userCertFilePath)) return callback(null, userCertFilePath, userKeyFilePath);
debug('ensureCertificate: %s cert require renewal', domain);
}
2015-12-17 13:17:46 -08:00
getApi(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', domain, apiOptions);
2015-12-17 13:17:46 -08:00
api.getCertificate(domain, apiOptions, function (error, certFilePath, keyFilePath) {
2015-12-15 00:23:57 -08:00
if (error) {
debug('ensureCertificate: could not get certificate. using fallback certs', error);
return callback(null, 'cert/host.cert', 'cert/host.key'); // use fallback certs
}
callback(null, certFilePath, keyFilePath);
});
});
}