From bc3d36a94a11a7d962ce5dc565b1b286e34f977c Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Fri, 1 Nov 2019 23:33:11 -0600 Subject: [PATCH] add greenlock cli add --- .gitignore | 1 + bin/add.js | 134 ++++++++++++++++++++++++++++++++++++++++++++ bin/greenlock.js | 15 +++-- bin/greenlockrc.js | 113 +++++++++++++++++++++++++++++++++++++ greenlock.js | 48 ++++++++-------- manager-underlay.js | 54 +++++++++--------- utils.js | 2 +- 7 files changed, 313 insertions(+), 54 deletions(-) create mode 100644 bin/add.js create mode 100644 bin/greenlockrc.js diff --git a/.gitignore b/.gitignore index d0fb096..ae7cff1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ TODO.txt link.sh .env +.greenlockrc # ---> Node # Logs diff --git a/bin/add.js b/bin/add.js new file mode 100644 index 0000000..9d87123 --- /dev/null +++ b/bin/add.js @@ -0,0 +1,134 @@ +'use strict'; + +var args = process.argv.slice(3); +var cli = require('./cli.js'); +var path = require('path'); +//var pkgpath = path.join(__dirname, '..', 'package.json'); +var pkgpath = path.join(process.cwd(), 'package.json'); + +require('./greenlockrc')(pkgpath).then(async function(rc) { + var Greenlock = require('../'); + // this is a copy, so it's safe to modify + rc._bin_mode = true; + var greenlock = Greenlock.create(rc); + var mconf = await greenlock.manager.defaults(); + + cli.parse({ + subject: [ + false, + 'the "subject" (primary domain) of the certificate', + 'string' + ], + altnames: [ + false, + 'the "subject alternative names" (additional domains) on the certificate, the first of which MUST be the subject', + 'string' + ], + 'renew-offset': [ + false, + "time to wait until renewing the cert such as '45d' (45 days after being issued) or '-3w' (3 weeks before expiration date)", + 'string', + mconf.renewOffset + ], + 'server-key-type': [ + false, + "either 'RSA-2048' or 'P-256' (ECDSA) - although other values are technically supported, they don't make sense and won't work with many services (More bits != More security)", + 'string', + mconf.serverKeyType + ], + challenge: [ + false, + 'the name name of file path of the HTTP-01, DNS-01, or TLS-ALPN-01 challenge module to use', + 'string', + Object.keys(mconf.challenges) + .map(function(typ) { + return mconf.challenges[typ].module; + }) + .join(',') + ], + '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', + '{}' + ], + 'force-save': [ + false, + "save all options for this site, even if it's the same as the defaults", + 'boolean', + false + ] + }); + + // ignore certonly and extraneous arguments + async function main(_, options) { + if (!options.subject || !options.altnames) { + console.error( + '--subject and --altnames must be provided and should be valid domains' + ); + process.exit(1); + return; + } + options.altnames = options.altnames.split(/[,\s]+/); + + Object.keys(options).forEach(function(k) { + if (options[k] === mconf[k] && !options.forceSave) { + delete options[k]; + } + }); + + var typ; + var challenge; + if (options.challenge) { + if (/http-01/.test(options.challenge)) { + typ = 'http-01'; + } else if (/dns-01/.test(options.challenge)) { + typ = 'dns-01'; + } else if (/tls-alpn-01/.test(options.challenge)) { + typ = 'tls-alpn-01'; + } + + challenge = options.challengeOpts; + challenge.module = options.challenge; + options.challenges = {}; + options.challenges[typ] = challenge; + delete options.challengeOpts; + delete options.challenge; + + var chall = mconf.challenges[typ]; + if (challenge.module === chall.module) { + var keys = Object.keys(challenge); + var same = + !keys.length || + keys.every(function(k) { + return chall[k] === challenge[k]; + }); + if (same && !options.forceSave) { + delete options.challenges; + } + } + } + + delete options.forceSave; + + /* + console.log('manager conf:'); + console.log(mconf); + console.log('cli options:'); + console.log(options); + */ + + greenlock.add(options).catch(function(err) { + console.error(); + console.error('error:', err.message); + console.error(); + }); + } + + cli.main(main, process.argv.slice(3)); +}); diff --git a/bin/greenlock.js b/bin/greenlock.js index 51cc82c..11e9e9e 100755 --- a/bin/greenlock.js +++ b/bin/greenlock.js @@ -2,12 +2,15 @@ 'use strict'; var args = process.argv.slice(2); +var arg0 = args[0]; //console.log(args); -//['certonly', 'add', 'config', 'defaults', 'remove'] -if ('certonly' === args[0]) { - require('./certonly.js'); - return; -} -console.error("command not yet implemented"); +['certonly', 'add', 'config', 'defaults', 'remove'].some(function(k) { + if (k === arg0) { + require('./' + k); + return true; + } +}); + +console.error(arg0 + 'command not yet implemented'); process.exit(); diff --git a/bin/greenlockrc.js b/bin/greenlockrc.js new file mode 100644 index 0000000..4973502 --- /dev/null +++ b/bin/greenlockrc.js @@ -0,0 +1,113 @@ +'use strict'; + +// TODO how to handle path differences when run from npx vs when required by greenlock? + +var promisify = require('util').promisify; +var fs = require('fs'); +var readFile = promisify(fs.readFile); +var writeFile = promisify(fs.writeFile); +var chmodFile = promisify(fs.chmod); +var path = require('path'); + +function saveFile(rcpath, data, enc) { + // because this may have a database url or some such + return writeFile(rcpath, data, enc).then(function() { + return chmodFile(rcpath, parseInt('0600', 8)); + }); +} + +module.exports = async function(pkgpath, manager, rc) { + // TODO when run from package + // Run from the package root (assumed) or exit + var pkgdir = path.dirname(pkgpath); + var rcpath = path.join(pkgdir, '.greenlockrc'); + var created = false; + + try { + require(pkgpath); + } catch (e) { + console.error( + 'npx greenlock must be run from the package root (where package.json is)' + ); + process.exit(1); + } + + if (manager) { + if ('.' === manager[0]) { + manager = path.resolve(pkgdir, manager); + } + try { + require(manager); + } catch (e) { + console.error('npx greenlock must be run from the package root'); + process.exit(1); + } + } + + var _data = await readFile(rcpath, 'utf8').catch(function(err) { + if ('ENOENT' !== err.code) { + throw err; + } + console.info('Creating ' + rcpath); + created = true; + var data = '{}'; + return saveFile(rcpath, data, 'utf8').then(function() { + return data; + }); + }); + + var changed; + var _rc; + try { + _rc = JSON.parse(_data); + } catch (e) { + console.error("couldn't parse " + rcpath, _data); + console.error('(perhaps you should just delete it and try again?)'); + process.exit(1); + } + + if (manager) { + if (!_rc.manager) { + _rc.manager = manager; + } + if (_rc.manager !== manager) { + console.info('Switching manager:'); + var older = _rc.manager; + var newer = manager; + if ('/' === older[0]) { + older = path.relative(pkgdir, older); + } + if ('/' === newer[0]) { + newer = path.relative(pkgdir, newer); + } + console.info('\told: ' + older); + console.info('\tnew: ' + newer); + changed = true; + } + } + + if (rc) { + changed = true; + Object.keys(rc).forEach(function(k) { + _rc[k] = rc; + }); + } + + if (!_rc.manager) { + changed = true; + _rc.manager = 'greenlock-manager-fs'; + console.info('Using default manager ' + _rc.manager); + } + + if (!changed) { + return _rc; + } + + var data = JSON.stringify(_rc, null, 2); + if (created) { + console.info('Wrote ' + rcpath); + } + return saveFile(rcpath, data, 'utf8').then(function() { + return _rc; + }); +}; diff --git a/greenlock.js b/greenlock.js index 18c01b0..f01ea79 100644 --- a/greenlock.js +++ b/greenlock.js @@ -26,21 +26,23 @@ G.create = function(gconf) { var manager; greenlock._create = function() { - if (!gconf.maintainerEmail) { - throw E.NO_MAINTAINER('create'); + if (!gconf._bin_mode) { + if (!gconf.maintainerEmail) { + throw E.NO_MAINTAINER('create'); + } + + // TODO send welcome message with benefit info + U._validMx(gconf.maintainerEmail).catch(function() { + console.error( + 'invalid maintainer contact info:', + gconf.maintainerEmail + ); + + // maybe move this to init and don't exit the process, just in case + process.exit(1); + }); } - // TODO send welcome message with benefit info - U._validMx(gconf.maintainerEmail).catch(function() { - console.error( - 'invalid maintainer contact info:', - gconf.maintainerEmail - ); - - // maybe move this to init and don't exit the process, just in case - process.exit(1); - }); - if ('function' === typeof gconf.notify) { gdefaults.notify = gconf.notify; } else { @@ -84,15 +86,17 @@ G.create = function(gconf) { greenlock._defaults = gdefaults; greenlock._defaults.debug = gconf.debug; - // renew every 90-ish minutes (random for staggering) - // the weak setTimeout (unref) means that when run as a CLI process this - // will still finish as expected, and not wait on the timeout - (function renew() { - setTimeout(function() { - greenlock.renew({}); - renew(); - }, Math.PI * 30 * 60 * 1000).unref(); - })(); + if (!gconf._bin_mode) { + // renew every 90-ish minutes (random for staggering) + // the weak setTimeout (unref) means that when run as a CLI process this + // will still finish as expected, and not wait on the timeout + (function renew() { + setTimeout(function() { + greenlock.renew({}); + renew(); + }, Math.PI * 30 * 60 * 1000).unref(); + })(); + } }; // The purpose of init is to make MCONF the source of truth diff --git a/manager-underlay.js b/manager-underlay.js index 19a4b6e..f1db0f7 100644 --- a/manager-underlay.js +++ b/manager-underlay.js @@ -5,7 +5,7 @@ var E = require('./errors.js'); var warned = {}; -module.exports.wrap = function(greenlock, manager) { +module.exports.wrap = function(greenlock, manager, gconf) { greenlock.manager = {}; greenlock.sites = {}; //greenlock.accounts = {}; @@ -143,12 +143,14 @@ module.exports.wrap = function(greenlock, manager) { } return manager.set(args).then(function(result) { - greenlock.renew({}).catch(function(err) { - if (!err.context) { - err.contxt = 'renew'; - } - greenlock._notify('error', err); - }); + if (!gconf._bin_mode) { + greenlock.renew({}).catch(function(err) { + if (!err.context) { + err.contxt = 'renew'; + } + greenlock._notify('error', err); + }); + } return result; }); }); @@ -222,25 +224,15 @@ function checkAltnames(subject, args) { return String(name || '').toLowerCase(); }); - if (subject && subject !== altnames[0]) { - throw new Error( - '`subject` must be the first domain in `altnames`', - args.subject, - altnames.join(' ') + // punycode BEFORE validation + // (set, find, remove) + if (altnames.join() !== args.altnames.join()) { + console.warn( + 'all domains in `altnames` must be lowercase:', + args.altnames ); } - /* - if (args.subject !== args.altnames[0]) { - throw E.BAD_ORDER( - 'add', - '(' + args.subject + ") '" + args.altnames.join("' '") + "'" - ); - } - */ - - // punycode BEFORE validation - // (set, find, remove) args.altnames = args.altnames.map(U._encodeName); if ( !args.altnames.every(function(d) { @@ -250,9 +242,21 @@ function checkAltnames(subject, args) { throw E.INVALID_HOSTNAME('add', "'" + args.altnames.join("' '") + "'"); } - if (altnames.join() !== args.altnames.join()) { - console.warn('all domains in `altnames` must be lowercase', altnames); + if (subject && subject !== args.altnames[0]) { + throw E.BAD_ORDER( + 'add', + '(' + args.subject + ") '" + args.altnames.join("' '") + "'" + ); } + /* + if (subject && subject !== altnames[0]) { + throw new Error( + '`subject` must be the first domain in `altnames`', + args.subject, + altnames.join(' ') + ); + } + */ return altnames; } diff --git a/utils.js b/utils.js index 24f86ab..17a0d42 100644 --- a/utils.js +++ b/utils.js @@ -59,7 +59,7 @@ U._validName = function(str) { // Note: _ (underscore) is only allowed for "domain names", not "hostnames" // Note: - (hyphen) is not allowed as a first character (but a number is) return ( - /^(\*\.)?[a-z0-9_\.\-]+$/.test(str) && + /^(\*\.)?[a-z0-9_\.\-]+\.[a-z0-9_\.\-]+$/.test(str) && str.length < 254 && str.split('.').every(function(label) { return label.length > 0 && label.length < 64;