v3.0.0: A file-based site manager for greenlock

This commit is contained in:
AJ ONeal 2019-10-31 00:35:17 -06:00
parent 116b4925e7
commit cdd1201bc6
5 changed files with 324 additions and 410 deletions

View File

@ -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
```

View File

@ -1,6 +1,8 @@
'use strict'; 'use strict';
var Manage = module.exports; var Manage = module.exports;
var doctor = {};
var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' }); var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' });
var promisify = require('util').promisify; var promisify = require('util').promisify;
var fs = require('fs'); var fs = require('fs');
@ -11,6 +13,15 @@ var homedir = require('os').homedir();
var path = require('path'); var path = require('path');
var mkdirp = promisify(require('@root/mkdirp')); 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) { Manage.create = function(CONF) {
if (!CONF) { if (!CONF) {
CONF = {}; CONF = {};
@ -25,264 +36,105 @@ Manage.create = function(CONF) {
manage._txPromise = Promise.resolve(); manage._txPromise = Promise.resolve();
manage.defaults = manage.config = function(conf) { // Note: all of these top-level methods are effectively mutexed
// get / set default site settings such as // You cannot call them from each other or they will deadlock
// subscriberEmail, store, challenges, renewOffset, renewStagger
return Manage._getLatest(manage, CONF).then(function(config) { 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) { if (!conf) {
conf = JSON.parse(JSON.stringify(config)); conf = JSON.parse(JSON.stringify(config.defaults));
delete conf.sites;
return conf; return conf;
} }
// TODO set initial sites // act as a setter
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'
);
}
})
) {
}
Object.keys(conf).forEach(function(k) { Object.keys(conf).forEach(function(k) {
if (-1 !== ['sites', 'module', 'manager'].indexOf(k)) { config.defaults[k] = conf[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];
}); });
return manage._save(config); 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; return manage._txPromise;
}; };
manage.find = function(args) { manage.set = async function(args) {
return _find(args).then(function(existing) { manage._txPromise = manage._txPromise.then(async function() {
if (!CONF.find) { var config = await Manage._getLatest(manage, CONF);
return existing;
}
return Promise.resolve(CONF.find(args)).then(function(results) { manage._merge(config, config.sites[args.subject], args);
// 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);
}
existing.some(function(_older) { await manage._save(config);
if (subject !== (_older.subject || '').toLowerCase()) { return JSON.parse(JSON.stringify(config.sites[args.subject]));
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;
});
});
});
}); });
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) { 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 // i.e. find certs more than 30 days old
//args.issuedBefore = Date.now() - 30 * 24 * 60 * 60 * 1000; //args.issuedBefore = Date.now() - 30 * 24 * 60 * 60 * 1000;
// i.e. find certs more that will expire in less than 45 days // i.e. find certs more that will expire in less than 45 days
//args.expiresBefore = Date.now() + 45 * 24 * 60 * 60 * 1000; //args.expiresBefore = Date.now() + 45 * 24 * 60 * 60 * 1000;
var issuedBefore = args.issuedBefore || Infinity; var issuedBefore = args.issuedBefore || Infinity;
var expiresBefore = args.expiresBefore || Infinity; //Date.now() + 21 * 24 * 60 * 60 * 1000; 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 anything to match, only return matches
// if there's nothing to match, return everything // if there's nothing to match, return everything
var nameKeys = ['subject', 'altnames'];
var matchAll = !nameKeys.some(function(k) { var matchAll = !nameKeys.some(function(k) {
return k in args; return k in args;
}); });
var querynames = (args.altnames || []).slice(0); 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) { .filter(function(subject) {
var site = doctor.site(config.sites, subject); var site = config.sites[subject];
if (site.deletedAt) { if (site.deletedAt) {
return false; return false;
} }
@ -309,172 +161,163 @@ Manage.create = function(CONF) {
}); });
}) })
.map(function(name) { .map(function(name) {
var site = config.sites[name]; return doctor.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 sites; return sites;
}); });
return manage._txPromise;
} }
manage.notify = CONF.notify || _notify; function _mergeFind(config, ours, theirs) {
function _notify(ev, args) { theirs.forEach(function(_newer) {
if (!args) { ours.some(function(_older) {
args = ev; if (_newer.subject !== _older.subject) {
ev = args.event; return false;
delete args.event; }
}
// TODO define message types // BE SURE TO SET THIS UNDEFINED AFTERWARDS
if (!manage._notify_notice) { _older._exists = true;
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(' ') + ')'
);
}
}
manage.update = function(args) { manage._merge(config, _older, _newer);
manage._txPromise = manage._txPromise.then(function() { _newer = config.sites[_older.subject];
return Manage._getLatest(manage, CONF).then(function(config) {
var site = config.sites[args.subject]; // handled the (only) match
//site.issuedAt = args.issuedAt; return true;
//site.expiresAt = args.expiresAt;
site.renewAt = args.renewAt;
return manage._save(config);
}); });
}); });
// 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; return manage._txPromise;
}; }
manage.remove = function(args) { manage.remove = function(args) {
if (!args.subject) { if (!args.subject) {
throw new Error('should have a subject for sites to remove'); throw new Error('should have a subject for sites to remove');
} }
manage._txPromise = manage._txPromise.then(function() { manage._txPromise = manage._txPromise.then(async function() {
return Manage._getLatest(manage, CONF).then(function(config) { var config = await Manage._getLatest(manage, CONF);
var site = config.sites[args.subject]; var site = config.sites[args.subject];
if (!site) { if (!site) {
return {}; return null;
} }
site.deletedAt = Date.now(); site.deletedAt = Date.now();
await manage._save(config);
return JSON.parse(JSON.stringify(site)); return JSON.parse(JSON.stringify(site));
});
}); });
return manage._txPromise; return manage._txPromise;
}; };
manage._lastStat = {
size: 0,
mtimeMs: 0
};
manage._config = {}; manage._config = {};
// (wrong type #1) specifically the wrong type (null)
manage._lastStat = { size: null, mtimeMs: null };
manage._save = function(config) { manage._save = async function(config) {
return mkdirp(path.dirname(CONF.configFile)).then(function() { await mkdirp(path.dirname(CONF.configFile));
return sfs // pretty-print the config file
.writeFileAsync( var data = JSON.stringify(config, null, 2);
CONF.configFile, await sfs.writeFileAsync(CONF.configFile, data, 'utf8');
// pretty-print the config file
JSON.stringify(config, null, 2), // this file may contain secrets, so keep it safe
'utf8' return chmodFile(CONF.configFile, parseInt('0600', 8))
) .catch(function() {
.then(function() { /*ignore for Windows */
// this file may contain secrets, so keep it safe })
return chmodFile(CONF.configFile, parseInt('0600', 8)) .then(async function() {
.catch(function() { var stat = await statFile(CONF.configFile);
/*ignore for Windows */ manage._lastStat.size = stat.size;
}) manage._lastStat.mtimeMs = stat.mtimeMs;
.then(function() { });
return statFile(CONF.configFile).then(function( };
stat
) { manage.init = async function(deps) {
manage._lastStat.size = stat.size; var request = deps.request;
manage._lastStat.mtimeMs = stat.mtimeMs; // how nice...
});
});
});
});
}; };
return manage; return manage;
}; };
Manage._getLatest = function(mng, CONF) { Manage._getLatest = function(MNG, CONF) {
return statFile(CONF.configFile) return statFile(CONF.configFile)
.catch(function(err) { .catch(async function(err) {
if ('ENOENT' === err.code) { if ('ENOENT' !== err.code) {
return { err.context = 'manager_read';
size: 0, throw err;
mtimeMs: 0
};
} }
err.context = 'manager_read'; await MNG._save(doctor.config());
throw err; // (wrong type #2) specifically the wrong type (bool)
return { size: false, mtimeMs: false };
}) })
.then(function(stat) { .then(async function(stat) {
if ( if (
stat.size === mng._lastStat.size && stat.size === MNG._lastStat.size &&
stat.mtimeMs === mng._lastStat.mtimeMs stat.mtimeMs === MNG._lastStat.mtimeMs
) { ) {
return mng._config; return MNG._config;
} }
return readFile(CONF.configFile, 'utf8').then(function(data) { var data = await readFile(CONF.configFile, 'utf8');
mng._lastStat = stat; MNG._lastStat = stat;
mng._config = JSON.parse(data); MNG._config = JSON.parse(data);
return mng._config; return doctor.config(MNG._config);
});
}); });
}; };
var doctor = {};
// users muck up config files, so we try to handle it gracefully. // 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) { doctor.site = function(sconfs, subject) {
var site = sconfs[subject]; var site = sconfs[subject];
if (!site) { if (!site) {
@ -482,8 +325,9 @@ doctor.site = function(sconfs, subject) {
site = {}; site = {};
} }
// TODO notify on any changes
if ('string' !== typeof site.subject) { if ('string' !== typeof site.subject) {
console.warn('warning: deleted malformed site from config file:');
console.warn(JSON.stringify(site));
delete sconfs[subject]; delete sconfs[subject];
site.subject = 'greenlock-error.example.com'; site.subject = 'greenlock-error.example.com';
} }
@ -496,3 +340,46 @@ doctor.site = function(sconfs, subject) {
return site; 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;
};

View File

@ -1,14 +1,18 @@
{ {
"name": "greenlock-manager-fs", "name": "greenlock-manager-fs",
"version": "0.7.0", "version": "3.0.0",
"description": "A simple file-based management strategy for Greenlock", "description": "A simple file-based management strategy for Greenlock",
"main": "manager.js", "main": "manager.js",
"scripts": { "scripts": {
"test": "node tests" "test": "node tests"
}, },
"files": [
"*.js",
"lib"
],
"repository": { "repository": {
"type": "git", "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": [ "keywords": [
"Greenlock", "Greenlock",
@ -20,6 +24,7 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"@root/mkdirp": "^1.0.0", "@root/mkdirp": "^1.0.0",
"safe-replace": "^1.1.0" "safe-replace": "^1.1.0",
"greenlock-manager-test": "^3.0.0"
} }
} }

52
test.js
View File

@ -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();

19
tests/index.js Normal file
View File

@ -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.");
});