'use strict'; /*global Promise*/ require('./compat.js'); var util = require('util'); function promisifyAll(obj) { var aobj = {}; Object.keys(obj).forEach(function(key) { if ('function' === typeof obj[key]) { aobj[key] = obj[key]; aobj[key + 'Async'] = util.promisify(obj[key]); } }); return aobj; } function _log(debug) { if (debug) { var args = Array.prototype.slice.call(arguments); args.shift(); args.unshift('[greenlock/lib/core.js]'); console.log.apply(console, args); } } module.exports.create = function(gl) { var utils = require('./utils'); var RSA = promisifyAll(require('rsa-compat').RSA); var log = gl.log || _log; // allow custom log var pendingRegistrations = {}; var core = { // // Helpers // getAcmeUrlsAsync: function(args) { var now = Date.now(); // TODO check response header on request for cache time if (now - gl._ipc.acmeUrlsUpdatedAt < 10 * 60 * 1000) { return Promise.resolve(gl._ipc.acmeUrls); } // TODO acme-v2/nocompat return gl.acme.getAcmeUrlsAsync(args.server).then(function(data) { gl._ipc.acmeUrlsUpdatedAt = Date.now(); gl._ipc.acmeUrls = data; return gl._ipc.acmeUrls; }); }, // // The Main Enchilada // // // Accounts // accounts: { // Accounts registerAsync: function(args) { var err; var copy = utils.merge(args, gl); var disagreeTos; args = utils.tplCopy(copy); if (!args.account) { args.account = {}; } if ('object' === typeof args.account && !args.account.id) { args.account.id = args.accountId || args.email || ''; } disagreeTos = !args.agreeTos && 'undefined' !== typeof args.agreeTos; if ( !args.email || disagreeTos || parseInt(args.rsaKeySize, 10) < 2048 ) { err = new Error( "In order to register an account both 'email' and 'agreeTos' must be present" + " and 'rsaKeySize' must be 2048 or greater." ); err.code = 'E_ARGS'; return Promise.reject(err); } return utils.testEmail(args.email).then(function() { if ( args.account && args.account.privkey && (args.account.privkey.jwk || args.account.privkey.pem) ) { // TODO import jwk or pem and return it here console.warn( 'TODO: implement accounts.checkKeypairAsync skipping' ); } var accountKeypair; var newAccountKeypair = true; var promise = gl.store.accounts .checkKeypairAsync(args) .then(function(keypair) { if (keypair) { // TODO keypairs newAccountKeypair = false; accountKeypair = RSA.import(keypair); return; } if (args.accountKeypair) { // TODO keypairs accountKeypair = RSA.import( args.accountKeypair ); return; } var keypairOpts = { bitlen: args.rsaKeySize, exp: 65537, public: true, pem: true }; // TODO keypairs return (args.generateKeypair || RSA.generateKeypairAsync)(keypairOpts).then( function(keypair) { keypair.privateKeyPem = RSA.exportPrivatePem( keypair ); keypair.publicKeyPem = RSA.exportPublicPem( keypair ); keypair.privateKeyJwk = RSA.exportPrivateJwk( keypair ); accountKeypair = keypair; } ); }) .then(function() { return accountKeypair; }); return promise.then(function(keypair) { // Note: the ACME urls are always fetched fresh on purpose // TODO acme-v2/nocompat return core.getAcmeUrlsAsync(args).then(function(urls) { args._acmeUrls = urls; // TODO acme-v2/nocompat return gl.acme .registerNewAccountAsync({ email: args.email, newRegUrl: args._acmeUrls.newReg, newAuthzUrl: args._acmeUrls.newAuthz, agreeToTerms: function(tosUrl, agreeCb) { if ( true === args.agreeTos || tosUrl === args.agreeTos || tosUrl === gl.agreeToTerms ) { agreeCb(null, tosUrl); return; } // args.email = email; // already there // args.domains = domains // already there args.tosUrl = tosUrl; gl.agreeToTerms(args, agreeCb); }, accountKeypair: keypair, debug: gl.debug || args.debug }) .then(function(receipt) { var reg = { keypair: keypair, receipt: receipt, kid: receipt && receipt.key && (receipt.key.kid || receipt.kid), email: args.email, newRegUrl: args._acmeUrls.newReg, newAuthzUrl: args._acmeUrls.newAuthz }; var accountKeypairPromise; args.keypair = keypair; args.receipt = receipt; if (newAccountKeypair) { accountKeypairPromise = gl.store.accounts.setKeypairAsync( args, keypair ); } return Promise.resolve( accountKeypairPromise ).then(function() { // TODO move templating of arguments to right here? if (!gl.store.accounts.setAsync) { return Promise.resolve({ keypair: keypair }); } return gl.store.accounts .setAsync(args, reg) .then(function(account) { if ( account && 'object' !== typeof account ) { throw new Error( "store.accounts.setAsync should either return 'null' or an object with at least a string 'id'" ); } if (!account) { account = {}; } account.keypair = keypair; return account; }); }); }); }); }); }); }, // Accounts // (only used for keypair) getAsync: function(args) { var accountPromise = null; if (gl.store.accounts.checkAsync) { accountPromise = core.accounts.checkAsync(args); } return Promise.resolve(accountPromise).then(function(account) { if (!account) { return core.accounts.registerAsync(args); } if (account.keypair) { return account; } if (!args.account) { args.account = {}; } if ('object' === typeof args.account && !args.account.id) { args.account.id = args.accountId || args.email || ''; } var copy = utils.merge(args, gl); args = utils.tplCopy(copy); return gl.store.accounts .checkKeypairAsync(args) .then(function(keypair) { if (keypair) { return { keypair: keypair }; } return core.accounts.registerAsync(args); }); }); }, // Accounts checkAsync: function(args) { var requiredArgs = ['accountId', 'email', 'domains', 'domain']; if ( !(args.account && (args.account.id || args.account.kid)) && !requiredArgs.some(function(key) { return -1 !== Object.keys(args).indexOf(key); }) ) { return Promise.reject( new Error( "In order to register or retrieve an account one of '" + requiredArgs.join("', '") + "' must be present" ) ); } var copy = utils.merge(args, gl); args = utils.tplCopy(copy); if (!args.account) { args.account = {}; } if ('object' === typeof args.account && !args.account.id) { args.account.id = args.accountId || args.email || ''; } // we can re-register the same account until we're blue in the face and it's all the same // of course, we can also skip the lookup if we do store the account, but whatever if (!gl.store.accounts.checkAsync) { return Promise.resolve(null); } return gl.store.accounts .checkAsync(args) .then(function(account) { if (!account) { return null; } args.account = account; args.accountId = account.id; return account; }); } }, certificates: { // Certificates registerAsync: function(args) { var err; var challengeDefaults = gl[ '_challengeOpts_' + (args.challengeType || gl.challengeType) ] || {}; var copy = utils.merge(args, challengeDefaults || {}); copy = utils.merge(copy, gl); if (!copy.subject) { copy.subject = copy.domains[0]; } if (!copy.domain) { copy.domain = copy.domains[0]; } args = utils.tplCopy(copy); if (!Array.isArray(args.domains)) { return Promise.reject( new Error('args.domains should be an array of domains') ); } //if (-1 === args.domains.indexOf(args.subject)) // TODO relax the constraint once acme-v2 handles subject? if (args.subject !== args.domains[0]) { console.warn( "The certificate's subject (primary domain) should be first in the list of opts.domains" ); console.warn( '\topts.subject: (set by you approveDomains(), falling back to opts.domain) ' + args.subject ); console.warn( '\topts.domain: (set by SNICallback()) ' + args.domain ); console.warn( '\topts.domains: (set by you in approveDomains()) ' + args.domains.join(',') ); console.warn( 'Updating your code will prevent weird, random, hard-to-repro bugs during renewals' ); console.warn( '(also this will be required in the next major version of greenlock)' ); //return Promise.reject(new Error('certificate subject (primary domain) must be the first in opts.domains')); } if ( !( args.domains.length && args.domains.every(utils.isValidDomain) ) ) { // NOTE: this library can't assume to handle the http loopback // (or dns-01 validation may be used) // so we do not check dns records or attempt a loopback here err = new Error( "invalid domain name(s): '(" + args.subject + ') ' + args.domains.join(',') + "'" ); err.code = 'INVALID_DOMAIN'; return Promise.reject(err); } // If a previous request to (re)register a certificate is already underway we need // to return the same promise created before rather than registering things twice. // I'm not 100% sure how to properly handle the case where someone registers domain // lists with some but not all elements common, nor am I sure that's even a case that // is allowed to happen anyway. But for now we act like the list is completely the // same if any elements are the same. var promise; args.domains.some(function(name) { if (pendingRegistrations.hasOwnProperty(name)) { promise = pendingRegistrations[name]; return true; } }); if (promise) { return promise; } promise = core.certificates._runRegistration(args); // Now that the registration is actually underway we need to make sure any subsequent // registration attempts return the same promise until it is completed (but not after // it is completed). args.domains.forEach(function(name) { pendingRegistrations[name] = promise; }); function clearPending() { args.domains.forEach(function(name) { delete pendingRegistrations[name]; }); } promise.then(clearPending, clearPending); return promise; }, _runRegistration: function(args) { // TODO renewal cb // accountId and or email return core.accounts.getAsync(args).then(function(account) { args.account = account; if ( args.certificate && args.certificate.privkey && (args.certificate.privkey.jwk || args.certificate.privkey.pem) ) { // TODO import jwk or pem and return it here console.warn( 'TODO: implement certificates.checkKeypairAsync skipping' ); } var domainKeypair; var newDomainKeypair = true; // This has been done in the getAsync already, so we skip it here // if approveDomains doesn't set subject, we set it here //args.subject = args.subject || args.domains[0]; var promise = gl.store.certificates .checkKeypairAsync(args) .then(function(keypair) { if (keypair) { domainKeypair = RSA.import(keypair); newDomainKeypair = false; return; } if (args.domainKeypair) { domainKeypair = RSA.import(args.domainKeypair); return; } var keypairOpts = { bitlen: args.rsaKeySize, exp: 65537, public: true, pem: true }; return (args.generateKeypair || RSA.generateKeypairAsync)(keypairOpts).then( function(keypair) { keypair.privateKeyPem = RSA.exportPrivatePem( keypair ); keypair.publicKeyPem = RSA.exportPublicPem( keypair ); keypair.privateKeyJwk = RSA.exportPrivateJwk( keypair ); domainKeypair = keypair; } ); }) .then(function() { return domainKeypair; }); return promise .then(function(domainKeypair) { args.domainKeypair = domainKeypair; //args.registration = domainKey; // Note: the ACME urls are always fetched fresh on purpose // TODO is this the right place for this? return core .getAcmeUrlsAsync(args) .then(function(urls) { args._acmeUrls = urls; var certReq = { debug: args.debug || gl.debug, newAuthzUrl: args._acmeUrls.newAuthz, newCertUrl: args._acmeUrls.newCert, accountKeypair: RSA.import( account.keypair ), domainKeypair: domainKeypair, subject: args.subject, // TODO handle this in acme-v2 domains: args.domains, challengeTypes: Object.keys( args.challenges ) }; // // IMPORTANT // // setChallenge and removeChallenge are handed defaults // instead of args because getChallenge does not have // access to args // (args is per-request, defaults is per instance) // // Each of these fires individually for each domain, // even though the certificate on the whole may have many domains // certReq.setChallenge = function( challenge, done ) { log( args.debug, "setChallenge called for '" + challenge.altname + "'" ); // NOTE: First arg takes precedence var copy = utils.merge( { domains: [challenge.altname] }, args ); copy = utils.merge(copy, gl); utils.tplCopy(copy); copy.challenge = challenge; if ( 1 === copy.challenges[challenge.type].set .length ) { copy.challenges[challenge.type] .set(copy) .then(function(result) { done(null, result); }) .catch(done); } else if ( 2 === copy.challenges[challenge.type].set .length ) { copy.challenges[challenge.type].set( copy, done ); } else { Object.keys(challenge).forEach( function(key) { done[key] = challenge[key]; } ); // regression bugfix for le-challenge-cloudflare // (_acme-challege => _greenlock-dryrun-XXXX) copy.acmePrefix = ( challenge.dnsHost || '' ).replace(/\.*/, '') || copy.acmePrefix; copy.challenges[challenge.type].set( copy, challenge.altname, challenge.token, challenge.keyAuthorization, done ); } }; certReq.removeChallenge = function( challenge, done ) { log( args.debug, "removeChallenge called for '" + challenge.altname + "'" ); var copy = utils.merge( { domains: [challenge.altname] }, args ); copy = utils.merge(copy, gl); utils.tplCopy(copy); copy.challenge = challenge; if ( 1 === copy.challenges[challenge.type] .remove.length ) { copy.challenges[challenge.type] .remove(copy) .then(function(result) { done(null, result); }) .catch(done); } else if ( 2 === copy.challenges[challenge.type] .remove.length ) { copy.challenges[ challenge.type ].remove(copy, done); } else { Object.keys(challenge).forEach( function(key) { done[key] = challenge[key]; } ); copy.challenges[ challenge.type ].remove( copy, challenge.altname, challenge.token, done ); } }; certReq.init = function(deps) { var copy = utils.merge(deps, args); copy = utils.merge(copy, gl); utils.tplCopy(copy); Object.keys(copy.challenges).forEach( function(key) { if ( 'function' === typeof copy.challenges[key] .init ) { copy.challenges[key].init( copy ); } } ); return null; }; certReq.getZones = function(challenge) { var copy = utils.merge( { dnsHosts: args.domains.map( function(x) { return 'xxxx.' + x; } ) }, args ); copy = utils.merge(copy, gl); utils.tplCopy(copy); copy.challenge = challenge; if ( !copy.challenges[challenge.type] || 'function' !== typeof copy.challenges[ challenge.type ].zones ) { // may not be available, that's fine. return Promise.resolve([]); } return copy.challenges[ challenge.type ].zones(copy); }; log( args.debug, 'calling greenlock.acme.getCertificateAsync', certReq.subject, certReq.domains ); // TODO acme-v2/nocompat return gl.acme .getCertificateAsync(certReq) .then(utils.attachCertInfo); }); }) .then(function(results) { //var requested = {}; //var issued = {}; // { cert, chain, privkey /*TODO, subject, altnames, issuedAt, expiresAt */ } // args.certs.privkey = RSA.exportPrivatePem(options.domainKeypair); args.certs = results; // args.pems is deprecated args.pems = results; // This has been done in the getAsync already, so we skip it here // if approveDomains doesn't set subject, we set it here //args.subject = args.subject || args.domains[0]; var promise; if (newDomainKeypair) { args.keypair = domainKeypair; promise = gl.store.certificates.setKeypairAsync( args, domainKeypair ); } return Promise.resolve(promise).then(function() { return gl.store.certificates .setAsync(args) .then(function() { return results; }); }); }); }); }, // Certificates renewAsync: function(args, certs) { var renewableAt = core.certificates._getRenewableAt( args, certs ); var err; //var halfLife = (certs.expiresAt - certs.issuedAt) / 2; //var renewable = (Date.now() - certs.issuedAt) > halfLife; log( args.debug, '(Renew) Expires At', new Date(certs.expiresAt).toISOString() ); log( args.debug, '(Renew) Renewable At', new Date(renewableAt).toISOString() ); if (!args.duplicate && Date.now() < renewableAt) { err = new Error( "[ERROR] Certificate issued at '" + new Date(certs.issuedAt).toISOString() + "' and expires at '" + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until '" + new Date(renewableAt).toISOString() + "'. Set { duplicate: true } to force." ); err.code = 'E_NOT_RENEWABLE'; return Promise.reject(err); } // Either the cert has entered its renewal period // or we're forcing a refresh via 'dupliate: true' log(args.debug, 'Renewing!'); if (!args.domains || !args.domains.length) { args.domains = args.servernames || [certs.subject].concat(certs.altnames); } return core.certificates.registerAsync(args); }, // Certificates _isRenewable: function(args, certs) { var renewableAt = core.certificates._getRenewableAt( args, certs ); log( args.debug, 'Check Expires At', new Date(certs.expiresAt).toISOString() ); log( args.debug, 'Check Renewable At', new Date(renewableAt).toISOString() ); if (args.duplicate || Date.now() >= renewableAt) { log(args.debug, 'certificates are renewable'); return true; } return false; }, _getRenewableAt: function(args, certs) { return certs.expiresAt - (args.renewWithin || gl.renewWithin); }, checkAsync: function(args) { var copy = utils.merge(args, gl); // if approveDomains doesn't set subject, we set it here if (!(copy.domains && copy.domains.length)) { copy.domains = [copy.subject || copy.domain].filter( Boolean ); } if (!copy.subject) { copy.subject = copy.domains[0]; } if (!copy.domain) { copy.domain = copy.domains[0]; } args = utils.tplCopy(copy); // returns pems return gl.store.certificates .checkAsync(args) .then(function(cert) { if (!cert) { log( args.debug, 'checkAsync failed to find certificates' ); return null; } cert = utils.attachCertInfo(cert); if (utils.certHasDomain(cert, args.domain)) { log( args.debug, 'checkAsync found existing certificates' ); if (cert.privkey) { return cert; } else { return gl.store.certificates .checkKeypairAsync(args) .then(function(keypair) { cert.privkey = keypair.privateKeyPem || RSA.exportPrivatePem(keypair); return cert; }); } } log( args.debug, 'checkAsync found mismatched / incomplete certificates' ); }); }, // Certificates getAsync: function(args) { var copy = utils.merge(args, gl); // if approveDomains doesn't set subject, we set it here if (!(copy.domains && copy.domains.length)) { copy.domains = [copy.subject || copy.domain].filter( Boolean ); } if (!copy.subject) { copy.subject = copy.domains[0]; } if (!copy.domain) { copy.domain = copy.domains[0]; } args = utils.tplCopy(copy); if ( args.certificate && args.certificate.privkey && args.certificate.cert && args.certificate.chain ) { // TODO skip fetching a certificate if it's fetched during approveDomains console.warn( 'TODO: implement certificates.checkAsync skipping' ); } return core.certificates .checkAsync(args) .then(function(certs) { if (certs) { certs = utils.attachCertInfo(certs); } if ( !certs || !utils.certHasDomain(certs, args.domain) ) { // There is no cert available if ( false !== args.securityUpdates && !args._communityMemberAdded ) { // We will notify all greenlock users of mandatory and security updates // We'll keep track of versions and os so we can make sure things work well // { name, version, email, domains, action, communityMember, telemetry } require('./community').add({ name: args._communityPackage, version: args._communityPackageVersion, email: args.email, domains: args.domains || args.servernames, action: 'reg', communityMember: args.communityMember, telemetry: args.telemetry }); args._communityMemberAdded = true; } return core.certificates.registerAsync(args); } if (core.certificates._isRenewable(args, certs)) { // it's time to renew the available cert if ( false !== args.securityUpdates && !args._communityMemberAdded ) { // We will notify all greenlock users of mandatory and security updates // We'll keep track of versions and os so we can make sure things work well // { name, version, email, domains, action, communityMember, telemetry } require('./community').add({ name: args._communityPackage, version: args._communityPackageVersion, email: args.email, domains: args.domains || args.servernames, action: 'renew', communityMember: args.communityMember, telemetry: args.telemetry }); args._communityMemberAdded = true; } certs.renewing = core.certificates.renewAsync( args, certs ); if (args.waitForRenewal) { return certs.renewing; } } // return existing unexpired (although potentially stale) certificates when available // there will be an additional .renewing property if the certs are being asynchronously renewed return certs; }) .then(function(results) { // returns pems return results; }); } } }; return core; };