'use strict'; exports = module.exports = { setAppCertificate, setFallbackCertificate, generateFallbackCertificate, validateCertificate, getCertificatePath, ensureCertificate, checkCerts, // the 'configure' ensure a certificate and generate nginx config configureApp, unconfigureApp, // these only generate nginx config writeDefaultConfig, writeDashboardConfig, writeAppConfig, removeAppConfigs, restoreFallbackCertificates, // exported for testing _getAcmeApi: getAcmeApi }; 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'), 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; let re = s.replace(/[\^$\\.*+?()[\]{}|]/g, '\\$&'); // https://github.com/es-shims/regexp.escape/blob/master/implementation.js return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex } async function getAcmeApi(domainObject) { assert.strictEqual(typeof domainObject, 'object'); const acmeApi = acme2; let 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 { acmeApi, 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(location, domainObject, certificate) { assert.strictEqual(typeof location, 'string'); assert.strictEqual(typeof domainObject, 'object'); assert(certificate && typeof certificate, 'object'); const cert = certificate.cert, key = certificate.key; // check for empty cert and key strings if (!cert && key) return new BoxError(BoxError.BAD_FIELD, 'missing cert'); 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(location, domainObject); let result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${fqdn}"`, { encoding: 'utf8', input: cert }); if (result === null) return new BoxError(BoxError.BAD_FIELD, 'Unable to get certificate subject:' + safe.error.message); 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(`generateFallbackCertificateSync: 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, fallback) { assert.strictEqual(typeof domain, 'string'); assert(fallback && typeof fallback === 'object'); assert.strictEqual(typeof fallback, 'object'); debug(`setFallbackCertificate: setting certs for domain ${domain}`); if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`), fallback.cert)) throw new BoxError(BoxError.FS_ERROR, safe.error.message); if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`), fallback.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 getAppCertificatePathSync(vhost) { assert.strictEqual(typeof vhost, 'string'); const certFilePath = path.join(paths.NGINX_CERT_DIR, `${vhost}.user.cert`); const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${vhost}.user.key`); return { certFilePath, keyFilePath }; } function getAcmeCertificatePathSync(vhost, domainObject) { assert.strictEqual(typeof vhost, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); let certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir = paths.ACME_CHALLENGES_DIR; if (vhost !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN certName = dns.makeWildcard(vhost).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 = vhost; certFilePath = path.join(paths.NGINX_CERT_DIR, `${vhost}.cert`); keyFilePath = path.join(paths.NGINX_CERT_DIR, `${vhost}.key`); csrFilePath = path.join(paths.NGINX_CERT_DIR, `${vhost}.csr`); } return { certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir }; } async function setAppCertificate(location, domainObject, certificate) { assert.strictEqual(typeof location, 'string'); assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof certificate, 'object'); const fqdn = dns.fqdn(location, domainObject); const { certFilePath, keyFilePath } = getAppCertificatePathSync(fqdn); if (certificate.cert && certificate.key) { 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 reload(); } 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 appCertPath = getAppCertificatePathSync(fqdn); // user cert always wins if (fs.existsSync(appCertPath.certFilePath) && fs.existsSync(appCertPath.keyFilePath)) return appCertPath; if (domainObject.tlsConfig.provider === 'fallback') return getFallbackCertificatePathSync(domain); const acmeCertPath = getAcmeCertificatePathSync(fqdn, domainObject); if (fs.existsSync(acmeCertPath.certFilePath) && fs.existsSync(acmeCertPath.keyFilePath)) return acmeCertPath; return getFallbackCertificatePathSync(domain); } async function checkAppCertificate(vhost, domainObject) { assert.strictEqual(typeof vhost, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); const subdomain = vhost.substr(0, vhost.length - domainObject.domain.length - 1); const certificate = await apps.getCertificate(subdomain, domainObject.domain); if (!certificate) return null; const { certFilePath, keyFilePath } = getAppCertificatePathSync(vhost); if (!safe.fs.writeFileSync(certFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Failed to write certificate: ${safe.error.message}`); if (!safe.fs.writeFileSync(keyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Failed to write key: ${safe.error.message}`); return { certFilePath, keyFilePath }; } async function checkAcmeCertificate(vhost, domainObject) { assert.strictEqual(typeof vhost, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(vhost, 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 (!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 }; } async function updateCertBlobs(vhost, domainObject) { assert.strictEqual(typeof vhost, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(vhost, 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}`); 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(vhost, domain, auditSource) { assert.strictEqual(typeof vhost, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof auditSource, 'object'); const domainObject = await domains.get(domain); let bundle = await checkAppCertificate(vhost, domainObject); if (bundle) return { bundle, renewed: false }; if (domainObject.tlsConfig.provider === 'fallback') { debug(`ensureCertificate: ${vhost} will use fallback certs`); return { bundle: getFallbackCertificatePathSync(domain), renewed: false }; } const { acmeApi, apiOptions } = await getAcmeApi(domainObject); let notAfter = null; const [, currentBundle] = await safe(checkAcmeCertificate(vhost, domainObject)); if (currentBundle) { debug(`ensureCertificate: ${vhost} certificate already exists at ${currentBundle.keyFilePath}`); notAfter = getExpiryDate(currentBundle.certFilePath); const isExpiring = (notAfter - new Date()) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month if (!isExpiring && providerMatchesSync(domainObject, currentBundle.certFilePath, apiOptions)) return { bundle: currentBundle, renewed: false }; debug(`ensureCertificate: ${vhost} cert requires renewal`); } else { debug(`ensureCertificate: ${vhost} cert does not exist`); } debug('ensureCertificate: getting certificate for %s with options %j', vhost, apiOptions); const acmePaths = getAcmeCertificatePathSync(vhost, domainObject); let [error] = await safe(acmeApi.getCertificate(vhost, domain, acmePaths, apiOptions)); debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${acmePaths.certFilePath || 'null'}`); await safe(eventlog.add(currentBundle ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: vhost, errorMessage: error ? error.message : '', notAfter })); if (error && currentBundle && (notAfter - new Date() > 0)) { // still some life left in this certificate debug('ensureCertificate: continue using existing bundle since renewal failed'); return { bundle: currentBundle, renewed: false }; } if (!error) { [error] = await safe(updateCertBlobs(vhost, domainObject)); if (!error) return { bundle: { certFilePath: acmePaths.certFilePath, keyFilePath: acmePaths.keyFilePath }, renewed: true }; } debug(`ensureCertificate: renewal of ${vhost} failed. using fallback certificates for ${domain}`); return { bundle: getFallbackCertificatePathSync(domain), renewed: false }; } async function writeDashboardNginxConfig(vhost, bundle) { assert.strictEqual(typeof vhost, 'string'); assert.strictEqual(typeof bundle, 'object'); const data = { sourceDir: path.resolve(__dirname, '..'), vhost: vhost, hasIPv6: sysinfo.hasIPv6(), endpoint: 'dashboard', certFilePath: bundle.certFilePath, keyFilePath: bundle.keyFilePath, robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'), proxyAuth: { enabled: false, id: null, location: nginxLocation('/') }, ocsp: await isOcspEnabled(bundle.certFilePath) }; const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${vhost}.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_LOCATION, domainObject); const bundle = await getCertificatePath(dashboardFqdn, domainObject.domain); await writeDashboardNginxConfig(dashboardFqdn, bundle); } 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, bundle) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof type, 'string'); assert.strictEqual(typeof bundle, 'object'); const data = { sourceDir: path.resolve(__dirname, '..'), vhost: fqdn, hasIPv6: sysinfo.hasIPv6(), ip: null, port: null, endpoint: null, redirectTo: null, certFilePath: bundle.certFilePath, keyFilePath: bundle.keyFilePath, robotsTxtQuoted: null, cspQuoted: null, hideHeaders: [], proxyAuth: { enabled: false }, upstreamUri: '', // only for endpoint === external ocsp: await isOcspEnabled(bundle.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.RELAY_APPSTORE_ID) { data.endpoint = 'external'; data.upstreamUri = 'http://example.com'; } // 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 writeAppConfig(app) { assert.strictEqual(typeof app, 'object'); const appDomains = [{ domain: app.domain, fqdn: app.fqdn, type: apps.LOCATION_TYPE_PRIMARY }] .concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, fqdn: sd.fqdn, type: apps.LOCATION_TYPE_SECONDARY }; })) .concat(app.redirectDomains.map(rd => { return { domain: rd.domain, fqdn: rd.fqdn, type: apps.LOCATION_TYPE_REDIRECT }; })) .concat(app.aliasDomains.map(ad => { return { domain: ad.domain, fqdn: ad.fqdn, type: apps.LOCATION_TYPE_ALIAS }; })); for (const appDomain of appDomains) { const bundle = await getCertificatePath(appDomain.fqdn, appDomain.domain); await writeAppNginxConfig(app, appDomain.fqdn, appDomain.type, bundle); } } 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 }; })); for (const appDomain of appDomains) { await ensureCertificate(appDomain.fqdn, appDomain.domain, auditSource); } await writeAppConfig(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 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' }); } 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) }; })); } if (options.domain) appDomains = appDomains.filter(function (appDomain) { return appDomain.domain === options.domain; }); let progress = 1, renewedCerts = []; for (const appDomain of appDomains) { progressCallback({ percent: progress, message: `Ensuring certs of ${appDomain.fqdn}` }); progress += Math.round(100/appDomains.length); const { bundle, 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(bundle.certFilePath)) continue; debug(`renewCerts: creating new nginx config since ${appDomain.nginxConfigFilename} does not have ${bundle.certFilePath}`); // reconfigure since the cert changed if (appDomain.type === 'webadmin' || appDomain.type === 'webadmin+mail') { await writeDashboardNginxConfig(settings.dashboardFqdn(), bundle); } else { await writeAppNginxConfig(appDomain.app, appDomain.fqdn, appDomain.type, bundle); } } debug(`renewCerts: Renewed certs of ${JSON.stringify(renewedCerts)}`); if (renewedCerts.length === 0) return; if (renewedCerts.includes(settings.mailFqdn())) await mail.handleCertChanged(); await reload(); // reload nginx if any certs were updated but the config was not rewritten // 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); } } async function cleanupCerts(auditSource) { assert.strictEqual(typeof auditSource, 'object'); const filenames = await fs.promises.readdir(paths.NGINX_CERT_DIR); const certFilenames = filenames.filter(f => f.endsWith('.cert')); const now = new Date(); debug('cleanupCerts: start'); 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$/, ''); debug(`cleanupCerts: 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); } 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 (let 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(); }