From fd02f44c13f26427c49bec2d1ceace1338cb7086 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 4 Aug 2016 18:49:35 -0400 Subject: [PATCH 01/47] partial refactor --- examples/README.md | 7 +- examples/simple.js | 64 ++++ index.js | 69 ++--- lib/accounts.js | 195 ------------ lib/common.js | 79 ++--- lib/core.js | 646 ++++++++++++---------------------------- lib/default-handlers.js | 10 +- lib/pycompat.js | 517 ++++++++++++++++++++++++++++++++ package.json | 2 - 9 files changed, 828 insertions(+), 761 deletions(-) create mode 100644 examples/simple.js delete mode 100644 lib/accounts.js create mode 100644 lib/pycompat.js diff --git a/examples/README.md b/examples/README.md index 9976c81..47fb61c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,9 +29,6 @@ No, I wanted node-letsencrypt ============================= Well, take a look at the API in the main README -and you can also check out the [scraps](https://github.com/Daplie/node-letsencrypt/tree/master/scraps). +and you can also check out the code in the repos above. -Feel free to create issues for examples that don't work and pull requests if you fix one. - -And please, please, do open an issue. We haven't updated the scrap examples -(hence being moved), but we do have it on the roadmap to bring back some raw API examples. +Feel free to open an issues to request any particular type of example. diff --git a/examples/simple.js b/examples/simple.js new file mode 100644 index 0000000..84dc3fc --- /dev/null +++ b/examples/simple.js @@ -0,0 +1,64 @@ +'use strict'; + +//var le = require('letsencrypt'); +var LE = require('../'); +var db = {}; + +var config = { + server: LE.stagingServerUrl // or LE.productionServerUrl + +, configDir: require('homedir')() + '/letsencrypt/etc' // or /etc/letsencrypt or wherever + +, privkeyPath: ':config/live/:hostname/privkey.pem' // +, fullchainPath: ':config/live/:hostname/fullchain.pem' // Note: both that :config and :hostname +, certPath: ':config/live/:hostname/cert.pem' // will be templated as expected +, chainPath: ':config/live/:hostname/chain.pem' // + +, rsaKeySize: 2048 + +, debug: true +}; + +var handlers = { + setChallenge: function (opts, hostname, key, val, cb) { // called during the ACME server handshake, before validation + db[key] = { + hostname: hostname + , key: key + , val: val + }; + + cb(null); + } +, removeChallenge: function (opts, hostname, key, cb) { // called after validation on both success and failure + db[key] = null; + cb(null); + } +, getChallenge: function (opts, hostname, key, cb) { // this is special because it is called by the webserver + cb(null, db[key].val); // (see letsencrypt-cli/bin & letsencrypt-express/standalone), + // not by the library itself + } +, agreeToTerms: function (tosUrl, cb) { // gives you an async way to expose the legal agreement + cb(null, tosUrl); // (terms of use) to your users before accepting + } +}; + +var le = LE.create(config, handlers); + // checks :conf/renewal/:hostname.conf +le.register({ // and either renews or registers + domains: ['example.com'] // CHANGE TO YOUR DOMAIN +, email: 'user@email.com' // CHANGE TO YOUR EMAIL +, agreeTos: false // set to true to automatically accept an agreement + // which you have pre-approved (not recommended) +, rsaKeySize: 2048 +}, function (err) { + if (err) { + // Note: you must have a webserver running + // and expose handlers.getChallenge to it + // in order to pass validation + // See letsencrypt-cli and or letsencrypt-express + console.error('[Error]: node-letsencrypt/examples/standalone'); + console.error(err.stack); + } else { + console.log('success'); + } +}); diff --git a/index.js b/index.js index f7233db..f63cd7a 100644 --- a/index.js +++ b/index.js @@ -4,45 +4,34 @@ var PromiseA = require('bluebird'); var leCore = require('letiny-core'); +var utils = require('./lib/common'); var merge = require('./lib/common').merge; var tplCopy = require('./lib/common').tplCopy; var LE = module.exports; -LE.productionServerUrl = leCore.productionServerUrl; -LE.stagingServerUrl = leCore.stagingServerUrl; -LE.configDir = leCore.configDir; -LE.logsDir = leCore.logsDir; -LE.workDir = leCore.workDir; -LE.acmeChallengPrefix = leCore.acmeChallengPrefix; -LE.knownEndpoints = leCore.knownEndpoints; -LE.privkeyPath = ':config/live/:hostname/privkey.pem'; -LE.fullchainPath = ':config/live/:hostname/fullchain.pem'; -LE.certPath = ':config/live/:hostname/cert.pem'; -LE.chainPath = ':config/live/:hostname/chain.pem'; -LE.renewalPath = ':config/renewal/:hostname.conf'; -LE.accountsDir = ':config/accounts/:server'; +LE.merge = require('./lib/common').merge; + LE.defaults = { - privkeyPath: LE.privkeyPath -, fullchainPath: LE.fullchainPath -, certPath: LE.certPath -, chainPath: LE.chainPath -, renewalPath: LE.renewalPath -, accountsDir: LE.accountsDir -, server: LE.productionServerUrl + server: leCore.productionServerUrl +, stagingServer: leCore.stagingServerUrl +, liveServer: leCore.productionServerUrl + +, productionServerUrl: leCore.productionServerUrl +, stagingServerUrl: leCore.stagingServerUrl + +, acmeChallengePrefix: leCore.acmeChallengePrefix }; // backwards compat -LE.stagingServer = leCore.stagingServerUrl; -LE.liveServer = leCore.productionServerUrl; -LE.knownUrls = leCore.knownEndpoints; - -LE.merge = require('./lib/common').merge; -LE.tplConfigDir = require('./lib/common').tplConfigDir; +Object.keys(LE.defaults).forEach(function (key) { + LE[key] = LE.defaults[key]; +}); // backend, defaults, handlers LE.create = function (defaults, handlers, backend) { - if (!backend) { backend = require('./lib/core'); } + var Backend = require('./lib/core'); + if (!backend) { backend = require('./lib/pycompat').create(defaults); } if (!handlers) { handlers = {}; } if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; } if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; } @@ -64,14 +53,15 @@ LE.create = function (defaults, handlers, backend) { // the request it came from... it's kinda stateless in that way // but realistically there only needs to be one handler and one // "directory" for this. It's not that big of a deal. - var defaultos = LE.merge(defaults, {}); + var defaultos = LE.merge({}, defaults); var getChallenge = require('./lib/default-handlers').getChallenge; - var copy = merge(defaults, { domains: [hostname] }); + var copy = merge({ domains: [hostname] }, defaults); tplCopy(copy); defaultos.domains = [hostname]; if (3 === getChallenge.length) { + console.warn('[WARNING] Deprecated use. Define getChallenge as function (opts, domain, key, cb) { }'); getChallenge(defaultos, key, done); } else if (4 === getChallenge.length) { @@ -102,22 +92,10 @@ LE.create = function (defaults, handlers, backend) { } handlers.agreeToTerms = require('./lib/default-handlers').agreeToTerms; } - if ('function' === typeof backend.create) { - backend = backend.create(defaults, handlers); - } - else { - // ignore - // this backend was created the v1.0.0 way - } - - // replaces strings of workDir, certPath, etc - // if they have :config/etc/live or :conf/etc/archive - // to instead have the path of the configDir - LE.tplConfigDir(defaults.configDir, defaults); + backend = Backend.create(defaults, handlers); backend = PromiseA.promisifyAll(backend); - var utils = require('./lib/common'); //var attempts = {}; // should exist in master process only var le; @@ -151,7 +129,7 @@ LE.create = function (defaults, handlers, backend) { return; } - var copy = LE.merge(defaults, args); + var copy = LE.merge(args, defaults); var err; if (!utils.isValidDomain(args.domains[0])) { @@ -185,6 +163,11 @@ LE.create = function (defaults, handlers, backend) { if (defaults.debug || args.debug) { console.log('[LE] fetch'); } + + // TODO figure out what TPLs are needed + var copy = merge(args, defaults); + tplCopy(copy); + return backend.fetchAsync(args).then(function (certInfo) { if (args.debug) { console.log('[LE] raw fetch certs', certInfo && Object.keys(certInfo)); diff --git a/lib/accounts.js b/lib/accounts.js deleted file mode 100644 index 7f8f235..0000000 --- a/lib/accounts.js +++ /dev/null @@ -1,195 +0,0 @@ -'use strict'; - -var PromiseA = require('bluebird'); -var crypto = require('crypto'); -var LeCore = require('letiny-core'); -var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); -var path = require('path'); -var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -var fs = PromiseA.promisifyAll(require('fs')); - -function createAccount(args, handlers) { - var os = require("os"); - var localname = os.hostname(); - - // arg.rsaBitLength args.rsaExponent - return RSA.generateKeypairAsync(args.rsaKeySize || 2048, 65537, { public: true, pem: true }).then(function (keypair) { - - return LeCore.registerNewAccountAsync({ - email: args.email - , newRegUrl: args._acmeUrls.newReg - , agreeToTerms: function (tosUrl, agree) { - // args.email = email; // already there - args.tosUrl = tosUrl; - handlers.agreeToTerms(args, agree); - } - , accountKeypair: keypair - - , debug: args.debug || handlers.debug - }).then(function (body) { - // TODO XXX use sha256 (the python client uses md5) - // TODO ssh fingerprint (noted on rsa-compat issues page, I believe) - keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex'); - keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex'); - var accountId = keypair.publicKeyMd5; - var accountDir = path.join(args.accountsDir, accountId); - var regr = { body: body }; - - args.accountId = accountId; - args.accountDir = accountDir; - - return mkdirpAsync(accountDir).then(function () { - - var isoDate = new Date().toISOString(); - var accountMeta = { - creation_host: localname - , creation_dt: isoDate - }; - - // TODO abstract file writing - return PromiseA.all([ - // meta.json {"creation_host": "ns1.redirect-www.org", "creation_dt": "2015-12-11T04:14:38Z"} - fs.writeFileAsync(path.join(accountDir, 'meta.json'), JSON.stringify(accountMeta), 'utf8') - // private_key.json { "e", "d", "n", "q", "p", "kty", "qi", "dp", "dq" } - , fs.writeFileAsync(path.join(accountDir, 'private_key.json'), JSON.stringify(RSA.exportPrivateJwk(keypair)), 'utf8') - // regr.json: - /* - { body: { contact: [ 'mailto:coolaj86@gmail.com' ], - agreement: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', - key: { e: 'AQAB', kty: 'RSA', n: '...' } }, - uri: 'https://acme-v01.api.letsencrypt.org/acme/reg/71272', - new_authzr_uri: 'https://acme-v01.api.letsencrypt.org/acme/new-authz', - terms_of_service: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf' } - */ - , fs.writeFileAsync(path.join(accountDir, 'regr.json'), JSON.stringify(regr), 'utf8') - ]).then(function () { - var pems = {}; - - // pems.private_key; - pems.meta = accountMeta; - pems.keypair = keypair; - pems.regr = regr; - pems.accountId = accountId; - pems.id = accountId; - return pems; - }); - }); - }); - }); -} - -function getAccount(args, handlers) { - var accountId = args.accountId; - var accountDir = path.join(args.accountsDir, accountId); - var files = {}; - var configs = ['meta.json', 'private_key.json', 'regr.json']; - - return PromiseA.all(configs.map(function (filename) { - var keyname = filename.slice(0, -5); - - return fs.readFileAsync(path.join(accountDir, filename), 'utf8').then(function (text) { - var data; - - try { - data = JSON.parse(text); - } catch(e) { - files[keyname] = { error: e }; - return; - } - - files[keyname] = data; - }, function (err) { - files[keyname] = { error: err }; - }); - })).then(function () { - - if (!Object.keys(files).every(function (key) { - return !files[key].error; - })) { - // TODO log renewal.conf - console.warn("Account '" + accountId + "' was corrupt. No big deal (I think?). Creating a new one..."); - //console.log(accountId, files); - return createAccount(args, handlers); - } - - var keypair = { privateKeyJwk: files.private_key }; - keypair.privateKeyPem = RSA.exportPrivatePem(keypair); - keypair.publicKeyPem = RSA.exportPublicPem(keypair); - - //files.private_key; - //files.regr; - //files.meta; - files.accountId = accountId; // preserve current account id - files.id = accountId; - files.keypair = keypair; - - return files; - }); -} - -function getAccountIdByEmail(args) { - // If we read 10,000 account directories looking for - // just one email address, that could get crazy. - // We should have a folder per email and list - // each account as a file in the folder - // TODO - var email = args.email; - if ('string' !== typeof email) { - if (args.debug) { - console.log("[LE] No email given"); - } - return PromiseA.resolve(null); - } - return fs.readdirAsync(args.accountsDir).then(function (nodes) { - if (args.debug) { - console.log("[LE] arg.accountsDir success"); - } - - return PromiseA.all(nodes.map(function (node) { - return fs.readFileAsync(path.join(args.accountsDir, node, 'regr.json'), 'utf8').then(function (text) { - var regr = JSON.parse(text); - regr.__accountId = node; - - return regr; - }); - })).then(function (regrs) { - var accountId; - - /* - if (args.debug) { - console.log('read many regrs'); - console.log('regrs', regrs); - } - */ - - regrs.some(function (regr) { - return regr.body.contact.some(function (contact) { - var match = contact.toLowerCase() === 'mailto:' + email.toLowerCase(); - if (match) { - accountId = regr.__accountId; - return true; - } - }); - }); - - if (!accountId) { - return null; - } - - return accountId; - }); - }).then(function (accountId) { - return accountId; - }, function (err) { - if ('ENOENT' === err.code) { - // ignore error - return null; - } - - return PromiseA.reject(err); - }); -} - -module.exports.getAccountIdByEmail = getAccountIdByEmail; -module.exports.getAccount = getAccount; -module.exports.createAccount = createAccount; diff --git a/lib/common.js b/lib/common.js index 1351d17..9af37e3 100644 --- a/lib/common.js +++ b/lib/common.js @@ -22,7 +22,7 @@ module.exports.isValidDomain = function (domain) { return ''; }; -module.exports.tplConfigDir = function merge(configDir, defaults) { +module.exports.tplConfigDir = function (configDir, defaults) { var homedir = require('homedir')(); Object.keys(defaults).forEach(function (key) { if ('string' === typeof defaults[key]) { @@ -32,12 +32,17 @@ module.exports.tplConfigDir = function merge(configDir, defaults) { }); }; -module.exports.merge = function merge(defaults, args) { +module.exports.merge = function (/*defaults, args*/) { + var allDefaults = Array.prototype.slice.apply(arguments); + var args = args.shift(); var copy = {}; - Object.keys(defaults).forEach(function (key) { - copy[key] = defaults[key]; + allDefaults.forEach(function (defaults) { + Object.keys(defaults).forEach(function (key) { + copy[key] = defaults[key]; + }); }); + Object.keys(args).forEach(function (key) { copy[key] = args[key]; }); @@ -45,7 +50,19 @@ module.exports.merge = function merge(defaults, args) { return copy; }; -module.exports.tplCopy = function merge(copy) { +module.exports.tplCopy = function (copy) { + var url = require('url'); + var acmeLocation = url.parse(copy.server); + var acmeHostpath = path.join(acmeLocation.hostname, acmeLocation.pathname); + copy.accountsDir = copy.accountsDir || path.join(copy.configDir, 'accounts', acmeHostpath); + // TODO move these defaults elsewhere? + //args.renewalDir = args.renewalDir || ':config/renewal/'; + 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'; + hargs.renewalDir = hargs.renewalDir || ':config/renewal/'; + copy.renewalPath = copy.renewalPath || path.join(copy.configDir, 'renewal', copy.domains[0] + '.conf'); var homedir = require('homedir')(); var tpls = { hostname: (copy.domains || [])[0] @@ -71,55 +88,3 @@ module.exports.tplCopy = function merge(copy) { //return copy; }; - -module.exports.fetchFromDisk = function (args) { - // TODO NO HARD-CODED DEFAULTS - if (!args.fullchainPath || !args.privkeyPath || !args.certPath || !args.chainPath) { - console.warn("missing one or more of args.privkeyPath, args.fullchainPath, args.certPath, args.chainPath"); - console.warn("hard-coded conventional pathnames were for debugging and are not a stable part of the API"); - } - - //, fs.readFileAsync(fullchainPath, 'ascii') - // note: if this ^^ gets added back in, the arrays below must change - return PromiseA.all([ - fs.readFileAsync(args.privkeyPath, 'ascii') // 0 - , fs.readFileAsync(args.certPath, 'ascii') // 1 - , fs.readFileAsync(args.chainPath, 'ascii') // 2 - - // stat the file, not the link - , fs.statAsync(args.certPath) // 3 - ]).then(function (arr) { - var cert = arr[1]; - var getCertInfo = require('./cert-info').getCertInfo; - - // XXX Note: Parsing the certificate info comes at a great cost (~500kb) - var certInfo = getCertInfo(cert); - - return { - key: arr[0] // privkey.pem - , privkey: arr[0] // privkey.pem - - , fullchain: arr[1] + '\n' + arr[2] // fullchain.pem - , cert: cert // cert.pem - - , chain: arr[2] // chain.pem - , ca: arr[2] // chain.pem - - , privkeyPath: args.privkeyPath - , fullchainPath: args.fullchainPath - , certPath: args.certPath - , chainPath: args.chainPath - - //, issuedAt: arr[3].mtime.valueOf() - , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() - , expiresAt: Date(certInfo.notAfter.value).valueOf() - , lifetime: args.lifetime - }; - }, function (err) { - if (args.debug) { - console.error("[letsencrypt/lib/common.js] fetchFromDisk"); - console.error(err.stack); - } - return null; - }); -}; diff --git a/lib/core.js b/lib/core.js index 75f7dcd..85f5001 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,454 +1,228 @@ 'use strict'; -var PromiseA = require('bluebird'); -var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); -var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -var path = require('path'); -var fs = PromiseA.promisifyAll(require('fs')); -var sfs = require('safe-replace'); var LE = require('../'); -var LeCore = PromiseA.promisifyAll(require('letiny-core')); -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(); +module.exports.create = function (defaults, handlers, backend) { + defaults.server = defaults.server || LE.liveServer; + handlers.merge = require('./common').merge; + handlers.tplCopy = require('./common').tplCopy; - // TODO check response header on request for cache time - if ((now - ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) { - return PromiseA.resolve(ipc.acmeUrls); - } + var PromiseA = require('bluebird'); + var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); + var LeCore = PromiseA.promisifyAll(require('letiny-core')); + var crypto = require('crypto'); - return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { - ipc.acmeUrlsUpdatedAt = Date.now(); - ipc.acmeUrls = data; + function createAccount(args, handlers) { + // arg.rsaBitLength args.rsaExponent + return RSA.generateKeypairAsync(args.rsaKeySize || 2048, 65537, { public: true, pem: true }).then(function (keypair) { - return ipc.acmeUrls; - }); -} + return LeCore.registerNewAccountAsync({ + email: args.email + , newRegUrl: args._acmeUrls.newReg + , agreeToTerms: function (tosUrl, agree) { + // args.email = email; // already there + args.tosUrl = tosUrl; + handlers.agreeToTerms(args, agree); + } + , accountKeypair: keypair -function readRenewalConfig(args) { - var pyconf = PromiseA.promisifyAll(require('pyconf')); + , debug: defaults.debug || args.debug || handlers.debug + }).then(function (body) { + // TODO XXX use sha256 (the python client uses md5) + // TODO ssh fingerprint (noted on rsa-compat issues page, I believe) + keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex'); + keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex'); - return pyconf.readFileAsync(args.renewalPath).then(function (pyobj) { - return pyobj; - }, function () { - return pyconf.readFileAsync(path.join(__dirname, 'renewal.conf.tpl')).then(function (pyobj) { - return pyobj; - }); - }); -} + var accountId = keypair.publicKeyMd5; + var regr = { body: body }; + var account = {}; -function writeRenewalConfig(args) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } - } + args.accountId = accountId; - var pyobj = args.pyobj; - pyobj.checkpoints = parseInt(pyobj.checkpoints, 10) || 0; + account.keypair = keypair; + account.regr = regr; + account.accountId = accountId; + account.id = accountId; - var pyconf = PromiseA.promisifyAll(require('pyconf')); + args.account = account; - var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); - - var certPath = args.certPath || pyobj.cert || path.join(liveDir, 'cert.pem'); - var fullchainPath = args.fullchainPath || pyobj.fullchain || path.join(liveDir, 'fullchain.pem'); - var chainPath = args.chainPath || pyobj.chain || path.join(liveDir, 'chain.pem'); - var privkeyPath = args.privkeyPath || pyobj.privkey - //|| args.domainPrivateKeyPath || args.domainKeyPath || pyobj.keyPath - || path.join(liveDir, 'privkey.pem'); - - log('[le/core.js] privkeyPath', privkeyPath); - - var updates = { - account: args.account.id - , configDir: args.configDir - , domains: args.domains - - , email: args.email - , tos: args.agreeTos && true - // yes, it's an array. weird, right? - , webrootPath: args.webrootPath && [args.webrootPath] || [] - , server: args.server || args.acmeDiscoveryUrl - - , privkey: privkeyPath - , fullchain: fullchainPath - , cert: certPath - , chain: chainPath - - , http01Port: args.http01Port - , keyPath: args.domainPrivateKeyPath || args.privkeyPath - , rsaKeySize: args.rsaKeySize - , checkpoints: pyobj.checkpoints - /* // 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 - , workDir: args.workDir - , 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 pyobject or - // annotations will be lost - Object.keys(updates).forEach(function (key) { - pyobj[key] = updates[key]; - }); - - return mkdirpAsync(path.dirname(args.renewalPath)).then(function () { - return pyconf.writeFileAsync(args.renewalPath, pyobj); - }).then(function () { - // NOTE - // writing twice seems to causes a bug, - // so instead we re-read the file from the disk - return pyconf.readFileAsync(args.renewalPath); - }); -} - -function getOrCreateRenewal(args) { - return readRenewalConfig(args).then(function (pyobj) { - var minver = pyobj.checkpoints >= 0; - - args.pyobj = pyobj; - - if (!minver) { - args.checkpoints = 0; - pyobj.checkpoints = 0; - return writeRenewalConfig(args); - } - - // args.account.id = pyobj.account - // args.configDir = args.configDir || pyobj.configDir; - - args.checkpoints = pyobj.checkpoints; - - args.agreeTos = (args.agreeTos || pyobj.tos) && true; - args.email = args.email || pyobj.email; - args.domains = args.domains || pyobj.domains; - - // yes, it's an array. weird, right? - args.webrootPath = args.webrootPath || pyobj.webrootPath[0]; - args.server = args.server || args.acmeDiscoveryUrl || pyobj.server; - - args.certPath = args.certPath || pyobj.cert; - args.privkeyPath = args.privkeyPath || pyobj.privkey; - args.chainPath = args.chainPath || pyobj.chain; - args.fullchainPath = args.fullchainPath || pyobj.fullchain; - - //, workDir: args.workDir - //, logsDir: args.logsDir - args.rsaKeySize = args.rsaKeySize || pyobj.rsaKeySize; - args.http01Port = args.http01Port || pyobj.http01Port; - args.domainKeyPath = args.domainPrivateKeyPath || args.domainKeyPath || args.keyPath || pyobj.keyPath; - - return writeRenewalConfig(args); - }); -} - -function writeCertificateAsync(args, defaults, handlers) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } - } - - log("[le/core.js] got certificate!"); - - var obj = args.pyobj; - var result = args.pems; - - result.fullchain = result.cert + '\n' + (result.chain || result.ca); - obj.checkpoints = parseInt(obj.checkpoints, 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.privkeyPath || obj.privkey - //|| args.domainPrivateKeyPath || args.domainKeyPath || obj.keyPath - || path.join(liveDir, 'privkey.pem'); - - log('[le/core.js] privkeyPath', privkeyPath); - - var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]); - - var checkpoints = obj.checkpoints.toString(); - var certArchive = path.join(archiveDir, 'cert' + checkpoints + '.pem'); - var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoints + '.pem'); - var chainArchive = path.join(archiveDir, 'chain'+ checkpoints + '.pem'); - var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoints + '.pem'); - - return mkdirpAsync(archiveDir).then(function () { - return PromiseA.all([ - sfs.writeFileAsync(certArchive, result.cert, 'ascii') - , sfs.writeFileAsync(chainArchive, (result.chain || result.ca), 'ascii') - , sfs.writeFileAsync(fullchainArchive, result.fullchain, 'ascii') - , sfs.writeFileAsync( - privkeyArchive - // TODO nix args.key, args.domainPrivateKeyPem ?? - , (result.privkey || result.key) || RSA.exportPrivatePem(args.domainKeypair) - , 'ascii' - ) - ]); - }).then(function () { - return mkdirpAsync(liveDir); - }).then(function () { - return PromiseA.all([ - sfs.writeFileAsync(certPath, result.cert, 'ascii') - , sfs.writeFileAsync(chainPath, (result.chain || result.ca), 'ascii') - , sfs.writeFileAsync(fullchainPath, result.fullchain, 'ascii') - , sfs.writeFileAsync( - privkeyPath - // TODO nix args.key, args.domainPrivateKeyPem ?? - , (result.privkey || result.key) || RSA.exportPrivatePem(args.domainKeypair) - , 'ascii' - ) - ]); - }).then(function () { - obj.checkpoints += 1; - args.checkpoints += 1; - - return writeRenewalConfig(args); - }).then(function () { - var getCertInfo = require('./cert-info').getCertInfo; - - // XXX Note: Parsing the certificate info comes at a great cost (~500kb) - var certInfo = getCertInfo(result.cert); - - return { - certPath: certPath - , chainPath: chainPath - , fullchainPath: fullchainPath - , privkeyPath: privkeyPath - - // TODO nix keypair - , keypair: args.domainKeypair - - // TODO nix args.key, args.domainPrivateKeyPem ?? - // some ambiguity here... - , privkey: (result.privkey || result.key) || RSA.exportPrivatePem(args.domainKeypair) - , fullchain: result.fullchain || (result.cert + '\n' + result.chain) - , chain: (result.chain || result.ca) - // especially this one... might be cert only, might be fullchain - , cert: result.cert - - , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() - , expiresAt: Date(certInfo.notAfter.value).valueOf() - , lifetime: defaults.lifetime || handlers.lifetime - }; - }); -} - -function getCertificateAsync(args, defaults, handlers) { - function log() { - if (args.debug || defaults.debug) { - console.log.apply(console, arguments); - } - } - - var account = args.account; - var promise; - var keypairOpts = { public: true, pem: true }; - - log('[le/core.js] domainKeyPath:', args.domainKeyPath); - - promise = fs.readFileAsync(args.domainKeyPath, 'ascii').then(function (pem) { - return RSA.import({ privateKeyPem: pem }); - }, function (/*err*/) { - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { - return mkdirpAsync(path.dirname(args.domainKeyPath)).then(function () { - return fs.writeFileAsync(args.domainKeyPath, keypair.privateKeyPem, 'ascii').then(function () { - return keypair; + return backend.setAccountAsync(args, account).then(function () { + return account; }); }); }); - }); - - return promise.then(function (domainKeypair) { - log("[le/core.js] get certificate"); - - args.domainKeypair = domainKeypair; - //args.registration = domainKey; - - return LeCore.getCertificateAsync({ - debug: args.debug - - , newAuthzUrl: args._acmeUrls.newAuthz - , newCertUrl: args._acmeUrls.newCert - - , accountKeypair: RSA.import(account.keypair) - , domainKeypair: domainKeypair - , 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 (results) { - // { cert, chain, fullchain, privkey } - args.pems = results; - return writeCertificateAsync(args, defaults, handlers); - }); -} - -function getOrCreateDomainCertificate(args, defaults, handlers) { - if (args.duplicate) { - // we're forcing a refresh via 'dupliate: true' - return getCertificateAsync(args, defaults, handlers); } - return fetchFromConfigLiveDir(args).then(function (certs) { - var halfLife = (certs.expiresAt - certs.issuedAt) / 2; + function getAcmeUrls(args) { + var now = Date.now(); - if (!certs || (Date.now() - certs.issuedAt) > halfLife) { - // There is no cert available - // Or the cert is more than half-expired + // 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(args, defaults, handlers) { + function log() { + if (args.debug || defaults.debug) { + console.log.apply(console, arguments); + } + } + + var account = args.account; + var promise; + var keypairOpts = { public: true, pem: true }; + + promise = backend.getPrivatePem(args).then(function (pem) { + return RSA.import({ privateKeyPem: pem }); + }, function (/*err*/) { + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { + keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); + return backend.setPrivatePem(args, keypair); + }); + }); + + return promise.then(function (domainKeypair) { + log("[le/core.js] get certificate"); + + args.domainKeypair = domainKeypair; + //args.registration = domainKey; + + return LeCore.getCertificateAsync({ + debug: args.debug + + , newAuthzUrl: args._acmeUrls.newAuthz + , newCertUrl: args._acmeUrls.newCert + + , accountKeypair: RSA.import(account.keypair) + , domainKeypair: domainKeypair + , 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 = handlers.merge({ domains: [domain] }, defaults); + handlers.tplCopy(copy); + + args.domains = [domain]; + //args.domains = args.domains || [domain]; + if (4 === handlers.setChallenge.length) { + console.warn('[WARNING] deprecated use. Define setChallenge as function (opts, domain, key, val, cb) { }'); + 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 = handlers.merge({ domains: [domain] }, defaults); + handlers.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 (results) { + // { cert, chain, fullchain, privkey } + args.pems = results; + return backend.setRegistration(args, defaults, handlers); + }); + } + + function getOrCreateDomainCertificate(args, defaults, handlers) { + if (args.duplicate) { + // we're forcing a refresh via 'dupliate: true' return getCertificateAsync(args, defaults, handlers); } - return PromiseA.reject(new Error( - "[ERROR] Certificate issued at '" - + new Date(certs.issuedAt).toISOString() + "' and expires at '" - + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '" - + new Date(certs.issuedA + halfLife).toISOString() + "'. Set { duplicate: true } to force." - )); - }); -} + return backend.getRegistration(args).then(function (certs) { + var halfLife = (certs.expiresAt - certs.issuedAt) / 2; -// returns 'account' from lib/accounts { meta, regr, keypair, accountId (id) } -function getOrCreateAcmeAccount(args, defaults, handlers) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } + if (!certs || (Date.now() - certs.issuedAt) > halfLife) { + // There is no cert available + // Or the cert is more than half-expired + return getCertificateAsync(args, defaults, handlers); + } + + return PromiseA.reject(new Error( + "[ERROR] Certificate issued at '" + + new Date(certs.issuedAt).toISOString() + "' and expires at '" + + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '" + + new Date(certs.issuedA + halfLife).toISOString() + "'. Set { duplicate: true } to force." + )); + }); } - var pyconf = PromiseA.promisifyAll(require('pyconf')); - - return pyconf.readFileAsync(args.renewalPath).then(function (renewal) { - var accountId = renewal.account; - renewal = renewal.account; - - return accountId; - }, function (err) { - if ("ENOENT" === err.code) { - log("[le/core.js] try email"); - return Accounts.getAccountIdByEmail(args, handlers); + // returns 'account' from lib/accounts { meta, regr, keypair, accountId (id) } + function getOrCreateAcmeAccount(args, defaults, handlers) { + function log() { + if (args.debug) { + console.log.apply(console, arguments); + } } - return PromiseA.reject(err); - }).then(function (accountId) { + return backend.getAccountId(args).then(function (accountId) { - // Note: the ACME urls are always fetched fresh on purpose - return getAcmeUrls(args).then(function (urls) { - args._acmeUrls = urls; + // Note: the ACME urls are always fetched fresh on purpose + return getAcmeUrls(args).then(function (urls) { + args._acmeUrls = urls; - if (accountId) { - log('[le/core.js] use account'); + if (accountId) { + log('[le/core.js] use account'); - args.accountId = accountId; - return Accounts.getAccount(args, handlers); - } else { - log('[le/core.js] create account'); - return Accounts.createAccount(args, handlers); + args.accountId = accountId; + return Accounts.getAccount(args, handlers); + } else { + log('[le/core.js] create account'); + return Accounts.createAccount(args, handlers); + } + }); + }).then(function (account) { + /* + if (renewal.account !== account) { + // the account has become corrupt, re-register + return; } + */ + log('[le/core.js] created account'); + return account; }); - }).then(function (account) { - /* - if (renewal.account !== account) { - // the account has become corrupt, re-register - return; - } - */ - log('[le/core.js] created account'); - return account; - }); -/* - 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.renewalDir = args.renewalDir || ':config/renewal/'; - 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); - - var url = require('url'); - var acmeLocation = url.parse(copy.server); - var acmeHostpath = path.join(acmeLocation.hostname, acmeLocation.pathname); - copy.renewalPath = copy.renewalPath || path.join(copy.configDir, 'renewal', copy.domains[0] + '.conf'); - copy.accountsDir = copy.accountsDir || path.join(copy.configDir, 'accounts', acmeHostpath); + registerAsync: function (args) { + var copy = handlers.merge(args, defaults); + handlers.tplCopy(copy); return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { copy.account = account; - return getOrCreateRenewal(copy).then(function (pyobj) { + return backend.getOrCreateRenewal(copy).then(function (pyobj) { copy.pyobj = pyobj; return getOrCreateDomainCertificate(copy, defaults, handlers); @@ -459,55 +233,19 @@ module.exports.create = function (defaults, handlers) { return PromiseA.reject(err); }); } - , fetchAsync: function (args) { - var copy = merge(args, defaults); - tplCopy(copy); - - return fetchFromConfigLiveDir(copy, defaults); + , getOrCreateAccount: function (args) { + // TODO + keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.publicKeyPem = RSA.exportPublicPem(keypair); + return createAccount(args, handlers); } , configureAsync: function (hargs) { - hargs.renewalPath = hargs.renewalPath || ':config/renewal/:hostname.conf'; var copy = merge(hargs, defaults); tplCopy(copy); return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { copy.account = account; - return getOrCreateRenewal(copy); - }); - } - , getConfigAsync: function (hargs) { - hargs.renewalPath = hargs.renewalPath || ':config/renewal/:hostname.conf'; - hargs.domains = []; - - var copy = merge(hargs, defaults); - tplCopy(copy); - - return readRenewalConfig(copy).then(function (pyobj) { - var exists = pyobj.checkpoints >= 0; - if (!exists) { - return null; - } - - return pyobj; - }); - } - , getConfigsAsync: function (hargs) { - hargs.renewalDir = hargs.renewalDir || ':config/renewal/'; - hargs.renewalPath = hargs.renewalPath || ':config/renewal/:hostname.conf'; - hargs.domains = []; - - var copy = merge(hargs, defaults); - tplCopy(copy); - - return fs.readdirAsync(copy.renewalDir).then(function (nodes) { - nodes = nodes.filter(function (node) { - return /^[a-z0-9]+.*\.conf$/.test(node); - }); - - return PromiseA.all(nodes.map(function (node) { - copy.domains = [node.replace(/\.conf$/, '')]; - return wrapped.getConfigAsync(copy); - })); + return backend.getOrCreateRenewal(copy); }); } }; diff --git a/lib/default-handlers.js b/lib/default-handlers.js index 17852a2..9982539 100644 --- a/lib/default-handlers.js +++ b/lib/default-handlers.js @@ -3,11 +3,11 @@ var fs = require('fs'); var path = require('path'); -module.exports.agreeToTerms = function (args, agree) { - agree(null, args.agreeTos); +module.exports.agreeToTerms = function (args, agreeCb) { + agreeCb(null, args.agreeTos); }; -module.exports.setChallenge = function (args, challengePath, keyAuthorization, done) { +module.exports.setChallenge = function (args, domain, challengePath, keyAuthorization, done) { //var hostname = args.domains[0]; var mkdirp = require('mkdirp'); @@ -26,14 +26,14 @@ module.exports.setChallenge = function (args, challengePath, keyAuthorization, d }); }; -module.exports.getChallenge = function (args, key, done) { +module.exports.getChallenge = function (args, domain, key, done) { //var hostname = args.domains[0]; //console.log("getting the challenge", args, key); fs.readFile(path.join(args.webrootPath, key), 'utf8', done); }; -module.exports.removeChallenge = function (args, key, done) { +module.exports.removeChallenge = function (args, domain, key, done) { //var hostname = args.domains[0]; fs.unlink(path.join(args.webrootPath, key), done); diff --git a/lib/pycompat.js b/lib/pycompat.js new file mode 100644 index 0000000..a19bfd6 --- /dev/null +++ b/lib/pycompat.js @@ -0,0 +1,517 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var mkdirpAsync = PromiseA.promisify(require('mkdirp')); +var path = require('path'); +var fs = PromiseA.promisifyAll(require('fs')); +var sfs = require('safe-replace'); + +var fetchFromConfigLiveDir = function (args) { + // TODO NO HARD-CODED DEFAULTS + if (!args.fullchainPath || !args.privkeyPath || !args.certPath || !args.chainPath) { + console.warn("missing one or more of args.privkeyPath, args.fullchainPath, args.certPath, args.chainPath"); + console.warn("hard-coded conventional pathnames were for debugging and are not a stable part of the API"); + } + + //, fs.readFileAsync(fullchainPath, 'ascii') + // note: if this ^^ gets added back in, the arrays below must change + return PromiseA.all([ + fs.readFileAsync(args.privkeyPath, 'ascii') // 0 + , fs.readFileAsync(args.certPath, 'ascii') // 1 + , fs.readFileAsync(args.chainPath, 'ascii') // 2 + + // stat the file, not the link + , fs.statAsync(args.certPath) // 3 + ]).then(function (arr) { + var cert = arr[1]; + var getCertInfo = require('./cert-info').getCertInfo; + + // XXX Note: Parsing the certificate info comes at a great cost (~500kb) + var certInfo = getCertInfo(cert); + + return { + key: arr[0] // privkey.pem + , privkey: arr[0] // privkey.pem + + , fullchain: arr[1] + '\n' + arr[2] // fullchain.pem + , cert: cert // cert.pem + + , chain: arr[2] // chain.pem + , ca: arr[2] // chain.pem + + , privkeyPath: args.privkeyPath + , fullchainPath: args.fullchainPath + , certPath: args.certPath + , chainPath: args.chainPath + + //, issuedAt: arr[3].mtime.valueOf() + , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() + , expiresAt: Date(certInfo.notAfter.value).valueOf() + , lifetime: args.lifetime + }; + }, function (err) { + if (args.debug) { + console.error("[letsencrypt/lib/common.js] fetchFromDisk"); + console.error(err.stack); + } + return null; + }); +}; + +function getAccount(args) { + var accountId = args.accountId; + var accountDir = path.join(args.accountsDir, accountId); + var files = {}; + var configs = [ 'meta.json', 'private_key.json', 'regr.json' ]; + + return PromiseA.all(configs.map(function (filename) { + var keyname = filename.slice(0, -5); + + return fs.readFileAsync(path.join(accountDir, filename), 'utf8').then(function (text) { + var data; + + try { + data = JSON.parse(text); + } catch(e) { + files[keyname] = { error: e }; + return; + } + + files[keyname] = data; + }, function (err) { + files[keyname] = { error: err }; + }); + })).then(function () { + var err; + + if (!Object.keys(files).every(function (key) { + return !files[key].error; + }) || !files.private_key || !files.private_key.n) { + err = new Error("Account '" + accountId + "' was corrupt. No big deal (I think?). Creating a new one..."); + err.code = 'E_ACCOUNT_CORRUPT'; + err.data = files; + return PromiseA.reject(err); + } + + //files.private_key; + //files.regr; + //files.meta; + files.accountId = accountId; // preserve current account id + files.id = accountId; + files.keypair = { privateKeyJwk: files.private_key }; + + return files; + }); +} + +function getAccountIdByEmail(args) { + // If we read 10,000 account directories looking for + // just one email address, that could get crazy. + // We should have a folder per email and list + // each account as a file in the folder + // TODO + var email = args.email; + if ('string' !== typeof email) { + if (args.debug) { + console.log("[LE] No email given"); + } + return PromiseA.resolve(null); + } + return fs.readdirAsync(args.accountsDir).then(function (nodes) { + if (args.debug) { + console.log("[LE] arg.accountsDir success"); + } + + return PromiseA.all(nodes.map(function (node) { + return fs.readFileAsync(path.join(args.accountsDir, node, 'regr.json'), 'utf8').then(function (text) { + var regr = JSON.parse(text); + regr.__accountId = node; + + return regr; + }); + })).then(function (regrs) { + var accountId; + + /* + if (args.debug) { + console.log('read many regrs'); + console.log('regrs', regrs); + } + */ + + regrs.some(function (regr) { + return regr.body.contact.some(function (contact) { + var match = contact.toLowerCase() === 'mailto:' + email.toLowerCase(); + if (match) { + accountId = regr.__accountId; + return true; + } + }); + }); + + if (!accountId) { + return null; + } + + return accountId; + }); + }).then(function (accountId) { + return accountId; + }, function (err) { + if ('ENOENT' === err.code) { + // ignore error + return null; + } + + return PromiseA.reject(err); + }); +} + +function readRenewalConfig(args) { + var pyconf = PromiseA.promisifyAll(require('pyconf')); + + return pyconf.readFileAsync(args.renewalPath).then(function (pyobj) { + return pyobj; + }, function () { + return pyconf.readFileAsync(path.join(__dirname, 'renewal.conf.tpl')).then(function (pyobj) { + return pyobj; + }); + }); +} + +function writeRenewalConfig(args) { + function log() { + if (args.debug) { + console.log.apply(console, arguments); + } + } + + var pyobj = args.pyobj; + pyobj.checkpoints = parseInt(pyobj.checkpoints, 10) || 0; + + var pyconf = PromiseA.promisifyAll(require('pyconf')); + + var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); + + var certPath = args.certPath || pyobj.cert || path.join(liveDir, 'cert.pem'); + var fullchainPath = args.fullchainPath || pyobj.fullchain || path.join(liveDir, 'fullchain.pem'); + var chainPath = args.chainPath || pyobj.chain || path.join(liveDir, 'chain.pem'); + var privkeyPath = args.privkeyPath || pyobj.privkey + //|| args.domainPrivateKeyPath || args.domainKeyPath || pyobj.keyPath + || path.join(liveDir, 'privkey.pem'); + + log('[le/core.js] privkeyPath', privkeyPath); + + var updates = { + account: args.account.id + , configDir: args.configDir + , domains: args.domains + + , email: args.email + , tos: args.agreeTos && true + // yes, it's an array. weird, right? + , webrootPath: args.webrootPath && [args.webrootPath] || [] + , server: args.server || args.acmeDiscoveryUrl + + , privkey: privkeyPath + , fullchain: fullchainPath + , cert: certPath + , chain: chainPath + + , http01Port: args.http01Port + , keyPath: args.domainPrivateKeyPath || args.privkeyPath + , rsaKeySize: args.rsaKeySize + , checkpoints: pyobj.checkpoints + /* // 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 + , workDir: args.workDir + , 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 pyobject or + // annotations will be lost + Object.keys(updates).forEach(function (key) { + pyobj[key] = updates[key]; + }); + + return mkdirpAsync(path.dirname(args.renewalPath)).then(function () { + return pyconf.writeFileAsync(args.renewalPath, pyobj); + }).then(function () { + // NOTE + // writing twice seems to causes a bug, + // so instead we re-read the file from the disk + return pyconf.readFileAsync(args.renewalPath); + }); +} + +function getOrCreateRenewal(args) { + return readRenewalConfig(args).then(function (pyobj) { + var minver = pyobj.checkpoints >= 0; + + args.pyobj = pyobj; + + if (!minver) { + args.checkpoints = 0; + pyobj.checkpoints = 0; + return writeRenewalConfig(args); + } + + // args.account.id = pyobj.account + // args.configDir = args.configDir || pyobj.configDir; + + args.checkpoints = pyobj.checkpoints; + + args.agreeTos = (args.agreeTos || pyobj.tos) && true; + args.email = args.email || pyobj.email; + args.domains = args.domains || pyobj.domains; + + // yes, it's an array. weird, right? + args.webrootPath = args.webrootPath || pyobj.webrootPath[0]; + args.server = args.server || args.acmeDiscoveryUrl || pyobj.server; + + args.certPath = args.certPath || pyobj.cert; + args.privkeyPath = args.privkeyPath || pyobj.privkey; + args.chainPath = args.chainPath || pyobj.chain; + args.fullchainPath = args.fullchainPath || pyobj.fullchain; + + //, workDir: args.workDir + //, logsDir: args.logsDir + args.rsaKeySize = args.rsaKeySize || pyobj.rsaKeySize; + args.http01Port = args.http01Port || pyobj.http01Port; + args.domainKeyPath = args.domainPrivateKeyPath || args.domainKeyPath || args.keyPath || pyobj.keyPath; + + return writeRenewalConfig(args); + }); +} + +function writeCertificateAsync(args) { + function log() { + if (args.debug) { + console.log.apply(console, arguments); + } + } + + log("[le/core.js] got certificate!"); + + var obj = args.pyobj; + var pems = args.pems; + + pems.fullchain = pems.cert + '\n' + (pems.chain || pems.ca); + obj.checkpoints = parseInt(obj.checkpoints, 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.privkeyPath || obj.privkey + //|| args.domainPrivateKeyPath || args.domainKeyPath || obj.keyPath + || path.join(liveDir, 'privkey.pem'); + + log('[le/core.js] privkeyPath', privkeyPath); + + var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]); + + var checkpoints = obj.checkpoints.toString(); + var certArchive = path.join(archiveDir, 'cert' + checkpoints + '.pem'); + var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoints + '.pem'); + var chainArchive = path.join(archiveDir, 'chain'+ checkpoints + '.pem'); + var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoints + '.pem'); + + return mkdirpAsync(archiveDir).then(function () { + return PromiseA.all([ + sfs.writeFileAsync(certArchive, pems.cert, 'ascii') + , sfs.writeFileAsync(chainArchive, (pems.chain || pems.ca), 'ascii') + , sfs.writeFileAsync(fullchainArchive, pems.fullchain, 'ascii') + , sfs.writeFileAsync( + privkeyArchive + // TODO nix args.key, args.domainPrivateKeyPem ?? + , (pems.privkey || pems.key) // || RSA.exportPrivatePem(args.domainKeypair) + , 'ascii' + ) + ]); + }).then(function () { + return mkdirpAsync(liveDir); + }).then(function () { + return PromiseA.all([ + sfs.writeFileAsync(certPath, pems.cert, 'ascii') + , sfs.writeFileAsync(chainPath, (pems.chain || pems.ca), 'ascii') + , sfs.writeFileAsync(fullchainPath, pems.fullchain, 'ascii') + , sfs.writeFileAsync( + privkeyPath + // TODO nix args.key, args.domainPrivateKeyPem ?? + , (pems.privkey || pems.key) // || RSA.exportPrivatePem(args.domainKeypair) + , 'ascii' + ) + ]); + }).then(function () { + obj.checkpoints += 1; + args.checkpoints += 1; + + return writeRenewalConfig(args); + }).then(function () { + var getCertInfo = require('./cert-info').getCertInfo; + + // XXX Note: Parsing the certificate info comes at a great cost (~500kb) + var certInfo = getCertInfo(pems.cert); + + return { + certPath: certPath + , chainPath: chainPath + , fullchainPath: fullchainPath + , privkeyPath: privkeyPath + + // TODO nix keypair + , keypair: args.domainKeypair + + // TODO nix args.key, args.domainPrivateKeyPem ?? + // some ambiguity here... + , privkey: (pems.privkey || pems.key) //|| RSA.exportPrivatePem(args.domainKeypair) + , fullchain: pems.fullchain || (pems.cert + '\n' + pems.chain) + , chain: (pems.chain || pems.ca) + // especially this one... might be cert only, might be fullchain + , cert: pems.cert + + , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() + , expiresAt: Date(certInfo.notAfter.value).valueOf() + }; + }); +} + +module.exports.create = function (/*defaults*/) { + function getConfigAsync(copy) { + copy.domains = []; + + return readRenewalConfig(copy).then(function (pyobj) { + var exists = pyobj.checkpoints >= 0; + if (!exists) { + return null; + } + + return pyobj; + }); + } + + return { + getDefaults: function () { +LE.tplConfigDir = require('./lib/common').tplConfigDir; + // replaces strings of workDir, certPath, etc + // if they have :config/etc/live or :conf/etc/archive + // to instead have the path of the configDir + LE.tplConfigDir(defaults.configDir, defaults); + return { + configDir: require('homedir')() + '/letsencrypt/etc' // /etc/letsencrypt/ + , logsDir: ':config/log' // /var/log/letsencrypt/ + , workDir: leCore.workDir // /var/lib/letsencrypt/ + , accountsDir: ':config/accounts/:server' + , renewalPath: ':config/renewal/:hostname.conf' + , renewalDir: ':config/renewal/' + , privkeyPath: ':config/live/:hostname/privkey.pem' + , fullchainPath: ':config/live/:hostname/fullchain.pem' + , certPath: ':config/live/:hostname/cert.pem' + , chainPath: ':config/live/:hostname/chain.pem' + , renewalPath: ':config/renewal/:hostname.conf' + , accountsDir: ':config/accounts/:server' + }; + } + , getPrivatePemAsync: function (args) { + return fs.readFileAsync(args.domainKeyPath, 'ascii'); + } + , setPrivatePemAsync: function (args, keypair) { + return mkdirpAsync(path.dirname(args.domainKeyPath)).then(function () { + return fs.writeFileAsync(args.domainKeyPath, keypair.privateKeyPem, 'ascii').then(function () { + return keypair; + }); + }); + } + , setRegistrationAsync: function (args) { + return writeCertificateAsync(args); + } + + , getRegistrationAsync: function (args) { + return fetchFromConfigLiveDir(args); + } + , getOrCreateRenewalAsync: function (args) { + return getOrCreateRenewal(args); + } + , getConfigAsync: getConfigAsync + , getConfigsAsync: function (copy) { + copy.domains = []; + + return fs.readdirAsync(copy.renewalDir).then(function (nodes) { + nodes = nodes.filter(function (node) { + return /^[a-z0-9]+.*\.conf$/.test(node); + }); + + return PromiseA.all(nodes.map(function (node) { + copy.domains = [node.replace(/\.conf$/, '')]; + return getConfigAsync(copy); + })); + }); + } + , fetchAsync: function (args) { + return fetchFromConfigLiveDir(args); + } + , getAccountIdByEmailAsync: getAccountIdByEmail + , getAccountAsync: getAccount + , setAccountAsync: function (args, account) { + var isoDate = new Date().toISOString(); + var os = require("os"); + var localname = os.hostname(); + var accountDir = path.join(args.accountsDir, account.accountId); + + account.meta = account.meta || { + creation_host: localname + , creation_dt: isoDate + }; + + return mkdirpAsync(accountDir).then(function () { + var RSA = require('rsa-compat').RSA; + + // TODO abstract file writing + return PromiseA.all([ + // meta.json {"creation_host": "ns1.redirect-www.org", "creation_dt": "2015-12-11T04:14:38Z"} + fs.writeFileAsync(path.join(accountDir, 'meta.json'), JSON.stringify(account.meta), 'utf8') + // private_key.json { "e", "d", "n", "q", "p", "kty", "qi", "dp", "dq" } + , fs.writeFileAsync(path.join(accountDir, 'private_key.json'), JSON.stringify(RSA.exportPrivateJwk(account.keypair)), 'utf8') + // regr.json: + /* + { body: { contact: [ 'mailto:coolaj86@gmail.com' ], + agreement: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', + key: { e: 'AQAB', kty: 'RSA', n: '...' } }, + uri: 'https://acme-v01.api.letsencrypt.org/acme/reg/71272', + new_authzr_uri: 'https://acme-v01.api.letsencrypt.org/acme/new-authz', + terms_of_service: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf' } + */ + , fs.writeFileAsync(path.join(accountDir, 'regr.json'), JSON.stringify(account.regr), 'utf8') + ]); + }); + } + , getAccountIdAsync: function (args) { + var pyconf = PromiseA.promisifyAll(require('pyconf')); + + return pyconf.readFileAsync(args.renewalPath).then(function (renewal) { + var accountId = renewal.account; + renewal = renewal.account; + + return accountId; + }, function (err) { + if ("ENOENT" === err.code) { + return getAccountIdByEmail(args); + } + + return PromiseA.reject(err); + }); + } + }; +}; diff --git a/package.json b/package.json index 70f9046..134140d 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,6 @@ }, "homepage": "https://github.com/Daplie/node-letsencrypt#readme", "devDependencies": { - "express": "^4.13.3", - "localhost.daplie.com-certificates": "^1.1.2" }, "optionalDependencies": {}, "dependencies": { From 805f7903f7ddafc878f4bedca4a94e91ed9b9569 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 03:03:27 -0400 Subject: [PATCH 02/47] document v2 api --- README.md | 379 ++++++++++++++++++++++++++---------------------------- 1 file changed, 183 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 222264b..48714a9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ letsencrypt =========== -Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS Certificates for node.js +Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS / TLS / SSL Certificates for node.js * [Automatic HTTPS with ExpressJS](https://github.com/Daplie/letsencrypt-express) * [Automatic live renewal](https://github.com/Daplie/letsencrypt-express#how-automatic) @@ -30,8 +30,9 @@ STOP **These aren't the droids you're looking for.** -This is a low-level library for implementing CLIs, +This is a **low-level library** for implementing ACME / LetsEncrypt Clients, CLIs, system tools, and abstracting storage backends (file vs db, etc). + This is not the thing to use in your webserver directly. ### Use [letsencrypt-express](https://github.com/Daplie/letsencrypt-express) if... @@ -57,224 +58,210 @@ You are planning to use one of these: * `cmd.exe` * `PowerShell` +CONTINUE +======== + +If you're sure you're at the right place, here's what you need to know now: + Install -======= +------- ```bash -npm install --save letsencrypt +npm install --save letsencrypt@2.x +npm install --save le-store-certbot@2.x +npm install --save le-challenge-fs@2.x ``` Usage -===== +----- -### letsencrypt +It's very simple and easy to use, but also very complete and easy to extend and customize. -There are **NO DEFAULTS**. +### Overly Simplified Example -A number of **constants** (such as LE.stagingServerUrl and LE.configDir) -are exported for your convenience, but all required options must be specified by the library invoking the call. - -Open an issue if you need a variable for something that isn't there yet. +Against my better judgement I'm providing a terribly oversimplified exmaple +of how to use this library: ```javascript -var LE = require('letsencrypt'); +var app = express(); +var le = require('letsencrypt').create({ server: 'staging' }); -var config = { - server: LE.stagingServerUrl // or LE.productionServerUrl +app.use('/', le.middleware()); -, configDir: require('homedir')() + '/letsencrypt/etc' // or /etc/letsencrypt or wherever - -, privkeyPath: ':config/live/:hostname/privkey.pem' // -, fullchainPath: ':config/live/:hostname/fullchain.pem' // Note: both that :config and :hostname -, certPath: ':config/live/:hostname/cert.pem' // will be templated as expected -, chainPath: ':config/live/:hostname/chain.pem' // - -, debug: false -}; - - -var handlers = { - setChallenge: function (opts, hostname, key, val, cb) {} // called during the ACME server handshake, before validation -, removeChallenge: function (opts, hostname, key, cb) {} // called after validation on both success and failure -, getChallenge: function (opts, hostname, key, cb) {} // this is special because it is called by the webserver - // (see letsencrypt-cli/bin & letsencrypt-express/standalone), - // not by the library itself - -, agreeToTerms: function (tosUrl, cb) {} // gives you an async way to expose the legal agreement - // (terms of use) to your users before accepting -}; - - -var le = LE.create(config, handlers); - - // checks :conf/renewal/:hostname.conf -le.register({ // and either renews or registers - - domains: ['example.com'] // CHANGE TO YOUR DOMAIN -, email: 'user@email.com' // CHANGE TO YOUR EMAIL -, agreeTos: false // set to true to automatically accept an agreement - // which you have pre-approved (not recommended) -}, function (err) { - - if (err) { - // Note: you must have a webserver running - // and expose handlers.getChallenge to it - // in order to pass validation - // See letsencrypt-cli and or letsencrypt-express - console.error('[Error]: node-letsencrypt/examples/standalone'); - console.error(err.stack); - } else { - console.log('success'); - } -}); -``` - -**However**, due to the nature of what this library does, it has a few more "moving parts" -than what makes sense to show in a minimal snippet. - -API -=== - -```javascript -LetsEncrypt.create(leConfig, handlers, backend) // wraps a given "backend" (the python or node client) -LetsEncrypt.stagingServer // string of staging server for testing - -le.middleware() // middleware for serving webrootPath to /.well-known/acme-challenge -le.sniCallback(hostname, function (err, tlsContext) {}) // uses fetch (below) and formats for https.SNICallback -le.register({ domains, email, agreeTos, ... }, cb) // registers or renews certs for a domain -le.fetch({domains, email, agreeTos, ... }, cb) // fetches certs from in-memory cache, occasionally refreshes from disk -le.registrationFailureCallback(err, args, certInfo, cb) // called when registration fails (not implemented yet) -``` - -### `LetsEncrypt.create(backend, leConfig, handlers)` - -#### leConfig - -The arguments passed here (typically `webpathRoot`, `configDir`, etc) will be merged with -any `args` (typically `domains`, `email`, and `agreeTos`) and passed to the backend whenever -it is called. - -Typically the backend wrapper will already merge any necessary backend-specific arguments. - -**Example**: -```javascript -{ webrootPath: __dirname, '/acme-challenge' -, fullchainTpl: '/live/:hostname/fullchain.pem' -, privkeyTpl: '/live/:hostname/fullchain.pem' -, configDir: '/etc/letsencrypt' -} -``` - -Note: `webrootPath` can be set as a default, semi-locally with `webrootPathTpl`, or per -registration as `webrootPath` (which overwrites `leConfig.webrootPath`). - -#### handlers *optional* - -`h.setChallenge(hostnames, name, value, cb)`: - -default is to write to fs - -`h.getChallenge(hostnames, value cb)` - -default is to read from fs - -`h.sniRegisterCallback(args, currentCerts, cb)` - -The default is to immediately call `cb(null, null)` and register (or renew) in the background -during the `SNICallback` phase. Right now it isn't reasonable to renew during SNICallback, -but around February when it is possible to use ECDSA keys (as opposed to RSA at present), -registration will take very little time. - -This will not be called while another registration is already in progress. - -### `le.middleware()` - -An express handler for `/.well-known/acme-challenge/`. -Will call `getChallenge([hostname], key, cb)` if present or otherwise read `challenge` from disk. - -Example: -```javascript -app.use('/', le.middleware()) -``` - -### `le.sniCallback(hostname, function (err, tlsContext) {});` - -Will call `fetch`. If fetch does not return certificates or returns expired certificates -it will call `sniRegisterCallback(args, currentCerts, cb)` and then return the error, -the new certificates, or call `fetch` a final time. - -Example: -```javascript -var server = require('https').createServer({ SNICallback: le.sniCallback, cert: '...', key: '...' }); -server.on('request', app); -``` - -### `le.register({ domains, email, agreeTos, ... }, cb)` - -Get certificates for a domain - -Example: -```javascript -le.register({ - domains: ['example.com', 'www.example.com'] -, email: 'user@example.com' -, webrootPath: '/srv/www/example.com/public' -, agreeTos: true -}, function (err, certs) { - // err is some error - - console.log(certs); - /* - { cert: "contents of fullchain.pem" - , key: "contents of privkey.pem" - , renewedAt: - , duration: - } - */ -}); -``` - -### `le.isValidDomain(hostname)` - -returns `true` if `hostname` is a valid ascii or punycode domain name. - -(also exposed on the main exported module as `LetsEncrypt.isValidDomain()`) - -### `le.fetch(args, cb)` - -Used internally, but exposed for convenience. - -Checks in-memory cache of certificates for `args.domains` and calls then calls `backend.fetch(args, cb)` -**after** merging `args` if necessary. - -### `le.registrationFailureCallback(err, args, certInfo, cb)` - -Not yet implemented - - -This is what `args` looks like: - -```javascript -{ domains: ['example.com', 'www.example.com'] +var reg = { + domains: ['example.com'] , email: 'user@email.com' , agreeTos: true -, configDir: '/etc/letsencrypt' -, fullchainTpl: '/live/:hostname/fullchain.pem' // :hostname will be replaced with the domainname -, privkeyTpl: '/live/:hostname/privkey.pem' -, webrootPathTpl: '/srv/www/:hostname/public' -, webrootPath: '/srv/www/example.com/public' // templated from webrootPathTpl +}; + +le.register(reg, function (err, results) { + if (err) { + console.error(err.stack); + return; + } + + console.log(results); +}); +``` + +### Useful Example + +The configuration consists of 3 components: + +* Storage Backend (search npm for projects starting with 'le-store-') +* ACME Challenge Handlers (search npm for projects starting with 'le-challenge-') +* Letsencryt Config (this is all you) + +```javascript +'use strict'; + +var LE = require('letsencrypt'); +var le; + + +// Storage Backend +var leStore = require('le-store-certbot').create({ + configDir: '~/letsencrypt/etc' // or /etc/letsencrypt or wherever +, debug: false +}); + + +// ACME Challenge Handlers +var leChallenger = require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt/var/' // or template string such as +, debug: false // '/srv/www/:hostname/.well-known/acme-challenge' +}); + + +function leAgree(opts, agreeCb) { + // opts = { email, domains, tosUrl } + agreeCb(null, opts.tosUrl); +} + +le = LE.create({ + server: LE.stagingServerUrl // or LE.productionServerUrl +, store: leStore // handles saving of config, accounts, and certificates +, challenger: leChallenger // handles /.well-known/acme-challege keys and tokens +, agreeToTerms: leAgree // hook to allow user to view and accept LE TOS +, debug: false +}); + + +// If using express you should use the middleware +// app.use('/', le.middleware()); +// +// Otherwise you should use the wrapped getChallenge: +// le.getChallenge(domain, key, val, done) + + + +// Check in-memory cache of certificates for the named domain +le.exists({ domain: 'example.com' }).then(function (results) { + if (results) { + // we already have certificates + return; + } + + // Register Certificate manually + le.register( + + { domains: ['example.com'] // CHANGE TO YOUR DOMAIN (list for SANS) + , email: 'user@email.com' // CHANGE TO YOUR EMAIL + , agreeTos: '' // set to tosUrl string to pre-approve (and skip agreeToTerms) + , rsaKeySize: 2048 // 1024 or 2048 + , challengeType: 'http-01' // http-01, tls-sni-01, or dns-01 + } + + , function (err, results) { + if (err) { + // Note: you must either use le.middleware() with express, + // manually use le.getChallenge(domain, key, val, done) + // or have a webserver running and responding + // to /.well-known/acme-challenge at `webrootPath` + console.error('[Error]: node-letsencrypt/examples/standalone'); + console.error(err.stack); + return; + } + + console.log('success'); + } + + ); + +}); +``` + +Here's what `results` looks like: + +```javascript +{ privkey: '' // PEM encoded private key +, cert: '' // PEM encoded cert +, chain: '' // PEM encoded intermediate cert +, fullchain: '' // cert + chain +, issuedAt: 0 // notBefore date (in ms) parsed from cert +, expiresAt: 0 // notAfter date (in ms) parsed from cert } ``` -This is what the implementation should look like: +API +--- -(it's expected that the client will follow the same conventions as -the python client, but it's not necessary) +The full end-user API is exposed in the example above and includes all relevant options. + +### Helper Functions + +We do expose a few helper functions: + +* LE.validDomain(hostname) // returns '' or the hostname string if it's a valid ascii or punycode domain name + +TODO fetch domain tld list + +Developer API +------------- + +If you are developing an `le-store-*` or `le-challenge-*` plugin you need to be aware of +additional internal API expectations. + +**IMPORTANT**: + +Use `v2.0.0` as your initial version - NOT v0.1.0 and NOT v1.0.0 and NOT v3.0.0. +This is to indicate that your module is compatible with v2.x of node-letsencrypt. + +Since the public API for your module is defined by node-letsencrypt the major version +should be kept in sync. + +### store implementation + +TODO double check and finish + +* accounts + * accounts.byDomain + * accounts.all + * accounts.get + * accounts.exists +* certs + * certs.byDomain + * certs.all + * certs.get + * certs.exists + +### challenge implementation + +TODO finish + +* setChallenge(opts, domain, key, value, done); // opts will be saved with domain/key +* getChallenge(domain, key, done); // opts will be retrieved by domain/key +* removeChallenge(domain, key, done); // opts will be retrieved by domain/key Change History ============== +* v2.0.0 - Aug 5th 2016 + * major refactor + * simplified API + * modular pluigns + * knock out bugs * v1.5.0 now using letiny-core v2.0.0 and rsa-compat * v1.4.x I can't remember... but it's better! * v1.1.0 Added letiny-core, removed node-letsencrypt-python From 2d647e734949f7a4a7450d6fc9385be45a21cd4d Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 03:20:19 -0400 Subject: [PATCH 03/47] simplify STOP --- README.md | 69 ++++++++++++++++++++++++------------------------------- 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 48714a9..fb83e6f 100644 --- a/README.md +++ b/README.md @@ -28,35 +28,21 @@ Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS / TLS / SSL Certificate STOP ==== -**These aren't the droids you're looking for.** +> **These aren't the droids you're looking for.** This is a **low-level library** for implementing ACME / LetsEncrypt Clients, CLIs, system tools, and abstracting storage backends (file vs db, etc). -This is not the thing to use in your webserver directly. +For `express`, raw `https` or `spdy`, or `restify` (same as raw https) see +[**letsencrypt-express**](https://github.com/Daplie/letsencrypt-express). -### Use [letsencrypt-express](https://github.com/Daplie/letsencrypt-express) if... +For `hapi` see [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi). -you are planning to use one of these: +For `koa` or `rill` +see [letsencrypt-koa](https://github.com/Daplie/letsencrypt-koa). - * `express` - * `connect` - * raw `https` - * raw `spdy` - * `restify` (same as raw https) - * `hapi` See [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi) - * `koa` See [letsencrypt-koa](https://github.com/Daplie/letsencrypt-koa) - * `rill` (similar to koa example) - -### Use [letsencrypt-cli](https://github.com/Daplie/letsencrypt-cli) if... - -You are planning to use one of these: - - * `bash` - * `fish` - * `zsh` - * `cmd.exe` - * `PowerShell` +For `bash`, `fish`, `zsh`, `cmd.exe`, `PowerShell` +see [**letsencrypt-cli**](https://github.com/Daplie/letsencrypt-cli). CONTINUE ======== @@ -66,6 +52,12 @@ If you're sure you're at the right place, here's what you need to know now: Install ------- +`letsencrypt` requires at least two plugins: +one for managing certificate storage and the other for handling ACME challenges. + +The default storage plugin is [`le-store-certbot`](https://github.com/Daplie/le-store-certbot) +and the default challenger is [`le-challenge-fs`](https://github.com/Daplie/le-challenge-fs). + ```bash npm install --save letsencrypt@2.x npm install --save le-store-certbot@2.x @@ -83,28 +75,27 @@ Against my better judgement I'm providing a terribly oversimplified exmaple of how to use this library: ```javascript -var app = express(); - var le = require('letsencrypt').create({ server: 'staging' }); -app.use('/', le.middleware()); - -var reg = { - domains: ['example.com'] -, email: 'user@email.com' -, agreeTos: true -}; - -le.register(reg, function (err, results) { - if (err) { - console.error(err.stack); - return; +le.register( + { domains: ['example.com'], email: 'user@email.com', agreeTos: true } +, function (err, results) { + console.log(err, results); } - - console.log(results); -}); +); ``` +You also need some sort of server to handle the acme challenge: + +``` +var app = express(); +app.use('/', le.middleware()); +``` + +Note: The `webrootPath` string is a template. +Any occurance of `:hostname` will be replaced +with the domain for which we are requested certificates. + ### Useful Example The configuration consists of 3 components: From ebe2a1fdd2ed44bd5e7b3333994955682627dd34 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 03:27:16 -0400 Subject: [PATCH 04/47] syntax highlighting --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb83e6f..0cdc6d2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ | [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi) | -letsencrypt +letsencrypt (v2) =========== Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS / TLS / SSL Certificates for node.js @@ -87,7 +87,7 @@ le.register( You also need some sort of server to handle the acme challenge: -``` +```javascript var app = express(); app.use('/', le.middleware()); ``` From 9c27ef2876bc47b4372dd4cc6202cb9430ccf7d5 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 04:13:25 -0400 Subject: [PATCH 05/47] remove cruft --- lib/default-handlers.js | 40 ---- lib/pycompat.js | 517 ---------------------------------------- lib/renewal.conf.tpl | 68 ------ tests/pyconf-write.js | 50 ---- 4 files changed, 675 deletions(-) delete mode 100644 lib/default-handlers.js delete mode 100644 lib/pycompat.js delete mode 100644 lib/renewal.conf.tpl delete mode 100644 tests/pyconf-write.js diff --git a/lib/default-handlers.js b/lib/default-handlers.js deleted file mode 100644 index 9982539..0000000 --- a/lib/default-handlers.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -var fs = require('fs'); -var path = require('path'); - -module.exports.agreeToTerms = function (args, agreeCb) { - agreeCb(null, args.agreeTos); -}; - -module.exports.setChallenge = function (args, domain, challengePath, keyAuthorization, done) { - //var hostname = args.domains[0]; - var mkdirp = require('mkdirp'); - - // TODO should be args.webrootPath - //console.log('args.webrootPath, challengePath'); - //console.log(args.webrootPath, challengePath); - mkdirp(args.webrootPath, function (err) { - if (err) { - done(err); - return; - } - - fs.writeFile(path.join(args.webrootPath, challengePath), keyAuthorization, 'utf8', function (err) { - done(err); - }); - }); -}; - -module.exports.getChallenge = function (args, domain, key, done) { - //var hostname = args.domains[0]; - - //console.log("getting the challenge", args, key); - fs.readFile(path.join(args.webrootPath, key), 'utf8', done); -}; - -module.exports.removeChallenge = function (args, domain, key, done) { - //var hostname = args.domains[0]; - - fs.unlink(path.join(args.webrootPath, key), done); -}; diff --git a/lib/pycompat.js b/lib/pycompat.js deleted file mode 100644 index a19bfd6..0000000 --- a/lib/pycompat.js +++ /dev/null @@ -1,517 +0,0 @@ -'use strict'; - -var PromiseA = require('bluebird'); -var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -var path = require('path'); -var fs = PromiseA.promisifyAll(require('fs')); -var sfs = require('safe-replace'); - -var fetchFromConfigLiveDir = function (args) { - // TODO NO HARD-CODED DEFAULTS - if (!args.fullchainPath || !args.privkeyPath || !args.certPath || !args.chainPath) { - console.warn("missing one or more of args.privkeyPath, args.fullchainPath, args.certPath, args.chainPath"); - console.warn("hard-coded conventional pathnames were for debugging and are not a stable part of the API"); - } - - //, fs.readFileAsync(fullchainPath, 'ascii') - // note: if this ^^ gets added back in, the arrays below must change - return PromiseA.all([ - fs.readFileAsync(args.privkeyPath, 'ascii') // 0 - , fs.readFileAsync(args.certPath, 'ascii') // 1 - , fs.readFileAsync(args.chainPath, 'ascii') // 2 - - // stat the file, not the link - , fs.statAsync(args.certPath) // 3 - ]).then(function (arr) { - var cert = arr[1]; - var getCertInfo = require('./cert-info').getCertInfo; - - // XXX Note: Parsing the certificate info comes at a great cost (~500kb) - var certInfo = getCertInfo(cert); - - return { - key: arr[0] // privkey.pem - , privkey: arr[0] // privkey.pem - - , fullchain: arr[1] + '\n' + arr[2] // fullchain.pem - , cert: cert // cert.pem - - , chain: arr[2] // chain.pem - , ca: arr[2] // chain.pem - - , privkeyPath: args.privkeyPath - , fullchainPath: args.fullchainPath - , certPath: args.certPath - , chainPath: args.chainPath - - //, issuedAt: arr[3].mtime.valueOf() - , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() - , expiresAt: Date(certInfo.notAfter.value).valueOf() - , lifetime: args.lifetime - }; - }, function (err) { - if (args.debug) { - console.error("[letsencrypt/lib/common.js] fetchFromDisk"); - console.error(err.stack); - } - return null; - }); -}; - -function getAccount(args) { - var accountId = args.accountId; - var accountDir = path.join(args.accountsDir, accountId); - var files = {}; - var configs = [ 'meta.json', 'private_key.json', 'regr.json' ]; - - return PromiseA.all(configs.map(function (filename) { - var keyname = filename.slice(0, -5); - - return fs.readFileAsync(path.join(accountDir, filename), 'utf8').then(function (text) { - var data; - - try { - data = JSON.parse(text); - } catch(e) { - files[keyname] = { error: e }; - return; - } - - files[keyname] = data; - }, function (err) { - files[keyname] = { error: err }; - }); - })).then(function () { - var err; - - if (!Object.keys(files).every(function (key) { - return !files[key].error; - }) || !files.private_key || !files.private_key.n) { - err = new Error("Account '" + accountId + "' was corrupt. No big deal (I think?). Creating a new one..."); - err.code = 'E_ACCOUNT_CORRUPT'; - err.data = files; - return PromiseA.reject(err); - } - - //files.private_key; - //files.regr; - //files.meta; - files.accountId = accountId; // preserve current account id - files.id = accountId; - files.keypair = { privateKeyJwk: files.private_key }; - - return files; - }); -} - -function getAccountIdByEmail(args) { - // If we read 10,000 account directories looking for - // just one email address, that could get crazy. - // We should have a folder per email and list - // each account as a file in the folder - // TODO - var email = args.email; - if ('string' !== typeof email) { - if (args.debug) { - console.log("[LE] No email given"); - } - return PromiseA.resolve(null); - } - return fs.readdirAsync(args.accountsDir).then(function (nodes) { - if (args.debug) { - console.log("[LE] arg.accountsDir success"); - } - - return PromiseA.all(nodes.map(function (node) { - return fs.readFileAsync(path.join(args.accountsDir, node, 'regr.json'), 'utf8').then(function (text) { - var regr = JSON.parse(text); - regr.__accountId = node; - - return regr; - }); - })).then(function (regrs) { - var accountId; - - /* - if (args.debug) { - console.log('read many regrs'); - console.log('regrs', regrs); - } - */ - - regrs.some(function (regr) { - return regr.body.contact.some(function (contact) { - var match = contact.toLowerCase() === 'mailto:' + email.toLowerCase(); - if (match) { - accountId = regr.__accountId; - return true; - } - }); - }); - - if (!accountId) { - return null; - } - - return accountId; - }); - }).then(function (accountId) { - return accountId; - }, function (err) { - if ('ENOENT' === err.code) { - // ignore error - return null; - } - - return PromiseA.reject(err); - }); -} - -function readRenewalConfig(args) { - var pyconf = PromiseA.promisifyAll(require('pyconf')); - - return pyconf.readFileAsync(args.renewalPath).then(function (pyobj) { - return pyobj; - }, function () { - return pyconf.readFileAsync(path.join(__dirname, 'renewal.conf.tpl')).then(function (pyobj) { - return pyobj; - }); - }); -} - -function writeRenewalConfig(args) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } - } - - var pyobj = args.pyobj; - pyobj.checkpoints = parseInt(pyobj.checkpoints, 10) || 0; - - var pyconf = PromiseA.promisifyAll(require('pyconf')); - - var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); - - var certPath = args.certPath || pyobj.cert || path.join(liveDir, 'cert.pem'); - var fullchainPath = args.fullchainPath || pyobj.fullchain || path.join(liveDir, 'fullchain.pem'); - var chainPath = args.chainPath || pyobj.chain || path.join(liveDir, 'chain.pem'); - var privkeyPath = args.privkeyPath || pyobj.privkey - //|| args.domainPrivateKeyPath || args.domainKeyPath || pyobj.keyPath - || path.join(liveDir, 'privkey.pem'); - - log('[le/core.js] privkeyPath', privkeyPath); - - var updates = { - account: args.account.id - , configDir: args.configDir - , domains: args.domains - - , email: args.email - , tos: args.agreeTos && true - // yes, it's an array. weird, right? - , webrootPath: args.webrootPath && [args.webrootPath] || [] - , server: args.server || args.acmeDiscoveryUrl - - , privkey: privkeyPath - , fullchain: fullchainPath - , cert: certPath - , chain: chainPath - - , http01Port: args.http01Port - , keyPath: args.domainPrivateKeyPath || args.privkeyPath - , rsaKeySize: args.rsaKeySize - , checkpoints: pyobj.checkpoints - /* // 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 - , workDir: args.workDir - , 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 pyobject or - // annotations will be lost - Object.keys(updates).forEach(function (key) { - pyobj[key] = updates[key]; - }); - - return mkdirpAsync(path.dirname(args.renewalPath)).then(function () { - return pyconf.writeFileAsync(args.renewalPath, pyobj); - }).then(function () { - // NOTE - // writing twice seems to causes a bug, - // so instead we re-read the file from the disk - return pyconf.readFileAsync(args.renewalPath); - }); -} - -function getOrCreateRenewal(args) { - return readRenewalConfig(args).then(function (pyobj) { - var minver = pyobj.checkpoints >= 0; - - args.pyobj = pyobj; - - if (!minver) { - args.checkpoints = 0; - pyobj.checkpoints = 0; - return writeRenewalConfig(args); - } - - // args.account.id = pyobj.account - // args.configDir = args.configDir || pyobj.configDir; - - args.checkpoints = pyobj.checkpoints; - - args.agreeTos = (args.agreeTos || pyobj.tos) && true; - args.email = args.email || pyobj.email; - args.domains = args.domains || pyobj.domains; - - // yes, it's an array. weird, right? - args.webrootPath = args.webrootPath || pyobj.webrootPath[0]; - args.server = args.server || args.acmeDiscoveryUrl || pyobj.server; - - args.certPath = args.certPath || pyobj.cert; - args.privkeyPath = args.privkeyPath || pyobj.privkey; - args.chainPath = args.chainPath || pyobj.chain; - args.fullchainPath = args.fullchainPath || pyobj.fullchain; - - //, workDir: args.workDir - //, logsDir: args.logsDir - args.rsaKeySize = args.rsaKeySize || pyobj.rsaKeySize; - args.http01Port = args.http01Port || pyobj.http01Port; - args.domainKeyPath = args.domainPrivateKeyPath || args.domainKeyPath || args.keyPath || pyobj.keyPath; - - return writeRenewalConfig(args); - }); -} - -function writeCertificateAsync(args) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } - } - - log("[le/core.js] got certificate!"); - - var obj = args.pyobj; - var pems = args.pems; - - pems.fullchain = pems.cert + '\n' + (pems.chain || pems.ca); - obj.checkpoints = parseInt(obj.checkpoints, 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.privkeyPath || obj.privkey - //|| args.domainPrivateKeyPath || args.domainKeyPath || obj.keyPath - || path.join(liveDir, 'privkey.pem'); - - log('[le/core.js] privkeyPath', privkeyPath); - - var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]); - - var checkpoints = obj.checkpoints.toString(); - var certArchive = path.join(archiveDir, 'cert' + checkpoints + '.pem'); - var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoints + '.pem'); - var chainArchive = path.join(archiveDir, 'chain'+ checkpoints + '.pem'); - var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoints + '.pem'); - - return mkdirpAsync(archiveDir).then(function () { - return PromiseA.all([ - sfs.writeFileAsync(certArchive, pems.cert, 'ascii') - , sfs.writeFileAsync(chainArchive, (pems.chain || pems.ca), 'ascii') - , sfs.writeFileAsync(fullchainArchive, pems.fullchain, 'ascii') - , sfs.writeFileAsync( - privkeyArchive - // TODO nix args.key, args.domainPrivateKeyPem ?? - , (pems.privkey || pems.key) // || RSA.exportPrivatePem(args.domainKeypair) - , 'ascii' - ) - ]); - }).then(function () { - return mkdirpAsync(liveDir); - }).then(function () { - return PromiseA.all([ - sfs.writeFileAsync(certPath, pems.cert, 'ascii') - , sfs.writeFileAsync(chainPath, (pems.chain || pems.ca), 'ascii') - , sfs.writeFileAsync(fullchainPath, pems.fullchain, 'ascii') - , sfs.writeFileAsync( - privkeyPath - // TODO nix args.key, args.domainPrivateKeyPem ?? - , (pems.privkey || pems.key) // || RSA.exportPrivatePem(args.domainKeypair) - , 'ascii' - ) - ]); - }).then(function () { - obj.checkpoints += 1; - args.checkpoints += 1; - - return writeRenewalConfig(args); - }).then(function () { - var getCertInfo = require('./cert-info').getCertInfo; - - // XXX Note: Parsing the certificate info comes at a great cost (~500kb) - var certInfo = getCertInfo(pems.cert); - - return { - certPath: certPath - , chainPath: chainPath - , fullchainPath: fullchainPath - , privkeyPath: privkeyPath - - // TODO nix keypair - , keypair: args.domainKeypair - - // TODO nix args.key, args.domainPrivateKeyPem ?? - // some ambiguity here... - , privkey: (pems.privkey || pems.key) //|| RSA.exportPrivatePem(args.domainKeypair) - , fullchain: pems.fullchain || (pems.cert + '\n' + pems.chain) - , chain: (pems.chain || pems.ca) - // especially this one... might be cert only, might be fullchain - , cert: pems.cert - - , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() - , expiresAt: Date(certInfo.notAfter.value).valueOf() - }; - }); -} - -module.exports.create = function (/*defaults*/) { - function getConfigAsync(copy) { - copy.domains = []; - - return readRenewalConfig(copy).then(function (pyobj) { - var exists = pyobj.checkpoints >= 0; - if (!exists) { - return null; - } - - return pyobj; - }); - } - - return { - getDefaults: function () { -LE.tplConfigDir = require('./lib/common').tplConfigDir; - // replaces strings of workDir, certPath, etc - // if they have :config/etc/live or :conf/etc/archive - // to instead have the path of the configDir - LE.tplConfigDir(defaults.configDir, defaults); - return { - configDir: require('homedir')() + '/letsencrypt/etc' // /etc/letsencrypt/ - , logsDir: ':config/log' // /var/log/letsencrypt/ - , workDir: leCore.workDir // /var/lib/letsencrypt/ - , accountsDir: ':config/accounts/:server' - , renewalPath: ':config/renewal/:hostname.conf' - , renewalDir: ':config/renewal/' - , privkeyPath: ':config/live/:hostname/privkey.pem' - , fullchainPath: ':config/live/:hostname/fullchain.pem' - , certPath: ':config/live/:hostname/cert.pem' - , chainPath: ':config/live/:hostname/chain.pem' - , renewalPath: ':config/renewal/:hostname.conf' - , accountsDir: ':config/accounts/:server' - }; - } - , getPrivatePemAsync: function (args) { - return fs.readFileAsync(args.domainKeyPath, 'ascii'); - } - , setPrivatePemAsync: function (args, keypair) { - return mkdirpAsync(path.dirname(args.domainKeyPath)).then(function () { - return fs.writeFileAsync(args.domainKeyPath, keypair.privateKeyPem, 'ascii').then(function () { - return keypair; - }); - }); - } - , setRegistrationAsync: function (args) { - return writeCertificateAsync(args); - } - - , getRegistrationAsync: function (args) { - return fetchFromConfigLiveDir(args); - } - , getOrCreateRenewalAsync: function (args) { - return getOrCreateRenewal(args); - } - , getConfigAsync: getConfigAsync - , getConfigsAsync: function (copy) { - copy.domains = []; - - return fs.readdirAsync(copy.renewalDir).then(function (nodes) { - nodes = nodes.filter(function (node) { - return /^[a-z0-9]+.*\.conf$/.test(node); - }); - - return PromiseA.all(nodes.map(function (node) { - copy.domains = [node.replace(/\.conf$/, '')]; - return getConfigAsync(copy); - })); - }); - } - , fetchAsync: function (args) { - return fetchFromConfigLiveDir(args); - } - , getAccountIdByEmailAsync: getAccountIdByEmail - , getAccountAsync: getAccount - , setAccountAsync: function (args, account) { - var isoDate = new Date().toISOString(); - var os = require("os"); - var localname = os.hostname(); - var accountDir = path.join(args.accountsDir, account.accountId); - - account.meta = account.meta || { - creation_host: localname - , creation_dt: isoDate - }; - - return mkdirpAsync(accountDir).then(function () { - var RSA = require('rsa-compat').RSA; - - // TODO abstract file writing - return PromiseA.all([ - // meta.json {"creation_host": "ns1.redirect-www.org", "creation_dt": "2015-12-11T04:14:38Z"} - fs.writeFileAsync(path.join(accountDir, 'meta.json'), JSON.stringify(account.meta), 'utf8') - // private_key.json { "e", "d", "n", "q", "p", "kty", "qi", "dp", "dq" } - , fs.writeFileAsync(path.join(accountDir, 'private_key.json'), JSON.stringify(RSA.exportPrivateJwk(account.keypair)), 'utf8') - // regr.json: - /* - { body: { contact: [ 'mailto:coolaj86@gmail.com' ], - agreement: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', - key: { e: 'AQAB', kty: 'RSA', n: '...' } }, - uri: 'https://acme-v01.api.letsencrypt.org/acme/reg/71272', - new_authzr_uri: 'https://acme-v01.api.letsencrypt.org/acme/new-authz', - terms_of_service: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf' } - */ - , fs.writeFileAsync(path.join(accountDir, 'regr.json'), JSON.stringify(account.regr), 'utf8') - ]); - }); - } - , getAccountIdAsync: function (args) { - var pyconf = PromiseA.promisifyAll(require('pyconf')); - - return pyconf.readFileAsync(args.renewalPath).then(function (renewal) { - var accountId = renewal.account; - renewal = renewal.account; - - return accountId; - }, function (err) { - if ("ENOENT" === err.code) { - return getAccountIdByEmail(args); - } - - return PromiseA.reject(err); - }); - } - }; -}; diff --git a/lib/renewal.conf.tpl b/lib/renewal.conf.tpl deleted file mode 100644 index ad7ae0a..0000000 --- a/lib/renewal.conf.tpl +++ /dev/null @@ -1,68 +0,0 @@ -#cert = :config/live/:hostname/cert.pem -cert = :cert_path -privkey = :privkey_path -chain = :chain_path -fullchain = :fullchain_path - -# Options and defaults used in the renewal process -[renewalparams] -apache_enmod = a2enmod -no_verify_ssl = False -ifaces = None -apache_dismod = a2dismod -register_unsafely_without_email = False -uir = None -installer = none -config_dir = :config -text_mode = True -# junk? -# https://github.com/letsencrypt/letsencrypt/issues/1955 -func = -prepare = False -work_dir = :work_dir -tos = :agree_tos -init = False -http01_port = :http_01_port -duplicate = False -# this is for the domain -key_path = :privkey_path -nginx = False -fullchain_path = :fullchain_path -email = :email -csr = None -agree_dev_preview = None -redirect = None -verbose_count = -3 -config_file = None -renew_by_default = True -hsts = False -authenticator = webroot -domains = :hostnames #comma,delimited,list -rsa_key_size = :rsa_key_size -# starts at 0 and increments at every renewal -checkpoints = -1 -manual_test_mode = False -apache = False -cert_path = :cert_path -webroot_path = :webroot_paths # comma,delimited,list -strict_permissions = False -apache_server_root = /etc/apache2 -# https://github.com/letsencrypt/letsencrypt/issues/1948 -account = :account_id -manual_public_ip_logging_ok = False -chain_path = :chain_path -standalone = False -manual = False -server = :acme_discovery_url -standalone_supported_challenges = "http-01,tls-sni-01" -webroot = True -apache_init_script = None -user_agent = None -apache_ctl = apache2ctl -apache_le_vhost_ext = -le-ssl.conf -debug = False -tls_sni_01_port = 443 -logs_dir = :logs_dir -configurator = None -[[webroot_map]] -# :hostname = :webroot_path diff --git a/tests/pyconf-write.js b/tests/pyconf-write.js deleted file mode 100644 index 98bc3a6..0000000 --- a/tests/pyconf-write.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -var PromiseA = require('bluebird'); -var pyconf = PromiseA.promisifyAll(require('pyconf')); -var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -var path = require('path'); - -pyconf.readFileAsync(path.join(__dirname, 'lib', 'renewal.conf.tpl')).then(function (obj) { - var domains = ['example.com', 'www.example.com']; - var webrootPath = '/tmp/www/example.com'; - - console.log(obj); - - var keys = obj.__keys; - var lines = obj.__lines; - - obj.__keys = null; - obj.__lines = null; - - var updates = { - account: 'ACCOUNT_ID' - - , cert: 'CERT_PATH' - , privkey: 'PRIVATEKEY_PATH' - , configDir: 'CONFIG_DIR' - , tos: true - , http01Port: 80 - , domains: domains - }; - - // final section is completely dynamic - // :hostname = :webroot_path - domains.forEach(function (hostname) { - updates[hostname] = webrootPath; - }); - - // must write back to the original object or - // annotations will be lost - Object.keys(updates).forEach(function (key) { - obj[key] = updates[key]; - }); - - var renewalPath = '/tmp/letsencrypt/renewal/example.com.conf'; - return mkdirpAsync(path.dirname(renewalPath)).then(function () { - console.log(obj); - obj.__keys = keys; - obj.__lines = lines; - return pyconf.writeFileAsync(renewalPath, obj); - }); -}); From 0ab67f733d6159aa3b914600a613e5ef91b6a04c Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 04:13:58 -0400 Subject: [PATCH 06/47] mention templates --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 0cdc6d2..cdc11d1 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,15 @@ We do expose a few helper functions: TODO fetch domain tld list +### Template Strings + +The following variables will be tempalted in any strings passed to the options object: + +* `~/` replaced with `os.homedir()` i.e. `/Users/aj` +* `:hostname` replaced with the domain i.e. `example.com` +* `:config` replaced with `configDir` i.e. `~/letsencrypt/etc` +* `:server` replaced with the hostname + pathname of the acme server api + Developer API ------------- From 9ae3c7d6f67f2782e3cf1c5241b40f801ef6e3ad Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 04:14:40 -0400 Subject: [PATCH 07/47] partial updates --- index.js | 203 +++++++------------------------------------------- lib/common.js | 52 ++++--------- lib/core.js | 112 +++++++++++++++++----------- 3 files changed, 110 insertions(+), 257 deletions(-) diff --git a/index.js b/index.js index f63cd7a..077a42c 100644 --- a/index.js +++ b/index.js @@ -4,14 +4,9 @@ var PromiseA = require('bluebird'); var leCore = require('letiny-core'); -var utils = require('./lib/common'); -var merge = require('./lib/common').merge; -var tplCopy = require('./lib/common').tplCopy; var LE = module.exports; -LE.merge = require('./lib/common').merge; - LE.defaults = { server: leCore.productionServerUrl , stagingServer: leCore.stagingServerUrl @@ -28,12 +23,11 @@ Object.keys(LE.defaults).forEach(function (key) { LE[key] = LE.defaults[key]; }); - // backend, defaults, handlers LE.create = function (defaults, handlers, backend) { - var Backend = require('./lib/core'); - if (!backend) { backend = require('./lib/pycompat').create(defaults); } + var Core = require('./lib/core'); + var core; + if (!backend) { backend = require('./lib/pycompat'); } if (!handlers) { handlers = {}; } - if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; } if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; } if (!handlers.memorizeFor) { handlers.memorizeFor = 1 * 24 * 60 * 60 * 1000; } if (!handlers.sniRegisterCallback) { @@ -42,188 +36,45 @@ LE.create = function (defaults, handlers, backend) { cb(null, null); }; } - if (!handlers.getChallenge) { - if (!defaults.manual && !defaults.webrootPath) { - // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} - throw new Error("handlers.getChallenge or defaults.webrootPath must be set"); - } - handlers.getChallenge = function (hostname, key, done) { - // TODO associate by hostname? - // hmm... I don't think there's a direct way to associate this with - // the request it came from... it's kinda stateless in that way - // but realistically there only needs to be one handler and one - // "directory" for this. It's not that big of a deal. - var defaultos = LE.merge({}, defaults); - var getChallenge = require('./lib/default-handlers').getChallenge; - var copy = merge({ domains: [hostname] }, defaults); - tplCopy(copy); - defaultos.domains = [hostname]; - - if (3 === getChallenge.length) { - console.warn('[WARNING] Deprecated use. Define getChallenge as function (opts, domain, key, cb) { }'); - getChallenge(defaultos, key, done); - } - else if (4 === getChallenge.length) { - getChallenge(defaultos, hostname, key, done); - } - else { - done(new Error("handlers.getChallenge [1] receives the wrong number of arguments")); - } - }; + if (backend.create) { + backend = backend.create(defaults); } - if (!handlers.setChallenge) { - if (!defaults.webrootPath) { - // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} - throw new Error("handlers.setChallenge or defaults.webrootPath must be set"); - } - handlers.setChallenge = require('./lib/default-handlers').setChallenge; - } - if (!handlers.removeChallenge) { - if (!defaults.webrootPath) { - // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} - throw new Error("handlers.removeChallenge or defaults.webrootPath must be set"); - } - handlers.removeChallenge = require('./lib/default-handlers').removeChallenge; - } - if (!handlers.agreeToTerms) { - if (defaults.agreeTos) { - console.warn("[WARN] Agreeing to terms by default is risky business..."); - } - handlers.agreeToTerms = require('./lib/default-handlers').agreeToTerms; - } - - backend = Backend.create(defaults, handlers); backend = PromiseA.promisifyAll(backend); + core = Core.create(defaults, handlers, backend); - //var attempts = {}; // should exist in master process only - var le; - - // TODO check certs on initial load - // TODO expect that certs expire every 90 days - // TODO check certs with setInterval? - //options.cacheContextsFor = options.cacheContextsFor || (1 * 60 * 60 * 1000); - - le = { + var le = { backend: backend - , pyToJson: function (pyobj) { - if (!pyobj) { - return null; - } - - var jsobj = {}; - Object.keys(pyobj).forEach(function (key) { - jsobj[key] = pyobj[key]; - }); - jsobj.__lines = undefined; - jsobj.__keys = undefined; - - return jsobj; - } - , register: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] register'); - } - if (!Array.isArray(args.domains)) { - cb(new Error('args.domains should be an array of domains')); - return; - } - - var copy = LE.merge(args, defaults); - var err; - - if (!utils.isValidDomain(args.domains[0])) { - err = new Error("invalid domain name: '" + args.domains + "'"); - err.code = "INVALID_DOMAIN"; - cb(err); - return; - } - - if ((!args.domains.length && args.domains.every(le.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 - cb(new Error("node-letsencrypt: invalid hostnames: " + args.domains.join(','))); - return; - } - - if (defaults.debug || args.debug) { - console.log("[NLE]: begin registration"); - } - - return backend.registerAsync(copy).then(function (pems) { - if (defaults.debug || args.debug) { - console.log("[NLE]: end registration"); - } + , core: core + // register + , create: function (args, cb) { + return core.registerAsync(args).then(function (pems) { cb(null, pems); - //return le.fetch(args, cb); }, cb); } - , fetch: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] fetch'); - } - - // TODO figure out what TPLs are needed - var copy = merge(args, defaults); - tplCopy(copy); - - return backend.fetchAsync(args).then(function (certInfo) { - if (args.debug) { - console.log('[LE] raw fetch certs', certInfo && Object.keys(certInfo)); - } - if (!certInfo) { cb(null, null); return; } - - // key, cert, issuedAt, lifetime, expiresAt - if (!certInfo.expiresAt) { - certInfo.expiresAt = certInfo.issuedAt + (certInfo.lifetime || handlers.lifetime); - } - if (!certInfo.lifetime) { - certInfo.lifetime = (certInfo.lifetime || handlers.lifetime); - } - // a pretty good hard buffer - certInfo.expiresAt -= (1 * 24 * 60 * 60 * 100); - + // fetch + , domain: function (args, cb) { + // TODO must return email, domains, tos, pems + return core.fetchAsync(args).then(function (certInfo) { cb(null, certInfo); }, cb); } - , getConfig: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] getConfig'); - } - backend.getConfigAsync(args).then(function (pyobj) { - cb(null, le.pyToJson(pyobj)); - }, function (err) { - console.error("[letsencrypt/index.js] getConfig"); - console.error(err.stack); - return cb(null, []); - }); + , domains: function (args, cb) { + // TODO show all domains or limit by account + throw new Error('not implemented'); } - , getConfigs: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] getConfigs'); - } - backend.getConfigsAsync(args).then(function (configs) { - cb(null, configs.map(le.pyToJson)); - }, function (err) { - if ('ENOENT' === err.code) { - cb(null, []); - } else { - console.error("[letsencrypt/index.js] getConfigs"); - console.error(err.stack); - cb(err); - } - }); + , accounts: function (args, cb) { + // TODO show all accounts or limit by domain + throw new Error('not implemented'); } - , setConfig: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] setConfig'); - } - backend.configureAsync(args).then(function (pyobj) { - cb(null, le.pyToJson(pyobj)); - }); + , account: function (args, cb) { + // TODO return one account + throw new Error('not implemented'); } }; + // exists + // get + return le; }; diff --git a/lib/common.js b/lib/common.js index 9af37e3..e00919f 100644 --- a/lib/common.js +++ b/lib/common.js @@ -1,9 +1,6 @@ 'use strict'; -var fs = require('fs'); var path = require('path'); -var PromiseA = require('bluebird'); - var homeRe = new RegExp("^~(\\/|\\\|\\" + path.sep + ")"); var re = /^[a-zA-Z0-9\.\-]+$/; var punycode = require('punycode'); @@ -22,16 +19,6 @@ module.exports.isValidDomain = function (domain) { return ''; }; -module.exports.tplConfigDir = function (configDir, defaults) { - var homedir = require('homedir')(); - Object.keys(defaults).forEach(function (key) { - if ('string' === typeof defaults[key]) { - defaults[key] = defaults[key].replace(':config', configDir).replace(':conf', configDir); - defaults[key] = defaults[key].replace(homeRe, homedir + path.sep); - } - }); -}; - module.exports.merge = function (/*defaults, args*/) { var allDefaults = Array.prototype.slice.apply(arguments); var args = args.shift(); @@ -51,18 +38,6 @@ module.exports.merge = function (/*defaults, args*/) { }; module.exports.tplCopy = function (copy) { - var url = require('url'); - var acmeLocation = url.parse(copy.server); - var acmeHostpath = path.join(acmeLocation.hostname, acmeLocation.pathname); - copy.accountsDir = copy.accountsDir || path.join(copy.configDir, 'accounts', acmeHostpath); - // TODO move these defaults elsewhere? - //args.renewalDir = args.renewalDir || ':config/renewal/'; - 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'; - hargs.renewalDir = hargs.renewalDir || ':config/renewal/'; - copy.renewalPath = copy.renewalPath || path.join(copy.configDir, 'renewal', copy.domains[0] + '.conf'); var homedir = require('homedir')(); var tpls = { hostname: (copy.domains || [])[0] @@ -72,19 +47,20 @@ module.exports.tplCopy = function (copy) { }; Object.keys(copy).forEach(function (key) { - if ('string' === typeof copy[key]) { - Object.keys(tpls).sort(function (a, b) { - return b.length - a.length; - }).forEach(function (tplname) { - if (!tpls[tplname]) { - // what can't be templated now may be templatable later - return; - } - copy[key] = copy[key].replace(':' + tplname, tpls[tplname]); - copy[key] = copy[key].replace(homeRe, homedir + path.sep); - }); + if ('string' !== typeof copy[key]) { + return; } - }); - //return copy; + copy[key] = copy[key].replace(homeRe, homedir + path.sep); + + Object.keys(tpls).sort(function (a, b) { + return b.length - a.length; + }).forEach(function (tplname) { + if (!tpls[tplname]) { + // what can't be templated now may be templatable later + return; + } + copy[key] = copy[key].replace(':' + tplname, tpls[tplname]); + }); + }); }; diff --git a/lib/core.js b/lib/core.js index 85f5001..93d73b6 100644 --- a/lib/core.js +++ b/lib/core.js @@ -4,6 +4,8 @@ var LE = require('../'); var ipc = {}; // in-process cache module.exports.create = function (defaults, handlers, backend) { + var backendDefaults = backend.getDefaults && backend.getDefaults || backend.defaults || {}; + defaults.server = defaults.server || LE.liveServer; handlers.merge = require('./common').merge; handlers.tplCopy = require('./common').tplCopy; @@ -13,9 +15,22 @@ module.exports.create = function (defaults, handlers, backend) { var LeCore = PromiseA.promisifyAll(require('letiny-core')); var crypto = require('crypto'); + function attachCertInfo(results) { + var getCertInfo = require('./cert-info').getCertInfo; + // XXX Note: Parsing the certificate info comes at a great cost (~500kb) + var certInfo = getCertInfo(results.cert); + + //results.issuedAt = arr[3].mtime.valueOf() + results.issuedAt = Date(certInfo.notBefore.value).valueOf(); // Date.now() + results.expiresAt = Date(certInfo.notAfter.value).valueOf(); + + return results; + } + function createAccount(args, handlers) { - // arg.rsaBitLength args.rsaExponent - return RSA.generateKeypairAsync(args.rsaKeySize || 2048, 65537, { public: true, pem: true }).then(function (keypair) { + args.rsaKeySize = args.rsaKeySize || 2048; + + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { return LeCore.registerNewAccountAsync({ email: args.email @@ -71,6 +86,9 @@ module.exports.create = function (defaults, handlers, backend) { } function getCertificateAsync(args, defaults, handlers) { + args.rsaKeySize = args.rsaKeySize || 2048; + args.challengeType = args.challengeType || 'http-01'; + function log() { if (args.debug || defaults.debug) { console.log.apply(console, arguments); @@ -106,6 +124,7 @@ module.exports.create = function (defaults, handlers, backend) { , accountKeypair: RSA.import(account.keypair) , domainKeypair: domainKeypair , domains: args.domains + , challengeType: args.challengeType // // IMPORTANT @@ -116,39 +135,36 @@ module.exports.create = function (defaults, handlers, backend) { // (args is per-request, defaults is per instance) // , setChallenge: function (domain, key, value, done) { - var copy = handlers.merge({ domains: [domain] }, defaults); + var copy = handlers.merge({ domains: [domain] }, defaults, backendDefaults); handlers.tplCopy(copy); - args.domains = [domain]; - //args.domains = args.domains || [domain]; - if (4 === handlers.setChallenge.length) { - console.warn('[WARNING] deprecated use. Define setChallenge as function (opts, domain, key, val, cb) { }'); - 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")); + //args.domains = [domain]; + args.domains = args.domains || [domain]; + + if (5 !== handlers.setChallenge.length) { + done(new Error("handlers.setChallenge receives the wrong number of arguments." + + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); + return; } + + handlers.setChallenge(copy, domain, key, value, done); } , removeChallenge: function (domain, key, done) { - var copy = handlers.merge({ domains: [domain] }, defaults); + var copy = handlers.merge({ domains: [domain] }, defaults, backendDefaults); handlers.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")); + if (4 !== handlers.removeChallenge.length) { + done(new Error("handlers.removeChallenge receives the wrong number of arguments." + + " You must define removeChallenge as function (opts, domain, key, cb) { }")); + return; } + + handlers.removeChallenge(copy, domain, key, done); } - }); + }).then(attachCertInfo); }).then(function (results) { // { cert, chain, fullchain, privkey } + args.pems = results; return backend.setRegistration(args, defaults, handlers); }); @@ -160,7 +176,7 @@ module.exports.create = function (defaults, handlers, backend) { return getCertificateAsync(args, defaults, handlers); } - return backend.getRegistration(args).then(function (certs) { + return wrapped.fetchAsync(args).then(function (certs) { var halfLife = (certs.expiresAt - certs.issuedAt) / 2; if (!certs || (Date.now() - certs.issuedAt) > halfLife) { @@ -196,27 +212,34 @@ module.exports.create = function (defaults, handlers, backend) { log('[le/core.js] use account'); args.accountId = accountId; - return Accounts.getAccount(args, handlers); + return backend.getAccount(args, handlers); } else { log('[le/core.js] create account'); - return Accounts.createAccount(args, handlers); + return createAccount(args, handlers); } }); - }).then(function (account) { - /* - if (renewal.account !== account) { - // the account has become corrupt, re-register - return; - } - */ - log('[le/core.js] created account'); - return account; }); } var wrapped = { - registerAsync: function (args) { - var copy = handlers.merge(args, defaults); + registerAsync: function (args) { + var utils = require('./lib/common'); + var err; + + if (!Array.isArray(args.domains)) { + return PromiseA.reject(new Error('args.domains should be an array of 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.domains + "'"); + err.code = "INVALID_DOMAIN"; + return PromiseA.reject(err); + } + + var copy = handlers.merge(args, defaults, backendDefaults); handlers.tplCopy(copy); return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { @@ -234,20 +257,23 @@ module.exports.create = function (defaults, handlers, backend) { }); } , getOrCreateAccount: function (args) { - // TODO - keypair.privateKeyPem = RSA.exportPrivatePem(keypair); - keypair.publicKeyPem = RSA.exportPublicPem(keypair); return createAccount(args, handlers); } , configureAsync: function (hargs) { - var copy = merge(hargs, defaults); - tplCopy(copy); + var copy = handlers.merge(hargs, defaults, backendDefaults); + handlers.tplCopy(copy); return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { copy.account = account; return backend.getOrCreateRenewal(copy); }); } + , fetchAsync: function (args) { + var copy = handlers.merge(args, defaults); + handlers.tplCopy(copy); + + return backend.fetchAsync(copy).then(attachCertInfo); + } }; return wrapped; From 5ff91ec37e3d6e8dbf12b3a19a90be4569337c00 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 18:11:19 -0400 Subject: [PATCH 08/47] most basic middleware function --- lib/middleware.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lib/middleware.js diff --git a/lib/middleware.js b/lib/middleware.js new file mode 100644 index 0000000..13af19c --- /dev/null +++ b/lib/middleware.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = function (le) { + return function () { + var prefix = le.acmeChallengePrefix; // /.well-known/acme-challenge/:token + + return function (req, res, next) { + if (0 !== req.url.indexOf(prefix)) { + next(); + return; + } + + var key = req.url.slice(prefix.length); + var hostname = req.hostname || (req.headers.host || '').toLowerCase().replace(/:*/, ''); + + // TODO tpl copy? + le.challenger.getAsync(le, hostname, key).then(function (token) { + if (!token) { + res.status = 404; + res.send("Error: These aren't the tokens you're looking for. Move along."); + return; + } + + res.send(token); + }, function (/*err*/) { + res.status = 404; + res.send("Error: These aren't the tokens you're looking for. Move along."); + }); + }; + }; +}; From 63b9cb5ec9e4cd1213623389360390e463474aad Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 18:16:29 -0400 Subject: [PATCH 09/47] getting pretty close --- index.js | 126 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 51 deletions(-) diff --git a/index.js b/index.js index 077a42c..b9bcfa6 100644 --- a/index.js +++ b/index.js @@ -23,58 +23,82 @@ Object.keys(LE.defaults).forEach(function (key) { LE[key] = LE.defaults[key]; }); -LE.create = function (defaults, handlers, backend) { - var Core = require('./lib/core'); - var core; - if (!backend) { backend = require('./lib/pycompat'); } - if (!handlers) { handlers = {}; } - if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; } - if (!handlers.memorizeFor) { handlers.memorizeFor = 1 * 24 * 60 * 60 * 1000; } - if (!handlers.sniRegisterCallback) { - handlers.sniRegisterCallback = function (args, cache, cb) { - // TODO when we have ECDSA, just do this automatically - cb(null, null); - }; - } - - if (backend.create) { - backend = backend.create(defaults); - } - backend = PromiseA.promisifyAll(backend); - core = Core.create(defaults, handlers, backend); - - var le = { - backend: backend - , core: core - // register - , create: function (args, cb) { - return core.registerAsync(args).then(function (pems) { - cb(null, pems); - }, cb); +var u; // undefined +LE._undefined = { + store: u +, challenger: u +, register: u +, check: u +, renewWithin: u +, memorizeFor: u +, acmeChallengePrefix: u +}; +LE._undefine = function (le) { + Object.keys(LE._undefined).forEach(function (key) { + if (!(key in le)) { + le[key] = u; } - // fetch - , domain: function (args, cb) { - // TODO must return email, domains, tos, pems - return core.fetchAsync(args).then(function (certInfo) { - cb(null, certInfo); - }, cb); - } - , domains: function (args, cb) { - // TODO show all domains or limit by account - throw new Error('not implemented'); - } - , accounts: function (args, cb) { - // TODO show all accounts or limit by domain - throw new Error('not implemented'); - } - , account: function (args, cb) { - // TODO return one account - throw new Error('not implemented'); - } - }; - - // exists - // get + }); + + return le; +}; +LE.create = function (le) { + le = LE._undefine(le); + var store = le.store || require('le-store-certbot').create({ debug: le.debug }); + var challenger = le.challenge || require('le-store-certbot').create({ debug: le.debug }); + var core = le.core = require('./lib/core'); + + le.acmeChallengePrefix = LE.acmeChallengePrefix; + + if (!le.renewWithin) { le.renewWithin = 3 * 24 * 60 * 60 * 1000; } + if (!le.memorizeFor) { le.memorizeFor = 1 * 24 * 60 * 60 * 1000; } + + if (!le.server) { + throw new Error("opts.server must be set to 'staging' or a production url, such as LE.productionServerUrl'"); + } + if ('staging' === le.server) { + le.server = LE.stagingServerUrl; + } + else if ('production' === le.server) { + le.server = LE.productionServerUrl; + } + + if (store.create) { + store = store.create(le); + } + store = PromiseA.promisifyAll(store); + le._storeOpts = store.getOptions(); + Object.keys(le._storeOpts).forEach(function (key) { + if (!(key in le._storeOpts)) { + le[key] = le._storeOpts[key]; + } + }); + + if (challenger.create) { + challenger = challenger.create(le); + } + challenger = PromiseA.promisifyAll(challenger); + le._challengerOpts = challenger.getOptions(); + Object.keys(le._storeOpts).forEach(function (key) { + if (!(key in le._challengerOpts)) { + le[key] = le._challengerOpts[key]; + } + }); + + core = le.core = core.create(le); + + le.register = function (args) { + return core.registerAsync(args); + }; + + le.check = function (args) { + // TODO must return email, domains, tos, pems + return core.fetchAsync(args); + }; + + le.middleware = function () { + return require('./lib/middleware')(le); + }; return le; }; From a654891cfae9c93238d72c8fc1859d1a1ae144d9 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 18:21:10 -0400 Subject: [PATCH 10/47] update --- README.md | 47 +++++++++++++++++++++++------------------------ index.js | 17 ++++++++++------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index cdc11d1..6ec547c 100644 --- a/README.md +++ b/README.md @@ -154,31 +154,30 @@ le.exists({ domain: 'example.com' }).then(function (results) { return; } + // Register Certificate manually - le.register( + le.register({ - { domains: ['example.com'] // CHANGE TO YOUR DOMAIN (list for SANS) - , email: 'user@email.com' // CHANGE TO YOUR EMAIL - , agreeTos: '' // set to tosUrl string to pre-approve (and skip agreeToTerms) - , rsaKeySize: 2048 // 1024 or 2048 - , challengeType: 'http-01' // http-01, tls-sni-01, or dns-01 - } + domains: ['example.com'] // CHANGE TO YOUR DOMAIN (list for SANS) + , email: 'user@email.com' // CHANGE TO YOUR EMAIL + , agreeTos: '' // set to tosUrl string to pre-approve (and skip agreeToTerms) + , rsaKeySize: 2048 // 2048 or higher + , challengeType: 'http-01' // http-01, tls-sni-01, or dns-01 - , function (err, results) { - if (err) { - // Note: you must either use le.middleware() with express, - // manually use le.getChallenge(domain, key, val, done) - // or have a webserver running and responding - // to /.well-known/acme-challenge at `webrootPath` - console.error('[Error]: node-letsencrypt/examples/standalone'); - console.error(err.stack); - return; - } + }).then(function (results) { - console.log('success'); - } + console.log('success'); - ); + }, function (err) { + + // Note: you must either use le.middleware() with express, + // manually use le.getChallenge(domain, key, val, done) + // or have a webserver running and responding + // to /.well-known/acme-challenge at `webrootPath` + console.error('[Error]: node-letsencrypt/examples/standalone'); + console.error(err.stack); + + }); }); ``` @@ -241,7 +240,7 @@ TODO double check and finish * accounts.get * accounts.exists * certs - * certs.byDomain + * certs.byAccount * certs.all * certs.get * certs.exists @@ -250,9 +249,9 @@ TODO double check and finish TODO finish -* setChallenge(opts, domain, key, value, done); // opts will be saved with domain/key -* getChallenge(domain, key, done); // opts will be retrieved by domain/key -* removeChallenge(domain, key, done); // opts will be retrieved by domain/key +* `.set(opts, domain, key, value, done);` // opts will be saved with domain/key +* `.get(opts, domain, key, done);` // opts will be retrieved by domain/key +* `.remove(opts, domain, key, done);` // opts will be retrieved by domain/key Change History ============== diff --git a/index.js b/index.js index b9bcfa6..0582be7 100644 --- a/index.js +++ b/index.js @@ -1,20 +1,17 @@ 'use strict'; -// TODO handle www and no-www together somehow? - var PromiseA = require('bluebird'); var leCore = require('letiny-core'); var LE = module.exports; LE.defaults = { - server: leCore.productionServerUrl -, stagingServer: leCore.stagingServerUrl -, liveServer: leCore.productionServerUrl - -, productionServerUrl: leCore.productionServerUrl + productionServerUrl: leCore.productionServerUrl , stagingServerUrl: leCore.stagingServerUrl +, rsaKeySize: leCore.rsaKeySize || 2048 +, challengeType: leCore.challengeType || 'http-01' + , acmeChallengePrefix: leCore.acmeChallengePrefix }; @@ -23,6 +20,7 @@ Object.keys(LE.defaults).forEach(function (key) { LE[key] = LE.defaults[key]; }); +// show all possible options var u; // undefined LE._undefined = { store: u @@ -32,6 +30,9 @@ LE._undefined = { , renewWithin: u , memorizeFor: u , acmeChallengePrefix: u +, rsaKeySize: u +, challengeType: u +, server: u }; LE._undefine = function (le) { Object.keys(LE._undefined).forEach(function (key) { @@ -49,6 +50,8 @@ LE.create = function (le) { var core = le.core = require('./lib/core'); le.acmeChallengePrefix = LE.acmeChallengePrefix; + le.rsaKeySize = le.rsaKeySize || LE.rsaKeySize; + le.challengeType = le.challengeType || LE.challengeType; if (!le.renewWithin) { le.renewWithin = 3 * 24 * 60 * 60 * 1000; } if (!le.memorizeFor) { le.memorizeFor = 1 * 24 * 60 * 60 * 1000; } From 56736c4f9800fbd846a41b10599c901d7f06bb22 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 18:50:42 -0400 Subject: [PATCH 11/47] normalize --- index.js | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/index.js b/index.js index 0582be7..13821b9 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,10 @@ 'use strict'; -var PromiseA = require('bluebird'); var leCore = require('letiny-core'); var LE = module.exports; +// in-process cache, shared between all instances +var ipc = {}; LE.defaults = { productionServerUrl: leCore.productionServerUrl @@ -33,6 +34,7 @@ LE._undefined = { , rsaKeySize: u , challengeType: u , server: u +, _ipc: u }; LE._undefine = function (le) { Object.keys(LE._undefined).forEach(function (key) { @@ -44,14 +46,17 @@ LE._undefine = function (le) { return le; }; LE.create = function (le) { - le = LE._undefine(le); - var store = le.store || require('le-store-certbot').create({ debug: le.debug }); - var challenger = le.challenge || require('le-store-certbot').create({ debug: le.debug }); - var core = le.core = require('./lib/core'); + var PromiseA = require('bluebird'); + le.store = le.store || require('le-store-certbot').create({ debug: le.debug }); + le.challenger = le.challenger || require('le-store-certbot').create({ debug: le.debug }); + le.core = require('./lib/core'); + + le = LE._undefine(le); le.acmeChallengePrefix = LE.acmeChallengePrefix; le.rsaKeySize = le.rsaKeySize || LE.rsaKeySize; le.challengeType = le.challengeType || LE.challengeType; + le._ipc = ipc; if (!le.renewWithin) { le.renewWithin = 3 * 24 * 60 * 60 * 1000; } if (!le.memorizeFor) { le.memorizeFor = 1 * 24 * 60 * 60 * 1000; } @@ -66,37 +71,39 @@ LE.create = function (le) { le.server = LE.productionServerUrl; } - if (store.create) { - store = store.create(le); + if (le.store.create) { + le.store = le.store.create(le); } - store = PromiseA.promisifyAll(store); - le._storeOpts = store.getOptions(); + le.store = PromiseA.promisifyAll(le.store); + le._storeOpts = le.store.getOptions(); Object.keys(le._storeOpts).forEach(function (key) { if (!(key in le._storeOpts)) { le[key] = le._storeOpts[key]; } }); - if (challenger.create) { - challenger = challenger.create(le); + if (le.challenger.create) { + le.challenger = le.challenger.create(le); } - challenger = PromiseA.promisifyAll(challenger); - le._challengerOpts = challenger.getOptions(); + le.challenger = PromiseA.promisifyAll(le.challenger); + le._challengerOpts = le.challenger.getOptions(); Object.keys(le._storeOpts).forEach(function (key) { if (!(key in le._challengerOpts)) { le[key] = le._challengerOpts[key]; } }); - core = le.core = core.create(le); + if (le.core.create) { + le.core = le.core.create(le); + } le.register = function (args) { - return core.registerAsync(args); + return le.core.registerAsync(args); }; le.check = function (args) { // TODO must return email, domains, tos, pems - return core.fetchAsync(args); + return le.core.fetchAsync(args); }; le.middleware = function () { From ae91c08dd1bee3850917c56bc60f16c65c5b718e Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Aug 2016 18:54:28 -0400 Subject: [PATCH 12/47] almost there --- lib/core.js | 27 +++++++-------------------- lib/{common.js => utils.js} | 0 2 files changed, 7 insertions(+), 20 deletions(-) rename lib/{common.js => utils.js} (100%) diff --git a/lib/core.js b/lib/core.js index 93d73b6..a6ec745 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,16 +1,8 @@ 'use strict'; -var LE = require('../'); -var ipc = {}; // in-process cache - -module.exports.create = function (defaults, handlers, backend) { - var backendDefaults = backend.getDefaults && backend.getDefaults || backend.defaults || {}; - - defaults.server = defaults.server || LE.liveServer; - handlers.merge = require('./common').merge; - handlers.tplCopy = require('./common').tplCopy; - +module.exports.create = function (le) { var PromiseA = require('bluebird'); + var utils = require('./utils'); // merge, tplCopy; var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); var LeCore = PromiseA.promisifyAll(require('letiny-core')); var crypto = require('crypto'); @@ -28,8 +20,6 @@ module.exports.create = function (defaults, handlers, backend) { } function createAccount(args, handlers) { - args.rsaKeySize = args.rsaKeySize || 2048; - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { return LeCore.registerNewAccountAsync({ @@ -73,21 +63,19 @@ module.exports.create = function (defaults, handlers, backend) { 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); + if ((now - le._ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) { + return PromiseA.resolve(le._ipc.acmeUrls); } return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { - ipc.acmeUrlsUpdatedAt = Date.now(); - ipc.acmeUrls = data; + le._ipc.acmeUrlsUpdatedAt = Date.now(); + le._ipc.acmeUrls = data; - return ipc.acmeUrls; + return le._ipc.acmeUrls; }); } function getCertificateAsync(args, defaults, handlers) { - args.rsaKeySize = args.rsaKeySize || 2048; - args.challengeType = args.challengeType || 'http-01'; function log() { if (args.debug || defaults.debug) { @@ -223,7 +211,6 @@ module.exports.create = function (defaults, handlers, backend) { var wrapped = { registerAsync: function (args) { - var utils = require('./lib/common'); var err; if (!Array.isArray(args.domains)) { diff --git a/lib/common.js b/lib/utils.js similarity index 100% rename from lib/common.js rename to lib/utils.js From a0d01815dffa34c424ac081c9cf8bf6b92664970 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 6 Aug 2016 01:32:59 -0400 Subject: [PATCH 13/47] move attachCertInfo to utils.js --- lib/utils.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/utils.js b/lib/utils.js index e00919f..d98b779 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -5,6 +5,18 @@ var homeRe = new RegExp("^~(\\/|\\\|\\" + path.sep + ")"); var re = /^[a-zA-Z0-9\.\-]+$/; var punycode = require('punycode'); +module.exports.attachCertInfo = function (results) { + var getCertInfo = require('./cert-info').getCertInfo; + // XXX Note: Parsing the certificate info comes at a great cost (~500kb) + var certInfo = getCertInfo(results.cert); + + //results.issuedAt = arr[3].mtime.valueOf() + results.issuedAt = Date(certInfo.notBefore.value).valueOf(); // Date.now() + results.expiresAt = Date(certInfo.notAfter.value).valueOf(); + + return results; +}; + module.exports.isValidDomain = function (domain) { if (re.test(domain)) { return domain; From afa10a2c63c5f47552aeab10ec926820c67bb0a9 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 6 Aug 2016 01:33:19 -0400 Subject: [PATCH 14/47] add agreeToTerms --- index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/index.js b/index.js index 13821b9..3b231bb 100644 --- a/index.js +++ b/index.js @@ -34,6 +34,7 @@ LE._undefined = { , rsaKeySize: u , challengeType: u , server: u +, agreeToTerms: u , _ipc: u }; LE._undefine = function (le) { From 16bfac31bb0f1cab93f263ce46772af1e335d100 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 6 Aug 2016 01:34:34 -0400 Subject: [PATCH 15/47] rename in api --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6ec547c..43ef29c 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ le = LE.create({ // Check in-memory cache of certificates for the named domain -le.exists({ domain: 'example.com' }).then(function (results) { +le.check({ domain: 'example.com' }).then(function (results) { if (results) { // we already have certificates return; @@ -156,11 +156,11 @@ le.exists({ domain: 'example.com' }).then(function (results) { // Register Certificate manually - le.register({ + le.get({ domains: ['example.com'] // CHANGE TO YOUR DOMAIN (list for SANS) , email: 'user@email.com' // CHANGE TO YOUR EMAIL - , agreeTos: '' // set to tosUrl string to pre-approve (and skip agreeToTerms) + , agreeTos: '' // set to tosUrl string (or true) to pre-approve (and skip agreeToTerms) , rsaKeySize: 2048 // 2048 or higher , challengeType: 'http-01' // http-01, tls-sni-01, or dns-01 @@ -199,6 +199,12 @@ API The full end-user API is exposed in the example above and includes all relevant options. +``` +le.register +le.get // checkAndRegister +le.check +``` + ### Helper Functions We do expose a few helper functions: From d1c0043f349868f3ced7584212032437131a5886 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 6 Aug 2016 02:05:04 -0400 Subject: [PATCH 16/47] just a little further... --- lib/core.js | 426 ++++++++++++++++++++++++++-------------------------- 1 file changed, 216 insertions(+), 210 deletions(-) diff --git a/lib/core.js b/lib/core.js index a6ec745..bcfbfd2 100644 --- a/lib/core.js +++ b/lib/core.js @@ -7,210 +7,234 @@ module.exports.create = function (le) { var LeCore = PromiseA.promisifyAll(require('letiny-core')); var crypto = require('crypto'); - function attachCertInfo(results) { - var getCertInfo = require('./cert-info').getCertInfo; - // XXX Note: Parsing the certificate info comes at a great cost (~500kb) - var certInfo = getCertInfo(results.cert); + var core = { + // + // Helpers + // + getAcmeUrlsAsync: function (args) { + var now = Date.now(); - //results.issuedAt = arr[3].mtime.valueOf() - results.issuedAt = Date(certInfo.notBefore.value).valueOf(); // Date.now() - results.expiresAt = Date(certInfo.notAfter.value).valueOf(); + // TODO check response header on request for cache time + if ((now - le._ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) { + return PromiseA.resolve(le._ipc.acmeUrls); + } - return results; - } + return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { + le._ipc.acmeUrlsUpdatedAt = Date.now(); + le._ipc.acmeUrls = data; - function createAccount(args, handlers) { - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { + return le._ipc.acmeUrls; + }); + } - return LeCore.registerNewAccountAsync({ - email: args.email - , newRegUrl: args._acmeUrls.newReg - , agreeToTerms: function (tosUrl, agree) { - // args.email = email; // already there - args.tosUrl = tosUrl; - handlers.agreeToTerms(args, agree); - } - , accountKeypair: keypair - , debug: defaults.debug || args.debug || handlers.debug - }).then(function (body) { - // TODO XXX use sha256 (the python client uses md5) - // TODO ssh fingerprint (noted on rsa-compat issues page, I believe) - keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex'); - keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex'); + // + // The Main Enchilada + // - var accountId = keypair.publicKeyMd5; - var regr = { body: body }; - var account = {}; + // + // Accounts + // + , accounts: { + registerAsync: function (args) { + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { - args.accountId = accountId; + return LeCore.registerNewAccountAsync({ + email: args.email + , newRegUrl: args._acmeUrls.newReg + , agreeToTerms: function (tosUrl, agreeCb) { + if (true === args.agreeTos || tosUrl === args.agreeTos || tosUrl === le.agreeToTerms) { + agreeCb(null, tosUrl); + return; + } - account.keypair = keypair; - account.regr = regr; - account.accountId = accountId; - account.id = accountId; + // args.email = email; // already there + // args.domains = domains // already there + args.tosUrl = tosUrl; + le.agreeToTerms(args, agreeCb); + } + , accountKeypair: keypair - args.account = account; + , debug: le.debug || args.debug + }).then(function (body) { + // TODO XXX use sha256 (the python client uses md5) + // TODO ssh fingerprint (noted on rsa-compat issues page, I believe) + keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex'); + keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex'); - return backend.setAccountAsync(args, account).then(function () { - return account; + var accountId = keypair.publicKeyMd5; + var regr = { body: body }; + var account = {}; + + args.accountId = accountId; + + account.keypair = keypair; + account.regr = regr; + account.accountId = accountId; + account.id = accountId; + + args.account = account; + + return le.store.accounts.setAsync(args, account).then(function () { + return account; + }); + }); }); - }); - }); - } - - function getAcmeUrls(args) { - var now = Date.now(); - - // TODO check response header on request for cache time - if ((now - le._ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) { - return PromiseA.resolve(le._ipc.acmeUrls); - } - - return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { - le._ipc.acmeUrlsUpdatedAt = Date.now(); - le._ipc.acmeUrls = data; - - return le._ipc.acmeUrls; - }); - } - - function getCertificateAsync(args, defaults, handlers) { - - function log() { - if (args.debug || defaults.debug) { - console.log.apply(console, arguments); } - } + // getOrCreateAcmeAccount + , getAsync: function (args) { + return core.accounts.checkAsync(args).then(function (account) { + if (account) { + return le.store.accounts.checkAccount(args); + } else { + return core.accounts.registerAsync(args); + } + }); + } + , checkAsync: function (args) { + return le.store.accounts.checkAccountId(args).then(function (accountId) { - var account = args.account; - var promise; - var keypairOpts = { public: true, pem: true }; - - promise = backend.getPrivatePem(args).then(function (pem) { - return RSA.import({ privateKeyPem: pem }); - }, function (/*err*/) { - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { - keypair.privateKeyPem = RSA.exportPrivatePem(keypair); - keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); - return backend.setPrivatePem(args, keypair); - }); - }); - - return promise.then(function (domainKeypair) { - log("[le/core.js] get certificate"); - - args.domainKeypair = domainKeypair; - //args.registration = domainKey; - - return LeCore.getCertificateAsync({ - debug: args.debug - - , newAuthzUrl: args._acmeUrls.newAuthz - , newCertUrl: args._acmeUrls.newCert - - , accountKeypair: RSA.import(account.keypair) - , domainKeypair: domainKeypair - , domains: args.domains - , challengeType: args.challengeType - - // - // 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 = handlers.merge({ domains: [domain] }, defaults, backendDefaults); - handlers.tplCopy(copy); - - //args.domains = [domain]; - args.domains = args.domains || [domain]; - - if (5 !== handlers.setChallenge.length) { - done(new Error("handlers.setChallenge receives the wrong number of arguments." - + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); - return; + if (!accountId) { + return null; } - handlers.setChallenge(copy, domain, key, value, done); - } - , removeChallenge: function (domain, key, done) { - var copy = handlers.merge({ domains: [domain] }, defaults, backendDefaults); - handlers.tplCopy(copy); - - if (4 !== handlers.removeChallenge.length) { - done(new Error("handlers.removeChallenge receives the wrong number of arguments." - + " You must define removeChallenge as function (opts, domain, key, cb) { }")); - return; - } - - handlers.removeChallenge(copy, domain, key, done); - } - }).then(attachCertInfo); - }).then(function (results) { - // { cert, chain, fullchain, privkey } - - args.pems = results; - return backend.setRegistration(args, defaults, handlers); - }); - } - - function getOrCreateDomainCertificate(args, defaults, handlers) { - if (args.duplicate) { - // we're forcing a refresh via 'dupliate: true' - return getCertificateAsync(args, defaults, handlers); - } - - return wrapped.fetchAsync(args).then(function (certs) { - var halfLife = (certs.expiresAt - certs.issuedAt) / 2; - - if (!certs || (Date.now() - certs.issuedAt) > halfLife) { - // There is no cert available - // Or the cert is more than half-expired - return getCertificateAsync(args, defaults, handlers); - } - - return PromiseA.reject(new Error( - "[ERROR] Certificate issued at '" - + new Date(certs.issuedAt).toISOString() + "' and expires at '" - + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '" - + new Date(certs.issuedA + halfLife).toISOString() + "'. Set { duplicate: true } to force." - )); - }); - } - - // returns 'account' from lib/accounts { meta, regr, keypair, accountId (id) } - function getOrCreateAcmeAccount(args, defaults, handlers) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } - } - - return backend.getAccountId(args).then(function (accountId) { - - // Note: the ACME urls are always fetched fresh on purpose - return getAcmeUrls(args).then(function (urls) { - args._acmeUrls = urls; - - if (accountId) { - log('[le/core.js] use account'); - args.accountId = accountId; - return backend.getAccount(args, handlers); - } else { - log('[le/core.js] create account'); - return createAccount(args, handlers); - } - }); - }); - } - var wrapped = { - registerAsync: function (args) { + // Note: the ACME urls are always fetched fresh on purpose + return core.getAcmeUrlsAsync(args).then(function (urls) { + args._acmeUrls = urls; + + + // return le.store.accounts.checkAccountId(args).then(function (accountId) { + return le.store.accounts.checkAsync(args); + }); + }); + } + } + + , certificates: { + // getCertificateAsync: + registerAsync: function (args) { + + function log() { + if (args.debug || le.debug) { + console.log.apply(console, arguments); + } + } + + var account = args.account; + var keypairOpts = { public: true, pem: true }; + + var promise = le.store.certificates.checkKeypairAsync(args).then(function (keypair) { + return RSA.import(keypair); + }, function (/*err*/) { + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { + keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); + return le.store.certificates.setKeypairAsync(args, keypair); + }); + }); + + return promise.then(function (domainKeypair) { + log("[le/core.js] get certificate"); + + args.domainKeypair = domainKeypair; + //args.registration = domainKey; + + return LeCore.getCertificateAsync({ + debug: args.debug || le.debug + + , newAuthzUrl: args._acmeUrls.newAuthz + , newCertUrl: args._acmeUrls.newCert + + , accountKeypair: RSA.import(account.keypair) + , domainKeypair: domainKeypair + , domains: args.domains + , challengeType: args.challengeType + + // + // 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 = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); + + //args.domains = [domain]; + args.domains = args.domains || [domain]; + + if (5 !== le.challenger.set.length) { + done(new Error("le.challenger.set receives the wrong number of arguments." + + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); + return; + } + + le.challenger.set(copy, domain, key, value, done); + } + , removeChallenge: function (domain, key, done) { + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); + + if (4 !== le.challenger.remove.length) { + done(new Error("le.challenger.remove receives the wrong number of arguments." + + " You must define removeChallenge as function (opts, domain, key, cb) { }")); + return; + } + + le.challenger.remove(copy, domain, key, done); + } + }).then(utils.attachCertInfo); + }).then(function (results) { + // { cert, chain, fullchain, privkey } + + args.pems = results; + return le.store.certificates.setAsync(args); + }); + } + // checkAsync + , checkAsync: function (args) { + var copy = utils.merge(args, le); + utils.tplCopy(copy); + + return le.store.certificates.checkAsync(copy).then(utils.attachCertInfo); + } + // getOrCreateDomainCertificate + , getAsync: function (args) { + var copy = utils.merge(args, le); + utils.tplCopy(copy); + + if (args.duplicate) { + // we're forcing a refresh via 'dupliate: true' + return core.certificates.registerAsync(args); + } + + return core.certificates.checkAsync(args).then(function (certs) { + var renewableAt = certs.expiresAt - le.renewWithin; + //var halfLife = (certs.expiresAt - certs.issuedAt) / 2; + //var renewable = (Date.now() - certs.issuedAt) > halfLife; + + if (!certs || Date.now() >= renewableAt) { + // There is no cert available + // Or the cert is more than half-expired + return core.certificates.registerAsync(args); + } + + return PromiseA.reject(new Error( + "[ERROR] Certificate issued at '" + + new Date(certs.issuedAt).toISOString() + "' and expires at '" + + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '" + + new Date(renewableAt).toISOString() + "'. Set { duplicate: true } to force." + )); + }); + } + } + + // returns 'account' from lib/accounts { meta, regr, keypair, accountId (id) } + , registerAsync: function (args) { var err; if (!Array.isArray(args.domains)) { @@ -226,16 +250,16 @@ module.exports.create = function (le) { return PromiseA.reject(err); } - var copy = handlers.merge(args, defaults, backendDefaults); - handlers.tplCopy(copy); + var copy = utils.merge(args, le); + utils.tplCopy(copy); - return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { + return core.accounts.getAsync(copy).then(function (account) { copy.account = account; return backend.getOrCreateRenewal(copy).then(function (pyobj) { copy.pyobj = pyobj; - return getOrCreateDomainCertificate(copy, defaults, handlers); + return core.certificates.getAsync(copy); }); }).then(function (result) { return result; @@ -243,25 +267,7 @@ module.exports.create = function (le) { return PromiseA.reject(err); }); } - , getOrCreateAccount: function (args) { - return createAccount(args, handlers); - } - , configureAsync: function (hargs) { - var copy = handlers.merge(hargs, defaults, backendDefaults); - handlers.tplCopy(copy); - - return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { - copy.account = account; - return backend.getOrCreateRenewal(copy); - }); - } - , fetchAsync: function (args) { - var copy = handlers.merge(args, defaults); - handlers.tplCopy(copy); - - return backend.fetchAsync(copy).then(attachCertInfo); - } }; - return wrapped; + return core; }; From 635b130ab37dae892b0acfba024e56d476fafb36 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 7 Aug 2016 02:02:02 -0400 Subject: [PATCH 17/47] just a little more... --- index.js | 4 +- lib/core.js | 203 +++++++++++++++++++++++---------------------------- lib/utils.js | 2 + 3 files changed, 96 insertions(+), 113 deletions(-) diff --git a/index.js b/index.js index 3b231bb..341d1dc 100644 --- a/index.js +++ b/index.js @@ -99,12 +99,12 @@ LE.create = function (le) { } le.register = function (args) { - return le.core.registerAsync(args); + return le.core.certificates.getAsync(args); }; le.check = function (args) { // TODO must return email, domains, tos, pems - return le.core.fetchAsync(args); + return le.core.certificates.checkAsync(args); }; le.middleware = function () { diff --git a/lib/core.js b/lib/core.js index bcfbfd2..ed1f9d3 100644 --- a/lib/core.js +++ b/lib/core.js @@ -113,99 +113,111 @@ module.exports.create = function (le) { } , certificates: { - // getCertificateAsync: registerAsync: function (args) { + var err; + var copy = utils.merge(args, le); + args = utils.tplCopy(copy); - function log() { - if (args.debug || le.debug) { - console.log.apply(console, arguments); - } + if (!Array.isArray(args.domains)) { + return PromiseA.reject(new Error('args.domains should be an array of domains')); } - var account = args.account; - var keypairOpts = { public: true, pem: true }; + 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.domains + "'"); + err.code = "INVALID_DOMAIN"; + return PromiseA.reject(err); + } - var promise = le.store.certificates.checkKeypairAsync(args).then(function (keypair) { - return RSA.import(keypair); - }, function (/*err*/) { - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { - keypair.privateKeyPem = RSA.exportPrivatePem(keypair); - keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); - return le.store.certificates.setKeypairAsync(args, keypair); + return core.accounts.getAsync(copy).then(function (account) { + copy.account = account; + + //var account = args.account; + var keypairOpts = { public: true, pem: true }; + + var promise = le.store.certificates.checkKeypairAsync(args).then(function (keypair) { + return RSA.import(keypair); + }, function (/*err*/) { + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { + keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); + return le.store.certificates.setKeypairAsync(args, keypair); + }); + }); + + return promise.then(function (domainKeypair) { + args.domainKeypair = domainKeypair; + //args.registration = domainKey; + + return LeCore.getCertificateAsync({ + debug: args.debug || le.debug + + , newAuthzUrl: args._acmeUrls.newAuthz + , newCertUrl: args._acmeUrls.newCert + + , accountKeypair: RSA.import(account.keypair) + , domainKeypair: domainKeypair + , domains: args.domains + , challengeType: args.challengeType + + // + // 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 = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); + + //args.domains = [domain]; + args.domains = args.domains || [domain]; + + if (5 !== le.challenger.set.length) { + done(new Error("le.challenger.set receives the wrong number of arguments." + + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); + return; + } + + le.challenger.set(copy, domain, key, value, done); + } + , removeChallenge: function (domain, key, done) { + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); + + if (4 !== le.challenger.remove.length) { + done(new Error("le.challenger.remove receives the wrong number of arguments." + + " You must define removeChallenge as function (opts, domain, key, cb) { }")); + return; + } + + le.challenger.remove(copy, domain, key, done); + } + }).then(utils.attachCertInfo); + }).then(function (results) { + // { cert, chain, fullchain, privkey } + + args.pems = results; + return le.store.certificates.setAsync(args).then(function () { + return results; + }); }); }); - - return promise.then(function (domainKeypair) { - log("[le/core.js] get certificate"); - - args.domainKeypair = domainKeypair; - //args.registration = domainKey; - - return LeCore.getCertificateAsync({ - debug: args.debug || le.debug - - , newAuthzUrl: args._acmeUrls.newAuthz - , newCertUrl: args._acmeUrls.newCert - - , accountKeypair: RSA.import(account.keypair) - , domainKeypair: domainKeypair - , domains: args.domains - , challengeType: args.challengeType - - // - // 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 = utils.merge({ domains: [domain] }, le); - utils.tplCopy(copy); - - //args.domains = [domain]; - args.domains = args.domains || [domain]; - - if (5 !== le.challenger.set.length) { - done(new Error("le.challenger.set receives the wrong number of arguments." - + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); - return; - } - - le.challenger.set(copy, domain, key, value, done); - } - , removeChallenge: function (domain, key, done) { - var copy = utils.merge({ domains: [domain] }, le); - utils.tplCopy(copy); - - if (4 !== le.challenger.remove.length) { - done(new Error("le.challenger.remove receives the wrong number of arguments." - + " You must define removeChallenge as function (opts, domain, key, cb) { }")); - return; - } - - le.challenger.remove(copy, domain, key, done); - } - }).then(utils.attachCertInfo); - }).then(function (results) { - // { cert, chain, fullchain, privkey } - - args.pems = results; - return le.store.certificates.setAsync(args); - }); } - // checkAsync , checkAsync: function (args) { var copy = utils.merge(args, le); utils.tplCopy(copy); + // returns pems return le.store.certificates.checkAsync(copy).then(utils.attachCertInfo); } - // getOrCreateDomainCertificate , getAsync: function (args) { var copy = utils.merge(args, le); - utils.tplCopy(copy); + args = utils.tplCopy(copy); if (args.duplicate) { // we're forcing a refresh via 'dupliate: true' @@ -229,44 +241,13 @@ module.exports.create = function (le) { + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '" + new Date(renewableAt).toISOString() + "'. Set { duplicate: true } to force." )); + }).then(function (results) { + // returns pems + return results; }); } } - // returns 'account' from lib/accounts { meta, regr, keypair, accountId (id) } - , registerAsync: function (args) { - var err; - - if (!Array.isArray(args.domains)) { - return PromiseA.reject(new Error('args.domains should be an array of 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.domains + "'"); - err.code = "INVALID_DOMAIN"; - return PromiseA.reject(err); - } - - var copy = utils.merge(args, le); - utils.tplCopy(copy); - - return core.accounts.getAsync(copy).then(function (account) { - copy.account = account; - - return backend.getOrCreateRenewal(copy).then(function (pyobj) { - - copy.pyobj = pyobj; - return core.certificates.getAsync(copy); - }); - }).then(function (result) { - return result; - }, function (err) { - return PromiseA.reject(err); - }); - } }; return core; diff --git a/lib/utils.js b/lib/utils.js index d98b779..1755f15 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -75,4 +75,6 @@ module.exports.tplCopy = function (copy) { copy[key] = copy[key].replace(':' + tplname, tpls[tplname]); }); }); + + return copy; }; From 6aaec506a879b70de80d6c6daa67736e9eea519d Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 09:13:34 -0600 Subject: [PATCH 18/47] Update README.md --- README.md | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 43ef29c..44ea7fd 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,12 @@ | [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi) | -letsencrypt (v2) +letsencrypt =========== Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS / TLS / SSL Certificates for node.js - * [Automatic HTTPS with ExpressJS](https://github.com/Daplie/letsencrypt-express) - * [Automatic live renewal](https://github.com/Daplie/letsencrypt-express#how-automatic) - * On-the-fly HTTPS certificates for Dynamic DNS (in-process, no server restart) - * Works with node cluster out of the box - * usable [via commandline](https://github.com/Daplie/letsencrypt-cli) as well - * Free SSL (HTTPS Certificates for TLS) - * [90-day certificates](https://letsencrypt.org/2015/11/09/why-90-days.html) - -**See Also** - -* [Let's Encrypt in (exactly) 90 seconds with Caddy](https://daplie.com/articles/lets-encrypt-in-literally-90-seconds/) -* [lego](https://github.com/xenolf/lego): Let's Encrypt for golang +Free SLL with [90-day](https://letsencrypt.org/2015/11/09/why-90-days.html) HTTPS / TLS Certificates STOP ==== From c82ccbbe3c34a61dbdf8c23e7c3a119ed85febc9 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 09:14:08 -0600 Subject: [PATCH 19/47] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 44ea7fd..ea57244 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ It's very simple and easy to use, but also very complete and easy to extend and ### Overly Simplified Example -Against my better judgement I'm providing a terribly oversimplified exmaple +Against my better judgement I'm providing a terribly oversimplified example of how to use this library: ```javascript From 3590842ce8964395efcd574c847b14789f570723 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 11:21:33 -0400 Subject: [PATCH 20/47] make shim of letiny-core possible #37 --- index.js | 21 +++++++++++++++++---- lib/core.js | 7 +++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 341d1dc..d1774fd 100644 --- a/index.js +++ b/index.js @@ -24,7 +24,8 @@ Object.keys(LE.defaults).forEach(function (key) { // show all possible options var u; // undefined LE._undefined = { - store: u + acme: u +, store: u , challenger: u , register: u , check: u @@ -49,6 +50,7 @@ LE._undefine = function (le) { LE.create = function (le) { var PromiseA = require('bluebird'); + le.acme = le.acme || leCore; le.store = le.store || require('le-store-certbot').create({ debug: le.debug }); le.challenger = le.challenger || require('le-store-certbot').create({ debug: le.debug }); le.core = require('./lib/core'); @@ -72,13 +74,24 @@ LE.create = function (le) { le.server = LE.productionServerUrl; } + if (le.acme.create) { + le.acme = le.acme.create(le); + } + le.acme = PromiseA.promisifyAll(le.acme); + le._acmeOpts = le.acme.getOptions(); + Object.keys(le._acmeOpts).forEach(function (key) { + if (!(key in le)) { + le[key] = le._acmeOpts[key]; + } + }); + if (le.store.create) { le.store = le.store.create(le); } le.store = PromiseA.promisifyAll(le.store); le._storeOpts = le.store.getOptions(); Object.keys(le._storeOpts).forEach(function (key) { - if (!(key in le._storeOpts)) { + if (!(key in le)) { le[key] = le._storeOpts[key]; } }); @@ -88,8 +101,8 @@ LE.create = function (le) { } le.challenger = PromiseA.promisifyAll(le.challenger); le._challengerOpts = le.challenger.getOptions(); - Object.keys(le._storeOpts).forEach(function (key) { - if (!(key in le._challengerOpts)) { + Object.keys(le._challengerOpts).forEach(function (key) { + if (!(key in le)) { le[key] = le._challengerOpts[key]; } }); diff --git a/lib/core.js b/lib/core.js index ed1f9d3..15ff111 100644 --- a/lib/core.js +++ b/lib/core.js @@ -4,7 +4,6 @@ module.exports.create = function (le) { var PromiseA = require('bluebird'); var utils = require('./utils'); // merge, tplCopy; var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); - var LeCore = PromiseA.promisifyAll(require('letiny-core')); var crypto = require('crypto'); var core = { @@ -19,7 +18,7 @@ module.exports.create = function (le) { return PromiseA.resolve(le._ipc.acmeUrls); } - return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { + return le.acme.getAcmeUrlsAsync(args.server).then(function (data) { le._ipc.acmeUrlsUpdatedAt = Date.now(); le._ipc.acmeUrls = data; @@ -39,7 +38,7 @@ module.exports.create = function (le) { registerAsync: function (args) { return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { - return LeCore.registerNewAccountAsync({ + return le.acme.registerNewAccountAsync({ email: args.email , newRegUrl: args._acmeUrls.newReg , agreeToTerms: function (tosUrl, agreeCb) { @@ -151,7 +150,7 @@ module.exports.create = function (le) { args.domainKeypair = domainKeypair; //args.registration = domainKey; - return LeCore.getCertificateAsync({ + return le.acme.getCertificateAsync({ debug: args.debug || le.debug , newAuthzUrl: args._acmeUrls.newAuthz From 25f8b591db2d2d7dbb1140a5c1483dcd7851ff10 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 15:17:09 -0400 Subject: [PATCH 21/47] now passes tests for invalid account --- index.js | 15 +-- lib/core.js | 230 +++++++++++++++++++++++----------------- lib/utils.js | 29 ++++- tests/create-account.js | 117 ++++++++++++++++++++ 4 files changed, 285 insertions(+), 106 deletions(-) create mode 100644 tests/create-account.js diff --git a/index.js b/index.js index d1774fd..d5b5595 100644 --- a/index.js +++ b/index.js @@ -1,19 +1,20 @@ 'use strict'; -var leCore = require('letiny-core'); +var ACME = require('le-acme-core').ACME; var LE = module.exports; +LE.LE = LE; // in-process cache, shared between all instances var ipc = {}; LE.defaults = { - productionServerUrl: leCore.productionServerUrl -, stagingServerUrl: leCore.stagingServerUrl + productionServerUrl: ACME.productionServerUrl +, stagingServerUrl: ACME.stagingServerUrl -, rsaKeySize: leCore.rsaKeySize || 2048 -, challengeType: leCore.challengeType || 'http-01' +, rsaKeySize: ACME.rsaKeySize || 2048 +, challengeType: ACME.challengeType || 'http-01' -, acmeChallengePrefix: leCore.acmeChallengePrefix +, acmeChallengePrefix: ACME.acmeChallengePrefix }; // backwards compat @@ -50,7 +51,7 @@ LE._undefine = function (le) { LE.create = function (le) { var PromiseA = require('bluebird'); - le.acme = le.acme || leCore; + le.acme = le.acme || ACME.create({ debug: le.debug }); le.store = le.store || require('le-store-certbot').create({ debug: le.debug }); le.challenger = le.challenger || require('le-store-certbot').create({ debug: le.debug }); le.core = require('./lib/core'); diff --git a/lib/core.js b/lib/core.js index 15ff111..92fdb1e 100644 --- a/lib/core.js +++ b/lib/core.js @@ -36,77 +36,101 @@ module.exports.create = function (le) { // , accounts: { registerAsync: function (args) { - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { + var err; - return le.acme.registerNewAccountAsync({ - email: args.email - , newRegUrl: args._acmeUrls.newReg - , agreeToTerms: function (tosUrl, agreeCb) { - if (true === args.agreeTos || tosUrl === args.agreeTos || tosUrl === le.agreeToTerms) { - agreeCb(null, tosUrl); - return; - } + if (!args.email || !args.agreeTos || (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 PromiseA.reject(err); + } - // args.email = email; // already there - // args.domains = domains // already there - args.tosUrl = tosUrl; - le.agreeToTerms(args, agreeCb); - } - , accountKeypair: keypair + return utils.testEmail(args.email).then(function () { - , debug: le.debug || args.debug - }).then(function (body) { - // TODO XXX use sha256 (the python client uses md5) - // TODO ssh fingerprint (noted on rsa-compat issues page, I believe) - keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex'); - keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex'); + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { + // 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 accountId = keypair.publicKeyMd5; - var regr = { body: body }; - var account = {}; + return le.acme.registerNewAccountAsync({ + email: args.email + , newRegUrl: args._acmeUrls.newReg + , agreeToTerms: function (tosUrl, agreeCb) { + if (true === args.agreeTos || tosUrl === args.agreeTos || tosUrl === le.agreeToTerms) { + agreeCb(null, tosUrl); + return; + } - args.accountId = accountId; + // args.email = email; // already there + // args.domains = domains // already there + args.tosUrl = tosUrl; + le.agreeToTerms(args, agreeCb); + } + , accountKeypair: keypair - account.keypair = keypair; - account.regr = regr; - account.accountId = accountId; - account.id = accountId; + , debug: le.debug || args.debug + }).then(function (body) { + // TODO XXX use sha256 (the python client uses md5) + // TODO ssh fingerprint (noted on rsa-compat issues page, I believe) + keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex'); + keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex'); - args.account = account; + var accountId = keypair.publicKeyMd5; + var regr = { body: body }; + var account = {}; - return le.store.accounts.setAsync(args, account).then(function () { - return account; + args.accountId = accountId; + + account.keypair = keypair; + account.regr = regr; + account.accountId = accountId; + account.id = accountId; + + args.account = account; + + return le.store.accounts.setAsync(args, account).then(function () { + return account; + }); + }); }); }); }); } - // getOrCreateAcmeAccount + , getAsync: function (args) { return core.accounts.checkAsync(args).then(function (account) { - if (account) { - return le.store.accounts.checkAccount(args); - } else { - return core.accounts.registerAsync(args); - } + if (account) { + return account; + } else { + return core.accounts.registerAsync(args); + } }); } - , checkAsync: function (args) { - return le.store.accounts.checkAccountId(args).then(function (accountId) { - if (!accountId) { + , checkAsync: function (args) { + var requiredArgs = ['accountId', 'email', 'domains', 'domain']; + if (!requiredArgs.some(function (key) { return -1 !== Object.keys(args).indexOf(key) })) { + return PromiseA.reject(new Error( + "In order to register or retrieve an account one of '" + requiredArgs.join("', '") + "' must be present" + )); + } + + var copy = utils.merge(args, le); + args = utils.tplCopy(copy); + + return le.store.accounts.checkAsync(args).then(function (account) { + + if (!account) { return null; } - args.accountId = accountId; + args.account = account; + args.accountId = account.id; - // Note: the ACME urls are always fetched fresh on purpose - return core.getAcmeUrlsAsync(args).then(function (urls) { - args._acmeUrls = urls; - - - // return le.store.accounts.checkAccountId(args).then(function (accountId) { - return le.store.accounts.checkAsync(args); - }); + return account; }); } } @@ -150,55 +174,61 @@ module.exports.create = function (le) { args.domainKeypair = domainKeypair; //args.registration = domainKey; - return le.acme.getCertificateAsync({ - debug: args.debug || le.debug + // 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; - , newAuthzUrl: args._acmeUrls.newAuthz - , newCertUrl: args._acmeUrls.newCert + return le.acme.getCertificateAsync({ + debug: args.debug || le.debug - , accountKeypair: RSA.import(account.keypair) - , domainKeypair: domainKeypair - , domains: args.domains - , challengeType: args.challengeType + , newAuthzUrl: args._acmeUrls.newAuthz + , newCertUrl: args._acmeUrls.newCert - // - // 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 = utils.merge({ domains: [domain] }, le); - utils.tplCopy(copy); + , accountKeypair: RSA.import(account.keypair) + , domainKeypair: domainKeypair + , domains: args.domains + , challengeType: args.challengeType - //args.domains = [domain]; - args.domains = args.domains || [domain]; + // + // 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 = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); - if (5 !== le.challenger.set.length) { - done(new Error("le.challenger.set receives the wrong number of arguments." - + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); - return; + //args.domains = [domain]; + args.domains = args.domains || [domain]; + + if (5 !== le.challenger.set.length) { + done(new Error("le.challenger.set receives the wrong number of arguments." + + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); + return; + } + + le.challenger.set(copy, domain, key, value, done); } + , removeChallenge: function (domain, key, done) { + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); - le.challenger.set(copy, domain, key, value, done); - } - , removeChallenge: function (domain, key, done) { - var copy = utils.merge({ domains: [domain] }, le); - utils.tplCopy(copy); + if (4 !== le.challenger.remove.length) { + done(new Error("le.challenger.remove receives the wrong number of arguments." + + " You must define removeChallenge as function (opts, domain, key, cb) { }")); + return; + } - if (4 !== le.challenger.remove.length) { - done(new Error("le.challenger.remove receives the wrong number of arguments." - + " You must define removeChallenge as function (opts, domain, key, cb) { }")); - return; + le.challenger.remove(copy, domain, key, done); } - - le.challenger.remove(copy, domain, key, done); - } - }).then(utils.attachCertInfo); + }).then(utils.attachCertInfo); + }); }).then(function (results) { - // { cert, chain, fullchain, privkey } + // { cert, chain, privkey } args.pems = results; return le.store.certificates.setAsync(args).then(function () { @@ -207,6 +237,10 @@ module.exports.create = function (le) { }); }); } + , renewAsync: function (args) { + // TODO fetch email address if not present + return core.certificates.registerAsync(args); + } , checkAsync: function (args) { var copy = utils.merge(args, le); utils.tplCopy(copy); @@ -218,20 +252,20 @@ module.exports.create = function (le) { var copy = utils.merge(args, le); args = utils.tplCopy(copy); - if (args.duplicate) { - // we're forcing a refresh via 'dupliate: true' - return core.certificates.registerAsync(args); - } - return core.certificates.checkAsync(args).then(function (certs) { + if (!certs) { + // There is no cert available + return core.certificates.registerAsync(args); + } + var renewableAt = certs.expiresAt - le.renewWithin; //var halfLife = (certs.expiresAt - certs.issuedAt) / 2; //var renewable = (Date.now() - certs.issuedAt) > halfLife; - if (!certs || Date.now() >= renewableAt) { - // There is no cert available - // Or the cert is more than half-expired - return core.certificates.registerAsync(args); + if (args.duplicate || Date.now() >= renewableAt) { + // The cert is more than half-expired + // We're forcing a refresh via 'dupliate: true' + return core.certificates.renewAsync(args); } return PromiseA.reject(new Error( diff --git a/lib/utils.js b/lib/utils.js index 1755f15..eaaad17 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,6 +4,8 @@ var path = require('path'); var homeRe = new RegExp("^~(\\/|\\\|\\" + path.sep + ")"); var re = /^[a-zA-Z0-9\.\-]+$/; var punycode = require('punycode'); +var PromiseA = require('bluebird'); +var dns = PromiseA.promisifyAll(require('dns')); module.exports.attachCertInfo = function (results) { var getCertInfo = require('./cert-info').getCertInfo; @@ -33,7 +35,7 @@ module.exports.isValidDomain = function (domain) { module.exports.merge = function (/*defaults, args*/) { var allDefaults = Array.prototype.slice.apply(arguments); - var args = args.shift(); + var args = allDefaults.shift(); var copy = {}; allDefaults.forEach(function (defaults) { @@ -78,3 +80,28 @@ module.exports.tplCopy = function (copy) { return copy; }; + +module.exports.testEmail = function (email) { + var parts = (email||'').split('@'); + var err; + + if (2 !== parts.length || !parts[0] || !parts[1]) { + err = new Error("malformed email address '" + email + "'"); + err.code = 'E_EMAIL'; + return PromiseA.reject(err); + } + + return dns.resolveMxAsync(parts[1]).then(function (records) { + // records only returns when there is data + if (!records.length) { + throw new Error("sanity check fail: success, but no MX records returned"); + } + return email; + }, function (err) { + if ('ENODATA' === err.code) { + err = new Error("no MX records found for '" + parts[1] + "'"); + err.code = 'E_EMAIL'; + return PromiseA.reject(err); + } + }); +}; diff --git a/tests/create-account.js b/tests/create-account.js new file mode 100644 index 0000000..32a775f --- /dev/null +++ b/tests/create-account.js @@ -0,0 +1,117 @@ +'use strict'; + +var LE = require('../').LE; +var le = LE.create({ + server: 'staging' +, acme: require('le-acme-core').ACME.create() +, store: require('le-store-certbot').create({ + configDir: '~/letsencrypt.test/etc/' + }) +}); + +var testId = Math.round(Date.now() / 1000).toString(); +var fakeEmail = 'coolaj86+le.' + testId + '@example.com'; +var testEmail = 'coolaj86+le.' + testId + '@example.com'; +var testAccount; + +var tests = [ + function () { + return le.core.accounts.checkAsync({ + email: testEmail + }).then(function (account) { + if (account) { + console.error(account); + throw new Error("Test account should not exist."); + } + }); + } +, function () { + return le.core.accounts.registerAsync({ + email: testEmail + , agreeTos: false + , rsaKeySize: 2048 + }).then(function (/*account*/) { + throw new Error("Should not register if 'agreeTos' is not truthy."); + }, function (err) { + if (err.code !== 'E_ARGS') { + throw err; + } + }); + } +, function () { + return le.core.accounts.registerAsync({ + email: testEmail + , agreeTos: true + , rsaKeySize: 1024 + }).then(function (/*account*/) { + throw new Error("Should not register if 'rsaKeySize' is less than 2048."); + }, function (err) { + if (err.code !== 'E_ARGS') { + throw err; + } + }); + } +, function () { + return le.core.accounts.registerAsync({ + email: fakeEmail + , agreeTos: true + , rsaKeySize: 2048 + }).then(function (/*account*/) { + // TODO test mx record + throw new Error("Registration should NOT succeed with a bad email address."); + }, function (err) { + if (err.code !== 'E_EMAIL') { + throw err; + } + }); + } +, function () { + throw new Error('NOT IMPLEMENTED'); + return le.core.accounts.registerAsync({ + email: 'coolaj86+le.' + testId + '@example.com' + , agreeTos: true + , rsaKeySize: 2048 + }).then(function (account) { + testAccount = account; + if (!account) { + throw new Error("Registration should always return a new account."); + } + if (!account.email) { + throw new Error("Registration should return the email."); + } + if (!account.id) { + throw new Error("Registration should return the account id."); + } + }); + } +, function () { + return le.core.accounts.checkAsync({ + email: testAccount.email + }).then(function (account) { + if (!account) { + throw new Error("Test account should exist when searched by email."); + } + }); + } +, function () { + return le.core.accounts.checkAsync({ + accountId: testAccount.id + }).then(function (account) { + if (!account) { + throw new Error("Test account should exist when searched by account id."); + } + }); + } +]; + +function run() { + var test = tests.shift(); + if (!test) { + console.info('All tests passed'); + return; + } + + test().then(run); +} + +run(); From e6f5081b93eacf4a96bf885aea732a7615c30279 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 13:54:27 -0600 Subject: [PATCH 22/47] add pkijs for reading expiresAt --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 134140d..5a230b9 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,16 @@ "url": "https://github.com/Daplie/node-letsencrypt/issues" }, "homepage": "https://github.com/Daplie/node-letsencrypt#readme", - "devDependencies": { - }, + "devDependencies": {}, "optionalDependencies": {}, "dependencies": { + "asn1": "^0.2.3", "bluebird": "^3.0.6", "homedir": "^0.6.0", "letiny-core": "^2.0.1", "mkdirp": "^0.5.1", + "node.extend": "^1.1.5", + "pkijs": "^1.3.27", "pyconf": "^1.1.2", "request": "^2.67.0", "rsa-compat": "^1.2.1", From 0eca27113a3f43be28f1bf16762a6867d580ec75 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 18:07:16 -0400 Subject: [PATCH 23/47] letiny-core -> le-acme-core --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5a230b9..93c9a8c 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "asn1": "^0.2.3", "bluebird": "^3.0.6", "homedir": "^0.6.0", - "letiny-core": "^2.0.1", + "le-acme-core": "^2.0.1", "mkdirp": "^0.5.1", "node.extend": "^1.1.5", "pkijs": "^1.3.27", From aa6656e723658862b4fb4e2fecffce31d8b1d5ac Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 18:10:23 -0400 Subject: [PATCH 24/47] remove cruft --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index ea57244..0ed0f29 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,7 @@ TODO fetch domain tld list The following variables will be tempalted in any strings passed to the options object: * `~/` replaced with `os.homedir()` i.e. `/Users/aj` -* `:hostname` replaced with the domain i.e. `example.com` -* `:config` replaced with `configDir` i.e. `~/letsencrypt/etc` -* `:server` replaced with the hostname + pathname of the acme server api +* `:hostname` replaced with the first domain in the list i.e. `example.com` Developer API ------------- From 29a4443d49bacf1309f90df6887f42ad1d2b3b75 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 18:11:25 -0400 Subject: [PATCH 25/47] pass test to save account keys --- lib/core.js | 25 +++++++++++++++++++++++-- lib/utils.js | 38 ++++++++++++++++++++++++++++---------- tests/create-account.js | 7 ++++--- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/lib/core.js b/lib/core.js index 92fdb1e..6176537 100644 --- a/lib/core.js +++ b/lib/core.js @@ -35,8 +35,11 @@ module.exports.create = function (le) { // Accounts // , accounts: { + // Accounts registerAsync: function (args) { var err; + var copy = utils.merge(args, le); + args = utils.tplCopy(copy); if (!args.email || !args.agreeTos || (parseInt(args.rsaKeySize, 10) < 2048)) { err = new Error( @@ -48,13 +51,26 @@ module.exports.create = function (le) { } return utils.testEmail(args.email).then(function () { + var keypairOpts = { public: true, pem: true }; - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, { public: true, pem: true }).then(function (keypair) { + var promise = le.store.accounts.checkKeypairAsync(args).then(function (keypair) { + return RSA.import(keypair); + }, function (/*err*/) { + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { + keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); + return le.store.accounts.setKeypairAsync(args, keypair); + }); + }); + + return promise.then(function (keypair) { // 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; + throw new Error("WAIT! Don't go yet!!!"); + return le.acme.registerNewAccountAsync({ email: args.email , newRegUrl: args._acmeUrls.newReg @@ -88,9 +104,11 @@ module.exports.create = function (le) { account.regr = regr; account.accountId = accountId; account.id = accountId; + account.email = args.email; args.account = account; + // TODO move templating to right here? return le.store.accounts.setAsync(args, account).then(function () { return account; }); @@ -100,6 +118,7 @@ module.exports.create = function (le) { }); } + // Accounts , getAsync: function (args) { return core.accounts.checkAsync(args).then(function (account) { if (account) { @@ -110,9 +129,10 @@ module.exports.create = function (le) { }); } + // Accounts , checkAsync: function (args) { var requiredArgs = ['accountId', 'email', 'domains', 'domain']; - if (!requiredArgs.some(function (key) { return -1 !== Object.keys(args).indexOf(key) })) { + if (!requiredArgs.some(function (key) { return -1 !== Object.keys(args).indexOf(key); })) { return PromiseA.reject(new Error( "In order to register or retrieve an account one of '" + requiredArgs.join("', '") + "' must be present" )); @@ -136,6 +156,7 @@ module.exports.create = function (le) { } , certificates: { + // Certificates registerAsync: function (args) { var err; var copy = utils.merge(args, le); diff --git a/lib/utils.js b/lib/utils.js index eaaad17..69af35f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -53,28 +53,46 @@ module.exports.merge = function (/*defaults, args*/) { module.exports.tplCopy = function (copy) { var homedir = require('homedir')(); - var tpls = { - hostname: (copy.domains || [])[0] - , server: (copy.server || '').replace('https://', '').replace(/(\/)$/, '') - , conf: copy.configDir - , config: copy.configDir + var tplKeys; + + copy.hostnameGet = function (copy) { + return (copy.domains || [])[0] || copy.domain; }; Object.keys(copy).forEach(function (key) { + var newName; + if (!/Get$/.test(key)) { + return; + } + + newName = key.replace(/Get$/, ''); + copy[newName] = copy[newName] || copy[key](copy); + }); + + tplKeys = Object.keys(copy); + tplKeys.sort(function (a, b) { + return b.length - a.length; + }); + + tplKeys.forEach(function (key) { if ('string' !== typeof copy[key]) { return; } copy[key] = copy[key].replace(homeRe, homedir + path.sep); + }); - Object.keys(tpls).sort(function (a, b) { - return b.length - a.length; - }).forEach(function (tplname) { - if (!tpls[tplname]) { + tplKeys.forEach(function (key) { + if ('string' !== typeof copy[key]) { + return; + } + + tplKeys.forEach(function (tplname) { + if (!copy[tplname]) { // what can't be templated now may be templatable later return; } - copy[key] = copy[key].replace(':' + tplname, tpls[tplname]); + copy[key] = copy[key].replace(':' + tplname, copy[tplname]); }); }); diff --git a/tests/create-account.js b/tests/create-account.js index 32a775f..1c5c888 100644 --- a/tests/create-account.js +++ b/tests/create-account.js @@ -6,12 +6,14 @@ var le = LE.create({ , acme: require('le-acme-core').ACME.create() , store: require('le-store-certbot').create({ configDir: '~/letsencrypt.test/etc/' + , webrootPath: '~/letsencrypt.test/tmp/:hostname' }) +, debug: true }); var testId = Math.round(Date.now() / 1000).toString(); var fakeEmail = 'coolaj86+le.' + testId + '@example.com'; -var testEmail = 'coolaj86+le.' + testId + '@example.com'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; var testAccount; var tests = [ @@ -66,9 +68,8 @@ var tests = [ }); } , function () { - throw new Error('NOT IMPLEMENTED'); return le.core.accounts.registerAsync({ - email: 'coolaj86+le.' + testId + '@example.com' + email: testEmail , agreeTos: true , rsaKeySize: 2048 }).then(function (account) { From eebfe38d62017a021a5a1a8e77ac846dce634d54 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 19:14:53 -0400 Subject: [PATCH 26/47] #38 passes create test --- index.js | 5 +++++ lib/core.js | 43 ++++++++++++++++------------------------- tests/create-account.js | 25 ++++++------------------ 3 files changed, 28 insertions(+), 45 deletions(-) diff --git a/index.js b/index.js index d5b5595..7cc6b7f 100644 --- a/index.js +++ b/index.js @@ -28,8 +28,10 @@ LE._undefined = { acme: u , store: u , challenger: u + , register: u , check: u + , renewWithin: u , memorizeFor: u , acmeChallengePrefix: u @@ -61,6 +63,9 @@ LE.create = function (le) { le.rsaKeySize = le.rsaKeySize || LE.rsaKeySize; le.challengeType = le.challengeType || LE.challengeType; le._ipc = ipc; + le.agreeToTerms = le.agreeToTerms || function (args, agreeCb) { + agreeCb(new Error("'agreeToTerms' was not supplied to LE and 'agreeTos' was not supplied to LE.register")); + }; if (!le.renewWithin) { le.renewWithin = 3 * 24 * 60 * 60 * 1000; } if (!le.memorizeFor) { le.memorizeFor = 1 * 24 * 60 * 60 * 1000; } diff --git a/lib/core.js b/lib/core.js index 6176537..2b0b7c6 100644 --- a/lib/core.js +++ b/lib/core.js @@ -2,9 +2,8 @@ module.exports.create = function (le) { var PromiseA = require('bluebird'); - var utils = require('./utils'); // merge, tplCopy; + var utils = require('./utils'); var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); - var crypto = require('crypto'); var core = { // @@ -39,9 +38,11 @@ module.exports.create = function (le) { registerAsync: function (args) { var err; var copy = utils.merge(args, le); + var disagreeTos; args = utils.tplCopy(copy); - if (!args.email || !args.agreeTos || (parseInt(args.rsaKeySize, 10) < 2048)) { + 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." @@ -58,6 +59,7 @@ module.exports.create = function (le) { }, function (/*err*/) { return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.publicKeyPem = RSA.exportPublicPem(keypair); keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); return le.store.accounts.setKeypairAsync(args, keypair); }); @@ -69,8 +71,6 @@ module.exports.create = function (le) { return core.getAcmeUrlsAsync(args).then(function (urls) { args._acmeUrls = urls; - throw new Error("WAIT! Don't go yet!!!"); - return le.acme.registerNewAccountAsync({ email: args.email , newRegUrl: args._acmeUrls.newReg @@ -88,28 +88,18 @@ module.exports.create = function (le) { , accountKeypair: keypair , debug: le.debug || args.debug - }).then(function (body) { - // TODO XXX use sha256 (the python client uses md5) - // TODO ssh fingerprint (noted on rsa-compat issues page, I believe) - keypair.publicKeyMd5 = crypto.createHash('md5').update(RSA.exportPublicPem(keypair)).digest('hex'); - keypair.publicKeySha256 = crypto.createHash('sha256').update(RSA.exportPublicPem(keypair)).digest('hex'); + }).then(function (receipt) { + var reg = { + keypair: keypair + , receipt: receipt + , email: args.email + }; - var accountId = keypair.publicKeyMd5; - var regr = { body: body }; - var account = {}; - - args.accountId = accountId; - - account.keypair = keypair; - account.regr = regr; - account.accountId = accountId; - account.id = accountId; - account.email = args.email; - - args.account = account; - - // TODO move templating to right here? - return le.store.accounts.setAsync(args, account).then(function () { + // TODO move templating of arguments to right here? + return le.store.accounts.setAsync(args, reg).then(function (account) { + // should now have account.id and account.accountId + args.account = account; + args.accountId = account.id; return account; }); }); @@ -186,6 +176,7 @@ module.exports.create = function (le) { }, function (/*err*/) { return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.publicKeyPem = RSA.exportPublicPem(keypair); keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); return le.store.certificates.setKeypairAsync(args, keypair); }); diff --git a/tests/create-account.js b/tests/create-account.js index 1c5c888..d648770 100644 --- a/tests/create-account.js +++ b/tests/create-account.js @@ -11,7 +11,8 @@ var le = LE.create({ , debug: true }); -var testId = Math.round(Date.now() / 1000).toString(); +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; var fakeEmail = 'coolaj86+le.' + testId + '@example.com'; var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; var testAccount; @@ -74,6 +75,10 @@ var tests = [ , rsaKeySize: 2048 }).then(function (account) { testAccount = account; + + console.log(testEmail); + console.log(testAccount); + if (!account) { throw new Error("Registration should always return a new account."); } @@ -85,24 +90,6 @@ var tests = [ } }); } -, function () { - return le.core.accounts.checkAsync({ - email: testAccount.email - }).then(function (account) { - if (!account) { - throw new Error("Test account should exist when searched by email."); - } - }); - } -, function () { - return le.core.accounts.checkAsync({ - accountId: testAccount.id - }).then(function (account) { - if (!account) { - throw new Error("Test account should exist when searched by account id."); - } - }); - } ]; function run() { From 8535b2127c6cca851e09f491953e81a2ad03df73 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 19:43:18 -0400 Subject: [PATCH 27/47] passes account lookup tests --- tests/check-account.js | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/check-account.js diff --git a/tests/check-account.js b/tests/check-account.js new file mode 100644 index 0000000..4dfaaa7 --- /dev/null +++ b/tests/check-account.js @@ -0,0 +1,56 @@ +'use strict'; + +var LE = require('../').LE; +var le = LE.create({ + server: 'staging' +, acme: require('le-acme-core').ACME.create() +, store: require('le-store-certbot').create({ + configDir: '~/letsencrypt.test/etc/' + , webrootPath: '~/letsencrypt.test/tmp/:hostname' + }) +, debug: true +}); + +// TODO test generateRsaKey code path separately +// and then provide opts.accountKeypair to create account + +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; +var testAccountId = '939573edbf2506c92c9ab32131209d7b'; + +var tests = [ + function () { + return le.core.accounts.checkAsync({ + accountId: testAccountId + }).then(function (account) { + if (!account) { + throw new Error("Test account should exist when searched by account id."); + } + }); + } + +, function () { + return le.core.accounts.checkAsync({ + email: testEmail + }).then(function (account) { + console.log('account.regr'); + console.log(account.regr); + if (!account) { + throw new Error("Test account should exist when searched by email."); + } + }); + } +]; + +function run() { + var test = tests.shift(); + if (!test) { + console.info('All tests passed'); + return; + } + + test().then(run); +} + +run(); From 06822604a12d83b958d095c917ceb0394c56b7a4 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 19:43:31 -0400 Subject: [PATCH 28/47] stuff --- lib/cert-info.js | 14 +++++++------- lib/core.js | 8 ++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/cert-info.js b/lib/cert-info.js index 50bd31c..b7bc669 100644 --- a/lib/cert-info.js +++ b/lib/cert-info.js @@ -48,15 +48,15 @@ certInfo.testGetCertInfo = function () { if (require.main === module) { var c = certInfo.testGetCertInfo(); - console.log(''); + console.info(''); - console.log(c.notBefore.value); - console.log(Date(c.notBefore.value).valueOf()); + console.info(c.notBefore.value); + console.info(Date(c.notBefore.value).valueOf()); - console.log(''); + console.info(''); - console.log(c.notAfter.value); - console.log(Date(c.notAfter.value).valueOf()); + console.info(c.notAfter.value); + console.info(Date(c.notAfter.value).valueOf()); - console.log(''); + console.info(''); } diff --git a/lib/core.js b/lib/core.js index 2b0b7c6..89ed823 100644 --- a/lib/core.js +++ b/lib/core.js @@ -57,6 +57,10 @@ module.exports.create = function (le) { var promise = le.store.accounts.checkKeypairAsync(args).then(function (keypair) { return RSA.import(keypair); }, function (/*err*/) { + if (args.accountKeypair) { + return le.store.accounts.setKeypairAsync(args, RSA.import(args.accountKeypair)); + } + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { keypair.privateKeyPem = RSA.exportPrivatePem(keypair); keypair.publicKeyPem = RSA.exportPublicPem(keypair); @@ -174,6 +178,10 @@ module.exports.create = function (le) { var promise = le.store.certificates.checkKeypairAsync(args).then(function (keypair) { return RSA.import(keypair); }, function (/*err*/) { + if (args.domainKeypair) { + return le.store.certificates.setKeypairAsync(args, RSA.import(args.domainKeypair)); + } + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { keypair.privateKeyPem = RSA.exportPrivatePem(keypair); keypair.publicKeyPem = RSA.exportPublicPem(keypair); From 5d8c71a982ee7e62806cc6262e63489449ae10d3 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 19:50:09 -0400 Subject: [PATCH 29/47] remove cruft --- tests/README.md | 9 --------- tests/acme-challenge/.gitkeep | 0 tests/acme-challenge/hello | 1 - tests/config.js | 14 -------------- tests/letsencrypt.config/.gitkeep | 0 tests/letsencrypt.logs/.gitkeep | 0 tests/letsencrypt.work/.gitkeep | 0 7 files changed, 24 deletions(-) delete mode 100644 tests/README.md delete mode 100644 tests/acme-challenge/.gitkeep delete mode 100644 tests/acme-challenge/hello delete mode 100644 tests/config.js delete mode 100644 tests/letsencrypt.config/.gitkeep delete mode 100644 tests/letsencrypt.logs/.gitkeep delete mode 100644 tests/letsencrypt.work/.gitkeep diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 277de9d..0000000 --- a/tests/README.md +++ /dev/null @@ -1,9 +0,0 @@ -moved the tests to the examples folder - -```bash -node examples/commandline.js example.com,www.example.com user@example.com agree -``` - -Try it for yourself. - -Go watch [Let's Encrypt in (exactly) 90 seconds](https://daplie.com/articles/lets-encrypt-in-literally-90-seconds/) and swap out the Caddy instructions with the node instructions. diff --git a/tests/acme-challenge/.gitkeep b/tests/acme-challenge/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/acme-challenge/hello b/tests/acme-challenge/hello deleted file mode 100644 index ce01362..0000000 --- a/tests/acme-challenge/hello +++ /dev/null @@ -1 +0,0 @@ -hello diff --git a/tests/config.js b/tests/config.js deleted file mode 100644 index 6cab74b..0000000 --- a/tests/config.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -var path = require('path'); - -module.exports = { - server: "https://acme-staging.api.letsencrypt.org/directory" -, tlsSni01Port: 5001 -, http01Port: 80 -, webrootPath: path.join(__dirname, "acme-challenge") -, configDir: path.join(__dirname, "letsencrypt.config") -, workDir: path.join(__dirname, "letsencrypt.work") -, logsDir: path.join(__dirname, "letsencrypt.logs") -, allowedDomains: ['example.com'] -}; diff --git a/tests/letsencrypt.config/.gitkeep b/tests/letsencrypt.config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/letsencrypt.logs/.gitkeep b/tests/letsencrypt.logs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/letsencrypt.work/.gitkeep b/tests/letsencrypt.work/.gitkeep deleted file mode 100644 index e69de29..0000000 From c3f6bebe2b138d929fe8d1f2bcb02f0b6755fc41 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 20:01:22 -0400 Subject: [PATCH 30/47] bugfix Date -> new Date --- lib/cert-info.js | 4 ++-- tests/cert-info.js | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/cert-info.js diff --git a/lib/cert-info.js b/lib/cert-info.js index b7bc669..394b7bf 100644 --- a/lib/cert-info.js +++ b/lib/cert-info.js @@ -51,12 +51,12 @@ if (require.main === module) { console.info(''); console.info(c.notBefore.value); - console.info(Date(c.notBefore.value).valueOf()); + console.info(new Date(c.notBefore.value).valueOf()); console.info(''); console.info(c.notAfter.value); - console.info(Date(c.notAfter.value).valueOf()); + console.info(new Date(c.notAfter.value).valueOf()); console.info(''); } diff --git a/tests/cert-info.js b/tests/cert-info.js new file mode 100644 index 0000000..525ce94 --- /dev/null +++ b/tests/cert-info.js @@ -0,0 +1,21 @@ +'use strict'; + +var certInfo = require('../lib/cert-info.js'); + +var c = certInfo.testGetCertInfo(); + +console.info(''); + +console.info(c.notBefore.value); +console.info(new Date(c.notBefore.value).valueOf()); + +console.info(''); + +console.info(c.notAfter.value); +console.info(new Date(c.notAfter.value).valueOf()); + +console.info(''); + +console.info(''); +console.info('If we got values at all, it must have passed.'); +console.info(''); From 0f87922cdfc1c2b7d8df45605b4b834a5a183904 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 20:26:46 -0400 Subject: [PATCH 31/47] don't attach info to null cert --- lib/core.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/core.js b/lib/core.js index 89ed823..4c5aea0 100644 --- a/lib/core.js +++ b/lib/core.js @@ -257,17 +257,26 @@ module.exports.create = function (le) { }); }); } + // Certificates , renewAsync: function (args) { - // TODO fetch email address if not present + // TODO fetch email address (accountBydomain) if not present return core.certificates.registerAsync(args); } + // Certificates , checkAsync: function (args) { var copy = utils.merge(args, le); utils.tplCopy(copy); // returns pems - return le.store.certificates.checkAsync(copy).then(utils.attachCertInfo); + return le.store.certificates.checkAsync(copy).then(function (cert) { + if (cert) { + return utils.attachCertInfo(cert); + } + + return null; + }); } + // Certificates , getAsync: function (args) { var copy = utils.merge(args, le); args = utils.tplCopy(copy); From 73645a445911e176f99a1c9a86993dc8bf9d363b Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Aug 2016 20:26:55 -0400 Subject: [PATCH 32/47] begin register cert test --- tests/register-certificate.js | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/register-certificate.js diff --git a/tests/register-certificate.js b/tests/register-certificate.js new file mode 100644 index 0000000..4537f58 --- /dev/null +++ b/tests/register-certificate.js @@ -0,0 +1,71 @@ +'use strict'; + +var LE = require('../').LE; +var le = LE.create({ + server: 'staging' +, acme: require('le-acme-core').ACME.create() +, store: require('le-store-certbot').create({ + configDir: '~/letsencrypt.test/etc/' + , webrootPath: '~/letsencrypt.test/tmp/:hostname' + }) +, debug: true +}); + +// TODO test generateRsaKey code path separately +// and then provide opts.accountKeypair to create account + +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; +// TODO integrate with Daplie Domains for junk domains to test with +var testDomains = [ 'pokemap.hellabit.com', 'www.pokemap.hellabit.com' ]; + +var tests = [ + function () { + return le.core.certificates.checkAsync({ + domains: [ 'example.com' ] + }).then(function (cert) { + if (cert) { + throw new Error("Bogus domain should not have certificate."); + } + }); + } + +, function () { + return le.core.certificates.checkAsync({ + email: testEmail + , domains: testDomains + }).then(function (account) { + if (!account) { + throw new Error("Test account should exist when searched by email."); + } + }); + } +]; + +function run() { + //var express = require(express); + var server = require('http').createServer(le.middleware()); + server.listen(80, function () { + console.log('Server running, proceeding to test.'); + + function next() { + var test = tests.shift(); + if (!test) { + console.info('All tests passed'); + return; + } + + test().then(next, function (err) { + console.error('ERROR'); + console.error(err.stack); + }).then(function () { + server.close(); + }); + } + + next(); + }); +} + +run(); From 285cc8f95b9f9fb3344f56dd5b186c569af40b4a Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 14:05:47 -0400 Subject: [PATCH 33/47] pass middleware tests --- README.md | 6 +- index.js | 25 +++++---- lib/middleware.js | 43 ++++++++++---- tests/challenge-middleware.js | 102 ++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 tests/challenge-middleware.js diff --git a/README.md b/README.md index 0ed0f29..bb446ef 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Install one for managing certificate storage and the other for handling ACME challenges. The default storage plugin is [`le-store-certbot`](https://github.com/Daplie/le-store-certbot) -and the default challenger is [`le-challenge-fs`](https://github.com/Daplie/le-challenge-fs). +and the default challenge is [`le-challenge-fs`](https://github.com/Daplie/le-challenge-fs). ```bash npm install --save letsencrypt@2.x @@ -108,7 +108,7 @@ var leStore = require('le-store-certbot').create({ // ACME Challenge Handlers -var leChallenger = require('le-challenge-fs').create({ +var leChallenge = require('le-challenge-fs').create({ webrootPath: '~/letsencrypt/var/' // or template string such as , debug: false // '/srv/www/:hostname/.well-known/acme-challenge' }); @@ -122,7 +122,7 @@ function leAgree(opts, agreeCb) { le = LE.create({ server: LE.stagingServerUrl // or LE.productionServerUrl , store: leStore // handles saving of config, accounts, and certificates -, challenger: leChallenger // handles /.well-known/acme-challege keys and tokens +, challenge: leChallenge // handles /.well-known/acme-challege keys and tokens , agreeToTerms: leAgree // hook to allow user to view and accept LE TOS , debug: false }); diff --git a/index.js b/index.js index 7cc6b7f..a59a8ac 100644 --- a/index.js +++ b/index.js @@ -27,7 +27,7 @@ var u; // undefined LE._undefined = { acme: u , store: u -, challenger: u +, challenge: u , register: u , check: u @@ -40,6 +40,8 @@ LE._undefined = { , server: u , agreeToTerms: u , _ipc: u +, duplicate: u +, _acmeUrls: u }; LE._undefine = function (le) { Object.keys(LE._undefined).forEach(function (key) { @@ -55,7 +57,7 @@ LE.create = function (le) { le.acme = le.acme || ACME.create({ debug: le.debug }); le.store = le.store || require('le-store-certbot').create({ debug: le.debug }); - le.challenger = le.challenger || require('le-store-certbot').create({ debug: le.debug }); + le.challenge = le.challenge || require('le-challenge-certbot').create({ debug: le.debug }); le.core = require('./lib/core'); le = LE._undefine(le); @@ -102,14 +104,14 @@ LE.create = function (le) { } }); - if (le.challenger.create) { - le.challenger = le.challenger.create(le); + if (le.challenge.create) { + le.challenge = le.challenge.create(le); } - le.challenger = PromiseA.promisifyAll(le.challenger); - le._challengerOpts = le.challenger.getOptions(); - Object.keys(le._challengerOpts).forEach(function (key) { + le.challenge = PromiseA.promisifyAll(le.challenge); + le._challengeOpts = le.challenge.getOptions(); + Object.keys(le._challengeOpts).forEach(function (key) { if (!(key in le)) { - le[key] = le._challengerOpts[key]; + le[key] = le._challengeOpts[key]; } }); @@ -126,9 +128,10 @@ LE.create = function (le) { return le.core.certificates.checkAsync(args); }; - le.middleware = function () { - return require('./lib/middleware')(le); - }; + le.middleware = le.middleware || require('./lib/middleware'); + if (le.middleware.create) { + le.middleware = le.middleware.create(le); + } return le; }; diff --git a/lib/middleware.js b/lib/middleware.js index 13af19c..6efe68b 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -1,30 +1,53 @@ 'use strict'; -module.exports = function (le) { +var utils = require('./utils'); + +function log(debug) { + if (debug) { + var args = Array.prototype.slice.call(arguments); + args.shift(); + args.unshift("[le/lib/middleware.js]"); + console.log.apply(console, args); + } +} + +module.exports.create = function (le) { + if (!le.challenge || !le.challenge.get) { + throw new Error("middleware requires challenge plugin with get method"); + } + + log(le.debug, "created middleware"); return function () { var prefix = le.acmeChallengePrefix; // /.well-known/acme-challenge/:token return function (req, res, next) { if (0 !== req.url.indexOf(prefix)) { + log(le.debug, "no match, skipping middleware"); next(); return; } - var key = req.url.slice(prefix.length); + log(le.debug, "this must be tinder, 'cuz it's a match!"); + + var token = req.url.slice(prefix.length); var hostname = req.hostname || (req.headers.host || '').toLowerCase().replace(/:*/, ''); + log(le.debug, "hostname", hostname, "token", token); + + var copy = utils.merge({}, le); + copy = utils.tplCopy(copy); + // TODO tpl copy? - le.challenger.getAsync(le, hostname, key).then(function (token) { - if (!token) { - res.status = 404; - res.send("Error: These aren't the tokens you're looking for. Move along."); + le.challenge.get(copy, hostname, token, function (err, secret) { + if (err || !token) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end('{ "error": { "message": "Error: These aren\'t the tokens you\'re looking for. Move along." } }'); return; } - res.send(token); - }, function (/*err*/) { - res.status = 404; - res.send("Error: These aren't the tokens you're looking for. Move along."); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(secret); }); }; }; diff --git a/tests/challenge-middleware.js b/tests/challenge-middleware.js new file mode 100644 index 0000000..625b27f --- /dev/null +++ b/tests/challenge-middleware.js @@ -0,0 +1,102 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var requestAsync = PromiseA.promisify(require('request')); +var LE = require('../').LE; +var le = LE.create({ + server: 'staging' +, acme: require('le-acme-core').ACME.create() +, store: require('le-store-certbot').create({ + configDir: '~/letsencrypt.test/etc' + , webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, challenge: require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, debug: true +}); +var utils = require('../lib/utils'); + +if ('/.well-known/acme-challenge/' !== LE.acmeChallengePrefix) { + throw new Error("Bad constant 'acmeChallengePrefix'"); +} + +var baseUrl; +var domain = 'example.com'; +var token = 'token-id'; +var secret = 'key-secret'; + +var tests = [ + function () { + console.log('Test Url:', baseUrl + token); + return requestAsync({ url: baseUrl + token }).then(function (req) { + if (404 !== req.statusCode) { + console.log(req.statusCode); + throw new Error("Should be status 404"); + } + }); + } + +, function () { + var copy = utils.merge({}, le); + copy = utils.tplCopy(copy); + return PromiseA.promisify(le.challenge.set)(copy, domain, token, secret); + } + +, function () { + return requestAsync(baseUrl + token).then(function (req) { + if (200 !== req.statusCode) { + console.log(req.statusCode, req.body); + throw new Error("Should be status 200"); + } + + if (req.body !== secret) { + console.error(token, secret, req.body); + throw new Error("req.body should be secret"); + } + }); + } + +, function () { + var copy = utils.merge({}, le); + copy = utils.tplCopy(copy); + return PromiseA.promisify(le.challenge.remove)(copy, domain, token); + } + +, function () { + return requestAsync(baseUrl + token).then(function (req) { + if (404 !== req.statusCode) { + console.log(req.statusCode); + throw new Error("Should be status 404"); + } + }); + } +]; + +function run() { + //var express = require(express); + var server = require('http').createServer(le.middleware()); + server.listen(0, function () { + console.log('Server running, proceeding to test.'); + baseUrl = 'http://localhost.daplie.com:' + server.address().port + LE.acmeChallengePrefix; + + function next() { + var test = tests.shift(); + if (!test) { + console.info('All tests passed'); + server.close(); + return; + } + + test().then(next, function (err) { + console.error('ERROR'); + console.error(err.stack); + server.close(); + }); + } + + next(); + }); +} + +run(); From 7730ffc319207ba877cd45e8e1004aa143af49f1 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 14:17:26 -0400 Subject: [PATCH 34/47] bugfix middleware with templated :hostname --- lib/middleware.js | 4 ++-- tests/challenge-middleware.js | 22 +++++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/middleware.js b/lib/middleware.js index 6efe68b..5473a15 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -30,11 +30,11 @@ module.exports.create = function (le) { log(le.debug, "this must be tinder, 'cuz it's a match!"); var token = req.url.slice(prefix.length); - var hostname = req.hostname || (req.headers.host || '').toLowerCase().replace(/:*/, ''); + var hostname = req.hostname || (req.headers.host || '').toLowerCase().replace(/:.*/, ''); log(le.debug, "hostname", hostname, "token", token); - var copy = utils.merge({}, le); + var copy = utils.merge({ domains: [ hostname ] }, le); copy = utils.tplCopy(copy); // TODO tpl copy? diff --git a/tests/challenge-middleware.js b/tests/challenge-middleware.js index 625b27f..917ef83 100644 --- a/tests/challenge-middleware.js +++ b/tests/challenge-middleware.js @@ -1,17 +1,18 @@ 'use strict'; var PromiseA = require('bluebird'); +var path = require('path'); var requestAsync = PromiseA.promisify(require('request')); var LE = require('../').LE; var le = LE.create({ server: 'staging' , acme: require('le-acme-core').ACME.create() , store: require('le-store-certbot').create({ - configDir: '~/letsencrypt.test/etc' - , webrootPath: '~/letsencrypt.test/var/:hostname' + configDir: '~/letsencrypt.test/etc'.split('/').join(path.sep) + , webrootPath: '~/letsencrypt.test/var/:hostname'.split('/').join(path.sep) }) , challenge: require('le-challenge-fs').create({ - webrootPath: '~/letsencrypt.test/var/:hostname' + webrootPath: '~/letsencrypt.test/var/:hostname'.split('/').join(path.sep) }) , debug: true }); @@ -22,7 +23,10 @@ if ('/.well-known/acme-challenge/' !== LE.acmeChallengePrefix) { } var baseUrl; -var domain = 'example.com'; +// could use localhost as well, but for the sake of an FQDN for testing, we use this +// also, example.com is just a junk domain to make sure that it is ignored +// (even though it should always be an array of only one element in lib/core.js) +var domains = [ 'localhost.daplie.com', 'example.com' ]; // or just localhost var token = 'token-id'; var secret = 'key-secret'; @@ -38,9 +42,9 @@ var tests = [ } , function () { - var copy = utils.merge({}, le); + var copy = utils.merge({ domains: domains }, le); copy = utils.tplCopy(copy); - return PromiseA.promisify(le.challenge.set)(copy, domain, token, secret); + return PromiseA.promisify(le.challenge.set)(copy, domains[0], token, secret); } , function () { @@ -58,9 +62,9 @@ var tests = [ } , function () { - var copy = utils.merge({}, le); + var copy = utils.merge({ domains: domains }, le); copy = utils.tplCopy(copy); - return PromiseA.promisify(le.challenge.remove)(copy, domain, token); + return PromiseA.promisify(le.challenge.remove)(copy, domains[0], token); } , function () { @@ -78,7 +82,7 @@ function run() { var server = require('http').createServer(le.middleware()); server.listen(0, function () { console.log('Server running, proceeding to test.'); - baseUrl = 'http://localhost.daplie.com:' + server.address().port + LE.acmeChallengePrefix; + baseUrl = 'http://' + domains[0] + ':' + server.address().port + LE.acmeChallengePrefix; function next() { var test = tests.shift(); From 40c336f20411696e109f5f547280fe11bc7d134c Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 14:17:44 -0400 Subject: [PATCH 35/47] close server only when finished --- tests/register-certificate.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/register-certificate.js b/tests/register-certificate.js index 4537f58..381f97b 100644 --- a/tests/register-certificate.js +++ b/tests/register-certificate.js @@ -5,8 +5,11 @@ var le = LE.create({ server: 'staging' , acme: require('le-acme-core').ACME.create() , store: require('le-store-certbot').create({ - configDir: '~/letsencrypt.test/etc/' - , webrootPath: '~/letsencrypt.test/tmp/:hostname' + configDir: '~/letsencrypt.test/etc' + , webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, challenge: require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt.test/var/:hostname' }) , debug: true }); @@ -23,7 +26,7 @@ var testDomains = [ 'pokemap.hellabit.com', 'www.pokemap.hellabit.com' ]; var tests = [ function () { return le.core.certificates.checkAsync({ - domains: [ 'example.com' ] + domains: [ 'example.com', 'www.example.com' ] }).then(function (cert) { if (cert) { throw new Error("Bogus domain should not have certificate."); @@ -32,12 +35,12 @@ var tests = [ } , function () { - return le.core.certificates.checkAsync({ + return le.core.certificates.getAsync({ email: testEmail , domains: testDomains - }).then(function (account) { - if (!account) { - throw new Error("Test account should exist when searched by email."); + }).then(function (certs) { + if (!certs) { + throw new Error("Should have acquired certificate for domains."); } }); } @@ -52,6 +55,7 @@ function run() { function next() { var test = tests.shift(); if (!test) { + server.close(); console.info('All tests passed'); return; } @@ -59,7 +63,6 @@ function run() { test().then(next, function (err) { console.error('ERROR'); console.error(err.stack); - }).then(function () { server.close(); }); } From 19d6ac68dec1b2a43c82e689b75527123edcf545 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 14:43:13 -0400 Subject: [PATCH 36/47] parse domains from cert info --- lib/cert-info.js | 25 +++++++++++++++++++++++++ tests/cert-info.js | 6 ++++++ 2 files changed, 31 insertions(+) diff --git a/lib/cert-info.js b/lib/cert-info.js index 394b7bf..3664fb3 100644 --- a/lib/cert-info.js +++ b/lib/cert-info.js @@ -35,6 +35,25 @@ certInfo.getCertInfo = function (pem) { return certSimpl; }; +certInfo.getBasicInfo = function (pem) { + var c = certInfo.getCertInfo(pem); + var domains = []; + + c.extensions.forEach(function (ext) { + if (ext.parsedValue && ext.parsedValue.altNames) { + ext.parsedValue.altNames.forEach(function (alt) { + domains.push(alt.Name); + }); + } + }); + + return { + issuedAt: c.notBefore.value + , expiresAt: c.notAfter.value + , domains: domains + }; +}; + certInfo.getCertInfoFromFile = function (pemFile) { return require('fs').readFileSync(pemFile, 'ascii'); }; @@ -45,6 +64,12 @@ certInfo.testGetCertInfo = function () { return certInfo.getCertInfo(certInfo.getCertInfoFromFile(pemFile)); }; +certInfo.testBasicCertInfo = function () { + var path = require('path'); + var pemFile = path.join(__dirname, '..', 'tests', 'example.cert.pem'); + return certInfo.getBasicInfo(certInfo.getCertInfoFromFile(pemFile)); +}; + if (require.main === module) { var c = certInfo.testGetCertInfo(); diff --git a/tests/cert-info.js b/tests/cert-info.js index 525ce94..ddab280 100644 --- a/tests/cert-info.js +++ b/tests/cert-info.js @@ -16,6 +16,12 @@ console.info(new Date(c.notAfter.value).valueOf()); console.info(''); +var json = certInfo.testBasicCertInfo(); + +console.log(''); +console.log(JSON.stringify(json, null, ' ')); +console.log(''); + console.info(''); console.info('If we got values at all, it must have passed.'); console.info(''); From 86f28ebbda75bcbd02c6b110895050fd3352763c Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 14:48:50 -0400 Subject: [PATCH 37/47] get cert subject --- lib/cert-info.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/cert-info.js b/lib/cert-info.js index 3664fb3..8672112 100644 --- a/lib/cert-info.js +++ b/lib/cert-info.js @@ -38,6 +38,7 @@ certInfo.getCertInfo = function (pem) { certInfo.getBasicInfo = function (pem) { var c = certInfo.getCertInfo(pem); var domains = []; + var sub; c.extensions.forEach(function (ext) { if (ext.parsedValue && ext.parsedValue.altNames) { @@ -47,10 +48,13 @@ certInfo.getBasicInfo = function (pem) { } }); + sub = c.subject.types_and_values[0].value.value_block.value || null; + return { issuedAt: c.notBefore.value , expiresAt: c.notAfter.value , domains: domains + , subject: sub }; }; From 3f08d7d83f574e3d8a78e5b222974754f0a5fa64 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 15:02:10 -0400 Subject: [PATCH 38/47] minor bugfix and improvements --- index.js | 13 +++++++ lib/core.js | 100 ++++++++++++++++++++++++++++++++-------------------- 2 files changed, 74 insertions(+), 39 deletions(-) diff --git a/index.js b/index.js index a59a8ac..5f6067e 100644 --- a/index.js +++ b/index.js @@ -114,6 +114,19 @@ LE.create = function (le) { le[key] = le._challengeOpts[key]; } }); + // TODO wrap these here and now with tplCopy? + if (5 !== le.challenge.set.length) { + throw new Error("le.challenge.set receives the wrong number of arguments." + + " You must define setChallenge as function (opts, domain, key, val, cb) { }"); + } + if (4 !== le.challenge.get.length) { + throw new Error("le.challenge.get receives the wrong number of arguments." + + " You must define getChallenge as function (opts, domain, key, cb) { }"); + } + if (4 !== le.challenge.remove.length) { + throw new Error("le.challenge.remove receives the wrong number of arguments." + + " You must define removeChallenge as function (opts, domain, key, cb) { }"); + } if (le.core.create) { le.core = le.core.create(le); diff --git a/lib/core.js b/lib/core.js index 4c5aea0..2ccad60 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,5 +1,14 @@ 'use strict'; +function log(debug) { + if (debug) { + var args = Array.prototype.slice.call(arguments); + args.shift(); + args.unshift("[le/lib/core.js]"); + console.log.apply(console, args); + } +} + module.exports.create = function (le) { var PromiseA = require('bluebird'); var utils = require('./utils'); @@ -55,8 +64,10 @@ module.exports.create = function (le) { var keypairOpts = { public: true, pem: true }; var promise = le.store.accounts.checkKeypairAsync(args).then(function (keypair) { - return RSA.import(keypair); - }, function (/*err*/) { + if (keypair) { + return RSA.import(keypair); + } + if (args.accountKeypair) { return le.store.accounts.setKeypairAsync(args, RSA.import(args.accountKeypair)); } @@ -169,6 +180,8 @@ module.exports.create = function (le) { return PromiseA.reject(err); } + // TODO renewal cb + // accountId and or email return core.accounts.getAsync(copy).then(function (account) { copy.account = account; @@ -176,8 +189,10 @@ module.exports.create = function (le) { var keypairOpts = { public: true, pem: true }; var promise = le.store.certificates.checkKeypairAsync(args).then(function (keypair) { - return RSA.import(keypair); - }, function (/*err*/) { + if (keypair) { + return RSA.import(keypair); + } + if (args.domainKeypair) { return le.store.certificates.setKeypairAsync(args, RSA.import(args.domainKeypair)); } @@ -199,7 +214,10 @@ module.exports.create = function (le) { return core.getAcmeUrlsAsync(args).then(function (urls) { args._acmeUrls = urls; - return le.acme.getCertificateAsync({ + log(args.debug, 'BEFORE CERT'); + log(args.debug, args); + throw new Error("Stop! Don't do it!"); + var certReq = { debug: args.debug || le.debug , newAuthzUrl: args._acmeUrls.newAuthz @@ -209,43 +227,35 @@ module.exports.create = function (le) { , domainKeypair: domainKeypair , domains: args.domains , challengeType: args.challengeType + }; - // - // 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 = utils.merge({ domains: [domain] }, le); - utils.tplCopy(copy); + // + // 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 (domain, key, value, done) { + log(args.debug, "setChallenge called for '" + domain + "'"); + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); - //args.domains = [domain]; - args.domains = args.domains || [domain]; + le.challenge.set(copy, domain, key, value, done); + }; + certReq.removeChallenge = function (domain, key, done) { + log(args.debug, "setChallenge called for '" + domain + "'"); + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); - if (5 !== le.challenger.set.length) { - done(new Error("le.challenger.set receives the wrong number of arguments." - + " You must define setChallenge as function (opts, domain, key, val, cb) { }")); - return; - } + le.challenge.remove(copy, domain, key, done); + }; - le.challenger.set(copy, domain, key, value, done); - } - , removeChallenge: function (domain, key, done) { - var copy = utils.merge({ domains: [domain] }, le); - utils.tplCopy(copy); - - if (4 !== le.challenger.remove.length) { - done(new Error("le.challenger.remove receives the wrong number of arguments." - + " You must define removeChallenge as function (opts, domain, key, cb) { }")); - return; - } - - le.challenger.remove(copy, domain, key, done); - } - }).then(utils.attachCertInfo); + return le.acme.getCertificateAsync(certReq).then(utils.attachCertInfo); }); }).then(function (results) { // { cert, chain, privkey } @@ -260,6 +270,7 @@ module.exports.create = function (le) { // Certificates , renewAsync: function (args) { // TODO fetch email address (accountBydomain) if not present + // store.config.getAsync(args.domains).then(function (config) { /*...*/ }); return core.certificates.registerAsync(args); } // Certificates @@ -284,6 +295,7 @@ module.exports.create = function (le) { return core.certificates.checkAsync(args).then(function (certs) { if (!certs) { // There is no cert available + log(args.debug, "no certificate found"); return core.certificates.registerAsync(args); } @@ -291,16 +303,26 @@ module.exports.create = function (le) { //var halfLife = (certs.expiresAt - certs.issuedAt) / 2; //var renewable = (Date.now() - certs.issuedAt) > halfLife; + log(args.debug, "Expires At", new Date(certs.expiresAt).toISOString()); + log(args.debug, "Renewable At", new Date(renewableAt).toISOString()); if (args.duplicate || Date.now() >= renewableAt) { // The cert is more than half-expired // We're forcing a refresh via 'dupliate: true' + log(args.debug, "Renewing!"); + if (Array.isArray(certs.domains) && certs.domains.length && args.domains.length <= 2) { + // this is a renewal, therefore we should renewal ALL of the domains + // associated with this certificate, unless args.domains is a list larger + // than example.com,www.example.com + // TODO check www. prefix + args.domains = certs.domains; + } return core.certificates.renewAsync(args); } return PromiseA.reject(new Error( "[ERROR] Certificate issued at '" + new Date(certs.issuedAt).toISOString() + "' and expires at '" - + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until half-life at '" + + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until '" + new Date(renewableAt).toISOString() + "'. Set { duplicate: true } to force." )); }).then(function (results) { From f06d25ab223b18e2b7376400644f93553aebb30e Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 15:10:44 -0400 Subject: [PATCH 39/47] registering cert passes --- lib/core.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core.js b/lib/core.js index 2ccad60..85508a1 100644 --- a/lib/core.js +++ b/lib/core.js @@ -214,9 +214,6 @@ module.exports.create = function (le) { return core.getAcmeUrlsAsync(args).then(function (urls) { args._acmeUrls = urls; - log(args.debug, 'BEFORE CERT'); - log(args.debug, args); - throw new Error("Stop! Don't do it!"); var certReq = { debug: args.debug || le.debug @@ -255,6 +252,9 @@ module.exports.create = function (le) { le.challenge.remove(copy, domain, key, done); }; + log(args.debug, 'BEFORE GET CERT'); + log(args.debug, certReq); + return le.acme.getCertificateAsync(certReq).then(utils.attachCertInfo); }); }).then(function (results) { From 815c569674702b6bd46db79b2b7390caea9baa50 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 15:10:53 -0400 Subject: [PATCH 40/47] allow specifying cert path --- lib/cert-info.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/cert-info.js b/lib/cert-info.js index 8672112..b3e8cc7 100644 --- a/lib/cert-info.js +++ b/lib/cert-info.js @@ -62,20 +62,20 @@ certInfo.getCertInfoFromFile = function (pemFile) { return require('fs').readFileSync(pemFile, 'ascii'); }; -certInfo.testGetCertInfo = function () { +certInfo.testGetCertInfo = function (pathname) { var path = require('path'); - var pemFile = path.join(__dirname, '..', 'tests', 'example.cert.pem'); + var pemFile = pathname || path.join(__dirname, '..', 'tests', 'example.cert.pem'); return certInfo.getCertInfo(certInfo.getCertInfoFromFile(pemFile)); }; -certInfo.testBasicCertInfo = function () { +certInfo.testBasicCertInfo = function (pathname) { var path = require('path'); - var pemFile = path.join(__dirname, '..', 'tests', 'example.cert.pem'); + var pemFile = pathname || path.join(__dirname, '..', 'tests', 'example.cert.pem'); return certInfo.getBasicInfo(certInfo.getCertInfoFromFile(pemFile)); }; if (require.main === module) { - var c = certInfo.testGetCertInfo(); + var c = certInfo.testGetCertInfo(process.argv[2]); console.info(''); @@ -88,4 +88,9 @@ if (require.main === module) { console.info(new Date(c.notAfter.value).valueOf()); console.info(''); + + var b = certInfo.testBasicCertInfo(process.argv[2]); + console.info(''); + console.info(JSON.stringify(b, null, ' ')); + console.info(''); } From dad5aca9ff910344b961e8e1fc3bb14fb3bb5e55 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 15:19:29 -0400 Subject: [PATCH 41/47] add subject, altnames to cert-info --- lib/cert-info.js | 12 ++++++++---- lib/utils.js | 9 +++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/cert-info.js b/lib/cert-info.js index b3e8cc7..fb90195 100644 --- a/lib/cert-info.js +++ b/lib/cert-info.js @@ -51,10 +51,14 @@ certInfo.getBasicInfo = function (pem) { sub = c.subject.types_and_values[0].value.value_block.value || null; return { - issuedAt: c.notBefore.value - , expiresAt: c.notAfter.value - , domains: domains - , subject: sub + subject: sub + , altnames: domains + // for debugging during console.log + // do not expect these values to be here + , _issuedAt: c.notBefore.value + , _expiresAt: c.notAfter.value + , issuedAt: new Date(c.notBefore.value).valueOf() + , expiresAt: new Date(c.notAfter.value).valueOf() }; }; diff --git a/lib/utils.js b/lib/utils.js index 69af35f..bf183ee 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -8,13 +8,14 @@ var PromiseA = require('bluebird'); var dns = PromiseA.promisifyAll(require('dns')); module.exports.attachCertInfo = function (results) { - var getCertInfo = require('./cert-info').getCertInfo; + var getCertInfo = require('./cert-info').getBasicInfo; // XXX Note: Parsing the certificate info comes at a great cost (~500kb) var certInfo = getCertInfo(results.cert); - //results.issuedAt = arr[3].mtime.valueOf() - results.issuedAt = Date(certInfo.notBefore.value).valueOf(); // Date.now() - results.expiresAt = Date(certInfo.notAfter.value).valueOf(); + // subject, altnames, issuedAt, expiresAt + Object.keys(certInfo).forEach(function (key) { + results[key] = certInfo[key]; + }); return results; }; From 9c4b1fd43eb9da86daef75ee689a3e495677974b Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 15:38:18 -0400 Subject: [PATCH 42/47] passes register and get, refactored renew --- lib/core.js | 77 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/lib/core.js b/lib/core.js index 85508a1..df93899 100644 --- a/lib/core.js +++ b/lib/core.js @@ -268,12 +268,58 @@ module.exports.create = function (le) { }); } // Certificates - , renewAsync: function (args) { - // TODO fetch email address (accountBydomain) if not present + , renewAsync: function (args, certs) { + var renewableAt = core.certificates._getRenewableAt(args, certs); + //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) { + return PromiseA.reject(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." + )); + } + + // Either the cert has entered its renewal period + // or we're forcing a refresh via 'dupliate: true' + log(args.debug, "Renewing!"); + + // TODO fetch email address / accountId (accountBydomain) if not present // store.config.getAsync(args.domains).then(function (config) { /*...*/ }); + if (!args.domains || (args.domains.length || 0) <= 2) { + // this is a renewal, therefore we should renewal ALL of the domains + // associated with this certificate, unless args.domains is a list larger + // than example.com,www.example.com + // TODO check www. prefix + args.domains = certs.altnames; + if (Array.isArray(certs.domains) && certs.domains.length) { + args.domains = certs.domains; + } + } + 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) { + return true; + } + + return false; + } + , _getRenewableAt: function (args, certs) { + return certs.expiresAt - le.renewWithin; + } , checkAsync: function (args) { var copy = utils.merge(args, le); utils.tplCopy(copy); @@ -299,32 +345,11 @@ module.exports.create = function (le) { return core.certificates.registerAsync(args); } - var renewableAt = certs.expiresAt - le.renewWithin; - //var halfLife = (certs.expiresAt - certs.issuedAt) / 2; - //var renewable = (Date.now() - certs.issuedAt) > halfLife; - - log(args.debug, "Expires At", new Date(certs.expiresAt).toISOString()); - log(args.debug, "Renewable At", new Date(renewableAt).toISOString()); - if (args.duplicate || Date.now() >= renewableAt) { - // The cert is more than half-expired - // We're forcing a refresh via 'dupliate: true' - log(args.debug, "Renewing!"); - if (Array.isArray(certs.domains) && certs.domains.length && args.domains.length <= 2) { - // this is a renewal, therefore we should renewal ALL of the domains - // associated with this certificate, unless args.domains is a list larger - // than example.com,www.example.com - // TODO check www. prefix - args.domains = certs.domains; - } - return core.certificates.renewAsync(args); + if (core.certificates._isRenewable(args, certs)) { + certs._renewing = core.certificates.renewAsync(args, certs); } - return PromiseA.reject(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." - )); + return certs; }).then(function (results) { // returns pems return results; From 54416f7a0b0beb2817ad9aa72dca6efce700442b Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 15:51:42 -0400 Subject: [PATCH 43/47] PASSES ALL TESTSgit status! --- lib/core.js | 9 ++-- tests/renew-certificate.js | 102 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 tests/renew-certificate.js diff --git a/lib/core.js b/lib/core.js index df93899..6ee4501 100644 --- a/lib/core.js +++ b/lib/core.js @@ -270,6 +270,7 @@ module.exports.create = function (le) { // 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; @@ -277,12 +278,14 @@ module.exports.create = function (le) { log(args.debug, "(Renew) Renewable At", new Date(renewableAt).toISOString()); if (!args.duplicate && Date.now() < renewableAt) { - return PromiseA.reject(new Error( + 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 PromiseA.reject(err); } // Either the cert has entered its renewal period @@ -318,7 +321,7 @@ module.exports.create = function (le) { return false; } , _getRenewableAt: function (args, certs) { - return certs.expiresAt - le.renewWithin; + return certs.expiresAt - (args.renewWithin || le.renewWithin); } , checkAsync: function (args) { var copy = utils.merge(args, le); diff --git a/tests/renew-certificate.js b/tests/renew-certificate.js new file mode 100644 index 0000000..58a4ae8 --- /dev/null +++ b/tests/renew-certificate.js @@ -0,0 +1,102 @@ +'use strict'; + +var LE = require('../').LE; +var le = LE.create({ + server: 'staging' +, acme: require('le-acme-core').ACME.create() +, store: require('le-store-certbot').create({ + configDir: '~/letsencrypt.test/etc' + , webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, challenge: require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, debug: true +}); + +// TODO test generateRsaKey code path separately +// and then provide opts.accountKeypair to create account + +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; +// TODO integrate with Daplie Domains for junk domains to test with +var testDomains = [ 'pokemap.hellabit.com', 'www.pokemap.hellabit.com' ]; +var testCerts; + +var tests = [ + function () { + // TODO test that an altname also fetches the proper certificate + return le.core.certificates.checkAsync({ + domains: testDomains + }).then(function (certs) { + if (!certs) { + throw new Error("Either certificates.registerAsync (in previous test)" + + " or certificates.checkAsync (in this test) failed."); + } + + testCerts = certs; + console.log('Issued At', new Date(certs.issuedAt).toISOString()); + console.log('Expires At', new Date(certs.expiresAt).toISOString()); + + if (certs.expiresAt <= Date.now()) { + throw new Error("Certificates are already expired. They cannot be tested for duplicate or forced renewal."); + } + }); + } + +, function () { + return le.core.certificates.renewAsync({ + email: testEmail + , domains: testDomains + }, testCerts).then(function () { + throw new Error("Should not have renewed non-expired certificates."); + }, function (err) { + if ('E_NOT_RENEWABLE' !== err.code) { + throw err; + } + }); + } + +, function () { + return le.core.certificates.renewAsync({ + email: testEmail + , domains: testDomains + , renewWithin: 720 * 24 * 60 * 60 * 1000 + }, testCerts).then(function (certs) { + console.log('Issued At', new Date(certs.issuedAt).toISOString()); + console.log('Expires At', new Date(certs.expiresAt).toISOString()); + + if (certs.issuedAt === testCerts.issuedAt) { + throw new Error("Should not have returned existing certificates."); + } + }); + } +]; + +function run() { + //var express = require(express); + var server = require('http').createServer(le.middleware()); + server.listen(80, function () { + console.log('Server running, proceeding to test.'); + + function next() { + var test = tests.shift(); + if (!test) { + server.close(); + console.info('All tests passed'); + return; + } + + test().then(next, function (err) { + console.error('ERROR'); + console.error(err.stack); + server.close(); + }); + } + + next(); + }); +} + +run(); From 68dcf29f303aeb3933cab7fd081b8eb7e53d5d42 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 16:08:41 -0400 Subject: [PATCH 44/47] update deps --- package.json | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 93c9a8c..e80107a 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,11 @@ "asn1": "^0.2.3", "bluebird": "^3.0.6", "homedir": "^0.6.0", - "le-acme-core": "^2.0.1", - "mkdirp": "^0.5.1", + "le-acme-core": "^2.0.5", + "le-challenge-fs": "^2.0.2", + "le-store-certbot": "^2.0.1", "node.extend": "^1.1.5", "pkijs": "^1.3.27", - "pyconf": "^1.1.2", - "request": "^2.67.0", - "rsa-compat": "^1.2.1", - "safe-replace": "^1.0.2" + "rsa-compat": "^1.2.1" } } From 6314a53a4a89a50d1d26d024237ab39513759d6d Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 16:08:49 -0400 Subject: [PATCH 45/47] v2.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e80107a..efbfe40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "letsencrypt", - "version": "1.5.1", + "version": "2.0.0", "description": "Let's Encrypt for node.js on npm", "main": "index.js", "scripts": { From b33b47407f37ab89ada4e7efb2a330241dbbec52 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 16:12:16 -0400 Subject: [PATCH 46/47] fix examples in docs --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bb446ef..411b16f 100644 --- a/README.md +++ b/README.md @@ -131,13 +131,13 @@ le = LE.create({ // If using express you should use the middleware // app.use('/', le.middleware()); // -// Otherwise you should use the wrapped getChallenge: -// le.getChallenge(domain, key, val, done) +// Otherwise you should see the test file for usage of this: +// le.challenge.get(opts.domain, key, val, done) // Check in-memory cache of certificates for the named domain -le.check({ domain: 'example.com' }).then(function (results) { +le.check({ domains: [ 'example.com' ] }).then(function (results) { if (results) { // we already have certificates return; @@ -145,7 +145,7 @@ le.check({ domain: 'example.com' }).then(function (results) { // Register Certificate manually - le.get({ + le.register({ domains: ['example.com'] // CHANGE TO YOUR DOMAIN (list for SANS) , email: 'user@email.com' // CHANGE TO YOUR EMAIL @@ -160,7 +160,7 @@ le.check({ domain: 'example.com' }).then(function (results) { }, function (err) { // Note: you must either use le.middleware() with express, - // manually use le.getChallenge(domain, key, val, done) + // manually use le.challenge.get(opts, domain, key, val, done) // or have a webserver running and responding // to /.well-known/acme-challenge at `webrootPath` console.error('[Error]: node-letsencrypt/examples/standalone'); @@ -190,7 +190,6 @@ The full end-user API is exposed in the example above and includes all relevant ``` le.register -le.get // checkAndRegister le.check ``` From d4045d484ad7e2a5994b4603ef243beeb9230b90 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 9 Aug 2016 16:12:23 -0400 Subject: [PATCH 47/47] v2.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index efbfe40..8f18824 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "letsencrypt", - "version": "2.0.0", + "version": "2.0.1", "description": "Let's Encrypt for node.js on npm", "main": "index.js", "scripts": {