'use strict'; exports = module.exports = { setUserCertificate, // per location certificate setFallbackCertificate, // per domain certificate generateFallbackCertificate, validateCertificate, getCertificatePath, // resolved cert path ensureCertificate, checkCerts, // the 'configure' functions ensure a certificate and generate nginx config configureApp, unconfigureApp, // these only generate nginx config writeDefaultConfig, writeDashboardConfig, writeAppConfigs, removeAppConfigs, restoreFallbackCertificates, // exported for testing _getAcmeApiOptions: getAcmeApiOptions }; const acme2 = require('./acme2.js'), apps = require('./apps.js'), assert = require('assert'), blobs = require('./blobs.js'), BoxError = require('./boxerror.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'), eventlog = require('./eventlog.js'), fs = require('fs'), mail = require('./mail.js'), os = require('os'), path = require('path'), paths = require('./paths.js'), safe = require('safetydance'), settings = require('./settings.js'), shell = require('./shell.js'), sysinfo = require('./sysinfo.js'), users = require('./users.js'), util = require('util'); const NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' }); const RESTART_SERVICE_CMD = path.join(__dirname, 'scripts/restartservice.sh'); function nginxLocation(s) { if (!s.startsWith('!')) return s; const 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 } async function getAcmeApiOptions(domainObject) { assert.strictEqual(typeof domainObject, 'object'); const apiOptions = { prod: false, performHttpAuthorization: false, wildcard: false, email: '' }; apiOptions.prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod' apiOptions.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null; apiOptions.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 const [error, owner] = await safe(users.getOwner()); apiOptions.email = (error || !owner) ? 'webmaster@cloudron.io' : owner.email; // can error if not activated yet return apiOptions; } function getExpiryDate(certFilePath) { assert.strictEqual(typeof certFilePath, 'string'); if (!fs.existsSync(certFilePath)) return null; // not found const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-enddate', '-noout', '-in', certFilePath ]); if (!result) return null; // some error const notAfter = result.stdout.toString('utf8').trim().split('=')[1]; const notAfterDate = new Date(notAfter); const daysLeft = (notAfterDate - new Date())/(24 * 60 * 60 * 1000); debug(`expiryDate: ${certFilePath} notAfter=${notAfter} daysLeft=${daysLeft}`); return notAfterDate; } async function isOcspEnabled(certFilePath) { // on some servers, OCSP does not work. see #796 const config = await settings.getReverseProxyConfig(); if (!config.ocsp) return false; // We used to check for the must-staple in the cert using openssl x509 -text -noout -in ${certFilePath} | grep -q status_request // however, we cannot set the must-staple because first request to nginx fails because of it's OCSP caching behavior const result = safe.child_process.execSync(`openssl x509 -in ${certFilePath} -noout -ocsp_uri`, { encoding: 'utf8' }); return result && result.length > 0; // no error and has uri } // 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') && !issuer.includes('STAGING'); 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(subdomain, domainObject, certificate) { assert.strictEqual(typeof subdomain, 'string'); assert.strictEqual(typeof domainObject, 'object'); assert(certificate && typeof certificate, 'object'); const { cert, key } = certificate; // check for empty cert and key strings if (!cert && key) return new BoxError(BoxError.BAD_FIELD, 'missing cert'); if (cert && !key) return new BoxError(BoxError.BAD_FIELD, 'missing key'); // -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN. const fqdn = dns.fqdn(subdomain, 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); if (result.indexOf('does match certificate') === -1) return new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`); // 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 certificate: ${safe.error.message}`); 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}`); if (pubKeyFromCert !== pubKeyFromKey) return new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.'); // 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.'); return null; } async function reload() { if (constants.TEST) return; const [error] = await safe(shell.promises.sudo('reload', [ RESTART_SERVICE_CMD, 'nginx' ], {})); if (error) throw new BoxError(BoxError.NGINX_ERROR, `Error reloading nginx: ${error.message}`); } // this is used in migration - 20211006200150-domains-ensure-fallbackCertificate.js async function generateFallbackCertificate(domain) { assert.strictEqual(typeof domain, 'string'); 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`); const 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; const cn = domain; debug(`generateFallbackCertificate: domain=${domain} cn=${cn}`); opensslConfWithSan = `${opensslConf}\n[SAN]\nsubjectAltName=DNS:${domain},DNS:*.${cn}\n`; const 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) const 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)) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error.message); safe.fs.unlinkSync(configFile); const cert = safe.fs.readFileSync(certFilePath, 'utf8'); if (!cert) throw new BoxError(BoxError.FS_ERROR, safe.error.message); safe.fs.unlinkSync(certFilePath); const key = safe.fs.readFileSync(keyFilePath, 'utf8'); if (!key) throw new BoxError(BoxError.FS_ERROR, safe.error.message); safe.fs.unlinkSync(keyFilePath); return { cert, key }; } async function setFallbackCertificate(domain, certificate) { assert.strictEqual(typeof domain, 'string'); assert(certificate && typeof certificate === 'object'); debug(`setFallbackCertificate: setting certs for domain ${domain}`); 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(); } async function restoreFallbackCertificates() { const result = await domains.list(); for (const domain of result) { if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain.domain}.host.cert`), domain.fallbackCertificate.cert)) throw new BoxError(BoxError.FS_ERROR, safe.error.message); if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain.domain}.host.key`), domain.fallbackCertificate.key)) throw new BoxError(BoxError.FS_ERROR, safe.error.message); } } function getFallbackCertificatePathSync(domain) { assert.strictEqual(typeof domain, 'string'); 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 }; } 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'); 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`); return { certFilePath, keyFilePath, csrFilePath }; } async function getCertificatePath(fqdn, domain) { assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof domain, 'string'); const domainObject = await domains.get(domain); 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'); 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 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 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 { 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}`); const cert = safe.fs.readFileSync(certFilePath); if (!cert) throw new BoxError(BoxError.FS_ERROR, `Failed to read cert: ${safe.error.message}`); 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 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'); if (await writeUserCertificate(fqdn, domainObject)) { debug(`ensureCertificate: ${fqdn} will use user certs`); return; } if (domainObject.tlsConfig.provider === 'fallback') { debug(`ensureCertificate: ${fqdn} will use fallback certs`); return; } let renewal = false; 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 (options.skipRenewal) return; 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 || '' })); } async function writeDashboardNginxConfig(fqdn, certificatePath) { assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof certificatePath, 'object'); const data = { sourceDir: path.resolve(__dirname, '..'), vhost: fqdn, hasIPv6: sysinfo.hasIPv6(), endpoint: 'dashboard', certFilePath: certificatePath.certFilePath, keyFilePath: certificatePath.keyFilePath, robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'), proxyAuth: { enabled: false, id: null, location: nginxLocation('/') }, ocsp: await isOcspEnabled(certificatePath.certFilePath) }; const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${fqdn}.conf`); if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) throw new BoxError(BoxError.FS_ERROR, safe.error); await reload(); } async function writeDashboardConfig(domainObject) { assert.strictEqual(typeof domainObject, 'object'); debug(`writeDashboardConfig: writing admin config for ${domainObject.domain}`); const dashboardFqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domainObject); const certificatePath = await getCertificatePath(dashboardFqdn, domainObject.domain); await writeDashboardNginxConfig(dashboardFqdn, certificatePath); } function getNginxConfigFilename(app, fqdn, type) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof type, 'string'); let nginxConfigFilenameSuffix = ''; if (type === apps.LOCATION_TYPE_ALIAS) { nginxConfigFilenameSuffix = `-alias-${fqdn.replace('*', '_')}`; } else if (type === apps.LOCATION_TYPE_SECONDARY) { nginxConfigFilenameSuffix = `-secondary-${fqdn}`; } else if (type === apps.LOCATION_TYPE_REDIRECT) { nginxConfigFilenameSuffix = `-redirect-${fqdn}`; } return path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}${nginxConfigFilenameSuffix}.conf`); } async function writeAppNginxConfig(app, fqdn, type, certificatePath) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof type, 'string'); assert.strictEqual(typeof certificatePath, 'object'); const data = { sourceDir: path.resolve(__dirname, '..'), vhost: fqdn, hasIPv6: sysinfo.hasIPv6(), ip: null, port: null, endpoint: null, redirectTo: null, certFilePath: certificatePath.certFilePath, keyFilePath: certificatePath.keyFilePath, robotsTxtQuoted: null, cspQuoted: null, hideHeaders: [], proxyAuth: { enabled: false }, upstreamUri: '', // only for endpoint === external ocsp: await isOcspEnabled(certificatePath.certFilePath) }; if (type === apps.LOCATION_TYPE_PRIMARY || type === apps.LOCATION_TYPE_ALIAS || type === apps.LOCATION_TYPE_SECONDARY) { data.endpoint = 'app'; if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) { data.endpoint = 'external'; // prevent generating invalid nginx configs if (!app.upstreamUri) throw new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty'); data.upstreamUri = app.upstreamUri; } // maybe these should become per domain at some point const reverseProxyConfig = app.reverseProxyConfig || {}; // some of our code uses fake app objects if (reverseProxyConfig.robotsTxt) data.robotsTxtQuoted = JSON.stringify(app.reverseProxyConfig.robotsTxt); if (reverseProxyConfig.csp) { data.cspQuoted = `"${app.reverseProxyConfig.csp}"`; data.hideHeaders = [ 'Content-Security-Policy' ]; if (reverseProxyConfig.csp.includes('frame-ancestors ')) data.hideHeaders.push('X-Frame-Options'); } if (type === apps.LOCATION_TYPE_PRIMARY || type == apps.LOCATION_TYPE_ALIAS) { data.proxyAuth = { enabled: app.sso && app.manifest.addons && app.manifest.addons.proxyAuth, id: app.id, location: nginxLocation(safe.query(app.manifest, 'addons.proxyAuth.path') || '/') }; data.ip = app.containerIp; data.port = app.manifest.httpPort; } else if (type === apps.LOCATION_TYPE_SECONDARY) { data.ip = app.containerIp; const secondaryDomain = app.secondaryDomains.find(sd => sd.fqdn === fqdn); data.port = app.manifest.httpPorts[secondaryDomain.environmentVariable].containerPort; } } else if (type === apps.LOCATION_TYPE_REDIRECT) { data.proxyAuth = { enabled: false, id: app.id, location: nginxLocation('/') }; data.endpoint = 'redirect'; data.redirectTo = app.fqdn; } const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); const filename = getNginxConfigFilename(app, fqdn, type); debug(`writeAppNginxConfig: writing config for "${fqdn}" 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); } await reload(); } async function writeAppConfigs(app) { assert.strictEqual(typeof app, 'object'); const locations = appLocationsSync(app); for (const location of locations) { const certificatePath = await getCertificatePath(location.fqdn, location.domain); await writeAppNginxConfig(app, location.fqdn, location.type, certificatePath); } } async function setUserCertificate(app, fqdn, certificate) { 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); } 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 locations = await appLocationsSync(app); const domainObjectMap = await domains.getDomainObjectMap(); for (const location of locations) { await ensureCertificate(location.fqdn, domainObjectMap[location.domain], {}, auditSource); } await writeAppConfigs(app); } async function unconfigureApp(app) { assert.strictEqual(typeof app, 'object'); const configFilenames = safe.fs.readdirSync(paths.NGINX_APPCONFIG_DIR); if (!configFilenames) throw new BoxError(BoxError.FS_ERROR, `Error loading nginx config files: ${safe.error.message}`); for (const filename of configFilenames) { if (!filename.startsWith(app.id)) continue; safe.fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, filename)); } await reload(); } async function renewCerts(options, auditSource, progressCallback) { assert.strictEqual(typeof options, 'object'); 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(appLocationsSync(app)); } let percent = 1, renewedCertificateNames = []; 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(getAcmeCertificateName(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 = getAcmeCertificateName(app.fqdn, domainObjectMap[app.domain]); if (renewedCertificateNames.includes(addonCertificateName)) await apps.restart(app, auditSource); } } 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 = [ 'default.cert' ]; 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') && !f.endsWith('.user.cert') && !f.endsWith('host.cert') && !certsInUse.includes(f)); const now = new Date(); 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); 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}` }); // 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`)); await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.key`); await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.cert`); await blobs.del(`${blobs.CERT_PREFIX}-${fqdn}.csr`); fqdns.push(fqdn); } } if (fqdns.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: fqdns })); debug('cleanupCerts: done'); } async function checkCerts(options, auditSource, progressCallback) { assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof progressCallback, 'function'); await renewCerts(options, auditSource, progressCallback); await cleanupCerts(auditSource, progressCallback); } function removeAppConfigs() { const dashboardConfigFilename = `${settings.dashboardFqdn()}.conf`; debug('removeAppConfigs: reomving nginx configs of apps'); // remove all configs which are not the default or current dashboard for (const appConfigFile of fs.readdirSync(paths.NGINX_APPCONFIG_DIR)) { if (appConfigFile !== constants.NGINX_DEFAULT_CONFIG_FILE_NAME && appConfigFile !== dashboardConfigFilename) { fs.unlinkSync(path.join(paths.NGINX_APPCONFIG_DIR, appConfigFile)); } } } async function writeDefaultConfig(options) { assert.strictEqual(typeof options, 'object'); 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}`); throw new BoxError(BoxError.OPENSSL_ERROR, safe.error); } } const data = { sourceDir: path.resolve(__dirname, '..'), 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('/') }, ocsp: false // self-signed cert }; 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)) throw new BoxError(BoxError.FS_ERROR, safe.error); await reload(); }