From e98e53e0a7b1b7dc4a8b05174a8ea0b8d694fa37 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 6 Apr 2019 02:36:52 -0600 Subject: [PATCH] v3.0.3: cleanup and consistency (with other refs) --- README.md | 101 ++++++++++++++-------------- index.js | 165 ++++++++++++++++++++++++++++++++-------------- package-lock.json | 5 ++ package.json | 5 +- test.js | 85 ++++++++---------------- 5 files changed, 206 insertions(+), 155 deletions(-) create mode 100644 package-lock.json diff --git a/README.md b/README.md index 0ae9ac8..8590323 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# le-challenge-dns +# [le-challenge-dns](https://git.coolaj86.com/coolaj86/le-challenge-dns.js) -| A [Root](https://rootprojects.org) Project -| [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js) (library) -| [greenlock-express.js](https://git.coolaj86.com/coolaj86/greenlock-express.js) -| [greenlock-cli.js](https://git.coolaj86.com/coolaj86/greenlock-cli.js) -| [acme-v2.js](https://git.coolaj86.com/coolaj86/acme-v2.js) -| +| A [Root](https://rootprojects.org) Project | -A manual (interactive CLI) dns-based strategy for greenlock.js for setting, retrieving, -and clearing ACME DNS-01 challenges issued by the ACME server - -Prints out a subdomain record for `_acme-challenge` with `keyAuthDigest` -to be tested by the ACME server. - -You can then update your DNS manually by whichever method you use and then -press [enter] to continue the process. +An extremely simple reference implementation +of an ACME (Let's Encrypt) dns-01 challenge strategy +for [Greenlock](https://git.coolaj86.com/coolaj86/greenlock-express.js) v2.7+ (and v3). ``` _acme-challenge.example.com TXT xxxxxxxxxxxxxxxx TTL 60 ``` +* Prints the ACME challenge DNS Host and DNS Key Authorization Digest to the terminal + * (waits for you to hit enter before continuing) +* Let's you know when the challenge as succeeded or failed, and is safe to remove. + +Other ACME Challenge Reference Implementations: + +* [le-challenge-manual](https://git.coolaj86.com/coolaj86/le-challenge-manual.js.git) +* [le-challenge-http](https://git.coolaj86.com/coolaj86/le-challenge-http.js.git) +* [**le-challenge-dns**](https://git.coolaj86.com/coolaj86/le-challenge-dns.js.git) + ## Install ```bash @@ -30,38 +30,21 @@ If you have `greenlock@v2.6` or lower, you'll need the old `le-challenge-dns@3.x ## Usage -The challenge can be set globally like this: - -```js -var leChallengeDns = require('le-challenge-dns').create({ - debug: false -}); - +```bash var Greenlock = require('greenlock'); Greenlock.create({ ... -, challenges: { - 'dns-01': leChallengeDns - } -, approveDomains: [ 'example.com', '*.example.com' ] +, challenges: { 'http-01': require('le-challenge-http') + , 'dns-01': require('le-challenge-dns').create({ debug: true }) + , 'tls-alpn-01': require('le-challenge-manual') + } + ... }); ``` -In can also be set in the `approveDomains` callback instead, like this: - -```js -function approveDomains(opts, certs, cb) { - ... - opts.subject = 'example.com' - opts.domains = [ 'example.com', '*.example.com' ]; - - cb(null, { options: opts, certs: certs }); -} -``` - -If you didn't make the dns challenge globally available in the main greenlock config, -you can make it locally available here: +You can also switch between different implementations by +overwriting the default with the one that you want in `approveDomains()`: ```js function approveDomains(opts, certs, cb) { @@ -78,14 +61,18 @@ function approveDomains(opts, certs, cb) { NOTE: If you request a certificate with 6 domains listed, it will require 6 individual challenges. -## Exposed Methods + +## Exposed (Promise) Methods For ACME Challenge: -* `set(opts, done)` -* `remove(opts, done)` +* `set(opts)` +* `remove(opts)` -The options object has whatever options were set in `approveDomains()` as well as the `challenge`: +The `dns-01` strategy supports wildcards (whereas `http-01` does not). + +The options object has whatever options were set in `approveDomains()` +as well as the `challenge`, which looks like this: ```js { challenge: { @@ -96,15 +83,33 @@ The options object has whatever options were set in `approveDomains()` as well a , token: 'xxxxxx' , keyAuthorization: 'xxxxxx.abc123' , dnsHost: '_acme-challenge.example.com' - , dnsAuthorization: 'abc123' + , dnsAuthorization: 'xyz567' , expires: '1970-01-01T00:00:00Z' } } ``` -Note: There's no `get()` because it's the DNS server, not the Greenlock server, that answers the requests. -(though I suppose you could implement it if you happen to run your DNS and webserver together... kinda weird though) - For greenlock.js internals: * `options` stores the internal defaults merged with the user-supplied options + +Optional: + +* `get(limitedOpts)` + +Note: Typically there wouldn't be a `get()` for DNS because the NameServer (not Greenlock) answers the requests. +It could be used for testing implementations, but that's about it. +(though I suppose you could implement it if you happen to run your DNS and webserver together... kinda weird though) + +If there were an implementation of Greenlock integrated directly into +a NameServer (which currently there is not), it would probably look like this: + +```js +{ challenge: { + type: 'dns-01' + , identifier: { type: 'dns', value: 'example.com' } + , token: 'abc123' + , dnsHost: '_acme-challenge.example.com' + } +} +``` diff --git a/index.js b/index.js index 04781c7..19e94a2 100644 --- a/index.js +++ b/index.js @@ -1,68 +1,137 @@ 'use strict'; +/*global Promise*/ var Challenge = module.exports; -Challenge.create = function (defaults) { - // if you need special options that apply to all domains, you could set them here. - return { - options: defaults - , set: Challenge.set - , get: Challenge.get - , remove: Challenge.remove +// If your implementation needs config options, set them. Otherwise, don't bother (duh). +Challenge.create = function (config) { + + var challenger = {}; + + // Note: normally you'd these right in the method body, but for the sake of + // "Table of Contents"-style documentation, I've pulled them out. + + // Note: All of these methods can be synchronous, async, Promise, and callback-style + // (the calling functions check function.length and then Promisify accordingly) + + // Called when it's tiem to set the challenge + challenger.set = function (opts, cb) { + return Challenge._setDns(opts, cb); }; + + // Called when it's time to remove the challenge + challenger.remove = function (opts) { + return Challenge._removeDns(opts); + }; + + // Optional (only really useful for http) + // Called when the challenge needs to be retrieved + challenger.get = function (opts) { + return Challenge._getDns(opts); + }; + + // Whatever you assign to 'options' will be merged into the incoming 'opts' beforehand + // (for convenience, so you don't have to do the if (!x) { x = y; } dance) + // (also, some defaults are layered, so it's good to set it any that you have) + challenger.options = { debug: config.debug }; + + return challenger; }; + // Show the user the token and key and wait for them to be ready to continue -Challenge.set = function (args, cb) { +Challenge._setDns = function (args, cb) { // if you need per-run / per-domain options set them in approveDomains() and they'll be on 'args' here. if (!args.challenge) { - console.error("please update to greenlock v2.7+"); + console.error("You must be using Greenlock v2.7+ to use le-challenge-dns v3+"); process.exit(); } - var opts = args.challenge; + var ch = args.challenge; - if (this.leDnsResponse) { - this.leDnsResponse(opts.token, opts.keyAuthorization, opts.dnsAuthorization, opts.dnsHost, opts.altname) - .then(function (/*successMessage*/) { - cb(null); - }); - } else { + console.info(""); + console.info("[ACME dns-01 '" + ch.altname + "' CHALLENGE]"); + console.info("You're about to receive the following DNS query:"); + console.info(""); + console.info("\tTXT\t" + ch.dnsHost + "\t" + ch.dnsAuthorization + "\tTTL 60"); + console.info(""); + if (ch.debug) { + console.info("Debug Info:"); console.info(""); - console.info("We now present (for your copy-and-paste pleasure)..."); - console.info("DNS-01 ACME (Let's Encrypt) Challenge for '" + opts.altname + "'"); + console.info(JSON.stringify(dnsChallengeToJson(ch), null, ' ').replace(/^/gm, '\t')); console.info(""); - console.info(opts.dnsHost + "\tTXT " + opts.dnsAuthorization + "\tTTL 60"); - console.info(""); - console.info(JSON.stringify({ - identifier: opts.identifier - , wildcard: opts.wildcard - , altname: opts.altname - , type: opts.type - , token: opts.token - , keyAuthorization: opts.keyAuthorization - , dnsHost: opts.dnsHost - , dnsAuthorization: opts.dnsAuthorization - , expires: opts.expires - }, null, ' ').replace(/^/gm, '\t')); - console.info(""); - console.info("Insert quarter, er... I mean hit the any key to continue..."); - process.stdin.resume(); - process.stdin.on('data', function () { - process.stdin.pause(); - cb(null); - }); } -}; - -// nothing to do here (that's the dns server's job), that's why it's manual -Challenge.get = function (defaults, cb) { - // defaults.challenge - cb(null); + console.info("Go set that DNS record, wait a few seconds for it to propagate, and then continue when ready"); + console.info("[Press the ANY key to continue...]"); + process.stdin.resume(); + process.stdin.once('data', function () { + process.stdin.pause(); + cb(null); + }); }; // might as well tell the user that whatever they were setting up has been checked -Challenge.remove = function (args, cb) { - console.info("Success. You may now remove the DNS-01 challenge record:"); - console.info("\t" + args.challenge.altname + "\tTXT\t" + args.challenge.dnsAuthorization); - cb(null); +Challenge._removeDns = function (args) { + var ch = args.challenge; + console.info(""); + console.info("[ACME http-01 '" + ch.altname + "' COMPLETE]: " + ch.status); + console.info("Challenge complete. You may now remove the DNS-01 challenge record:"); + console.info(""); + console.info("\tTXT\t" + args.challenge.altname + "\t" + args.challenge.dnsAuthorization); + console.info(""); + + return null; }; + +// This is implemented here for completeness (and perhaps some possible use in testing), +// but it's not something you would implement because the Greenlock server isn't the NameServer. +Challenge._getDns = function (args) { + var ch = args.challenge; + + if (!Challenge._getCache[ch.altname + ':' + ch.token]) { + Challenge._getCache[ch.altname + ':' + ch.token] = true; + console.info(""); + console.info("[ACME " + ch.type + " '" + ch.altname + "' REQUEST]: " + ch.status); + console.info("The '" + ch.type + "' challenge request has arrived!"); + console.info('dig TXT ' + ch.dnsHost); + console.info("(paste in the \"DNS Authorization\" you received a moment ago to respond)"); + process.stdout.write("> "); + } + + return new Promise(function (resolve, reject) { + process.stdin.resume(); + process.stdin.once('error', reject); + process.stdin.once('data', function (chunk) { + process.stdin.pause(); + + var result = chunk.toString('utf8'); + try { + result = JSON.parse(result); + } catch(e) { + args.challenge.dnsAuthorization = result; + result = args.challenge; + } + if (result.dnsAuthorization) { + resolve(result); + return; + } + + // The return value will checked. It must not be 'undefined'. + resolve(null); + }); + }); +}; + +function dnsChallengeToJson(ch) { + return { + type: ch.type + , altname: ch.altname + , identifier: ch.identifier + , wildcard: ch.wildcard + , expires: ch.expires + , token: ch.token + , thumbprint: ch.thumbprint + , keyAuthorization: ch.keyAuthorization + , dnsHost: ch.dnsHost + , dnsAuthorization: ch.dnsAuthorization + }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ea035dd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "le-challenge-dns", + "version": "3.0.3", + "lockfileVersion": 1 +} diff --git a/package.json b/package.json index c3cd783..5c08038 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "le-challenge-dns", - "version": "3.0.2", + "version": "3.0.3", "description": "A manual (interactive CLI) dns-based strategy for Greenlock / Let's Encrypt / ACME DNS-01 challenges", "main": "index.js", "files": [], @@ -31,6 +31,5 @@ "url": "https://git.coolaj86.com/coolaj86/le-challenge-dns.js/issues" }, "homepage": "https://git.coolaj86.com/coolaj86/le-challenge-dns.js", - "dependencies": { - } + "dependencies": {} } diff --git a/test.js b/test.js index 3a5e197..927df90 100644 --- a/test.js +++ b/test.js @@ -1,63 +1,36 @@ 'use strict'; +/*global Promise*/ -var PromiseA = require('bluebird'); -var resolveTxtAsync = PromiseA.promisify(require('dns').resolveTxt); -var Challenge = require('./'); -var leChallengeDns = Challenge.create({ }); -var opts = leChallengeDns.getOptions(); -var domain = 'test.example.com'; -var challenge = 'xxx-acme-challenge-xxx'; -var keyAuthorization = 'xxx-acme-challenge-xxx.xxx-acme-authorization-xxx'; +var challenge = require('./').create({}); -Challenge.loopback = function (defaults, domain, challenge, done) { - var challengeDomain = (defaults.test || '') + defaults.acmeChallengeDns + domain; - console.log("dig TXT +noall +answer @8.8.8.8 '" + challengeDomain + "' # " + challenge); - resolveTxtAsync(challengeDomain).then(function (x) { done(null, x); }, done); -}; +var opts = challenge.getOptions && challenge.getOptions() || challenge.options; -Challenge.test = function (args, domain, challenge, keyAuthorization, done) { - var me = this; - - args.test = args.test || '_test.'; - //defaults.test = args.test; - - me.set(args, domain, challenge, keyAuthorization || challenge, function (err, k) { - if (err) { done(err); return; } - - me.loopback(/*defaults*/args, domain, challenge, function (err, arr) { - if (err) { done(err); return; } - - if (!arr.some(function (a) { - return a.some(function (keyAuthDigest) { - return keyAuthDigest === k; - }); - })) { - err = new Error("txt record '" + challenge + "' doesn't match '" + k + "'"); - } - - me.remove(/*defaults*/args, domain, challenge, function (_err) { - if (_err) { done(_err); return; } - - // TODO needs to use native-dns so that specific nameservers can be used - // (otherwise the cache will still have the old answer) - done(err || null); - /* - me.loopback(defaults, domain, challenge, function (err) { - if (err) { done(err); return; } - - done(); - }); - */ - }); +function run() { + // this will cause the prompt to appear + return new Promise(function (resolve, reject) { + challenge.set(opts, function () { + // this will cause the final completion message to appear + return Promise.resolve(challenge.remove(opts)).then(resolve).catch(reject); }); }); +} + +opts.challenge = { + type: 'http-01' +, identifier: { type: 'dns', value: 'example.com' } +, wildcard: false +, expires: '2012-01-01T12:00:00.000Z' +, token: 'abc123' +, thumbprint: '<>' +, keyAuthorization: 'abc123.xxxx' +, dnsHost: '_acme-challenge.example.com' +, dnsAuthorization: 'yyyy' +, altname: 'example.com' }; - -setTimeout(function () { - leChallengeDns.test(opts, domain, challenge, keyAuthorization, function (err) { - // if there's an error, there's a problem - if (err) { throw err; } - - console.log('test passed'); - }); -}, 300); +run(opts).then(function () { + console.info("PASS"); +}).catch(function (err) { + console.error("FAIL"); + console.error(err); + process.exit(18); +});