diff --git a/src/acme2.js b/src/acme2.js index 64d709ba0..1631207fa 100644 --- a/src/acme2.js +++ b/src/acme2.js @@ -17,6 +17,7 @@ const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), + paths = require('./paths.js'), promiseRetry = require('./promise-retry.js'), superagent = require('superagent'), safe = require('safetydance'), @@ -478,12 +479,12 @@ Acme2.prototype.cleanupChallenge = async function (hostname, domain, challenge, } }; -Acme2.prototype.acmeFlow = async function (hostname, domain, paths) { +Acme2.prototype.acmeFlow = async function (hostname, domain, acmeCertificatePaths) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof paths, 'object'); + assert.strictEqual(typeof acmeCertificatePaths, 'object'); - const { certFilePath, keyFilePath, csrFilePath, acmeChallengesDir } = paths; + const { certFilePath, keyFilePath, csrFilePath } = acmeCertificatePaths; await this.ensureAccount(); const { order, orderUrl } = await this.newOrder(hostname); @@ -492,7 +493,7 @@ Acme2.prototype.acmeFlow = async function (hostname, domain, paths) { const authorizationUrl = order.authorizations[i]; debug(`acmeFlow: authorizing ${authorizationUrl}`); - const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl, acmeChallengesDir); + const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl, paths.ACME_CHALLENGES_DIR); await this.notifyChallengeReady(challenge); await this.waitForChallenge(challenge); const csrDer = await this.createKeyAndCsr(hostname, keyFilePath, csrFilePath); @@ -501,7 +502,7 @@ Acme2.prototype.acmeFlow = async function (hostname, domain, paths) { await this.downloadCertificate(hostname, certUrl, certFilePath); try { - await this.cleanupChallenge(hostname, domain, challenge, acmeChallengesDir); + await this.cleanupChallenge(hostname, domain, challenge, paths.ACME_CHALLENGES_DIR); } catch (cleanupError) { debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError); } @@ -522,10 +523,10 @@ Acme2.prototype.loadDirectory = async function () { }); }; -Acme2.prototype.getCertificate = async function (fqdn, domain, paths) { +Acme2.prototype.getCertificate = async function (fqdn, domain, acmeCertificatePaths) { assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof paths, 'object'); + assert.strictEqual(typeof acmeCertificatePaths, 'object'); debug(`getCertificate: start acme flow for ${fqdn} from ${this.caDirectory}`); @@ -535,19 +536,19 @@ Acme2.prototype.getCertificate = async function (fqdn, domain, paths) { } await this.loadDirectory(); - await this.acmeFlow(fqdn, domain, paths); + await this.acmeFlow(fqdn, domain, acmeCertificatePaths); }; -async function getCertificate(fqdn, domain, paths, options) { +async function getCertificate(fqdn, domain, acmeCertificatePaths, options) { assert.strictEqual(typeof fqdn, 'string'); // this can also be a wildcard domain (for alias domains) assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof paths, 'object'); + assert.strictEqual(typeof acmeCertificatePaths, 'object'); assert.strictEqual(typeof options, 'object'); await promiseRetry({ times: 3, interval: 0, debug }, async function () { debug(`getCertificate: for fqdn ${fqdn} and domain ${domain}`); const acme = new Acme2(options || { }); - return await acme.getCertificate(fqdn, domain, paths); + return await acme.getCertificate(fqdn, domain, acmeCertificatePaths); }); } diff --git a/src/apps.js b/src/apps.js index 29a5b2ba3..185a2705a 100644 --- a/src/apps.js +++ b/src/apps.js @@ -1067,13 +1067,6 @@ async function clear() { await database.query('DELETE FROM apps'); } -async function getDomainObjectMap() { - const domainObjects = await domains.list(); - let domainObjectMap = {}; - for (let d of domainObjects) { domainObjectMap[d.domain] = d; } - return domainObjectMap; -} - // each query simply join apps table with another table by id. we then join the full result together const PB_QUERY = 'SELECT id, GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes FROM apps LEFT JOIN appPortBindings ON apps.id = appPortBindings.appId GROUP BY apps.id'; const ENV_QUERY = 'SELECT id, JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues FROM apps LEFT JOIN appEnvVars ON apps.id = appEnvVars.appId GROUP BY apps.id'; @@ -1088,7 +1081,7 @@ const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariab async function get(id) { assert.strictEqual(typeof id, 'string'); - const domainObjectMap = await getDomainObjectMap(); + const domainObjectMap = await domains.getDomainObjectMap(); const result = await database.query(`${APPS_QUERY} WHERE apps.id = ?`, [ id ]); if (result.length === 0) return null; @@ -1103,7 +1096,7 @@ async function get(id) { async function getByIpAddress(ip) { assert.strictEqual(typeof ip, 'string'); - const domainObjectMap = await getDomainObjectMap(); + const domainObjectMap = await domains.getDomainObjectMap(); const result = await database.query(`${APPS_QUERY} WHERE apps.containerIp = ?`, [ ip ]); if (result.length === 0) return null; @@ -1114,7 +1107,7 @@ async function getByIpAddress(ip) { } async function list() { - const domainObjectMap = await getDomainObjectMap(); + const domainObjectMap = await domains.getDomainObjectMap(); const results = await database.query(`${APPS_QUERY} ORDER BY apps.id`, [ ]); results.forEach(postProcess); @@ -1290,7 +1283,7 @@ function checkAppState(app, state) { async function validateLocations(locations) { assert(Array.isArray(locations)); - const domainObjectMap = await getDomainObjectMap(); + const domainObjectMap = await domains.getDomainObjectMap(); for (let location of locations) { if (!(location.domain in domainObjectMap)) throw new BoxError(BoxError.BAD_FIELD, `No such domain in ${location.type} location`); diff --git a/src/cloudron.js b/src/cloudron.js index af07b1a59..ddb81dcba 100644 --- a/src/cloudron.js +++ b/src/cloudron.js @@ -113,7 +113,9 @@ 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); }); @@ -323,7 +325,7 @@ 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, domainObject), domain, auditSource); + await reverseProxy.ensureCertificate(dns.fqdn(subdomain, domainObject), domainObject, {}, auditSource); } async function syncDnsRecords(options) { diff --git a/src/directoryserver.js b/src/directoryserver.js index 3f8831c14..07bc89a73 100644 --- a/src/directoryserver.js +++ b/src/directoryserver.js @@ -4,6 +4,8 @@ exports = module.exports = { start, stop, + handleCertChanged, + validateConfig, applyConfig }; @@ -369,3 +371,8 @@ async function stop() { gServer.close(); gServer = null; } + +async function handleCertChanged() { + await stop(); + await start(); +} diff --git a/src/domains.js b/src/domains.js index cccb090a7..ee4149499 100644 --- a/src/domains.js +++ b/src/domains.js @@ -9,6 +9,8 @@ module.exports = exports = { del, clear, + getDomainObjectMap, + removePrivateFields, removeRestrictedFields, }; @@ -306,3 +308,10 @@ function removeRestrictedFields(domain) { return result; } + +async function getDomainObjectMap() { + const domainObjects = await list(); + let domainObjectMap = {}; + for (let d of domainObjects) { domainObjectMap[d.domain] = d; } + return domainObjectMap; +} diff --git a/src/reverseproxy.js b/src/reverseproxy.js index a95584c90..77bf4f186 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -26,7 +26,7 @@ exports = module.exports = { restoreFallbackCertificates, // exported for testing - _getAcmeApi: getAcmeApi + _getAcmeApiOptions: getAcmeApiOptions }; const acme2 = require('./acme2.js'), @@ -37,6 +37,7 @@ const acme2 = require('./acme2.js'), constants = require('./constants.js'), crypto = require('crypto'), debug = require('debug')('box:reverseproxy'), + directoryServer = require('./directoryserver.js'), dns = require('./dns.js'), domains = require('./domains.js'), ejs = require('ejs'), @@ -63,7 +64,7 @@ function nginxLocation(s) { return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex } -async function getAcmeApi(domainObject) { +async function getAcmeApiOptions(domainObject) { assert.strictEqual(typeof domainObject, 'object'); const apiOptions = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' }; @@ -78,7 +79,7 @@ async function getAcmeApi(domainObject) { const [error, owner] = await safe(users.getOwner()); apiOptions.email = (error || !owner) ? 'webmaster@cloudron.io' : owner.email; // can error if not activated yet - return { acme2, apiOptions }; + return apiOptions; } function getExpiryDate(certFilePath) { @@ -255,89 +256,102 @@ function getUserCertificatePathSync(fqdn) { return { certFilePath, keyFilePath }; } +function getAcmeCertificateName(fqdn, domainObject) { + assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains) + assert.strictEqual(typeof domainObject, 'object'); + + if (fqdn !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN + return dns.makeWildcard(fqdn).replace('*.', '_.'); + } else { + return fqdn; + } +} + function getAcmeCertificatePathSync(fqdn, domainObject) { assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); - let certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir = paths.ACME_CHALLENGES_DIR; + const certName = getAcmeCertificateName(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`); - if (fqdn !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN - certName = dns.makeWildcard(fqdn).replace('*.', '_.'); - certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`); - keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`); - csrFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.csr`); - } else { - certName = fqdn; - certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.cert`); - keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.key`); - csrFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.csr`); - } - - return { certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir }; + return { certFilePath, keyFilePath, csrFilePath }; } async function getCertificatePath(fqdn, domain) { assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof domain, 'string'); - // 1. user cert always wins - // 2. if using fallback provider, return that cert - // 3. look for LE certs - const domainObject = await domains.get(domain); - const userPath = getUserCertificatePathSync(fqdn); // user cert always wins - if (fs.existsSync(userPath.certFilePath) && fs.existsSync(userPath.keyFilePath)) return userPath; + 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 acmePath = getAcmeCertificatePathSync(fqdn, domainObject); - if (fs.existsSync(acmePath.certFilePath) && fs.existsSync(acmePath.keyFilePath)) return acmePath; + 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 syncUserCertificate(fqdn, domainObject) { +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 null; + 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 { certFilePath, keyFilePath }; + return true; } -async function syncAcmeCertificate(fqdn, domainObject) { +async function getAcmeCertificate(fqdn, domainObject) { assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); - const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject); + const certName = getAcmeCertificateName(fqdn, domainObject); + const privateKey = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`); + const cert = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.cert`); + + if (!privateKey || !cert) return null; + + return { privateKey, cert }; +} + +async function writeAcmeCertificate(fqdn, domainObject) { + assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains) + assert.strictEqual(typeof domainObject, 'object'); + + const certName = getAcmeCertificateName(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`); - if (!privateKey || !cert) return null; + if (!privateKey || !cert) return false; + 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); - return { certFilePath, keyFilePath }; + return true; } async function updateCertBlobs(fqdn, domainObject) { assert.strictEqual(typeof fqdn, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); - const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject); + const { certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(fqdn, domainObject); const privateKey = safe.fs.readFileSync(keyFilePath); if (!privateKey) throw new BoxError(BoxError.FS_ERROR, `Failed to read private key: ${safe.error.message}`); @@ -348,62 +362,81 @@ async function updateCertBlobs(fqdn, domainObject) { const csr = safe.fs.readFileSync(csrFilePath); if (!csr) throw new BoxError(BoxError.FS_ERROR, `Failed to read csr: ${safe.error.message}`); + const certName = getAcmeCertificateName(fqdn, domainObject); await blobs.set(`${blobs.CERT_PREFIX}-${certName}.key`, privateKey); await blobs.set(`${blobs.CERT_PREFIX}-${certName}.cert`, cert); await blobs.set(`${blobs.CERT_PREFIX}-${certName}.csr`, csr); } -async function ensureCertificate(subdomain, domain, auditSource) { - assert.strictEqual(typeof subdomain, 'string'); - assert.strictEqual(typeof domain, 'string'); +async function needsRenewal(fqdn, domainObject) { + const apiOptions = await getAcmeApiOptions(domainObject); + const { certFilePath } = getAcmeCertificatePathSync(fqdn, domainObject); + + const notAfter = getExpiryDate(certFilePath); + const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month + if (!isExpiring && providerMatchesSync(domainObject, certFilePath, apiOptions)) return false; + debug(`needsRenewal: ${certFilePath} cert requires renewal`); + return true; +} + +async function renewCert(fqdn, domainObject) { + const apiOptions = await getAcmeApiOptions(domainObject); + const acmePaths = getAcmeCertificatePathSync(fqdn, domainObject); + + const [error] = await safe(acme2.getCertificate(fqdn, domainObject.domain, acmePaths, apiOptions)); + 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 { + await safe(updateCertBlobs(fqdn, domainObject)); + } + + if (settings.mailFqdn() === fqdn) { + debug('renewCert: restarting mail container'); + const [restartError] = await safe(mail.handleCertChanged()); + if (restartError) debug(`renewCert: error restarting mail container on cert change: ${restartError.message}`); + } + + if (settings.dashboardFqdn() === fqdn) { + debug('renewCert: restarting directory server'); + const [restartError] = await safe(directoryServer.handleCertChanged()); + if (restartError) debug(`renewCert: error restarting directory server on cert change: ${restartError.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'); assert.strictEqual(typeof auditSource, 'object'); - const domainObject = await domains.get(domain); - - const userCertificatePath = await syncUserCertificate(subdomain, domainObject); - if (userCertificatePath) return { certificatePath: userCertificatePath, renewed: false }; + if (await writeUserCertificate(fqdn, domainObject)) { + debug(`ensureCertificate: ${fqdn} will use user certs`); + return; + } if (domainObject.tlsConfig.provider === 'fallback') { - debug(`ensureCertificate: ${subdomain} will use fallback certs`); - - return { certificatePath: getFallbackCertificatePathSync(domain), renewed: false }; + debug(`ensureCertificate: ${fqdn} will use fallback certs`); + return; } - const { acme2, apiOptions } = await getAcmeApi(domainObject); - let notAfter = null; + let renewal = false; - const [, acmeCertificatePath] = await safe(syncAcmeCertificate(subdomain, domainObject)); - if (acmeCertificatePath) { - debug(`ensureCertificate: ${subdomain} certificate already exists at ${acmeCertificatePath.keyFilePath}`); - notAfter = getExpiryDate(acmeCertificatePath.certFilePath); - const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month - if (!isExpiring && providerMatchesSync(domainObject, acmeCertificatePath.certFilePath, apiOptions)) return { certificatePath: acmeCertificatePath, renewed: false }; - debug(`ensureCertificate: ${subdomain} cert requires renewal`); + if (await writeAcmeCertificate(fqdn, domainObject)) { + if (!await needsRenewal(fqdn, domainObject)) return; + renewal = true; + debug(`ensureCertificate: ${fqdn} cert requires renewal`); } else { - debug(`ensureCertificate: ${subdomain} cert does not exist`); + debug(`ensureCertificate: ${fqdn} cert does not exist`); } - debug(`ensureCertificate: getting certificate for ${subdomain} with options ${JSON.stringify(apiOptions)}`); + if (options.skipRenewal) return; - const acmePaths = getAcmeCertificatePathSync(subdomain, domainObject); - const [error] = await safe(acme2.getCertificate(subdomain, domain, acmePaths, apiOptions)); - debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${acmePaths.certFilePath || 'null'}`); + const [renewError] = await safe(renewCert(fqdn, domainObject)); + debug(`ensureCertificate: error: ${renewError ? renewError.message : 'null'}`); - await safe(eventlog.add(acmeCertificatePath ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: subdomain, errorMessage: error ? error.message : '', notAfter })); - - if (error && acmeCertificatePath && (notAfter - new Date() > 0)) { // still some life left in this certificate - debug('ensureCertificate: continue using existing certificate since renewal failed'); - return { certificatePath: acmeCertificatePath, renewed: false }; - } - - if (!error) { - const [updateCertError] = await safe(updateCertBlobs(subdomain, domainObject)); - if (!updateCertError) return { certificatePath: { certFilePath: acmePaths.certFilePath, keyFilePath: acmePaths.keyFilePath }, renewed: true }; - } - - debug(`ensureCertificate: renewal of ${subdomain} failed. using fallback certificates for ${domain}`); - - return { certificatePath: getFallbackCertificatePathSync(domain), renewed: false }; + await safe(eventlog.add(renewal ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: renewError?.message || '' })); } async function writeDashboardNginxConfig(fqdn, certificatePath) { @@ -535,14 +568,11 @@ async function writeAppNginxConfig(app, fqdn, type, certificatePath) { async function writeAppConfigs(app) { assert.strictEqual(typeof app, 'object'); - const appDomains = [{ 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 }; })); + const locations = appLocationsSync(app); - for (const appDomain of appDomains) { - const certificatePath = await getCertificatePath(appDomain.fqdn, appDomain.domain); - await writeAppNginxConfig(app, appDomain.fqdn, appDomain.type, certificatePath); + for (const location of locations) { + const certificatePath = await getCertificatePath(location.fqdn, location.domain); + await writeAppNginxConfig(app, location.fqdn, location.type, certificatePath); } } @@ -560,17 +590,26 @@ async function setUserCertificate(app, fqdn, certificate) { await writeAppConfigs(app); } +function appLocationsSync(app) { + assert.strictEqual(typeof app, 'object'); + + const locations = [{ 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 }; })); + + return locations; +} + async function configureApp(app, auditSource) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof auditSource, 'object'); - const appDomains = [{ domain: app.domain, fqdn: app.fqdn }] - .concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, fqdn: sd.fqdn }; })) - .concat(app.redirectDomains.map(rd => { return { domain: rd.domain, fqdn: rd.fqdn }; })) - .concat(app.aliasDomains.map(ad => { return { domain: ad.domain, fqdn: ad.fqdn }; })); + const locations = await appLocationsSync(app); + const domainObjectMap = await domains.getDomainObjectMap(); - for (const appDomain of appDomains) { - await ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource); + for (const location of locations) { + await ensureCertificate(location.fqdn, domainObjectMap[location.domain], {}, auditSource); } await writeAppConfigs(app); @@ -596,66 +635,48 @@ async function renewCerts(options, auditSource, progressCallback) { 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 appDomains = []; - - // add webadmin and mail domain - if (settings.mailFqdn() === settings.dashboardFqdn()) { - appDomains.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), type: 'webadmin+mail', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.dashboardFqdn()}.conf`) }); - } else { - appDomains.push({ domain: settings.dashboardDomain(), fqdn: settings.dashboardFqdn(), type: 'webadmin', nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, `${settings.dashboardFqdn()}.conf`) }); - appDomains.push({ domain: settings.mailDomain(), fqdn: settings.mailFqdn(), type: 'mail' }); - } + 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 - - appDomains = appDomains.concat([{ app, domain: app.domain, fqdn: app.fqdn, type: apps.LOCATION_TYPE_PRIMARY, nginxConfigFilename: getNginxConfigFilename(app, app.fqdn, apps.LOCATION_TYPE_PRIMARY) }]) - .concat(app.secondaryDomains.map(sd => { return { app, domain: sd.domain, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY, nginxConfigFilename: getNginxConfigFilename(app, sd.fqdn, apps.LOCATION_TYPE_SECONDARY) }; })) - .concat(app.redirectDomains.map(rd => { return { app, domain: rd.domain, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT, nginxConfigFilename: getNginxConfigFilename(app, rd.fqdn, apps.LOCATION_TYPE_REDIRECT) }; })) - .concat(app.aliasDomains.map(ad => { return { app, domain: ad.domain, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS, nginxConfigFilename: getNginxConfigFilename(app, ad.fqdn, apps.LOCATION_TYPE_ALIAS) }; })); + locations = locations.concat(appLocationsSync(app)); } - if (options.domain) appDomains = appDomains.filter(function (appDomain) { return appDomain.domain === options.domain; }); + let percent = 1, renewedCertificateNames = []; + for (const location of locations) { + percent += Math.round(100/locations.length); + progressCallback({ percent, message: `Ensuring certs of ${location.fqdn}` }); - let progress = 1, renewedCerts = []; + const domainObject = domainObjectMap[location.domain]; + if (location.certificate?.key && location.certificate?.cert) continue; // user cert + if (domainObject.tlsConfig.provider === 'fallback') continue; // fallback certs - for (const appDomain of appDomains) { - progressCallback({ percent: progress, message: `Ensuring certs of ${appDomain.fqdn}` }); - progress += Math.round(100/appDomains.length); - - const { certificatePath, renewed } = await ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource); - - if (renewed) renewedCerts.push(appDomain.fqdn); - - if (appDomain.type === 'mail') continue; // 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(certificatePath.certFilePath)) continue; - - debug(`renewCerts: creating new nginx config since ${appDomain.nginxConfigFilename} does not have ${certificatePath.certFilePath}`); - - // reconfigure since the cert changed - if (appDomain.type === 'webadmin' || appDomain.type === 'webadmin+mail') { - await writeDashboardNginxConfig(settings.dashboardFqdn(), certificatePath); + if (await needsRenewal(location.fqdn, domainObject)) { + await renewCert(location.fqdn, domainObject); + renewedCertificateNames.push(getAcmeCertificateName(location.fqdn, domainObject)); } else { - await writeAppNginxConfig(appDomain.app, appDomain.fqdn, appDomain.type, certificatePath); + progressCallback({ percent, message: `Cert of ${location.fqdn} does not require renewal` }); } } - debug(`renewCerts: Renewed certs of ${JSON.stringify(renewedCerts)}`); - if (renewedCerts.length === 0) return; + if (renewedCertificateNames.length === 0) return; - if (renewedCerts.includes(settings.mailFqdn())) await mail.handleCertChanged(); + progressCallback({ message: `Reloading nginx after renewing ${JSON.stringify(renewedCertificateNames)}` }); - await reload(); // reload nginx if any certs were updated but the config was not rewritten + await reload(); - // restart tls apps on cert change - const tlsApps = allApps.filter(app => app.manifest.addons && app.manifest.addons.tls && renewedCerts.includes(app.fqdn)); - for (const app of tlsApps) { - await apps.restart(app, auditSource); + for (const app of allApps) { + if (!app.manifest.addons?.tls) continue; + const addonCertificateName = getAcmeCertificateName(app.fqdn, domainObjectMap[app.domain]); + if (renewedCertificateNames.includes(addonCertificateName)) await apps.restart(app, auditSource); } } @@ -663,11 +684,28 @@ async function cleanupCerts(auditSource, progressCallback) { 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(appLocationsSync(app)); + } + + const certsInUse = []; + for (const location of locations) { + const certificatePath = await getCertificatePath(location.fqdn, location.domain); + certsInUse.push(path.basename(certificatePath.certFilePath)); + } + const filenames = await fs.promises.readdir(paths.NGINX_CERT_DIR); - const certFilenames = filenames.filter(f => f.endsWith('.cert')); + const certFilenames = filenames.filter(f => f.endsWith('.cert') && !f.endsWith('.user.cert') && !f.endsWith('host.cert') && !certsInUse.includes(f)); const now = new Date(); - progressCallback({ message: 'Checking expired certs for removal' }); + debug(`cleanupCerts: considering ${JSON.stringify(certFilenames)} for cleanup`); const fqdns = []; diff --git a/src/scripts/restartservice.sh b/src/scripts/restartservice.sh index 96b55d5a0..0ebed32a4 100755 --- a/src/scripts/restartservice.sh +++ b/src/scripts/restartservice.sh @@ -25,7 +25,11 @@ if [[ "${service}" == "unbound" ]]; then unbound-anchor -a /var/lib/unbound/root.key systemctl restart --no-block unbound elif [[ "${service}" == "nginx" ]]; then - nginx -s reload + if systemctl -q is-active nginx; then + nginx -s reload + else + systemctl restart --no-block nginx + fi elif [[ "${service}" == "docker" ]]; then systemctl restart --no-block docker elif [[ "${service}" == "collectd" ]]; then diff --git a/src/test/reverseproxy-test.js b/src/test/reverseproxy-test.js index 72f39017b..28acfcbd6 100644 --- a/src/test/reverseproxy-test.js +++ b/src/test/reverseproxy-test.js @@ -13,7 +13,7 @@ const common = require('./common.js'), reverseProxy = require('../reverseproxy.js'); describe('Reverse Proxy', function () { - const { setup, cleanup, domain, auditSource, app } = common; + const { setup, cleanup, domain, auditSource, app, admin } = common; const domainCopy = Object.assign({}, domain); before(setup); @@ -147,9 +147,9 @@ describe('Reverse Proxy', function () { }); it('returns prod acme in prod cloudron', async function () { - const { acme2, apiOptions } = await reverseProxy._getAcmeApi(domainCopy); - expect(acme2._name).to.be('acme'); + const apiOptions = await reverseProxy._getAcmeApiOptions(domainCopy); expect(apiOptions.prod).to.be(true); + expect(apiOptions.email).to.be(admin.email); }); }); @@ -161,9 +161,9 @@ describe('Reverse Proxy', function () { }); it('returns staging acme in prod cloudron', async function () { - const { acme2, apiOptions } = await reverseProxy._getAcmeApi(domainCopy); - expect(acme2._name).to.be('acme'); + const apiOptions = await reverseProxy._getAcmeApiOptions(domainCopy); expect(apiOptions.prod).to.be(false); + expect(apiOptions.email).to.be(admin.email); }); });