untested separation of concerns

This commit is contained in:
AJ ONeal 2016-02-12 20:04:40 -05:00
parent 8d27e09217
commit d1375aceb0
4 changed files with 198 additions and 60 deletions

135
README.md
View File

@ -2,7 +2,6 @@
Free SSL and managed or automatic HTTPS for node.js with Express, Connect, and other middleware systems. Free SSL and managed or automatic HTTPS for node.js with Express, Connect, and other middleware systems.
## Install ## Install
``` ```
@ -95,7 +94,7 @@ node -e 'require("letsencrypt-express").testing().create( require("express")().u
'use strict'; 'use strict';
// Note: using staging server url, remove .testing() for production // Note: using staging server url, remove .testing() for production
var lex = require('letsencrypt-express').testing(); var LEX = require('letsencrypt-express').testing();
var express = require('express'); var express = require('express');
var app = express(); var app = express();
@ -103,22 +102,22 @@ app.use('/', function (req, res) {
res.send({ success: true }); res.send({ success: true });
}); });
lex.create({ LEX.create({
configDir: './letsencrypt.config' // ~/letsencrypt, /etc/letsencrypt, whatever you want configDir: './letsencrypt.config' // ~/letsencrypt, /etc/letsencrypt, whatever you want
, onRequest: app // your express app (or plain node http app) , onRequest: app // your express app (or plain node http app)
, letsencrypt: null // you can provide you own instance of letsencrypt , letsencrypt: null // you can provide you own instance of letsencrypt
// if you need to configure it (with an agreeToTerms // if you need to configure it (with an agreeToTerms
// callback, for example) // callback, for example)
, approveRegistration: function (hostname, cb) { // PRODUCTION MODE needs this function, but only if you want , approveRegistration: function (hostname, cb) { // PRODUCTION MODE needs this function, but only if you want
// automatic registration (usually not necessary) // automatic registration (usually not necessary)
// renewals for registered domains will still be automatic // renewals for registered domains will still be automatic
cb(null, { cb(null, {
domains: [hostname] domains: [hostname]
, email: 'user@example.com' , email: 'user@example.com'
, agreeTos: true // you , agreeTos: true // you
}); });
} }
}).listen([80], [443, 5001], function () { }).listen([80], [443, 5001], function () {
@ -164,6 +163,42 @@ console.log(results.plainServers);
console.log(results.tlsServers); console.log(results.tlsServers);
``` ```
### Use with raw http / https modules
Let's say you want to redirect all http to https.
```
var http = require('http');
var https = require('https');
var LEX = require('letsencrypt-express');
var LE = require('letsencrypt');
var lex = LEX.create({
configDir: __dirname + '/letsencrypt.config'
, approveRegistration: function (hostname, cb) {
cb(null, {
domains: [hostname]
, email: 'user@example.com'
, agreeTos: true
});
}
});
http.createServer(LEX.createAcmeResponder(lex, function redirectHttps(req, res) {
res.setHeader('Location', 'https://' + req.headers.host + req.url);
res.end('<!-- Hello Mr Developer! Please use HTTPS instead -->');
}));
var app = require('express')();
app.use('/', function (req, res) {
res.end('Hello!');
});
https.createServer(lex.httpsOptions, LEX.createAcmeResponder(lex, app));
```
### WebSockets with Let's Encrypt ### WebSockets with Let's Encrypt
Note: you don't need to create websockets for the plain ports. Note: you don't need to create websockets for the plain ports.
@ -180,7 +215,7 @@ function onConnection(ws) {
var location = url.parse(ws.upgradeReq.url, true); var location = url.parse(ws.upgradeReq.url, true);
// you might use location.query.access_token to authenticate or share sessions // you might use location.query.access_token to authenticate or share sessions
// or ws.upgradeReq.headers.cookie (see http://stackoverflow.com/a/16395220/151312) // or ws.upgradeReq.headers.cookie (see http://stackoverflow.com/a/16395220/151312)
ws.on('message', function incoming(message) { ws.on('message', function incoming(message) {
console.log('received: %s', message); console.log('received: %s', message);
}); });
@ -229,7 +264,20 @@ LEX.createSniCallback(opts) // this will call letsencrypt.renew and letsencr
// uses `opts.webrootPath` to read from the filesystem // uses `opts.webrootPath` to read from the filesystem
LEX.getChallenge(opts, hostname, key cb) LEX.getChallenge(opts, hostname, key cb)
LEX.createAcmeResponder(opts, fn) // this will return the necessary request handler for /.well-known/acme-challenges
// which then calls `fn` (such as express app) to complete the request
//
// opts lex instance created with LEX.create(opts)
// more generally, any object with a compatible `getChallenge` will work:
// `lex.getChallenge(opts, domain, key, function (err, val) {})`
//
// fn function (req, res) {
// console.log(req.method, req.url);
//
// res.end('Hello!');
// }
``` ```
## Options ## Options
@ -286,6 +334,77 @@ server: url // url use letsencrypt.productionServerUr
// default production // default production
``` ```
### Fullest Example Ever
Here's absolutely every option and function exposed
```
var http = require('http');
var https = require('https');
var LEX = require('letsencrypt-express');
var LE = require('letsencrypt');
var lex;
lex = LEX.create({
webrootPath: '/tmp/.well-known/acme-challenge'
, lifetime: 90 * 24 * 60 * 60 * 1000 // expect certificates to last 90 days
, failedWait: 5 * 60 * 1000 // if registering fails wait 5 minutes before trying again
, renewWithin: 3 * 24 * 60 * 60 * 1000 // renew at least 3 days before expiration
, memorizeFor: 1 * 24 * 60 * 60 * 1000 // keep certificates in memory for 1 day
, approveRegistration: function (hostname, cb) {
cb(null, {
domains: [hostname]
, email: 'user@example.com'
, agreeTos: true
});
}
, handleRenewFailure: function (err, hostname, certInfo) {
console.error("ERROR: Failed to renew domain '", hostname, "':");
if (err) {
console.error(err.stack || err);
}
if (certInfo) {
console.error(certInfo);
}
}
, letsencrypt: LE.create(
// options
{ configDir: './letsencrypt.config'
, server: LE.productionServerUrl
, privkeyPath: LE.privkeyPath
, fullchainPath: LE.fullchainPath
, certPath: LE.certPath
, chainPath: LE.chainPath
, renewalPath: LE.renewalPath
, accountsDir: LE.accountsDir
, debug: false
}
// handlers
, { setChallenge: LEX.setChallenge
, removeChallenge: LEX.removeChallenge
}
)
, debug: false
});
http.createServer(LEX.createAcmeResponder(lex, function (req, res) {
res.setHeader('Location', 'https://' + req.headers.host + req.url);
res.end('<!-- Hello Mr Developer! Please use HTTPS instead -->');
}));
https.createServer(lex.httpsOptions, LEX.createAcmeResponder(lex, function (req, res) {
res.end('Hello!');
}));
```
## Heroku? ## Heroku?
This doesn't work on heroku because heroku uses a proxy with built-in https This doesn't work on heroku because heroku uses a proxy with built-in https

View File

@ -151,7 +151,7 @@ cli.main(function(_, options) {
function startServers() { function startServers() {
// Note: using staging server url, remove .testing() for production // Note: using staging server url, remove .testing() for production
var LE = require('letsencrypt'); var LE = require('letsencrypt');
var challengeStore = require('../lib/challenge-handlers'); var LEX = require('../');
var le = LE.create({ var le = LE.create({
configDir: configDir configDir: configDir
, manual: true , manual: true
@ -163,10 +163,9 @@ cli.main(function(_, options) {
, renewalPath: LE.renewalPath , renewalPath: LE.renewalPath
, accountsDir: LE.accountsDir , accountsDir: LE.accountsDir
}, { }, {
setChallenge: challengeStore.set setChallenge: LEX.setChallenge
, removeChallenge: challengeStore.remove , removeChallenge: LEX.removeChallenge
}); });
var lex = require('../');
var app = express(); var app = express();
var vhosts = {}; var vhosts = {};
@ -192,7 +191,7 @@ cli.main(function(_, options) {
}); });
app.use('/', express.static(path.join(__dirname, '..', 'lib', 'public'))); app.use('/', express.static(path.join(__dirname, '..', 'lib', 'public')));
lex.create({ LEX.create({
onRequest: app onRequest: app
, configDir: configDir , configDir: configDir
, letsencrypt: le , letsencrypt: le

View File

@ -4,12 +4,23 @@ var crypto = require('crypto');
var tls = require('tls'); var tls = require('tls');
module.exports.create = function (opts) { module.exports.create = function (opts) {
var ipc = {}; // in-process cache
// function (/*err, hostname, certInfo*/) {}
function handleRenewFailure(err, hostname, certInfo) {
console.error("ERROR: Failed to renew domain '", hostname, "':");
if (err) {
console.error(err.stack || err);
}
if (certInfo) {
console.error(certInfo);
}
}
if (!opts) { throw new Error("requires opts to be an object"); }
if (opts.debug) { if (opts.debug) {
console.debug("[LEX] creating sniCallback", JSON.stringify(opts, null, ' ')); console.debug("[LEX] creating sniCallback", JSON.stringify(opts, null, ' '));
} }
var ipc = {}; // in-process cache
if (!opts) { throw new Error("requires opts to be an object"); }
if (!opts.letsencrypt) { throw new Error("requires opts.letsencrypt to be a letsencrypt instance"); } if (!opts.letsencrypt) { throw new Error("requires opts.letsencrypt to be a letsencrypt instance"); }
if (!opts.lifetime) { opts.lifetime = 90 * 24 * 60 * 60 * 1000; } if (!opts.lifetime) { opts.lifetime = 90 * 24 * 60 * 60 * 1000; }
@ -19,7 +30,7 @@ module.exports.create = function (opts) {
if (!opts.approveRegistration) { opts.approveRegistration = function (hostname, cb) { cb(null, null); }; } if (!opts.approveRegistration) { opts.approveRegistration = function (hostname, cb) { cb(null, null); }; }
//opts.approveRegistration = function (hostname, cb) { cb(null, null); }; //opts.approveRegistration = function (hostname, cb) { cb(null, null); };
if (!opts.handleRenewFailure) { opts.handleRenewFailure = function (/*err, hostname, certInfo*/) {}; } if (!opts.handleRenewFailure) { opts.handleRenewFailure = handleRenewFailure; }
function assignBestByDates(now, certInfo) { function assignBestByDates(now, certInfo) {
certInfo = certInfo || { loadedAt: now, expiresAt: 0, issuedAt: 0, lifetime: 0 }; certInfo = certInfo || { loadedAt: now, expiresAt: 0, issuedAt: 0, lifetime: 0 };

View File

@ -5,6 +5,39 @@ var challengeStore = require('./challenge-handlers');
var createSniCallback = require('./sni-callback').create; var createSniCallback = require('./sni-callback').create;
var LE = require('letsencrypt'); var LE = require('letsencrypt');
function createAcmeResponder(obj, onRequest) {
function httpAcmeResponder(req, res) {
if (LEX.debug) {
console.debug('[LEX] ', req.method, req.headers.host, req.url);
}
var acmeChallengePrefix = '/.well-known/acme-challenge/';
if (0 !== req.url.indexOf(acmeChallengePrefix)) {
onRequest(req, res);
return;
}
var key = req.url.slice(acmeChallengePrefix.length);
obj.getChallenge({
debug: LEX.debug || obj.debug
}, req.headers.host, key, function (err, val) {
if (LEX.debug) {
console.debug('[LEX] GET challenge, response:');
console.debug('challenge:', key);
console.debug('response:', val);
if (err) {
console.debug(err.stack);
}
}
res.end(val || '_');
});
}
return httpAcmeResponder;
}
function lexHelper(obj, app) { function lexHelper(obj, app) {
var defaultPems = require('localhost.daplie.com-certificates'); var defaultPems = require('localhost.daplie.com-certificates');
@ -32,19 +65,14 @@ function lexHelper(obj, app) {
if (!obj.getChallenge) { if (!obj.getChallenge) {
if (false !== obj.getChallenge) { if (false !== obj.getChallenge) {
obj.getChallenge = challengeStore.get; obj.getChallenge = LEX.getChallenge;
} }
if (!obj.webrootPath) { if (!obj.webrootPath) {
obj.webrootPath = path.join(require('os').tmpdir(), 'acme-challenge'); obj.webrootPath = path.join(require('os').tmpdir(), 'acme-challenge');
} }
} }
if (!obj.onRequest && false !== obj.onRequest) { // BEGIN LetsEncrypt options
console.warn("You should either do args.onRequest = app or server.on('request', app),"
+ " otherwise only acme-challenge requests will be handled (and the rest will hang)");
console.warn("You can silence this warning by setting args.onRequest = false");
}
if (!obj.configDir) { if (!obj.configDir) {
obj.configDir = path.join(require('homedir')(), '/letsencrypt/etc'); obj.configDir = path.join(require('homedir')(), '/letsencrypt/etc');
} }
@ -60,17 +88,21 @@ function lexHelper(obj, app) {
if (!obj.chainPath) { if (!obj.chainPath) {
obj.chainPath = ':config/live/:hostname/chain.pem'; obj.chainPath = ':config/live/:hostname/chain.pem';
} }
if (!obj.server) { if (!obj.server) {
obj.server = LEX.defaultServerUrl; obj.server = LEX.defaultServerUrl;
} }
// END LetsEncrypt options
obj.getChallenge = obj.getChallenge || LEX.getChallenge;
obj.setChallenge = obj.setChallenge || LEX.setChallenge;
obj.removeChallenge = obj.removeChallenge || LEX.removeChallenge;
if (!obj.letsencrypt) { if (!obj.letsencrypt) {
//LE.merge(obj, ); //LE.merge(obj, );
// { configDir, webrootPath, server } // { configDir, webrootPath, server }
obj.letsencrypt = LE.create(obj, { obj.letsencrypt = LE.create(obj, {
setChallenge: challengeStore.set setChallenge: obj.setChallenge
, removeChallenge: challengeStore.remove , removeChallenge: obj.removeChallenge
}); });
} }
@ -132,39 +164,6 @@ function lexHelper(obj, app) {
httpsOptions.SNICallback = createSniCallback(obj); httpsOptions.SNICallback = createSniCallback(obj);
} }
function createAcmeResponder(onRequest) {
function httpAcmeResponder(req, res) {
if (LEX.debug) {
console.debug('[LEX] ', req.method, req.headers.host, req.url);
}
var acmeChallengePrefix = '/.well-known/acme-challenge/';
if (0 !== req.url.indexOf(acmeChallengePrefix)) {
onRequest(req, res);
return;
}
var key = req.url.slice(acmeChallengePrefix.length);
obj.getChallenge(obj, req.headers.host, key, function (err, val) {
if (LEX.debug) {
console.debug('[LEX] GET challenge, response:');
console.debug('challenge:', key);
console.debug('response:', val);
if (err) {
console.debug(err.stack);
}
}
res.end(val || '_');
});
}
return httpAcmeResponder;
}
obj.httpAcmeResponder = createAcmeResponder(obj.onHttpRequest||obj.onRequest);
obj.httpsAcmeResponder = createAcmeResponder(obj.onHttpsRequest||obj.onRequest);
obj.httpsOptions = httpsOptions; obj.httpsOptions = httpsOptions;
return obj; return obj;
@ -175,6 +174,14 @@ function LEX(obj, app) {
var http = require('http'); var http = require('http');
function listen(plainPorts, tlsPorts, onListening) { function listen(plainPorts, tlsPorts, onListening) {
if (!(obj.onRequest || (obj.onHttpRequest && obj.onHttpsRequest)) && false !== obj.onRequest) {
console.warn("You should either do args.onRequest = app or server.on('request', app),"
+ " otherwise only acme-challenge requests will be handled (and the rest will hang)");
console.warn("You can silence this warning by setting args.onRequest = false");
}
obj.httpAcmeResponder = createAcmeResponder(obj, obj.onHttpRequest || obj.onRequest);
obj.httpsAcmeResponder = createAcmeResponder(obj, obj.onHttpsRequest || obj.onRequest);
if (plainPorts && (!Array.isArray(plainPorts) || !Array.isArray(tlsPorts))) { if (plainPorts && (!Array.isArray(plainPorts) || !Array.isArray(tlsPorts))) {
throw new Error(".listen() must be used with plain and tls port arrays, like this: `.listen([80], [443, 5001], function () {})`"); throw new Error(".listen() must be used with plain and tls port arrays, like this: `.listen([80], [443, 5001], function () {})`");
} }
@ -293,6 +300,8 @@ LEX.middleware = function (defaults) {
LEX.stagingServerUrl = LE.stagingServerUrl; LEX.stagingServerUrl = LE.stagingServerUrl;
LEX.productionServerUrl = LE.productionServerUrl || LE.liveServerUrl; LEX.productionServerUrl = LE.productionServerUrl || LE.liveServerUrl;
LEX.defaultServerUrl = LEX.productionServerUrl; LEX.defaultServerUrl = LEX.productionServerUrl;
LEX.createAcmeResponder = createAcmeResponder;
LEX.normalizeOptions = lexHelper;
LEX.testing = function () { LEX.testing = function () {
LEX.debug = true; LEX.debug = true;
LEX.defaultServerUrl = LEX.stagingServerUrl; LEX.defaultServerUrl = LEX.stagingServerUrl;