From cdd1201bc6f17f46141aa6f78b5057a577dc8317 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 31 Oct 2019 00:35:17 -0600 Subject: [PATCH] v3.0.0: A file-based site manager for greenlock --- README.md | 59 ++++- manager.js | 593 ++++++++++++++++++++----------------------------- package.json | 11 +- test.js | 52 ----- tests/index.js | 19 ++ 5 files changed, 324 insertions(+), 410 deletions(-) delete mode 100644 test.js create mode 100644 tests/index.js diff --git a/README.md b/README.md index ff86bfc..21e942b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,58 @@ -# greenlock-manager-fs.js +# [greenlock-manager-fs.js](https://git.rootprojects.org/root/greenlock-manager-fs.js) -A simple file-based management strategy for greenlock +A simple file-based management strategy for Greenlock v3 + +(to manage SSL certificates for sites) + +## Install + +```js +npm install --save greenlock-manager-fs@v3 +``` + +## Use with Greenlock + +```js +var greenlock = require('greenlock').create({ + // ... + + manager: 'greenlock-manager-fs', + configFile: '~/.config/greenlock/manager.json' +}); +``` + +## Example config file + +You might start your config file like this: + +`~/.config/greenlock/manager.json`: + +```json +{ + "subscriberEmail": "jon@example.com", + "agreeToTerms": true, + "sites": [ + { + "subject": "example.com", + "altnames": ["example.com", "*.example.com"] + } + ] +} +``` + +## CLI Management (coming soon) + +We're going to be adding some tools to greenlock so that you can do +something like this to manage your sites and SSL certificates: + +```js +npx greenlock defaults --subscriber-email jon@example.com --agree-to-terms true +``` + +```js +npx greenlock add --subject example.com --altnames example.com,*.example.com +``` + +```js +npx greenlock renew --all +``` diff --git a/manager.js b/manager.js index 70aec4f..7e3ff94 100644 --- a/manager.js +++ b/manager.js @@ -1,6 +1,8 @@ 'use strict'; var Manage = module.exports; +var doctor = {}; + var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' }); var promisify = require('util').promisify; var fs = require('fs'); @@ -11,6 +13,15 @@ var homedir = require('os').homedir(); var path = require('path'); var mkdirp = promisify(require('@root/mkdirp')); +// NOTE +// this is over-complicated to account for people +// doing weird things, and this just being a file system +// and wanting to be fairly sure it works and produces +// meaningful errors + +// For your use case you'll probably find a better example +// in greenlock-manager-test + Manage.create = function(CONF) { if (!CONF) { CONF = {}; @@ -25,264 +36,105 @@ Manage.create = function(CONF) { manage._txPromise = Promise.resolve(); - manage.defaults = manage.config = function(conf) { - // get / set default site settings such as - // subscriberEmail, store, challenges, renewOffset, renewStagger - return Manage._getLatest(manage, CONF).then(function(config) { + // Note: all of these top-level methods are effectively mutexed + // You cannot call them from each other or they will deadlock + + manage.defaults = manage.config = async function(conf) { + manage._txPromise = manage._txPromise.then(async function() { + var config = await Manage._getLatest(manage, CONF); + + // act as a getter if (!conf) { - conf = JSON.parse(JSON.stringify(config)); - delete conf.sites; + conf = JSON.parse(JSON.stringify(config.defaults)); return conf; } - // TODO set initial sites - if (conf.sites) { - throw new Error('cannot set sites as global config'); - } - - // TODO whitelist rather than blacklist? - if ( - [ - 'subject', - 'altnames', - 'lastAttemptAt', - 'expiresAt', - 'issuedAt', - 'renewAt' - ].some(function(k) { - if (k in conf) { - throw new Error( - '`' + k + '` not allowed as a default setting' - ); - } - }) - ) { - } - + // act as a setter Object.keys(conf).forEach(function(k) { - if (-1 !== ['sites', 'module', 'manager'].indexOf(k)) { - return; - } - - if ('undefined' === typeof k) { - throw new Error( - "'" + - k + - "' should be set to a value, or `null`, but not left `undefined`" - ); - } - - if (null === k) { - delete config[k]; - } - - config[k] = conf[k]; + config.defaults[k] = conf[k]; }); return manage._save(config); }); - }; - manage.add = function(args) { - manage._txPromise = manage._txPromise.then(function() { - // if the fs has changed since we last wrote, get the lastest from disk - return Manage._getLatest(manage, CONF).then(function(config) { - // TODO move to Greenlock.add - var subscriberEmail = args.subscriberEmail; - var subject = args.subject || args.domain; - var primary = subject; - var altnames = - args.servernames || args.altnames || args.domains; - if ('string' !== typeof primary) { - if (!Array.isArray(altnames) || !altnames.length) { - throw new Error('there needs to be a subject'); - } - primary = altnames.slice(0).sort()[0]; - } - if (!Array.isArray(altnames) || !altnames.length) { - altnames = [primary]; - } - primary = primary.toLowerCase(); - altnames = altnames.map(function(name) { - return name.toLowerCase(); - }); - - if (!config.sites) { - config.sites = {}; - } - - var existing = config.sites[primary]; - var site = existing; - if (!existing) { - site = config.sites[primary] = { altnames: [primary] }; - } - - // The goal is to make this decently easy to manage by hand without mistakes - // but also reasonably easy to error check and correct - // and to make deterministic auto-corrections - - // TODO added, removed, moved (duplicate), changed - if (subscriberEmail) { - site.subscriberEmail = subscriberEmail; - } - site.subject = subject; - site.renewAt = args.renewAt || site.renewAt || 0; - if ( - altnames - .slice(0) - .sort() - .join(' ') !== - site.altnames - .slice(0) - .sort() - .join(' ') - ) { - // TODO signal to wait for renewal? - // it will definitely be renewed on the first request anyway - site.renewAt = 0; - } - site.altnames = altnames; - if (!site.issuedAt) { - site.issuedAt = 0; - } - site.expiresAt = site.expiresAt || 0; - site.lastAttemptAt = site.lastAttemptAt || 0; - // re-add if this was deleted - site.deletedAt = 0; - if ( - site.altnames - .slice(0) - .sort() - .join() !== - altnames - .slice(0) - .sort() - .join() - ) { - site.expiresAt = 0; - site.issuedAt = 0; - } - - // These should usually be empty, for most situations - if (args.customerEmail) { - site.customerEmail = args.customerEmail; - } - if (args.challenges) { - site.challenges = args.challenges; - } - if (args.store) { - site.store = args.store; - } - - return manage._save(config).then(function() { - return JSON.parse(JSON.stringify(site)); - }); - }); - }); return manage._txPromise; }; - manage.find = function(args) { - return _find(args).then(function(existing) { - if (!CONF.find) { - return existing; - } + manage.set = async function(args) { + manage._txPromise = manage._txPromise.then(async function() { + var config = await Manage._getLatest(manage, CONF); - return Promise.resolve(CONF.find(args)).then(function(results) { - // TODO also detect and delete stale (just ignoring them for now) - var changed = []; - var same = []; - results.forEach(function(_newer) { - // Check lowercase subject names - var subject = (_newer.subject || '').toLowerCase(); - // Set the default altnames to the subject, just in case - var altnames = (_newer.altnames || []).slice(0); - if (!altnames.includes(subject)) { - console.warn( - "all site configs should include 'subject' and 'altnames': " + - subject - ); - altnames.push(subject); - } + manage._merge(config, config.sites[args.subject], args); - existing.some(function(_older) { - if (subject !== (_older.subject || '').toLowerCase()) { - return false; - } - _newer._exists = true; - - // Compare the altnames and update if needed - if ( - altnames - .slice(0) - .sort() - .join(' ') !== - (_older.altnames || []) - .slice(0) - .sort() - .join(' ') - ) { - _older.renewAt = 0; - _older.altnames = altnames; - changed.push(_older); - } else { - same.push(_older); - } - - return true; - }); - - if (!_newer._exists) { - changed.push({ - subject: subject, - altnames: altnames, - renewAt: 0 - }); - } - }); - - if (!changed.length) { - return same; - } - - // kinda redundant to pull again, but whatever... - return Manage._getLatest(manage, CONF).then(function(config) { - changed.forEach(function(site) { - config.sites[site.subject] = site; - }); - return manage._save(config).then(function() { - // everything was either added, updated, or not different - // hence, this is everything - var all = changed.concat(same); - return all; - }); - }); - }); + await manage._save(config); + return JSON.parse(JSON.stringify(config.sites[args.subject])); }); + + return manage._txPromise; + }; + + manage._merge = function(config, current, args) { + if (!current || current.deletedAt) { + current = config.sites[args.subject] = { + subject: args.subject, + altnames: [], + renewAt: 1 + }; + } + + current.renewAt = parseInt(args.renewAt || current.renewAt, 10) || 1; + var oldAlts; + var newAlts; + if (args.altnames) { + // copy as to not disturb order, which matters + oldAlts = current.altnames.slice(0).sort(); + newAlts = args.altnames.slice(0).sort(); + + if (newAlts.join() !== oldAlts.join()) { + // this will cause immediate renewal + args.renewAt = 1; + current.renewAt = 1; + current.altnames = args.altnames.slice(0); + } + } + }; + + // no transaction promise here because it calls set + manage.find = async function(args) { + var ours = await _find(args); + if (!CONF.find) { + return ours; + } + + // if the user has an overlay find function we'll do a diff + // between the managed state and the overlay, and choose + // what was found. + var theirs = await CONF.find(args); + return _mergeFind(ours, theirs); }; function _find(args) { - return Manage._getLatest(manage, CONF).then(function(config) { + manage._txPromise = manage._txPromise.then(async function() { + var config = await Manage._getLatest(manage, CONF); // i.e. find certs more than 30 days old //args.issuedBefore = Date.now() - 30 * 24 * 60 * 60 * 1000; // i.e. find certs more that will expire in less than 45 days //args.expiresBefore = Date.now() + 45 * 24 * 60 * 60 * 1000; var issuedBefore = args.issuedBefore || Infinity; var expiresBefore = args.expiresBefore || Infinity; //Date.now() + 21 * 24 * 60 * 60 * 1000; - var nameKeys = ['subject', 'altnames']; // if there's anything to match, only return matches // if there's nothing to match, return everything + var nameKeys = ['subject', 'altnames']; var matchAll = !nameKeys.some(function(k) { return k in args; }); var querynames = (args.altnames || []).slice(0); - // TODO match ANY domain on any cert - var sites = Object.keys(config.sites || {}) + var sites = Object.keys(config.sites) .filter(function(subject) { - var site = doctor.site(config.sites, subject); + var site = config.sites[subject]; if (site.deletedAt) { return false; } @@ -309,172 +161,163 @@ Manage.create = function(CONF) { }); }) .map(function(name) { - var site = config.sites[name]; - return { - subject: site.subject, - altnames: site.altnames, - issuedAt: site.issuedAt, - expiresAt: site.expiresAt, - renewOffset: site.renewOffset, - renewStagger: site.renewStagger, - renewAt: site.renewAt, - subscriberEmail: site.subscriberEmail, - customerEmail: site.customerEmail, - challenges: site.challenges, - store: site.store - }; + return doctor.site(config.sites, name); }); return sites; }); + + return manage._txPromise; } - manage.notify = CONF.notify || _notify; - function _notify(ev, args) { - if (!args) { - args = ev; - ev = args.event; - delete args.event; - } + function _mergeFind(config, ours, theirs) { + theirs.forEach(function(_newer) { + ours.some(function(_older) { + if (_newer.subject !== _older.subject) { + return false; + } - // TODO define message types - if (!manage._notify_notice) { - console.info( - 'set greenlockOptions.notify to override the default logger' - ); - manage._notify_notice = true; - } - switch (ev) { - case 'error': - /* falls through */ - case 'warning': - console.error( - 'Error%s:', - (' ' + (args.context || '')).trimRight() - ); - console.error(args.message); - if (args.description) { - console.error(args.description); - } - if (args.code) { - console.error('code:', args.code); - } - break; - default: - if (/status/.test(ev)) { - console.info( - ev, - args.altname || args.subject || '', - args.status || '' - ); - if (!args.status) { - console.info(args); - } - break; - } - console.info( - ev, - '(more info available: ' + Object.keys(args).join(' ') + ')' - ); - } - } + // BE SURE TO SET THIS UNDEFINED AFTERWARDS + _older._exists = true; - manage.update = function(args) { - manage._txPromise = manage._txPromise.then(function() { - return Manage._getLatest(manage, CONF).then(function(config) { - var site = config.sites[args.subject]; - //site.issuedAt = args.issuedAt; - //site.expiresAt = args.expiresAt; - site.renewAt = args.renewAt; - return manage._save(config); + manage._merge(config, _older, _newer); + _newer = config.sites[_older.subject]; + + // handled the (only) match + return true; }); }); + + // delete the things that are gone + ours.forEach(function(_older) { + if (!_older._exists) { + delete config.sites[_older.subject]; + } + _older._exists = undefined; + }); + + manage._txPromise = manage._txPromise.then(async function() { + // kinda redundant to pull again, but whatever... + var config = await Manage._getLatest(manage, CONF); + await manage._save(config); + // everything was either added, updated, or not different + // hence, this is everything + return JSON.parse(JSON.stringify(config.sites)); + }); + return manage._txPromise; - }; + } manage.remove = function(args) { if (!args.subject) { throw new Error('should have a subject for sites to remove'); } - manage._txPromise = manage._txPromise.then(function() { - return Manage._getLatest(manage, CONF).then(function(config) { - var site = config.sites[args.subject]; - if (!site) { - return {}; - } - site.deletedAt = Date.now(); - - return JSON.parse(JSON.stringify(site)); - }); + manage._txPromise = manage._txPromise.then(async function() { + var config = await Manage._getLatest(manage, CONF); + var site = config.sites[args.subject]; + if (!site) { + return null; + } + site.deletedAt = Date.now(); + await manage._save(config); + return JSON.parse(JSON.stringify(site)); }); return manage._txPromise; }; - manage._lastStat = { - size: 0, - mtimeMs: 0 - }; manage._config = {}; + // (wrong type #1) specifically the wrong type (null) + manage._lastStat = { size: null, mtimeMs: null }; - manage._save = function(config) { - return mkdirp(path.dirname(CONF.configFile)).then(function() { - return sfs - .writeFileAsync( - CONF.configFile, - // pretty-print the config file - JSON.stringify(config, null, 2), - 'utf8' - ) - .then(function() { - // this file may contain secrets, so keep it safe - return chmodFile(CONF.configFile, parseInt('0600', 8)) - .catch(function() { - /*ignore for Windows */ - }) - .then(function() { - return statFile(CONF.configFile).then(function( - stat - ) { - manage._lastStat.size = stat.size; - manage._lastStat.mtimeMs = stat.mtimeMs; - }); - }); - }); - }); + manage._save = async function(config) { + await mkdirp(path.dirname(CONF.configFile)); + // pretty-print the config file + var data = JSON.stringify(config, null, 2); + await sfs.writeFileAsync(CONF.configFile, data, 'utf8'); + + // this file may contain secrets, so keep it safe + return chmodFile(CONF.configFile, parseInt('0600', 8)) + .catch(function() { + /*ignore for Windows */ + }) + .then(async function() { + var stat = await statFile(CONF.configFile); + manage._lastStat.size = stat.size; + manage._lastStat.mtimeMs = stat.mtimeMs; + }); + }; + + manage.init = async function(deps) { + var request = deps.request; + // how nice... }; return manage; }; -Manage._getLatest = function(mng, CONF) { +Manage._getLatest = function(MNG, CONF) { return statFile(CONF.configFile) - .catch(function(err) { - if ('ENOENT' === err.code) { - return { - size: 0, - mtimeMs: 0 - }; + .catch(async function(err) { + if ('ENOENT' !== err.code) { + err.context = 'manager_read'; + throw err; } - err.context = 'manager_read'; - throw err; + await MNG._save(doctor.config()); + // (wrong type #2) specifically the wrong type (bool) + return { size: false, mtimeMs: false }; }) - .then(function(stat) { + .then(async function(stat) { if ( - stat.size === mng._lastStat.size && - stat.mtimeMs === mng._lastStat.mtimeMs + stat.size === MNG._lastStat.size && + stat.mtimeMs === MNG._lastStat.mtimeMs ) { - return mng._config; + return MNG._config; } - return readFile(CONF.configFile, 'utf8').then(function(data) { - mng._lastStat = stat; - mng._config = JSON.parse(data); - return mng._config; - }); + var data = await readFile(CONF.configFile, 'utf8'); + MNG._lastStat = stat; + MNG._config = JSON.parse(data); + return doctor.config(MNG._config); }); }; -var doctor = {}; // users muck up config files, so we try to handle it gracefully. +doctor.config = function(config) { + if (!config) { + config = {}; + } + if (!config.defaults) { + config.defaults = {}; + } + + doctor.sites(config); + + Object.keys(config).forEach(function(key) { + if (['defaults', 'routes', 'sites'].includes(key)) { + return; + } + config.defaults[key] = config[key]; + delete config[key]; + }); + + doctor.challenges(config.defaults); + + return config; +}; +doctor.sites = function(config) { + var sites = config.sites; + if (!sites) { + sites = {}; + } + if (Array.isArray(sites)) { + sites.forEach(function(site) { + sites[site.subject] = site; + }); + } + Object.keys(sites).forEach(function(k) { + doctor.site(sites, k); + }); + config.sites = sites; +}; doctor.site = function(sconfs, subject) { var site = sconfs[subject]; if (!site) { @@ -482,8 +325,9 @@ doctor.site = function(sconfs, subject) { site = {}; } - // TODO notify on any changes if ('string' !== typeof site.subject) { + console.warn('warning: deleted malformed site from config file:'); + console.warn(JSON.stringify(site)); delete sconfs[subject]; site.subject = 'greenlock-error.example.com'; } @@ -496,3 +340,46 @@ doctor.site = function(sconfs, subject) { return site; }; + +doctor.challenges = function(defaults) { + var challenges = defaults.challenges; + if (!challenges) { + challenges = {}; + } + if (Array.isArray(defaults.challenges)) { + defaults.challenges.forEach(function(challenge) { + var typ = doctor.challengeType(challenge); + challenges[typ] = challenge; + }); + } + Object.keys(challenges).forEach(function(k) { + doctor.challenge(challenges, k); + }); + defaults.challenges = challenges; + if (!Object.keys(defaults.challenges).length) { + delete defaults.challenges; + } +}; +doctor.challengeType = function(challenge) { + var typ = challenge.type; + if (!typ) { + if (/\bhttp-01\b/.test(challenge.module)) { + typ = 'http-01'; + } else if (/\bdns-01\b/.test(challenge.module)) { + typ = 'dns-01'; + } else if (/\btls-alpn-01\b/.test(challenge.module)) { + typ = 'tls-alpn-01'; + } else { + typ = 'error-01'; + } + } + delete challenge.type; + return typ; +}; +doctor.challenge = function(chconfs, typ) { + var ch = chconfs[typ]; + if (!ch) { + delete chconfs[typ]; + } + return; +}; diff --git a/package.json b/package.json index 384e9d0..8f9cad0 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { "name": "greenlock-manager-fs", - "version": "0.7.0", + "version": "3.0.0", "description": "A simple file-based management strategy for Greenlock", "main": "manager.js", "scripts": { "test": "node tests" }, + "files": [ + "*.js", + "lib" + ], "repository": { "type": "git", - "url": "https://git.coolaj86.com/coolaj86/greenlock-manager-fs.js.git" + "url": "https://git.rootprojects.org/root/greenlock-manager-fs.js.git" }, "keywords": [ "Greenlock", @@ -20,6 +24,7 @@ "license": "MPL-2.0", "dependencies": { "@root/mkdirp": "^1.0.0", - "safe-replace": "^1.1.0" + "safe-replace": "^1.1.0", + "greenlock-manager-test": "^3.0.0" } } diff --git a/test.js b/test.js deleted file mode 100644 index 3639b6b..0000000 --- a/test.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -var Manager = require('./'); -var manager = Manager.create({ - configFile: 'greenlock-manager-test.delete-me.json' -}); -var domains = ['example.com', 'www.example.com']; - -async function run() { - await manager.add({ - subject: domains[0], - altnames: domains - }); - - await manager.find({}).then(function(results) { - if (!results.length) { - console.log(results); - throw new Error('should have found all managed sites'); - } - }); - - await manager.find({ subject: 'www.example.com' }).then(function(results) { - if (results.length) { - console.log(results); - throw new Error( - "shouldn't find what doesn't exist, exactly, by subject" - ); - } - }); - - await manager - .find({ altnames: ['www.example.com'] }) - .then(function(results) { - if (!results.length) { - console.log(results); - throw new Error('should have found sites matching altname'); - } - }); - - await manager.find({ altnames: ['*.example.com'] }).then(function(results) { - if (results.length) { - console.log(results); - throw new Error( - 'should only find an exact (literal) wildcard match' - ); - } - }); - - console.log("PASS"); -} - -run(); diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..870b134 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,19 @@ +'use strict'; + +var Tester = require('greenlock-manager-test'); + +var Manager = require('../'); +var config = { + configFile: 'greenlock-manager-test.delete-me.json' +}; + +Tester.test(Manager, config) + .then(function() { + console.log('PASS: Known-good test module passes'); + }) + .catch(function(err) { + console.error('Oops, you broke it. Here are the details:'); + console.error(err.stack); + console.error(); + console.error("That's all I know."); + });