739 lines
33 KiB
JavaScript
739 lines
33 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
setFallbackCertificate,
|
|
getFallbackCertificate,
|
|
|
|
generateFallbackCertificateSync,
|
|
setAppCertificateSync,
|
|
|
|
validateCertificate,
|
|
|
|
getCertificate,
|
|
ensureCertificate,
|
|
|
|
renewCerts,
|
|
|
|
// the 'configure' ensure a certificate and generate nginx config
|
|
configureAdmin,
|
|
configureApp,
|
|
unconfigureApp,
|
|
|
|
// these only generate nginx config
|
|
writeDefaultConfig,
|
|
writeDashboardConfig,
|
|
writeAppConfig,
|
|
|
|
removeAppConfigs,
|
|
|
|
// exported for testing
|
|
_getAcmeApi: getAcmeApi
|
|
};
|
|
|
|
var acme2 = require('./cert/acme2.js'),
|
|
apps = require('./apps.js'),
|
|
assert = require('assert'),
|
|
async = require('async'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
crypto = require('crypto'),
|
|
debug = require('debug')('box:reverseproxy'),
|
|
domains = require('./domains.js'),
|
|
ejs = require('ejs'),
|
|
eventlog = require('./eventlog.js'),
|
|
fs = require('fs'),
|
|
mail = require('./mail.js'),
|
|
os = require('os'),
|
|
path = require('path'),
|
|
paths = require('./paths.js'),
|
|
rimraf = require('rimraf'),
|
|
safe = require('safetydance'),
|
|
settings = require('./settings.js'),
|
|
shell = require('./shell.js'),
|
|
sysinfo = require('./sysinfo.js'),
|
|
users = require('./users.js'),
|
|
util = require('util');
|
|
|
|
var NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' }),
|
|
RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh');
|
|
|
|
function nginxLocation(s) {
|
|
if (!s.startsWith('!')) return s;
|
|
|
|
let re = s.replace(/[\^$\\.*+?()[\]{}|]/g, '\\$&'); // https://github.com/es-shims/regexp.escape/blob/master/implementation.js
|
|
|
|
return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex
|
|
}
|
|
|
|
function getAcmeApi(domainObject, callback) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
const api = acme2;
|
|
|
|
let options = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' };
|
|
options.prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
|
|
options.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
|
|
options.wildcard = !!domainObject.tlsConfig.wildcard;
|
|
|
|
// 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
|
|
users.getOwner(function (error, owner) {
|
|
options.email = error ? 'webmaster@cloudron.io' : owner.email; // can error if not activated yet
|
|
|
|
callback(null, api, options);
|
|
});
|
|
}
|
|
|
|
function isExpiringSync(certFilePath, hours) {
|
|
assert.strictEqual(typeof certFilePath, 'string');
|
|
assert.strictEqual(typeof hours, 'number');
|
|
|
|
if (!fs.existsSync(certFilePath)) return 2; // not found
|
|
|
|
var result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-checkend', String(60 * 60 * hours), '-in', certFilePath ]);
|
|
|
|
if (!result) return 3; // some error
|
|
|
|
debug('isExpiringSync: %s %s %s', certFilePath, result.stdout.toString('utf8').trim(), result.status);
|
|
|
|
return result.status === 1; // 1 - expired 0 - not expired
|
|
}
|
|
|
|
// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
|
|
function providerMatchesSync(domainObject, certFilePath, apiOptions) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof certFilePath, 'string');
|
|
assert.strictEqual(typeof apiOptions, 'object');
|
|
|
|
if (!fs.existsSync(certFilePath)) return false; // not found
|
|
|
|
const subjectAndIssuer = safe.child_process.execSync(`/usr/bin/openssl x509 -noout -subject -issuer -in "${certFilePath}"`, { encoding: 'utf8' });
|
|
if (!subjectAndIssuer) return false; // something bad happenned
|
|
|
|
const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
|
|
const domain = subject.substr(subject.indexOf('=') + 1).trim(); // subject can be /CN=, CN=, CN = and other forms
|
|
const issuer = subjectAndIssuer.match(/^issuer=(.*)$/m)[1];
|
|
const isWildcardCert = domain.includes('*');
|
|
const isLetsEncryptProd = issuer.includes('Let\'s Encrypt');
|
|
|
|
const issuerMismatch = (apiOptions.prod && !isLetsEncryptProd) || (!apiOptions.prod && isLetsEncryptProd);
|
|
// bare domain is not part of wildcard SAN
|
|
const wildcardMismatch = (domain !== domainObject.domain) && (apiOptions.wildcard && !isWildcardCert) || (!apiOptions.wildcard && isWildcardCert);
|
|
|
|
const mismatch = issuerMismatch || wildcardMismatch;
|
|
|
|
debug(`providerMatchesSync: ${certFilePath} subject=${subject} domain=${domain} issuer=${issuer} `
|
|
+ `wildcard=${isWildcardCert}/${apiOptions.wildcard} prod=${isLetsEncryptProd}/${apiOptions.prod} `
|
|
+ `issuerMismatch=${issuerMismatch} wildcardMismatch=${wildcardMismatch} match=${!mismatch}`);
|
|
|
|
return !mismatch;
|
|
}
|
|
|
|
// 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(location, domainObject, certificate) {
|
|
assert.strictEqual(typeof location, 'string');
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert(certificate && typeof certificate, 'object');
|
|
|
|
const cert = certificate.cert, key = certificate.key;
|
|
|
|
// check for empty cert and key strings
|
|
if (!cert && key) return new BoxError(BoxError.BAD_FIELD, 'missing cert', { field: 'cert' });
|
|
if (cert && !key) return new BoxError(BoxError.BAD_FIELD, 'missing key', { field: 'key' });
|
|
|
|
// -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
|
|
const fqdn = domains.fqdn(location, domainObject);
|
|
|
|
let result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${fqdn}"`, { encoding: 'utf8', input: cert });
|
|
if (result === null) return new BoxError(BoxError.BAD_FIELD, 'Unable to get certificate subject:' + safe.error.message, { field: 'cert' });
|
|
|
|
if (result.indexOf('does match certificate') === -1) return new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`, { field: 'cert' });
|
|
|
|
// check if public key in the cert and private key matches. pkey below works for RSA and ECDSA keys
|
|
const pubKeyFromCert = safe.child_process.execSync('openssl x509 -noout -pubkey', { encoding: 'utf8', input: cert });
|
|
if (pubKeyFromCert === null) return new BoxError(BoxError.BAD_FIELD, `Unable to get public key from cert: ${safe.error.message}`, { field: 'cert' });
|
|
|
|
const pubKeyFromKey = safe.child_process.execSync('openssl pkey -pubout', { encoding: 'utf8', input: key });
|
|
if (pubKeyFromKey === null) return new BoxError(BoxError.BAD_FIELD, `Unable to get public key from private key: ${safe.error.message}`, { field: 'cert' });
|
|
|
|
if (pubKeyFromCert !== pubKeyFromKey) return new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.', { field: 'cert' });
|
|
|
|
// check expiration
|
|
result = safe.child_process.execSync('openssl x509 -checkend 0', { encoding: 'utf8', input: cert });
|
|
if (!result) return new BoxError(BoxError.BAD_FIELD, 'Certificate has expired.', { field: 'cert' });
|
|
|
|
return null;
|
|
}
|
|
|
|
function reload(callback) {
|
|
if (constants.TEST) return callback();
|
|
|
|
shell.sudo('reload', [ RELOAD_NGINX_CMD ], {}, function (error) {
|
|
if (error) return callback(new BoxError(BoxError.NGINX_ERROR, `Error reloading nginx: ${error.message}`));
|
|
|
|
callback();
|
|
});
|
|
}
|
|
|
|
function generateFallbackCertificateSync(domainObject) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
|
|
const domain = domainObject.domain;
|
|
const certFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.cert`);
|
|
const keyFilePath = path.join(os.tmpdir(), `${domain}-${crypto.randomBytes(4).readUInt32LE(0)}.key`);
|
|
|
|
let opensslConf = safe.fs.readFileSync('/etc/ssl/openssl.cnf', 'utf8');
|
|
// SAN must contain all the domains since CN check is based on implementation if SAN is found. -checkhost also checks only SAN if present!
|
|
let opensslConfWithSan;
|
|
let cn = domain;
|
|
|
|
debug(`generateFallbackCertificateSync: domain=${domainObject.domain} cn=${cn}`);
|
|
|
|
opensslConfWithSan = `${opensslConf}\n[SAN]\nsubjectAltName=DNS:${domain},DNS:*.${cn}\n`;
|
|
let configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf');
|
|
safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8');
|
|
// the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
|
|
let certCommand = util.format(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=*.${cn} -extensions SAN -config ${configFile} -nodes`);
|
|
if (!safe.child_process.execSync(certCommand)) return { error: new BoxError(BoxError.OPENSSL_ERROR, safe.error.message) };
|
|
safe.fs.unlinkSync(configFile);
|
|
|
|
const cert = safe.fs.readFileSync(certFilePath, 'utf8');
|
|
if (!cert) return { error: new BoxError(BoxError.FS_ERROR, safe.error.message) };
|
|
safe.fs.unlinkSync(certFilePath);
|
|
|
|
const key = safe.fs.readFileSync(keyFilePath, 'utf8');
|
|
if (!key) return { error: new BoxError(BoxError.FS_ERROR, safe.error.message) };
|
|
safe.fs.unlinkSync(keyFilePath);
|
|
|
|
return { cert: cert, key: key, error: null };
|
|
}
|
|
|
|
function setFallbackCertificate(domain, fallback, callback) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert(fallback && typeof fallback === 'object');
|
|
assert.strictEqual(typeof fallback, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
debug(`setFallbackCertificate: setting certs for domain ${domain}`);
|
|
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.cert`), fallback.cert)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
|
|
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${domain}.host.key`), fallback.key)) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message));
|
|
|
|
// TODO: maybe the cert is being used by the mail container
|
|
reload(function (error) {
|
|
if (error) return callback(new BoxError(BoxError.NGINX_ERROR, error));
|
|
|
|
return callback(null);
|
|
});
|
|
}
|
|
|
|
function getFallbackCertificate(domain, callback) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
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`);
|
|
|
|
callback(null, { certFilePath, keyFilePath });
|
|
}
|
|
|
|
function setAppCertificateSync(location, domainObject, certificate) {
|
|
assert.strictEqual(typeof location, 'string');
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof certificate, 'object');
|
|
|
|
let fqdn = domains.fqdn(location, domainObject);
|
|
if (certificate.cert && certificate.key) {
|
|
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`), certificate.cert)) return safe.error;
|
|
if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`), certificate.key)) return safe.error;
|
|
} else { // remove existing cert/key
|
|
if (!safe.fs.unlinkSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`))) debug('Error removing cert: ' + safe.error.message);
|
|
if (!safe.fs.unlinkSync(path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`))) debug('Error removing key: ' + safe.error.message);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getAcmeCertificate(vhost, domainObject, callback) {
|
|
assert.strictEqual(typeof vhost, 'string'); // this can contain wildcard domain (for alias domains)
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
let certFilePath, keyFilePath;
|
|
|
|
if (vhost !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN
|
|
let certName = domains.makeWildcard(vhost).replace('*.', '_.');
|
|
certFilePath = path.join(paths.APP_CERTS_DIR, `${certName}.cert`);
|
|
keyFilePath = path.join(paths.APP_CERTS_DIR, `${certName}.key`);
|
|
|
|
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
|
|
} else {
|
|
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 });
|
|
}
|
|
|
|
callback(null);
|
|
}
|
|
|
|
function getCertificate(fqdn, domain, callback) {
|
|
assert.strictEqual(typeof fqdn, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
// 1. user cert always wins
|
|
// 2. if using fallback provider, return that cert
|
|
// 3. look for LE certs
|
|
|
|
domains.get(domain, function (error, domainObject) {
|
|
if (error) return callback(error);
|
|
|
|
// user cert always wins
|
|
let certFilePath = path.join(paths.APP_CERTS_DIR, `${fqdn}.user.cert`);
|
|
let keyFilePath = path.join(paths.APP_CERTS_DIR, `${fqdn}.user.key`);
|
|
|
|
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, { certFilePath, keyFilePath });
|
|
|
|
if (domainObject.tlsConfig.provider === 'fallback') return getFallbackCertificate(domain, callback);
|
|
|
|
getAcmeCertificate(fqdn, domainObject, function (error, result) {
|
|
if (error || result) return callback(error, result);
|
|
|
|
return getFallbackCertificate(domain, callback);
|
|
});
|
|
});
|
|
}
|
|
|
|
function ensureCertificate(vhost, domain, auditSource, callback) {
|
|
assert.strictEqual(typeof vhost, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
domains.get(domain, function (error, domainObject) {
|
|
if (error) return callback(error);
|
|
|
|
// user cert always wins
|
|
let certFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.cert`);
|
|
let keyFilePath = path.join(paths.APP_CERTS_DIR, `${vhost}.user.key`);
|
|
|
|
if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
|
|
debug(`ensureCertificate: ${vhost} will use custom app certs`);
|
|
return callback(null, { certFilePath, keyFilePath }, { renewed: false });
|
|
}
|
|
|
|
if (domainObject.tlsConfig.provider === 'fallback') {
|
|
debug(`ensureCertificate: ${vhost} will use fallback certs`);
|
|
|
|
return getFallbackCertificate(domain, function (error, bundle) {
|
|
if (error) return callback(error);
|
|
|
|
callback(null, bundle, { renewed: false });
|
|
});
|
|
}
|
|
|
|
getAcmeApi(domainObject, function (error, acmeApi, apiOptions) {
|
|
if (error) return callback(error);
|
|
|
|
getAcmeCertificate(vhost, domainObject, function (_error, currentBundle) {
|
|
if (currentBundle) {
|
|
debug(`ensureCertificate: ${vhost} certificate already exists at ${currentBundle.keyFilePath}`);
|
|
|
|
if (!isExpiringSync(currentBundle.certFilePath, 24 * 30) && providerMatchesSync(domainObject, currentBundle.certFilePath, apiOptions)) return callback(null, currentBundle, { renewed: false });
|
|
debug(`ensureCertificate: ${vhost} cert requires renewal`);
|
|
} else {
|
|
debug(`ensureCertificate: ${vhost} cert does not exist`);
|
|
}
|
|
|
|
debug('ensureCertificate: getting certificate for %s with options %j', vhost, apiOptions);
|
|
|
|
acmeApi.getCertificate(vhost, domain, apiOptions, function (error, certFilePath, keyFilePath) {
|
|
debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${certFilePath || 'null'}`);
|
|
|
|
eventlog.add(currentBundle ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: vhost, errorMessage: error ? error.message : '' });
|
|
|
|
if (error && currentBundle && !isExpiringSync(currentBundle.certFilePath, 0)) {
|
|
debug('ensureCertificate: continue using existing bundle since renewal failed');
|
|
return callback(null, currentBundle, { renewed: false });
|
|
}
|
|
|
|
if (certFilePath && keyFilePath) return callback(null, { certFilePath, keyFilePath }, { renewed: true });
|
|
|
|
debug(`ensureCertificate: renewal of ${vhost} failed. using fallback certificates for ${domain}`);
|
|
|
|
getFallbackCertificate(domain, function (error, bundle) {
|
|
if (error) return callback(error);
|
|
|
|
callback(null, bundle, { renewed: false });
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function writeDashboardNginxConfig(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: settings.adminOrigin(),
|
|
vhost: vhost,
|
|
hasIPv6: sysinfo.hasIPv6(),
|
|
endpoint: 'admin',
|
|
certFilePath: bundle.certFilePath,
|
|
keyFilePath: bundle.keyFilePath,
|
|
robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'),
|
|
proxyAuth: { enabled: false, id: null, location: nginxLocation('/') }
|
|
};
|
|
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(new BoxError(BoxError.FS_ERROR, safe.error));
|
|
|
|
reload(callback);
|
|
}
|
|
|
|
function configureAdmin(domain, auditSource, callback) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
domains.get(domain, function (error, domainObject) {
|
|
if (error) return callback(error);
|
|
|
|
const adminFqdn = domains.fqdn(constants.ADMIN_LOCATION, domainObject);
|
|
|
|
ensureCertificate(adminFqdn, domainObject.domain, auditSource, function (error, bundle) {
|
|
if (error) return callback(error);
|
|
|
|
writeDashboardNginxConfig(bundle, `${adminFqdn}.conf`, adminFqdn, callback);
|
|
});
|
|
});
|
|
}
|
|
|
|
function writeDashboardConfig(domain, callback) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
debug(`writeDashboardConfig: writing admin config for ${domain}`);
|
|
|
|
domains.get(domain, function (error, domainObject) {
|
|
if (error) return callback(error);
|
|
|
|
const adminFqdn = domains.fqdn(constants.ADMIN_LOCATION, domainObject);
|
|
|
|
getCertificate(adminFqdn, domainObject.domain, function (error, bundle) {
|
|
if (error) return callback(error);
|
|
|
|
writeDashboardNginxConfig(bundle, `${adminFqdn}.conf`, adminFqdn, callback);
|
|
});
|
|
});
|
|
}
|
|
|
|
function writeAppNginxConfig(app, fqdn, bundle, callback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof fqdn, 'string');
|
|
assert.strictEqual(typeof bundle, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
var sourceDir = path.resolve(__dirname, '..');
|
|
var endpoint = 'app';
|
|
|
|
let robotsTxtQuoted = null, hideHeaders = [], cspQuoted = null;
|
|
const reverseProxyConfig = app.reverseProxyConfig || {}; // some of our code uses fake app objects
|
|
if (reverseProxyConfig.robotsTxt) robotsTxtQuoted = JSON.stringify(app.reverseProxyConfig.robotsTxt);
|
|
if (reverseProxyConfig.csp) {
|
|
cspQuoted = `"${app.reverseProxyConfig.csp}"`;
|
|
hideHeaders = [ 'Content-Security-Policy' ];
|
|
if (reverseProxyConfig.csp.includes('frame-ancestors ')) hideHeaders.push('X-Frame-Options');
|
|
}
|
|
|
|
var data = {
|
|
sourceDir: sourceDir,
|
|
adminOrigin: settings.adminOrigin(),
|
|
vhost: fqdn,
|
|
hasIPv6: sysinfo.hasIPv6(),
|
|
ip: app.containerIp,
|
|
port: app.manifest.httpPort,
|
|
endpoint: endpoint,
|
|
certFilePath: bundle.certFilePath,
|
|
keyFilePath: bundle.keyFilePath,
|
|
robotsTxtQuoted,
|
|
cspQuoted,
|
|
hideHeaders,
|
|
proxyAuth: {
|
|
enabled: app.sso && app.manifest.addons && app.manifest.addons.proxyAuth,
|
|
id: app.id,
|
|
location: nginxLocation(safe.query(app.manifest, 'addons.proxyAuth.path') || '/')
|
|
},
|
|
httpPaths: app.manifest.httpPaths || {}
|
|
};
|
|
var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
|
|
|
|
const aliasSuffix = app.fqdn === fqdn ? '' : `-alias-${fqdn.replace('*', '_')}`;
|
|
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}${aliasSuffix}.conf`);
|
|
debug('writeAppNginxConfig: writing config for "%s" to %s with options %j', fqdn, nginxConfigFilename, data);
|
|
|
|
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) {
|
|
debug('Error creating nginx config for "%s" : %s', app.fqdn, safe.error.message);
|
|
return callback(new BoxError(BoxError.FS_ERROR, safe.error));
|
|
}
|
|
|
|
reload(callback);
|
|
}
|
|
|
|
function writeAppRedirectNginxConfig(app, fqdn, bundle, callback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof fqdn, 'string');
|
|
assert.strictEqual(typeof bundle, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
var data = {
|
|
sourceDir: path.resolve(__dirname, '..'),
|
|
vhost: fqdn,
|
|
redirectTo: app.fqdn,
|
|
hasIPv6: sysinfo.hasIPv6(),
|
|
endpoint: 'redirect',
|
|
certFilePath: bundle.certFilePath,
|
|
keyFilePath: bundle.keyFilePath,
|
|
robotsTxtQuoted: null,
|
|
cspQuoted: null,
|
|
hideHeaders: [],
|
|
proxyAuth: { enabled: false, id: app.id, location: nginxLocation('/') }
|
|
};
|
|
var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
|
|
|
|
// if we change the filename, also change it in unconfigureApp()
|
|
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}-redirect-${fqdn}.conf`);
|
|
debug('writing config for "%s" redirecting to "%s" to %s with options %j', app.fqdn, fqdn, nginxConfigFilename, data);
|
|
|
|
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) {
|
|
debug('Error creating nginx redirect config for "%s" : %s', app.fqdn, safe.error.message);
|
|
return callback(new BoxError(BoxError.FS_ERROR, safe.error));
|
|
}
|
|
|
|
reload(callback);
|
|
}
|
|
|
|
function writeAppConfig(app, callback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
let appDomains = [];
|
|
appDomains.push({ domain: app.domain, fqdn: app.fqdn, type: 'primary' });
|
|
|
|
app.alternateDomains.forEach(function (alternateDomain) {
|
|
appDomains.push({ domain: alternateDomain.domain, fqdn: alternateDomain.fqdn, type: 'alternate' });
|
|
});
|
|
|
|
app.aliasDomains.forEach(function (aliasDomain) {
|
|
appDomains.push({ domain: aliasDomain.domain, fqdn: aliasDomain.fqdn, type: 'alias' });
|
|
});
|
|
|
|
async.eachSeries(appDomains, function (appDomain, iteratorDone) {
|
|
getCertificate(appDomain.fqdn, appDomain.domain, function (error, bundle) {
|
|
if (error) return iteratorDone(error);
|
|
|
|
if (appDomain.type === 'primary') {
|
|
writeAppNginxConfig(app, appDomain.fqdn, bundle, iteratorDone);
|
|
} else if (appDomain.type === 'alternate') {
|
|
writeAppRedirectNginxConfig(app, appDomain.fqdn, bundle, iteratorDone);
|
|
} else if (appDomain.type === 'alias') {
|
|
writeAppNginxConfig(app, appDomain.fqdn, bundle, iteratorDone);
|
|
}
|
|
});
|
|
}, callback);
|
|
}
|
|
|
|
function configureApp(app, auditSource, callback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
let appDomains = [];
|
|
appDomains.push({ domain: app.domain, fqdn: app.fqdn, type: 'primary' });
|
|
|
|
app.alternateDomains.forEach(function (alternateDomain) {
|
|
appDomains.push({ domain: alternateDomain.domain, fqdn: alternateDomain.fqdn, type: 'alternate' });
|
|
});
|
|
|
|
app.aliasDomains.forEach(function (aliasDomain) {
|
|
appDomains.push({ domain: aliasDomain.domain, fqdn: aliasDomain.fqdn, type: 'alias' });
|
|
});
|
|
|
|
async.eachSeries(appDomains, function (appDomain, iteratorDone) {
|
|
ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource, function (error, bundle) {
|
|
if (error) return iteratorDone(error);
|
|
|
|
if (appDomain.type === 'primary') {
|
|
writeAppNginxConfig(app, appDomain.fqdn, bundle, iteratorDone);
|
|
} else if (appDomain.type === 'alternate') {
|
|
writeAppRedirectNginxConfig(app, appDomain.fqdn, bundle, iteratorDone);
|
|
} else if (appDomain.type === 'alias') {
|
|
writeAppNginxConfig(app, appDomain.fqdn, bundle, iteratorDone);
|
|
}
|
|
});
|
|
}, callback);
|
|
}
|
|
|
|
function unconfigureApp(app, callback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
// we use globbing to find all nginx configs for an app
|
|
rimraf(path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}*.conf`), function (error) {
|
|
if (error) debug('Error removing nginx configurations of "%s":', app.fqdn, error);
|
|
|
|
reload(callback);
|
|
});
|
|
}
|
|
|
|
function renewCerts(options, auditSource, progressCallback, callback) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
apps.getAll(function (error, allApps) {
|
|
if (error) return callback(error);
|
|
|
|
let appDomains = [];
|
|
|
|
// add webadmin and mail domain
|
|
if (settings.mailFqdn() === settings.adminFqdn()) {
|
|
appDomains.push({ domain: settings.adminDomain(), fqdn: settings.adminFqdn(), type: 'webadmin+mail', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.adminFqdn()}.conf`) });
|
|
} else {
|
|
appDomains.push({ domain: settings.adminDomain(), fqdn: settings.adminFqdn(), type: 'webadmin', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.adminFqdn()}.conf`) });
|
|
appDomains.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), type: 'mail' });
|
|
}
|
|
|
|
allApps.forEach(function (app) {
|
|
if (app.runState === apps.RSTATE_STOPPED) return; // do not renew certs of stopped apps
|
|
|
|
appDomains.push({ domain: app.domain, fqdn: app.fqdn, type: 'primary', app: app, nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf') });
|
|
|
|
app.alternateDomains.forEach(function (alternateDomain) {
|
|
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}-redirect-${alternateDomain.fqdn}.conf`);
|
|
appDomains.push({ domain: alternateDomain.domain, fqdn: alternateDomain.fqdn, type: 'alternate', app: app, nginxConfigFilename });
|
|
});
|
|
|
|
app.aliasDomains.forEach(function (aliasDomain) {
|
|
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}-alias-${aliasDomain.fqdn.replace('*', '_')}.conf`);
|
|
appDomains.push({ domain: aliasDomain.domain, fqdn: aliasDomain.fqdn, type: 'alias', app: app, nginxConfigFilename });
|
|
});
|
|
});
|
|
|
|
if (options.domain) appDomains = appDomains.filter(function (appDomain) { return appDomain.domain === options.domain; });
|
|
|
|
let progress = 1, renewed = [];
|
|
|
|
async.eachSeries(appDomains, function (appDomain, iteratorCallback) {
|
|
progressCallback({ percent: progress, message: `Renewing certs of ${appDomain.fqdn}` });
|
|
progress += Math.round(100/appDomains.length);
|
|
|
|
ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource, function (error, bundle, state) {
|
|
if (error) return iteratorCallback(error); // this can happen if cloudron is not setup yet
|
|
|
|
if (state.renewed) renewed.push(appDomain.fqdn);
|
|
|
|
if (appDomain.type === 'mail') return iteratorCallback(); // mail has no nginx config to check current cert
|
|
|
|
// hack to check if the app's cert changed or not. this doesn't handle prod/staging le change since they use same file name
|
|
let currentNginxConfig = safe.fs.readFileSync(appDomain.nginxConfigFilename, 'utf8') || '';
|
|
if (currentNginxConfig.includes(bundle.certFilePath)) return iteratorCallback();
|
|
|
|
debug(`renewCerts: creating new nginx config since ${appDomain.nginxConfigFilename} does not have ${bundle.certFilePath}`);
|
|
|
|
// reconfigure since the cert changed
|
|
if (appDomain.type === 'webadmin') {
|
|
return writeDashboardNginxConfig(bundle, `${settings.adminFqdn()}.conf`, settings.adminFqdn(), iteratorCallback);
|
|
} else if (appDomain.type === 'webadmin+mail') {
|
|
return async.series([
|
|
mail.handleCertChanged,
|
|
writeDashboardNginxConfig.bind(null, bundle, `${settings.adminFqdn()}.conf`, settings.adminFqdn())
|
|
], iteratorCallback);
|
|
} else if (appDomain.type === 'primary') {
|
|
return writeAppNginxConfig(appDomain.app, appDomain.fqdn, bundle, iteratorCallback);
|
|
} else if (appDomain.type === 'alternate') {
|
|
return writeAppRedirectNginxConfig(appDomain.app, appDomain.fqdn, bundle, iteratorCallback);
|
|
} else if (appDomain.type === 'alias') {
|
|
return writeAppNginxConfig(appDomain.app, appDomain.fqdn, bundle, iteratorCallback);
|
|
}
|
|
|
|
iteratorCallback(new BoxError(BoxError.INTERNAL_ERROR, `Unknown domain type for ${appDomain.fqdn}. This should never happen`));
|
|
});
|
|
}, function (error) {
|
|
if (error) return callback(error);
|
|
|
|
debug(`renewCerts: Renewed certs of ${JSON.stringify(renewed)}`);
|
|
if (renewed.length === 0) return callback(null);
|
|
|
|
async.series([
|
|
(next) => { return renewed.includes(settings.mailFqdn()) ? mail.handleCertChanged(next) : next(); },// mail cert renewed
|
|
reload, // reload nginx if any certs were updated but the config was not rewritten
|
|
(next) => { // restart tls apps on cert change
|
|
const tlsApps = allApps.filter(app => app.manifest.addons && app.manifest.addons.tls && renewed.includes(app.fqdn));
|
|
async.eachSeries(tlsApps, function (app, iteratorDone) {
|
|
apps.restart(app, auditSource, () => iteratorDone());
|
|
}, next);
|
|
}
|
|
], callback);
|
|
});
|
|
});
|
|
}
|
|
|
|
function removeAppConfigs() {
|
|
for (let appConfigFile of fs.readdirSync(paths.NGINX_APPCONFIG_DIR)) {
|
|
if (appConfigFile !== constants.NGINX_DEFAULT_CONFIG_FILE_NAME && !appConfigFile.startsWith(constants.ADMIN_LOCATION)) {
|
|
fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, appConfigFile));
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeDefaultConfig(options, callback) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
const certFilePath = path.join(paths.NGINX_CERT_DIR, 'default.cert');
|
|
const keyFilePath = path.join(paths.NGINX_CERT_DIR, 'default.key');
|
|
|
|
if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
|
|
debug('writeDefaultConfig: create new cert');
|
|
|
|
const cn = 'cloudron-' + (new Date()).toISOString(); // randomize date a bit to keep firefox happy
|
|
// the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
|
|
if (!safe.child_process.execSync(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=${cn} -nodes`)) {
|
|
debug(`writeDefaultConfig: could not generate certificate: ${safe.error.message}`);
|
|
return callback(new BoxError(BoxError.OPENSSL_ERROR, safe.error));
|
|
}
|
|
}
|
|
|
|
const data = {
|
|
sourceDir: path.resolve(__dirname, '..'),
|
|
adminOrigin: settings.adminOrigin(),
|
|
vhost: '',
|
|
hasIPv6: sysinfo.hasIPv6(),
|
|
endpoint: options.activated ? 'ip' : 'setup',
|
|
certFilePath,
|
|
keyFilePath,
|
|
robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'),
|
|
proxyAuth: { enabled: false, id: null, location: nginxLocation('/') }
|
|
};
|
|
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
|
|
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, constants.NGINX_DEFAULT_CONFIG_FILE_NAME);
|
|
|
|
debug(`writeDefaultConfig: writing configs for endpoint "${data.endpoint}"`);
|
|
|
|
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) return callback(new BoxError(BoxError.FS_ERROR, safe.error));
|
|
|
|
reload(callback);
|
|
}
|