ARI is a hint from the cert issuer about when to renew a cert. We will use it when the API is available. It provides a mechanim for CAs to revoke certs and signal to clients that cert should be renewed. https://www.rfc-editor.org/rfc/rfc9773.txt https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients
744 lines
30 KiB
JavaScript
744 lines
30 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
setUserCertificate, // per location certificate
|
|
setFallbackCertificate, // per domain certificate
|
|
|
|
getMailCertificate,
|
|
getDirectoryServerCertificate,
|
|
|
|
ensureCertificate,
|
|
|
|
startRenewCerts,
|
|
checkCerts,
|
|
|
|
// the 'configure' functions ensure a certificate and generate nginx config
|
|
configureApp,
|
|
unconfigureApp,
|
|
|
|
// these only generate nginx config
|
|
writeDefaultConfig,
|
|
writeDashboardConfig,
|
|
writeAppConfigs,
|
|
|
|
removeDashboardConfig,
|
|
removeAppConfigs,
|
|
restoreFallbackCertificates,
|
|
|
|
handleCertificateProviderChanged,
|
|
|
|
getTrustedIps,
|
|
setTrustedIps
|
|
};
|
|
|
|
const acme2 = require('./acme2.js'),
|
|
apps = require('./apps.js'),
|
|
assert = require('node:assert'),
|
|
blobs = require('./blobs.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
dashboard = require('./dashboard.js'),
|
|
debug = require('debug')('box:reverseproxy'),
|
|
dns = require('./dns.js'),
|
|
docker = require('./docker.js'),
|
|
domains = require('./domains.js'),
|
|
ejs = require('ejs'),
|
|
eventlog = require('./eventlog.js'),
|
|
ipaddr = require('./ipaddr.js'),
|
|
fs = require('node:fs'),
|
|
Location = require('./location.js'),
|
|
mailServer = require('./mailserver.js'),
|
|
network = require('./network.js'),
|
|
openssl = require('./openssl.js'),
|
|
path = require('node:path'),
|
|
paths = require('./paths.js'),
|
|
safe = require('safetydance'),
|
|
settings = require('./settings.js'),
|
|
shell = require('./shell.js')('reverseproxy'),
|
|
tasks = require('./tasks.js');
|
|
|
|
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
|
|
}
|
|
|
|
// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
|
|
async function providerMatches(domainObject, cert) {
|
|
assert.strictEqual(typeof domainObject, 'object');
|
|
assert.strictEqual(typeof cert, 'string');
|
|
|
|
const [error, subjectAndIssuer] = await safe(openssl.getSubjectAndIssuer(cert));
|
|
if (error) return false; // something bad happenned
|
|
|
|
const { subject, issuer } = subjectAndIssuer;
|
|
const domain = subject.substr(subject.indexOf('=') + 1).trim(); // subject can be /CN=, CN=, CN = and other forms
|
|
const isWildcardCert = domain.includes('*');
|
|
const isLetsEncryptProd = issuer.includes('Let\'s Encrypt') && !issuer.includes('STAGING');
|
|
|
|
const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
|
|
const wildcard = !!domainObject.tlsConfig.wildcard;
|
|
|
|
const issuerMismatch = (prod && !isLetsEncryptProd) || (!prod && isLetsEncryptProd);
|
|
// bare domain is not part of wildcard SAN
|
|
const wildcardMismatch = (domain !== domainObject.domain) && (wildcard && !isWildcardCert) || (!wildcard && isWildcardCert);
|
|
|
|
const mismatch = issuerMismatch || wildcardMismatch;
|
|
|
|
debug(`providerMatches: subject=${subject} domain=${domain} issuer=${issuer} `
|
|
+ `wildcard=${isWildcardCert}/${wildcard} prod=${isLetsEncryptProd}/${prod} `
|
|
+ `issuerMismatch=${issuerMismatch} wildcardMismatch=${wildcardMismatch} match=${!mismatch}`);
|
|
|
|
return !mismatch;
|
|
}
|
|
|
|
async function notifyCertChange() {
|
|
await mailServer.checkCertificate();
|
|
await shell.sudo([ RESTART_SERVICE_CMD, 'box' ], {}); // directory server
|
|
const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
|
|
for (const app of allApps) {
|
|
if (app.manifest.addons?.tls) await setupTlsAddon(app);
|
|
}
|
|
}
|
|
|
|
async function reload() {
|
|
if (constants.TEST) return;
|
|
|
|
const [error] = await safe(shell.sudo([ RESTART_SERVICE_CMD, 'nginx' ], {}));
|
|
if (error) throw new BoxError(BoxError.NGINX_ERROR, `Error reloading nginx: ${error.message}`);
|
|
}
|
|
|
|
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);
|
|
|
|
await reload();
|
|
await notifyCertChange(); // if domain uses fallback certs, propagate immediately
|
|
}
|
|
|
|
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 getAppLocationsSync(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
return [new Location(app.subdomain, app.domain, Location.TYPE_PRIMARY, app.certificate)]
|
|
.concat(app.secondaryDomains.map(sd => new Location(sd.subdomain, sd.domain, Location.TYPE_SECONDARY, sd.certificate)))
|
|
.concat(app.redirectDomains.map(rd => new Location(rd.subdomain, rd.domain, Location.TYPE_REDIRECT, rd.certificate)))
|
|
.concat(app.aliasDomains.map(ad => new Location(ad.subdomain, ad.domain, Location.TYPE_ALIAS, ad.certificate)));
|
|
}
|
|
|
|
function getAcmeCertificateNameSync(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 if (fqdn.includes('*')) { // alias domain with non-wildcard cert
|
|
return fqdn.replace('*.', '_.');
|
|
} else {
|
|
return fqdn;
|
|
}
|
|
}
|
|
|
|
async function needsRenewal(cert, renewalInfo, options) {
|
|
assert.strictEqual(typeof cert, 'string');
|
|
assert.strictEqual(typeof renewalInfo, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
const { startDate, endDate } = await openssl.getCertificateDates(cert);
|
|
const now = new Date();
|
|
|
|
// if we have ARI respect it. this allows us to be expempt from rate limit checks when "replaces" field is set
|
|
// https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-6-indicating-which-certificate-is-replaced-by-this-new-order
|
|
if (renewalInfo) {
|
|
const rt = new Date(renewalInfo.rt);
|
|
let renew = false;
|
|
if (now.getTime() >= rt.getTime()) renew = true; // renew immediately since now is in the past
|
|
else if ((now.getTime() + (24*60*60*1000)) >= rt.getTime()) renew = true; // next cron run will be in the past
|
|
debug(`needsRenewal: ${renew}. ARI ${JSON.stringify(renewalInfo)}`);
|
|
return renew; // can wait
|
|
}
|
|
|
|
// for CAs without ARI, fallback to 1 month
|
|
let isExpiring;
|
|
if (options.forceRenewal) {
|
|
isExpiring = (now - startDate) > (65 * 60 * 1000); // was renewed 5 minutes ago. LE backdates issue date by 1 hour for clock skew
|
|
} else {
|
|
isExpiring = (endDate - now) <= (30 * 24 * 60 * 60 * 1000); // expiring in a month
|
|
}
|
|
|
|
debug(`needsRenewal: ${isExpiring}. force: ${!!options.forceRenewal}`);
|
|
return isExpiring;
|
|
}
|
|
|
|
async function getCertificate(location) {
|
|
assert.strictEqual(typeof location, 'object');
|
|
|
|
const domainObject = await domains.get(location.domain);
|
|
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${location.domain} not found`);
|
|
|
|
if (location.certificate) return location.certificate;
|
|
if (domainObject.tlsConfig.provider === 'fallback') return domainObject.fallbackCertificate;
|
|
|
|
const certName = getAcmeCertificateNameSync(location.fqdn, domainObject);
|
|
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
|
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
|
|
if (!key || !cert) return domainObject.fallbackCertificate;
|
|
|
|
return { key, cert };
|
|
}
|
|
|
|
async function getMailCertificate() {
|
|
const mailLocation = await mailServer.getLocation();
|
|
return await getCertificate(mailLocation);
|
|
}
|
|
|
|
async function getDirectoryServerCertificate() {
|
|
const dashboardLocation = await dashboard.getLocation();
|
|
return await getCertificate(dashboardLocation);
|
|
}
|
|
|
|
// write if contents mismatch (thus preserving mtime)
|
|
function writeFileSync(filePath, data) {
|
|
assert.strictEqual(typeof filePath, 'string');
|
|
assert.strictEqual(typeof data, 'string');
|
|
|
|
const curData = safe.fs.readFileSync(filePath, { encoding: 'utf8' });
|
|
if (curData === data) return false;
|
|
if (!safe.fs.writeFileSync(filePath, data)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
|
return true;
|
|
}
|
|
|
|
async function setupTlsAddon(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
const certificateDir = `${paths.PLATFORM_DATA_DIR}/tls/${app.id}`;
|
|
const contents = [];
|
|
for (const location of getAppLocationsSync(app)) {
|
|
if (location.type === Location.TYPE_REDIRECT) continue;
|
|
|
|
const certificate = await getCertificate(location);
|
|
contents.push({ filename: `${location.fqdn.replace('*', '_')}.cert`, data: certificate.cert });
|
|
contents.push({ filename: `${location.fqdn.replace('*', '_')}.key`, data: certificate.key });
|
|
|
|
if (location.type === Location.TYPE_PRIMARY) { // backward compat
|
|
contents.push({ filename: 'tls_cert.pem', data: certificate.cert });
|
|
contents.push({ filename: 'tls_key.pem', data: certificate.key });
|
|
}
|
|
}
|
|
|
|
let changed = 0;
|
|
for (const content of contents) {
|
|
if (writeFileSync(`${certificateDir}/${content.filename}`, content.data)) ++changed;
|
|
}
|
|
debug(`setupTlsAddon: ${changed} files changed`);
|
|
|
|
// clean up any certs of old locations
|
|
const filenamesInUse = new Set(contents.map(c => c.filename));
|
|
const filenames = safe.fs.readdirSync(certificateDir) || [];
|
|
let removed = 0;
|
|
for (const filename of filenames) {
|
|
if (filenamesInUse.has(filename)) continue;
|
|
safe.fs.unlinkSync(path.join(certificateDir, filename));
|
|
++removed;
|
|
}
|
|
debug(`setupTlsAddon: ${removed} files removed`);
|
|
|
|
if (changed || removed) await docker.restartContainer(app.id);
|
|
}
|
|
|
|
// writes latest certificate to disk and returns the path
|
|
async function writeCertificate(location) {
|
|
assert.strictEqual(typeof location, 'object');
|
|
|
|
const { domain, fqdn } = location;
|
|
const domainObject = await domains.get(domain);
|
|
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${domain} not found`);
|
|
|
|
if (location.certificate) {
|
|
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.cert`);
|
|
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${fqdn}.user.key`);
|
|
|
|
writeFileSync(certFilePath, location.certificate.cert);
|
|
writeFileSync(keyFilePath, location.certificate.key);
|
|
|
|
return { certFilePath, keyFilePath };
|
|
}
|
|
|
|
if (domainObject.tlsConfig.provider === 'fallback') {
|
|
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.cert`);
|
|
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${domain}.host.key`);
|
|
|
|
debug(`writeCertificate: ${fqdn} will use fallback certs`);
|
|
writeFileSync(certFilePath, domainObject.fallbackCertificate.cert);
|
|
writeFileSync(keyFilePath, domainObject.fallbackCertificate.key);
|
|
|
|
return { certFilePath, keyFilePath };
|
|
}
|
|
|
|
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
|
|
let cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
|
let key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
|
|
|
|
if (!key || !cert) { // use fallback certs if we didn't manage to get acme certs
|
|
debug(`writeCertificate: ${fqdn} will use fallback certs because acme is missing`);
|
|
cert = domainObject.fallbackCertificate.cert;
|
|
key = domainObject.fallbackCertificate.key;
|
|
}
|
|
const certFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.cert`);
|
|
const keyFilePath = path.join(paths.NGINX_CERT_DIR, `${certName}.key`);
|
|
|
|
writeFileSync(certFilePath, cert);
|
|
writeFileSync(keyFilePath, key);
|
|
|
|
return { certFilePath, keyFilePath };
|
|
}
|
|
|
|
async function getKey(certName) {
|
|
assert.strictEqual(typeof certName, 'string');
|
|
|
|
const key = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.key`);
|
|
if (key) return key;
|
|
|
|
debug(`ensureKey: generating new key for ${certName}`);
|
|
// secp384r1 is same as prime256v1. openssl ecparam -list_curves. we used to use secp384r1 but it doesn't seem to be accepted by few mail servers
|
|
return await openssl.generateKey('secp256r1');
|
|
};
|
|
|
|
async function getRenewalInfo(cert, certName) {
|
|
const renewalInfo = JSON.parse(await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.renewal`));
|
|
if (!renewalInfo) return null;
|
|
|
|
if (Date.now() < (new Date(renewalInfo.valid)).getTime()) return renewalInfo; // still valid
|
|
|
|
debug(`getRenewalInfo: ${certName} refreshing`);
|
|
const [error, result] = await safe(acme2.getRenewalInfo(cert, renewalInfo.url));
|
|
if (error) {
|
|
debug(`getRenewalInfo: ${certName} error getting renewal info`, error);
|
|
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.renewal`);
|
|
} else {
|
|
debug(`getRenewalInfo: ${certName} updated: ${JSON.stringify(result)}`);
|
|
await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.renewal`, JSON.stringify(result));
|
|
}
|
|
|
|
return error ? null : result;
|
|
}
|
|
|
|
async function ensureCertificate(location, options, auditSource) {
|
|
assert.strictEqual(typeof location, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const domainObject = await domains.get(location.domain);
|
|
if (!domainObject) throw new BoxError(BoxError.NOT_FOUND, `${location.domain} not found`);
|
|
|
|
const fqdn = dns.fqdn(location.subdomain, location.domain);
|
|
|
|
if (location.certificate) { // user certificate
|
|
debug(`ensureCertificate: ${fqdn} will use user certs`);
|
|
return;
|
|
}
|
|
|
|
if (domainObject.tlsConfig.provider === 'fallback') {
|
|
debug(`ensureCertificate: ${fqdn} will use fallback certs`);
|
|
return;
|
|
}
|
|
|
|
const certName = getAcmeCertificateNameSync(fqdn, domainObject);
|
|
const key = await getKey(certName); // generates one on the fly. we only save the key in db if we end up using it
|
|
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
|
|
|
if (key && cert) {
|
|
const sameProvider = await providerMatches(domainObject, cert);
|
|
const renewalInfo = await getRenewalInfo(cert, certName);
|
|
const outdated = await needsRenewal(cert, renewalInfo, options);
|
|
|
|
if (sameProvider && !outdated) {
|
|
debug(`ensureCertificate: ${fqdn} acme cert exists and is up to date`);
|
|
return;
|
|
}
|
|
debug(`ensureCertificate: ${fqdn} acme cert exists but provider mismatch or needs renewal`);
|
|
}
|
|
|
|
debug(`ensureCertificate: ${fqdn} needs acme cert`);
|
|
const [error, result] = await safe(acme2.getCertificate(fqdn, domainObject, key));
|
|
|
|
await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.key`, key);
|
|
await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.cert`, result.cert);
|
|
await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.csr`, result.csr);
|
|
await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.renewal`, JSON.stringify(result.renewalInfo));
|
|
|
|
debug(`ensureCertificate: error: ${error ? error.message : 'null'}`);
|
|
|
|
await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: error?.message || '', renewalInfo: result.renewalInfo }));
|
|
}
|
|
|
|
async function writeDashboardNginxConfig(vhost, certificatePath) {
|
|
assert.strictEqual(typeof vhost, 'string');
|
|
assert.strictEqual(typeof certificatePath, 'object');
|
|
|
|
const data = {
|
|
sourceDir: path.resolve(__dirname, '..'),
|
|
vhost,
|
|
hasIPv6: network.hasIPv6(),
|
|
endpoint: 'dashboard',
|
|
certFilePath: certificatePath.certFilePath,
|
|
keyFilePath: certificatePath.keyFilePath,
|
|
robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'),
|
|
proxyAuth: { enabled: false, id: null, location: nginxLocation('/') },
|
|
hstsPreload: false
|
|
};
|
|
const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data);
|
|
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);
|
|
|
|
writeFileSync(nginxConfigFilename, nginxConf);
|
|
}
|
|
|
|
// also syncs the certs to disk
|
|
async function writeDashboardConfig(subdomain, domain) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
debug(`writeDashboardConfig: writing dashboard config for ${domain}`);
|
|
|
|
const dashboardFqdn = dns.fqdn(subdomain, domain);
|
|
const location = { domain, fqdn: dashboardFqdn, certificate: null };
|
|
const certificatePath = await writeCertificate(location);
|
|
await writeDashboardNginxConfig(dashboardFqdn, certificatePath);
|
|
await reload();
|
|
}
|
|
|
|
async function removeDashboardConfig(subdomain, domain) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
debug(`removeDashboardConfig: removing dashboard config of ${domain}`);
|
|
|
|
const vhost = dns.fqdn(subdomain, domain);
|
|
const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`);
|
|
|
|
if (!safe.fs.unlinkSync(nginxConfigFilename)) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
|
|
|
await reload();
|
|
}
|
|
|
|
function normalizeCSP(csp) {
|
|
const lines = csp.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#'));
|
|
const statements = lines.map(line => line.endsWith(';') ? line : `${line};`); // semicolon terminate all lines
|
|
return statements.join(' ').replace(/\s+/g, ' ').trim(); // merge into single line
|
|
}
|
|
|
|
async function writeAppLocationNginxConfig(app, location, certificatePath) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof location, 'object');
|
|
assert.strictEqual(typeof certificatePath, 'object');
|
|
|
|
const { type, fqdn} = location;
|
|
|
|
const data = {
|
|
sourceDir: path.resolve(__dirname, '..'),
|
|
vhost: fqdn,
|
|
hasIPv6: network.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
|
|
hstsPreload: !!app.reverseProxyConfig?.hstsPreload
|
|
};
|
|
|
|
if (type === Location.TYPE_PRIMARY || type === Location.TYPE_ALIAS || type === Location.TYPE_SECONDARY) {
|
|
data.endpoint = 'app';
|
|
|
|
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) {
|
|
data.endpoint = 'external';
|
|
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 = `"${normalizeCSP(app.reverseProxyConfig.csp)}"`;
|
|
data.hideHeaders = [ 'Content-Security-Policy' ];
|
|
if (reverseProxyConfig.csp.includes('frame-ancestors ')) data.hideHeaders.push('X-Frame-Options');
|
|
}
|
|
if (type === Location.TYPE_PRIMARY || type == 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;
|
|
|
|
if (data.proxyAuth.enabled) {
|
|
data.proxyAuth.oidcClientId = app.id;
|
|
data.proxyAuth.oidcEndpoint = (await dashboard.getLocation()).fqdn;
|
|
}
|
|
} else if (type === 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 === 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 = path.join(paths.NGINX_APPCONFIG_DIR, app.id, `${fqdn.replace('*', '_')}.conf`);
|
|
debug(`writeAppLocationNginxConfig: writing config for "${fqdn}" to ${filename} with options ${JSON.stringify(data)}`);
|
|
writeFileSync(filename, nginxConf);
|
|
}
|
|
|
|
async function writeAppConfigs(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
const locations = getAppLocationsSync(app);
|
|
|
|
if (!safe.fs.mkdirSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true })) throw new BoxError(BoxError.FS_ERROR, `Could not create nginx config directory: ${safe.error.message}`);
|
|
|
|
for (const location of locations) {
|
|
const certificatePath = await writeCertificate(location);
|
|
await writeAppLocationNginxConfig(app, location, certificatePath);
|
|
}
|
|
|
|
await reload();
|
|
}
|
|
|
|
async function setUserCertificate(app, location) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof location, 'object');
|
|
|
|
const certificatePath = await writeCertificate(location);
|
|
await writeAppLocationNginxConfig(app, location, certificatePath);
|
|
await reload();
|
|
}
|
|
|
|
async function configureApp(app, auditSource) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const locations = getAppLocationsSync(app);
|
|
|
|
for (const location of locations) {
|
|
await ensureCertificate(location, {}, auditSource);
|
|
}
|
|
|
|
await writeAppConfigs(app);
|
|
|
|
if (app.manifest.addons?.tls) await setupTlsAddon(app);
|
|
}
|
|
|
|
async function unconfigureApp(app) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
|
|
if (!safe.fs.rmSync(path.join(paths.NGINX_APPCONFIG_DIR, app.id), { recursive: true, force: true })) throw new BoxError(BoxError.FS_ERROR, `Could not remove nginx config directory: ${safe.error.message}`);
|
|
await reload();
|
|
}
|
|
|
|
async function cleanupCerts(locations, auditSource, progressCallback) {
|
|
assert(Array.isArray(locations));
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
progressCallback({ message: 'Checking expired certs for removal' });
|
|
|
|
const domainObjectMap = await domains.getDomainObjectMap();
|
|
const certNamesInUse = new Set();
|
|
for (const location of locations) {
|
|
certNamesInUse.add(await getAcmeCertificateNameSync(location.fqdn, domainObjectMap[location.domain]));
|
|
}
|
|
|
|
const now = new Date();
|
|
const certIds = await blobs.listCertIds();
|
|
const removedCertNames = [];
|
|
for (const certId of certIds) {
|
|
const certName = certId.match(new RegExp(`${blobs.CERT_PREFIX}-(.*).cert`))[1];
|
|
if (certNamesInUse.has(certName)) continue;
|
|
|
|
const cert = await blobs.getString(certId);
|
|
const { endDate } = await openssl.getCertificateDates(cert);
|
|
if (!endDate) continue; // some error
|
|
|
|
if (now - endDate >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago and not in use
|
|
progressCallback({ message: `deleting certs of ${certName}` });
|
|
|
|
// it is safe to delete the certs of stopped apps because their nginx configs are removed
|
|
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.cert`));
|
|
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.key`));
|
|
safe.fs.unlinkSync(path.join(paths.NGINX_CERT_DIR, `${certName}.csr`));
|
|
|
|
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.key`);
|
|
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
|
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.csr`);
|
|
await blobs.del(`${blobs.CERT_PREFIX}-${certName}.renewal`);
|
|
|
|
removedCertNames.push(certName);
|
|
}
|
|
}
|
|
|
|
if (removedCertNames.length) await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_CLEANUP, auditSource, { domains: removedCertNames }));
|
|
|
|
debug('cleanupCerts: done');
|
|
}
|
|
|
|
async function checkCerts(options, auditSource, progressCallback) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
let locations = [];
|
|
const dashboardLocation = await dashboard.getLocation();
|
|
locations.push(dashboardLocation);
|
|
|
|
const mailLocation = await mailServer.getLocation();
|
|
if (dashboardLocation.fqdn !== mailLocation.fqdn) locations.push(mailLocation);
|
|
|
|
const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED);
|
|
for (const app of allApps) {
|
|
locations = locations.concat(getAppLocationsSync(app));
|
|
}
|
|
|
|
let percent = 1;
|
|
for (const location of locations) {
|
|
percent += Math.round(100/locations.length);
|
|
progressCallback({ percent, message: `Ensuring certs of ${location.fqdn}` });
|
|
await ensureCertificate(location, options, auditSource);
|
|
}
|
|
|
|
if (options.rebuild || fs.existsSync(paths.REVERSE_PROXY_REBUILD_FILE)) {
|
|
progressCallback( { message: 'Rebuilding app configs' });
|
|
for (const app of allApps) {
|
|
await writeAppConfigs(app);
|
|
}
|
|
await writeDashboardConfig(dashboardLocation.subdomain, dashboardLocation.domain);
|
|
await notifyCertChange(); // this allows user to "rebuild" using UI just in case we crashed and went out of sync
|
|
safe.fs.unlinkSync(paths.REVERSE_PROXY_REBUILD_FILE);
|
|
} else {
|
|
// sync all locations and not just the ones that changed. this helps with 0 length certs when disk is full and also
|
|
// if renewal task crashed midway.
|
|
for (const location of locations) {
|
|
await writeCertificate(location);
|
|
}
|
|
await reload();
|
|
await notifyCertChange(); // propagate any cert changes to services
|
|
}
|
|
|
|
await cleanupCerts(locations, auditSource, progressCallback);
|
|
}
|
|
|
|
async function startRenewCerts(options, auditSource) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const taskId = await tasks.add(tasks.TASK_CHECK_CERTS, [ options, auditSource ]);
|
|
safe(tasks.startTask(taskId, {}), { debug }); // background
|
|
return taskId;
|
|
}
|
|
|
|
function removeAppConfigs() {
|
|
debug('removeAppConfigs: removing app nginx configs');
|
|
|
|
// remove all configs which are not the default or current dashboard
|
|
for (const entry of fs.readdirSync(paths.NGINX_APPCONFIG_DIR, { withFileTypes: true })) {
|
|
if (entry.isDirectory() && entry.name === 'dashboard') continue;
|
|
if (entry.isFile() && entry.name === constants.NGINX_DEFAULT_CONFIG_FILE_NAME) continue;
|
|
|
|
const fullPath = path.join(paths.NGINX_APPCONFIG_DIR, entry.name);
|
|
if (entry.isDirectory()) {
|
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
} else if (entry.isFile()) {
|
|
fs.unlinkSync(fullPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
const { cert, key } = await openssl.generateCertificate(cn);
|
|
if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, safe.error);
|
|
if (!safe.fs.writeFileSync(certFilePath, cert)) throw new BoxError(BoxError.FS_ERROR, safe.error);
|
|
}
|
|
|
|
const data = {
|
|
sourceDir: path.resolve(__dirname, '..'),
|
|
vhost: '',
|
|
hasIPv6: network.hasIPv6(),
|
|
endpoint: options.activated ? 'ip' : 'setup',
|
|
certFilePath,
|
|
keyFilePath,
|
|
robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'),
|
|
proxyAuth: { enabled: false, id: null, location: nginxLocation('/') },
|
|
hstsPreload: false
|
|
};
|
|
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();
|
|
}
|
|
|
|
async function handleCertificateProviderChanged(domain) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
safe.fs.appendFileSync(paths.REVERSE_PROXY_REBUILD_FILE, `${domain}\n`, 'utf8');
|
|
}
|
|
|
|
async function getTrustedIps() {
|
|
const value = await settings.get(settings.TRUSTED_IPS_KEY);
|
|
return value || '';
|
|
}
|
|
|
|
async function setTrustedIps(trustedIps) {
|
|
assert.strictEqual(typeof trustedIps, 'string');
|
|
|
|
let trustedIpsConfig = 'real_ip_header X-Forwarded-For;\nreal_ip_recursive on;\n';
|
|
|
|
for (const line of trustedIps.split('\n')) {
|
|
if (!line || line.startsWith('#')) continue;
|
|
const rangeOrIP = line.trim();
|
|
if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `'${rangeOrIP}' is not a valid IP or range`);
|
|
trustedIpsConfig += `set_real_ip_from ${rangeOrIP};\n`;
|
|
}
|
|
|
|
await settings.set(settings.TRUSTED_IPS_KEY, trustedIps);
|
|
if (!safe.fs.writeFileSync(paths.NGINX_TRUSTED_IPS_FILE, trustedIpsConfig, 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
|
await reload();
|
|
}
|