greenlock-manager-fs.js/manager.js

499 lines
12 KiB
JavaScript

'use strict';
var Manage = module.exports;
var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' });
var promisify = require('util').promisify;
var fs = require('fs');
var readFile = promisify(fs.readFile);
var statFile = promisify(fs.stat);
var chmodFile = promisify(fs.chmod);
var homedir = require('os').homedir();
var path = require('path');
var mkdirp = promisify(require('@root/mkdirp'));
Manage.create = function(CONF) {
if (!CONF) {
CONF = {};
}
if (!CONF.configFile) {
CONF.configFile = '~/.config/greenlock/manager.json';
console.info('Greenlock Manager Config File: ' + CONF.configFile);
}
CONF.configFile = CONF.configFile.replace('~/', homedir + '/');
var manage = {};
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) {
if (!conf) {
conf = JSON.parse(JSON.stringify(config));
delete conf.sites;
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'
);
}
})
) {
}
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];
});
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;
}
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);
}
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;
});
});
});
});
};
function _find(args) {
return Manage._getLatest(manage, CONF).then(function(config) {
// 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 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 || {})
.filter(function(subject) {
var site = doctor.site(config.sites, subject);
if (site.deletedAt) {
return false;
}
if (site.expiresAt >= expiresBefore) {
return false;
}
if (site.issuedAt >= issuedBefore) {
return false;
}
// after attribute filtering, before cert filtering
if (matchAll) {
return true;
}
// if subject is specified, don't return anything else
if (site.subject === args.subject) {
return true;
}
// altnames, servername, and wildname all get rolled into one
return site.altnames.some(function(altname) {
return querynames.includes(altname);
});
})
.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 sites;
});
}
manage.notify = CONF.notify || _notify;
function _notify(ev, args) {
if (!args) {
args = ev;
ev = args.event;
delete args.event;
}
// 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(' ') + ')'
);
}
}
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);
});
});
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));
});
});
return manage._txPromise;
};
manage._lastStat = {
size: 0,
mtimeMs: 0
};
manage._config = {};
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;
});
});
});
});
};
return manage;
};
Manage._getLatest = function(mng, CONF) {
return statFile(CONF.configFile)
.catch(function(err) {
if ('ENOENT' === err.code) {
return {
size: 0,
mtimeMs: 0
};
}
err.context = 'manager_read';
throw err;
})
.then(function(stat) {
if (
stat.size === mng._lastStat.size &&
stat.mtimeMs === mng._lastStat.mtimeMs
) {
return mng._config;
}
return readFile(CONF.configFile, 'utf8').then(function(data) {
mng._lastStat = stat;
mng._config = JSON.parse(data);
return mng._config;
});
});
};
var doctor = {};
// users muck up config files, so we try to handle it gracefully.
doctor.site = function(sconfs, subject) {
var site = sconfs[subject];
if (!site) {
delete sconfs[subject];
site = {};
}
// TODO notify on any changes
if ('string' !== typeof site.subject) {
delete sconfs[subject];
site.subject = 'greenlock-error.example.com';
}
if (!Array.isArray(site.altnames)) {
site.altnames = [site.subject];
}
if (!site.renewAt) {
site.renewAt = 1;
}
return site;
};