diff --git a/index.js b/index.js index fd678b4..06148c1 100644 --- a/index.js +++ b/index.js @@ -3,9 +3,9 @@ // TODO handle www and no-www together somehow? var PromiseA = require('bluebird'); -var crypto = require('crypto'); -var tls = require('tls'); var leCore = require('letiny-core'); +var merge = require('./lib/common').merge; +var tplHostname = require('./lib/common').tplHostname; var LE = module.exports; LE.productionServerUrl = leCore.productionServerUrl; @@ -21,18 +21,8 @@ LE.stagingServer = leCore.stagingServerUrl; LE.liveServer = leCore.productionServerUrl; LE.knownUrls = leCore.knownEndpoints; -LE.merge = function merge(defaults, args) { - var copy = {}; - - Object.keys(defaults).forEach(function (key) { - copy[key] = defaults[key]; - }); - Object.keys(args).forEach(function (key) { - copy[key] = args[key]; - }); - - return copy; -}; +LE.merge = require('./lib/common').merge; +LE.tplConfigDir = require('./lib/common').tplConfigDir; // backend, defaults, handlers LE.create = function (defaults, handlers, backend) { @@ -66,7 +56,11 @@ LE.create = function (defaults, handlers, backend) { // "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(defaults, { domains: [hostname] }); + + tplHostname(hostname, copy); defaultos.domains = [hostname]; + if (3 === getChallenge.length) { getChallenge(defaultos, key, done); } @@ -105,11 +99,16 @@ LE.create = function (defaults, handlers, backend) { // 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 = PromiseA.promisifyAll(backend); - var utils = require('./utils'); + var utils = require('./lib/utils'); //var attempts = {}; // should exist in master process only - var ipc = {}; // in-process cache var le; // TODO check certs on initial load @@ -141,39 +140,6 @@ LE.create = function (defaults, handlers, backend) { console.warn("[SECURITY WARNING]: node-letsencrypt: validate(hostnames, cb) NOT IMPLEMENTED"); cb(null, true); } - , middleware: function () { - var prefix = leCore.acmeChallengePrefix; - - return function (req, res, next) { - if (0 !== req.url.indexOf(prefix)) { - //console.log('[LE middleware]: pass'); - next(); - return; - } - - //args.domains = [req.hostname]; - //console.log('[LE middleware]:', req.hostname, req.url, req.url.slice(prefix.length)); - function done(err, token) { - if (err) { - res.send("Error: These aren't the tokens you're looking for. Move along."); - return; - } - - res.send(token); - } - - if (3 === handlers.getChallenge.length) { - handlers.getChallenge(req.hostname, req.url.slice(prefix.length), done); - } - else if (4 === handlers.getChallenge.length) { - handlers.getChallenge(defaults, req.hostname, req.url.slice(prefix.length), done); - } - else { - console.error("handlers.getChallenge [2] receives the wrong number of arguments"); - done(new Error("handlers.getChallenge [2] receives the wrong number of arguments")); - } - }; - } , _registerHelper: function (args, cb) { var copy = LE.merge(defaults, args); var err; @@ -202,10 +168,7 @@ LE.create = function (defaults, handlers, backend) { , _fetchHelper: function (args, cb) { return backend.fetchAsync(args).then(function (certInfo) { if (args.debug) { - console.log('[LE] debug is on'); - } - if (true || args.debug) { - console.log('[LE] raw fetch certs', certInfo); + console.log('[LE] raw fetch certs', certInfo && Object.keys(certInfo)); } if (!certInfo) { cb(null, null); return; } @@ -239,42 +202,38 @@ LE.create = function (defaults, handlers, backend) { // but ensure that it has the most fresh copy // before attempting a renew le._fetchHelper(args, function (err, hit) { - var hostname = args.domains[0]; + var now = Date.now(); - if (err) { cb(err); return; } - else if (hit) { cb(null, hit); return; } + if (err) { + // had a bad day + cb(err); + return; + } + else if (hit) { + if ((now - hit.issuedAt) < ((hit.lifetime || handlers.lifetime) * 0.65)) { + console.warn("tried to renew a certificate with over 1/3 of its lifetime left, ignoring"); + cb(null, hit); + return; + } + } - return le._registerHelper(args, function (err, pems) { + return le._registerHelper(args, function (err/*, pems*/) { if (err) { cb(err); return; } - le._fetchHelper(args, function (err, cache) { - if (cache) { - cb(null, cache); + // Sanity Check + le._fetchHelper(args, function (err, pems) { + if (pems) { + cb(null, pems); return; } // still couldn't read the certs after success... that's weird cb(err, null); }); - }, function (err) { - console.error("[Error] Let's Encrypt failed:"); - console.error(err.stack || new Error(err.message || err.toString()).stack); - - // wasn't successful with lets encrypt, don't automatically try again for 12 hours - // TODO what's the better way to handle this? - // failure callback? - ipc[hostname] = { - context: null // TODO default context - , issuedAt: Date.now() - , lifetime: (12 * 60 * 60 * 1000) - // , expiresAt: generated in next step - }; - - cb(err, ipc[hostname]); - }); + }, cb); }); } }; diff --git a/lib/accounts.js b/lib/accounts.js index 594a52e..b06853e 100644 --- a/lib/accounts.js +++ b/lib/accounts.js @@ -116,3 +116,4 @@ function getAccountByEmail(/*args*/) { module.exports.getAccountByEmail = getAccountByEmail; module.exports.getAccount = getAccount; +module.exports.createAccount = createAccount; diff --git a/lib/common.js b/lib/common.js index 6c77e45..ae6bcbe 100644 --- a/lib/common.js +++ b/lib/common.js @@ -3,42 +3,71 @@ var fs = require('fs'); var PromiseA = require('bluebird'); -module.exports.fetchFromDisk = function (args, defaults) { - var hostname = args.domains[0]; - var certPath = (args.fullchainPath || defaults.fullchainPath) - || (defaults.configDir - + (args.fullchainTpl || defaults.fullchainTpl || ':hostname/fullchain.pem').replace(/:hostname/, hostname)); - var privkeyPath = (args.privkeyPath || defaults.privkeyPath) - || (defaults.configDir - + (args.privkeyTpl || defaults.privkeyTpl || ':hostname/privkey.pem').replace(/:hostname/, hostname)); - var chainPath = (args.chainPath || defaults.chainPath) - || (defaults.configDir - + (args.chainTpl || defaults.chainTpl || ':hostname/chain.pem').replace(/:hostname/, hostname)); - /* - var fullchainPath = (args.fullchainPath || defaults.fullchainPath) - || (defaults.configDir - + (args.fullchainTpl || defaults.fullchainTpl || ':hostname/fullchain.pem').replace(/:hostname/, hostname)); - */ +module.exports.tplConfigDir = function merge(configDir, defaults) { + Object.keys(defaults).forEach(function (key) { + if ('string' === typeof defaults[key]) { + defaults[key] = defaults[key].replace(':config', configDir).replace(':conf', configDir); + } + }); +}; +module.exports.merge = function merge(defaults, args) { + var copy = {}; + + Object.keys(defaults).forEach(function (key) { + copy[key] = defaults[key]; + }); + Object.keys(args).forEach(function (key) { + copy[key] = args[key]; + }); + + return copy; +}; + +module.exports.tplHostname = function merge(hostname, copy) { + Object.keys(copy).forEach(function (key) { + if ('string' === typeof copy[key]) { + copy[key] = copy[key].replace(':hostname', hostname).replace(':host', hostname); + } + }); + + //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"); + } - return PromiseA.all([ - fs.readFileAsync(privkeyPath, 'ascii') - , fs.readFileAsync(certPath, 'ascii') - , fs.readFileAsync(chainPath, 'ascii') //, 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(certPath) + , fs.statAsync(args.certPath) // 3 ]).then(function (arr) { - // TODO parse certificate to determine lifetime and expiresAt + return { key: arr[0] // privkey.pem - , cert: arr[1] // cert.pem - , chain: arr[2] // chain.pem - , fullchain: arr[1] + '\n' + arr[2] // fullchain.pem + , privkey: arr[0] // privkey.pem - , issuedAt: arr[4].mtime.valueOf() // ??? + , fullchain: arr[1] + '\n' + arr[2] // fullchain.pem + , cert: arr[1] // cert.pem + + , chain: arr[2] // chain.pem + , ca: arr[2] // chain.pem + + , issuedAt: arr[3].mtime.valueOf() // ??? TODO parse to determine expiresAt and lifetime }; - }, function () { + }, function (err) { + if (true || args.debug) { + console.error(err.stack); + } return null; }); }; diff --git a/lib/letiny-core.js b/lib/letiny-core.js index ca5272e..1a32d69 100644 --- a/lib/letiny-core.js +++ b/lib/letiny-core.js @@ -9,6 +9,8 @@ var LeCore = PromiseA.promisifyAll(require('letiny-core')); var leCrypto = PromiseA.promisifyAll(LeCore.leCrypto); var Accounts = require('./accounts'); +var merge = require('./common').merge; +var tplHostname = require('./common').tplHostname; var fetchFromConfigLiveDir = require('./common').fetchFromDisk; var ipc = {}; // in-process cache @@ -32,8 +34,6 @@ function getAcmeUrls(args) { function getCertificateAsync(account, args, defaults, handlers) { - //var pyconf = PromiseA.promisifyAll(require('pyconf')); - return leCrypto.generateRsaKeypairAsync(args.rsaBitLength, args.rsaExponent).then(function (domain) { return LeCore.getCertificateAsync({ newAuthzUrl: args._acmeUrls.newAuthz @@ -43,36 +43,45 @@ function getCertificateAsync(account, args, defaults, handlers) { , domainPrivateKeyPem: domain.privateKeyPem , domains: args.domains + // + // IMPORTANT + // + // setChallenge and removeChallenge are handed defaults + // instead of args because getChallenge does not have + // access to args + // (args is per-request, defaults is per instance) + // , setChallenge: function (domain, key, value, done) { + var copy = merge(defaults, { domains: [domain] }); + tplHostname(domain, copy); + args.domains = [domain]; - args.webrootPath = args.webrootPath || defaults.webrootPath; + args.webrootPath = args.webrootPath; if (4 === handlers.setChallenge.length) { - handlers.setChallenge(args, key, value, done); + handlers.setChallenge(copy, key, value, done); } else if (5 === handlers.setChallenge.length) { - // TODO merge templates with domain - handlers.setChallenge(defaults, domain, key, value, done); + handlers.setChallenge(copy, domain, key, value, done); } else { done(new Error("handlers.setChallenge receives the wrong number of arguments")); } } , removeChallenge: function (domain, key, done) { - args.domains = [domain]; - args.webrootPath = args.webrootPath || defaults.webrootPath; + var copy = merge(defaults, { domains: [domain] }); + tplHostname(domain, copy); + if (3 === handlers.removeChallenge.length) { - handlers.removeChallenge(args, key, done); + handlers.removeChallenge(copy, key, done); } else if (4 === handlers.removeChallenge.length) { - // TODO merge templates with domain - handlers.removeChallenge(defaults, domain, key, done); + handlers.removeChallenge(copy, domain, key, done); } else { done(new Error("handlers.removeChallenge receives the wrong number of arguments")); } } }).then(function (result) { - // TODO write pems={ca,cert,key} to disk var liveDir = path.join(args.configDir, 'live', args.domains[0]); var certPath = path.join(liveDir, 'cert.pem'); var fullchainPath = path.join(liveDir, 'fullchain.pem'); @@ -82,12 +91,18 @@ function getCertificateAsync(account, args, defaults, handlers) { result.fullchain = result.cert + '\n' + result.ca; // TODO write to archive first, then write to live + + // XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + // TODO read renewal.conf.default, write renewal.conf + // var pyconf = PromiseA.promisifyAll(require('pyconf')); + // XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + return mkdirpAsync(liveDir).then(function () { return PromiseA.all([ sfs.writeFileAsync(certPath, result.cert, 'ascii') - , sfs.writeFileAsync(chainPath, result.chain, 'ascii') + , sfs.writeFileAsync(chainPath, result.ca || result.chain, 'ascii') , sfs.writeFileAsync(fullchainPath, result.fullchain, 'ascii') - , sfs.writeFileAsync(privkeyPath, result.key, 'ascii') + , sfs.writeFileAsync(privkeyPath, result.key || result.privkey, 'ascii') ]).then(function () { // TODO format result licesy //console.log(liveDir); @@ -97,6 +112,16 @@ function getCertificateAsync(account, args, defaults, handlers) { , chainPath: chainPath , fullchainPath: fullchainPath , privkeyPath: privkeyPath + + // some ambiguity here... + , privkey: result.key || result.privkey + , fullchain: result.fullchain || result.cert + , chain: result.ca || result.chain + // especially this one... might be cert only, might be fullchain + , cert: result.cert + + , issuedAt: Date.now() + , lifetime: defaults.lifetime || handlers.lifetime }; }); }); @@ -106,9 +131,9 @@ function getCertificateAsync(account, args, defaults, handlers) { function registerWithAcme(args, defaults, handlers) { var pyconf = PromiseA.promisifyAll(require('pyconf')); - var server = args.server || defaults.server || LeCore.stagingServerUrl; // https://acme-v01.api.letsencrypt.org/directory + var server = args.server; var acmeHostname = require('url').parse(server).hostname; - var configDir = args.configDir || defaults.configDir || LE.configDir; + var configDir = args.configDir; args.server = server; args.renewalDir = args.renewalDir || path.join(configDir, 'renewal', args.domains[0] + '.conf'); @@ -145,7 +170,7 @@ function registerWithAcme(args, defaults, handlers) { */ //console.log(account); - return fetchFromConfigLiveDir(args, defaults).then(function (certs) { + return fetchFromConfigLiveDir(args).then(function (certs) { // if nothing, register and save // if something, check date (don't register unless 30+ days) // if good, don't bother registering @@ -252,11 +277,22 @@ module.exports.create = function (defaults, handlers) { var wrapped = { registerAsync: function (args) { - //require('./common').registerWithAcme(args, defaults, handlers); - return registerWithAcme(args, defaults, handlers); + var copy = merge(args, defaults); + tplHostname(args.domains[0], copy); + + if (args.debug) { + console.log('[LE DEBUG] reg domains', args.domains); + } + return registerWithAcme(copy, defaults, handlers); } , fetchAsync: function (args) { - return fetchFromConfigLiveDir(args, defaults); + var copy = merge(args, defaults); + tplHostname(args.domains[0], copy); + + if (args.debug) { + console.log('[LE DEBUG] fetch domains', copy); + } + return fetchFromConfigLiveDir(copy, defaults); } }; diff --git a/utils.js b/lib/utils.js similarity index 100% rename from utils.js rename to lib/utils.js