From 89dc5fe2870c582eeeee92703515e5c1effe5278 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 17 Oct 2019 05:43:25 -0600 Subject: [PATCH] start on a nicer greenlock cli --- bin/certonly.js | 316 +++++++++++++++++++++++++++++++++++++++++++++++ bin/cli.js | 234 +++++++++++++++++++++++++++++++++++ bin/greenlock.js | 9 ++ bin/plugins.js | 75 +++++++++++ 4 files changed, 634 insertions(+) create mode 100755 bin/certonly.js create mode 100644 bin/cli.js create mode 100755 bin/greenlock.js create mode 100644 bin/plugins.js diff --git a/bin/certonly.js b/bin/certonly.js new file mode 100755 index 0000000..dbb14cd --- /dev/null +++ b/bin/certonly.js @@ -0,0 +1,316 @@ +'use strict'; + +var mkdirp = require('@root/mkdirp'); +var cli = require('./cli.js'); + +cli.parse({ + 'directory-url': [ + false, + ' ACME Directory Resource URL', + 'string', + 'https://acme-v02.api.letsencrypt.org/directory', + 'server,acme-url' + ], + email: [ + false, + ' Email used for registration and recovery contact. (default: null)', + 'email' + ], + 'agree-tos': [ + false, + " Agree to the Greenlock and Let's Encrypt Subscriber Agreements", + 'boolean', + false + ], + 'community-member': [ + false, + ' Submit stats to and get updates from Greenlock', + 'boolean', + false + ], + domains: [ + false, + ' Domain names to apply. For multiple domains you can enter a comma separated list of domains as a parameter. (default: [])', + 'string' + ], + 'renew-within': [ + false, + ' Renew certificates this many days before expiry', + 'int', + 7 + ], + 'cert-path': [ + false, + ' Path to where new cert.pem is saved', + 'string', + ':configDir/live/:hostname/cert.pem' + ], + 'fullchain-path': [ + false, + ' Path to where new fullchain.pem (cert + chain) is saved', + 'string', + ':configDir/live/:hostname/fullchain.pem' + ], + 'bundle-path': [ + false, + ' Path to where new bundle.pem (fullchain + privkey) is saved', + 'string', + ':configDir/live/:hostname/bundle.pem' + ], + 'chain-path': [ + false, + ' Path to where new chain.pem is saved', + 'string', + ':configDir/live/:hostname/chain.pem' + ], + 'privkey-path': [ + false, + ' Path to where privkey.pem is saved', + 'string', + ':configDir/live/:hostname/privkey.pem' + ], + 'config-dir': [ + false, + ' Configuration directory.', + 'string', + '~/letsencrypt/etc/' + ], + store: [ + false, + ' The name of the storage module to use', + 'string', + 'greenlock-store-fs' + ], + 'store-xxxx': [ + false, + ' An option for the chosen storage module, such as --store-apikey or --store-bucket', + 'bag' + ], + 'store-json': [ + false, + ' A JSON string containing all option for the chosen store module (instead of --store-xxxx)', + 'json', + '{}' + ], + challenge: [ + false, + ' The name of the HTTP-01, DNS-01, or TLS-ALPN-01 challenge module to use', + 'string', + '@greenlock/acme-http-01-fs' + ], + 'challenge-xxxx': [ + false, + ' An option for the chosen challenge module, such as --challenge-apikey or --challenge-bucket', + 'bag' + ], + 'challenge-json': [ + false, + ' A JSON string containing all option for the chosen challenge module (instead of --challenge-xxxx)', + 'json', + '{}' + ], + 'skip-dry-run': [ + false, + ' Use with caution (and test with the staging url first). Creates an Order on the ACME server without a self-test.', + 'boolean' + ], + 'skip-challenge-tests': [ + false, + ' Use with caution (and with the staging url first). Presents challenges to the ACME server without first testing locally.', + 'boolean' + ], + 'http-01-port': [ + false, + ' Required to be 80 for live servers. Do not use. For special test environments only.', + 'int' + ], + 'dns-01': [false, ' Use DNS-01 challange type', 'boolean', false], + standalone: [ + false, + ' Obtain certs using a "standalone" webserver.', + 'boolean', + false + ], + manual: [ + false, + ' Print the token and key to the screen and wait for you to hit enter, giving you time to copy it somewhere before continuing (uses acme-http-01-cli or acme-dns-01-cli)', + 'boolean', + false + ], + debug: [false, ' show traces and logs', 'boolean', false], + root: [ + false, + ' public_html / webroot path (may use the :hostname template such as /srv/www/:hostname)', + 'string', + undefined, + 'webroot-path' + ], + + // + // backwards compat + // + duplicate: [ + false, + ' Allow getting a certificate that duplicates an existing one/is an early renewal', + 'boolean', + false + ], + 'rsa-key-size': [ + false, + ' (ignored) use domain-key-type or account-key-type instead', + 'ignore', + 2048 + ], + 'domain-key-path': [ + false, + ' Path to privkey.pem to use for domain (default: generate new)', + 'string' + ], + 'domain-key-type': [ + false, + " One of 'RSA' (2048), 'RSA-3084', 'RSA-4096', 'ECDSA' (P-256), or 'P-384'. For best compatibility, security, and efficiency use the default (More bits != More security)", + 'string', + 'RSA' + ], + 'account-key-path': [ + false, + ' Path to privkey.pem to use for account (default: generate new)', + 'string' + ], + 'account-key-type': [ + false, + " One of 'ECDSA' (P-256), 'P-384', 'RSA', 'RSA-3084', or 'RSA-4096'. Stick with 'ECDSA' (P-256) unless you need 'RSA' (2048) for legacy compatibility. (More bits != More security)", + 'string', + 'P-256' + ], + webroot: [false, ' (ignored) for certbot compatibility', 'ignore', false], + //, 'standalone-supported-challenges': [ false, " Supported challenges, order preferences are randomly chosen. (default: http-01,tls-sni-01)", 'string', 'http-01,tls-sni-01'] + 'work-dir': [ + false, + ' for certbot compatibility (ignored)', + 'string', + '~/letsencrypt/var/lib/' + ], + 'logs-dir': [ + false, + ' for certbot compatibility (ignored)', + 'string', + '~/letsencrypt/var/log/' + ], + 'acme-version': [ + false, + ' (ignored) ACME is now RFC 8555 and prior drafts are no longer supported', + 'ignore', + 'rfc8555' + ] +}); + +// ignore certonly and extraneous arguments +cli.main(function(_, options) { + console.info(''); + + [ + 'configDir', + 'privkeyPath', + 'certPath', + 'chainPath', + 'fullchainPath', + 'bundlePath' + ].forEach(function(k) { + if (options[k]) { + options.storeOpts[k] = options[k]; + } + delete options[k]; + }); + + if (options.workDir) { + options.challengeOpts.workDir = options.workDir; + delete options.workDir; + } + + if (options.debug) { + console.debug(options); + } + + var args = {}; + var homedir = require('os').homedir(); + + Object.keys(options).forEach(function(key) { + var val = options[key]; + + if ('string' === typeof val) { + val = val.replace(/^~/, homedir); + } + + key = key.replace(/\-([a-z0-9A-Z])/g, function(c) { + return c[1].toUpperCase(); + }); + args[key] = val; + }); + + Object.keys(args).forEach(function(key) { + var val = args[key]; + + if ('string' === typeof val) { + val = val.replace(/(\:configDir)|(\:config)/, args.configDir); + } + + args[key] = val; + }); + + if (args.domains) { + args.domains = args.domains.split(','); + } + + if ( + !(Array.isArray(args.domains) && args.domains.length) || + !args.email || + !args.agreeTos || + (!args.server && !args.directoryUrl) + ) { + console.error('\nUsage:\n\ngreenlock certonly --standalone \\'); + console.error( + '\t--agree-tos --email user@example.com --domains example.com \\' + ); + console.error('\t--config-dir ~/acme/etc \\'); + console.error('\nSee greenlock --help for more details\n'); + return; + } + + if (args.http01Port) { + // [@agnat]: Coerce to string. cli returns a number although we request a string. + args.http01Port = '' + args.http01Port; + args.http01Port = args.http01Port.split(',').map(function(port) { + return parseInt(port, 10); + }); + } + + function run() { + console.log('\ngot to the run step'); + process.exit(1); + require('../') + .run(args) + .then(function(status) { + process.exit(status); + }); + } + + if ('greenlock-store-fs' !== args.store) { + run(); + return; + } + + // TODO remove mkdirp and let greenlock-store-fs do this? + mkdirp(args.storeOpts.configDir, function(err) { + if (!err) { + run(); + } + + console.error( + "Could not create --config-dir '" + args.configDir + "':", + err.code + ); + console.error("Try setting --config-dir '/tmp'"); + return; + }); +}, process.argv.slice(3)); diff --git a/bin/cli.js b/bin/cli.js new file mode 100644 index 0000000..b7c9aad --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,234 @@ +'use strict'; + +var CLI = module.exports; + +var defaultConf; +var defaultOpts; +var bags = []; + +CLI.parse = function(conf) { + var opts = (defaultOpts = {}); + defaultConf = conf; + + Object.keys(conf).forEach(function(k) { + var v = conf[k]; + var aliases = v[5]; + var bag; + var bagName; + + // the name of the argument set is now the 0th argument + v.unshift(k); + // v[0] flagname + // v[1] short flagname + // v[2] description + // v[3] type + // v[4] default value + // v[5] aliases + + if ('bag' === v[3]) { + bag = v[0]; // 'bag-option-xxxx' => '--bag-option-' + bag = '--' + bag.replace(/xxx.*/, ''); + bags.push(bag); + + bagName = toBagName(bag.replace(/^--/, '')); + opts[bagName] = {}; + } + + if ('json' === v[3]) { + bagName = toBagName(v[0].replace(/-json$/, '')); // 'bag-option-json' => 'bagOptionOpts' + opts[bagName] = {}; + } else if ('ignore' !== v[3] && 'undefined' !== typeof v[4]) { + // set the default values (where 'undefined' is not an allowed value) + opts[toCamel(k)] = v[4]; + } + + if (!aliases) { + aliases = []; + } else if ('string' === typeof aliases) { + aliases = aliases.split(','); + } + aliases.forEach(function(alias) { + if (alias in conf) { + throw new Error( + "Cannot alias '" + + alias + + "' from '" + + k + + "': option already exists" + ); + } + conf[alias] = v; + }); + }); +}; + +CLI.main = function(cb, args) { + var leftovers = []; + var conf = defaultConf; + var opts = defaultOpts; + + if (!opts) { + throw new Error("you didn't call `CLI.parse(configuration)`"); + } + + // TODO what's the existing API for this? + if (!args) { + args = process.argv.slice(2); + } + + var flag; + var cnf; + var typ; + + function grab(bag) { + var bagName = toBagName(bag); + if (bag !== flag.slice(0, bag.length)) { + return false; + } + console.log(bagName, toCamel(flag.slice(bag.length))); + opts[bagName][toCamel(flag.slice(bag.length))] = args.shift(); + return true; + } + + while (args.length) { + // take one off the top + flag = args.shift(); + + // mind the gap + if ('--' === flag) { + leftovers = leftovers.concat(args); + break; + } + + // help! + if ( + '--help' === flag || + '-h' === flag || + '/?' === flag || + 'help' === flag + ) { + printHelp(conf); + process.exit(1); + } + + // only long names are actually used + if ('--' !== flag.slice(0, 2)) { + console.error("Unrecognized argument '" + flag + "'"); + process.exit(1); + } + + cnf = conf[flag.slice(2)]; + if (!cnf) { + // look for arbitrary flags + if (bags.some(grab)) { + continue; + } + + // other arbitrary args are not used + console.error("Unrecognized flag '" + flag + "'"); + process.exit(1); + } + + // encourage switching to non-aliased version + if (flag !== '--' + cnf[0]) { + console.warn( + "use of '" + + flag + + "' is deprecated, use '--" + + cnf[0] + + "' instead" + ); + } + + // look for xxx-json flags + if ('json' === cnf[3]) { + try { + var json = JSON.parse(args.shift()); + var bagName = toBagName(cnf[0].replace(/-json$/, '')); + Object.keys(json).forEach(function(k) { + opts[bagName][k] = json[k]; + }); + } catch (e) { + console.error("Could not parse option '" + flag + "' as JSON:"); + console.error(e.message); + process.exit(1); + } + continue; + } + + // set booleans, otherwise grab the next arg in line + typ = cnf[3]; + // TODO --no- to negate + if (Boolean === typ || 'boolean' === typ) { + opts[toCamel(cnf[0])] = true; + continue; + } + opts[toCamel(cnf[0])] = args.shift(); + continue; + } + + cb(leftovers, opts); +}; + +function toCamel(str) { + return str.replace(/-([a-z0-9])/g, function(m) { + return m[1].toUpperCase(); + }); +} + +function toBagName(bag) { + // trim leading and trailing '-' + bag = bag.replace(/^-+/g, '').replace(/-+$/g, '') + return toCamel(bag) + 'Opts'; // '--bag-option-' => bagOptionOpts +} + +function printHelp(conf) { + var flagLen = 0; + var typeLen = 0; + var defLen = 0; + + Object.keys(conf).forEach(function(k) { + flagLen = Math.max(flagLen, conf[k][0].length); + typeLen = Math.max(typeLen, conf[k][3].length); + if ('undefined' !== typeof conf[k][4]) { + defLen = Math.max( + defLen, + '(Default: )'.length + String(conf[k][4]).length + ); + } + }); + + Object.keys(conf).forEach(function(k) { + var v = conf[k]; + + // skip aliases + if (v[0] !== k) { + return; + } + + var def = v[4]; + if ('undefined' === typeof def) { + def = ''; + } else { + def = '(default: ' + JSON.stringify(def) + ')'; + } + + var msg = + ' --' + + v[0].padEnd(flagLen) + + ' ' + + v[3].padStart(typeLen + 1) + + ' ' + + (v[2] || '') + + ' ' + + def; /*.padStart(defLen)*/ + // v[0] flagname + // v[1] short flagname + // v[2] description + // v[3] type + // v[4] default value + // v[5] aliases + + console.info(msg); + }); +} diff --git a/bin/greenlock.js b/bin/greenlock.js new file mode 100755 index 0000000..9422092 --- /dev/null +++ b/bin/greenlock.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +'use strict'; + +var args = process.argv.slice(2); +console.log(args); +if ('certonly' === args[0]) { + require('./certonly.js'); + return; +} diff --git a/bin/plugins.js b/bin/plugins.js new file mode 100644 index 0000000..a5a90a8 --- /dev/null +++ b/bin/plugins.js @@ -0,0 +1,75 @@ +'use strict'; + +var spawn = require('child_process').spawn; +var path = require('path'); +var PKG_DIR = path.join(__dirname, '..'); + +module.exports.install = function(moduleName) { + return new Promise(function(resolve) { + if (!moduleName) { + throw new Error('no module name given'); + } + + var npm = 'npm'; + var args = ['install', '--save', moduleName]; + var out = ''; + var cmd = spawn(npm, args, { + cwd: PKG_DIR, + windowsHide: true + }); + + cmd.stdout.on('data', function(chunk) { + out += chunk.toString('utf8'); + }); + cmd.stdout.on('data', function(chunk) { + out += chunk.toString('utf8'); + }); + + cmd.on('error', function(e) { + console.error( + "Failed to start: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + console.error(e.message); + process.exit(1); + }); + + cmd.on('exit', function(code) { + if (!code) { + resolve(); + return; + } + + if (out) { + console.error(out); + console.error(); + console.error(); + } + console.error( + "Failed to run: '" + + npm + + ' ' + + args.join(' ') + + "' in '" + + PKG_DIR + + "'" + ); + console.error( + 'Try for yourself:\n\tcd ' + + PKG_DIR + + '\n\tnpm ' + + args.join(' ') + ); + process.exit(1); + }); + }); +}; + +if (require.main === module) { + module.exports.install(process.argv[2]); +}