reverseproxy: rework cert logic

9c8f78a059 already fixed many of the cert issues.

However, some issues were caught in the CI:

* The TLS addon has to be rebuilt and not just restarted. For this reason, we now
  move things to a directory instead of mounting files. This way the container is just restarted.

* Cleanups must be driven by the database and not the filesystem . Deleting files on disk or after a restore,
  the certs are left dangling forever in the db.

* Separate the db cert logic and disk cert logic. This way we can sync as many times as we want and whenever we want.
This commit is contained in:
Girish Ramakrishnan
2022-11-28 22:32:34 +01:00
parent c844be5be1
commit 89127e1df7
12 changed files with 279 additions and 348 deletions

View File

@@ -64,9 +64,6 @@ exports = module.exports = {
appendLogLine,
getCertificate,
getLocationsSync,
start,
stop,
restart,
@@ -139,6 +136,11 @@ exports = module.exports = {
LOCATION_TYPE_REDIRECT: 'redirect',
LOCATION_TYPE_ALIAS: 'alias',
// should probably be in table as well
LOCATION_TYPE_DASHBOARD: 'dashboard',
LOCATION_TYPE_MAIL: 'mail',
LOCATION_TYPE_DIRECTORY_SERVER: 'directoryserver',
// respositories, match with appstore
REPOSITORY_CORE: 'core',
REPOSITORY_COMMUNITY: 'community',
@@ -204,6 +206,7 @@ const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationS
'apps.storageVolumeId', 'apps.storageVolumePrefix', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(',');
// const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId' ].join(',');
const LOCATION_FIELDS = [ 'appId', 'subdomain', 'domain', 'type', 'certificateJson' ];
const CHECKVOLUME_CMD = path.join(__dirname, 'scripts/checkvolume.sh');
@@ -1797,11 +1800,23 @@ async function setCertificate(app, data, auditSource) {
const result = await database.query('UPDATE locations SET certificateJson=? WHERE location=? AND domain=?', [ certificate ? JSON.stringify(certificate) : null, subdomain, domain ]);
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Location not found');
app = await get(app.id); // refresh app object
await reverseProxy.setUserCertificate(app, dns.fqdn(subdomain, domain), certificate);
const location = await getLocation(subdomain, domain); // fresh location object
await reverseProxy.setUserCertificate(app, location);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: app.id, app, subdomain, domain, cert });
}
async function getLocation(subdomain, domain) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
const result = await database.query(`SELECT ${LOCATION_FIELDS} FROM locations WHERE subdomain=? AND domain=?`, [ subdomain, domain ]);
if (result.length === 0) return null;
result[0].certificate = safe.JSON.parse(result[0].certificateJson);
result[0].fqdn = dns.fqdn(subdomain, domain);
return result[0];
}
async function setLocation(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object');
@@ -2049,15 +2064,6 @@ async function appendLogLine(app, line) {
if (!safe.fs.appendFileSync(logFilePath, line)) console.error(`Could not append log line for app ${app.id} at ${logFilePath}: ${safe.error.message}`);
}
async function getCertificate(subdomain, domain) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
const result = await database.query('SELECT certificateJson FROM locations WHERE subdomain=? AND domain=?', [ subdomain, domain ]);
if (result.length === 0) return null;
return safe.JSON.parse(result[0].certificateJson);
}
// does a re-configure when called from most states. for install/clone errors, it re-installs with an optional manifest
// re-configure can take a dockerImage but not a manifest because re-configure does not clean up addons
async function repair(app, data, auditSource) {
@@ -2866,14 +2872,3 @@ async function restoreConfig(app) {
await update(app.id, data);
}
function getLocationsSync(app) {
assert.strictEqual(typeof app, 'object');
const locations = [{ subdomain: app.subdomain, domain: app.domain, fqdn: app.fqdn, certificate: app.certificate, type: exports.LOCATION_TYPE_PRIMARY }]
.concat(app.secondaryDomains.map(sd => { return { subdomain: sd.subdomain, domain: sd.domain, certificate: sd.certificate, fqdn: sd.fqdn, type: exports.LOCATION_TYPE_SECONDARY }; }))
.concat(app.redirectDomains.map(rd => { return { subdomain: rd.subdomain, domain: rd.domain, certificate: rd.certificate, fqdn: rd.fqdn, type: exports.LOCATION_TYPE_REDIRECT }; }))
.concat(app.aliasDomains.map(ad => { return { subdomain: ad.subdomain, domain: ad.domain, certificate: ad.certificate, fqdn: ad.fqdn, type: exports.LOCATION_TYPE_ALIAS }; }));
return locations;
}

View File

@@ -9,6 +9,8 @@ exports = module.exports = {
setString,
del,
listCertIds,
ACME_ACCOUNT_KEY: 'acme_account_key',
ADDON_TURN_SECRET: 'addon_turn_secret',
SFTP_PUBLIC_KEY: 'sftp_public_key',
@@ -16,6 +18,7 @@ exports = module.exports = {
PROXY_AUTH_TOKEN_SECRET: 'proxy_auth_token_secret',
CERT_PREFIX: 'cert',
CERT_SUFFIX: 'cert',
_clear: clear
};
@@ -62,3 +65,8 @@ async function del(id) {
async function clear() {
await database.query('DELETE FROM blobs');
}
async function listCertIds() {
const result = await database.query('SELECT id FROM blobs WHERE id LIKE ?', [ `${exports.CERT_PREFIX}-%.${exports.CERT_SUFFIX}` ]);
return result.map(r => r.id);
}

View File

@@ -36,7 +36,6 @@ const apps = require('./apps.js'),
delay = require('./delay.js'),
dns = require('./dns.js'),
dockerProxy = require('./dockerproxy.js'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
LogStream = require('./log-stream.js'),
@@ -113,10 +112,7 @@ async function runStartupTasks() {
tasks.push(async function () {
if (!settings.dashboardDomain()) return;
// always write certs to overcome 0 length certs on disk full
const domainObject = await domains.get(settings.dashboardDomain());
await reverseProxy.ensureCertificate(settings.dashboardFqdn(), domainObject, { skipRenewal: true }, AuditSource.PLATFORM);
await reverseProxy.writeDashboardConfig(domainObject);
await reverseProxy.writeDashboardConfig(settings.dashboardDomain());
});
tasks.push(async function () {
@@ -267,10 +263,7 @@ async function setDashboardDomain(domain, auditSource) {
debug(`setDashboardDomain: ${domain}`);
const domainObject = await domains.get(domain);
if (!domain) throw new BoxError(BoxError.NOT_FOUND, 'No such domain');
await reverseProxy.writeDashboardConfig(domainObject);
await reverseProxy.writeDashboardConfig(domain);
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
await settings.setDashboardLocation(domain, fqdn);
@@ -308,7 +301,6 @@ async function setupDnsAndCert(subdomain, domain, auditSource, progressCallback)
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const domainObject = await domains.get(domain);
const dashboardFqdn = dns.fqdn(subdomain, domain);
const ipv4 = await sysinfo.getServerIPv4();
@@ -321,7 +313,8 @@ async function setupDnsAndCert(subdomain, domain, auditSource, progressCallback)
await dns.waitForDnsRecord(subdomain, domain, 'A', ipv4, { interval: 30000, times: 50000 });
if (ipv6) await dns.waitForDnsRecord(subdomain, domain, 'AAAA', ipv6, { interval: 30000, times: 50000 });
progressCallback({ percent: 60, message: `Getting certificate of ${dashboardFqdn}` });
await reverseProxy.ensureCertificate(dns.fqdn(subdomain, domain), domainObject, {}, auditSource);
const location = { subdomain, domain, fqdn: dashboardFqdn, type: apps.LOCATION_TYPE_DASHBOARD, certificate: null };
await reverseProxy.ensureCertificate(location, auditSource);
}
async function syncDnsRecords(options) {

View File

@@ -14,10 +14,7 @@ const assert = require('assert'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:directoryserver'),
dns = require('./dns.js'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
groups = require('./groups.js'),
ldap = require('ldapjs'),
path = require('path'),
@@ -31,7 +28,7 @@ const assert = require('assert'),
util = require('util'),
validator = require('validator');
let gServer = null;
let gServer = null, gCertificate = null;
const NOOP = function () {};
@@ -298,7 +295,6 @@ async function userAuth(req, res, next) {
next();
}
// FIXME this needs to be restarted if settings changes or dashboard cert got renewed
async function start() {
if (gServer) return; // already running
@@ -311,13 +307,11 @@ async function start() {
fatal: debug
};
const domainObject = await domains.get(settings.dashboardDomain());
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, settings.dashboardDomain());
const certificatePath = await reverseProxy.getCertificatePath(dashboardFqdn, domainObject.domain);
gCertificate = await reverseProxy.getDirectoryServerCertificate();
gServer = ldap.createServer({
certificate: fs.readFileSync(certificatePath.certFilePath, 'utf8'),
key: fs.readFileSync(certificatePath.keyFilePath, 'utf8'),
certificate: gCertificate.cert,
key: gCertificate.key,
log: logger
});
@@ -373,6 +367,13 @@ async function stop() {
}
async function handleCertChanged() {
const certificate = await reverseProxy.getDirectoryServerCertificate();
if (certificate.cert.equals(gCertificate.cert)) {
debug('handleCertChanged: certificate has not changed');
return;
}
debug('handleCertChanged: certificate changed. restarting');
await stop();
await start();
}

View File

@@ -39,7 +39,7 @@ const apps = require('./apps.js'),
debug = require('debug')('box:docker'),
delay = require('./delay.js'),
Docker = require('dockerode'),
reverseProxy = require('./reverseproxy.js'),
paths = require('./paths.js'),
services = require('./services.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
@@ -205,18 +205,11 @@ async function getAddonMounts(app) {
break;
}
case 'tls': {
const certificatePath = await reverseProxy.getCertificatePath(app.fqdn, app.domain);
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
mounts.push({
Target: '/etc/certs/tls_cert.pem',
Source: certificatePath.certFilePath,
Type: 'bind',
ReadOnly: true
});
mounts.push({
Target: '/etc/certs/tls_key.pem',
Source: certificatePath.keyFilePath,
Target: '/etc/certs',
Source: certificateDir,
Type: 'bind',
ReadOnly: true
});

View File

@@ -243,7 +243,6 @@ async function setConfig(domain, data, auditSource) {
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
if (fallbackCertificate) await reverseProxy.setFallbackCertificate(domain, fallbackCertificate);
if (!_.isEqual(domainObject.tlsConfig, tlsConfig.provider)) await reverseProxy.handleCertificateProviderChanged();
await eventlog.add(eventlog.ACTION_DOMAIN_UPDATE, auditSource, { domain, zoneName, provider });
}

View File

@@ -35,7 +35,7 @@ exports = module.exports = {
ACTION_BACKUP_CLEANUP_FINISH: 'backup.cleanup.finish',
ACTION_CERTIFICATE_NEW: 'certificate.new',
ACTION_CERTIFICATE_RENEWAL: 'certificate.renew',
ACTION_CERTIFICATE_RENEWAL: 'certificate.renew', // obsolete
ACTION_CERTIFICATE_CLEANUP: 'certificate.cleanup',
ACTION_DASHBOARD_DOMAIN_UPDATE: 'dashboard.domain.update',

View File

@@ -717,15 +717,15 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
const memory = await system.getMemoryAllocation(memoryLimit);
const cloudronToken = hat(8 * 128), relayToken = hat(8 * 128);
const certificatePath = await reverseProxy.getCertificatePath(mailFqdn, mailDomain);
const certificate = await reverseProxy.getMailCertificate();
const dhparamsFilePath = `${paths.MAIL_CONFIG_DIR}/dhparams.pem`;
const mailCertFilePath = `${paths.MAIL_CONFIG_DIR}/tls_cert.pem`;
const mailKeyFilePath = `${paths.MAIL_CONFIG_DIR}/tls_key.pem`;
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not copy dhparams:' + safe.error.message);
if (!safe.child_process.execSync(`cp ${certificatePath.certFilePath} ${mailCertFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not create cert file:' + safe.error.message);
if (!safe.child_process.execSync(`cp ${certificatePath.keyFilePath} ${mailKeyFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not create key file:' + safe.error.message);
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${safe.error.message}`);
if (!safe.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key file: ${safe.error.message}`);
// if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
if (!safe.fs.chmodSync(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`);
@@ -812,6 +812,14 @@ async function restartMailIfActivated() {
async function handleCertChanged() {
debug('handleCertChanged: will restart if activated');
const certificate = await reverseProxy.getMailCertificate();
const cert = safe.fs.readFileSync(`${paths.MAIL_CONFIG_DIR}/tls_cert.pem`);
if (cert && cert.equals(certificate.cert)) {
debug('handleCertChanged: certificate has not changed');
return;
}
debug('handleCertChanged: certificate has changed');
await restartMailIfActivated();
}

View File

@@ -8,10 +8,10 @@ exports = module.exports = {
validateCertificate,
getCertificatePath, // resolved cert path
ensureCertificate,
getMailCertificate,
getDirectoryServerCertificate,
handleCertificateProviderChanged,
ensureCertificate,
checkCerts,
@@ -37,6 +37,7 @@ const acme2 = require('./acme2.js'),
crypto = require('crypto'),
debug = require('debug')('box:reverseproxy'),
dns = require('./dns.js'),
docker = require('./docker.js'),
domains = require('./domains.js'),
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
@@ -61,7 +62,7 @@ function nginxLocation(s) {
return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex
}
function getExpiryDate(cert) {
function getExpiryDateSync(cert) {
assert(Buffer.isBuffer(cert));
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-enddate', '-noout' ], { input: cert });
@@ -202,7 +203,6 @@ async function setFallbackCertificate(domain, certificate) {
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), certificate.cert)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`), certificate.key)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
// TODO: maybe the cert is being used by the mail container
await reload();
}
@@ -215,22 +215,13 @@ async function restoreFallbackCertificates() {
}
}
function getFallbackCertificatePathSync(domain) {
assert.strictEqual(typeof domain, 'string');
function getAppLocationsSync(app) {
assert.strictEqual(typeof app, 'object');
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
return { certFilePath, keyFilePath };
}
function getUserCertificatePathSync(fqdn) {
assert.strictEqual(typeof fqdn, 'string');
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);
return { certFilePath, keyFilePath };
return [{ domain: app.domain, fqdn: app.fqdn, certificate: app.certificate, type: apps.LOCATION_TYPE_PRIMARY }]
.concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, certificate: sd.certificate, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY }; }))
.concat(app.redirectDomains.map(rd => { return { domain: rd.domain, certificate: rd.certificate, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT }; }))
.concat(app.aliasDomains.map(ad => { return { domain: ad.domain, certificate: ad.certificate, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS }; }));
}
function getAcmeCertificateNameSync(fqdn, domainObject) {
@@ -244,139 +235,126 @@ function getAcmeCertificateNameSync(fqdn, domainObject) {
}
}
function getAcmeCertificatePathSync(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
function needsRenewalSync(cert) {
assert(Buffer.isBuffer(cert));
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);
const csrFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.csr`);
return { certFilePath, keyFilePath, csrFilePath };
const notAfter = getExpiryDateSync(cert);
const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
debug(`needsRenewal: ${isExpiring}`);
return isExpiring;
}
async function getCertificatePath(fqdn, domain) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domain, 'string');
async function getCertificate(location) {
assert.strictEqual(typeof location, 'object');
const { domain, fqdn } = location;
const domainObject = await domains.get(domain);
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);
const subdomain = fqdn.substr(0, fqdn.length - domainObject.domain.length - 1);
const userCertificate = await apps.getCertificate(subdomain, domainObject.domain);
if (userCertificate) return getUserCertificatePathSync(fqdn);
if (domainObject.tlsConfig.provider === 'fallback') return getFallbackCertificatePathSync(domain);
const acmeCertificate = await getAcmeCertificate(fqdn, domainObject);
if (acmeCertificate) return getAcmeCertificatePathSync(fqdn, domainObject);
// only use fallback certs if acme cert was never got. expired acme certs will continue to be used
return getFallbackCertificatePathSync(domain);
}
async function writeUserCertificate(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
const subdomain = fqdn.substr(0, fqdn.length - domainObject.domain.length - 1);
const userCertificate = await apps.getCertificate(subdomain, domainObject.domain);
if (!userCertificate) return false;
const { certFilePath, keyFilePath } = getUserCertificatePathSync(fqdn);
if (!safe.fs.writeFileSync(certFilePath, userCertificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write certificate: ${safe.error.message}`);
if (!safe.fs.writeFileSync(keyFilePath, userCertificate.key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key: ${safe.error.message}`);
return true;
}
async function getAcmeCertificate(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
if (location.certificate) return location.certificate;
if (domainObject.tlsConfig.provider === 'fallback') return domainObject.fallbackCertificate;
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
const key = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`);
const cert = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.cert`);
if (!key || !cert) return null;
const key = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`);
if (!key || !cert) return domainObject.fallbackCertificate;
return { key, cert };
}
async function writeAcmeCertificate(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains)
assert.strictEqual(typeof domainObject, 'object');
async function getMailCertificate() {
return await getCertificate({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null, type: apps.LOCATION_TYPE_MAIL });
}
async function getDirectoryServerCertificate() {
return await getCertificate({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null, type: apps.LOCATION_TYPE_DIRECTORY_SERVER });
}
// write if contents mismatch
function writeFileSync(filePath, data) {
assert.strictEqual(typeof filePath, 'string');
assert(Buffer.isBuffer(data) || typeof data === 'string'); // domain and location stores certs as json but not acme
const curData = safe.fs.readFileSync(filePath);
if (curData && curData.equals(Buffer.from(data))) return;
if (!safe.fs.writeFileSync(filePath, data)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
}
async function setupTlsAddon(app) {
assert.strictEqual(typeof app, 'object');
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
for (const location of getAppLocationsSync(app)) {
const certificate = await getCertificate(location);
writeFileSync(`${certificateDir}/${location.fqdn}.cert`, certificate.cert);
writeFileSync(`${certificateDir}/${location.fqdn}.key`, certificate.key);
if (location.type === apps.LOCATION_TYPE_PRIMARY) { // backward compat
writeFileSync(`${certificateDir}/tls_cert.pem`, certificate.cert);
writeFileSync(`${certificateDir}/tls_key.pem`, certificate.key);
}
}
await docker.restartContainer(app.id);
}
// writes latest certificate to disk and returns the path
async function writeCertificate(location) {
assert.strictEqual(typeof location, 'object');
const { domain, fqdn } = location;
const domainObject = await domains.get(domain);
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);
if (location.certificate) {
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);
writeFileSync(certFilePath, location.certificate.cert);
writeFileSync(keyFilePath, location.certificate.key);
return { certFilePath, keyFilePath };
}
if (domainObject.tlsConfig.provider === 'fallback') {
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
debug(`writeCertificate: ${fqdn} will use fallback certs`);
writeFileSync(certFilePath, domainObject.fallbackCertificate.cert);
writeFileSync(keyFilePath, domainObject.fallbackCertificate.key);
return { certFilePath, keyFilePath };
}
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
const privateKey = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`);
const cert = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.cert`);
const csr = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.csr`);
let cert = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.cert`);
let key = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`);
if (!privateKey || !cert) return false;
if (!key || !cert) { // use fallback certs if we didn't manage to get acme certs
debug(`writeCertificate: ${fqdn} will use fallback certs because acme is missing`);
cert = domainObject.fallbackCertificate.cert;
key = domainObject.fallbackCertificate.key;
}
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);
const { keyFilePath, certFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject);
if (!safe.fs.writeFileSync(keyFilePath, privateKey)) throw new BoxError(BoxError.FS_ERROR, `Failed to write private key: ${safe.error.message}`);
if (!safe.fs.writeFileSync(certFilePath, cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write certificate: ${safe.error.message}`);
if (csr) safe.fs.writeFileSync(csrFilePath, csr);
writeFileSync(certFilePath, cert);
writeFileSync(keyFilePath, key);
return true;
return { certFilePath, keyFilePath };
}
async function needsRenewal(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domainObject, 'object');
const certificate = await getAcmeCertificate(fqdn, domainObject);
if (!certificate) return true;
const notAfter = getExpiryDate(certificate.cert);
const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
if (!isExpiring && providerMatchesSync(domainObject, certificate.cert)) return false;
debug(`needsRenewal: ${fqdn} cert requires renewal`);
return true;
}
async function renewCert(fqdn, domainObject) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domainObject, 'object');
const acmePaths = getAcmeCertificatePathSync(fqdn, domainObject);
const [error, result] = await safe(acme2.getCertificate(fqdn, domainObject));
if (error) { // write the fallback cert to keep the nginx configs consistent
fs.writeFileSync(acmePaths.certFilePath, domainObject.certificate.cert);
fs.writeFileSync(acmePaths.keyFilePath, domainObject.certificate.key);
} else {
const { certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject);
if (!safe.fs.writeFileSync(keyFilePath, result.key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write private key: ${safe.error.message}`);
if (!safe.fs.writeFileSync(certFilePath, result.cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write cert: ${safe.error.message}`);
if (!safe.fs.writeFileSync(csrFilePath, result.csr)) throw new BoxError(BoxError.FS_ERROR, `Failed to write csr: ${safe.error.message}`);
}
if (domainObject.domain === settings.mailDomain() && getAcmeCertificatePathSync(settings.mailFqdn(), domainObject).certFilePath === acmePaths.certFilePath) {
debug('renewCert: mail certificate changed');
const [restartError] = await safe(mail.handleCertChanged());
if (restartError) debug(`renewCert: error updating mail container on cert change: ${restartError.message}`);
}
if (domainObject.domain === settings.dashboardDomain() && getAcmeCertificatePathSync(settings.dashboardFqdn(), domainObject).certFilePath === acmePaths.certFilePath) {
debug('renewCert: directory server certificate changed');
const [reloadError] = await safe(shell.promises.sudo('renewCert', [ RESTART_SERVICE_CMD, 'box' ], {}));
if (reloadError) debug(`renewCert: error updating directory server on cert change: ${reloadError.message}`);
}
}
// ensures the cert of fqdn is available on disk. returns the path to the cert
async function ensureCertificate(fqdn, domainObject, options, auditSource) {
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof options, 'object');
async function ensureCertificate(location, auditSource) {
assert.strictEqual(typeof location, 'object');
assert.strictEqual(typeof auditSource, 'object');
if (await writeUserCertificate(fqdn, domainObject)) {
const domainObject = await domains.get(location.domain);
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${location.domain} not found`);
const fqdn = location.fqdn;
if (location.certificate) { // user certificate
debug(`ensureCertificate: ${fqdn} will use user certs`);
return;
}
@@ -386,22 +364,23 @@ async function ensureCertificate(fqdn, domainObject, options, auditSource) {
return;
}
let renewal = false;
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
const key = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`);
const cert = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.cert`);
if (await writeAcmeCertificate(fqdn, domainObject)) {
if (!await needsRenewal(fqdn, domainObject)) return;
renewal = true;
debug(`ensureCertificate: ${fqdn} cert requires renewal`);
} else {
debug(`ensureCertificate: ${fqdn} cert does not exist`);
if (key && cert) {
if (providerMatchesSync(domainObject, cert) && !needsRenewalSync(cert)) {
debug(`ensureCertificate: ${fqdn} acme cert exists and is up to date`);
return;
}
debug(`ensureCertificate: ${fqdn} acme cert exists but provider mismatch or needs renewal`);
}
if (options.skipRenewal) return;
debug(`ensureCertificate: ${fqdn} needs acme cert`);
const [error] = await safe(acme2.getCertificate(fqdn, domainObject));
debug(`ensureCertificate: error: ${error ? error.message : 'null'}`);
const [renewError] = await safe(renewCert(fqdn, domainObject));
debug(`ensureCertificate: error: ${renewError ? renewError.message : 'null'}`);
await safe(eventlog.add(renewal ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: renewError?.message || '' }));
await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: error?.message || '' }));
}
async function writeDashboardNginxConfig(vhost, certificatePath) {
@@ -422,27 +401,29 @@ async function writeDashboardNginxConfig(vhost, certificatePath) {
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) throw new BoxError(BoxError.FS_ERROR, safe.error);
writeFileSync(nginxConfigFilename, nginxConf);
}
async function writeDashboardConfig(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
// also syncs the certs to disk
async function writeDashboardConfig(domain) {
assert.strictEqual(typeof domain, 'string');
debug(`writeDashboardConfig: writing admin config for ${domainObject.domain}`);
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject.domain);
const certificatePath = await getCertificatePath(dashboardFqdn, domainObject.domain);
debug(`writeDashboardConfig: writing admin config for ${domain}`);
const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
const location = { domain, fqdn: dashboardFqdn, certificate: null };
const certificatePath = await writeCertificate(location);
await writeDashboardNginxConfig(dashboardFqdn, certificatePath);
await reload();
}
async function writeAppNginxConfig(app, vhost, type, certificatePath) {
async function writeAppLocationNginxConfig(app, location, certificatePath) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof vhost, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof location, 'object');
assert.strictEqual(typeof certificatePath, 'object');
const type = location.type, vhost = location.fqdn;
const data = {
sourceDir: path.resolve(__dirname, '..'),
vhost,
@@ -498,58 +479,47 @@ async function writeAppNginxConfig(app, vhost, type, certificatePath) {
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
const filename = path.join(paths.NGINX_APPCONFIG_DIR, app.id, `${vhost.replace('*', '_')}.conf`);
debug(`writeAppNginxConfig: writing config for "${vhost}" to ${filename} with options ${JSON.stringify(data)}`);
if (!safe.fs.writeFileSync(filename, nginxConf)) {
debug(`Error creating nginx config for "${app.fqdn}" : ${safe.error.message}`);
throw new BoxError(BoxError.FS_ERROR, safe.error);
}
debug(`writeAppLocationNginxConfig: writing config for "${vhost}" to ${filename} with options ${JSON.stringify(data)}`);
writeFileSync(filename, nginxConf);
}
async function writeAppConfigs(app) {
assert.strictEqual(typeof app, 'object');
const locations = apps.getLocationsSync(app);
const locations = getAppLocationsSync(app);
if (!safe.fs.mkdirSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true })) throw new BoxError(BoxError.FS_ERROR, `Could not create nginx config directory: ${safe.error.message}`);
for (const location of locations) {
const certificatePath = await getCertificatePath(location.fqdn, location.domain);
await writeAppNginxConfig(app, location.fqdn, location.type, certificatePath);
const certificatePath = await writeCertificate(location);
await writeAppLocationNginxConfig(app, location, certificatePath);
}
await reload();
}
async function setUserCertificate(app, fqdn, certificate) {
async function setUserCertificate(app, location) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof certificate, 'object');
assert.strictEqual(typeof location, 'object');
const { certFilePath, keyFilePath } = getUserCertificatePathSync(fqdn);
if (certificate !== null) {
if (!safe.fs.writeFileSync(certFilePath, certificate.cert)) throw safe.error;
if (!safe.fs.writeFileSync(keyFilePath, certificate.key)) throw safe.error;
} else { // remove existing cert/key
if (!safe.fs.unlinkSync(certFilePath)) debug(`Error removing cert: ${safe.error.message}`);
if (!safe.fs.unlinkSync(keyFilePath)) debug(`Error removing key: ${safe.error.message}`);
}
await writeAppConfigs(app);
const certificatePath = await writeCertificate(location);
await writeAppLocationNginxConfig(app, location, certificatePath);
await reload();
}
async function configureApp(app, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof auditSource, 'object');
const locations = await apps.getLocationsSync(app);
const domainObjectMap = await domains.getDomainObjectMap();
const locations = getAppLocationsSync(app);
for (const location of locations) {
await ensureCertificate(location.fqdn, domainObjectMap[location.domain], {}, auditSource);
await ensureCertificate(location, auditSource);
}
await writeAppConfigs(app);
if (app.manifest.addons?.tls) await setupTlsAddon(app);
}
async function unconfigureApp(app) {
@@ -559,142 +529,91 @@ async function unconfigureApp(app) {
await reload();
}
async function renewCerts(auditSource, progressCallback) {
async function ensureCertificates(locations, auditSource, progressCallback) {
assert(Array.isArray(locations));
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const domainObjects = await domains.list();
let domainObjectMap = {};
for (const d of domainObjects) { domainObjectMap[d.domain] = d; }
const allApps = await apps.list();
let locations = [];
// important: mail domain goes first because restart is only done on renewal. when using wildcard, renewal will skip if it appears later
if (settings.dashboardDomain() !== settings.mailDomain()) locations.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null });
locations.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null });
for (const app of allApps) {
if (app.runState === apps.RSTATE_STOPPED) continue; // do not renew certs of stopped apps
locations = locations.concat(apps.getLocationsSync(app));
}
let percent = 1, renewedCertificateNames = [];
let percent = 1;
for (const location of locations) {
percent += Math.round(100/locations.length);
progressCallback({ percent, message: `Ensuring certs of ${location.fqdn}` });
const domainObject = domainObjectMap[location.domain];
if (location.certificate?.key && location.certificate?.cert) continue; // user cert
if (domainObject.tlsConfig.provider === 'fallback') {
progressCallback({ message: `${location.fqdn} is using fallback certs` });
continue;
}
if (await needsRenewal(location.fqdn, domainObject)) {
await renewCert(location.fqdn, domainObject);
renewedCertificateNames.push(getAcmeCertificateNameSync(location.fqdn, domainObject));
} else {
progressCallback({ message: `Cert of ${location.fqdn} does not require renewal` });
}
}
if (renewedCertificateNames.length === 0) return;
progressCallback({ message: `Reloading nginx after renewing ${JSON.stringify(renewedCertificateNames)}` });
await reload();
for (const app of allApps) {
if (!app.manifest.addons?.tls) continue;
const addonCertificateName = getAcmeCertificateNameSync(app.fqdn, domainObjectMap[app.domain]);
if (renewedCertificateNames.includes(addonCertificateName)) await apps.restart(app, auditSource);
await ensureCertificate(location, auditSource);
}
}
async function cleanupCerts(auditSource, progressCallback) {
async function cleanupCerts(locations, auditSource, progressCallback) {
assert(Array.isArray(locations));
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
progressCallback({ message: 'Checking expired certs for removal' });
let locations = [];
if (settings.dashboardDomain() !== settings.mailDomain()) locations.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null });
locations.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null });
for (const app of await apps.list()) {
if (app.runState === apps.RSTATE_STOPPED) continue; // not in use
locations = locations.concat(apps.getLocationsSync(app));
}
const certsInUse = [ 'default.cert' ];
const domainObjectMap = await domains.getDomainObjectMap();
const certNamesInUse = new Set();
for (const location of locations) {
const certificatePath = await getCertificatePath(location.fqdn, location.domain);
certsInUse.push(path.basename(certificatePath.certFilePath));
certNamesInUse.add(await getAcmeCertificateNameSync(location.fqdn, domainObjectMap[location.domain]));
}
const filenames = await fs.promises.readdir(paths.NGINX_CERT_DIR);
const certFilenames = filenames.filter(f => f.endsWith('.cert') && !f.endsWith('.user.cert') && !f.endsWith('host.cert') && !certsInUse.includes(f));
const now = new Date();
const certIds = await blobs.listCertIds();
const removedCertNames = [];
for (const certId of certIds) {
const certName = certId.match(new RegExp(`${blobs.CERT_PREFIX}-(.*).cert`))[0];
if (certNamesInUse.has(certName)) continue;
debug(`cleanupCerts: considering ${JSON.stringify(certFilenames)} for cleanup`);
const fqdns = [];
for (const certFilename of certFilenames) {
const certFilePath = path.join(paths.NGINX_CERT_DIR, certFilename);
const notAfter = getExpiryDate(certFilePath);
const cert = await blobs.get(certId);
const notAfter = getExpiryDateSync(cert);
if (!notAfter) continue; // some error
if (now - notAfter >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago
const fqdn = certFilename.replace(/\.cert$/, '');
progressCallback({ message: `deleting certs of ${fqdn}` });
if (now - notAfter >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago and not in use
progressCallback({ message: `deleting certs of ${certName}` });
// it is safe to delete the certs of stopped apps because their nginx configs are removed
safe.fs.unlinkSync(certFilePath);
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${fqdn}.key`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${fqdn}.csr`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.cert`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.key`));
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.csr`));
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.key`);
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.cert`);
await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.csr`);
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.key`);
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.cert`);
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.csr`);
fqdns.push(fqdn);
removedCertNames.push(certName);
}
}
if (fqdns.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: fqdns }));
if (removedCertNames.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: removedCertNames }));
debug('cleanupCerts: done');
}
async function rebuildConfigs(auditSource, progressCallback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
debug('rebuildConfigs: rebuilding all configs');
progressCallback( { message: 'Rebuilding app configs' });
for (const app of await apps.list()) {
if (app.runState === apps.RSTATE_STOPPED) continue; // not in use
await writeAppConfigs(app);
}
await writeDashboardConfig(await domains.get(settings.dashboardDomain()));
await shell.promises.sudo('rebuildConfigs', [ RESTART_SERVICE_CMD, 'box' ], {});
progressCallback( { message: 'Rebuilding mail config' });
await mail.handleCertChanged();
}
async function checkCerts(auditSource, progressCallback) {
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await renewCerts(auditSource, progressCallback);
if (fs.existsSync(paths.REVERSE_PROXY_REBUILD_FILE)) {
await rebuildConfigs(auditSource, progressCallback);
safe.fs.unlinkSync(paths.REVERSE_PROXY_REBUILD_FILE);
let locations = [];
if (settings.dashboardDomain() !== settings.mailDomain()) locations.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), certificate: null, type: apps.LOCATION_TYPE_MAIL });
locations.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), certificate: null, type: apps.LOCATION_TYPE_DASHBOARD });
const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
for (const app of allApps) {
locations = locations.concat(getAppLocationsSync(app));
}
await cleanupCerts(auditSource, progressCallback);
await ensureCertificates(locations, auditSource, progressCallback);
progressCallback( { message: 'Rebuilding app configs' });
for (const app of allApps) {
await writeAppConfigs(app);
}
await writeDashboardConfig(settings.dashboardDomain());
await mail.handleCertChanged();
await shell.promises.sudo('rebuildConfigs', [ RESTART_SERVICE_CMD, 'box' ], {});
for (const app of allApps) {
if (app.manifest.addons?.tls) await setupTlsAddon(app);
}
await cleanupCerts(locations, auditSource, progressCallback);
}
function removeAppConfigs() {
@@ -751,7 +670,3 @@ async function writeDefaultConfig(options) {
await reload();
}
async function handleCertificateProviderChanged() {
safe.fs.writeFileSync(paths.REVERSE_PROXY_REBUILD_FILE, 'cert provider changed\n', 'utf8');
}

View File

@@ -161,8 +161,8 @@ const ADDONS = {
clear: NOOP,
},
tls: {
setup: NOOP,
teardown: NOOP,
setup: setupTls,
teardown: teardownTls,
backup: NOOP,
restore: NOOP,
clear: NOOP,
@@ -1813,6 +1813,23 @@ async function restoreRedis(app, options) {
await pipeFileToRequest(dumpPath('redis', app.id), `http://${result.ip}:3000/restore?access_token=${result.token}`);
}
async function setupTls(app, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
if (!safe.fs.mkdirSync(`${paths.PLATFORM_DATA_DIR}/tls/${app.id}`, { recursive: true })) {
debug('Error creating tls directory');
throw new BoxError(BoxError.FS_ERROR, safe.error.message);
}
}
async function teardownTls(app, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
safe.fs.rmSync(`${paths.PLATFORM_DATA_DIR}/tls/${app.id}`, { recursive: true, force: true });
}
async function statusTurn() {
const [error, container] = await safe(docker.inspect('turn'));
if (error && error.reason === BoxError.NOT_FOUND) return { status: exports.SERVICE_STATUS_STOPPED };