diff --git a/README.md b/README.md index f097911..2eb208a 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,195 @@ letsencrypt Let's Encrypt for node.js -### Update: Fri, Dec 11 +This allows you to get Free SSL Certificates for Automatic HTTPS. -Committing some stub code. +NOT YET PUBLISHED +============ -Expect something workable by Tuesday or Wednesday. +Dec 12 2015: almost done + +Install +======= + +```bash +npm install --save letsencrypt +``` + +Right now this uses [`letsencrypt-python`](https://github.com/Daplie/node-letsencrypt-python), +but it's built to be able to use a pure javasript version. + +```bash +# install the python client (takes 2 minutes normally, 20 on a rasberry pi) +git clone https://github.com/letsencrypt/letsencrypt +pushd letsencrypt + +./letsencrypt-auto +``` + +Usage +===== + +```javascript +var leBinPath = '/home/user/.local/share/letsencrypt/bin/letsencrypt'; +var lep = require('letsencrypt-python').create(leBinPath); + +// backend-specific defaults +// Note: For legal reasons you should NOT set email or agreeTos as a default +var bkDefaults = { + webroot: true +, webrootPath: __dirname, '/acme-challenge' +, fullchainTpl: '/live/:hostname/fullchain.pem' +, privkeyTpl: '/live/:hostname/fullchain.pem' +, configDir: '/etc/letsencrypt' +, logsDir: '/var/log/letsencrypt' +, workDir: '/var/lib/letsencrypt' +, text: true +}; +var leConfig = { +, webrootPath: __dirname, '/acme-challenge' +, configDir: '/etc/letsencrypt' +}; +var le = require('letsencrypt').create(le, bkDefaults, leConfig); + +var localCerts = require('localhost.daplie.com-certificates'); +var express = require('express'); +var app = express(); + +app.use(le.middleware); + +server = require('http').createServer(); +server.on('request', app); +server.listen(80, function () { + console.log('Listening http', server.address()); +}); + +tlsServer = require('https').createServer({ + key: localCerts.key +, cert: localCerts.cert +, SNICallback: le.SNICallback +}); +tlsServer.on('request', app); +tlsServer.listen(443, function () { + console.log('Listening http', server.address()); +}); + +le.register('certonly', { +, domains: ['example.com'] +, agreeTos: true +, email: 'user@example.com' +}).then(function () { + server.close(); + tlsServer.close(); +}); +``` + +``` +lep.register('certonly', { +, domains: ['example.com'] +, agreeTos: true +, email: 'user@example.com' + +, configDir: '/etc/letsencrypt' +, logsDir: '/var/log/letsencrypt' +, workDir: '/var/lib/letsencrypt' +, text: true +}); +``` + +``` +// if you would like to register new domains on-the-fly +// you can use this function to return the user to which +// it should be registered (or null if none) +, needsRegistration: function (hostname, cb) { + cb(null, { + agreeTos: true + , email: 'user@example.com' + }); + } +``` + +Backends +-------- + +* [`letsencrypt-python`](https://github.com/Daplie/node-letsencrypt-python) (complete) +* [`lejs`](https://github.com/Daplie/node-lejs) (in progress) + +#### How to write a backend + +A backend must implement (or be wrapped to implement) this API: + +* fetch(hostname, cb) will cb(err, certs) will get registered certs or null unless there is an error +* register(args, challengeCb, done) will register and or renew a cert + * args = `{ domains, email, agreeTos }` MUST check that agreeTos === true + * challengeCb = `function (challenge, cb) { }` handle challenge as needed, call cb() + +This is what `args` looks like: + +```javascript +{ domains: ['example.com', 'www.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' // :hostname +} +``` + +This is what the implementation should look like: + +(it's expected that the client will follow the same conventions as +the python client, but it's not necessary) + +```javascript +return { + fetch: function (args, cb) { + // NOTE: should return an error if args.domains cannot be satisfied with a single cert + // (usually example.com and www.example.com will be handled on the same cert, for example) + if (errorHappens) { + // return an error if there is an actual error (db, etc) + cb(err); + return; + } + // return null if there is no error, nor a certificate + else if (!cert) { + cb(null, null); + return; + } + + // NOTE: if the certificate is available but expired it should be + // returned and the calling application will decide to renew when + // it is convenient + + // NOTE: the application should handle caching, not the library + + // return the cert with metadata + cb(null, { + cert: "/*contcatonated certs in pem format: cert + intermediate*/" + , key: "/*private keypair in pem format*/" + , renewedAt: new Date() // fs.stat cert.pem should also work + , expiresIn: 90 * 60 // assumes 90-days unless specified + }); + } +, register: function (args, challengeCallback, completeCallback) { + // **MUST** reject if args.agreeTos is not true + + // once you're ready for the caller to know the challenge + if (challengeCallback) { + challengeCallback(challenge, function () { + continueRegistration(); + }) + } else { + continueRegistration(); + } + + function continueRegistration() { + // it is not neccessary to to return the certificates here + // the client will call fetch() when it needs them + completeCallback(err); + } + } +}; +``` LICENSE diff --git a/bin/standalone.js b/bin/standalone.js new file mode 100644 index 0000000..764e352 --- /dev/null +++ b/bin/standalone.js @@ -0,0 +1,59 @@ +'use strict'; + +var homedir = require('homedir'); +var leBinPath = homedir() + '/.local/share/letsencrypt/bin/letsencrypt'; +var lep = require('letsencrypt-python').create(leBinPath); +var conf = { + domains: process.argv[2] +, email: process.argv[3] +, agree: process.argv[4] +}; + +// backend-specific defaults +// Note: For legal reasons you should NOT set email or agreeTos as a default +var bkDefaults = { + webroot: true +, webrootPath: __dirname + '/acme-challenge' +, fullchainTpl: '/live/:hostname/fullchain.pem' +, privkeyTpl: '/live/:hostname/fullchain.pem' +, configDir: '/etc/letsencrypt' +, logsDir: '/var/log/letsencrypt' +, workDir: '/var/lib/letsencrypt' +, text: true +}; +var le = require('letsencrypt').create(lep, bkDefaults); + +var localCerts = require('localhost.daplie.com-certificates'); +var express = require('express'); +var app = express(); + +app.use(le.middleware); + +var server = require('http').createServer(); +server.on('request', app); +server.listen(80, function () { + console.log('Listening http', server.address()); +}); + +var tlsServer = require('https').createServer({ + key: localCerts.key +, cert: localCerts.cert +, SNICallback: le.SNICallback +}); +tlsServer.on('request', app); +tlsServer.listen(443, function () { + console.log('Listening http', tlsServer.address()); +}); + +le.register('certonly', { + agreeTos: 'agree' === conf.agree +, domains: conf.domains.split(',') +, email: conf.email +}).then(function () { + console.log('success'); +}, function (err) { + console.error(err.stack); +}).then(function () { + server.close(); + tlsServer.close(); +}); diff --git a/le-base.js b/index.js similarity index 100% rename from le-base.js rename to index.js diff --git a/le-exec-wrapper.js b/le-exec-wrapper.js deleted file mode 100644 index 3b7b48a..0000000 --- a/le-exec-wrapper.js +++ /dev/null @@ -1,131 +0,0 @@ -'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/le-webroot.js b/le-webroot.js deleted file mode 100644 index bf27bb7..0000000 --- a/le-webroot.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -cacheIpAddresses - -var https = require('https'); -var http = require('http'); -var letsencrypt = require('letsencrypt'); -var localCerts = require('localhost.daplie.com-certificates'); -var insecureServer; -var server; - -letsencrypt.create( - '/home/user/.local/share/letsencrypt/bin/letsencrypt' - // set some defaults -, { "": "" - } -).then(function (le) { - - var express = require('express'); - var app = express(); - var getSecureContext = require('./le-standalone').getSecureContext; - - insecureServer = http.createServer(); - localCerts.sniCallback = function (hostname, cb) { - getSecureContext(le, hostname, cb); - }; - server = https.createServer(localCerts); - - insecureServer.on('request', app); - - server.on('request', app); -}); - -insecureServer.listen(80, function () { - console.log('http server listening', insecureServer.address()); -}); - -server.listen(443, function () { - console.log('https server listening', server.address()); -}); diff --git a/package.json b/package.json index 5dbb4b3..343685c 100644 --- a/package.json +++ b/package.json @@ -32,5 +32,8 @@ "devDependencies": { "express": "^4.13.3", "localhost.daplie.com-certificates": "^1.1.2" + }, + "dependencies": { + "letsencrypt-python": "^1.0.3" } } diff --git a/tests/config.js b/tests/config.js index d899d1e..1acc050 100644 --- a/tests/config.js +++ b/tests/config.js @@ -9,5 +9,5 @@ module.exports = { , webrootPath: path.join(__dirname, "acme-challenge") , configDir: path.join(__dirname, "letsencrypt.config") , workDir: path.join(__dirname, "letsencrypt.work") -, logDir: path.join(__dirname, "letsencrypt.log") +, logsDir: path.join(__dirname, "letsencrypt.logs") }; diff --git a/le-standalone.js b/tests/le-standalone.js similarity index 100% rename from le-standalone.js rename to tests/le-standalone.js diff --git a/tests/letsencrypt.log/.gitkeep b/tests/letsencrypt.logs/.gitkeep similarity index 100% rename from tests/letsencrypt.log/.gitkeep rename to tests/letsencrypt.logs/.gitkeep diff --git a/tests/serve-acme-challenges.js b/tests/serve-acme-challenges.js index 31c3ad0..3d7d2c6 100644 --- a/tests/serve-acme-challenges.js +++ b/tests/serve-acme-challenges.js @@ -7,87 +7,84 @@ var http = require('http'); var express = require('express'); var app = express(); -var config = require('./config'); +module.exports.create = function (opts) { + function getSecureContext(domainname, opts, cb) { + + if (!opts) { opts = {}; } + + opts.key = fs.readFileSync(path.join(opts.configDir, 'live', domainname, 'privkey.pem')); + opts.cert = fs.readFileSync(path.join(opts.configDir, 'live', domainname, 'cert.pem')); + opts.ca = fs.readFileSync(path.join(opts.configDir, 'live', domainname, 'chain.pem'), 'ascii') + .split('-----END CERTIFICATE-----') + .filter(function (ca) { + return ca.trim(); + }).map(function (ca) { + return (ca + '-----END CERTIFICATE-----').trim(); + }); + + cb(null, require('tls').createSecureContext(opts)); + } -function getSecureContext(domainname, opts, cb) { - var letsetc = '/etc/letsencrypt/live/'; + // log the requests + app.use('/', function (req, res, next) { + console.log('[' + req.ip + ']', req.method + ' ' + req.headers.host, req.protocol + req.url); + next(); + }); + // handle static requests to /.well-known/acme-challenge + app.use( + '/.well-known/acme-challenge' + , express.static(opts.webrootPath, { dotfiles: undefined }) + ); - if (!opts) { opts = {}; } + function serveHttps() { + // + // SSL Certificates + // + var server; + var localCerts = require('localhost.daplie.com-certificates'); + var options = { + requestCert: false + , rejectUnauthorized: true - opts.key = fs.readFileSync(path.join(letsetc, domainname, 'privkey.pem')); - opts.cert = fs.readFileSync(path.join(letsetc, domainname, 'cert.pem')); - opts.ca = fs.readFileSync(path.join(letsetc, domainname, 'chain.pem'), 'ascii') - .split('-----END CERTIFICATE-----') - .filter(function (ca) { - return ca.trim(); - }).map(function (ca) { - return (ca + '-----END CERTIFICATE-----').trim(); + // If you need to use SNICallback you should be using io.js >= 1.x (possibly node >= 0.12) + , SNICallback: function (domainname, cb) { + var secureContext = getSecureContext(domainname); + cb(null, secureContext); + } + // If you need to support HTTP2 this is what you need to work with + //, NPNProtocols: ['http/2.0', 'http/1.1', 'http/1.0'] + //, NPNProtocols: ['http/1.1'] + , key: localCerts.key + , cert: localCerts.cert + //, ca: null + }; + + // Start the tls sni server4 + server = https.createServer(options); + server.on('error', function (err) { + console.error(err); }); + server.on('request', app); + server.listen(opts.tlsSni01Port, function () { + console.log('[https] Listening', server.address()); + }); + } - cb(null, require('tls').createSecureContext(opts)); -} + function serveHttp() { + // Start the http server4 + var insecureServer = http.createServer(); + insecureServer.on('error', function (err) { + console.error(err); + }); + // note that request handler must be attached *before* and handle comes in + insecureServer.on('request', app); + insecureServer.listen(opts.http01Port, function () { + console.log('[http] Listening', insecureServer.address()); + }); + } -// log the requests -app.use('/', function (req, res, next) { - console.log('[' + req.ip + ']', req.method + ' ' + req.headers.host, req.protocol + req.url); - next(); -}); -// handle static requests to /.well-known/acme-challenge -app.use( - '/.well-known/acme-challenge' -, express.static(config.webrootPath, { dotfiles: undefined }) -); - - -function serveHttps() { - // - // SSL Certificates - // - var server; - var localCerts = require('localhost.daplie.com-certificates'); - var options = { - requestCert: false - , rejectUnauthorized: true - - // If you need to use SNICallback you should be using io.js >= 1.x (possibly node >= 0.12) - , SNICallback: function (domainname, cb) { - var secureContext = getSecureContext(domainname); - cb(null, secureContext); - } - // If you need to support HTTP2 this is what you need to work with - //, NPNProtocols: ['http/2.0', 'http/1.1', 'http/1.0'] - //, NPNProtocols: ['http/1.1'] - , key: localCerts.key - , cert: localCerts.cert - //, ca: null - }; - - // Start the tls sni server4 - server = https.createServer(options); - server.on('error', function (err) { - console.error(err); - }); - server.on('request', app); - server.listen(config.tlsSni01Port, function () { - console.log('[https] Listening', server.address()); - }); -} - -function serveHttp() { - // Start the http server4 - var insecureServer = http.createServer(); - insecureServer.on('error', function (err) { - console.error(err); - }); - // note that request handler must be attached *before* and handle comes in - insecureServer.on('request', app); - insecureServer.listen(config.http01Port, function () { - console.log('[http] Listening', insecureServer.address()); - }); -} - - -serveHttps(); -serveHttp(); + serveHttps(); + serveHttp(); +};