diff --git a/README.md b/README.md index cdc11d1..ea57244 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 ==== @@ -71,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 @@ -148,37 +137,36 @@ 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; } + // 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) - , 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 (or true) 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); + + }); }); ``` @@ -200,6 +188,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: @@ -241,7 +235,7 @@ TODO double check and finish * accounts.get * accounts.exists * certs - * certs.byDomain + * certs.byAccount * certs.all * certs.get * certs.exists @@ -250,9 +244,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 077a42c..d5b5595 100644 --- a/index.js +++ b/index.js @@ -1,21 +1,20 @@ 'use strict'; -// TODO handle www and no-www together somehow? - -var PromiseA = require('bluebird'); -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 = { - server: leCore.productionServerUrl -, stagingServer: leCore.stagingServerUrl -, liveServer: leCore.productionServerUrl + productionServerUrl: ACME.productionServerUrl +, stagingServerUrl: ACME.stagingServerUrl -, productionServerUrl: leCore.productionServerUrl -, stagingServerUrl: leCore.stagingServerUrl +, rsaKeySize: ACME.rsaKeySize || 2048 +, challengeType: ACME.challengeType || 'http-01' -, acmeChallengePrefix: leCore.acmeChallengePrefix +, acmeChallengePrefix: ACME.acmeChallengePrefix }; // backwards compat @@ -23,58 +22,108 @@ 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); +// show all possible options +var u; // undefined +LE._undefined = { + acme: u +, store: u +, challenger: u +, register: u +, check: u +, renewWithin: u +, memorizeFor: u +, acmeChallengePrefix: u +, rsaKeySize: u +, challengeType: u +, server: u +, agreeToTerms: u +, _ipc: 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) { + var PromiseA = require('bluebird'); + + 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'); + + 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; } + + 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 (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)) { + le[key] = le._storeOpts[key]; + } + }); + + if (le.challenger.create) { + le.challenger = le.challenger.create(le); + } + le.challenger = PromiseA.promisifyAll(le.challenger); + le._challengerOpts = le.challenger.getOptions(); + Object.keys(le._challengerOpts).forEach(function (key) { + if (!(key in le)) { + le[key] = le._challengerOpts[key]; + } + }); + + if (le.core.create) { + le.core = le.core.create(le); + } + + le.register = function (args) { + return le.core.certificates.getAsync(args); + }; + + le.check = function (args) { + // TODO must return email, domains, tos, pems + return le.core.certificates.checkAsync(args); + }; + + le.middleware = function () { + return require('./lib/middleware')(le); + }; return le; }; diff --git a/lib/core.js b/lib/core.js index 93d73b6..92fdb1e 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,280 +1,287 @@ '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'); - 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 le.acme.getAcmeUrlsAsync(args.server).then(function (data) { + le._ipc.acmeUrlsUpdatedAt = Date.now(); + le._ipc.acmeUrls = data; - function createAccount(args, handlers) { - args.rsaKeySize = args.rsaKeySize || 2048; + return le._ipc.acmeUrls; + }); + } - return RSA.generateKeypairAsync(args.rsaKeySize, 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); + // + // The Main Enchilada + // + + // + // Accounts + // + , accounts: { + registerAsync: function (args) { + var err; + + 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); } - , 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'); + return utils.testEmail(args.email).then(function () { - var accountId = keypair.publicKeyMd5; - var regr = { body: body }; - var account = {}; + 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; - args.accountId = accountId; + 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; + } - 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'); + + 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; + }); + }); + }); + }); + }); + } + + , getAsync: function (args) { + return core.accounts.checkAsync(args).then(function (account) { + if (account) { + return account; + } else { + return core.accounts.registerAsync(args); + } + }); + } + + , 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.account = account; + args.accountId = account.id; - return backend.setAccountAsync(args, account).then(function () { return account; }); - }); - }); - } - - function getAcmeUrls(args) { - 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); - } - - return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { - ipc.acmeUrlsUpdatedAt = Date.now(); - ipc.acmeUrls = data; - - return 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) { - console.log.apply(console, arguments); } } - var account = args.account; - var promise; - var keypairOpts = { public: true, pem: true }; + , certificates: { + registerAsync: function (args) { + var err; + var copy = utils.merge(args, le); + args = utils.tplCopy(copy); - 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; - } - - handlers.setChallenge(copy, domain, key, value, done); + if (!Array.isArray(args.domains)) { + return PromiseA.reject(new Error('args.domains should be an array of domains')); } - , 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); + 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); } - }).then(attachCertInfo); - }).then(function (results) { - // { cert, chain, fullchain, privkey } - args.pems = results; - return backend.setRegistration(args, defaults, handlers); - }); - } + return core.accounts.getAsync(copy).then(function (account) { + copy.account = account; - function getOrCreateDomainCertificate(args, defaults, handlers) { - if (args.duplicate) { - // we're forcing a refresh via 'dupliate: true' - return getCertificateAsync(args, defaults, handlers); - } + //var account = args.account; + var keypairOpts = { public: true, pem: true }; - return wrapped.fetchAsync(args).then(function (certs) { - var halfLife = (certs.expiresAt - certs.issuedAt) / 2; + 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); + }); + }); - 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 promise.then(function (domainKeypair) { + args.domainKeypair = domainKeypair; + //args.registration = domainKey; - 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." - )); - }); - } + // 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; - // 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 le.acme.getCertificateAsync({ + debug: args.debug || le.debug - return backend.getAccountId(args).then(function (accountId) { + , newAuthzUrl: args._acmeUrls.newAuthz + , newCertUrl: args._acmeUrls.newCert - // Note: the ACME urls are always fetched fresh on purpose - return getAcmeUrls(args).then(function (urls) { - args._acmeUrls = urls; + , accountKeypair: RSA.import(account.keypair) + , domainKeypair: domainKeypair + , domains: args.domains + , challengeType: args.challengeType - if (accountId) { - log('[le/core.js] use account'); + // + // 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.accountId = accountId; - return backend.getAccount(args, handlers); - } else { - log('[le/core.js] create account'); - return createAccount(args, handlers); - } - }); - }); - } + //args.domains = [domain]; + args.domains = args.domains || [domain]; - var wrapped = { - registerAsync: function (args) { - var utils = require('./lib/common'); - var err; + 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; + } - if (!Array.isArray(args.domains)) { - return PromiseA.reject(new Error('args.domains should be an array of domains')); - } + le.challenger.set(copy, domain, key, value, done); + } + , removeChallenge: function (domain, key, done) { + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); - 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); - } + 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; + } - var copy = handlers.merge(args, defaults, backendDefaults); - handlers.tplCopy(copy); + le.challenger.remove(copy, domain, key, done); + } + }).then(utils.attachCertInfo); + }); + }).then(function (results) { + // { cert, chain, privkey } - return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { - copy.account = account; - - return backend.getOrCreateRenewal(copy).then(function (pyobj) { - - copy.pyobj = pyobj; - return getOrCreateDomainCertificate(copy, defaults, handlers); + args.pems = results; + return le.store.certificates.setAsync(args).then(function () { + return results; + }); + }); }); - }).then(function (result) { - return result; - }, function (err) { - return PromiseA.reject(err); - }); - } - , getOrCreateAccount: function (args) { - return createAccount(args, handlers); - } - , configureAsync: function (hargs) { - var copy = handlers.merge(hargs, defaults, backendDefaults); - handlers.tplCopy(copy); + } + , 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); - 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); + // returns pems + return le.store.certificates.checkAsync(copy).then(utils.attachCertInfo); + } + , getAsync: function (args) { + var copy = utils.merge(args, le); + args = utils.tplCopy(copy); - return backend.fetchAsync(copy).then(attachCertInfo); + 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 (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( + "[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." + )); + }).then(function (results) { + // returns pems + return results; + }); + } } + }; - return wrapped; + return core; }; 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."); + }); + }; + }; +}; diff --git a/lib/common.js b/lib/utils.js similarity index 53% rename from lib/common.js rename to lib/utils.js index e00919f..eaaad17 100644 --- a/lib/common.js +++ b/lib/utils.js @@ -4,6 +4,20 @@ 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; + // 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)) { @@ -21,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) { @@ -63,4 +77,31 @@ module.exports.tplCopy = function (copy) { copy[key] = copy[key].replace(':' + tplname, tpls[tplname]); }); }); + + 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();