'use strict'; var PromiseA = require('bluebird'); var mkdirpAsync = PromiseA.promisify(require('mkdirp')); var path = require('path'); var sfs = require('safe-replace'); var LE = require('../'); var LeCore = PromiseA.promisifyAll(require('letiny-core')); var leCrypto = PromiseA.promisifyAll(LeCore.leCrypto); var Accounts = require('./accounts'); var merge = require('./common').merge; var tplCopy = require('./common').tplCopy; var fetchFromConfigLiveDir = require('./common').fetchFromDisk; var ipc = {}; // in-process cache function getAcmeUrls(args) { var now = Date.now(); // TODO check response header on request for cache time if ((now - ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) { return PromiseA.resolve(ipc.acmeUrls); } return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { ipc.acmeUrlsUpdatedAt = Date.now(); ipc.acmeUrls = data; return ipc.acmeUrls; }); } function getCertificateAsync(account, args, defaults, handlers) { return leCrypto.generateRsaKeypairAsync(args.rsaKeySize, 65537).then(function (domainKey) { if (args.debug) { console.log("get certificate"); } return LeCore.getCertificateAsync({ debug: args.debug , newAuthzUrl: args._acmeUrls.newAuthz , newCertUrl: args._acmeUrls.newCert , accountPrivateKeyPem: account.privateKeyPem , domainPrivateKeyPem: domainKey.privateKeyPem , domains: args.domains // // 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) // , setChallenge: function (domain, key, value, done) { var copy = merge(defaults, { domains: [domain] }); tplCopy(copy); args.domains = [domain]; args.webrootPath = args.webrootPath; if (4 === handlers.setChallenge.length) { handlers.setChallenge(copy, key, value, done); } else if (5 === handlers.setChallenge.length) { handlers.setChallenge(copy, domain, key, value, done); } else { done(new Error("handlers.setChallenge receives the wrong number of arguments")); } } , removeChallenge: function (domain, key, done) { var copy = merge(defaults, { domains: [domain] }); tplCopy(copy); if (3 === handlers.removeChallenge.length) { handlers.removeChallenge(copy, key, done); } else if (4 === handlers.removeChallenge.length) { handlers.removeChallenge(copy, domain, key, done); } else { done(new Error("handlers.removeChallenge receives the wrong number of arguments")); } } }).then(function (result) { if (args.debug) { console.log("got certificate!"); } result.fullchain = result.cert + '\n' + result.ca; var pyconf = PromiseA.promisifyAll(require('pyconf')); return pyconf.readFileAsync(args.renewalPath).then(function (obj) { return obj; }, function () { return pyconf.readFileAsync(path.join(__dirname, 'lib', 'renewal.conf.tpl')).then(function (obj) { return obj; }); }).then(function (obj) { obj.checkpoint = parseInt(obj.checkpoint, 10) || 0; var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); var certPath = args.certPath || obj.cert || path.join(liveDir, 'cert.pem'); var fullchainPath = args.fullchainPath || obj.fullchain || path.join(liveDir, 'fullchain.pem'); var chainPath = args.chainPath || obj.chain || path.join(liveDir, 'chain.pem'); var privkeyPath = args.domainPrivateKeyPath || args.domainKeyPath || obj.privkey || obj.keyPath || path.join(liveDir, 'privkey.pem'); var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]); var checkpoint = obj.checkpoint.toString(); var certArchive = path.join(archiveDir, 'cert' + checkpoint + '.pem'); var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoint + '.pem'); var chainArchive = path.join(archiveDir, 'chain'+ checkpoint + '.pem'); var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoint + '.pem'); return mkdirpAsync(archiveDir).then(function () { return PromiseA.all([ sfs.writeFileAsync(certArchive, result.cert, 'ascii') , sfs.writeFileAsync(chainArchive, result.ca || result.chain, 'ascii') , sfs.writeFileAsync(fullchainArchive, result.fullchain, 'ascii') , sfs.writeFileAsync(privkeyArchive, result.key || result.privkey, 'ascii') ]); }).then(function () { return mkdirpAsync(liveDir); }).then(function () { return PromiseA.all([ sfs.writeFileAsync(certPath, result.cert, 'ascii') , sfs.writeFileAsync(chainPath, result.ca || result.chain, 'ascii') , sfs.writeFileAsync(fullchainPath, result.fullchain, 'ascii') , sfs.writeFileAsync(privkeyPath, result.key || result.privkey, 'ascii') ]); }).then(function () { obj.checkpoint += 1; var updates = { cert: certPath , privkey: privkeyPath , chain: chainPath , fullchain: fullchainPath , configDir: args.configDir , workDir: args.workDir , tos: args.agreeTos && true , http01Port: args.http01Port , keyPath: args.domainPrivateKeyPath || args.privkeyPath , email: args.email , domains: args.domains , rsaKeySize: args.rsaKeySize , checkpoints: obj.checkpoint // TODO XXX what's the deal with these? they don't make sense // are they just old junk? or do they have a meaning that I don't know about? , fullchainPath: path.join(args.configDir, 'chain.pem') , certPath: path.join(args.configDir, 'cert.pem') , chainPath: path.join(args.configDir, 'chain.pem') // TODO XXX end // yes, it's an array. weird, right? , webrootPath: args.webrootPath && [args.webrootPath] || [] , account: account.accountId , server: args.server || args.acmeDiscoveryUrl , logsDir: args.logsDir }; // final section is completely dynamic // :hostname = :webroot_path args.domains.forEach(function (hostname) { updates[hostname] = args.webrootPath; }); // must write back to the original object or // annotations will be lost Object.keys(updates).forEach(function (key) { obj[key] = updates[key]; }); return pyconf.writeFile(args.renewalPath, obj); }).then(function () { return { certPath: certPath , chainPath: chainPath , fullchainPath: fullchainPath , privkeyPath: privkeyPath // some ambiguity here... , privkey: result.key || result.privkey , fullchain: result.fullchain || result.cert , chain: result.ca || result.chain // especially this one... might be cert only, might be fullchain , cert: result.cert , issuedAt: Date.now() , lifetime: defaults.lifetime || handlers.lifetime }; }); }); }); }); } function getOrCreateDomainCertificate(account, args, defaults, hanlers) { return fetchFromConfigLiveDir(args).then(function (certs) { // if nothing, register and save // if something, check date (don't register unless 30+ days) // if good, don't bother registering // (but if we get to the point that we're actually calling // this function, that shouldn't be the case, right?) //console.log(certs); if (!certs) { // no certs, seems like a good time to get some return getCertificateAsync(account, args, defaults, handlers); } else if (certs.issuedAt > (27 * 24 * 60 * 60 * 1000)) { // cert is at least 27 days old we can renew that return getCertificateAsync(account, args, defaults, handlers); } else if (args.duplicate) { // YOLO! I be gettin' fresh certs 'erday! Yo! return getCertificateAsync(account, args, defaults, handlers); } else { console.warn('[WARN] Ignoring renewal attempt for certificate less than 27 days old. Use args.duplicate to force.'); // We're happy with what we have return certs; } }); }; function getOrCreateAcmeAccount(args, defaults, handlers) { var pyconf = PromiseA.promisifyAll(require('pyconf')); var server = args.server; var acmeHostname = require('url').parse(server).hostname; var configDir = args.configDir; args.renewalPath = args.renewalPath || path.join(configDir, 'renewal', args.domains[0] + '.conf'); args.accountsDir = args.accountsDir || path.join(configDir, 'accounts', acmeHostname, 'directory'); return pyconf.readFileAsync(args.renewalPath).then(function (renewal) { var accountId = renewal.account; renewal = renewal.account; return accountId; }, function (err) { if ("ENOENT" === err.code) { if (args.debug) { console.log("[LE] try email"); } return Accounts.getAccountIdByEmail(args, handlers); } return PromiseA.reject(err); }).then(function (accountId) { // Note: the ACME urls are always fetched fresh on purpose return getAcmeUrls(args).then(function (urls) { args._acmeUrls = urls; if (accountId) { if (args.debug) { console.log('[LE] use account'); } return Accounts.getAccount(accountId, args, handlers); } else { if (args.debug) { console.log('[LE] create account'); } return Accounts.createAccount(args, handlers); } }); }).then(function (account) { /* if (renewal.account !== account) { // the account has become corrupt, re-register return; } */ }); /* return fs.readdirAsync(accountsDir, function (nodes) { return PromiseA.all(nodes.map(function (node) { var reMd5 = /[a-f0-9]{32}/i; if (reMd5.test(node)) { } })); }); */ } module.exports.create = function (defaults, handlers) { defaults.server = defaults.server || LE.liveServer; var wrapped = { registerAsync: function (args) { var copy; // TODO move these defaults elsewhere? args.renewalPath = args.renewalPath || ':config/renewal/:hostname.conf'; // Note: the /directory is part of the server url and, as such, bleeds into the pathname // So :config/accounts/:server/directory is *incorrect*, but the following *is* correct: args.accountsDir = args.accountsDir || ':config/accounts/:server'; copy = merge(args, defaults); tplCopy(copy); if (args.debug) { console.log('[LE DEBUG] reg domains', args.domains); } return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { return getOrCreateDomainCertificate(account, copy, defaults, handlers); }); } , fetchAsync: function (args) { var copy = merge(args, defaults); tplCopy(copy); if (args.debug) { console.log('[LE DEBUG] fetch domains', copy); } return fetchFromConfigLiveDir(copy, defaults); } }; return wrapped; };