'use strict'; const config = require('wild-config'); const ACME = require('@root/acme'); const { pem2jwk } = require('pem-jwk'); const CSR = require('@root/csr'); const { Certificate } = require('@fidm/x509'); const AcmeChallenge = require('./acme-challenge'); const pkg = require('../../package.json'); const { normalizeDomain } = require('../tools'); const Lock = require('ioredfour'); const log = require('npmlog'); const { Resolver } = require('dns').promises; const resolver = new Resolver(); const Joi = require('joi'); const db = require('../db'); const { SettingsHandler } = require('../settings-handler'); if (config.resolver && config.resolver.ns && config.resolver.ns.length) { resolver.setServers([].concat(config.resolver.ns || [])); } const RENEW_AFTER_REMAINING = 10000 + 30 * 24 * 3600 * 1000; const BLOCK_RENEW_AFTER_ERROR_TTL = 10; //3600; const acme = ACME.create({ maintainerEmail: pkg.author.email, packageAgent: pkg.name + '/' + pkg.version, notify(ev, params) { log.info('ACME', 'Notification for %s (%s)', ev, JSON.stringify(params)); } }); let settings; let getLock, releaseLock; // First try triggers initialization, others will wait until first is finished let acmeInitialized = false; let acmeInitializing = false; let acmeInitPending = []; const ensureAcme = async acmeOptions => { if (!settings) { settings = new SettingsHandler({ db: db.database }); } if (acmeInitialized) { return true; } if (acmeInitializing) { return new Promise((resolve, reject) => { acmeInitPending.push({ resolve, reject }); }); } try { await acme.init(acmeOptions.directoryUrl); acmeInitialized = true; if (acmeInitPending.length) { for (let entry of acmeInitPending) { entry.resolve(true); } } } catch (err) { if (acmeInitPending.length) { for (let entry of acmeInitPending) { entry.reject(err); } } throw err; } finally { acmeInitializing = false; } return true; }; const getAcmeAccount = async (acmeOptions, certHandler) => { await ensureAcme(acmeOptions); const entryKey = `acme:account:${acmeOptions.key}`; const settingsValue = await settings.get(entryKey); // there is already an existing acme account, no need to create a new one if (settingsValue) { return settingsValue; } // account not found, create a new one log.info('ACME', 'ACME account for %s not found, provisioning new one from %s', acmeOptions.key, acmeOptions.directoryUrl); const accountKey = await certHandler.generateKey(acmeOptions.keyBits, acmeOptions.keyExponent); const jwkAccount = pem2jwk(accountKey); log.info('ACME', 'Generated Acme account key for %s', acmeOptions.key); const accountOptions = { subscriberEmail: acmeOptions.email, agreeToTerms: true, accountKey: jwkAccount }; const account = await acme.accounts.create(accountOptions); await settings.set( entryKey, { key: accountKey, account }, { enumerable: false } ); log.info('ACME', 'ACME account provisioned for %s', acmeOptions.key); return { key: accountKey, account }; }; const validateDomain = async domain => { // check domain name format const validation = Joi.string() .domain({ tlds: { allow: true } }) .validate(domain); if (validation.error) { // invalid domain name, can not create certificate let err = new Error('${domain} is not a valid domain name'); err.responseCode = 400; err.code = 'invalid_domain'; throw err; } // check CAA support const caaDomains = config.acme.caaDomains.map(normalizeDomain).filter(d => d); // CAA support in node 15+ if (typeof resolver.resolveCaa === 'function' && caaDomains.length) { let parts = domain.split('.'); for (let i = 0; i < parts.length - 1; i++) { let subdomain = parts.slice(i).join('.'); let caaRes; try { caaRes = await resolver.resolveCaa(subdomain); } catch (err) { // assume not found } if (caaRes && caaRes.length && !caaRes.some(r => config.acme.caaDomains.includes(normalizeDomain(r && r.issue)))) { let err = new Error(`LE not listed in the CAA record for ${subdomain} (${domain})`); err.responseCode = 403; err.code = 'caa_mismatch'; throw err; } else if (caaRes && caaRes.length) { log.info('ACME', 'Found matching CAA record for %s (%s)', subdomain, domain); break; } } } return true; }; const acquireCert = async (domain, acmeOptions, certificateData, certHandler) => { const domainSafeLockKey = `d:lock:safe:${domain}`; const domainOpLockKey = `d:lock:op:${domain}`; if (await db.redis.exists(domainSafeLockKey)) { // nothing to do here, renewal blocked log.info('ACME', 'Renewal blocked by failsafe lock for %s', domain); // use default return certificateData; } try { // throws if can not validate domain await validateDomain(domain); log.info('ACME', 'Domain validation for %s passed', domain); } catch (err) { log.error('ACME', 'Failed to validate domain %s: %s', domain, err.message); return certificateData; } // Use locking to avoid race conditions, first try gets the lock, others wait until first is finished if (!getLock) { let lock = new Lock({ redis: db.redis, namespace: 'acme' }); getLock = (...args) => lock.waitAcquireLock(...args); releaseLock = (...args) => lock.releaseLock(...args); } let lock = await getLock(domainOpLockKey, 10 * 60 * 1000, 3 * 60 * 1000); if (!lock.success) { return certificateData; } try { // reload from db, maybe already renewed if (certificateData.expires > new Date(Date.now() + RENEW_AFTER_REMAINING)) { // no need to renew return certificateData; } let privateKey = certificateData.privateKey; if (!privateKey) { // generate new key log.info('ACME', 'Provision new private key for %s', domain); privateKey = await certHandler.resetPrivateKey({ _id: certificateData._id }, config.acme); } const jwkPrivateKey = pem2jwk(privateKey); const csr = await CSR.csr({ jwk: jwkPrivateKey, domains: [domain], encoding: 'pem' }); const acmeAccount = await getAcmeAccount(acmeOptions, certHandler); if (!acmeAccount) { log.info('ACME', 'Skip certificate renwal for %s, acme account not found', domain); return false; } const jwkAccount = pem2jwk(acmeAccount.key); const certificateOptions = { account: acmeAccount.account, accountKey: jwkAccount, csr, domains: [domain], challenges: { 'http-01': AcmeChallenge.create({ db: db.database }) } }; const aID = ((acmeAccount && acmeAccount.account && acmeAccount.account.key && acmeAccount.account.key.kid) || '').split('/acct/').pop(); log.info('ACME', 'Generate ACME cert for %s (account=%s)', domain, aID); const cert = await acme.certificates.create(certificateOptions); if (!cert || !cert.cert) { log.error('ACME', 'Failed to generate certificate for %s. Empty response', domain); return cert; } log.info('ACME', 'Received certificate from ACME for %s', domain); let now = new Date(); const parsed = Certificate.fromPEM(cert.cert); let updates = { cert: cert.cert, ca: [].concat(cert.chain || []), validFrom: new Date(parsed.validFrom), expires: new Date(parsed.validTo), altNames: parsed.dnsNames, issuer: parsed.issuer.commonName, lastCheck: now, status: 'valid' }; let updated = await certHandler.update({ _id: certificateData._id }, updates, { certUpdated: true }); if (!updated) { log.error('ACME', 'Failed to generate certificate for %s. Update failed', domain); return cert; } log.info('ACME', 'Certificate successfully generated for %s (expires %s)', domain, parsed.validTo); return await certHandler.getRecord({ _id: certificateData._id }, true); } catch (err) { try { await db.redis.multi().set(domainSafeLockKey, 1).expire(domainSafeLockKey, BLOCK_RENEW_AFTER_ERROR_TTL).exec(); } catch (err) { log.error('ACME', 'Redis call failed key=%s domains=%s error=%s', domainSafeLockKey, domain, err.message); } log.error('ACME', 'Failed to generate certificate domains=%s error=%s', domain, err.stack); if (certificateData && certificateData._id) { try { await db.database.collection('certs').findOneAndUpdate( { _id: certificateData._id }, { $set: { '_acme.lastError': { err: err.message, code: err.code, time: new Date() } } } ); } catch (err) { log.error('ACME', 'Failed to update certificate record domain=%s error=%s', domain, err.message); } } if (certificateData && certificateData.cert) { // use existing certificate data if exists return certificateData; } throw err; } finally { try { await releaseLock(lock); } catch (err) { log.error('Lock', 'Failed to release lock for %s: %s', domainOpLockKey, err); } } }; const getCertificate = async (domain, acmeOptions, certHandler) => { await ensureAcme(acmeOptions, certHandler); domain = normalizeDomain(domain); let certificateData = await certHandler.getRecord({ servername: domain }, true); if (!certificateData) { let err = new Error('Missing certificate info for ${domain}'); err.responseCode = 404; err.code = 'missing_certificate'; throw err; } if (!certificateData.status) { certificateData.status = 'pending'; } if (certificateData.status === 'valid' && certificateData.expires < new Date()) { certificateData.status = 'expired'; } if (certificateData.expires > new Date(Date.now() + RENEW_AFTER_REMAINING)) { // no need to renew, use existing return certificateData; } return await acquireCert(domain, acmeOptions, certificateData, certHandler); }; module.exports = { getCertificate, acquireCert };