From bda3a62e28c9848aa886d03060d514f8f56e0af9 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 5 Apr 2019 23:11:00 -0600 Subject: [PATCH] v3.0.0: update for greenlock v2.7+ --- README.md | 99 +++++++++++++++++++---- index.js | 224 +++++++++++++++++++++++++++++++++++++++++++++------ package.json | 2 +- test.js | 56 ++++++++++--- 4 files changed, 324 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index bad6a2b..2c3e238 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ le-challenge-manual A [Root](https://rootprojects.org) Project -A manual cli-based strategy for node-letsencrypt. +A manual cli-based strategy for [Greenlock](https://git.coolaj86.com/coolaj86/greenlock-express.js) v2.7+ (and v3). Prints the ACME challenge Token and Key and then waits for you to hit enter before continuing. @@ -18,37 +18,102 @@ Install ------- ```bash -npm install --save le-challenge-manual@2.x +npm install --save le-challenge-manual@3.x ``` Usage ----- ```bash -var leChallenge = require('le-challenge-manual').create({ -, debug: false -}); +var Greenlock = require('greenlock'); -var LE = require('letsencrypt'); - -LE.create({ - server: LE.stagingServerUrl -, challenge: leChallenge +Greenlock.create({ + ... +, challenges: { 'http-01': require('le-challenge-manual') + , 'dns-01': require('le-challenge-manual') + , 'tls-alpn-01': require('le-challenge-manual') + } + ... }); ``` -NOTE: If you request a certificate with 6 domains listed, +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, domain, key, val, done)` -* `get(defaults, domain, key, done)` -* `remove(defaults, domain, key, done)` +* `set(opts)` +* `remove(opts)` -For node-letsencrypt internals: +The options will look like this for normal domains: -* `getOptions()` returns the internal defaults merged with the user-supplied options +```js +{ 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' + } +} +``` + +And they'll look like this for wildcard domains: + +```js +{ challenge: { + type: 'http-01' + , identifier: { type: 'dns', value: 'example.com' } + , wildcard: true + , expires: '2012-01-01T12:00:00.000Z' + , token: 'abc123' + , thumbprint: '<>' + , keyAuthorization: 'abc123.xxxx' + , dnsHost: '_acme-challenge.example.com' + , dnsAuthorization: 'yyyy' + , altname: '*.example.com' + } +} +``` + +The only difference is that `altname` will have the `*.` prefix (which you would expect +but, of course, can't work as a specific a DNS record) and the `wildcard` property is `true`. + +Optional + +* `get(limitedOpts)` + +Because the get method is apart from the main flow (such as a DNS query), +it's not always implemented and the options are much more limited in scope: + +```js +{ challenge: { + type: 'http-01' + , identifier: { type: 'dns', value: 'example.com' } + , wildcard: false + , token: 'abc123' + , altname: 'example.com' + } +} +``` + +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 a438f1f..ca1a2b1 100644 --- a/index.js +++ b/index.js @@ -1,35 +1,132 @@ 'use strict'; +/*global Promise*/ var Challenge = module.exports; -Challenge.create = function (defaults) { - return { - getOptions: function () { - return defaults; +// IMPORTANT +// +// These are all PROMISIFIED by Greenlock in such a way that +// it doesn't matter whether you return synchronously, asynchronously, +// or even node-style callback thunk. +// +// Typically you should be using a promise or async function, +// but choose whichever makes sense for you. +Challenge.create = function (config) { + // If your implementation needs config options, set them. Otherwise, don't bother (duh). + + var challenger = {}; + + // Note: normally you'd implement these right here, but for the sake of + // documentation I've abstracted them out "Table of Contents"-style. + + // call out to set the challenge, wherever + challenger.set = function (opts, cb) { + var ch = opts.challenge; + if ('http-01' === ch.type) { + return Challenge._setHttp(opts, cb); + } else if ('dns-01' === ch.type) { + return Challenge._setDns(opts, cb); + } else { + return Challenge._setAny(opts, cb); } - , set: Challenge.set - , get: Challenge.get - , remove: Challenge.remove }; + + // call out to remove the challenge, wherever + challenger.remove = function (opts) { + var ch = opts.challenge; + if ('http-01' === ch.type) { + return Challenge._removeHttp(opts); + } else if ('dns-01' === ch.type) { + return Challenge._removeDns(opts); + } else { + return Challenge._removeAny(opts); + } + }; + + // only really useful for http, + // but probably not so much in this context... + // (though you can test it and it'll work) + challenger.get = function (opts) { + var ch = opts.challenge; + if ('http-01' === ch.type) { + return Challenge._getHttp(opts); + } else if ('dns-01' === ch.type) { + return Challenge._getDns(opts); + } else { + return Challenge._getAny(opts); + } + }; + + // Whatever you set to 'options' will be merged into 'opts' just before each call + // (for convenience, so you don't have to merge it yourself). + 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, domain, token, secret, cb) { +Challenge._setHttp = function (args, cb) { + // Using a node-style callback "thunk" in this example, because that makes + + var ch = args.challenge; + console.info("[ACME http-01 '" + ch.altname + "' CHALLENGE]"); + console.info("Your mission (since you chose to accept it):"); + console.info("First, you must create a file with the following name and contents."); + console.info("Then, by any means necessary, you cause that file to appear at the specified URL."); console.info(""); - console.info("Challenge for '" + domain + "'"); + console.info("\tFilename: " + ch.token); + console.info("\tContents: " + ch.keyAuthorization); + // TODO let acme-v2 handle generating this url + console.info('\tURL: http://' + ch.altname + '/.well-known/acme-challenge/' + ch.token); console.info(""); - console.info("We now present (for you copy-and-paste pleasure) your ACME Challenge"); - console.info("public Token and secret Key, in that order, respectively:"); - console.info(token); - console.info(secret); + console.info("And, if you need additional information for debugging:"); console.info(""); - console.info(JSON.stringify({ - domain: domain - , token: token - , key: secret - }, null, ' ').replace(/^/gm, '\t')); + console.info(JSON.stringify(httpChallengeToJson(ch), null, 2).replace(/^/gm, '\t')); console.info(""); - console.info("hit enter to continue..."); + console.info("This message won't self-destruct, but you may press hit the any as soon as you're ready to continue..."); + console.info(""); + console.info("[Press the ANY key to continue...]"); + + process.stdin.resume(); + process.stdin.once('data', function () { + process.stdin.pause(); + cb(null); + }); +}; + +Challenge._setDns = function (args, cb) { + // Using a node-style callback "thunk" in this example, because that makes + + var ch = args.challenge; + console.info("[ACME dns-01 '" + ch.altname + "' CHALLENGE]"); + console.info("Your mission (since you chose to accept it):"); + console.info("First, you must create a DNS record with the following parameters:"); + console.info(""); + console.info(ch.dnsHost + "\tTXT\t" + ch.dnsKeyAuthorization + "\tTTL 60"); + console.info(""); + console.info("Next, wait, no... there is no next. That's it - but here's some stuff anyway:"); + console.info(""); + console.info(JSON.stringify(dnsChallengeToJson(ch), null, 2).replace(/^/gm, '\t')); + console.info(""); + console.info("[Press the ANY key to continue...]"); + + process.stdin.resume(); + process.stdin.once('data', function () { + process.stdin.pause(); + cb(null); + }); +}; + +Challenge._setAny = function (args, cb) { + var ch = args.challenge; + console.info("[ACME " + ch.type + " '" + ch.altname + "' CHALLENGE]"); + console.info("There's no quippy pre-programmed response for this type of challenge."); + console.info("I have no idea what you intend to do, but I'll tell you everything I know:"); + console.info(""); + console.info(JSON.stringify(ch, null, 2).replace(/^/gm, '\t')); + console.info(""); + console.info("[Press the ANY key to continue...]"); + process.stdin.resume(); process.stdin.on('data', function () { process.stdin.pause(); @@ -37,14 +134,89 @@ Challenge.set = function (args, domain, token, secret, cb) { }); }; -// nothing to do here, that's why it's manual -Challenge.get = function (args, domain, token, cb) { - cb(null); +// might as well tell the user that whatever they were setting up has been checked +Challenge._removeHttp = function (args) { + var ch = args.challenge; + console.info(""); + console.info("Challenge for '" + ch.altname + "' complete. You can delete this file now:"); + console.info('\thttp://' + ch.altname + '/.well-known/acme-challenge/' + ch.token); + console.info(""); + + // this can return null or a Promise null + // (or callback null, just like the set() above) + return null; +}; +Challenge._removeDns = function (args) { + var ch = args.challenge; + console.info(""); + console.info("Challenge for '" + ch.altname + "' complete. You can remove this record now:"); + console.info("\t" + ch.dnsHost + "\tTXT\t" + ch.dnsKeyAuthorization + "\tTTL 60"); + console.info(""); + + // this can return null or a Promise null + // (or callback null, just like the set() above) + return null; +}; +Challenge._removeAny = function (args) { + var ch = args.challenge; + console.info(""); + console.info("Challenge for '" + ch.altname + "' complete. You can now undo what you did."); + console.info(""); + + // this can return null or a Promise null + // (or callback null, just like set() above) + return null; }; -// might as well tell the user that whatever they were setting up has been checked -Challenge.remove = function (args, domain, token, cb) { - console.info("Challenge for '" + domain + "' complete."); +// nothing to do here, that's why it's manual +Challenge._get = function (args, cb) { console.info(""); - cb(null); + console.info("Woah! Hey, guess what!? That's right you guessed it:"); + console.info("It's time to painstakingly type out the ACME challenge response with your bear hands. Yes. Your bear hands."); + process.stdout.write("> "); + + // Using a promise here just to show that Promises are support + // (in fact, they're the default) + return new Promise(function (resolve, reject) { + process.stdin.resume(); + process.stdin.on('error', reject); + process.stdin.on('data', function (chunk) { + process.stdin.pause(); + var result = chunk.toString(); + try { + result = JSON.parse(result); + } catch(e) { + args.keyAuthorization = result; + } + cb(null); + }); + }); }; + +function httpChallengeToJson(ch) { + return { + type: ch.type + , altname: ch.altname + , identifier: ch.identifier + , wildcard: false + , expires: ch.expires + , token: ch.token + , thumbprint: ch.thumbprint + , keyAuthorization: ch.keyAuthorization + }; +} + +function dnsChallengeToJson(ch) { + return { + type: ch.type + , altname: '*.example.com' + , 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.json b/package.json index cf473c8..ad9db33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "le-challenge-manual", - "version": "2.1.1", + "version": "3.0.0", "description": "A cli-based strategy for node-letsencrypt. Prints the ACME challenge Token and Key and then waits for you to hit enter before continuing.", "main": "index.js", "homepage": "https://git.coolaj86.com/coolaj86/le-challenge-manual.js", diff --git a/test.js b/test.js index a80f16e..496c77c 100644 --- a/test.js +++ b/test.js @@ -1,20 +1,50 @@ 'use strict'; +/*global Promise*/ var challenge = require('./').create({}); -var opts = challenge.getOptions(); -var domain = 'example.com'; -var token = 'token-id'; -var key = 'secret-key'; +var opts = challenge.getOptions && challenge.getOptions() || challenge.options; -// this will cause the prompt to appear -challenge.set(opts, domain, token, key, function (err) { - // if there's an error, there's a problem - if (err) { - throw err; - } +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); + }); + }); +} - // this will cause the final completion message to appear - challenge.remove(opts, domain, token, function () { - }); +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' +}; +run(opts).then(function () { + opts.challenge = { + type: 'dns-01' + , identifier: { type: 'dns', value: 'example.com' } + , wildcard: true + , expires: '2012-01-01T12:00:00.000Z' + , token: 'abc123' + , thumbprint: '<>' + , keyAuthorization: 'abc123.xxxx' + , dnsHost: '_acme-challenge.example.com' + , dnsAuthorization: 'yyyy' + , altname: '*.example.com' + }; + return run(opts); +}).then(function () { + console.info("PASS"); +}).catch(function (err) { + console.error("FAIL"); + console.error(err); + process.exit(17); });