'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; };