diff --git a/README.md b/README.md index 222264b..411b16f 100644 --- a/README.md +++ b/README.md @@ -10,271 +10,249 @@ letsencrypt =========== -Automatic [Let's Encrypt](https://letsencrypt.org) HTTPS Certificates for node.js +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 ==== -**These aren't the droids you're looking for.** +> **These aren't the droids you're looking for.** -This is a low-level library for implementing CLIs, +This is a **low-level library** for implementing ACME / LetsEncrypt Clients, CLIs, system tools, and abstracting storage backends (file vs db, etc). -This is not the thing to use in your webserver directly. -### Use [letsencrypt-express](https://github.com/Daplie/letsencrypt-express) if... +For `express`, raw `https` or `spdy`, or `restify` (same as raw https) see +[**letsencrypt-express**](https://github.com/Daplie/letsencrypt-express). -you are planning to use one of these: +For `hapi` see [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi). - * `express` - * `connect` - * raw `https` - * raw `spdy` - * `restify` (same as raw https) - * `hapi` See [letsencrypt-hapi](https://github.com/Daplie/letsencrypt-hapi) - * `koa` See [letsencrypt-koa](https://github.com/Daplie/letsencrypt-koa) - * `rill` (similar to koa example) +For `koa` or `rill` +see [letsencrypt-koa](https://github.com/Daplie/letsencrypt-koa). -### Use [letsencrypt-cli](https://github.com/Daplie/letsencrypt-cli) if... +For `bash`, `fish`, `zsh`, `cmd.exe`, `PowerShell` +see [**letsencrypt-cli**](https://github.com/Daplie/letsencrypt-cli). -You are planning to use one of these: +CONTINUE +======== - * `bash` - * `fish` - * `zsh` - * `cmd.exe` - * `PowerShell` +If you're sure you're at the right place, here's what you need to know now: Install -======= +------- + +`letsencrypt` requires at least two plugins: +one for managing certificate storage and the other for handling ACME challenges. + +The default storage plugin is [`le-store-certbot`](https://github.com/Daplie/le-store-certbot) +and the default challenge is [`le-challenge-fs`](https://github.com/Daplie/le-challenge-fs). ```bash -npm install --save letsencrypt +npm install --save letsencrypt@2.x +npm install --save le-store-certbot@2.x +npm install --save le-challenge-fs@2.x ``` Usage -===== +----- -### letsencrypt +It's very simple and easy to use, but also very complete and easy to extend and customize. -There are **NO DEFAULTS**. +### Overly Simplified Example -A number of **constants** (such as LE.stagingServerUrl and LE.configDir) -are exported for your convenience, but all required options must be specified by the library invoking the call. - -Open an issue if you need a variable for something that isn't there yet. +Against my better judgement I'm providing a terribly oversimplified example +of how to use this library: ```javascript +var le = require('letsencrypt').create({ server: 'staging' }); + +le.register( + { domains: ['example.com'], email: 'user@email.com', agreeTos: true } +, function (err, results) { + console.log(err, results); + } +); +``` + +You also need some sort of server to handle the acme challenge: + +```javascript +var app = express(); +app.use('/', le.middleware()); +``` + +Note: The `webrootPath` string is a template. +Any occurance of `:hostname` will be replaced +with the domain for which we are requested certificates. + +### Useful Example + +The configuration consists of 3 components: + +* Storage Backend (search npm for projects starting with 'le-store-') +* ACME Challenge Handlers (search npm for projects starting with 'le-challenge-') +* Letsencryt Config (this is all you) + +```javascript +'use strict'; + var LE = require('letsencrypt'); +var le; -var config = { - server: LE.stagingServerUrl // or LE.productionServerUrl - -, configDir: require('homedir')() + '/letsencrypt/etc' // or /etc/letsencrypt or wherever - -, privkeyPath: ':config/live/:hostname/privkey.pem' // -, fullchainPath: ':config/live/:hostname/fullchain.pem' // Note: both that :config and :hostname -, certPath: ':config/live/:hostname/cert.pem' // will be templated as expected -, chainPath: ':config/live/:hostname/chain.pem' // - +// Storage Backend +var leStore = require('le-store-certbot').create({ + configDir: '~/letsencrypt/etc' // or /etc/letsencrypt or wherever , debug: false -}; +}); -var handlers = { - setChallenge: function (opts, hostname, key, val, cb) {} // called during the ACME server handshake, before validation -, removeChallenge: function (opts, hostname, key, cb) {} // called after validation on both success and failure -, getChallenge: function (opts, hostname, key, cb) {} // this is special because it is called by the webserver - // (see letsencrypt-cli/bin & letsencrypt-express/standalone), - // not by the library itself - -, agreeToTerms: function (tosUrl, cb) {} // gives you an async way to expose the legal agreement - // (terms of use) to your users before accepting -}; +// ACME Challenge Handlers +var leChallenge = require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt/var/' // or template string such as +, debug: false // '/srv/www/:hostname/.well-known/acme-challenge' +}); -var le = LE.create(config, handlers); +function leAgree(opts, agreeCb) { + // opts = { email, domains, tosUrl } + agreeCb(null, opts.tosUrl); +} - // checks :conf/renewal/:hostname.conf -le.register({ // and either renews or registers +le = LE.create({ + server: LE.stagingServerUrl // or LE.productionServerUrl +, store: leStore // handles saving of config, accounts, and certificates +, challenge: leChallenge // handles /.well-known/acme-challege keys and tokens +, agreeToTerms: leAgree // hook to allow user to view and accept LE TOS +, debug: false +}); - domains: ['example.com'] // CHANGE TO YOUR DOMAIN -, email: 'user@email.com' // CHANGE TO YOUR EMAIL -, agreeTos: false // set to true to automatically accept an agreement - // which you have pre-approved (not recommended) -}, function (err) { - if (err) { - // Note: you must have a webserver running - // and expose handlers.getChallenge to it - // in order to pass validation - // See letsencrypt-cli and or letsencrypt-express +// If using express you should use the middleware +// app.use('/', le.middleware()); +// +// Otherwise you should see the test file for usage of this: +// le.challenge.get(opts.domain, key, val, done) + + + +// Check in-memory cache of certificates for the named domain +le.check({ domains: [ 'example.com' ] }).then(function (results) { + if (results) { + // we already have certificates + return; + } + + + // Register Certificate manually + le.register({ + + 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 + + }).then(function (results) { + + console.log('success'); + + }, function (err) { + + // Note: you must either use le.middleware() with express, + // manually use le.challenge.get(opts, 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); - } else { - console.log('success'); - } + + }); + }); ``` -**However**, due to the nature of what this library does, it has a few more "moving parts" -than what makes sense to show in a minimal snippet. +Here's what `results` looks like: + +```javascript +{ privkey: '' // PEM encoded private key +, cert: '' // PEM encoded cert +, chain: '' // PEM encoded intermediate cert +, fullchain: '' // cert + chain +, issuedAt: 0 // notBefore date (in ms) parsed from cert +, expiresAt: 0 // notAfter date (in ms) parsed from cert +} +``` API -=== +--- -```javascript -LetsEncrypt.create(leConfig, handlers, backend) // wraps a given "backend" (the python or node client) -LetsEncrypt.stagingServer // string of staging server for testing +The full end-user API is exposed in the example above and includes all relevant options. -le.middleware() // middleware for serving webrootPath to /.well-known/acme-challenge -le.sniCallback(hostname, function (err, tlsContext) {}) // uses fetch (below) and formats for https.SNICallback -le.register({ domains, email, agreeTos, ... }, cb) // registers or renews certs for a domain -le.fetch({domains, email, agreeTos, ... }, cb) // fetches certs from in-memory cache, occasionally refreshes from disk -le.registrationFailureCallback(err, args, certInfo, cb) // called when registration fails (not implemented yet) +``` +le.register +le.check ``` -### `LetsEncrypt.create(backend, leConfig, handlers)` +### Helper Functions -#### leConfig +We do expose a few helper functions: -The arguments passed here (typically `webpathRoot`, `configDir`, etc) will be merged with -any `args` (typically `domains`, `email`, and `agreeTos`) and passed to the backend whenever -it is called. +* LE.validDomain(hostname) // returns '' or the hostname string if it's a valid ascii or punycode domain name -Typically the backend wrapper will already merge any necessary backend-specific arguments. +TODO fetch domain tld list -**Example**: -```javascript -{ webrootPath: __dirname, '/acme-challenge' -, fullchainTpl: '/live/:hostname/fullchain.pem' -, privkeyTpl: '/live/:hostname/fullchain.pem' -, configDir: '/etc/letsencrypt' -} -``` +### Template Strings -Note: `webrootPath` can be set as a default, semi-locally with `webrootPathTpl`, or per -registration as `webrootPath` (which overwrites `leConfig.webrootPath`). +The following variables will be tempalted in any strings passed to the options object: -#### handlers *optional* +* `~/` replaced with `os.homedir()` i.e. `/Users/aj` +* `:hostname` replaced with the first domain in the list i.e. `example.com` -`h.setChallenge(hostnames, name, value, cb)`: +Developer API +------------- -default is to write to fs +If you are developing an `le-store-*` or `le-challenge-*` plugin you need to be aware of +additional internal API expectations. -`h.getChallenge(hostnames, value cb)` +**IMPORTANT**: -default is to read from fs +Use `v2.0.0` as your initial version - NOT v0.1.0 and NOT v1.0.0 and NOT v3.0.0. +This is to indicate that your module is compatible with v2.x of node-letsencrypt. -`h.sniRegisterCallback(args, currentCerts, cb)` +Since the public API for your module is defined by node-letsencrypt the major version +should be kept in sync. -The default is to immediately call `cb(null, null)` and register (or renew) in the background -during the `SNICallback` phase. Right now it isn't reasonable to renew during SNICallback, -but around February when it is possible to use ECDSA keys (as opposed to RSA at present), -registration will take very little time. +### store implementation -This will not be called while another registration is already in progress. +TODO double check and finish -### `le.middleware()` +* accounts + * accounts.byDomain + * accounts.all + * accounts.get + * accounts.exists +* certs + * certs.byAccount + * certs.all + * certs.get + * certs.exists -An express handler for `/.well-known/acme-challenge/`. -Will call `getChallenge([hostname], key, cb)` if present or otherwise read `challenge` from disk. +### challenge implementation -Example: -```javascript -app.use('/', le.middleware()) -``` +TODO finish -### `le.sniCallback(hostname, function (err, tlsContext) {});` - -Will call `fetch`. If fetch does not return certificates or returns expired certificates -it will call `sniRegisterCallback(args, currentCerts, cb)` and then return the error, -the new certificates, or call `fetch` a final time. - -Example: -```javascript -var server = require('https').createServer({ SNICallback: le.sniCallback, cert: '...', key: '...' }); -server.on('request', app); -``` - -### `le.register({ domains, email, agreeTos, ... }, cb)` - -Get certificates for a domain - -Example: -```javascript -le.register({ - domains: ['example.com', 'www.example.com'] -, email: 'user@example.com' -, webrootPath: '/srv/www/example.com/public' -, agreeTos: true -}, function (err, certs) { - // err is some error - - console.log(certs); - /* - { cert: "contents of fullchain.pem" - , key: "contents of privkey.pem" - , renewedAt: - , duration: - } - */ -}); -``` - -### `le.isValidDomain(hostname)` - -returns `true` if `hostname` is a valid ascii or punycode domain name. - -(also exposed on the main exported module as `LetsEncrypt.isValidDomain()`) - -### `le.fetch(args, cb)` - -Used internally, but exposed for convenience. - -Checks in-memory cache of certificates for `args.domains` and calls then calls `backend.fetch(args, cb)` -**after** merging `args` if necessary. - -### `le.registrationFailureCallback(err, args, certInfo, cb)` - -Not yet implemented - - -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' -, webrootPathTpl: '/srv/www/:hostname/public' -, webrootPath: '/srv/www/example.com/public' // templated from webrootPathTpl -} -``` - -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) +* `.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 ============== +* v2.0.0 - Aug 5th 2016 + * major refactor + * simplified API + * modular pluigns + * knock out bugs * v1.5.0 now using letiny-core v2.0.0 and rsa-compat * v1.4.x I can't remember... but it's better! * v1.1.0 Added letiny-core, removed node-letsencrypt-python diff --git a/examples/README.md b/examples/README.md index 9976c81..47fb61c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,9 +29,6 @@ No, I wanted node-letsencrypt ============================= Well, take a look at the API in the main README -and you can also check out the [scraps](https://github.com/Daplie/node-letsencrypt/tree/master/scraps). +and you can also check out the code in the repos above. -Feel free to create issues for examples that don't work and pull requests if you fix one. - -And please, please, do open an issue. We haven't updated the scrap examples -(hence being moved), but we do have it on the roadmap to bring back some raw API examples. +Feel free to open an issues to request any particular type of example. diff --git a/examples/simple.js b/examples/simple.js new file mode 100644 index 0000000..84dc3fc --- /dev/null +++ b/examples/simple.js @@ -0,0 +1,64 @@ +'use strict'; + +//var le = require('letsencrypt'); +var LE = require('../'); +var db = {}; + +var config = { + server: LE.stagingServerUrl // or LE.productionServerUrl + +, configDir: require('homedir')() + '/letsencrypt/etc' // or /etc/letsencrypt or wherever + +, privkeyPath: ':config/live/:hostname/privkey.pem' // +, fullchainPath: ':config/live/:hostname/fullchain.pem' // Note: both that :config and :hostname +, certPath: ':config/live/:hostname/cert.pem' // will be templated as expected +, chainPath: ':config/live/:hostname/chain.pem' // + +, rsaKeySize: 2048 + +, debug: true +}; + +var handlers = { + setChallenge: function (opts, hostname, key, val, cb) { // called during the ACME server handshake, before validation + db[key] = { + hostname: hostname + , key: key + , val: val + }; + + cb(null); + } +, removeChallenge: function (opts, hostname, key, cb) { // called after validation on both success and failure + db[key] = null; + cb(null); + } +, getChallenge: function (opts, hostname, key, cb) { // this is special because it is called by the webserver + cb(null, db[key].val); // (see letsencrypt-cli/bin & letsencrypt-express/standalone), + // not by the library itself + } +, agreeToTerms: function (tosUrl, cb) { // gives you an async way to expose the legal agreement + cb(null, tosUrl); // (terms of use) to your users before accepting + } +}; + +var le = LE.create(config, handlers); + // checks :conf/renewal/:hostname.conf +le.register({ // and either renews or registers + domains: ['example.com'] // CHANGE TO YOUR DOMAIN +, email: 'user@email.com' // CHANGE TO YOUR EMAIL +, agreeTos: false // set to true to automatically accept an agreement + // which you have pre-approved (not recommended) +, rsaKeySize: 2048 +}, function (err) { + if (err) { + // Note: you must have a webserver running + // and expose handlers.getChallenge to it + // in order to pass validation + // See letsencrypt-cli and or letsencrypt-express + console.error('[Error]: node-letsencrypt/examples/standalone'); + console.error(err.stack); + } else { + console.log('success'); + } +}); diff --git a/index.js b/index.js index f7233db..5f6067e 100644 --- a/index.js +++ b/index.js @@ -1,246 +1,150 @@ 'use strict'; -// TODO handle www and no-www together somehow? - -var PromiseA = require('bluebird'); -var leCore = require('letiny-core'); -var merge = require('./lib/common').merge; -var tplCopy = require('./lib/common').tplCopy; +var ACME = require('le-acme-core').ACME; var LE = module.exports; -LE.productionServerUrl = leCore.productionServerUrl; -LE.stagingServerUrl = leCore.stagingServerUrl; -LE.configDir = leCore.configDir; -LE.logsDir = leCore.logsDir; -LE.workDir = leCore.workDir; -LE.acmeChallengPrefix = leCore.acmeChallengPrefix; -LE.knownEndpoints = leCore.knownEndpoints; +LE.LE = LE; +// in-process cache, shared between all instances +var ipc = {}; -LE.privkeyPath = ':config/live/:hostname/privkey.pem'; -LE.fullchainPath = ':config/live/:hostname/fullchain.pem'; -LE.certPath = ':config/live/:hostname/cert.pem'; -LE.chainPath = ':config/live/:hostname/chain.pem'; -LE.renewalPath = ':config/renewal/:hostname.conf'; -LE.accountsDir = ':config/accounts/:server'; LE.defaults = { - privkeyPath: LE.privkeyPath -, fullchainPath: LE.fullchainPath -, certPath: LE.certPath -, chainPath: LE.chainPath -, renewalPath: LE.renewalPath -, accountsDir: LE.accountsDir -, server: LE.productionServerUrl + productionServerUrl: ACME.productionServerUrl +, stagingServerUrl: ACME.stagingServerUrl + +, rsaKeySize: ACME.rsaKeySize || 2048 +, challengeType: ACME.challengeType || 'http-01' + +, acmeChallengePrefix: ACME.acmeChallengePrefix }; // backwards compat -LE.stagingServer = leCore.stagingServerUrl; -LE.liveServer = leCore.productionServerUrl; -LE.knownUrls = leCore.knownEndpoints; +Object.keys(LE.defaults).forEach(function (key) { + LE[key] = LE.defaults[key]; +}); -LE.merge = require('./lib/common').merge; -LE.tplConfigDir = require('./lib/common').tplConfigDir; +// show all possible options +var u; // undefined +LE._undefined = { + acme: u +, store: u +, challenge: u - // backend, defaults, handlers -LE.create = function (defaults, handlers, backend) { - if (!backend) { backend = require('./lib/core'); } - if (!handlers) { handlers = {}; } - if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; } - 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 (!handlers.getChallenge) { - if (!defaults.manual && !defaults.webrootPath) { - // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} - throw new Error("handlers.getChallenge or defaults.webrootPath must be set"); +, register: u +, check: u + +, renewWithin: u +, memorizeFor: u +, acmeChallengePrefix: u +, rsaKeySize: u +, challengeType: u +, server: u +, agreeToTerms: u +, _ipc: u +, duplicate: u +, _acmeUrls: u +}; +LE._undefine = function (le) { + Object.keys(LE._undefined).forEach(function (key) { + if (!(key in le)) { + le[key] = u; } - handlers.getChallenge = function (hostname, key, done) { - // TODO associate by hostname? - // hmm... I don't think there's a direct way to associate this with - // the request it came from... it's kinda stateless in that way - // but realistically there only needs to be one handler and one - // "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] }); - - tplCopy(copy); - defaultos.domains = [hostname]; - - if (3 === getChallenge.length) { - getChallenge(defaultos, key, done); - } - else if (4 === getChallenge.length) { - getChallenge(defaultos, hostname, key, done); - } - else { - done(new Error("handlers.getChallenge [1] receives the wrong number of arguments")); - } - }; - } - if (!handlers.setChallenge) { - if (!defaults.webrootPath) { - // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} - throw new Error("handlers.setChallenge or defaults.webrootPath must be set"); - } - handlers.setChallenge = require('./lib/default-handlers').setChallenge; - } - if (!handlers.removeChallenge) { - if (!defaults.webrootPath) { - // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} - throw new Error("handlers.removeChallenge or defaults.webrootPath must be set"); - } - handlers.removeChallenge = require('./lib/default-handlers').removeChallenge; - } - if (!handlers.agreeToTerms) { - if (defaults.agreeTos) { - console.warn("[WARN] Agreeing to terms by default is risky business..."); - } - handlers.agreeToTerms = require('./lib/default-handlers').agreeToTerms; - } - if ('function' === typeof backend.create) { - backend = backend.create(defaults, handlers); - } - else { - // 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('./lib/common'); - //var attempts = {}; // should exist in master process only - var le; - - // TODO check certs on initial load - // TODO expect that certs expire every 90 days - // TODO check certs with setInterval? - //options.cacheContextsFor = options.cacheContextsFor || (1 * 60 * 60 * 1000); - - le = { - backend: backend - , pyToJson: function (pyobj) { - if (!pyobj) { - return null; - } - - var jsobj = {}; - Object.keys(pyobj).forEach(function (key) { - jsobj[key] = pyobj[key]; - }); - jsobj.__lines = undefined; - jsobj.__keys = undefined; - - return jsobj; - } - , register: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] register'); - } - if (!Array.isArray(args.domains)) { - cb(new Error('args.domains should be an array of domains')); - return; - } - - var copy = LE.merge(defaults, args); - var err; - - if (!utils.isValidDomain(args.domains[0])) { - err = new Error("invalid domain name: '" + args.domains + "'"); - err.code = "INVALID_DOMAIN"; - cb(err); - return; - } - - if ((!args.domains.length && args.domains.every(le.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 - cb(new Error("node-letsencrypt: invalid hostnames: " + args.domains.join(','))); - return; - } - - if (defaults.debug || args.debug) { - console.log("[NLE]: begin registration"); - } - - return backend.registerAsync(copy).then(function (pems) { - if (defaults.debug || args.debug) { - console.log("[NLE]: end registration"); - } - cb(null, pems); - //return le.fetch(args, cb); - }, cb); - } - , fetch: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] fetch'); - } - return backend.fetchAsync(args).then(function (certInfo) { - if (args.debug) { - console.log('[LE] raw fetch certs', certInfo && Object.keys(certInfo)); - } - if (!certInfo) { cb(null, null); return; } - - // key, cert, issuedAt, lifetime, expiresAt - if (!certInfo.expiresAt) { - certInfo.expiresAt = certInfo.issuedAt + (certInfo.lifetime || handlers.lifetime); - } - if (!certInfo.lifetime) { - certInfo.lifetime = (certInfo.lifetime || handlers.lifetime); - } - // a pretty good hard buffer - certInfo.expiresAt -= (1 * 24 * 60 * 60 * 100); - - cb(null, certInfo); - }, cb); - } - , getConfig: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] getConfig'); - } - backend.getConfigAsync(args).then(function (pyobj) { - cb(null, le.pyToJson(pyobj)); - }, function (err) { - console.error("[letsencrypt/index.js] getConfig"); - console.error(err.stack); - return cb(null, []); - }); - } - , getConfigs: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] getConfigs'); - } - backend.getConfigsAsync(args).then(function (configs) { - cb(null, configs.map(le.pyToJson)); - }, function (err) { - if ('ENOENT' === err.code) { - cb(null, []); - } else { - console.error("[letsencrypt/index.js] getConfigs"); - console.error(err.stack); - cb(err); - } - }); - } - , setConfig: function (args, cb) { - if (defaults.debug || args.debug) { - console.log('[LE] setConfig'); - } - backend.configureAsync(args).then(function (pyobj) { - cb(null, le.pyToJson(pyobj)); - }); - } - }; + }); + + 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.challenge = le.challenge || require('le-challenge-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; + le.agreeToTerms = le.agreeToTerms || function (args, agreeCb) { + agreeCb(new Error("'agreeToTerms' was not supplied to LE and 'agreeTos' was not supplied to LE.register")); + }; + + 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.challenge.create) { + le.challenge = le.challenge.create(le); + } + le.challenge = PromiseA.promisifyAll(le.challenge); + le._challengeOpts = le.challenge.getOptions(); + Object.keys(le._challengeOpts).forEach(function (key) { + if (!(key in le)) { + le[key] = le._challengeOpts[key]; + } + }); + // TODO wrap these here and now with tplCopy? + if (5 !== le.challenge.set.length) { + throw new Error("le.challenge.set receives the wrong number of arguments." + + " You must define setChallenge as function (opts, domain, key, val, cb) { }"); + } + if (4 !== le.challenge.get.length) { + throw new Error("le.challenge.get receives the wrong number of arguments." + + " You must define getChallenge as function (opts, domain, key, cb) { }"); + } + if (4 !== le.challenge.remove.length) { + throw new Error("le.challenge.remove receives the wrong number of arguments." + + " You must define removeChallenge as function (opts, domain, key, cb) { }"); + } + + 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 = le.middleware || require('./lib/middleware'); + if (le.middleware.create) { + le.middleware = le.middleware.create(le); + } return le; }; diff --git a/lib/accounts.js b/lib/accounts.js deleted file mode 100644 index 7f8f235..0000000 --- a/lib/accounts.js +++ /dev/null @@ -1,195 +0,0 @@ -'use strict'; - -var PromiseA = require('bluebird'); -var crypto = require('crypto'); -var LeCore = require('letiny-core'); -var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); -var path = require('path'); -var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -var fs = PromiseA.promisifyAll(require('fs')); - -function createAccount(args, handlers) { - var os = require("os"); - var localname = os.hostname(); - - // arg.rsaBitLength args.rsaExponent - return RSA.generateKeypairAsync(args.rsaKeySize || 2048, 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); - } - , accountKeypair: keypair - - , 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'); - var accountId = keypair.publicKeyMd5; - var accountDir = path.join(args.accountsDir, accountId); - var regr = { body: body }; - - args.accountId = accountId; - args.accountDir = accountDir; - - return mkdirpAsync(accountDir).then(function () { - - var isoDate = new Date().toISOString(); - var accountMeta = { - creation_host: localname - , creation_dt: isoDate - }; - - // TODO abstract file writing - return PromiseA.all([ - // meta.json {"creation_host": "ns1.redirect-www.org", "creation_dt": "2015-12-11T04:14:38Z"} - fs.writeFileAsync(path.join(accountDir, 'meta.json'), JSON.stringify(accountMeta), 'utf8') - // private_key.json { "e", "d", "n", "q", "p", "kty", "qi", "dp", "dq" } - , fs.writeFileAsync(path.join(accountDir, 'private_key.json'), JSON.stringify(RSA.exportPrivateJwk(keypair)), 'utf8') - // regr.json: - /* - { body: { contact: [ 'mailto:coolaj86@gmail.com' ], - agreement: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', - key: { e: 'AQAB', kty: 'RSA', n: '...' } }, - uri: 'https://acme-v01.api.letsencrypt.org/acme/reg/71272', - new_authzr_uri: 'https://acme-v01.api.letsencrypt.org/acme/new-authz', - terms_of_service: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf' } - */ - , fs.writeFileAsync(path.join(accountDir, 'regr.json'), JSON.stringify(regr), 'utf8') - ]).then(function () { - var pems = {}; - - // pems.private_key; - pems.meta = accountMeta; - pems.keypair = keypair; - pems.regr = regr; - pems.accountId = accountId; - pems.id = accountId; - return pems; - }); - }); - }); - }); -} - -function getAccount(args, handlers) { - var accountId = args.accountId; - var accountDir = path.join(args.accountsDir, accountId); - var files = {}; - var configs = ['meta.json', 'private_key.json', 'regr.json']; - - return PromiseA.all(configs.map(function (filename) { - var keyname = filename.slice(0, -5); - - return fs.readFileAsync(path.join(accountDir, filename), 'utf8').then(function (text) { - var data; - - try { - data = JSON.parse(text); - } catch(e) { - files[keyname] = { error: e }; - return; - } - - files[keyname] = data; - }, function (err) { - files[keyname] = { error: err }; - }); - })).then(function () { - - if (!Object.keys(files).every(function (key) { - return !files[key].error; - })) { - // TODO log renewal.conf - console.warn("Account '" + accountId + "' was corrupt. No big deal (I think?). Creating a new one..."); - //console.log(accountId, files); - return createAccount(args, handlers); - } - - var keypair = { privateKeyJwk: files.private_key }; - keypair.privateKeyPem = RSA.exportPrivatePem(keypair); - keypair.publicKeyPem = RSA.exportPublicPem(keypair); - - //files.private_key; - //files.regr; - //files.meta; - files.accountId = accountId; // preserve current account id - files.id = accountId; - files.keypair = keypair; - - return files; - }); -} - -function getAccountIdByEmail(args) { - // If we read 10,000 account directories looking for - // just one email address, that could get crazy. - // We should have a folder per email and list - // each account as a file in the folder - // TODO - var email = args.email; - if ('string' !== typeof email) { - if (args.debug) { - console.log("[LE] No email given"); - } - return PromiseA.resolve(null); - } - return fs.readdirAsync(args.accountsDir).then(function (nodes) { - if (args.debug) { - console.log("[LE] arg.accountsDir success"); - } - - return PromiseA.all(nodes.map(function (node) { - return fs.readFileAsync(path.join(args.accountsDir, node, 'regr.json'), 'utf8').then(function (text) { - var regr = JSON.parse(text); - regr.__accountId = node; - - return regr; - }); - })).then(function (regrs) { - var accountId; - - /* - if (args.debug) { - console.log('read many regrs'); - console.log('regrs', regrs); - } - */ - - regrs.some(function (regr) { - return regr.body.contact.some(function (contact) { - var match = contact.toLowerCase() === 'mailto:' + email.toLowerCase(); - if (match) { - accountId = regr.__accountId; - return true; - } - }); - }); - - if (!accountId) { - return null; - } - - return accountId; - }); - }).then(function (accountId) { - return accountId; - }, function (err) { - if ('ENOENT' === err.code) { - // ignore error - return null; - } - - return PromiseA.reject(err); - }); -} - -module.exports.getAccountIdByEmail = getAccountIdByEmail; -module.exports.getAccount = getAccount; -module.exports.createAccount = createAccount; diff --git a/lib/cert-info.js b/lib/cert-info.js index 50bd31c..fb90195 100644 --- a/lib/cert-info.js +++ b/lib/cert-info.js @@ -35,28 +35,66 @@ certInfo.getCertInfo = function (pem) { return certSimpl; }; +certInfo.getBasicInfo = function (pem) { + var c = certInfo.getCertInfo(pem); + var domains = []; + var sub; + + c.extensions.forEach(function (ext) { + if (ext.parsedValue && ext.parsedValue.altNames) { + ext.parsedValue.altNames.forEach(function (alt) { + domains.push(alt.Name); + }); + } + }); + + sub = c.subject.types_and_values[0].value.value_block.value || null; + + return { + subject: sub + , altnames: domains + // for debugging during console.log + // do not expect these values to be here + , _issuedAt: c.notBefore.value + , _expiresAt: c.notAfter.value + , issuedAt: new Date(c.notBefore.value).valueOf() + , expiresAt: new Date(c.notAfter.value).valueOf() + }; +}; + certInfo.getCertInfoFromFile = function (pemFile) { return require('fs').readFileSync(pemFile, 'ascii'); }; -certInfo.testGetCertInfo = function () { +certInfo.testGetCertInfo = function (pathname) { var path = require('path'); - var pemFile = path.join(__dirname, '..', 'tests', 'example.cert.pem'); + var pemFile = pathname || path.join(__dirname, '..', 'tests', 'example.cert.pem'); return certInfo.getCertInfo(certInfo.getCertInfoFromFile(pemFile)); }; +certInfo.testBasicCertInfo = function (pathname) { + var path = require('path'); + var pemFile = pathname || path.join(__dirname, '..', 'tests', 'example.cert.pem'); + return certInfo.getBasicInfo(certInfo.getCertInfoFromFile(pemFile)); +}; + if (require.main === module) { - var c = certInfo.testGetCertInfo(); + var c = certInfo.testGetCertInfo(process.argv[2]); - console.log(''); + console.info(''); - console.log(c.notBefore.value); - console.log(Date(c.notBefore.value).valueOf()); + console.info(c.notBefore.value); + console.info(new Date(c.notBefore.value).valueOf()); - console.log(''); + console.info(''); - console.log(c.notAfter.value); - console.log(Date(c.notAfter.value).valueOf()); + console.info(c.notAfter.value); + console.info(new Date(c.notAfter.value).valueOf()); - console.log(''); + console.info(''); + + var b = certInfo.testBasicCertInfo(process.argv[2]); + console.info(''); + console.info(JSON.stringify(b, null, ' ')); + console.info(''); } diff --git a/lib/common.js b/lib/common.js deleted file mode 100644 index 1351d17..0000000 --- a/lib/common.js +++ /dev/null @@ -1,125 +0,0 @@ -'use strict'; - -var fs = require('fs'); -var path = require('path'); -var PromiseA = require('bluebird'); - -var homeRe = new RegExp("^~(\\/|\\\|\\" + path.sep + ")"); -var re = /^[a-zA-Z0-9\.\-]+$/; -var punycode = require('punycode'); - -module.exports.isValidDomain = function (domain) { - if (re.test(domain)) { - return domain; - } - - domain = punycode.toASCII(domain); - - if (re.test(domain)) { - return domain; - } - - return ''; -}; - -module.exports.tplConfigDir = function merge(configDir, defaults) { - var homedir = require('homedir')(); - Object.keys(defaults).forEach(function (key) { - if ('string' === typeof defaults[key]) { - defaults[key] = defaults[key].replace(':config', configDir).replace(':conf', configDir); - defaults[key] = defaults[key].replace(homeRe, homedir + path.sep); - } - }); -}; - -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.tplCopy = function merge(copy) { - var homedir = require('homedir')(); - var tpls = { - hostname: (copy.domains || [])[0] - , server: (copy.server || '').replace('https://', '').replace(/(\/)$/, '') - , conf: copy.configDir - , config: copy.configDir - }; - - Object.keys(copy).forEach(function (key) { - if ('string' === typeof copy[key]) { - Object.keys(tpls).sort(function (a, b) { - return b.length - a.length; - }).forEach(function (tplname) { - if (!tpls[tplname]) { - // what can't be templated now may be templatable later - return; - } - copy[key] = copy[key].replace(':' + tplname, tpls[tplname]); - copy[key] = copy[key].replace(homeRe, homedir + path.sep); - }); - } - }); - - //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"); - } - - //, 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(args.certPath) // 3 - ]).then(function (arr) { - var cert = arr[1]; - var getCertInfo = require('./cert-info').getCertInfo; - - // XXX Note: Parsing the certificate info comes at a great cost (~500kb) - var certInfo = getCertInfo(cert); - - return { - key: arr[0] // privkey.pem - , privkey: arr[0] // privkey.pem - - , fullchain: arr[1] + '\n' + arr[2] // fullchain.pem - , cert: cert // cert.pem - - , chain: arr[2] // chain.pem - , ca: arr[2] // chain.pem - - , privkeyPath: args.privkeyPath - , fullchainPath: args.fullchainPath - , certPath: args.certPath - , chainPath: args.chainPath - - //, issuedAt: arr[3].mtime.valueOf() - , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() - , expiresAt: Date(certInfo.notAfter.value).valueOf() - , lifetime: args.lifetime - }; - }, function (err) { - if (args.debug) { - console.error("[letsencrypt/lib/common.js] fetchFromDisk"); - console.error(err.stack); - } - return null; - }); -}; diff --git a/lib/core.js b/lib/core.js index 75f7dcd..6ee4501 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,516 +1,366 @@ 'use strict'; -var PromiseA = require('bluebird'); -var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); -var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -var path = require('path'); -var fs = PromiseA.promisifyAll(require('fs')); -var sfs = require('safe-replace'); -var LE = require('../'); -var LeCore = PromiseA.promisifyAll(require('letiny-core')); -var Accounts = require('./accounts'); - -var merge = require('./common').merge; -var tplCopy = require('./common').tplCopy; -var fetchFromConfigLiveDir = require('./common').fetchFromDisk; - -var ipc = {}; // in-process cache - -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); +function log(debug) { + if (debug) { + var args = Array.prototype.slice.call(arguments); + args.shift(); + args.unshift("[le/lib/core.js]"); + console.log.apply(console, args); } - - return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { - ipc.acmeUrlsUpdatedAt = Date.now(); - ipc.acmeUrls = data; - - return ipc.acmeUrls; - }); } -function readRenewalConfig(args) { - var pyconf = PromiseA.promisifyAll(require('pyconf')); +module.exports.create = function (le) { + var PromiseA = require('bluebird'); + var utils = require('./utils'); + var RSA = PromiseA.promisifyAll(require('rsa-compat').RSA); - return pyconf.readFileAsync(args.renewalPath).then(function (pyobj) { - return pyobj; - }, function () { - return pyconf.readFileAsync(path.join(__dirname, 'renewal.conf.tpl')).then(function (pyobj) { - return pyobj; - }); - }); -} + var core = { + // + // Helpers + // + getAcmeUrlsAsync: function (args) { + var now = Date.now(); -function writeRenewalConfig(args) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } - } + // TODO check response header on request for cache time + if ((now - le._ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) { + return PromiseA.resolve(le._ipc.acmeUrls); + } - var pyobj = args.pyobj; - pyobj.checkpoints = parseInt(pyobj.checkpoints, 10) || 0; + return le.acme.getAcmeUrlsAsync(args.server).then(function (data) { + le._ipc.acmeUrlsUpdatedAt = Date.now(); + le._ipc.acmeUrls = data; - var pyconf = PromiseA.promisifyAll(require('pyconf')); - - var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); - - var certPath = args.certPath || pyobj.cert || path.join(liveDir, 'cert.pem'); - var fullchainPath = args.fullchainPath || pyobj.fullchain || path.join(liveDir, 'fullchain.pem'); - var chainPath = args.chainPath || pyobj.chain || path.join(liveDir, 'chain.pem'); - var privkeyPath = args.privkeyPath || pyobj.privkey - //|| args.domainPrivateKeyPath || args.domainKeyPath || pyobj.keyPath - || path.join(liveDir, 'privkey.pem'); - - log('[le/core.js] privkeyPath', privkeyPath); - - var updates = { - account: args.account.id - , configDir: args.configDir - , domains: args.domains - - , email: args.email - , tos: args.agreeTos && true - // yes, it's an array. weird, right? - , webrootPath: args.webrootPath && [args.webrootPath] || [] - , server: args.server || args.acmeDiscoveryUrl - - , privkey: privkeyPath - , fullchain: fullchainPath - , cert: certPath - , chain: chainPath - - , http01Port: args.http01Port - , keyPath: args.domainPrivateKeyPath || args.privkeyPath - , rsaKeySize: args.rsaKeySize - , checkpoints: pyobj.checkpoints - /* // TODO XXX what's the deal with these? they don't make sense - // are they just old junk? or do they have a meaning that I don't know about? - , fullchainPath: path.join(args.configDir, 'chain.pem') - , certPath: path.join(args.configDir, 'cert.pem') - , chainPath: path.join(args.configDir, 'chain.pem') - */ // TODO XXX end - , workDir: args.workDir - , logsDir: args.logsDir - }; - - // final section is completely dynamic - // :hostname = :webroot_path - args.domains.forEach(function (hostname) { - updates[hostname] = args.webrootPath; - }); - - // must write back to the original pyobject or - // annotations will be lost - Object.keys(updates).forEach(function (key) { - pyobj[key] = updates[key]; - }); - - return mkdirpAsync(path.dirname(args.renewalPath)).then(function () { - return pyconf.writeFileAsync(args.renewalPath, pyobj); - }).then(function () { - // NOTE - // writing twice seems to causes a bug, - // so instead we re-read the file from the disk - return pyconf.readFileAsync(args.renewalPath); - }); -} - -function getOrCreateRenewal(args) { - return readRenewalConfig(args).then(function (pyobj) { - var minver = pyobj.checkpoints >= 0; - - args.pyobj = pyobj; - - if (!minver) { - args.checkpoints = 0; - pyobj.checkpoints = 0; - return writeRenewalConfig(args); + return le._ipc.acmeUrls; + }); } - // args.account.id = pyobj.account - // args.configDir = args.configDir || pyobj.configDir; - args.checkpoints = pyobj.checkpoints; + // + // The Main Enchilada + // - args.agreeTos = (args.agreeTos || pyobj.tos) && true; - args.email = args.email || pyobj.email; - args.domains = args.domains || pyobj.domains; + // + // Accounts + // + , accounts: { + // Accounts + registerAsync: function (args) { + var err; + var copy = utils.merge(args, le); + var disagreeTos; + args = utils.tplCopy(copy); - // yes, it's an array. weird, right? - args.webrootPath = args.webrootPath || pyobj.webrootPath[0]; - args.server = args.server || args.acmeDiscoveryUrl || pyobj.server; + disagreeTos = (!args.agreeTos && 'undefined' !== typeof args.agreeTos); + if (!args.email || disagreeTos || (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); + } - args.certPath = args.certPath || pyobj.cert; - args.privkeyPath = args.privkeyPath || pyobj.privkey; - args.chainPath = args.chainPath || pyobj.chain; - args.fullchainPath = args.fullchainPath || pyobj.fullchain; + return utils.testEmail(args.email).then(function () { + var keypairOpts = { public: true, pem: true }; - //, workDir: args.workDir - //, logsDir: args.logsDir - args.rsaKeySize = args.rsaKeySize || pyobj.rsaKeySize; - args.http01Port = args.http01Port || pyobj.http01Port; - args.domainKeyPath = args.domainPrivateKeyPath || args.domainKeyPath || args.keyPath || pyobj.keyPath; + var promise = le.store.accounts.checkKeypairAsync(args).then(function (keypair) { + if (keypair) { + return RSA.import(keypair); + } - return writeRenewalConfig(args); - }); -} + if (args.accountKeypair) { + return le.store.accounts.setKeypairAsync(args, RSA.import(args.accountKeypair)); + } -function writeCertificateAsync(args, defaults, handlers) { - function log() { - if (args.debug) { - console.log.apply(console, arguments); - } - } + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { + keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.publicKeyPem = RSA.exportPublicPem(keypair); + keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); + return le.store.accounts.setKeypairAsync(args, keypair); + }); + }); - log("[le/core.js] got certificate!"); + return promise.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; - var obj = args.pyobj; - var result = args.pems; + 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; + } - result.fullchain = result.cert + '\n' + (result.chain || result.ca); - obj.checkpoints = parseInt(obj.checkpoints, 10) || 0; + // args.email = email; // already there + // args.domains = domains // already there + args.tosUrl = tosUrl; + le.agreeToTerms(args, agreeCb); + } + , accountKeypair: keypair - var liveDir = args.liveDir || path.join(args.configDir, 'live', args.domains[0]); + , debug: le.debug || args.debug + }).then(function (receipt) { + var reg = { + keypair: keypair + , receipt: receipt + , email: args.email + }; - var certPath = args.certPath || obj.cert || path.join(liveDir, 'cert.pem'); - var fullchainPath = args.fullchainPath || obj.fullchain || path.join(liveDir, 'fullchain.pem'); - var chainPath = args.chainPath || obj.chain || path.join(liveDir, 'chain.pem'); - var privkeyPath = args.privkeyPath || obj.privkey - //|| args.domainPrivateKeyPath || args.domainKeyPath || obj.keyPath - || path.join(liveDir, 'privkey.pem'); - - log('[le/core.js] privkeyPath', privkeyPath); - - var archiveDir = args.archiveDir || path.join(args.configDir, 'archive', args.domains[0]); - - var checkpoints = obj.checkpoints.toString(); - var certArchive = path.join(archiveDir, 'cert' + checkpoints + '.pem'); - var fullchainArchive = path.join(archiveDir, 'fullchain' + checkpoints + '.pem'); - var chainArchive = path.join(archiveDir, 'chain'+ checkpoints + '.pem'); - var privkeyArchive = path.join(archiveDir, 'privkey' + checkpoints + '.pem'); - - return mkdirpAsync(archiveDir).then(function () { - return PromiseA.all([ - sfs.writeFileAsync(certArchive, result.cert, 'ascii') - , sfs.writeFileAsync(chainArchive, (result.chain || result.ca), 'ascii') - , sfs.writeFileAsync(fullchainArchive, result.fullchain, 'ascii') - , sfs.writeFileAsync( - privkeyArchive - // TODO nix args.key, args.domainPrivateKeyPem ?? - , (result.privkey || result.key) || RSA.exportPrivatePem(args.domainKeypair) - , 'ascii' - ) - ]); - }).then(function () { - return mkdirpAsync(liveDir); - }).then(function () { - return PromiseA.all([ - sfs.writeFileAsync(certPath, result.cert, 'ascii') - , sfs.writeFileAsync(chainPath, (result.chain || result.ca), 'ascii') - , sfs.writeFileAsync(fullchainPath, result.fullchain, 'ascii') - , sfs.writeFileAsync( - privkeyPath - // TODO nix args.key, args.domainPrivateKeyPem ?? - , (result.privkey || result.key) || RSA.exportPrivatePem(args.domainKeypair) - , 'ascii' - ) - ]); - }).then(function () { - obj.checkpoints += 1; - args.checkpoints += 1; - - return writeRenewalConfig(args); - }).then(function () { - var getCertInfo = require('./cert-info').getCertInfo; - - // XXX Note: Parsing the certificate info comes at a great cost (~500kb) - var certInfo = getCertInfo(result.cert); - - return { - certPath: certPath - , chainPath: chainPath - , fullchainPath: fullchainPath - , privkeyPath: privkeyPath - - // TODO nix keypair - , keypair: args.domainKeypair - - // TODO nix args.key, args.domainPrivateKeyPem ?? - // some ambiguity here... - , privkey: (result.privkey || result.key) || RSA.exportPrivatePem(args.domainKeypair) - , fullchain: result.fullchain || (result.cert + '\n' + result.chain) - , chain: (result.chain || result.ca) - // especially this one... might be cert only, might be fullchain - , cert: result.cert - - , issuedAt: Date(certInfo.notBefore.value).valueOf() // Date.now() - , expiresAt: Date(certInfo.notAfter.value).valueOf() - , lifetime: defaults.lifetime || handlers.lifetime - }; - }); -} - -function getCertificateAsync(args, defaults, handlers) { - function log() { - if (args.debug || defaults.debug) { - console.log.apply(console, arguments); - } - } - - var account = args.account; - var promise; - var keypairOpts = { public: true, pem: true }; - - log('[le/core.js] domainKeyPath:', args.domainKeyPath); - - promise = fs.readFileAsync(args.domainKeyPath, 'ascii').then(function (pem) { - return RSA.import({ privateKeyPem: pem }); - }, function (/*err*/) { - return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { - return mkdirpAsync(path.dirname(args.domainKeyPath)).then(function () { - return fs.writeFileAsync(args.domainKeyPath, keypair.privateKeyPem, 'ascii').then(function () { - return keypair; + // TODO move templating of arguments to right here? + return le.store.accounts.setAsync(args, reg).then(function (account) { + // should now have account.id and account.accountId + args.account = account; + args.accountId = account.id; + return account; + }); + }); + }); + }); }); - }); - }); - }); - - 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 - - // - // 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] }); - tplCopy(copy); - - args.domains = [domain]; - args.webrootPath = args.webrootPath; - if (4 === handlers.setChallenge.length) { - handlers.setChallenge(copy, key, value, done); - } - else if (5 === handlers.setChallenge.length) { - handlers.setChallenge(copy, domain, key, value, done); - } - else { - done(new Error("handlers.setChallenge receives the wrong number of arguments")); - } } - , removeChallenge: function (domain, key, done) { - var copy = merge(defaults, { domains: [domain] }); - tplCopy(copy); - if (3 === handlers.removeChallenge.length) { - handlers.removeChallenge(copy, key, done); - } - else if (4 === handlers.removeChallenge.length) { - handlers.removeChallenge(copy, domain, key, done); - } - else { - done(new Error("handlers.removeChallenge receives the wrong number of arguments")); - } - } - }); - }).then(function (results) { - // { cert, chain, fullchain, privkey } - args.pems = results; - return writeCertificateAsync(args, defaults, handlers); - }); -} - -function getOrCreateDomainCertificate(args, defaults, handlers) { - if (args.duplicate) { - // we're forcing a refresh via 'dupliate: true' - return getCertificateAsync(args, defaults, handlers); - } - - return fetchFromConfigLiveDir(args).then(function (certs) { - var halfLife = (certs.expiresAt - certs.issuedAt) / 2; - - 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 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." - )); - }); -} - -// 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); - } - } - - var pyconf = PromiseA.promisifyAll(require('pyconf')); - - return pyconf.readFileAsync(args.renewalPath).then(function (renewal) { - var accountId = renewal.account; - renewal = renewal.account; - - return accountId; - }, function (err) { - if ("ENOENT" === err.code) { - log("[le/core.js] try email"); - return Accounts.getAccountIdByEmail(args, handlers); - } - - return PromiseA.reject(err); - }).then(function (accountId) { - - // Note: the ACME urls are always fetched fresh on purpose - return getAcmeUrls(args).then(function (urls) { - args._acmeUrls = urls; - - if (accountId) { - log('[le/core.js] use account'); - - args.accountId = accountId; - return Accounts.getAccount(args, handlers); - } else { - log('[le/core.js] create account'); - return Accounts.createAccount(args, handlers); - } - }); - }).then(function (account) { - /* - if (renewal.account !== account) { - // the account has become corrupt, re-register - return; - } - */ - log('[le/core.js] created account'); - return account; - }); -/* - return fs.readdirAsync(accountsDir, function (nodes) { - return PromiseA.all(nodes.map(function (node) { - var reMd5 = /[a-f0-9]{32}/i; - if (reMd5.test(node)) { - } - })); - }); -*/ -} - -module.exports.create = function (defaults, handlers) { - defaults.server = defaults.server || LE.liveServer; - - var wrapped = { - registerAsync: function (args) { - var copy; - // TODO move these defaults elsewhere? - //args.renewalDir = args.renewalDir || ':config/renewal/'; - args.renewalPath = args.renewalPath || ':config/renewal/:hostname.conf'; - // Note: the /directory is part of the server url and, as such, bleeds into the pathname - // So :config/accounts/:server/directory is *incorrect*, but the following *is* correct: - args.accountsDir = args.accountsDir || ':config/accounts/:server'; - copy = merge(args, defaults); - tplCopy(copy); - - var url = require('url'); - var acmeLocation = url.parse(copy.server); - var acmeHostpath = path.join(acmeLocation.hostname, acmeLocation.pathname); - copy.renewalPath = copy.renewalPath || path.join(copy.configDir, 'renewal', copy.domains[0] + '.conf'); - copy.accountsDir = copy.accountsDir || path.join(copy.configDir, 'accounts', acmeHostpath); - - return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { - copy.account = account; - - return getOrCreateRenewal(copy).then(function (pyobj) { - - copy.pyobj = pyobj; - return getOrCreateDomainCertificate(copy, defaults, handlers); + // Accounts + , getAsync: function (args) { + return core.accounts.checkAsync(args).then(function (account) { + if (account) { + return account; + } else { + return core.accounts.registerAsync(args); + } }); - }).then(function (result) { - return result; - }, function (err) { - return PromiseA.reject(err); - }); + } + + // Accounts + , 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 account; + }); + } } - , fetchAsync: function (args) { - var copy = merge(args, defaults); - tplCopy(copy); - return fetchFromConfigLiveDir(copy, defaults); - } - , configureAsync: function (hargs) { - hargs.renewalPath = hargs.renewalPath || ':config/renewal/:hostname.conf'; - var copy = merge(hargs, defaults); - tplCopy(copy); + , certificates: { + // Certificates + registerAsync: function (args) { + var err; + var copy = utils.merge(args, le); + args = utils.tplCopy(copy); - return getOrCreateAcmeAccount(copy, defaults, handlers).then(function (account) { - copy.account = account; - return getOrCreateRenewal(copy); - }); - } - , getConfigAsync: function (hargs) { - hargs.renewalPath = hargs.renewalPath || ':config/renewal/:hostname.conf'; - hargs.domains = []; + if (!Array.isArray(args.domains)) { + return PromiseA.reject(new Error('args.domains should be an array of domains')); + } - var copy = merge(hargs, defaults); - 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); + } + + // TODO renewal cb + // accountId and or email + return core.accounts.getAsync(copy).then(function (account) { + copy.account = account; + + //var account = args.account; + var keypairOpts = { public: true, pem: true }; + + var promise = le.store.certificates.checkKeypairAsync(args).then(function (keypair) { + if (keypair) { + return RSA.import(keypair); + } + + if (args.domainKeypair) { + return le.store.certificates.setKeypairAsync(args, RSA.import(args.domainKeypair)); + } + + return RSA.generateKeypairAsync(args.rsaKeySize, 65537, keypairOpts).then(function (keypair) { + keypair.privateKeyPem = RSA.exportPrivatePem(keypair); + keypair.publicKeyPem = RSA.exportPublicPem(keypair); + keypair.privateKeyJwk = RSA.exportPrivateJwk(keypair); + return le.store.certificates.setKeypairAsync(args, keypair); + }); + }); + + return promise.then(function (domainKeypair) { + args.domainKeypair = domainKeypair; + //args.registration = domainKey; + + // 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; + + var certReq = { + debug: args.debug || le.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) + // + // Each of these fires individually for each domain, + // even though the certificate on the whole may have many domains + // + certReq.setChallenge = function (domain, key, value, done) { + log(args.debug, "setChallenge called for '" + domain + "'"); + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); + + le.challenge.set(copy, domain, key, value, done); + }; + certReq.removeChallenge = function (domain, key, done) { + log(args.debug, "setChallenge called for '" + domain + "'"); + var copy = utils.merge({ domains: [domain] }, le); + utils.tplCopy(copy); + + le.challenge.remove(copy, domain, key, done); + }; + + log(args.debug, 'BEFORE GET CERT'); + log(args.debug, certReq); + + return le.acme.getCertificateAsync(certReq).then(utils.attachCertInfo); + }); + }).then(function (results) { + // { cert, chain, privkey } + + args.pems = results; + return le.store.certificates.setAsync(args).then(function () { + return results; + }); + }); + }); + } + // Certificates + , renewAsync: function (args, certs) { + var renewableAt = core.certificates._getRenewableAt(args, certs); + var err; + //var halfLife = (certs.expiresAt - certs.issuedAt) / 2; + //var renewable = (Date.now() - certs.issuedAt) > halfLife; + + log(args.debug, "(Renew) Expires At", new Date(certs.expiresAt).toISOString()); + log(args.debug, "(Renew) Renewable At", new Date(renewableAt).toISOString()); + + if (!args.duplicate && Date.now() < renewableAt) { + err = new Error( + "[ERROR] Certificate issued at '" + + new Date(certs.issuedAt).toISOString() + "' and expires at '" + + new Date(certs.expiresAt).toISOString() + "'. Ignoring renewal attempt until '" + + new Date(renewableAt).toISOString() + "'. Set { duplicate: true } to force." + ); + err.code = 'E_NOT_RENEWABLE'; + return PromiseA.reject(err); + } + + // Either the cert has entered its renewal period + // or we're forcing a refresh via 'dupliate: true' + log(args.debug, "Renewing!"); + + // TODO fetch email address / accountId (accountBydomain) if not present + // store.config.getAsync(args.domains).then(function (config) { /*...*/ }); + if (!args.domains || (args.domains.length || 0) <= 2) { + // this is a renewal, therefore we should renewal ALL of the domains + // associated with this certificate, unless args.domains is a list larger + // than example.com,www.example.com + // TODO check www. prefix + args.domains = certs.altnames; + if (Array.isArray(certs.domains) && certs.domains.length) { + args.domains = certs.domains; + } + } + + return core.certificates.registerAsync(args); + } + // Certificates + , _isRenewable: function (args, certs) { + var renewableAt = core.certificates._getRenewableAt(args, certs); + + log(args.debug, "Check Expires At", new Date(certs.expiresAt).toISOString()); + log(args.debug, "Check Renewable At", new Date(renewableAt).toISOString()); + + if (args.duplicate || Date.now() >= renewableAt) { + return true; + } + + return false; + } + , _getRenewableAt: function (args, certs) { + return certs.expiresAt - (args.renewWithin || le.renewWithin); + } + , checkAsync: function (args) { + var copy = utils.merge(args, le); + utils.tplCopy(copy); + + // returns pems + return le.store.certificates.checkAsync(copy).then(function (cert) { + if (cert) { + return utils.attachCertInfo(cert); + } - return readRenewalConfig(copy).then(function (pyobj) { - var exists = pyobj.checkpoints >= 0; - if (!exists) { return null; - } - - return pyobj; - }); - } - , getConfigsAsync: function (hargs) { - hargs.renewalDir = hargs.renewalDir || ':config/renewal/'; - hargs.renewalPath = hargs.renewalPath || ':config/renewal/:hostname.conf'; - hargs.domains = []; - - var copy = merge(hargs, defaults); - tplCopy(copy); - - return fs.readdirAsync(copy.renewalDir).then(function (nodes) { - nodes = nodes.filter(function (node) { - return /^[a-z0-9]+.*\.conf$/.test(node); }); + } + // Certificates + , getAsync: function (args) { + var copy = utils.merge(args, le); + args = utils.tplCopy(copy); - return PromiseA.all(nodes.map(function (node) { - copy.domains = [node.replace(/\.conf$/, '')]; - return wrapped.getConfigAsync(copy); - })); - }); + return core.certificates.checkAsync(args).then(function (certs) { + if (!certs) { + // There is no cert available + log(args.debug, "no certificate found"); + return core.certificates.registerAsync(args); + } + + if (core.certificates._isRenewable(args, certs)) { + certs._renewing = core.certificates.renewAsync(args, certs); + } + + return certs; + }).then(function (results) { + // returns pems + return results; + }); + } } + }; - return wrapped; + return core; }; diff --git a/lib/default-handlers.js b/lib/default-handlers.js deleted file mode 100644 index 17852a2..0000000 --- a/lib/default-handlers.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -var fs = require('fs'); -var path = require('path'); - -module.exports.agreeToTerms = function (args, agree) { - agree(null, args.agreeTos); -}; - -module.exports.setChallenge = function (args, challengePath, keyAuthorization, done) { - //var hostname = args.domains[0]; - var mkdirp = require('mkdirp'); - - // TODO should be args.webrootPath - //console.log('args.webrootPath, challengePath'); - //console.log(args.webrootPath, challengePath); - mkdirp(args.webrootPath, function (err) { - if (err) { - done(err); - return; - } - - fs.writeFile(path.join(args.webrootPath, challengePath), keyAuthorization, 'utf8', function (err) { - done(err); - }); - }); -}; - -module.exports.getChallenge = function (args, key, done) { - //var hostname = args.domains[0]; - - //console.log("getting the challenge", args, key); - fs.readFile(path.join(args.webrootPath, key), 'utf8', done); -}; - -module.exports.removeChallenge = function (args, key, done) { - //var hostname = args.domains[0]; - - fs.unlink(path.join(args.webrootPath, key), done); -}; diff --git a/lib/middleware.js b/lib/middleware.js new file mode 100644 index 0000000..5473a15 --- /dev/null +++ b/lib/middleware.js @@ -0,0 +1,54 @@ +'use strict'; + +var utils = require('./utils'); + +function log(debug) { + if (debug) { + var args = Array.prototype.slice.call(arguments); + args.shift(); + args.unshift("[le/lib/middleware.js]"); + console.log.apply(console, args); + } +} + +module.exports.create = function (le) { + if (!le.challenge || !le.challenge.get) { + throw new Error("middleware requires challenge plugin with get method"); + } + + log(le.debug, "created middleware"); + return function () { + var prefix = le.acmeChallengePrefix; // /.well-known/acme-challenge/:token + + return function (req, res, next) { + if (0 !== req.url.indexOf(prefix)) { + log(le.debug, "no match, skipping middleware"); + next(); + return; + } + + log(le.debug, "this must be tinder, 'cuz it's a match!"); + + var token = req.url.slice(prefix.length); + var hostname = req.hostname || (req.headers.host || '').toLowerCase().replace(/:.*/, ''); + + log(le.debug, "hostname", hostname, "token", token); + + var copy = utils.merge({ domains: [ hostname ] }, le); + copy = utils.tplCopy(copy); + + // TODO tpl copy? + le.challenge.get(copy, hostname, token, function (err, secret) { + if (err || !token) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end('{ "error": { "message": "Error: These aren\'t the tokens you\'re looking for. Move along." } }'); + return; + } + + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(secret); + }); + }; + }; +}; diff --git a/lib/renewal.conf.tpl b/lib/renewal.conf.tpl deleted file mode 100644 index ad7ae0a..0000000 --- a/lib/renewal.conf.tpl +++ /dev/null @@ -1,68 +0,0 @@ -#cert = :config/live/:hostname/cert.pem -cert = :cert_path -privkey = :privkey_path -chain = :chain_path -fullchain = :fullchain_path - -# Options and defaults used in the renewal process -[renewalparams] -apache_enmod = a2enmod -no_verify_ssl = False -ifaces = None -apache_dismod = a2dismod -register_unsafely_without_email = False -uir = None -installer = none -config_dir = :config -text_mode = True -# junk? -# https://github.com/letsencrypt/letsencrypt/issues/1955 -func = -prepare = False -work_dir = :work_dir -tos = :agree_tos -init = False -http01_port = :http_01_port -duplicate = False -# this is for the domain -key_path = :privkey_path -nginx = False -fullchain_path = :fullchain_path -email = :email -csr = None -agree_dev_preview = None -redirect = None -verbose_count = -3 -config_file = None -renew_by_default = True -hsts = False -authenticator = webroot -domains = :hostnames #comma,delimited,list -rsa_key_size = :rsa_key_size -# starts at 0 and increments at every renewal -checkpoints = -1 -manual_test_mode = False -apache = False -cert_path = :cert_path -webroot_path = :webroot_paths # comma,delimited,list -strict_permissions = False -apache_server_root = /etc/apache2 -# https://github.com/letsencrypt/letsencrypt/issues/1948 -account = :account_id -manual_public_ip_logging_ok = False -chain_path = :chain_path -standalone = False -manual = False -server = :acme_discovery_url -standalone_supported_challenges = "http-01,tls-sni-01" -webroot = True -apache_init_script = None -user_agent = None -apache_ctl = apache2ctl -apache_le_vhost_ext = -le-ssl.conf -debug = False -tls_sni_01_port = 443 -logs_dir = :logs_dir -configurator = None -[[webroot_map]] -# :hostname = :webroot_path diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..bf183ee --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,126 @@ +'use strict'; + +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').getBasicInfo; + // XXX Note: Parsing the certificate info comes at a great cost (~500kb) + var certInfo = getCertInfo(results.cert); + + // subject, altnames, issuedAt, expiresAt + Object.keys(certInfo).forEach(function (key) { + results[key] = certInfo[key]; + }); + + return results; +}; + +module.exports.isValidDomain = function (domain) { + if (re.test(domain)) { + return domain; + } + + domain = punycode.toASCII(domain); + + if (re.test(domain)) { + return domain; + } + + return ''; +}; + +module.exports.merge = function (/*defaults, args*/) { + var allDefaults = Array.prototype.slice.apply(arguments); + var args = allDefaults.shift(); + var copy = {}; + + allDefaults.forEach(function (defaults) { + Object.keys(defaults).forEach(function (key) { + copy[key] = defaults[key]; + }); + }); + + Object.keys(args).forEach(function (key) { + copy[key] = args[key]; + }); + + return copy; +}; + +module.exports.tplCopy = function (copy) { + var homedir = require('homedir')(); + var tplKeys; + + copy.hostnameGet = function (copy) { + return (copy.domains || [])[0] || copy.domain; + }; + + Object.keys(copy).forEach(function (key) { + var newName; + if (!/Get$/.test(key)) { + return; + } + + newName = key.replace(/Get$/, ''); + copy[newName] = copy[newName] || copy[key](copy); + }); + + tplKeys = Object.keys(copy); + tplKeys.sort(function (a, b) { + return b.length - a.length; + }); + + tplKeys.forEach(function (key) { + if ('string' !== typeof copy[key]) { + return; + } + + copy[key] = copy[key].replace(homeRe, homedir + path.sep); + }); + + tplKeys.forEach(function (key) { + if ('string' !== typeof copy[key]) { + return; + } + + tplKeys.forEach(function (tplname) { + if (!copy[tplname]) { + // what can't be templated now may be templatable later + return; + } + copy[key] = copy[key].replace(':' + tplname, copy[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/package.json b/package.json index 70f9046..8f18824 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "letsencrypt", - "version": "1.5.1", + "version": "2.0.1", "description": "Let's Encrypt for node.js on npm", "main": "index.js", "scripts": { @@ -29,19 +29,17 @@ "url": "https://github.com/Daplie/node-letsencrypt/issues" }, "homepage": "https://github.com/Daplie/node-letsencrypt#readme", - "devDependencies": { - "express": "^4.13.3", - "localhost.daplie.com-certificates": "^1.1.2" - }, + "devDependencies": {}, "optionalDependencies": {}, "dependencies": { + "asn1": "^0.2.3", "bluebird": "^3.0.6", "homedir": "^0.6.0", - "letiny-core": "^2.0.1", - "mkdirp": "^0.5.1", - "pyconf": "^1.1.2", - "request": "^2.67.0", - "rsa-compat": "^1.2.1", - "safe-replace": "^1.0.2" + "le-acme-core": "^2.0.5", + "le-challenge-fs": "^2.0.2", + "le-store-certbot": "^2.0.1", + "node.extend": "^1.1.5", + "pkijs": "^1.3.27", + "rsa-compat": "^1.2.1" } } diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 277de9d..0000000 --- a/tests/README.md +++ /dev/null @@ -1,9 +0,0 @@ -moved the tests to the examples folder - -```bash -node examples/commandline.js example.com,www.example.com user@example.com agree -``` - -Try it for yourself. - -Go watch [Let's Encrypt in (exactly) 90 seconds](https://daplie.com/articles/lets-encrypt-in-literally-90-seconds/) and swap out the Caddy instructions with the node instructions. diff --git a/tests/acme-challenge/.gitkeep b/tests/acme-challenge/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/acme-challenge/hello b/tests/acme-challenge/hello deleted file mode 100644 index ce01362..0000000 --- a/tests/acme-challenge/hello +++ /dev/null @@ -1 +0,0 @@ -hello diff --git a/tests/cert-info.js b/tests/cert-info.js new file mode 100644 index 0000000..ddab280 --- /dev/null +++ b/tests/cert-info.js @@ -0,0 +1,27 @@ +'use strict'; + +var certInfo = require('../lib/cert-info.js'); + +var c = certInfo.testGetCertInfo(); + +console.info(''); + +console.info(c.notBefore.value); +console.info(new Date(c.notBefore.value).valueOf()); + +console.info(''); + +console.info(c.notAfter.value); +console.info(new Date(c.notAfter.value).valueOf()); + +console.info(''); + +var json = certInfo.testBasicCertInfo(); + +console.log(''); +console.log(JSON.stringify(json, null, ' ')); +console.log(''); + +console.info(''); +console.info('If we got values at all, it must have passed.'); +console.info(''); diff --git a/tests/challenge-middleware.js b/tests/challenge-middleware.js new file mode 100644 index 0000000..917ef83 --- /dev/null +++ b/tests/challenge-middleware.js @@ -0,0 +1,106 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var path = require('path'); +var requestAsync = PromiseA.promisify(require('request')); +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'.split('/').join(path.sep) + , webrootPath: '~/letsencrypt.test/var/:hostname'.split('/').join(path.sep) + }) +, challenge: require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt.test/var/:hostname'.split('/').join(path.sep) + }) +, debug: true +}); +var utils = require('../lib/utils'); + +if ('/.well-known/acme-challenge/' !== LE.acmeChallengePrefix) { + throw new Error("Bad constant 'acmeChallengePrefix'"); +} + +var baseUrl; +// could use localhost as well, but for the sake of an FQDN for testing, we use this +// also, example.com is just a junk domain to make sure that it is ignored +// (even though it should always be an array of only one element in lib/core.js) +var domains = [ 'localhost.daplie.com', 'example.com' ]; // or just localhost +var token = 'token-id'; +var secret = 'key-secret'; + +var tests = [ + function () { + console.log('Test Url:', baseUrl + token); + return requestAsync({ url: baseUrl + token }).then(function (req) { + if (404 !== req.statusCode) { + console.log(req.statusCode); + throw new Error("Should be status 404"); + } + }); + } + +, function () { + var copy = utils.merge({ domains: domains }, le); + copy = utils.tplCopy(copy); + return PromiseA.promisify(le.challenge.set)(copy, domains[0], token, secret); + } + +, function () { + return requestAsync(baseUrl + token).then(function (req) { + if (200 !== req.statusCode) { + console.log(req.statusCode, req.body); + throw new Error("Should be status 200"); + } + + if (req.body !== secret) { + console.error(token, secret, req.body); + throw new Error("req.body should be secret"); + } + }); + } + +, function () { + var copy = utils.merge({ domains: domains }, le); + copy = utils.tplCopy(copy); + return PromiseA.promisify(le.challenge.remove)(copy, domains[0], token); + } + +, function () { + return requestAsync(baseUrl + token).then(function (req) { + if (404 !== req.statusCode) { + console.log(req.statusCode); + throw new Error("Should be status 404"); + } + }); + } +]; + +function run() { + //var express = require(express); + var server = require('http').createServer(le.middleware()); + server.listen(0, function () { + console.log('Server running, proceeding to test.'); + baseUrl = 'http://' + domains[0] + ':' + server.address().port + LE.acmeChallengePrefix; + + function next() { + var test = tests.shift(); + if (!test) { + console.info('All tests passed'); + server.close(); + return; + } + + test().then(next, function (err) { + console.error('ERROR'); + console.error(err.stack); + server.close(); + }); + } + + next(); + }); +} + +run(); diff --git a/tests/check-account.js b/tests/check-account.js new file mode 100644 index 0000000..4dfaaa7 --- /dev/null +++ b/tests/check-account.js @@ -0,0 +1,56 @@ +'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/' + , webrootPath: '~/letsencrypt.test/tmp/:hostname' + }) +, debug: true +}); + +// TODO test generateRsaKey code path separately +// and then provide opts.accountKeypair to create account + +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; +var testAccountId = '939573edbf2506c92c9ab32131209d7b'; + +var tests = [ + function () { + return le.core.accounts.checkAsync({ + accountId: testAccountId + }).then(function (account) { + if (!account) { + throw new Error("Test account should exist when searched by account id."); + } + }); + } + +, function () { + return le.core.accounts.checkAsync({ + email: testEmail + }).then(function (account) { + console.log('account.regr'); + console.log(account.regr); + if (!account) { + throw new Error("Test account should exist when searched by email."); + } + }); + } +]; + +function run() { + var test = tests.shift(); + if (!test) { + console.info('All tests passed'); + return; + } + + test().then(run); +} + +run(); diff --git a/tests/config.js b/tests/config.js deleted file mode 100644 index 6cab74b..0000000 --- a/tests/config.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -var path = require('path'); - -module.exports = { - server: "https://acme-staging.api.letsencrypt.org/directory" -, tlsSni01Port: 5001 -, http01Port: 80 -, webrootPath: path.join(__dirname, "acme-challenge") -, configDir: path.join(__dirname, "letsencrypt.config") -, workDir: path.join(__dirname, "letsencrypt.work") -, logsDir: path.join(__dirname, "letsencrypt.logs") -, allowedDomains: ['example.com'] -}; diff --git a/tests/create-account.js b/tests/create-account.js new file mode 100644 index 0000000..d648770 --- /dev/null +++ b/tests/create-account.js @@ -0,0 +1,105 @@ +'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/' + , webrootPath: '~/letsencrypt.test/tmp/:hostname' + }) +, debug: true +}); + +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; +var fakeEmail = 'coolaj86+le.' + testId + '@example.com'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.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 () { + return le.core.accounts.registerAsync({ + email: testEmail + , agreeTos: true + , rsaKeySize: 2048 + }).then(function (account) { + testAccount = account; + + console.log(testEmail); + console.log(testAccount); + + 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 run() { + var test = tests.shift(); + if (!test) { + console.info('All tests passed'); + return; + } + + test().then(run); +} + +run(); diff --git a/tests/letsencrypt.config/.gitkeep b/tests/letsencrypt.config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/letsencrypt.logs/.gitkeep b/tests/letsencrypt.logs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/letsencrypt.work/.gitkeep b/tests/letsencrypt.work/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/pyconf-write.js b/tests/pyconf-write.js deleted file mode 100644 index 98bc3a6..0000000 --- a/tests/pyconf-write.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -var PromiseA = require('bluebird'); -var pyconf = PromiseA.promisifyAll(require('pyconf')); -var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -var path = require('path'); - -pyconf.readFileAsync(path.join(__dirname, 'lib', 'renewal.conf.tpl')).then(function (obj) { - var domains = ['example.com', 'www.example.com']; - var webrootPath = '/tmp/www/example.com'; - - console.log(obj); - - var keys = obj.__keys; - var lines = obj.__lines; - - obj.__keys = null; - obj.__lines = null; - - var updates = { - account: 'ACCOUNT_ID' - - , cert: 'CERT_PATH' - , privkey: 'PRIVATEKEY_PATH' - , configDir: 'CONFIG_DIR' - , tos: true - , http01Port: 80 - , domains: domains - }; - - // final section is completely dynamic - // :hostname = :webroot_path - domains.forEach(function (hostname) { - updates[hostname] = webrootPath; - }); - - // must write back to the original object or - // annotations will be lost - Object.keys(updates).forEach(function (key) { - obj[key] = updates[key]; - }); - - var renewalPath = '/tmp/letsencrypt/renewal/example.com.conf'; - return mkdirpAsync(path.dirname(renewalPath)).then(function () { - console.log(obj); - obj.__keys = keys; - obj.__lines = lines; - return pyconf.writeFileAsync(renewalPath, obj); - }); -}); diff --git a/tests/register-certificate.js b/tests/register-certificate.js new file mode 100644 index 0000000..381f97b --- /dev/null +++ b/tests/register-certificate.js @@ -0,0 +1,74 @@ +'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' + , webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, challenge: require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, debug: true +}); + +// TODO test generateRsaKey code path separately +// and then provide opts.accountKeypair to create account + +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; +// TODO integrate with Daplie Domains for junk domains to test with +var testDomains = [ 'pokemap.hellabit.com', 'www.pokemap.hellabit.com' ]; + +var tests = [ + function () { + return le.core.certificates.checkAsync({ + domains: [ 'example.com', 'www.example.com' ] + }).then(function (cert) { + if (cert) { + throw new Error("Bogus domain should not have certificate."); + } + }); + } + +, function () { + return le.core.certificates.getAsync({ + email: testEmail + , domains: testDomains + }).then(function (certs) { + if (!certs) { + throw new Error("Should have acquired certificate for domains."); + } + }); + } +]; + +function run() { + //var express = require(express); + var server = require('http').createServer(le.middleware()); + server.listen(80, function () { + console.log('Server running, proceeding to test.'); + + function next() { + var test = tests.shift(); + if (!test) { + server.close(); + console.info('All tests passed'); + return; + } + + test().then(next, function (err) { + console.error('ERROR'); + console.error(err.stack); + server.close(); + }); + } + + next(); + }); +} + +run(); diff --git a/tests/renew-certificate.js b/tests/renew-certificate.js new file mode 100644 index 0000000..58a4ae8 --- /dev/null +++ b/tests/renew-certificate.js @@ -0,0 +1,102 @@ +'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' + , webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, challenge: require('le-challenge-fs').create({ + webrootPath: '~/letsencrypt.test/var/:hostname' + }) +, debug: true +}); + +// TODO test generateRsaKey code path separately +// and then provide opts.accountKeypair to create account + +//var testId = Math.round(Date.now() / 1000).toString(); +var testId = 'test1000'; +var testEmail = 'coolaj86+le.' + testId + '@gmail.com'; +// TODO integrate with Daplie Domains for junk domains to test with +var testDomains = [ 'pokemap.hellabit.com', 'www.pokemap.hellabit.com' ]; +var testCerts; + +var tests = [ + function () { + // TODO test that an altname also fetches the proper certificate + return le.core.certificates.checkAsync({ + domains: testDomains + }).then(function (certs) { + if (!certs) { + throw new Error("Either certificates.registerAsync (in previous test)" + + " or certificates.checkAsync (in this test) failed."); + } + + testCerts = certs; + console.log('Issued At', new Date(certs.issuedAt).toISOString()); + console.log('Expires At', new Date(certs.expiresAt).toISOString()); + + if (certs.expiresAt <= Date.now()) { + throw new Error("Certificates are already expired. They cannot be tested for duplicate or forced renewal."); + } + }); + } + +, function () { + return le.core.certificates.renewAsync({ + email: testEmail + , domains: testDomains + }, testCerts).then(function () { + throw new Error("Should not have renewed non-expired certificates."); + }, function (err) { + if ('E_NOT_RENEWABLE' !== err.code) { + throw err; + } + }); + } + +, function () { + return le.core.certificates.renewAsync({ + email: testEmail + , domains: testDomains + , renewWithin: 720 * 24 * 60 * 60 * 1000 + }, testCerts).then(function (certs) { + console.log('Issued At', new Date(certs.issuedAt).toISOString()); + console.log('Expires At', new Date(certs.expiresAt).toISOString()); + + if (certs.issuedAt === testCerts.issuedAt) { + throw new Error("Should not have returned existing certificates."); + } + }); + } +]; + +function run() { + //var express = require(express); + var server = require('http').createServer(le.middleware()); + server.listen(80, function () { + console.log('Server running, proceeding to test.'); + + function next() { + var test = tests.shift(); + if (!test) { + server.close(); + console.info('All tests passed'); + return; + } + + test().then(next, function (err) { + console.error('ERROR'); + console.error(err.stack); + server.close(); + }); + } + + next(); + }); +} + +run();