From e7702c636e5b00e8ef995ec38f30dbe6a06d9d54 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 11 Dec 2015 06:22:46 -0800 Subject: [PATCH] progress --- le-base.js | 120 +++++++++++++++++++++++++++++++++++++++++ le-exec-wrapper.js | 131 +++++++++++++++++++++++++++++++++++++++++++++ utils.js | 20 +++++++ 3 files changed, 271 insertions(+) create mode 100644 le-base.js create mode 100644 le-exec-wrapper.js create mode 100644 utils.js diff --git a/le-base.js b/le-base.js new file mode 100644 index 0000000..ea37ae9 --- /dev/null +++ b/le-base.js @@ -0,0 +1,120 @@ +'use strict'; + +module.exports.create = function (lebinpath, defaults, options) { + var PromiseA = require('bluebird'); + var tls = require('tls'); + var fs = PromiseA.promisifyAll(require('fs')); + var letsencrypt = PromiseA.promisifyAll(require('./le-exec-wrapper')); + + //var attempts = {}; // should exist in master process only + var ipc = {}; // in-process cache + var count = 0; + + //var certTpl = "/live/:hostname/cert.pem"; + var certTpl = "/live/:hostname/fullchain.pem"; + var privTpl = "/live/:hostname/privkey.pem"; + + options.cacheContextsFor = options.cacheContextsFor || (1 * 60 * 60 * 1000); + + defaults.webroot = true; + defaults.webrootPath = '/srv/www/acme-challenge'; + + return letsencrypt.optsAsync(lebinpath).then(function (keys) { + var now; + var le; + + le = { + validate: function () { + } + , argnames: keys + , readCerts: function (hostname) { + var crtpath = defaults.configDir + certTpl.replace(/:hostname/, hostname); + var privpath = defaults.configDir + privTpl.replace(/:hostname/, hostname); + + return PromiseA.all([ + fs.readFileAsync(privpath, 'ascii') + , fs.readFileAsync(crtpath, 'ascii') + // stat the file, not the link + , fs.statAsync(crtpath, 'ascii') + ]).then(function (arr) { + + + return arr; + }); + } + , cacheCerts: function (hostname, certs) { + // assume 90 day renewals based on stat time, for now + ipc[hostname] = { + context: tls.createSecureContext({ + key: certs[0] // privkey.pem + , cert: certs[1] // fullchain.pem + //, ciphers // node's defaults are great + }) + , updated: Date.now() + }; + + return ipc[hostname]; + } + , readAndCacheCerts: function (hostname) { + return le.readCerts(hostname).then(function (certs) { + return le.cacheCerts(hostname, certs); + }); + } + , get: function (hostname, args, opts, cb) { + count += 1; + + if (count >= 1000) { + now = Date.now(); + count = 0; + } + + var cached = ipc[hostname]; + // TODO handle www and no-www together + if (cached && ((now - cached.updated) < options.cacheContextsFor)) { + cb(null, cached.context); + return; + } + + return le.readCerts(hostname).then(function (cached) { + cb(null, cached.context); + }, function (/*err*/) { + var copy = {}; + var arr; + + // TODO validate domains and such + Object.keys(defaults).forEach(function (key) { + copy[key] = defaults[key]; + }); + Object.keys(args).forEach(function (key) { + copy[key] = args[key]; + }); + + arr = letsencrypt.objToArr(keys, copy); + // TODO validate domains empirically before trying le + return letsencrypt.execAsync(lebinpath, arr, opts).then(function () { + // wait at least n minutes + return le.readCerts(hostname).then(function (cached) { + // success + cb(null, cached.context); + }, function (err) { + // still couldn't read the certs after success... that's weird + cb(err); + }); + }, function (err) { + console.error("[Error] Let's Encrypt failed:"); + console.error(err.stack || new Error(err.message || err.toString())); + + // wasn't successful with lets encrypt, don't try again for n minutes + ipc[hostname] = { + context: null + , updated: Date.now() + }; + cb(null, ipc[hostname]); + }); + }); + } + }; + + return le; + }); +}; diff --git a/le-exec-wrapper.js b/le-exec-wrapper.js new file mode 100644 index 0000000..3b7b48a --- /dev/null +++ b/le-exec-wrapper.js @@ -0,0 +1,131 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var spawn = require('child_process').spawn; + +var letsencrypt = module.exports; + +letsencrypt.parseOptions = function (text) { + var options = {}; + var re = /--([a-z0-9\-]+)/g; + var m; + + function uc(match, c) { + return c.toUpperCase(); + } + + while ((m = re.exec(text))) { + var key = m[1].replace(/-([a-z0-9])/g, uc); + + options[key] = true; + } + + return options; +}; + +letsencrypt.opts = function (lebinpath, cb) { + letsencrypt.exec(lebinpath, ['--help', 'all'], function (err, text) { + if (err) { + cb(err); + return; + } + + cb(null, Object.keys(letsencrypt.parseOptions(text))); + }); +}; + +letsencrypt.exec = function (lebinpath, args, opts, cb) { + // TODO create and watch the directory for challenge callback + if (opts.challengeCallback) { + return PromiseA.reject({ + message: "challengeCallback not yet supported" + }); + } + + var le = spawn(lebinpath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + var text = ''; + var errtext = ''; + var err; + + le.on('error', function (error) { + err = error; + }); + + le.stdout.on('data', function (chunk) { + text += chunk.toString('ascii'); + }); + + le.stderr.on('data', function (chunk) { + errtext += chunk.toString('ascii'); + }); + + le.on('close', function (code, signal) { + if (err) { + cb(err); + return; + } + + if (errtext) { + err = new Error(errtext); + err.code = code; + err.signal = signal; + cb(err); + return; + } + + if (0 !== code) { + err = new Error("exited with code '" + code + "'"); + err.code = code; + err.signal = signal; + cb(err); + return; + } + + cb(null, text); + }); +}; + +letsencrypt.objToArr = function (params, opts) { + var args = {}; + var arr = []; + + Object.keys(opts).forEach(function (key) { + var val = opts[key]; + + if (!val && 0 !== val) { + // non-zero value which is false, null, or otherwise falsey + // falsey values should not be passed + return; + } + + if (!params.indexOf(key)) { + // key is not recognized by the python client + return; + } + + if (Array.isArray(val)) { + args[key] = opts[key].join(','); + } else { + args[key] = opts[key]; + } + }); + + Object.keys(args).forEach(function (key) { + if ('tlsSni01Port' === key) { + arr.push('--tls-sni-01-port'); + } + else if ('http01Port' === key) { + arr.push('--http-01-port'); + } + else { + arr.push('--' + key.replace(/([A-Z])/g, '-$1').toLowerCase()); + } + + if (true !== opts[key]) { + // value is truthy, but not true (and falsies were weeded out above) + arr.push(opts[key]); + } + }); + + return arr; +}; diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..545a65c --- /dev/null +++ b/utils.js @@ -0,0 +1,20 @@ +'use strict'; + +var re = /^[a-zA-Z0-9\.\-]+$/; +var punycode = require('punycode'); + +var utils = module.exports; + +utils.isValidDomain = function (domain) { + if (re.test(domain)) { + return domain; + } + + domain = punycode.toASCII(domain); + + if (re.test(domain)) { + return domain; + } + + return ''; +};