447 lines
11 KiB
JavaScript
447 lines
11 KiB
JavaScript
'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');
|
|
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'));
|
|
|
|
// 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
|
|
|
|
// IMPORTANT
|
|
// For your use case you'll probably find a better example
|
|
// in greenlock-manager-test:
|
|
//
|
|
// npm install --save greenlock-manager-test
|
|
// npx greenlock-manager-init
|
|
//
|
|
|
|
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();
|
|
|
|
// 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.defaults));
|
|
return conf;
|
|
}
|
|
|
|
// act as a setter
|
|
Object.keys(conf).forEach(function(k) {
|
|
// challenges are either both overwritten, or not set
|
|
// this is as it should be
|
|
config.defaults[k] = conf[k];
|
|
});
|
|
|
|
return manage._save(config);
|
|
});
|
|
|
|
return manage._txPromise;
|
|
};
|
|
|
|
manage.set = async function(args) {
|
|
manage._txPromise = manage._txPromise.then(async function() {
|
|
var config = await Manage._getLatest(manage, CONF);
|
|
|
|
manage._merge(config, config.sites[args.subject], args);
|
|
|
|
await manage._save(config);
|
|
return JSON.parse(JSON.stringify(config.sites[args.subject]));
|
|
});
|
|
|
|
return manage._txPromise;
|
|
};
|
|
|
|
manage.get = async function(args) {
|
|
manage._txPromise = manage._txPromise.then(async function() {
|
|
var config = await Manage._getLatest(manage, CONF);
|
|
var site;
|
|
Object.keys(config.sites).some(function(k) {
|
|
// if subject is specified, don't return anything else
|
|
var _site = config.sites[k];
|
|
|
|
// altnames, servername, and wildname all get rolled into one
|
|
return _site.altnames.some(function(altname) {
|
|
if ([args.servername, args.wildname].includes(altname)) {
|
|
site = _site;
|
|
}
|
|
});
|
|
});
|
|
|
|
if (site && !site.deletedAt) {
|
|
return doctor.site(config.sites, site.subject);
|
|
}
|
|
return null;
|
|
});
|
|
|
|
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.altnames = args.altnames.slice(0);
|
|
}
|
|
}
|
|
Object.keys(args).forEach(function(k) {
|
|
if ('altnames' === k) {
|
|
return;
|
|
}
|
|
current[k] = args[k];
|
|
});
|
|
};
|
|
|
|
// 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);
|
|
var config = await Manage._getLatest(manage, CONF);
|
|
return _mergeFind(config, ours, theirs);
|
|
};
|
|
|
|
function _find(args) {
|
|
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 renewBefore = args.renewBefore || Infinity; //Date.now() + 21 * 24 * 60 * 60 * 1000;
|
|
|
|
// 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);
|
|
|
|
var sites = Object.keys(config.sites)
|
|
.filter(function(subject) {
|
|
var site = config.sites[subject];
|
|
if (site.deletedAt) {
|
|
return false;
|
|
}
|
|
if (site.expiresAt >= expiresBefore) {
|
|
return false;
|
|
}
|
|
if (site.issuedAt >= issuedBefore) {
|
|
return false;
|
|
}
|
|
if (site.renewAt >= renewBefore) {
|
|
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) {
|
|
return doctor.site(config.sites, name);
|
|
});
|
|
|
|
return sites;
|
|
});
|
|
|
|
return manage._txPromise;
|
|
}
|
|
|
|
function _mergeFind(config, ours, theirs) {
|
|
theirs.forEach(function(_newer) {
|
|
var hasCurrent = ours.some(function(_older) {
|
|
if (_newer.subject !== _older.subject) {
|
|
return false;
|
|
}
|
|
|
|
// BE SURE TO SET THIS UNDEFINED AFTERWARDS
|
|
_older._exists = true;
|
|
|
|
manage._merge(config, _older, _newer);
|
|
_newer = config.sites[_older.subject];
|
|
|
|
// handled the (only) match
|
|
return true;
|
|
});
|
|
if (hasCurrent) {
|
|
manage._merge(config, null, _newer);
|
|
}
|
|
});
|
|
|
|
// 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
|
|
var copy = JSON.parse(JSON.stringify(config.sites));
|
|
return Object.keys(copy).map(function(k) {
|
|
return copy[k];
|
|
});
|
|
});
|
|
|
|
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(async function() {
|
|
var config = await Manage._getLatest(manage, CONF);
|
|
var site = config.sites[args.subject];
|
|
if (!site || site.deletedAt) {
|
|
return null;
|
|
}
|
|
site.deletedAt = Date.now();
|
|
await manage._save(config);
|
|
return JSON.parse(JSON.stringify(site));
|
|
});
|
|
return manage._txPromise;
|
|
};
|
|
|
|
manage._config = {};
|
|
// (wrong type #1) specifically the wrong type (null)
|
|
manage._lastStat = { size: null, mtimeMs: null };
|
|
|
|
manage._save = async function(config) {
|
|
await mkdirp(path.dirname(CONF.configFile));
|
|
// pretty-print the config file
|
|
var data = JSON.parse(JSON.stringify(config));
|
|
var sites = data.sites || {};
|
|
data.sites = Object.keys(sites).map(function(k) {
|
|
return sites[k];
|
|
});
|
|
await sfs.writeFileAsync(
|
|
CONF.configFile,
|
|
JSON.stringify(data, null, 2),
|
|
'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) {
|
|
// even though we don't need it
|
|
manage.request = deps.request;
|
|
return null;
|
|
};
|
|
|
|
return manage;
|
|
};
|
|
|
|
Manage._getLatest = function(MNG, CONF) {
|
|
return statFile(CONF.configFile)
|
|
.catch(async function(err) {
|
|
if ('ENOENT' !== err.code) {
|
|
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(async function(stat) {
|
|
if (
|
|
stat.size === MNG._lastStat.size &&
|
|
stat.mtimeMs === MNG._lastStat.mtimeMs
|
|
) {
|
|
return MNG._config;
|
|
}
|
|
var data = await readFile(CONF.configFile, 'utf8');
|
|
MNG._lastStat = stat;
|
|
MNG._config = JSON.parse(data);
|
|
return doctor.config(MNG._config);
|
|
});
|
|
};
|
|
|
|
// 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) {
|
|
// .greenlockrc and greenlock.json shall merge as one
|
|
// and be called greenlock.json because calling it
|
|
// .greenlockrc seems to rub people the wrong way
|
|
if (['manager', '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(config.sites)) {
|
|
sites = {};
|
|
config.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) {
|
|
delete sconfs[subject];
|
|
site = {};
|
|
}
|
|
|
|
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';
|
|
}
|
|
if (!Array.isArray(site.altnames)) {
|
|
site.altnames = [site.subject];
|
|
}
|
|
if (!site.renewAt) {
|
|
site.renewAt = 1;
|
|
}
|
|
|
|
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;
|
|
};
|